edge-tts.provider.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. /**
  2. * Microsoft Edge-TTS Provider(免费,基于 Edge 浏览器"大声朗读"引擎)
  3. *
  4. * 使用方式:通过 CLI 调用 edge-tts Python 包。
  5. * 安装依赖:pip install edge-tts
  6. *
  7. * 限制:
  8. * - 单次最大 ~3000 字符(微软限制,实际按 bytes 算,中文约 1000 字安全)
  9. * - 需 Python 3.8+ 环境
  10. * - 依赖微软服务器,稳定性不如商业 API
  11. *
  12. * 音色文档:edge-tts --list-voices
  13. * 常用中文音色:
  14. * zh-CN-XiaoxiaoNeural — 女声,温柔自然(推荐)
  15. * zh-CN-YunxiNeural — 男声,标准播音
  16. * zh-CN-YunyangNeural — 男声,新闻风格
  17. * zh-CN-XiaoyiNeural — 女声,活泼
  18. * zh-CN-YunjianNeural — 男声,老成
  19. */
  20. import { spawn } from 'child_process';
  21. import fs from 'fs';
  22. import path from 'path';
  23. import { VoiceParams } from '../../types';
  24. import { ITtsProvider } from './provider.interface';
  25. // Edge-TTS 中文音色映射表(10个统一音色 + 附加音色)
  26. const EDGE_VOICE_MAP: Record<string, string> = {
  27. // 统一 Voice ID → Edge-TTS 音色名(与 tts.service.ts 保持一致)
  28. voice_01: 'zh-CN-XiaoxiaoNeural', // 温柔女声 → 晓晓
  29. voice_02: 'zh-CN-YunxiNeural', // 磁性男声 → 云希
  30. voice_03: 'zh-CN-XiaoyiNeural', // 活泼女声 → 晓依
  31. voice_04: 'zh-CN-YunyangNeural', // 知性女声 → 云扬(新闻风格)
  32. voice_05: 'zh-CN-YunjianNeural', // 阳光男声 → 云健
  33. voice_06: 'zh-CN-YunyangNeural', // 沧桑男声 → 云扬
  34. voice_07: 'zh-CN-XiaoxiaoNeural', // 甜美女声 → 晓晓
  35. voice_08: 'zh-CN-YunxiNeural', // 清朗男声 → 云希
  36. voice_09: 'zh-CN-XiaoyiNeural', // 亲切女声 → 晓依
  37. voice_10: 'zh-CN-XiaoshuangNeural', // 稚嫩童声 → 晓双(童声)
  38. // 友好别名(也可以直接传 Edge 原生音色名)
  39. xiaoxiao: 'zh-CN-XiaoxiaoNeural',
  40. yunxi: 'zh-CN-YunxiNeural',
  41. xiaoyi: 'zh-CN-XiaoyiNeural',
  42. yunyang: 'zh-CN-YunyangNeural',
  43. };
  44. /** 将内部 Voice ID 映射到 Edge-TTS 音色名 */
  45. function resolveVoice(voiceId: string): string {
  46. // 先查映射表
  47. if (EDGE_VOICE_MAP[voiceId]) return EDGE_VOICE_MAP[voiceId];
  48. // 如果已经是 Edge 原生格式(包含 Neural),直接使用
  49. if (voiceId.includes('Neural')) return voiceId;
  50. // 兜底:默认女声
  51. return 'zh-CN-XiaoxiaoNeural';
  52. }
  53. export class EdgeTtsProvider implements ITtsProvider {
  54. readonly name: string;
  55. readonly vendor = 'edge';
  56. readonly mode = 'sync' as const;
  57. readonly maxTextLength: number = 1000; // Edge-TTS 单次中文安全长度
  58. readonly concurrency = 2;
  59. readonly modelId: string;
  60. readonly ttsApiPath = ''; // 不需要 API 路径
  61. readonly defaultVoice: string;
  62. constructor(
  63. vendorKey: string = 'edge',
  64. _apiKey?: string, // Edge-TTS 无需 API Key
  65. modelId?: string,
  66. _ttsApiPath?: string,
  67. maxTextLength?: number,
  68. ) {
  69. this.name = `${vendorKey}-tts`;
  70. this.modelId = modelId || 'edge-tts';
  71. this.defaultVoice = 'zh-CN-XiaoxiaoNeural';
  72. if (maxTextLength) this.maxTextLength = maxTextLength;
  73. }
  74. async synthesize(
  75. text: string,
  76. voiceId: string,
  77. params: VoiceParams,
  78. outputPath: string,
  79. ): Promise<string> {
  80. const voice = resolveVoice(voiceId || this.defaultVoice);
  81. const dir = path.dirname(outputPath);
  82. if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
  83. // 确保输出路径为 .mp3
  84. const finalPath = outputPath.endsWith('.mp3') ? outputPath : outputPath.replace(/\.[^.]+$/, '.mp3');
  85. const args = [
  86. '--voice', voice,
  87. '--text', text,
  88. '--write-media', finalPath,
  89. ];
  90. // 语速映射:speed 0.5~2.0 → rate -50%~+100%
  91. // 注意:使用 --rate=<value> 格式而非 --rate <value>,避免 shell 解析负值参数
  92. if (params.speed !== undefined && params.speed !== 1) {
  93. const ratePercent = Math.round((params.speed - 1) * 100);
  94. const sign = ratePercent > 0 ? '+' : '';
  95. args.push(`--rate=${sign}${ratePercent}%`);
  96. }
  97. // 音调映射:pitch -500~500 → pitch -50Hz~+50Hz
  98. if (params.pitch !== undefined && params.pitch !== 0) {
  99. const pitchHz = Math.round(params.pitch / 10);
  100. const sign = pitchHz > 0 ? '+' : '';
  101. args.push(`--pitch=${sign}${pitchHz}Hz`);
  102. }
  103. // 音量映射:volume 0~100 → -100%~+100%(默认50不调整)
  104. if (params.volume !== undefined && params.volume !== 50) {
  105. const volPercent = Math.round((params.volume / 50 - 1) * 100);
  106. if (volPercent !== 0) {
  107. const sign = volPercent > 0 ? '+' : '';
  108. args.push(`--volume=${sign}${volPercent}%`);
  109. }
  110. }
  111. console.log(`📢 [EdgeTTS] 开始合成: voice=${voice}, text=${text.length}字, output=${path.basename(finalPath)}`);
  112. const startTime = Date.now();
  113. return new Promise<string>((resolve, reject) => {
  114. // 优先用 edge-tts 命令(避免 python -m 解析长文本的 argparse 问题)
  115. // shell: false 避免 shell 解析带负号的参数(如 -20%)
  116. const edgeCmd = spawn('edge-tts', args, {
  117. shell: false,
  118. stdio: ['pipe', 'pipe', 'pipe'],
  119. });
  120. let stderr = '';
  121. let exited = false;
  122. edgeCmd.stderr.on('data', (chunk: Buffer) => {
  123. stderr += chunk.toString();
  124. });
  125. edgeCmd.on('error', (err) => {
  126. if (exited) return;
  127. exited = true;
  128. // 尝试 fallback:直接用 edge-tts 命令
  129. console.log(`⚠️ [EdgeTTS] python -m edge_tts 失败,尝试 edge-tts 命令: ${err.message}`);
  130. this.runEdgeTtsDirect(args, finalPath, startTime, resolve, reject);
  131. });
  132. edgeCmd.on('close', (code) => {
  133. if (exited) return;
  134. exited = true;
  135. if (code === 0 && fs.existsSync(finalPath)) {
  136. const stats = fs.statSync(finalPath);
  137. const elapsed = Date.now() - startTime;
  138. console.log(`✅ [EdgeTTS] 完成: ${finalPath} (${stats.size} bytes, ${elapsed}ms)`);
  139. resolve(finalPath);
  140. } else {
  141. // 如果 python -m 失败,尝试 edge-tts 命令
  142. if (stderr.toLowerCase().includes('no module') || code !== 0) {
  143. console.log(`⚠️ [EdgeTTS] python -m 返回 code=${code},尝试 edge-tts 命令`);
  144. this.runEdgeTtsDirect(args, finalPath, startTime, resolve, reject);
  145. } else {
  146. reject(new Error(`EdgeTTS 合成失败 (code=${code}): ${stderr.substring(0, 200)}`));
  147. }
  148. }
  149. });
  150. });
  151. }
  152. /** Fallback:直接用 edge-tts 命令 */
  153. private runEdgeTtsDirect(
  154. args: string[],
  155. finalPath: string,
  156. startTime: number,
  157. resolve: (value: string) => void,
  158. reject: (error: Error) => void,
  159. ) {
  160. const child = spawn('edge-tts', args, {
  161. shell: false,
  162. stdio: ['pipe', 'pipe', 'pipe'],
  163. });
  164. let stderr = '';
  165. child.stderr.on('data', (chunk: Buffer) => {
  166. stderr += chunk.toString();
  167. });
  168. child.on('error', (err) => {
  169. reject(new Error(`EdgeTTS 命令不可用: ${err.message}\n请安装:pip install edge-tts`));
  170. });
  171. child.on('close', (code) => {
  172. if (code === 0 && fs.existsSync(finalPath)) {
  173. const stats = fs.statSync(finalPath);
  174. const elapsed = Date.now() - startTime;
  175. console.log(`✅ [EdgeTTS] 完成: ${finalPath} (${stats.size} bytes, ${elapsed}ms)`);
  176. resolve(finalPath);
  177. } else {
  178. const errMsg = `EdgeTTS 合成失败 (code=${code}): ${stderr.substring(0, 300)}`;
  179. console.error(`❌ [EdgeTTS] ${errMsg}`);
  180. reject(new Error(errMsg));
  181. }
  182. });
  183. }
  184. /** 健康检查:edge-tts 命令是否可用 */
  185. async healthCheck(): Promise<boolean> {
  186. return new Promise((resolve) => {
  187. const child = spawn('edge-tts', ['--list-voices'], {
  188. shell: false,
  189. stdio: 'pipe',
  190. });
  191. const timer = setTimeout(() => {
  192. child.kill();
  193. resolve(false);
  194. }, 10000);
  195. child.on('error', () => {
  196. clearTimeout(timer);
  197. // 尝试 python -m
  198. const pythonChild = spawn('python', ['-m', 'edge_tts', '--list-voices'], {
  199. stdio: 'pipe',
  200. });
  201. pythonChild.on('close', (code) => {
  202. clearTimeout(timer);
  203. resolve(code === 0);
  204. });
  205. pythonChild.on('error', () => {
  206. clearTimeout(timer);
  207. resolve(false);
  208. });
  209. });
  210. child.on('close', (code) => {
  211. clearTimeout(timer);
  212. resolve(code === 0);
  213. });
  214. });
  215. }
  216. }