import axios from 'axios'; import fs from 'fs'; import path from 'path'; import { config } from '../../config'; import { VoiceParams } from '../../types'; /** * 阿里云百炼 Qwen-TTS Provider(同步模式) * * 接口限制参考: * - Qwen-TTS (multimodal-generation): 单次 20,000 字符(同步/流式) * - CosyVoice (SpeechSynthesizer): 单次 20,000 字符(同步/流式) * - 传统长文本合成: 80,000 字符(建议 40,000 以内) * - 传统基础合成: 300 字符(已弃用) * * 调用方式: * - 同步非流式: POST → 直接返回音频 URL * - SSE 流式: 加 X-DashScope-SSE: enable header * - ⚠️ 不支持 X-DashScope-Async 异步模式 * * 当前策略: 使用同步模式 + 1000 字符分段,每段快速返回音频 URL * * 文档: https://help.aliyun.com/zh/model-studio/qwen-tts */ export class AliyunTtsProvider { private apiKey: string; private model: string; private voice: string; private baseUrl = 'https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation'; constructor() { this.apiKey = config.dashscope.apiKey; this.model = config.dashscope.model; this.voice = config.dashscope.voice; } /** * 语音合成(同步模式:直接返回音频 URL → 下载) */ async synthesize( text: string, voiceId: string, params: VoiceParams, outputPath: string, retries: number = 3, modelOverride?: string, ): Promise { const activeModel = modelOverride || this.model; let lastError: Error | null = null; for (let attempt = 1; attempt <= retries; attempt++) { try { // 构建请求体 const requestBody: any = { model: activeModel, input: { text: text, voice: voiceId || this.voice, language_type: 'Chinese', }, }; // 使用 instruct 模型时支持指令控制 if (activeModel.includes('instruct')) { const instructions: string[] = []; if (params.speed !== 1) { const speedDesc = params.speed > 1 ? '较快' : '较慢'; instructions.push(`语速${speedDesc}`); } if (params.pitch !== 0) { const pitchDesc = params.pitch > 0 ? '较高' : '较低'; instructions.push(`音调${pitchDesc}`); } if (instructions.length > 0) { requestBody.input.instructions = instructions.join(','); } } console.log(`📤 [Aliyun TTS] 尝试 ${attempt}/${retries}, model: ${activeModel}, voice: ${voiceId || this.voice}, text length: ${text.length}`); // 同步调用(不加 X-DashScope-Async,Qwen-TTS 不支持异步模式) const response = await axios.post(this.baseUrl, requestBody, { headers: { 'Authorization': `Bearer ${this.apiKey}`, 'Content-Type': 'application/json', }, timeout: 60000, }); // 检查响应 if (response.status !== 200) { throw new Error(`Aliyun TTS 请求失败: HTTP ${response.status}`); } const data = response.data; if (data.code) { throw new Error(`Aliyun TTS 错误: ${data.message || JSON.stringify(data)}`); } // 获取音频 URL(同步模式直接在 output.audio.url 中返回) const audioUrl = data.output?.audio?.url; if (!audioUrl) { throw new Error(`Aliyun TTS 未返回音频 URL: ${JSON.stringify(data).substring(0, 200)}`); } console.log(`🔗 [Aliyun TTS] 获取音频 URL: ${audioUrl.substring(0, 80)}...`); // 下载音频文件 try { return await this.downloadAudio(audioUrl, outputPath); } catch (downloadError: any) { console.warn(`⚠️ [Aliyun TTS] 音频下载失败: ${downloadError.message}`); // 下载失败,返回 cloud: URL 标记(后续会处理) return `cloud:${audioUrl}`; } } catch (error: any) { const errorDetails = error.response?.data || error.message; const isRateLimit = error.response?.status === 429 || errorDetails?.code === 'Throttling.RateQuota'; const isServerError = error.response?.status >= 500; console.error(`❌ [Aliyun TTS] 失败 (尝试 ${attempt}/${retries}):`, error.message); if ((isRateLimit || isServerError) && attempt < retries) { const waitTime = Math.pow(2, attempt) * 1000; console.warn(`⏳ 等待 ${waitTime}ms 后重试...`); await new Promise(resolve => setTimeout(resolve, waitTime)); lastError = new Error(`Aliyun TTS 临时错误: ${error.message}`); continue; } lastError = new Error(`Aliyun TTS 调用失败: ${error.message}`); } } throw lastError || new Error('Aliyun TTS 服务调用失败'); } /** * 下载音频文件到本地 */ private async downloadAudio(audioUrl: string, outputPath: string): Promise { console.log(`⬇️ [Aliyun TTS] 下载音频: ${audioUrl.substring(0, 80)}...`); const response = await axios.get(audioUrl, { responseType: 'arraybuffer', timeout: 120000, }); const dir = path.dirname(outputPath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } // 确保输出路径以 .wav 结尾(Qwen-TTS 返回 wav 格式) const finalPath = outputPath.endsWith('.wav') ? outputPath : outputPath.replace(/\.[^.]+$/, '.wav'); fs.writeFileSync(finalPath, response.data); const stats = fs.statSync(finalPath); console.log(`✅ [Aliyun TTS] 下载完成: ${finalPath} (${stats.size} bytes)`); return finalPath; } }