# All files / modules/tts aliyun.provider.ts

0% Statements 0/206
0% Branches 0/1
0% Functions 0/1
0% Lines 0/206

Press n or j to go to the next uncovered block, b, p or k for the previous block.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    172
    173
    174
    175
    176
    177
    178
    179
    180
    181
    182
    183
    184
    185
    186
    187
    188
    189
    190
    191
    192
    193
    194
    195
    196
    197
    198
    199
    200
    201
    202
    203
    204
    205
    206
    207
    208
    209
    210
    211
    212
    213
    214
    215
    216
    217
    218
    219
    220
    221
    222
    223
    224
    225
    226
    227
    228
    229
    230
    231
    232
    233
    234
    235
    236
    237
    238
    239
    240
    241
    242
    243
    244
    245
    246
    247
    248
    249
    250
    251
    252
    253
    254
    255
    256
    257
    258
    259
    260
    261
    262
    263
    264
    265
    266
    267
    268
    269
    270
    271
    272
    273
    274
    275
    276
    277
    278
    279
    280
    281
    282
    283
    284
    285
    286
    287
    288
    289
    290
    291

     import axios from 'axios';
    import fs from 'fs';
    import path from 'path';
    import { config } from '../../config';
    import { VoiceParams } from '../../types';
    import { ITtsProvider } from './provider.interface';
    import { withAiLog } from '../../services/ai-call-logger';

    /**
     * 阿里云百炼 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 implements ITtsProvider {
      readonly name: string;
      readonly vendor = 'bailian';
      readonly mode = 'sync' as const;
      maxTextLength = 0; // 由 models.json 配置，未配置时用默认 1000
      readonly concurrency = 2;
      readonly modelId: string;
      readonly ttsApiPath: string;

      private apiKey: string;
      private voice: string;

      /**
       * @param vendorKey  供应商 key（如 'bailian'）
       * @param apiKey     API Key
       * @param modelId    TTS 模型 ID（来自 models.json，如 'qwen3-tts-instruct-flash'）
       * @param ttsApiPath TTS API 完整路径（来自 models.json vendor 的 ttsApiPath）
       */
      constructor(vendorKey: string = 'bailian', apiKey?: string, modelId?: string, ttsApiPath?: string, maxTextLength?: number) {
        this.name = `${vendorKey}-tts`;
        // 从 models.json 获取默认 TTS 模型（兜底：qwen3-tts-instruct-flash）
        this.modelId = modelId || (config.models as any).tts?.defaultModel || 'qwen3-tts-instruct-flash';
        this.ttsApiPath = ttsApiPath || 'https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation';
        if (maxTextLength) this.maxTextLength = maxTextLength;
        if (apiKey) {
          this.apiKey = apiKey;
        } else {
          // 从 models.json 获取对应 vendor 的 apiKey
          const vendorConfig = (config.models as any).vendors?.[vendorKey];
          this.apiKey = vendorConfig?.apiKey || '';
        }
        // 默认音色从 models.json TTS 配置读取
        this.voice = (config.models as any).tts?.defaultVoice || 'Cherry';
      }

      /**
       * 语音合成（CosyVoice SSE 流式，千问同步非流式）
       */
      async synthesize(
        text: string,
        voiceId: string,
        params: VoiceParams,
        outputPath: string,
        retries: number = 3,
        modelOverride?: string,
      ): Promise<string> {
        const activeModel = modelOverride || this.modelId;
        const isCosyVoice = activeModel.includes('cosyvoice');
        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,
                ...(isCosyVoice ? {} : { language_type: 'Chinese' }),
              },
            };
            if (isCosyVoice) {
              requestBody.input.format = 'mp3';
              requestBody.input.sample_rate = 24000;
              const parts: string[] = [];
              // Instrut 情感/场景控制（优先级最高，放在最前面）
              if ((params as any).instructText) parts.push((params as any).instructText);
              if (params.speed !== undefined && params.speed !== 1) parts.push(`语速${params.speed > 1 ? '偏快' : '偏慢'}`);
              if (params.pitch !== undefined && params.pitch !== 0) parts.push(`音调${params.pitch > 0 ? '偏高' : '偏低'}`);
              if (params.volume !== undefined && params.volume !== 50) parts.push(`音量${params.volume > 50 ? '较大' : '较小'}`);
              if (parts.length > 0) requestBody.input.instructions = parts.join('，') + '。';
            }
            if (!isCosyVoice && activeModel.includes('instruct')) {
              const instructions: string[] = [];
              if ((params as any).instructText) instructions.push((params as any).instructText);
              if (params.speed !== 1) instructions.push(`语速${params.speed > 1 ? '较快' : '较慢'}`);
              if (params.pitch !== 0) instructions.push(`音调${params.pitch > 0 ? '较高' : '较低'}`);
              if (instructions.length > 0) requestBody.input.instructions = instructions.join('，');
            }

            console.log(`📤 [Aliyun TTS] ${isCosyVoice?'CosyVoice SSE':'Qwen'} ${attempt}/${retries}, model: ${activeModel}, voice: ${voiceId || this.voice}, text: ${text.length}字`);

            // CosyVoice: SSE 流式，边收边存，无超时
            if (isCosyVoice) {
              return await this.synthesizeStream(requestBody, outputPath);
            }

            // 千问: 同步非流式
            const response = await withAiLog(
              () => axios.post(this.ttsApiPath, requestBody, {
                headers: { 'Authorization': `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
                timeout: 600000,
              }),
              { callType: 'tts_synthesize', provider: this.vendor, model: activeModel, textLen: text.length }
            );

            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)}`);
            const audioUrl = data.output?.audio?.url;
            if (!audioUrl) throw new Error(`Aliyun TTS 未返回音频 URL`);

            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 服务调用失败');
      }

      /**
       * CosyVoice SSE 流式合成：边生成边接收，无超时限制
       */
      private async synthesizeStream(requestBody: any, outputPath: string): Promise<string> {
        console.log(`🔊 [Aliyun SSE] 请求: ${this.ttsApiPath}, model=${this.modelId}, voice=${requestBody.input?.voice || 'default'}, textLen=${requestBody.input?.text?.length || 0}`);
        const response = await axios.post(this.ttsApiPath, requestBody, {
          headers: {
            'Authorization': `Bearer ${this.apiKey}`,
            'Content-Type': 'application/json',
            'X-DashScope-SSE': 'enable',
          },
          responseType: 'stream',
          timeout: 0,
        });
        console.log(`📡 [Aliyun SSE] 响应: HTTP ${response.status}, contentType=${response.headers['content-type']}`);

        if (response.status !== 200) {
          const body = await new Promise<string>((r) => {
            let d = '';
            response.data.on('data', (c: Buffer) => d += c.toString());
            response.data.on('end', () => r(d));
          });
          console.error(`❌ [Aliyun SSE] HTTP ${response.status}: ${body.substring(0, 500)}`);
          throw new Error(`Aliyun SSE HTTP ${response.status}: ${body.substring(0, 200)}`);
        }

        return new Promise((resolve, reject) => {
          const chunks: Buffer[] = [];
          let buffer = '';
          let sseEventCount = 0;
          let audioEventCount = 0;
          let lastErrorMsg = '';
          const lastRawEvents: string[] = []; // 保留最后5个事件用于调试

          response.data.on('data', (chunk: Buffer) => {
            buffer += chunk.toString();
            const lines = buffer.split('\n');
            buffer = lines.pop() || '';

            for (const line of lines) {
              if (line.startsWith('data:')) {
                sseEventCount++;
                const rawData = line.substring(5).trim();

                // 调试：记录最后5个事件
                lastRawEvents.push(rawData.substring(0, 300));
                if (lastRawEvents.length > 5) lastRawEvents.shift();

                try {
                  const json = JSON.parse(rawData);

                  // 检查是否为错误响应
                  if (json.code || json.message) {
                    lastErrorMsg = `[${json.code || 'unknown'}] ${json.message || ''}`;
                    console.warn(`⚠️ [Aliyun SSE] 错误事件: ${lastErrorMsg}, raw=${rawData.substring(0, 200)}`);
                    continue;
                  }

                  // 提取音频数据
                  const audioData = json.output?.audio?.data;
                  if (audioData) {
                    audioEventCount++;
                    chunks.push(Buffer.from(audioData, 'base64'));
                  } else {
                    // 非音频事件（如 task_id、运行状态等），记录便于调试
                    const snippet = JSON.stringify(json).substring(0, 150);
                    console.log(`📡 [Aliyun SSE] 非音频事件 #${sseEventCount}: ${snippet}`);
                  }
                } catch {
                  console.warn(`⚠️ [Aliyun SSE] JSON解析失败: raw=${rawData.substring(0, 200)}`);
                }
              }
            }
          });

          response.data.on('end', () => {
            console.log(`[Aliyun SSE] 流结束: totalEvents=${sseEventCount}, audioEvents=${audioEventCount}, chunks=${chunks.length}`);
            if (chunks.length === 0) {
              const diag = [
                `eventCount=${sseEventCount}`,
                `audioCount=${audioEventCount}`,
                `lastError=${lastErrorMsg || '无错误事件'}`,
                `lastEvents=${JSON.stringify(lastRawEvents)}`,
              ].join(', ');
              reject(new Error(`CosyVoice SSE 未收到音频数据（诊断: ${diag}）`));
              return;
            }
            const audioBuffer = Buffer.concat(chunks);
            const dir = path.dirname(outputPath);
            if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
            fs.writeFileSync(outputPath, audioBuffer);
            console.log(`✅ [Aliyun SSE] 流式完成: ${outputPath} (${audioBuffer.length} bytes)`);
            resolve(outputPath);
          });

          response.data.on('error', (err: any) => {
            console.error(`❌ [Aliyun SSE] Stream error: ${err.message}, chunks=${chunks.length}, events=${sseEventCount}`);
            reject(err);
          });
        });
      }

      /**
       * 下载音频文件到本地
       */
      private async downloadAudio(audioUrl: string, outputPath: string): Promise<string> {
        console.log(`⬇️ [Aliyun TTS] 下载音频: ${audioUrl.substring(0, 80)}...`);

        const response = await withAiLog(
          () => axios.get(audioUrl, {
            responseType: 'arraybuffer',
            timeout: 600000,
          }),
          { callType: 'tts_download', provider: this.vendor, model: this.modelId }
        );

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