aliyun.provider.ts 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. import axios from 'axios';
  2. import fs from 'fs';
  3. import path from 'path';
  4. import { config } from '../../config';
  5. import { VoiceParams } from '../../types';
  6. /**
  7. * 阿里云百炼 Qwen-TTS Provider(同步模式)
  8. *
  9. * 接口限制参考:
  10. * - Qwen-TTS (multimodal-generation): 单次 20,000 字符(同步/流式)
  11. * - CosyVoice (SpeechSynthesizer): 单次 20,000 字符(同步/流式)
  12. * - 传统长文本合成: 80,000 字符(建议 40,000 以内)
  13. * - 传统基础合成: 300 字符(已弃用)
  14. *
  15. * 调用方式:
  16. * - 同步非流式: POST → 直接返回音频 URL
  17. * - SSE 流式: 加 X-DashScope-SSE: enable header
  18. * - ⚠️ 不支持 X-DashScope-Async 异步模式
  19. *
  20. * 当前策略: 使用同步模式 + 1000 字符分段,每段快速返回音频 URL
  21. *
  22. * 文档: https://help.aliyun.com/zh/model-studio/qwen-tts
  23. */
  24. export class AliyunTtsProvider {
  25. private apiKey: string;
  26. private model: string;
  27. private voice: string;
  28. private baseUrl = 'https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation';
  29. constructor() {
  30. this.apiKey = config.dashscope.apiKey;
  31. this.model = config.dashscope.model;
  32. this.voice = config.dashscope.voice;
  33. }
  34. /**
  35. * 语音合成(同步模式:直接返回音频 URL → 下载)
  36. */
  37. async synthesize(
  38. text: string,
  39. voiceId: string,
  40. params: VoiceParams,
  41. outputPath: string,
  42. retries: number = 3,
  43. modelOverride?: string,
  44. ): Promise<string> {
  45. const activeModel = modelOverride || this.model;
  46. let lastError: Error | null = null;
  47. for (let attempt = 1; attempt <= retries; attempt++) {
  48. try {
  49. // 构建请求体
  50. const requestBody: any = {
  51. model: activeModel,
  52. input: {
  53. text: text,
  54. voice: voiceId || this.voice,
  55. language_type: 'Chinese',
  56. },
  57. };
  58. // 使用 instruct 模型时支持指令控制
  59. if (activeModel.includes('instruct')) {
  60. const instructions: string[] = [];
  61. if (params.speed !== 1) {
  62. const speedDesc = params.speed > 1 ? '较快' : '较慢';
  63. instructions.push(`语速${speedDesc}`);
  64. }
  65. if (params.pitch !== 0) {
  66. const pitchDesc = params.pitch > 0 ? '较高' : '较低';
  67. instructions.push(`音调${pitchDesc}`);
  68. }
  69. if (instructions.length > 0) {
  70. requestBody.input.instructions = instructions.join(',');
  71. }
  72. }
  73. console.log(`📤 [Aliyun TTS] 尝试 ${attempt}/${retries}, model: ${activeModel}, voice: ${voiceId || this.voice}, text length: ${text.length}`);
  74. // 同步调用(不加 X-DashScope-Async,Qwen-TTS 不支持异步模式)
  75. const response = await axios.post(this.baseUrl, requestBody, {
  76. headers: {
  77. 'Authorization': `Bearer ${this.apiKey}`,
  78. 'Content-Type': 'application/json',
  79. },
  80. timeout: 60000,
  81. });
  82. // 检查响应
  83. if (response.status !== 200) {
  84. throw new Error(`Aliyun TTS 请求失败: HTTP ${response.status}`);
  85. }
  86. const data = response.data;
  87. if (data.code) {
  88. throw new Error(`Aliyun TTS 错误: ${data.message || JSON.stringify(data)}`);
  89. }
  90. // 获取音频 URL(同步模式直接在 output.audio.url 中返回)
  91. const audioUrl = data.output?.audio?.url;
  92. if (!audioUrl) {
  93. throw new Error(`Aliyun TTS 未返回音频 URL: ${JSON.stringify(data).substring(0, 200)}`);
  94. }
  95. console.log(`🔗 [Aliyun TTS] 获取音频 URL: ${audioUrl.substring(0, 80)}...`);
  96. // 下载音频文件
  97. try {
  98. return await this.downloadAudio(audioUrl, outputPath);
  99. } catch (downloadError: any) {
  100. console.warn(`⚠️ [Aliyun TTS] 音频下载失败: ${downloadError.message}`);
  101. // 下载失败,返回 cloud: URL 标记(后续会处理)
  102. return `cloud:${audioUrl}`;
  103. }
  104. } catch (error: any) {
  105. const errorDetails = error.response?.data || error.message;
  106. const isRateLimit = error.response?.status === 429 || errorDetails?.code === 'Throttling.RateQuota';
  107. const isServerError = error.response?.status >= 500;
  108. console.error(`❌ [Aliyun TTS] 失败 (尝试 ${attempt}/${retries}):`, error.message);
  109. if ((isRateLimit || isServerError) && attempt < retries) {
  110. const waitTime = Math.pow(2, attempt) * 1000;
  111. console.warn(`⏳ 等待 ${waitTime}ms 后重试...`);
  112. await new Promise(resolve => setTimeout(resolve, waitTime));
  113. lastError = new Error(`Aliyun TTS 临时错误: ${error.message}`);
  114. continue;
  115. }
  116. lastError = new Error(`Aliyun TTS 调用失败: ${error.message}`);
  117. }
  118. }
  119. throw lastError || new Error('Aliyun TTS 服务调用失败');
  120. }
  121. /**
  122. * 下载音频文件到本地
  123. */
  124. private async downloadAudio(audioUrl: string, outputPath: string): Promise<string> {
  125. console.log(`⬇️ [Aliyun TTS] 下载音频: ${audioUrl.substring(0, 80)}...`);
  126. const response = await axios.get(audioUrl, {
  127. responseType: 'arraybuffer',
  128. timeout: 120000,
  129. });
  130. const dir = path.dirname(outputPath);
  131. if (!fs.existsSync(dir)) {
  132. fs.mkdirSync(dir, { recursive: true });
  133. }
  134. // 确保输出路径以 .wav 结尾(Qwen-TTS 返回 wav 格式)
  135. const finalPath = outputPath.endsWith('.wav') ? outputPath : outputPath.replace(/\.[^.]+$/, '.wav');
  136. fs.writeFileSync(finalPath, response.data);
  137. const stats = fs.statSync(finalPath);
  138. console.log(`✅ [Aliyun TTS] 下载完成: ${finalPath} (${stats.size} bytes)`);
  139. return finalPath;
  140. }
  141. }