tts.service.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. "use strict";
  2. var __importDefault = (this && this.__importDefault) || function (mod) {
  3. return (mod && mod.__esModule) ? mod : { "default": mod };
  4. };
  5. Object.defineProperty(exports, "__esModule", { value: true });
  6. exports.VOICES = void 0;
  7. exports.getAliyunVoice = getAliyunVoice;
  8. exports.shouldUseLongText = shouldUseLongText;
  9. exports.splitText = splitText;
  10. exports.generateAudio = generateAudio;
  11. exports.getAudioStatus = getAudioStatus;
  12. exports.getVoices = getVoices;
  13. const path_1 = __importDefault(require("path"));
  14. const fs_1 = __importDefault(require("fs"));
  15. const uuid_1 = require("uuid");
  16. const config_1 = require("../../config");
  17. const aliyun_provider_1 = require("./aliyun.provider");
  18. const aliyun_realtime_provider_1 = require("./aliyun-realtime.provider");
  19. const mock_provider_1 = require("./mock.provider");
  20. const audio_merger_1 = require("./audio-merger");
  21. const ai_summary_service_1 = require("./ai-summary.service");
  22. // 日志文件路径
  23. const LOG_FILE = path_1.default.join(process.cwd(), 'tts-debug.log');
  24. function logToFile(msg) {
  25. const timestamp = new Date().toISOString();
  26. fs_1.default.appendFileSync(LOG_FILE, `[${timestamp}] ${msg}\n`);
  27. }
  28. // 可用音色列表(使用阿里云官方音色)
  29. exports.VOICES = [
  30. { id: 'cherry', name: '芊悦', gender: 'female', description: '阳光积极、亲切自然' },
  31. { id: 'serena', name: '苏瑶', gender: 'female', description: '温柔女声' },
  32. { id: 'ethan', name: '晨煦', gender: 'male', description: '阳光温暖、活力男声' },
  33. { id: 'chelsie', name: '千雪', gender: 'female', description: '二次元虚拟女友' },
  34. { id: 'momo', name: '茉兔', gender: 'female', description: '撒娇搞怪' },
  35. { id: 'vivian', name: '十三', gender: 'female', description: '可爱小暴躁' },
  36. { id: 'moon', name: '月白', gender: 'male', description: '率性帅气' },
  37. { id: 'maia', name: '四月', gender: 'female', description: '知性温柔' },
  38. { id: 'kai', name: '凯', gender: 'male', description: '舒缓放松' },
  39. { id: 'nofish', name: '不吃鱼', gender: 'male', description: '不会翘舌音' },
  40. ];
  41. // 前端音色 ID 到阿里云音色的映射
  42. const VOICE_MAPPING = {
  43. cherry: 'Cherry',
  44. serena: 'Serena',
  45. ethan: 'Ethan',
  46. chelsie: 'Chelsie',
  47. momo: 'Momo',
  48. vivian: 'Vivian',
  49. moon: 'Moon',
  50. maia: 'Maia',
  51. kai: 'Kai',
  52. nofish: 'Nofish',
  53. };
  54. // 获取阿里云音色名称
  55. function getAliyunVoice(voiceId) {
  56. return VOICE_MAPPING[voiceId] || 'Cherry';
  57. }
  58. // 判断是否使用长文本模式(>5000字符且启用realtime)
  59. // 注意:WebSocket realtime 模式需要特殊的API权限和配置,如果连接失败会导致生成失败。
  60. // 暂时强制禁用,使用 HTTP 分段模式
  61. function shouldUseLongText(text) {
  62. return false; // 强制返回 false,禁用 realtime 模式
  63. }
  64. // 文本分段 - 阿里云 TTS 限制 600 字符,增加到 550 留安全余量
  65. function splitText(text, maxLength = 550) {
  66. const segments = [];
  67. let current = '';
  68. // 清理文本,移除可能导致问题的字符
  69. const cleanText = text.replace(/\r/g, '');
  70. // 按段落分割
  71. const paragraphs = cleanText.split(/\n+/);
  72. for (const para of paragraphs) {
  73. if ((current + para).length <= maxLength) {
  74. current += (current ? '\n' : '') + para;
  75. }
  76. else {
  77. if (current)
  78. segments.push(current);
  79. // 如果段落本身超长,按句子分割
  80. if (para.length > maxLength) {
  81. // 使用更安全的分割方式
  82. const sentences = para.match(/[^。!?;]+[。!?;]?/g) || [para];
  83. current = '';
  84. for (const sentence of sentences) {
  85. if (sentence.length === 0)
  86. continue;
  87. if ((current + sentence).length <= maxLength) {
  88. current += sentence;
  89. }
  90. else {
  91. if (current)
  92. segments.push(current);
  93. // 如果句子本身超长,强制分割
  94. if (sentence.length > maxLength) {
  95. for (let i = 0; i < sentence.length; i += maxLength) {
  96. segments.push(sentence.slice(i, i + maxLength));
  97. }
  98. current = '';
  99. }
  100. else {
  101. current = sentence;
  102. }
  103. }
  104. }
  105. }
  106. else {
  107. current = para;
  108. }
  109. }
  110. }
  111. if (current)
  112. segments.push(current);
  113. // 验证每段长度不超过限制(安全检查)
  114. const safeLimit = 550;
  115. const validatedSegments = segments.map((seg, idx) => {
  116. if (seg.length > safeLimit) {
  117. console.warn(`⚠️ 段落 ${idx + 1} 长度 ${seg.length} 超过限制,强制截断`);
  118. return seg.substring(0, safeLimit);
  119. }
  120. return seg;
  121. });
  122. return validatedSegments;
  123. }
  124. // TTS Provider 工厂
  125. function getTtsProvider(text, voiceId) {
  126. const useLongText = shouldUseLongText(text);
  127. // 文本超长时使用 WebSocket 实时合成
  128. if (useLongText && config_1.config.dashscope.apiKey) {
  129. console.log('🔊 文本超过5000字符,使用 Qwen Realtime TTS 流式合成');
  130. return { provider: new aliyun_realtime_provider_1.AliyunRealtimeTtsProvider(), type: 'realtime' };
  131. }
  132. // 优先使用阿里云百炼 Qwen TTS
  133. if (config_1.config.dashscope.apiKey) {
  134. console.log('🔊 使用阿里云百炼 Qwen TTS 服务');
  135. return { provider: new aliyun_provider_1.AliyunTtsProvider(), type: 'http' };
  136. }
  137. // 降级到模拟服务
  138. console.log('🔊 使用模拟 TTS 服务(无 API Key)');
  139. return { provider: new mock_provider_1.MockTtsProvider(), type: 'mock' };
  140. }
  141. // 随机选择 TTS 模型
  142. function getRandomModel() {
  143. const models = config_1.config.dashscope.ttsModels;
  144. const model = models[Math.floor(Math.random() * models.length)];
  145. console.log(`🎲 随机选择 TTS 模型: ${model}`);
  146. return model;
  147. }
  148. // 生成音频(异步模式,不再创建 Audio 记录)
  149. async function generateAudio(userId, text, voiceId, voiceParams, onComplete) {
  150. // 使用 UUID 作为音频 ID
  151. const audioId = (0, uuid_1.v4)();
  152. const audioDir = path_1.default.join(config_1.config.upload.dir, audioId);
  153. if (!fs_1.default.existsSync(audioDir)) {
  154. fs_1.default.mkdirSync(audioDir, { recursive: true });
  155. }
  156. console.log('📝 开始音频生成:', audioId, '文本长度:', text.length);
  157. // 异步处理音频生成
  158. processAudioGeneration(audioId, text, voiceId, voiceParams, audioDir, onComplete).catch(error => {
  159. const errMsg = `❌ 异步音频生成失败: ${error.message}`;
  160. console.error(errMsg);
  161. console.error('❌ 错误堆栈:', error.stack);
  162. logToFile(errMsg + '\n' + error.stack);
  163. });
  164. // 立即返回音频ID和状态(audioUrl 为空,生成完成后通过回调更新)
  165. return {
  166. audioId,
  167. audioUrl: '',
  168. };
  169. }
  170. /**
  171. * 异步处理音频生成
  172. */
  173. async function processAudioGeneration(audioId, text, voiceId, voiceParams, audioDir, onComplete) {
  174. const logMsg = `🔄 开始处理音频 ID: ${audioId}, 文本长度: ${text.length}, voiceId: ${voiceId}`;
  175. console.log(logMsg);
  176. logToFile(logMsg);
  177. try {
  178. const { provider, type } = getTtsProvider(text, voiceId);
  179. console.log(`🔧 Provider type: ${type}, voiceName: ${voiceId}`);
  180. logToFile(`Provider type: ${type}, voiceId: ${voiceId}`);
  181. const voiceName = getAliyunVoice(voiceId);
  182. const selectedModel = (type !== 'mock') ? getRandomModel() : undefined;
  183. console.log(`🎲 selectedModel: ${selectedModel}`);
  184. // 根据 Provider 类型决定分段策略
  185. let segments;
  186. if (type === 'realtime') {
  187. segments = [text];
  188. console.log(`📝 使用 Qwen Realtime TTS,文本长度 ${text.length} 字符(不需分段)`);
  189. }
  190. else {
  191. segments = splitText(text);
  192. console.log(`📝 文本已分段: ${segments.length} 段`);
  193. }
  194. segments.forEach((seg, i) => {
  195. console.log(` 段落 ${i + 1}: ${seg.length} 字符`);
  196. });
  197. // 并行生成各段音频
  198. const audioFiles = [];
  199. const cloudUrls = [];
  200. const concurrency = type === 'realtime' ? 1 : 2;
  201. for (let i = 0; i < segments.length; i += concurrency) {
  202. const batch = segments.slice(i, i + concurrency);
  203. // 根据类型选择调用方式
  204. let results;
  205. if (type === 'mock') {
  206. results = await Promise.all(batch.map((segment, idx) => provider.synthesize(segment, voiceName, voiceParams, path_1.default.join(audioDir, `segment_${i + idx}.mp3`))));
  207. }
  208. else {
  209. results = await Promise.all(batch.map((segment, idx) => provider.synthesize(segment, voiceName, voiceParams, path_1.default.join(audioDir, `segment_${i + idx}.mp3`), 3, selectedModel)));
  210. }
  211. results.forEach(r => {
  212. if (r.startsWith('cloud:')) {
  213. cloudUrls.push(r.substring(6));
  214. }
  215. else {
  216. audioFiles.push(r);
  217. }
  218. });
  219. }
  220. console.log(`📁 生成了 ${audioFiles.length} 个音频文件, ${cloudUrls.length} 个云端URL`);
  221. let audioUrl = '';
  222. let duration = 0;
  223. let size = 0;
  224. if (cloudUrls.length > 0) {
  225. audioUrl = cloudUrls[0];
  226. console.log('☁️ 使用云端音频 URL:', audioUrl);
  227. }
  228. else if (audioFiles.length > 0) {
  229. const outputPath = path_1.default.join(audioDir, 'output.mp3');
  230. const mergedFile = await audio_merger_1.AudioMerger.merge(audioFiles, outputPath);
  231. const stats = fs_1.default.statSync(mergedFile);
  232. size = stats.size;
  233. duration = await audio_merger_1.AudioMerger.getDuration(mergedFile);
  234. audioUrl = `/uploads/${audioDir.split(/[/\\]/).pop()}/output.mp3`;
  235. }
  236. // 使用 AI 生成标题、摘要和标签
  237. console.log('🤖 使用 AI 生成标题、摘要和标签...');
  238. const [title, summary, tags] = await Promise.all([
  239. ai_summary_service_1.aiSummaryService.generateTitle(text),
  240. ai_summary_service_1.aiSummaryService.generateSummary(text, 200),
  241. ai_summary_service_1.aiSummaryService.extractTags(text),
  242. ]);
  243. const finalAudioUrl = cloudUrls.length > 0 ? cloudUrls[0] : audioUrl;
  244. // 调用完成回调(如果有)
  245. if (onComplete) {
  246. onComplete(finalAudioUrl, duration);
  247. }
  248. console.log('✅ 音频生成完成:', audioId, 'URL:', finalAudioUrl);
  249. }
  250. catch (error) {
  251. console.error('❌ processAudioGeneration 错误:', error);
  252. throw error;
  253. }
  254. }
  255. /**
  256. * 获取音频状态(已禁用,因为不再有 Audio 表)
  257. * TODO: 如需查询状态,需要实现基于文件系统的状态跟踪
  258. */
  259. async function getAudioStatus(audioId) {
  260. // 由于 Audio 表已删除,暂时返回 not_found
  261. // 后续可以实现基于文件系统的状态跟踪
  262. return { status: 'not_found' };
  263. }
  264. // 获取可用音色
  265. function getVoices() {
  266. return exports.VOICES;
  267. }