/** * Microsoft Edge-TTS Provider(免费,基于 Edge 浏览器"大声朗读"引擎) * * 使用方式:通过 CLI 调用 edge-tts Python 包。 * 安装依赖:pip install edge-tts * * 限制: * - 单次最大 ~3000 字符(微软限制,实际按 bytes 算,中文约 1000 字安全) * - 需 Python 3.8+ 环境 * - 依赖微软服务器,稳定性不如商业 API * * 音色文档:edge-tts --list-voices * 常用中文音色: * zh-CN-XiaoxiaoNeural — 女声,温柔自然(推荐) * zh-CN-YunxiNeural — 男声,标准播音 * zh-CN-YunyangNeural — 男声,新闻风格 * zh-CN-XiaoyiNeural — 女声,活泼 * zh-CN-YunjianNeural — 男声,老成 */ import { spawn } from 'child_process'; import fs from 'fs'; import path from 'path'; import { VoiceParams } from '../../types'; import { ITtsProvider } from './provider.interface'; // Edge-TTS 中文音色映射表(10个统一音色 + 附加音色) const EDGE_VOICE_MAP: Record = { // 统一 Voice ID → Edge-TTS 音色名(与 tts.service.ts 保持一致) voice_01: 'zh-CN-XiaoxiaoNeural', // 温柔女声 → 晓晓 voice_02: 'zh-CN-YunxiNeural', // 磁性男声 → 云希 voice_03: 'zh-CN-XiaoyiNeural', // 活泼女声 → 晓依 voice_04: 'zh-CN-YunyangNeural', // 知性女声 → 云扬(新闻风格) voice_05: 'zh-CN-YunjianNeural', // 阳光男声 → 云健 voice_06: 'zh-CN-YunyangNeural', // 沧桑男声 → 云扬 voice_07: 'zh-CN-XiaoxiaoNeural', // 甜美女声 → 晓晓 voice_08: 'zh-CN-YunxiNeural', // 清朗男声 → 云希 voice_09: 'zh-CN-XiaoyiNeural', // 亲切女声 → 晓依 voice_10: 'zh-CN-XiaoshuangNeural', // 稚嫩童声 → 晓双(童声) // 友好别名(也可以直接传 Edge 原生音色名) xiaoxiao: 'zh-CN-XiaoxiaoNeural', yunxi: 'zh-CN-YunxiNeural', xiaoyi: 'zh-CN-XiaoyiNeural', yunyang: 'zh-CN-YunyangNeural', }; /** 将内部 Voice ID 映射到 Edge-TTS 音色名 */ function resolveVoice(voiceId: string): string { // 先查映射表 if (EDGE_VOICE_MAP[voiceId]) return EDGE_VOICE_MAP[voiceId]; // 如果已经是 Edge 原生格式(包含 Neural),直接使用 if (voiceId.includes('Neural')) return voiceId; // 兜底:默认女声 return 'zh-CN-XiaoxiaoNeural'; } export class EdgeTtsProvider implements ITtsProvider { readonly name: string; readonly vendor = 'edge'; readonly mode = 'sync' as const; readonly maxTextLength: number = 1000; // Edge-TTS 单次中文安全长度 readonly concurrency = 2; readonly modelId: string; readonly ttsApiPath = ''; // 不需要 API 路径 readonly defaultVoice: string; constructor( vendorKey: string = 'edge', _apiKey?: string, // Edge-TTS 无需 API Key modelId?: string, _ttsApiPath?: string, maxTextLength?: number, ) { this.name = `${vendorKey}-tts`; this.modelId = modelId || 'edge-tts'; this.defaultVoice = 'zh-CN-XiaoxiaoNeural'; if (maxTextLength) this.maxTextLength = maxTextLength; } async synthesize( text: string, voiceId: string, params: VoiceParams, outputPath: string, ): Promise { const voice = resolveVoice(voiceId || this.defaultVoice); const dir = path.dirname(outputPath); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); // 确保输出路径为 .mp3 const finalPath = outputPath.endsWith('.mp3') ? outputPath : outputPath.replace(/\.[^.]+$/, '.mp3'); const args = [ '--voice', voice, '--text', text, '--write-media', finalPath, ]; // 语速映射:speed 0.5~2.0 → rate -50%~+100% // 注意:使用 --rate= 格式而非 --rate ,避免 shell 解析负值参数 if (params.speed !== undefined && params.speed !== 1) { const ratePercent = Math.round((params.speed - 1) * 100); const sign = ratePercent > 0 ? '+' : ''; args.push(`--rate=${sign}${ratePercent}%`); } // 音调映射:pitch -500~500 → pitch -50Hz~+50Hz if (params.pitch !== undefined && params.pitch !== 0) { const pitchHz = Math.round(params.pitch / 10); const sign = pitchHz > 0 ? '+' : ''; args.push(`--pitch=${sign}${pitchHz}Hz`); } // 音量映射:volume 0~100 → -100%~+100%(默认50不调整) if (params.volume !== undefined && params.volume !== 50) { const volPercent = Math.round((params.volume / 50 - 1) * 100); if (volPercent !== 0) { const sign = volPercent > 0 ? '+' : ''; args.push(`--volume=${sign}${volPercent}%`); } } console.log(`📢 [EdgeTTS] 开始合成: voice=${voice}, text=${text.length}字, output=${path.basename(finalPath)}`); const startTime = Date.now(); return new Promise((resolve, reject) => { // 优先用 edge-tts 命令(避免 python -m 解析长文本的 argparse 问题) // shell: false 避免 shell 解析带负号的参数(如 -20%) const edgeCmd = spawn('edge-tts', args, { shell: false, stdio: ['pipe', 'pipe', 'pipe'], }); let stderr = ''; let exited = false; edgeCmd.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString(); }); edgeCmd.on('error', (err) => { if (exited) return; exited = true; // 尝试 fallback:直接用 edge-tts 命令 console.log(`⚠️ [EdgeTTS] python -m edge_tts 失败,尝试 edge-tts 命令: ${err.message}`); this.runEdgeTtsDirect(args, finalPath, startTime, resolve, reject); }); edgeCmd.on('close', (code) => { if (exited) return; exited = true; if (code === 0 && fs.existsSync(finalPath)) { const stats = fs.statSync(finalPath); const elapsed = Date.now() - startTime; console.log(`✅ [EdgeTTS] 完成: ${finalPath} (${stats.size} bytes, ${elapsed}ms)`); resolve(finalPath); } else { // 如果 python -m 失败,尝试 edge-tts 命令 if (stderr.toLowerCase().includes('no module') || code !== 0) { console.log(`⚠️ [EdgeTTS] python -m 返回 code=${code},尝试 edge-tts 命令`); this.runEdgeTtsDirect(args, finalPath, startTime, resolve, reject); } else { reject(new Error(`EdgeTTS 合成失败 (code=${code}): ${stderr.substring(0, 200)}`)); } } }); }); } /** Fallback:直接用 edge-tts 命令 */ private runEdgeTtsDirect( args: string[], finalPath: string, startTime: number, resolve: (value: string) => void, reject: (error: Error) => void, ) { const child = spawn('edge-tts', args, { shell: false, stdio: ['pipe', 'pipe', 'pipe'], }); let stderr = ''; child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString(); }); child.on('error', (err) => { reject(new Error(`EdgeTTS 命令不可用: ${err.message}\n请安装:pip install edge-tts`)); }); child.on('close', (code) => { if (code === 0 && fs.existsSync(finalPath)) { const stats = fs.statSync(finalPath); const elapsed = Date.now() - startTime; console.log(`✅ [EdgeTTS] 完成: ${finalPath} (${stats.size} bytes, ${elapsed}ms)`); resolve(finalPath); } else { const errMsg = `EdgeTTS 合成失败 (code=${code}): ${stderr.substring(0, 300)}`; console.error(`❌ [EdgeTTS] ${errMsg}`); reject(new Error(errMsg)); } }); } /** 健康检查:edge-tts 命令是否可用 */ async healthCheck(): Promise { return new Promise((resolve) => { const child = spawn('edge-tts', ['--list-voices'], { shell: false, stdio: 'pipe', }); const timer = setTimeout(() => { child.kill(); resolve(false); }, 10000); child.on('error', () => { clearTimeout(timer); // 尝试 python -m const pythonChild = spawn('python', ['-m', 'edge_tts', '--list-voices'], { stdio: 'pipe', }); pythonChild.on('close', (code) => { clearTimeout(timer); resolve(code === 0); }); pythonChild.on('error', () => { clearTimeout(timer); resolve(false); }); }); child.on('close', (code) => { clearTimeout(timer); resolve(code === 0); }); }); } }