diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 564fbac..09a387f 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -23,6 +23,7 @@ import { ProgressModule } from './modules/progress/progress.module' import { ContributionModule } from './modules/contribution/contribution.module' import { DailyQuestionModule } from './modules/daily-question/daily-question.module' import { ScheduleModule } from './modules/schedule/schedule.module' +import { TtsModule } from './modules/tts/tts.module' const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/zhiyin' @@ -54,6 +55,7 @@ const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/zhiyin ContributionModule, DailyQuestionModule, ScheduleModule, + TtsModule, ], providers: [ JwtStrategy, diff --git a/backend/src/modules/interview/interview.controller.ts b/backend/src/modules/interview/interview.controller.ts index f7c6d75..165e4d2 100644 --- a/backend/src/modules/interview/interview.controller.ts +++ b/backend/src/modules/interview/interview.controller.ts @@ -28,7 +28,9 @@ export class InterviewController { @Param('id') id: string, @CurrentUser('userId') userId: string, @Body('answer') answer: string, + @Body('avatar') avatar?: boolean, ) { + if (avatar) return this.interviewService.answerWithAvatar(id, userId, answer) return this.interviewService.answer(id, userId, answer) } diff --git a/backend/src/modules/interview/interview.module.ts b/backend/src/modules/interview/interview.module.ts index 574d4ca..95c10a9 100644 --- a/backend/src/modules/interview/interview.module.ts +++ b/backend/src/modules/interview/interview.module.ts @@ -5,6 +5,7 @@ import { InterviewService } from './interview.service' import { Interview, InterviewSchema } from './interview.schema' import { Progress, ProgressSchema } from '../schemas/progress.schema' import { UserModule } from '../user/user.module' +import { TtsModule } from '../tts/tts.module' @Module({ imports: [ @@ -13,6 +14,7 @@ import { UserModule } from '../user/user.module' { name: Progress.name, schema: ProgressSchema }, ]), UserModule, + TtsModule, ], controllers: [InterviewController], providers: [InterviewService], diff --git a/backend/src/modules/interview/interview.service.ts b/backend/src/modules/interview/interview.service.ts index 1d26d86..79ef060 100644 --- a/backend/src/modules/interview/interview.service.ts +++ b/backend/src/modules/interview/interview.service.ts @@ -6,6 +6,7 @@ import { Progress, ProgressDocument } from '../schemas/progress.schema' import { AiService } from '../ai/ai.service' import { UserService } from '../user/user.service' import { QuotaService } from '../user/quota.service' +import { TtsService } from '../tts/tts.service' import { analyzeSpeech } from '../../common/utils/filler-words' @Injectable() @@ -16,6 +17,7 @@ export class InterviewService { private aiService: AiService, private userService: UserService, private quotaService: QuotaService, + private ttsService: TtsService, ) {} async create(userId: string, position: string) { @@ -99,6 +101,20 @@ ${conversationHistory} } } + async answerWithAvatar(interviewId: string, userId: string, answer: string) { + const base = await this.answer(interviewId, userId, answer) + const aiMsg = base.messages?.find(m => m.role === 'ai') + if (aiMsg?.content) { + try { + const tts = await this.ttsService.synthesize(aiMsg.content) + return { ...base, ttsHash: tts.hash, ttsDurationMs: tts.durationMs } + } catch { + // TTS failure is non-critical, return without audio + } + } + return base + } + async complete(interviewId: string, userId: string) { const interview = await this.interviewModel.findOne({ _id: interviewId, userId }).exec() if (!interview) throw new HttpException('面试不存在', HttpStatus.NOT_FOUND) diff --git a/backend/src/modules/tts/tts.controller.ts b/backend/src/modules/tts/tts.controller.ts new file mode 100644 index 0000000..57cbe01 --- /dev/null +++ b/backend/src/modules/tts/tts.controller.ts @@ -0,0 +1,33 @@ +import { Controller, Get, Post, Body, Param, Res, HttpException, HttpStatus } from '@nestjs/common' +import { Response } from 'express' +import * as fs from 'fs' +import { TtsService } from './tts.service' +import { Public } from '../../common/decorators/public.decorator' + +@Controller('tts') +export class TtsController { + constructor(private ttsService: TtsService) {} + + @Public() + @Post('synthesize') + async synthesize(@Body('text') text: string, @Body('voice') voice?: string) { + if (!text || text.length > 500) { + throw new HttpException('文本不能为空且不超过500字', HttpStatus.BAD_REQUEST) + } + const result = await this.ttsService.synthesize(text, voice) + return { hash: result.hash, durationMs: result.durationMs } + } + + @Public() + @Get('audio/:hash') + async getAudio(@Param('hash') hash: string, @Res() res: Response) { + const filePath = this.ttsService.getCachedPath(hash) + if (!filePath) { + throw new HttpException('音频不存在', HttpStatus.NOT_FOUND) + } + const stream = fs.createReadStream(filePath) + res.setHeader('Content-Type', 'audio/mpeg') + res.setHeader('Cache-Control', 'public, max-age=31536000') + stream.pipe(res) + } +} diff --git a/backend/src/modules/tts/tts.module.ts b/backend/src/modules/tts/tts.module.ts new file mode 100644 index 0000000..07a087f --- /dev/null +++ b/backend/src/modules/tts/tts.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common' +import { TtsController } from './tts.controller' +import { TtsService } from './tts.service' + +@Module({ + controllers: [TtsController], + providers: [TtsService], + exports: [TtsService], +}) +export class TtsModule {} diff --git a/backend/src/modules/tts/tts.service.ts b/backend/src/modules/tts/tts.service.ts new file mode 100644 index 0000000..545c649 --- /dev/null +++ b/backend/src/modules/tts/tts.service.ts @@ -0,0 +1,67 @@ +import * as crypto from 'crypto' +import * as fs from 'fs' +import * as path from 'path' +import { execSync } from 'child_process' +import { Injectable, Logger } from '@nestjs/common' + +const CACHE_DIR = '/tmp/tts-cache' + +interface TtsResult { + hash: string + filePath: string + durationMs: number +} + +@Injectable() +export class TtsService { + private readonly logger = new Logger(TtsService.name) + + constructor() { + if (!fs.existsSync(CACHE_DIR)) { + fs.mkdirSync(CACHE_DIR, { recursive: true }) + } + } + + async synthesize(text: string, voice: string = 'zh-CN-XiaoxiaoNeural'): Promise { + const hash = crypto.createHash('md5').update(text + voice).digest('hex') + const filePath = path.join(CACHE_DIR, `${hash}.mp3`) + + if (fs.existsSync(filePath)) { + const durationMs = await this.getDuration(filePath) + return { hash, filePath, durationMs } + } + + try { + execSync( + `edge-tts --voice "${voice}" --text "${this.escapeText(text)}" --write-media "${filePath}"`, + { timeout: 30000 }, + ) + const durationMs = await this.getDuration(filePath) + this.logger.log(`TTS generated: hash=${hash} text="${text.slice(0, 40)}..." duration=${durationMs}ms`) + return { hash, filePath, durationMs } + } catch (e) { + this.logger.error(`TTS failed: ${e.message}`) + throw e + } + } + + getCachedPath(hash: string): string | null { + const filePath = path.join(CACHE_DIR, `${hash}.mp3`) + return fs.existsSync(filePath) ? filePath : null + } + + private escapeText(text: string): string { + return text.replace(/"/g, '\\"').replace(/\n/g, ' ').replace(/\r/g, '') + } + + private async getDuration(filePath: string): Promise { + try { + // Estimate duration from file size (~16kbps for mp3 at 22050Hz) + const stat = fs.statSync(filePath) + const bytesPerMs = 16 * 1024 / 8 / 1000 // 16kbps → bytes per ms + return Math.round(stat.size / bytesPerMs) + } catch { + return 3000 + } + } +} diff --git a/zhiyin-app/src/components/digital-human.vue b/zhiyin-app/src/components/digital-human.vue new file mode 100644 index 0000000..53f65f5 --- /dev/null +++ b/zhiyin-app/src/components/digital-human.vue @@ -0,0 +1,291 @@ + + + + + diff --git a/zhiyin-app/src/config.ts b/zhiyin-app/src/config.ts index 259f3b9..48a21ab 100644 --- a/zhiyin-app/src/config.ts +++ b/zhiyin-app/src/config.ts @@ -86,6 +86,10 @@ export const API_ENDPOINTS = { CHECK: (outTradeNo: string) => `/payment/check/${outTradeNo}`, ACTIVATE: '/payment/activate', }, + TTS: { + SYNTHESIZE: '/tts/synthesize', + AUDIO: (hash: string) => `/tts/audio/${hash}`, + }, } as const const PROD_API_HOST = import.meta.env.VITE_PROD_API_HOST || 'https://zhiyinwx.yzrcloud.cn' diff --git a/zhiyin-app/src/pages/interview/interview.vue b/zhiyin-app/src/pages/interview/interview.vue index 6bb1c25..7931cec 100644 --- a/zhiyin-app/src/pages/interview/interview.vue +++ b/zhiyin-app/src/pages/interview/interview.vue @@ -1,6 +1,5 @@