"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.VOICES = void 0; exports.getAliyunVoice = getAliyunVoice; exports.shouldUseLongText = shouldUseLongText; exports.splitText = splitText; exports.generateAudio = generateAudio; exports.getAudioStatus = getAudioStatus; exports.getVoices = getVoices; const path_1 = __importDefault(require("path")); const fs_1 = __importDefault(require("fs")); const uuid_1 = require("uuid"); const config_1 = require("../../config"); const aliyun_provider_1 = require("./aliyun.provider"); const aliyun_realtime_provider_1 = require("./aliyun-realtime.provider"); const mock_provider_1 = require("./mock.provider"); const audio_merger_1 = require("./audio-merger"); const ai_summary_service_1 = require("./ai-summary.service"); // 日志文件路径 const LOG_FILE = path_1.default.join(process.cwd(), 'tts-debug.log'); function logToFile(msg) { const timestamp = new Date().toISOString(); fs_1.default.appendFileSync(LOG_FILE, `[${timestamp}] ${msg}\n`); } // 可用音色列表(使用阿里云官方音色) exports.VOICES = [ { id: 'cherry', name: '芊悦', gender: 'female', description: '阳光积极、亲切自然' }, { id: 'serena', name: '苏瑶', gender: 'female', description: '温柔女声' }, { id: 'ethan', name: '晨煦', gender: 'male', description: '阳光温暖、活力男声' }, { id: 'chelsie', name: '千雪', gender: 'female', description: '二次元虚拟女友' }, { id: 'momo', name: '茉兔', gender: 'female', description: '撒娇搞怪' }, { id: 'vivian', name: '十三', gender: 'female', description: '可爱小暴躁' }, { id: 'moon', name: '月白', gender: 'male', description: '率性帅气' }, { id: 'maia', name: '四月', gender: 'female', description: '知性温柔' }, { id: 'kai', name: '凯', gender: 'male', description: '舒缓放松' }, { id: 'nofish', name: '不吃鱼', gender: 'male', description: '不会翘舌音' }, ]; // 前端音色 ID 到阿里云音色的映射 const VOICE_MAPPING = { cherry: 'Cherry', serena: 'Serena', ethan: 'Ethan', chelsie: 'Chelsie', momo: 'Momo', vivian: 'Vivian', moon: 'Moon', maia: 'Maia', kai: 'Kai', nofish: 'Nofish', }; // 获取阿里云音色名称 function getAliyunVoice(voiceId) { return VOICE_MAPPING[voiceId] || 'Cherry'; } // 判断是否使用长文本模式(>5000字符且启用realtime) // 注意:WebSocket realtime 模式需要特殊的API权限和配置,如果连接失败会导致生成失败。 // 暂时强制禁用,使用 HTTP 分段模式 function shouldUseLongText(text) { return false; // 强制返回 false,禁用 realtime 模式 } // 文本分段 - 阿里云 TTS 限制 600 字符,增加到 550 留安全余量 function splitText(text, maxLength = 550) { const segments = []; let current = ''; // 清理文本,移除可能导致问题的字符 const cleanText = text.replace(/\r/g, ''); // 按段落分割 const paragraphs = cleanText.split(/\n+/); for (const para of paragraphs) { if ((current + para).length <= maxLength) { current += (current ? '\n' : '') + para; } else { if (current) segments.push(current); // 如果段落本身超长,按句子分割 if (para.length > maxLength) { // 使用更安全的分割方式 const sentences = para.match(/[^。!?;]+[。!?;]?/g) || [para]; current = ''; for (const sentence of sentences) { if (sentence.length === 0) continue; if ((current + sentence).length <= maxLength) { current += sentence; } else { if (current) segments.push(current); // 如果句子本身超长,强制分割 if (sentence.length > maxLength) { for (let i = 0; i < sentence.length; i += maxLength) { segments.push(sentence.slice(i, i + maxLength)); } current = ''; } else { current = sentence; } } } } else { current = para; } } } if (current) segments.push(current); // 验证每段长度不超过限制(安全检查) const safeLimit = 550; const validatedSegments = segments.map((seg, idx) => { if (seg.length > safeLimit) { console.warn(`⚠️ 段落 ${idx + 1} 长度 ${seg.length} 超过限制,强制截断`); return seg.substring(0, safeLimit); } return seg; }); return validatedSegments; } // TTS Provider 工厂 function getTtsProvider(text, voiceId) { const useLongText = shouldUseLongText(text); // 文本超长时使用 WebSocket 实时合成 if (useLongText && config_1.config.dashscope.apiKey) { console.log('🔊 文本超过5000字符,使用 Qwen Realtime TTS 流式合成'); return { provider: new aliyun_realtime_provider_1.AliyunRealtimeTtsProvider(), type: 'realtime' }; } // 优先使用阿里云百炼 Qwen TTS if (config_1.config.dashscope.apiKey) { console.log('🔊 使用阿里云百炼 Qwen TTS 服务'); return { provider: new aliyun_provider_1.AliyunTtsProvider(), type: 'http' }; } // 降级到模拟服务 console.log('🔊 使用模拟 TTS 服务(无 API Key)'); return { provider: new mock_provider_1.MockTtsProvider(), type: 'mock' }; } // 随机选择 TTS 模型 function getRandomModel() { const models = config_1.config.dashscope.ttsModels; const model = models[Math.floor(Math.random() * models.length)]; console.log(`🎲 随机选择 TTS 模型: ${model}`); return model; } // 生成音频(异步模式,不再创建 Audio 记录) async function generateAudio(userId, text, voiceId, voiceParams, onComplete) { // 使用 UUID 作为音频 ID const audioId = (0, uuid_1.v4)(); const audioDir = path_1.default.join(config_1.config.upload.dir, audioId); if (!fs_1.default.existsSync(audioDir)) { fs_1.default.mkdirSync(audioDir, { recursive: true }); } console.log('📝 开始音频生成:', audioId, '文本长度:', text.length); // 异步处理音频生成 processAudioGeneration(audioId, text, voiceId, voiceParams, audioDir, onComplete).catch(error => { const errMsg = `❌ 异步音频生成失败: ${error.message}`; console.error(errMsg); console.error('❌ 错误堆栈:', error.stack); logToFile(errMsg + '\n' + error.stack); }); // 立即返回音频ID和状态(audioUrl 为空,生成完成后通过回调更新) return { audioId, audioUrl: '', }; } /** * 异步处理音频生成 */ async function processAudioGeneration(audioId, text, voiceId, voiceParams, audioDir, onComplete) { const logMsg = `🔄 开始处理音频 ID: ${audioId}, 文本长度: ${text.length}, voiceId: ${voiceId}`; console.log(logMsg); logToFile(logMsg); try { const { provider, type } = getTtsProvider(text, voiceId); console.log(`🔧 Provider type: ${type}, voiceName: ${voiceId}`); logToFile(`Provider type: ${type}, voiceId: ${voiceId}`); const voiceName = getAliyunVoice(voiceId); const selectedModel = (type !== 'mock') ? getRandomModel() : undefined; console.log(`🎲 selectedModel: ${selectedModel}`); // 根据 Provider 类型决定分段策略 let segments; if (type === 'realtime') { segments = [text]; console.log(`📝 使用 Qwen Realtime TTS,文本长度 ${text.length} 字符(不需分段)`); } else { segments = splitText(text); console.log(`📝 文本已分段: ${segments.length} 段`); } segments.forEach((seg, i) => { console.log(` 段落 ${i + 1}: ${seg.length} 字符`); }); // 并行生成各段音频 const audioFiles = []; const cloudUrls = []; const concurrency = type === 'realtime' ? 1 : 2; for (let i = 0; i < segments.length; i += concurrency) { const batch = segments.slice(i, i + concurrency); // 根据类型选择调用方式 let results; if (type === 'mock') { results = await Promise.all(batch.map((segment, idx) => provider.synthesize(segment, voiceName, voiceParams, path_1.default.join(audioDir, `segment_${i + idx}.mp3`)))); } else { results = await Promise.all(batch.map((segment, idx) => provider.synthesize(segment, voiceName, voiceParams, path_1.default.join(audioDir, `segment_${i + idx}.mp3`), 3, selectedModel))); } results.forEach(r => { if (r.startsWith('cloud:')) { cloudUrls.push(r.substring(6)); } else { audioFiles.push(r); } }); } console.log(`📁 生成了 ${audioFiles.length} 个音频文件, ${cloudUrls.length} 个云端URL`); let audioUrl = ''; let duration = 0; let size = 0; if (cloudUrls.length > 0) { audioUrl = cloudUrls[0]; console.log('☁️ 使用云端音频 URL:', audioUrl); } else if (audioFiles.length > 0) { const outputPath = path_1.default.join(audioDir, 'output.mp3'); const mergedFile = await audio_merger_1.AudioMerger.merge(audioFiles, outputPath); const stats = fs_1.default.statSync(mergedFile); size = stats.size; duration = await audio_merger_1.AudioMerger.getDuration(mergedFile); audioUrl = `/uploads/${audioDir.split(/[/\\]/).pop()}/output.mp3`; } // 使用 AI 生成标题、摘要和标签 console.log('🤖 使用 AI 生成标题、摘要和标签...'); const [title, summary, tags] = await Promise.all([ ai_summary_service_1.aiSummaryService.generateTitle(text), ai_summary_service_1.aiSummaryService.generateSummary(text, 200), ai_summary_service_1.aiSummaryService.extractTags(text), ]); const finalAudioUrl = cloudUrls.length > 0 ? cloudUrls[0] : audioUrl; // 调用完成回调(如果有) if (onComplete) { onComplete(finalAudioUrl, duration); } console.log('✅ 音频生成完成:', audioId, 'URL:', finalAudioUrl); } catch (error) { console.error('❌ processAudioGeneration 错误:', error); throw error; } } /** * 获取音频状态(已禁用,因为不再有 Audio 表) * TODO: 如需查询状态,需要实现基于文件系统的状态跟踪 */ async function getAudioStatus(audioId) { // 由于 Audio 表已删除,暂时返回 not_found // 后续可以实现基于文件系统的状态跟踪 return { status: 'not_found' }; } // 获取可用音色 function getVoices() { return exports.VOICES; }