From 19dc953d6e3c29e960e75d7d483357fcf4b17b0a Mon Sep 17 00:00:00 2001 From: Dmitry Date: Mon, 5 Jan 2026 03:25:02 +0300 Subject: [PATCH] voice --- backend/src/app.module.ts | 2 + backend/src/voice/voice.controller.ts | 70 +++++++++++++++++++++++++ backend/src/voice/voice.module.ts | 11 ++++ backend/src/voice/voice.service.ts | 74 +++++++++++++++++++++++++++ src/hooks/useVoice.js | 7 +-- src/index.css | 18 +++++-- 6 files changed, 175 insertions(+), 7 deletions(-) create mode 100644 backend/src/voice/voice.controller.ts create mode 100644 backend/src/voice/voice.module.ts create mode 100644 backend/src/voice/voice.service.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index c121b08..8b68448 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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], diff --git a/backend/src/voice/voice.controller.ts b/backend/src/voice/voice.controller.ts new file mode 100644 index 0000000..cc6cf9e --- /dev/null +++ b/backend/src/voice/voice.controller.ts @@ -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, + }); + } + } +} + diff --git a/backend/src/voice/voice.module.ts b/backend/src/voice/voice.module.ts new file mode 100644 index 0000000..a233ee8 --- /dev/null +++ b/backend/src/voice/voice.module.ts @@ -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 {} + diff --git a/backend/src/voice/voice.service.ts b/backend/src/voice/voice.service.ts new file mode 100644 index 0000000..254dae7 --- /dev/null +++ b/backend/src/voice/voice.service.ts @@ -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('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 { + 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 { + 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, + ); + } + } +} + diff --git a/src/hooks/useVoice.js b/src/hooks/useVoice.js index 6722086..80aabb4 100644 --- a/src/hooks/useVoice.js +++ b/src/hooks/useVoice.js @@ -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) { diff --git a/src/index.css b/src/index.css index c914888..f951967 100644 --- a/src/index.css +++ b/src/index.css @@ -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 */