voice
This commit is contained in:
parent
249bc2e8cb
commit
19dc953d6e
6 changed files with 175 additions and 7 deletions
|
|
@ -8,6 +8,7 @@ import { RoomsModule } from './rooms/rooms.module';
|
||||||
import { QuestionsModule } from './questions/questions.module';
|
import { QuestionsModule } from './questions/questions.module';
|
||||||
import { GameModule } from './game/game.module';
|
import { GameModule } from './game/game.module';
|
||||||
import { StatsModule } from './stats/stats.module';
|
import { StatsModule } from './stats/stats.module';
|
||||||
|
import { VoiceModule } from './voice/voice.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -18,6 +19,7 @@ import { StatsModule } from './stats/stats.module';
|
||||||
QuestionsModule,
|
QuestionsModule,
|
||||||
GameModule,
|
GameModule,
|
||||||
StatsModule,
|
StatsModule,
|
||||||
|
VoiceModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
|
|
||||||
70
backend/src/voice/voice.controller.ts
Normal file
70
backend/src/voice/voice.controller.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
11
backend/src/voice/voice.module.ts
Normal file
11
backend/src/voice/voice.module.ts
Normal 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 {}
|
||||||
|
|
||||||
74
backend/src/voice/voice.service.ts
Normal file
74
backend/src/voice/voice.service.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
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
|
* Hook for voice generation and playback
|
||||||
|
|
@ -56,11 +56,12 @@ export function useVoice() {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${VOICE_SERVICE_URL}/api/voice/tts`, {
|
const response = await fetch(`${API_URL}/voice/tts`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
|
credentials: 'include',
|
||||||
body: JSON.stringify({ text, voice }),
|
body: JSON.stringify({ text, voice }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -147,7 +148,7 @@ export function useVoice() {
|
||||||
if (!isEnabled) return;
|
if (!isEnabled) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const audio = new Audio(`${VOICE_SERVICE_URL}/api/voice/effects/${effectType}`);
|
const audio = new Audio(`${API_URL}/voice/effects/${effectType}`);
|
||||||
audio.volume = effectsVolume;
|
audio.volume = effectsVolume;
|
||||||
await audio.play();
|
await audio.play();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -29,25 +29,35 @@ body {
|
||||||
/* Новогодние снежинки */
|
/* Новогодние снежинки */
|
||||||
@keyframes snow {
|
@keyframes snow {
|
||||||
0% {
|
0% {
|
||||||
transform: translateY(0) rotate(0deg);
|
transform: translateY(-20px) rotate(0deg);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
2% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
98% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
transform: translateY(100vh) rotate(360deg);
|
transform: translateY(calc(100vh + 20px)) rotate(360deg);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.snowflake {
|
.snowflake {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: -10px;
|
top: 0;
|
||||||
color: white;
|
color: white;
|
||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
font-family: Arial;
|
font-family: Arial;
|
||||||
text-shadow: 0 0 5px rgba(255, 255, 255, 0.8);
|
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;
|
pointer-events: none;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
will-change: transform, opacity;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Page styles */
|
/* Page styles */
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue