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