| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245 |
- /**
- * 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<string, string> = {
- // 统一 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<string> {
- 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=<value> 格式而非 --rate <value>,避免 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<string>((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<boolean> {
- 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);
- });
- });
- }
- }
|