126 lines
4.1 KiB
TypeScript
126 lines
4.1 KiB
TypeScript
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<number> {
|
|
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<number>((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<number> {
|
|
return getDuration(chunkPath);
|
|
}
|
|
|
|
export async function generateSilence(duration: number, outputFile: string): Promise<void> {
|
|
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<void>((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<void> {
|
|
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<void>((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<void>((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<void>((resolve, reject) => {
|
|
mixProcess.on("close", (code: number | null) => {
|
|
if (code === 0) {
|
|
resolve();
|
|
} else {
|
|
reject(new Error(`ffmpeg mix process exited with code ${code}`));
|
|
}
|
|
});
|
|
});
|
|
}
|