import { spawn } from "child_process"; import { StoryConfig } from "./config"; import * as path from "path"; import * as fs from "fs"; import ffmpeg from "ffmpeg-static"; import ffprobe from "ffprobe-static"; export function getDuration(file: string): Promise { const ffprobePath: string = (ffprobe as unknown as { path: string }).path; const args = ["-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", file]; const ffprobeProcess = spawn(ffprobePath, args); let duration = ""; ffprobeProcess.stdout.on("data", (data: Buffer) => { duration += data.toString(); }); return new Promise((resolve, reject) => { ffprobeProcess.on("close", (code: number | null) => { if (code === 0) { resolve(parseFloat(duration)); } else { reject(new Error(`ffprobe process exited with code ${code}`)); } }); }); } export async function getChunkDuration(chunkPath: string): Promise { return getDuration(chunkPath); } export async function generateSilence(duration: number, outputFile: string): Promise { const ffmpegPath: string = (ffmpeg as unknown as string) || "ffmpeg"; const args = ["-y", "-f", "lavfi", "-i", `anullsrc=r=44100:cl=stereo:d=${duration}`, outputFile]; const ffmpegProcess = spawn(ffmpegPath, args); return new Promise((resolve, reject) => { ffmpegProcess.on("close", (code: number | null) => { if (code === 0) { resolve(); } else { reject(new Error(`ffmpeg process exited with code ${code}`)); } }); }); } export async function combineAudio(storyName: string, storyConfig: StoryConfig, audioFiles: string[]): Promise { const ffmpegPath: string = (ffmpeg as unknown as string) || "ffmpeg"; const introFile = path.resolve("stories", storyName, storyConfig.config.intro_audio_file); const outroFile = path.resolve("stories", storyName, storyConfig.config.outro_audio_file); const finalAudioDir = path.resolve("stories", storyName, "final_audio"); const tempAudioFile = path.join(finalAudioDir, "temp.mp3"); const finalAudioFile = path.join(finalAudioDir, "final.mp3"); const backgroundMusicFile = path.resolve("stories", storyName, storyConfig.config.background_music_file); fs.mkdirSync(finalAudioDir, { recursive: true }); const allFiles = [introFile, ...audioFiles.map((f) => path.resolve(f)), outroFile]; const fileList = allFiles.map((f) => `file '${f.replace(/'/g, "'\\''")}'`).join("\n"); const listFile = path.resolve("stories", storyName, "filelist.txt"); fs.writeFileSync(listFile, fileList); const concatArgs = ["-y", "-f", "concat", "-safe", "0", "-i", listFile, "-c", "copy", tempAudioFile]; const concatProcess = spawn(ffmpegPath, concatArgs); await new Promise((resolve, reject) => { concatProcess.on("close", (code: number | null) => { if (code === 0) { resolve(); } else { reject(new Error(`ffmpeg concat process exited with code ${code}`)); } }); }); const duration = await getDuration(tempAudioFile); if (!fs.existsSync(backgroundMusicFile)) { await new Promise((resolve, reject) => { const args = ["-y", "-i", tempAudioFile, "-c:a", "libmp3lame", "-q:a", "4", finalAudioFile]; const p = spawn(ffmpegPath, args); p.on("close", (code: number | null) => code === 0 ? resolve() : reject(new Error(`ffmpeg copy failed ${code}`)) ); }); return; } const bgVolume = "0.2"; const mixArgs = [ "-y", "-i", tempAudioFile, "-stream_loop", "-1", "-i", backgroundMusicFile, "-filter_complex", `[1:a]volume=${bgVolume},atrim=0:${duration},asetpts=N/SR/TB[bg];[0:a][bg]amix=inputs=2:duration=first:dropout_transition=0[a]`, "-map", "[a]", "-c:a", "libmp3lame", "-q:a", "4", finalAudioFile, ]; const mixProcess = spawn(ffmpegPath, mixArgs); return new Promise((resolve, reject) => { mixProcess.on("close", (code: number | null) => { if (code === 0) { resolve(); } else { reject(new Error(`ffmpeg mix process exited with code ${code}`)); } }); }); }