import { Controller, Get, Post, Body, Param, Res, HttpException, HttpStatus, UseGuards, UploadedFile, UseInterceptors, Logger } from '@nestjs/common' import { FileInterceptor } from '@nestjs/platform-express' import { Response } from 'express' import * as fs from 'fs' import * as path from 'path' import { execSync } from 'child_process' import { TtsService } from './tts.service' import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard' import { Public } from '../../common/decorators/public.decorator' @Controller('tts') export class TtsController { private readonly logger = new Logger(TtsController.name) constructor(private ttsService: TtsService) {} @UseGuards(JwtAuthGuard) @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, amplitudeData: result.amplitudeData } } @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) } @UseGuards(JwtAuthGuard) @Post('asr') @UseInterceptors(FileInterceptor('audio', { dest: '/tmp/asr_uploads' })) async recognize(@UploadedFile() file: any) { if (!file) throw new HttpException('请上传音频文件', HttpStatus.BAD_REQUEST) const uploadDir = '/tmp/asr_uploads' if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true }) const ext = file.originalname ? path.extname(file.originalname) || '.aac' : '.aac' const dest = path.join(uploadDir, file.filename + ext) fs.renameSync(file.path, dest) // 拒绝损坏/空音频文件,避免 whisper 调用 ffmpeg 解码失败刷错误日志 const stat = fs.statSync(dest) if (!stat.size || stat.size < 500) { this.logger.warn(`ASR: empty audio upload (size=${stat.size}), ext=${ext}`) try { fs.unlinkSync(dest) } catch {} return { text: '' } } let wavPath = dest if (ext.toLowerCase() !== '.wav') { const potentialWav = dest.replace(/\.[^.]+$/, '.wav') try { execSync(`ffmpeg -y -i "${dest}" -ar 16000 -ac 1 -c:a pcm_s16le "${potentialWav}"`, { timeout: 30000, encoding: 'utf8', stdio: 'pipe', }) wavPath = potentialWav } catch (e: any) { this.logger.warn(`FFmpeg conversion failed: ${e.message}, using original format`) } } try { let text = '' if (process.env.OPENAI_API_KEY) { try { const result = execSync( `curl -s -X POST https://api.openai.com/v1/audio/transcriptions \ -H "Authorization: Bearer ${process.env.OPENAI_API_KEY}" \ -H "Content-Type: multipart/form-data" \ -F "file=@${wavPath}" \ -F "model=whisper-1" \ -F "language=zh"`, { encoding: 'utf8', timeout: 30000 }, ) const parsed = JSON.parse(result) if (parsed.text) text = parsed.text.trim() } catch (e: any) { this.logger.warn(`OpenAI ASR failed, falling back to local: ${e.message}`) } } if (!text) { const whisperResult = execSync( `python3 -c 'import sys, whisper; model = whisper.load_model("tiny"); print(model.transcribe(sys.argv[1], language="zh")["text"].strip())' "${wavPath}"`, { encoding: 'utf8', timeout: 60000 }, ) if (whisperResult?.trim()) text = whisperResult.trim() } return { text } } catch (e: any) { this.logger.error(`ASR failed: ${e?.message || e}`) return { text: '' } } finally { try { if (dest) fs.unlinkSync(dest) } catch {} try { if (wavPath && wavPath !== dest) fs.unlinkSync(wavPath) } catch {} } } }