tts.service.ts 42 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058
  1. import path from 'path';
  2. import fs from 'fs';
  3. import { v4 as uuidv4 } from 'uuid';
  4. import { config } from '../../config';
  5. import { prisma } from '../../models';
  6. import { VoiceParams, Voice } from '../../types';
  7. import { getMiniMaxVoice } from './minimax.provider';
  8. import { AudioMerger } from './audio-merger';
  9. import { aiSummaryService } from './ai-summary.service';
  10. import { storageService } from '../../services/storage.service';
  11. import { getTtsRegistry, getAvailableTtsProvider, startTtsHealthCheck } from './provider.registry';
  12. import { ITtsProvider } from './provider.interface';
  13. import { CircuitBreakerOpenError } from '../../common/circuit-breaker';
  14. import { ProviderNode } from '../../common/provider-registry';
  15. import axios from 'axios';
  16. // 日志文件路径
  17. const LOG_FILE = path.join(process.cwd(), 'tts-debug.log');
  18. function logToFile(msg: string) {
  19. const timestamp = new Date().toISOString();
  20. fs.appendFileSync(LOG_FILE, `[${timestamp}] ${msg}\n`);
  21. }
  22. // 可用音色列表(CosyVoice v3-flash - 67个音色)
  23. // 数据来源:阿里云百炼官方文档 2026-05
  24. export const VOICES: Voice[] = [
  25. // 标杆音色
  26. { id: 'longanyang', name: '龙安洋', gender: 'male', description: '阳光大男孩 20~30岁' },
  27. { id: 'longanhuan', name: '龙安欢', gender: 'female', description: '欢脱元气女 20~30岁' },
  28. { id: 'longhuhu_v3', name: '龙呼呼', gender: 'female', description: '天真烂漫女童 6~10岁' },
  29. // 有声书 - 男声
  30. { id: 'longsanshu_v3', name: '龙三叔', gender: 'male', description: '沉稳质感男 25~45岁' },
  31. { id: 'longxiu_v3', name: '龙修', gender: 'male', description: '博才说书男 25~35岁' },
  32. { id: 'longnan_v3', name: '龙楠', gender: 'male', description: '睿智青年男 25~30岁' },
  33. { id: 'longyichen_v3', name: '龙逸尘', gender: 'male', description: '洒脱活力男 20~30岁' },
  34. { id: 'longlaobo_v3', name: '龙老伯', gender: 'male', description: '沧桑岁月爷 60岁以上' },
  35. // 有声书 - 女声
  36. { id: 'longmiao_v3', name: '龙妙', gender: 'female', description: '抑扬顿挫女 25~30岁' },
  37. { id: 'longyuan_v3', name: '龙媛', gender: 'female', description: '温暖治愈女 35~40岁' },
  38. { id: 'longyue_v3', name: '龙悦', gender: 'female', description: '温暖磁性女 30~35岁' },
  39. { id: 'longwanjun_v3', name: '龙婉君', gender: 'female', description: '细腻柔声女 20~30岁' },
  40. { id: 'longlaoyi_v3', name: '龙老姨', gender: 'female', description: '烟火从容阿姨 60岁以上' },
  41. // 客服/电话
  42. { id: 'longyingxun_v3', name: '龙应询', gender: 'male', description: '年轻青涩男 20~25岁' },
  43. { id: 'longyingjing_v3', name: '龙应静', gender: 'female', description: '低调冷静女 25~35岁' },
  44. { id: 'longyingling_v3', name: '龙应聆', gender: 'female', description: '温和共情女 25~30岁' },
  45. { id: 'longyingtao_v3', name: '龙应桃', gender: 'female', description: '温柔淡定女 25~30岁' },
  46. { id: 'longyingmu_v3', name: '龙应沐', gender: 'female', description: '优雅知性女 25~30岁' },
  47. { id: 'longyingxiao_v3', name: '龙应笑', gender: 'female', description: '清甜推销女 20~25岁' },
  48. // 语音助手
  49. { id: 'longxiaochun_v3', name: '龙小淳', gender: 'female', description: '知性积极女 25~30岁' },
  50. { id: 'longxiaoxia_v3', name: '龙小夏', gender: 'female', description: '沉稳权威女 25~30岁' },
  51. { id: 'longanyun_v3', name: '龙安昀', gender: 'male', description: '居家暖男 30~35岁' },
  52. { id: 'longanwen_v3', name: '龙安温', gender: 'female', description: '优雅知性女 25~35岁' },
  53. { id: 'longanli_v3', name: '龙安莉', gender: 'female', description: '利落从容女 25~35岁' },
  54. { id: 'longanlang_v3', name: '龙安朗', gender: 'male', description: '清爽利落男 20~25岁' },
  55. { id: 'longyumi_v3', name: 'YUMI', gender: 'female', description: '正经青年女 20~25岁' },
  56. // 社交陪伴
  57. { id: 'longanqin_v3', name: '龙安亲', gender: 'female', description: '亲和活泼女 20~25岁' },
  58. { id: 'longanya_v3', name: '龙安雅', gender: 'female', description: '高雅气质女 25~35岁' },
  59. { id: 'longanzhi_v3', name: '龙安智', gender: 'male', description: '睿智轻熟男 25~35岁' },
  60. { id: 'longanling_v3', name: '龙安灵', gender: 'female', description: '思维灵动女 20~30岁' },
  61. { id: 'longantai_v3', name: '龙安台', gender: 'female', description: '嗲甜台湾女 20~25岁' },
  62. { id: 'longhua_v3', name: '龙华', gender: 'female', description: '元气甜美女 20~25岁' },
  63. { id: 'longcheng_v3', name: '龙橙', gender: 'male', description: '智慧青年男 20~25岁' },
  64. { id: 'longze_v3', name: '龙泽', gender: 'male', description: '温暖元气男 25~30岁' },
  65. { id: 'longzhe_v3', name: '龙哲', gender: 'male', description: '呆板大暖男 25~30岁' },
  66. { id: 'longyan_v3', name: '龙颜', gender: 'female', description: '温暖春风女 30~35岁' },
  67. { id: 'longxing_v3', name: '龙星', gender: 'female', description: '温婉邻家女 20~25岁' },
  68. { id: 'longtian_v3', name: '龙天', gender: 'male', description: '磁性理智男 30~35岁' },
  69. { id: 'longwan_v3', name: '龙婉', gender: 'female', description: '细腻柔声女 20~30岁' },
  70. { id: 'longqiang_v3', name: '龙嫱', gender: 'female', description: '浪漫风情女 30~35岁' },
  71. { id: 'longfeifei_v3', name: '龙菲菲', gender: 'female', description: '甜美娇气女 20~25岁' },
  72. { id: 'longhao_v3', name: '龙浩', gender: 'male', description: '多情忧郁男 30~35岁' },
  73. { id: 'longanrou_v3', name: '龙安柔', gender: 'female', description: '温柔闺蜜女 20~35岁' },
  74. { id: 'longhan_v3', name: '龙寒', gender: 'male', description: '温暖痴情男 30~35岁' },
  75. // 童声
  76. { id: 'longpaopao_v3', name: '龙泡泡', gender: 'female', description: '飞天泡泡音 6~15岁' },
  77. { id: 'longjielidou_v3', name: '龙杰力豆', gender: 'male', description: '阳光顽皮男 10岁' },
  78. { id: 'longxian_v3', name: '龙仙', gender: 'female', description: '豪放可爱女 12岁' },
  79. { id: 'longling_v3', name: '龙铃', gender: 'female', description: '稚气呆板女 10岁' },
  80. { id: 'longshanshan_v3', name: '龙闪闪', gender: 'female', description: '戏剧化童声 6~15岁' },
  81. { id: 'longniuniu_v3', name: '龙牛牛', gender: 'male', description: '阳光男童声 6~15岁' },
  82. // 方言
  83. { id: 'longjiaxin_v3', name: '龙嘉欣', gender: 'female', description: '优雅粤语女 30~35岁' },
  84. { id: 'longjiayi_v3', name: '龙嘉怡', gender: 'female', description: '知性粤语女 25~30岁' },
  85. { id: 'longanyue_v3', name: '龙安粤', gender: 'male', description: '欢脱粤语男 25~35岁' },
  86. { id: 'longlaotie_v3', name: '龙老铁', gender: 'male', description: '东北直率男 25~30岁' },
  87. { id: 'longshange_v3', name: '龙陕哥', gender: 'male', description: '陕北原味男 25~35岁' },
  88. // 新闻/直播
  89. { id: 'longshuo_v3', name: '龙硕', gender: 'male', description: '博才干练男 25~30岁' },
  90. { id: 'longshu_v3', name: '龙书', gender: 'male', description: '沉稳青年男 20~25岁' },
  91. { id: 'loongbella_v3', name: 'Bella3.0',gender: 'female', description: '精准干练女 25~30岁' },
  92. { id: 'longanran_v3', name: '龙安燃', gender: 'female', description: '活泼质感女 30~40岁' },
  93. { id: 'longanxuan_v3', name: '龙安宣', gender: 'female', description: '经典直播女 30~40岁' },
  94. // 特色
  95. { id: 'longjiqi_v3', name: '龙机器', gender: 'male', description: '呆萌机器人 20~30岁' },
  96. { id: 'longhouge_v3', name: '龙猴哥', gender: 'male', description: '经典猴哥 20~25岁' },
  97. { id: 'longdaiyu_v3', name: '龙黛玉', gender: 'female', description: '娇率才女音 15~25岁' },
  98. { id: 'longfei_v3', name: '龙飞', gender: 'male', description: '热血磁性男 30~35岁' },
  99. // 外语
  100. { id: 'loongkyong_v3', name: '韩语女', gender: 'female', description: '韩语女声' },
  101. { id: 'loongriko_v3', name: 'Riko霓虹', gender: 'female', description: '二次元日语女' },
  102. { id: 'loongabby_v3', name: '美语女', gender: 'female', description: '美式英文女' },
  103. { id: 'loongandy_v3', name: '美语男', gender: 'male', description: '美式英文男' },
  104. ];
  105. // CosyVoice v3-flash 音色ID直接用作API参数名,无需映射
  106. export function getAliyunVoice(voiceId: string): string {
  107. return voiceId;
  108. }
  109. // 获取或创建默认"我的音频"书籍
  110. async function getOrCreateDefaultBook(userId: string): Promise<number> {
  111. const userIdNum = parseInt(userId);
  112. const bookTitle = '我的音频';
  113. // 先尝试查找现有默认书籍
  114. let book = await prisma.book.findFirst({
  115. where: {
  116. userId: userIdNum,
  117. title: bookTitle,
  118. },
  119. });
  120. if (book) {
  121. return book.id;
  122. }
  123. // 不存在则创建
  124. book = await prisma.book.create({
  125. data: {
  126. title: bookTitle,
  127. description: '我的语音合成音频收藏',
  128. userId: userIdNum,
  129. genStage: 'content_completed',
  130. bookScale: 'short',
  131. totalChapters: 0,
  132. estimatedWords: 0,
  133. },
  134. });
  135. console.log(`📚 创建默认书籍: bookId=${book.id}, userId=${userIdNum}`);
  136. return book.id;
  137. }
  138. // 判断是否使用长文本模式(>5000字符且启用realtime)
  139. // 注意:WebSocket realtime 模式需要特殊的API权限和配置,如果连接失败会导致生成失败。
  140. // 暂时强制禁用,使用 HTTP 分段模式
  141. export function shouldUseLongText(text: string): boolean {
  142. return false; // 强制返回 false,禁用 realtime 模式
  143. }
  144. // 文本分段 - 统一 1000 字符上限,优先在标点处断开
  145. // 策略:按自然句子累加,接近上限时在最后一个标点处截断,避免在字中间硬切
  146. // 好处:TTS 在句末韵律自然,拼接后听感流畅
  147. //
  148. // 1000 字的依据(主流 TTS 接口上限,大多以千为单位):
  149. // MiniMax 同步: 1万 | 阿里云 CosyVoice: 2万 | 讯飞 流式: ~4000字
  150. // 百度 短文本: 5120字 | OpenAI TTS: 4096字 | Google Cloud: 5000字节
  151. // ElevenLabs: 3000~5000字 | Azure: ~3000~5000字
  152. // 不支持1000的接口(应走异步长文本API,而非短文本接口):
  153. // 火山引擎 非流式: 300字 | 腾讯云 基础: 150字 | 百度 短文本: 60字
  154. const SEGMENT_MAX_LENGTH = 1000;
  155. // 句末标点(中文 + 英文),用于寻找断点
  156. const SENTENCE_END_RE = /[。!?;\n.!?;]/;
  157. export function splitText(text: string, maxLength: number = SEGMENT_MAX_LENGTH): string[] {
  158. const segments: string[] = [];
  159. // 清理文本
  160. const cleanText = text.replace(/\r/g, '');
  161. // 第一步:按换行拆成段落,再按句末标点拆成句子
  162. // 这样保留了段落边界,又不会在一个句子中间断开
  163. const rawSentences: string[] = [];
  164. const paragraphs = cleanText.split(/\n+/);
  165. for (const para of paragraphs) {
  166. if (para.length === 0) continue;
  167. // 按句末标点拆分,保留标点
  168. const sentences = para.match(/[^。!?;.!?;]+[。!?;.!?;]?/g) || [para];
  169. for (const s of sentences) {
  170. if (s.trim().length > 0) {
  171. rawSentences.push(s);
  172. }
  173. }
  174. }
  175. // 第二步:将句子累加成段,接近上限时在最后一个标点处截断
  176. let current = '';
  177. for (const sentence of rawSentences) {
  178. // 当前段 + 这句还能放下
  179. if ((current + sentence).length <= maxLength) {
  180. current += sentence;
  181. continue;
  182. }
  183. // 放不下了,先把当前段输出
  184. if (current) {
  185. segments.push(current);
  186. current = '';
  187. }
  188. // 如果这句话本身就超长,需要在句内找标点断点
  189. if (sentence.length > maxLength) {
  190. let remaining = sentence;
  191. while (remaining.length > 0) {
  192. if (remaining.length <= maxLength) {
  193. current = remaining;
  194. break;
  195. }
  196. // 在上限附近向前找最后一个标点作为断点
  197. let breakPos = -1;
  198. for (let i = maxLength; i > Math.max(0, maxLength - 100); i--) {
  199. if (SENTENCE_END_RE.test(remaining[i])) {
  200. breakPos = i + 1; // 标点后一位
  201. break;
  202. }
  203. }
  204. // 找不到标点,尝试逗号/顿号等次级标点
  205. if (breakPos === -1) {
  206. for (let i = maxLength; i > Math.max(0, maxLength - 100); i--) {
  207. if (/[,、,]/.test(remaining[i])) {
  208. breakPos = i + 1;
  209. break;
  210. }
  211. }
  212. }
  213. // 实在找不到标点,只能在上限处硬切(最后手段)
  214. if (breakPos === -1) {
  215. breakPos = maxLength;
  216. }
  217. segments.push(remaining.slice(0, breakPos));
  218. remaining = remaining.slice(breakPos);
  219. }
  220. } else {
  221. current = sentence;
  222. }
  223. }
  224. if (current) segments.push(current);
  225. return segments;
  226. }
  227. // ============ TTS Provider 调度(使用注册表 + 熔断器)============
  228. //
  229. // 优先级顺序由 Registry 中的 priority 决定:
  230. // 1. MiniMax (priority=1) - 异步长文本,业界最长
  231. // 2. 阿里云 Qwen-TTS (priority=2) - 同步快速
  232. // 3. Mock (priority=100) - 降级兜底
  233. //
  234. // 熔断器: 连续 3 次失败自动熔断,MiniMax 冷却 60s,阿里云 30s
  235. //
  236. // 分段策略: 统一 1000 字符/段,兼容所有 TTS 模型
  237. // 各家 TTS API 限制参考(2026年5月):
  238. // ┌──────────────┬────────────────────┬───────────┬──────────────────────┐
  239. // │ 服务商 │ 接口类型 │ 最大长度 │ 备注 │
  240. // ├──────────────┼────────────────────┼───────────┼──────────────────────┤
  241. // │ MiniMax │ 异步长文本 │ 1,000,000 │ 业界最长 │
  242. // │ 阿里云 │ 非流式/单向流式 │ 20,000 │ CosyVoice │
  243. // │ 火山引擎/豆包 │ 异步长文本 │ 100,000 │ 音频保存7天 │
  244. // │ 百度 │ 长文本异步 │ 100,000 │ 一次性合成 │
  245. // │ 讯飞 │ 长文本TTS │ 100,000 │ 万字级别快速合成 │
  246. // │ 腾讯云 │ 长文本语音合成 │ 10,000+ │ 每个speak标签≤150字 │
  247. // │ OpenAI │ TTS-1/HD │ 4,096 │ 隐藏限制 │
  248. // └──────────────┴────────────────────┴───────────┴──────────────────────┘
  249. // 生成音频(异步模式,不再创建 Audio 记录)
  250. export async function generateAudio(
  251. userId: string,
  252. text: string,
  253. voiceId: string,
  254. voiceParams: VoiceParams,
  255. onComplete?: (audioUrl: string, duration: number) => void,
  256. options?: {
  257. bookId?: string;
  258. chapterId?: number;
  259. chapterTitle?: string;
  260. ttsProvider?: 'aliyun' | 'minimax';
  261. }
  262. ): Promise<{
  263. audioId: string;
  264. audioUrl: string;
  265. bookId: number;
  266. }> {
  267. // 使用 UUID 作为音频 ID
  268. const audioId = uuidv4();
  269. const audioDir = path.join(config.upload.dir, audioId);
  270. if (!fs.existsSync(audioDir)) {
  271. fs.mkdirSync(audioDir, { recursive: true });
  272. }
  273. console.log('📝 开始音频生成:', audioId, '文本长度:', text.length, 'bookId:', options?.bookId);
  274. // 解析或创建目标书籍 ID
  275. let targetBookId: number;
  276. if (options?.bookId) {
  277. targetBookId = parseInt(options.bookId);
  278. } else {
  279. targetBookId = await getOrCreateDefaultBook(userId);
  280. }
  281. // 创建 AudioRecord 记录(状态为 processing)
  282. const wordCount = (text.match(/[\u4e00-\u9fa5]/g) || []).length;
  283. try {
  284. await prisma.audioRecord.create({
  285. data: {
  286. audioId,
  287. userId: userId ? parseInt(userId) : null,
  288. title: options.chapterTitle || '未命名音频',
  289. text: text.length > 10000 ? text.substring(0, 10000) : text,
  290. wordCount,
  291. voiceId,
  292. voiceParams: JSON.stringify(voiceParams),
  293. status: 'processing',
  294. bookId: targetBookId,
  295. },
  296. });
  297. console.log('💾 AudioRecord 已创建:', audioId);
  298. } catch (error) {
  299. console.error('❌ 创建 AudioRecord 失败:', error);
  300. }
  301. // 等待音频生成完成
  302. try {
  303. const result = await processAudioGeneration(audioId, text, voiceId, voiceParams, audioDir, onComplete, {
  304. ...options,
  305. userId,
  306. bookId: String(targetBookId),
  307. ttsProvider: options?.ttsProvider,
  308. });
  309. return result;
  310. } catch (error: any) {
  311. console.error('❌ 音频生成失败:', error.message);
  312. // 更新 AudioRecord 状态为失败
  313. try {
  314. await prisma.audioRecord.update({
  315. where: { audioId },
  316. data: { status: 'failed', errorMsg: error.message },
  317. });
  318. } catch (dbError) {
  319. console.error('❌ 更新 AudioRecord 失败状态失败:', dbError);
  320. }
  321. // 创建失败标记文件
  322. const failedMarker = path.join(audioDir, 'failed');
  323. fs.writeFileSync(failedMarker, error.message);
  324. // 推送 WebSocket 事件(音频生成失败)
  325. if (options?.bookId && options?.chapterId) {
  326. const { pushAudioGenerationComplete } = await import('../../services/websocket.service.js');
  327. pushAudioGenerationComplete(options.bookId, options.chapterId, 'failed');
  328. }
  329. throw error;
  330. }
  331. }
  332. /**
  333. * 异步处理音频生成(使用 Provider 注册表 + 熔断器)
  334. */
  335. async function processAudioGeneration(
  336. audioId: string,
  337. text: string,
  338. voiceId: string,
  339. voiceParams: VoiceParams,
  340. audioDir: string,
  341. onComplete?: (audioUrl: string, duration: number) => void,
  342. options?: {
  343. userId?: string;
  344. bookId?: string;
  345. chapterId?: number;
  346. chapterTitle?: string;
  347. ttsProvider?: 'aliyun' | 'minimax';
  348. }
  349. ) {
  350. const logMsg = `🔄 开始处理音频 ID: ${audioId}, 文本长度: ${text.length}, voiceId: ${voiceId}`;
  351. console.log(logMsg);
  352. logToFile(logMsg);
  353. const registry = getTtsRegistry();
  354. // 解析首选 Provider 名称(与注册表命名规则一致:${vendorKey}-tts)
  355. // 兼容前端传 'aliyun' → 实际 vendor key 是 'bailian'
  356. const vendorAlias: Record<string, string> = { aliyun: 'bailian' };
  357. const vendorKey = vendorAlias[options?.ttsProvider ?? ''] ?? options?.ttsProvider;
  358. const preferredName = vendorKey ? `${vendorKey}-tts` : undefined;
  359. // 带降级的执行:优先使用 preferredName,失败后通过 getNextAvailable 轮转
  360. // 与 LLM 的 invokeWithRetry 保持一致:可恢复错误先重试,再切换供应商
  361. const getInitialNode = (): ProviderNode<ITtsProvider> => {
  362. if (preferredName) {
  363. const pref = registry.get(preferredName);
  364. if (pref && pref.enabled && !pref.breaker.isOpen()) return pref;
  365. const next = registry.getNextAvailable(preferredName);
  366. if (next) return next;
  367. }
  368. const primary = registry.getPrimary();
  369. if (!primary) throw new Error('没有可用的 TTS Provider');
  370. return primary;
  371. };
  372. const visited = new Set<string>();
  373. let currentNode = getInitialNode();
  374. const maxProviders = registry.listEnabled().length;
  375. while (currentNode && visited.size < maxProviders) {
  376. if (visited.has(currentNode.provider.name)) break;
  377. visited.add(currentNode.provider.name);
  378. const tts = currentNode.provider;
  379. const node = currentNode;
  380. try {
  381. console.log(`🔊 使用 TTS Provider: ${tts.name} (vendor=${tts.vendor}, mode=${tts.mode})`);
  382. logToFile(`Provider: ${tts.name}, vendor=${tts.vendor}, mode=${tts.mode}`);
  383. // 解析音色名称(MiniMax 和阿里云用不同的音色映射表)
  384. const voiceName = tts.vendor.startsWith('minimax') ? getMiniMaxVoice(voiceId) : getAliyunVoice(voiceId);
  385. // 按模型配置分段:有 maxTextLength 就用它的 80%,没配置就用默认 1000
  386. const segmentMax = tts.maxTextLength ? Math.floor(tts.maxTextLength * 0.8) : 1000;
  387. const segments = splitText(text, segmentMax);
  388. console.log(`📝 文本已分段: ${segments.length} 段, 阈值=${segmentMax}字 (Provider: ${tts.name})`);
  389. // 并行生成各段音频(使用 Provider 自身的并发设置,每段带重试)
  390. const audioFiles: string[] = [];
  391. const cloudUrls: string[] = [];
  392. const concurrency = tts.concurrency;
  393. for (let i = 0; i < segments.length; i += concurrency) {
  394. const batch = segments.slice(i, i + concurrency);
  395. // 统一调用 synthesizeSegmentWithRetry(熔断保护 + 可恢复错误重试1次)
  396. const results = await Promise.all(
  397. batch.map(async (segment, idx) => {
  398. const segPath = path.join(audioDir, `segment_${i + idx}.mp3`);
  399. return synthesizeSegmentWithRetry(tts, node, segment, voiceName, voiceParams, segPath);
  400. })
  401. );
  402. results.forEach(r => {
  403. if (r.startsWith('cloud:')) {
  404. cloudUrls.push(r.substring(6));
  405. } else {
  406. audioFiles.push(r);
  407. }
  408. });
  409. }
  410. console.log(`📁 生成了 ${audioFiles.length} 个音频文件, ${cloudUrls.length} 个云端URL`);
  411. let audioUrl = '';
  412. let duration = 0;
  413. let size = 0;
  414. if (cloudUrls.length > 0 && audioFiles.length === 0 && cloudUrls.length === 1) {
  415. console.log('☁️ 下载单个云端音频并上传...');
  416. const cloudUrl = cloudUrls[0];
  417. const response = await axios.get(cloudUrl, { responseType: 'arraybuffer', timeout: 60000 });
  418. const buffer = Buffer.from(response.data);
  419. const tempPath = path.join(audioDir, 'output.mp3');
  420. if (!fs.existsSync(audioDir)) fs.mkdirSync(audioDir, { recursive: true });
  421. fs.writeFileSync(tempPath, buffer);
  422. audioUrl = await storageService.uploadAudio(tempPath, audioId);
  423. duration = await AudioMerger.getDuration(tempPath);
  424. size = fs.statSync(tempPath).size;
  425. } else {
  426. // 下载所有云端URL到本地
  427. const allLocalFiles = [...audioFiles];
  428. if (cloudUrls.length > 0) {
  429. for (let ci = 0; ci < cloudUrls.length; ci++) {
  430. try {
  431. const response = await axios.get(cloudUrls[ci], { responseType: 'arraybuffer', timeout: 60000 });
  432. const localPath = path.join(audioDir, `cloud_segment_${ci}.mp3`);
  433. fs.writeFileSync(localPath, Buffer.from(response.data));
  434. allLocalFiles.push(localPath);
  435. } catch (dlErr: any) {
  436. console.error(`❌ 云端音频 ${ci + 1} 下载失败:`, dlErr.message);
  437. }
  438. }
  439. }
  440. if (allLocalFiles.length === 0) {
  441. throw new Error('所有音频文件下载失败,无法生成音频');
  442. }
  443. const outputPath = path.join(audioDir, 'output.mp3');
  444. const mergedFile = await AudioMerger.merge(allLocalFiles, outputPath);
  445. size = fs.statSync(mergedFile).size;
  446. duration = await AudioMerger.getDuration(mergedFile);
  447. audioUrl = await storageService.uploadAudio(mergedFile, audioId);
  448. console.log(`📁 音频已合并并上传 (${duration}s):`, audioUrl);
  449. }
  450. // AI 生成标题、摘要和标签
  451. const [title] = await Promise.all([
  452. aiSummaryService.generateTitle(text),
  453. aiSummaryService.generateSummary(text, 200),
  454. aiSummaryService.extractTags(text),
  455. ]);
  456. const finalAudioUrl = audioUrl;
  457. // LRC 歌词时间轴
  458. let lrcLyrics = '';
  459. try {
  460. const localAudioPath = path.join(audioDir, 'output.mp3');
  461. lrcLyrics = generateLrc(text, duration, localAudioPath);
  462. } catch (lrcErr: any) {
  463. console.error(`❌ LRC 生成异常:`, lrcErr.message);
  464. }
  465. // 保存到书籍章节
  466. const targetBookId = options?.bookId ? parseInt(options.bookId) : null;
  467. const targetChapterId = options?.chapterId ?? null;
  468. if (targetBookId && targetChapterId) {
  469. try {
  470. await prisma.bookChapter.update({
  471. where: { id: targetChapterId },
  472. data: {
  473. audioUrl: finalAudioUrl,
  474. audioDuration: duration,
  475. lrcLyrics: lrcLyrics || null,
  476. generatedAt: new Date(),
  477. },
  478. });
  479. console.log(`✅ 已更新书籍章节音频 (chapterId: ${targetChapterId})`);
  480. } catch (error) {
  481. console.error('❌ 保存到书籍章节失败:', error);
  482. }
  483. }
  484. // 更新 AudioRecord 为完成
  485. try {
  486. await prisma.audioRecord.update({
  487. where: { audioId },
  488. data: {
  489. status: 'completed',
  490. audioUrl: finalAudioUrl,
  491. audioDuration: duration,
  492. audioSize: size,
  493. title: options?.chapterTitle || title,
  494. },
  495. });
  496. } catch (error) {
  497. console.error('❌ 更新 AudioRecord 失败:', error);
  498. }
  499. // 完成回调
  500. if (onComplete) {
  501. try { await onComplete(finalAudioUrl, duration); } catch (callbackErr) {
  502. console.error(`[TTS] onComplete回调失败:`, callbackErr);
  503. }
  504. }
  505. // WebSocket 推送
  506. if (options?.bookId && options?.chapterId) {
  507. const { pushAudioGenerationComplete } = await import('../../services/websocket.service.js');
  508. pushAudioGenerationComplete(options.bookId, options.chapterId, 'completed');
  509. }
  510. console.log('✅ 音频生成完成:', audioId, 'URL:', finalAudioUrl);
  511. return { audioId, audioUrl: finalAudioUrl, bookId: targetBookId };
  512. } catch (error: any) {
  513. // 自动降级:与 LLM 共用 shouldSwitchModel 判断可恢复错误
  514. const errorMsg = error?.message || '';
  515. const isRecoverable = error instanceof CircuitBreakerOpenError
  516. || config.models.shouldSwitchModel(error);
  517. // 额度耗尽立即标记(4小时自动恢复)
  518. if (['quota', 'balance', 'insufficient', 'usage limit'].some(k => errorMsg.toLowerCase().includes(k))) {
  519. registry.markExhausted(tts.name, errorMsg, 4 * 60 * 60 * 1000);
  520. }
  521. if (isRecoverable) {
  522. console.error(`❌ Provider ${tts.name} 失败(可恢复),通过 getNextAvailable 降级...`);
  523. currentNode = registry.getNextAvailable(tts.name);
  524. continue;
  525. }
  526. // 非可恢复错误,直接抛出
  527. console.error(`❌ processAudioGeneration 失败(非可恢复错误):`, errorMsg);
  528. throw error;
  529. }
  530. }
  531. // 所有 Provider 都试过了,仍然失败
  532. throw new Error('所有 TTS Provider 都已尝试,均无法生成音频');
  533. }
  534. /**
  535. * 片段级合成(带熔断保护 + 可恢复错误重试1次)
  536. * 与 LLM invokeWithRetry 策略一致:瞬时波动先重试,持续性故障再切换
  537. */
  538. async function synthesizeSegmentWithRetry(
  539. tts: ITtsProvider,
  540. node: ProviderNode<ITtsProvider>,
  541. segment: string,
  542. voiceName: string,
  543. voiceParams: VoiceParams,
  544. segPath: string,
  545. ): Promise<string> {
  546. try {
  547. return await node.breaker.call(() =>
  548. tts.synthesize(segment, voiceName, voiceParams, segPath),
  549. );
  550. } catch (err: any) {
  551. // 熔断器开启 → 直接抛出,让外层切换 Provider
  552. if (err instanceof CircuitBreakerOpenError) throw err;
  553. const errorMsg = err?.message || '';
  554. // 不可恢复错误 → 直接抛出(如认证失败、参数错误)
  555. if (!config.models.shouldSwitchModel(err)) throw err;
  556. // 可恢复错误:延迟后重试1次(与 LLM 相同的 2s 延迟)
  557. console.log(`[TTS] 片段合成失败(${errorMsg.substring(0, 80)}),2s后重试...`);
  558. logToFile(`[Retry] 片段合成失败,2s后重试: ${errorMsg.substring(0, 100)}`);
  559. await new Promise(resolve => setTimeout(resolve, 2000));
  560. return await node.breaker.call(() =>
  561. tts.synthesize(segment, voiceName, voiceParams, segPath),
  562. );
  563. }
  564. }
  565. /**
  566. * 获取音频状态(基于文件系统)
  567. */
  568. export async function getAudioStatus(audioId: string): Promise<{ status: string; audio?: any }> {
  569. const audioDir = path.join(config.upload.dir, audioId);
  570. const outputPath = path.join(audioDir, 'output.mp3');
  571. const failedMarker = path.join(audioDir, 'failed');
  572. // 检查是否失败
  573. if (fs.existsSync(failedMarker)) {
  574. const errorMsg = fs.readFileSync(failedMarker, 'utf-8');
  575. return { status: 'failed', audio: { error: errorMsg } };
  576. }
  577. // 检查是否生成完成
  578. if (fs.existsSync(outputPath)) {
  579. const stats = fs.statSync(outputPath);
  580. const duration = await AudioMerger.getDuration(outputPath);
  581. return {
  582. status: 'completed',
  583. audio: {
  584. audioUrl: `/uploads/${audioId}/output.mp3`,
  585. audioDuration: duration,
  586. audioSize: stats.size,
  587. },
  588. };
  589. }
  590. // 检查目录是否存在
  591. if (fs.existsSync(audioDir)) {
  592. // 检查是否为僵尸任务:目录存在超过 5 分钟但没有任何文件
  593. const dirStat = fs.statSync(audioDir);
  594. const dirAge = Date.now() - dirStat.mtimeMs;
  595. const files = fs.readdirSync(audioDir);
  596. if (files.length === 0 && dirAge > 120000) {
  597. // 空目录且超过 2 分钟,判定为僵尸任务
  598. console.warn(`⚠️ 检测到僵尸任务: ${audioId},目录创建于 ${Math.round(dirAge / 1000)}s 前,无任何文件`);
  599. // 更新数据库状态为失败
  600. try {
  601. await prisma.audioRecord.update({
  602. where: { audioId },
  603. data: { status: 'failed', errorMsg: '任务超时:生成进程异常终止' },
  604. });
  605. } catch (e) {}
  606. return { status: 'failed', audio: { error: '生成进程异常终止' } };
  607. }
  608. return { status: 'processing' };
  609. }
  610. // 不存在
  611. return { status: 'not_found' };
  612. }
  613. // 获取可用音色
  614. export function getVoices(): Voice[] {
  615. return VOICES;
  616. }
  617. // ============ LRC 歌词生成 ============
  618. /** 格式化秒数为 [MM:SS.XX] */
  619. function formatLrcTimestamp(seconds: number): string {
  620. const m = Math.floor(seconds / 60);
  621. const s = seconds % 60;
  622. const cs = Math.round((s - Math.floor(s)) * 100);
  623. const sec = Math.floor(s);
  624. return `${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}.${cs.toString().padStart(2, '0')}`;
  625. }
  626. /** 清洗文本中的换行符(LRC每行只能有一个时间戳,文本中不能含换行) */
  627. function sanitizeLrcText(s: string): string {
  628. return s.replace(/\n+/g, ' ').trim();
  629. }
  630. /** 统计可见字符数(去除空白) */
  631. function countVisible(s: string): number {
  632. return s.replace(/\s/g, '').length;
  633. }
  634. /** 最大每行字符数(超过则进一步拆分) */
  635. const MAX_CHARS_PER_LINE = 50;
  636. /**
  637. * 将文本拆分为适合 LRC 显示的小句子
  638. * 策略:
  639. * 1. 先按 Markdown 标题(#/##)切分,标题独立一行
  640. * 2. 按句末标点(。!?)切分
  641. * 3. 按分号/冒号(;:)切分
  642. * 4. 按逗号(,,)切分
  643. * 5. 按换行切分
  644. * 6. 兜底:按固定字符数切分
  645. * 每行不超过 MAX_CHARS_PER_LINE 字符
  646. */
  647. function splitIntoLrcSentences(text: string): string[] {
  648. const result: string[] = [];
  649. const maxChars = MAX_CHARS_PER_LINE;
  650. // 第一步:按 Markdown 标题切分,标题独立成行
  651. const titleParts = text.split(/(?=^#{1,3}\s)/m);
  652. for (const part of titleParts) {
  653. const trimmed = part.trim();
  654. if (!trimmed) continue;
  655. // 如果是标题行(单独一行)
  656. const titleMatch = trimmed.match(/^(#{1,3}\s+.+?)(\n|$)/);
  657. if (titleMatch) {
  658. const titleLine = titleMatch[1].trim();
  659. const rest = trimmed.substring(titleMatch[0].length).trim();
  660. // 标题单独一行
  661. result.push(titleLine);
  662. // 处理标题后面的正文
  663. if (rest) {
  664. result.push(...splitBodyIntoSentences(rest, maxChars));
  665. }
  666. } else {
  667. // 没有标题,直接处理正文
  668. result.push(...splitBodyIntoSentences(trimmed, maxChars));
  669. }
  670. }
  671. return result.filter(s => countVisible(s) > 0);
  672. }
  673. /**
  674. * 将正文拆分为小句子
  675. * 优先级:句号 > 分号/冒号 > 逗号 > 换行 > 固定长度
  676. */
  677. function splitBodyIntoSentences(text: string, maxChars: number): string[] {
  678. const result: string[] = [];
  679. // 先按段落(空行/换行)粗分
  680. const paragraphs = text.split(/\n+/).filter(p => p.trim());
  681. for (const para of paragraphs) {
  682. const trimmed = para.trim();
  683. if (!trimmed) continue;
  684. // 如果整段就小于 maxChars,直接加入
  685. if (countVisible(trimmed) <= maxChars) {
  686. result.push(trimmed);
  687. continue;
  688. }
  689. // 按句末标点切分(中文:。!?,英文:.!?)
  690. let sentences = splitByPunctuation(trimmed, /[。!?.!?]+/, maxChars);
  691. // 如果切分后仍有超长句子,按顿号再切
  692. sentences = furtherSplit(sentences, /[、]/, maxChars);
  693. // 兜底:强制按字符数切分
  694. sentences = forceSplitByCharCount(sentences, maxChars);
  695. result.push(...sentences);
  696. }
  697. return result;
  698. }
  699. /** 按指定标点切分,标点附在前面那句末尾 */
  700. function splitByPunctuation(text: string, punctRegex: RegExp, maxChars: number): string[] {
  701. const parts: string[] = [];
  702. let remaining = text;
  703. while (remaining.length > 0) {
  704. // 查找下一个标点位置
  705. const match = remaining.match(punctRegex);
  706. if (!match || match.index === undefined) {
  707. // 没有更多标点,剩余部分整体加入
  708. parts.push(remaining);
  709. break;
  710. }
  711. const cutPos = match.index + match[0].length;
  712. const sentence = remaining.substring(0, cutPos).trim();
  713. if (sentence) {
  714. parts.push(sentence);
  715. }
  716. remaining = remaining.substring(cutPos).trim();
  717. }
  718. return parts;
  719. }
  720. /** 对已切分的句子,如果某些句子仍然超长,用更细粒度的标点再切 */
  721. function furtherSplit(sentences: string[], punctRegex: RegExp, maxChars: number): string[] {
  722. const result: string[] = [];
  723. for (const s of sentences) {
  724. if (countVisible(s) <= maxChars) {
  725. result.push(s);
  726. continue;
  727. }
  728. // 用更细粒度标点再切
  729. const subParts = splitByPunctuation(s, punctRegex, maxChars);
  730. result.push(...subParts);
  731. }
  732. return result;
  733. }
  734. /** 兜底:强制按字符数切分超长句子 */
  735. function forceSplitByCharCount(sentences: string[], maxChars: number): string[] {
  736. const result: string[] = [];
  737. for (const s of sentences) {
  738. if (countVisible(s) <= maxChars) {
  739. result.push(s);
  740. continue;
  741. }
  742. // 按可见字符数强制切分
  743. let buf = '';
  744. let visibleCount = 0;
  745. for (const ch of s) {
  746. buf += ch;
  747. if (!/\s/.test(ch)) visibleCount++;
  748. if (visibleCount >= maxChars) {
  749. result.push(buf.trim());
  750. buf = '';
  751. visibleCount = 0;
  752. }
  753. }
  754. if (buf.trim()) result.push(buf.trim());
  755. }
  756. return result;
  757. }
  758. /**
  759. * 生成 LRC 歌词时间轴
  760. *
  761. * 核心算法:**按句子拆分 + 均匀语速分配**
  762. *
  763. * 原理:
  764. * - TTS 生成的音频语速相对均匀
  765. * - 用 总时长/总字数 得到真实平均语速(秒/字)
  766. * - 每句时长 = 该句字数 × 平均语速
  767. * - 这样保证所有句子时间之和 = 总时长,且短句少分时间、长句多分时间
  768. */
  769. export function generateLrc(text: string, duration: number, audioPath?: string): string {
  770. if (!text || duration <= 0) return '';
  771. try {
  772. const sentences = splitIntoLrcSentences(text);
  773. if (sentences.length === 0) return '';
  774. logToFile(`🎵 LRC 拆分: ${sentences.length} 句, 总时长=${duration}s`);
  775. // 计算总可见字符数
  776. const totalChars = sentences.reduce((sum, s) => sum + countVisible(s), 0);
  777. if (totalChars === 0) return '';
  778. // 核心:均匀语速 = 总时长 / 总字数
  779. const speechRate = duration / totalChars; // 秒/字
  780. logToFile(`🎵 语速: ${speechRate.toFixed(3)}s/字, 总字数=${totalChars}`);
  781. const lines: string[] = [];
  782. let currentTime = 0;
  783. for (let i = 0; i < sentences.length; i++) {
  784. const charCount = countVisible(sentences[i]);
  785. const isTitle = /^#{1,3}\s/.test(sentences[i].trim());
  786. // 标题行语速较慢(通常有停顿),给予 1.5 倍时长系数
  787. const titleBoost = isTitle ? 1.5 : 1.0;
  788. const lineDuration = charCount * speechRate * titleBoost;
  789. lines.push(`[${formatLrcTimestamp(currentTime)}] ${sanitizeLrcText(sentences[i])}`);
  790. logToFile(` [${formatLrcTimestamp(currentTime)}] ${charCount}字 ${sentences[i].substring(0, 30)}...`);
  791. currentTime += lineDuration;
  792. }
  793. // 由于标题行增加了时长系数,实际计算总时长可能超过音频时长
  794. // 用线性缩放将所有时间戳归一化到 [0, duration] 区间
  795. if (currentTime > duration && lines.length > 0) {
  796. const scaleFactor = duration / currentTime;
  797. const rescaledLines: string[] = [];
  798. let rescaledTime = 0;
  799. for (let i = 0; i < sentences.length; i++) {
  800. const charCount = countVisible(sentences[i]);
  801. const isTitle = /^#{1,3}\s/.test(sentences[i].trim());
  802. const titleBoost = isTitle ? 1.5 : 1.0;
  803. const rawLineDuration = charCount * speechRate * titleBoost;
  804. const scaledLineDuration = rawLineDuration * scaleFactor;
  805. rescaledLines.push(`[${formatLrcTimestamp(rescaledTime)}] ${sanitizeLrcText(sentences[i])}`);
  806. rescaledTime += scaledLineDuration;
  807. }
  808. logToFile(`🎵 LRC 标题补偿缩放: 原始总时长=${currentTime.toFixed(2)}s, 缩放因子=${scaleFactor.toFixed(3)}`);
  809. return rescaledLines.join('\n');
  810. }
  811. // 确保最后一行不超过总时长
  812. logToFile(`🎵 LRC 完成: ${lines.length} 行, 末尾时间=${currentTime.toFixed(2)}s, 音频时长=${duration}s`);
  813. return lines.join('\n');
  814. } catch (err: any) {
  815. logToFile(`❌ generateLrc 异常: ${err.message}`);
  816. console.error(`❌ generateLrc 异常:`, err.message);
  817. return buildSimpleLrc(text, duration);
  818. }
  819. }
  820. /** 最简降级:按句号或换行分割,再不行按固定长度切分 */
  821. function buildSimpleLrc(text: string, duration: number): string {
  822. // 中英文句末标点(句号、感叹号、问号)
  823. let sentences = text.split(/(?<=[。!?.!?])\s*/).filter(s => s.trim());
  824. if (sentences.length <= 1) {
  825. sentences = text.split(/\n+/).filter(s => s.trim());
  826. }
  827. if (sentences.length <= 1 && text.length > 20) {
  828. // 英文按句号切分后再按固定长度兜底
  829. const englishSentences = text.split(/(?<=[.!?])\s+/).filter(s => s.trim().length > 0);
  830. if (englishSentences.length > 1) {
  831. sentences = englishSentences;
  832. } else {
  833. const chunkSize = 40;
  834. sentences = [];
  835. for (let i = 0; i < text.length; i += chunkSize) {
  836. sentences.push(text.substring(i, i + chunkSize));
  837. }
  838. }
  839. }
  840. if (sentences.length === 0) return '';
  841. const totalChars = sentences.reduce((sum, s) => sum + countVisible(s), 0);
  842. if (totalChars === 0) return '';
  843. const speechRate = duration / totalChars;
  844. const lines: string[] = [];
  845. let t = 0;
  846. for (const s of sentences) {
  847. lines.push(`[${formatLrcTimestamp(t)}] ${sanitizeLrcText(s)}`);
  848. t += countVisible(s) * speechRate;
  849. }
  850. return lines.join('\n');
  851. }
  852. // 获取可用 TTS 服务商(从注册表读取)
  853. export function getAvailableProviders() {
  854. const registry = getTtsRegistry();
  855. return registry.listEnabled().map(n => ({
  856. id: n.provider.vendor,
  857. name: n.provider.name,
  858. enabled: n.enabled,
  859. state: n.breaker.getState(),
  860. }));
  861. }
  862. // 生成预览音频(短文本)
  863. export async function generatePreview(
  864. voiceId: string,
  865. voiceParams?: { speed?: number; pitch?: number; volume?: number },
  866. providerType?: 'aliyun' | 'minimax'
  867. ): Promise<{ audioId: string; audioUrl: string }> {
  868. const previewText = '你好,欢迎使用AI有声书';
  869. const audioId = 'preview-' + uuidv4();
  870. const audioDir = path.join(config.upload.dir, audioId);
  871. if (!fs.existsSync(audioDir)) {
  872. fs.mkdirSync(audioDir, { recursive: true });
  873. }
  874. const params = {
  875. speed: voiceParams?.speed ?? 1.0,
  876. pitch: voiceParams?.pitch ?? 0,
  877. volume: voiceParams?.volume ?? 50,
  878. };
  879. console.log(`[TTS Preview] params: speed=${params.speed}, pitch=${params.pitch}, volume=${params.volume}`);
  880. const registry = getTtsRegistry();
  881. // 优先使用的 Provider
  882. const preferredName = providerType === 'minimax' ? 'minimax-tts' : providerType === 'aliyun' ? 'bailian-tts' : undefined;
  883. const startNode = preferredName ? registry.get(preferredName) : undefined;
  884. // 按优先级尝试所有可用 Provider
  885. const candidates = startNode
  886. ? [startNode, ...registry.listAvailable().filter(n => n !== startNode)]
  887. : registry.listAvailable();
  888. let lastError: Error | null = null;
  889. for (const node of candidates) {
  890. if (node.exhausted || node.breaker.isOpen()) continue;
  891. const tts = node.provider;
  892. const voiceName = tts.vendor.startsWith('minimax') ? getMiniMaxVoice(voiceId) : getAliyunVoice(voiceId);
  893. if (tts.mode === 'mock') {
  894. const mockPath = path.join(audioDir, 'preview.mp3');
  895. fs.writeFileSync(mockPath, Buffer.alloc(1024));
  896. return { audioId, audioUrl: `/uploads/${audioId}/preview.mp3` };
  897. }
  898. try {
  899. // 先创建空文件让 aliyun 下载能定位目录
  900. const ext = tts.vendor.startsWith('minimax') ? 'mp3' : 'wav';
  901. const outputPath = path.join(audioDir, `preview.${ext}`);
  902. if (!fs.existsSync(outputPath)) {
  903. fs.writeFileSync(outputPath, Buffer.alloc(0));
  904. }
  905. const actualPath = await node.breaker.call(() => tts.synthesize(previewText, voiceName, params, outputPath));
  906. const audioUrl = await storageService.uploadAudio(actualPath, audioId);
  907. console.log(`✅ [TTS Preview] ${tts.name} 生成成功`);
  908. return { audioId, audioUrl };
  909. } catch (error: any) {
  910. console.error(`❌ [TTS Preview] ${tts.name} 失败:`, error.message);
  911. lastError = error;
  912. // 额度耗尽标记
  913. if (['quota', 'balance', 'insufficient', 'usage limit'].some(k => error.message?.toLowerCase().includes(k))) {
  914. registry.markExhausted(tts.name, error.message, 4 * 60 * 60 * 1000);
  915. }
  916. continue;
  917. }
  918. }
  919. console.error('❌ 预览生成失败:', lastError?.message);
  920. throw lastError || new Error('所有 TTS Provider 都已尝试');
  921. }
  922. // ============ 初始化 ============
  923. // 首次调用时初始化注册表并启动健康检查
  924. startTtsHealthCheck();