File size: 3,742 Bytes
3165afb
2cae2a9
 
3165afb
 
 
 
 
 
2cae2a9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d5d9687
2cae2a9
 
 
 
 
3165afb
2cae2a9
 
 
 
 
 
3165afb
d5d9687
2cae2a9
 
3165afb
 
2cae2a9
 
 
 
 
 
 
 
 
 
 
d5d9687
 
3165afb
2cae2a9
 
 
3165afb
2cae2a9
 
 
 
 
 
 
 
 
 
 
 
 
3165afb
2cae2a9
 
 
 
 
 
 
3165afb
74dba9a
2cae2a9
 
 
3165afb
 
2cae2a9
 
 
 
 
 
 
3165afb
2cae2a9
3165afb
 
2cae2a9
3165afb
2cae2a9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import { existsSync } from "node:fs"
import path from "node:path"

import { v4 as uuidv4 } from "uuid"
import ffmpeg, { FfmpegCommand } from "fluent-ffmpeg"
import { getRandomDirectory, removeTemporaryFiles, writeBase64ToFile } from "@aitube/io"
import { addBase64Header } from "@aitube/encoders"

import { getMediaInfo } from "../analyze/getMediaInfo"

export type ConcatenateAudioOptions = {
  // those are base64 audio strings!
  audioTracks?: string[]; // base64
  audioFilePaths?: string[]; // path
  crossfadeDurationInSec?: number;
  outputFormat?: string; // "wav" or "mp3"
  output?: string;
}

export type ConcatenateAudioOutput = {
  filepath: string;
  durationInSec: number;
}

export async function concatenateAudio({
  output,
  audioTracks = [],
  audioFilePaths = [],
  crossfadeDurationInSec = 10,
  outputFormat = "mp3"
}: ConcatenateAudioOptions): Promise<ConcatenateAudioOutput> {
  if (!Array.isArray(audioTracks)) {
    throw new Error("Audios must be provided in an array");
  }

  const tempDir = await getRandomDirectory()

  // console.log("  |- created tmp dir")

  // trivial case: there is only one audio to concatenate!
  if (audioTracks.length === 1 && audioTracks[0]) {
    const audioTrack = audioTracks[0]
    const outputFilePath = path.join(tempDir, `audio_0.${outputFormat}`)
    await writeBase64ToFile(addBase64Header(audioTrack, outputFormat), outputFilePath)

    // console.log("  |- there is only one track! so.. returning that")
    const { durationInSec } = await getMediaInfo(outputFilePath)
    return { filepath: outputFilePath, durationInSec }
  }

  if (audioFilePaths.length === 1) {
    throw new Error("concatenating a single audio file path is not implemented yet")
  }
  
  try {

    let i = 0
    for (const track of audioTracks) {
      if (!track) { continue }
      const audioFilePath = path.join(tempDir, `audio_${++i}.${outputFormat}`);
      await writeBase64ToFile(addBase64Header(track, outputFormat), audioFilePath)
      
      audioFilePaths.push(audioFilePath);
    }

    // TODO: convert this to an async filter using promises
    audioFilePaths = audioFilePaths.filter((audio) => existsSync(audio))

    const outputFilePath = output ?? path.join(tempDir, `${uuidv4()}.${outputFormat}`);
  
    let filterComplex = "";
    let prevLabel = "0";

    for (let i = 0; i < audioFilePaths.length - 1; i++) {
      const nextLabel = `a${i}`;
      filterComplex += `[${prevLabel}][${i + 1}]acrossfade=d=${crossfadeDurationInSec}:c1=tri:c2=tri[${nextLabel}];`;
      prevLabel = nextLabel;
    }

    /*
    console.log("  |- concatenateAudio(): DEBUG:", {
      tempDir,
      audioFilePaths,
      outputFilePath,
      filterComplex,
      prevLabel
    })
    */
 
    let cmd: FfmpegCommand = ffmpeg() // .outputOptions('-vn');

    audioFilePaths.forEach((audio, i) => {
      cmd = cmd.input(audio)
    })

    const promise = new Promise<ConcatenateAudioOutput>((resolve, reject) => {
      cmd = cmd
        .on('error', reject)
        .on('end', async () => {
          try {
            const { durationInSec } = await getMediaInfo(outputFilePath);
            
            // console.log("concatenation ended! see ->", outputFilePath)
            resolve({ filepath: outputFilePath, durationInSec })

          } catch (err) {
            reject(err)
          }
        })
        .complexFilter(filterComplex, prevLabel)
        .save(outputFilePath);
    });

    const result = await promise

    return result
  } catch (error) {
    console.error(`Failed to assemble audio!`)
    console.error(error)
    throw new Error(`Failed to assemble audio: ${(error as Error)?.message || error}`);
  } finally {
    await removeTemporaryFiles(audioFilePaths)
  }
}