This commit is contained in:
Dmitry 2026-01-05 03:25:02 +03:00
parent 249bc2e8cb
commit 19dc953d6e
6 changed files with 175 additions and 7 deletions

View file

@ -8,6 +8,7 @@ import { RoomsModule } from './rooms/rooms.module';
import { QuestionsModule } from './questions/questions.module';
import { GameModule } from './game/game.module';
import { StatsModule } from './stats/stats.module';
import { VoiceModule } from './voice/voice.module';
@Module({
imports: [
@ -18,6 +19,7 @@ import { StatsModule } from './stats/stats.module';
QuestionsModule,
GameModule,
StatsModule,
VoiceModule,
],
controllers: [AppController],
providers: [AppService],

View file

@ -0,0 +1,70 @@
import {
Controller,
Post,
Get,
Body,
Param,
Res,
HttpStatus,
HttpException,
} from '@nestjs/common';
import { Response } from 'express';
import { VoiceService } from './voice.service';
@Controller('voice')
export class VoiceController {
constructor(private voiceService: VoiceService) {}
@Post('tts')
async generateTTS(
@Body() body: { text: string; voice?: string },
@Res() res: Response,
) {
const { text, voice = 'sarah' } = body;
if (!text) {
return res.status(HttpStatus.BAD_REQUEST).json({
error: 'Text is required',
});
}
try {
const audioBuffer = await this.voiceService.generateTTS(text, voice);
res.setHeader('Content-Type', 'audio/mpeg');
res.setHeader('Content-Length', audioBuffer.length.toString());
res.send(audioBuffer);
} catch (error) {
const status = error instanceof HttpException
? error.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const message = error.message || 'Failed to generate speech';
return res.status(status).json({
error: message,
});
}
}
@Get('effects/:effectType')
async getEffect(
@Param('effectType') effectType: string,
@Res() res: Response,
) {
try {
const audioBuffer = await this.voiceService.getEffect(effectType);
res.setHeader('Content-Type', 'audio/mpeg');
res.setHeader('Content-Length', audioBuffer.length.toString());
res.send(audioBuffer);
} catch (error) {
const status = error instanceof HttpException
? error.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const message = error.message || 'Failed to get sound effect';
return res.status(status).json({
error: message,
});
}
}
}

View file

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { VoiceService } from './voice.service';
import { VoiceController } from './voice.controller';
@Module({
controllers: [VoiceController],
providers: [VoiceService],
exports: [VoiceService],
})
export class VoiceModule {}

View file

@ -0,0 +1,74 @@
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class VoiceService {
private readonly voiceServiceUrl: string;
constructor(private configService: ConfigService) {
this.voiceServiceUrl = this.configService.get<string>('VOICE_SERVICE_HOST');
if (!this.voiceServiceUrl) {
throw new Error('VOICE_SERVICE_HOST environment variable is not set');
}
}
async generateTTS(text: string, voice: string = 'sarah'): Promise<Buffer> {
try {
const url = `${this.voiceServiceUrl}/api/voice/tts`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ text, voice }),
});
if (!response.ok) {
throw new HttpException(
`Voice service error: ${response.statusText}`,
response.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
}
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
} catch (error) {
if (error instanceof HttpException) {
throw error;
}
throw new HttpException(
`Failed to generate speech: ${error.message}`,
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
async getEffect(effectType: string): Promise<Buffer> {
try {
const url = `${this.voiceServiceUrl}/api/voice/effects/${effectType}`;
const response = await fetch(url, {
method: 'GET',
});
if (!response.ok) {
throw new HttpException(
`Voice service error: ${response.statusText}`,
response.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
}
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
} catch (error) {
if (error instanceof HttpException) {
throw error;
}
throw new HttpException(
`Failed to get sound effect: ${error.message}`,
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}

View file

@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react';
const VOICE_SERVICE_URL = import.meta.env.VITE_VOICE_SERVICE_URL || 'http://localhost:3001';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
/**
* Hook for voice generation and playback
@ -56,11 +56,12 @@ export function useVoice() {
}
try {
const response = await fetch(`${VOICE_SERVICE_URL}/api/voice/tts`, {
const response = await fetch(`${API_URL}/voice/tts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ text, voice }),
});
@ -147,7 +148,7 @@ export function useVoice() {
if (!isEnabled) return;
try {
const audio = new Audio(`${VOICE_SERVICE_URL}/api/voice/effects/${effectType}`);
const audio = new Audio(`${API_URL}/voice/effects/${effectType}`);
audio.volume = effectsVolume;
await audio.play();
} catch (error) {

View file

@ -29,25 +29,35 @@ body {
/* Новогодние снежинки */
@keyframes snow {
0% {
transform: translateY(0) rotate(0deg);
transform: translateY(-20px) rotate(0deg);
opacity: 0;
}
2% {
opacity: 1;
}
98% {
opacity: 1;
}
100% {
transform: translateY(100vh) rotate(360deg);
transform: translateY(calc(100vh + 20px)) rotate(360deg);
opacity: 0;
}
}
.snowflake {
position: fixed;
top: -10px;
top: 0;
color: white;
font-size: 1em;
font-family: Arial;
text-shadow: 0 0 5px rgba(255, 255, 255, 0.8);
animation: snow 10s linear infinite;
animation-name: snow;
animation-timing-function: linear;
animation-iteration-count: 1;
animation-fill-mode: forwards;
pointer-events: none;
z-index: 1;
will-change: transform, opacity;
}
/* Page styles */