# All files / modules/tts audio-merger.ts

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

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

     import fs from 'fs';
    import path from 'path';
    import { exec } from 'child_process';
    import { promisify } from 'util';
    import { FFmpegProcessor } from '../../services/ffmpeg.processor';

    const execAsync = promisify(exec);

    export class AudioMerger {
      // 合并多个音频文件（支持 OSS URL）
      static async merge(inputFiles: string[], outputPath: string): Promise<string> {
        if (inputFiles.length === 0) {
          throw new Error('没有音频文件需要合并');
        }

        if (inputFiles.length === 1) {
          // 单个文件：远程URL直接用FFmpegProcessor上传
          if (inputFiles[0].startsWith('http')) {
            const outputExt = outputPath.split('.').pop() || 'mp3';
            return await FFmpegProcessor.mergeAudio(inputFiles, outputExt);
          }

          // 本地单文件：检查是否需要格式转换
          const inputExt = inputFiles[0].split('.').pop()?.toLowerCase();
          const outputExt = outputPath.split('.').pop()?.toLowerCase();

          if (inputExt !== outputExt) {
            // 格式不同（如 WAV→MP3），用 FFmpeg 转码
            let cmd: string;
            if (outputExt === 'mp3') {
              cmd = `ffmpeg -i "${inputFiles[0]}" -c:a libmp3lame -b:a 192k -y "${outputPath}"`;
            } else {
              cmd = `ffmpeg -i "${inputFiles[0]}" -c copy -y "${outputPath}"`;
            }
            await execAsync(cmd, { timeout: 120000 });
            console.log(`✅ 音频格式转换成功: ${inputExt} → ${outputExt}`);
          } else {
            // 格式相同，直接复制
            if (inputFiles[0] !== outputPath) {
              fs.copyFileSync(inputFiles[0], outputPath);
            }
          }
          return outputPath;
        }

        // 检测是否有远程 URL
        const hasRemoteFiles = inputFiles.some(f => f.startsWith('http'));

        if (hasRemoteFiles) {
          // 使用 FFmpegProcessor 处理远程文件
          const outputExt = outputPath.split('.').pop() || 'mp3';
          return await FFmpegProcessor.mergeAudio(inputFiles, outputExt);
        }

        // 本地文件，使用原有逻辑
        return this.mergeLocalFiles(inputFiles, outputPath);
      }

      // 合并本地音频文件（原有逻辑）
      private static async mergeLocalFiles(inputFiles: string[], outputPath: string): Promise<string> {
        try {
          // 在输出文件同目录创建临时列表文件（避免 Windows /tmp 路径问题）
          const outputDir = path.dirname(outputPath);
          const listFile = path.join(outputDir, 'ffmpeg_concat_list.txt');

          // 使用相对路径避免 Windows 反斜杠问题
          const listContent = inputFiles.map(f => {
            const relativePath = path.relative(outputDir, f).replace(/\\/g, '/');
            return `file '${relativePath}'`;
          }).join('\n');
          fs.writeFileSync(listFile, listContent);

          // 检查输出格式
          const outputExt = outputPath.split('.').pop()?.toLowerCase();

          // 构建 FFmpeg 命令（在工作目录中执行，使相对路径生效）
          let cmd: string;
          if (outputExt === 'mp3') {
            // MP3 需要转码（WAV/PCM -> MP3）
            cmd = `ffmpeg -f concat -safe 0 -i "${listFile}" -c:a libmp3lame -b:a 192k -y "${outputPath}"`;
          } else {
            // 其他格式直接复制
            cmd = `ffmpeg -f concat -safe 0 -i "${listFile}" -c copy -y "${outputPath}"`;
          }

          await execAsync(cmd, { timeout: 300000, cwd: outputDir });

          // 清理临时列表文件
          try { fs.unlinkSync(listFile); } catch {}

          console.log(`✅ 音频合并成功: ${outputPath}`);
          return outputPath;
        } catch (error) {
          console.error('❌ 音频合并失败:', error);
          throw new Error('音频合并失败');
        }
      }

      // 获取音频时长（支持 OSS URL）
      static async getDuration(filePath: string): Promise<number> {
        // 检测是否为远程 URL
        if (filePath.startsWith('http')) {
          return await FFmpegProcessor.getDuration(filePath);
        }

        // 本地文件，使用原有逻辑
        try {
          const cmd = `ffprobe -i "${filePath}" -show_entries format=duration -v quiet -of csv="p=0"`;
          const { stdout } = await execAsync(cmd, { timeout: 10000 });
          // 保留一位小数精度（不再 Math.round 丢失精度）
          return Math.round((parseFloat(stdout.trim()) || 0) * 10) / 10;
        } catch (error) {
          console.error('❌ 获取音频时长失败:', error);
          return 0;
        }
      }
    }