| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267 |
- "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;
- }
|