refactor: invoke ffmpeg without deprecated wrapper
This commit is contained in:
61
src/audio/ffmpegProcess.ts
Normal file
61
src/audio/ffmpegProcess.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { spawn } from "child_process";
|
||||||
|
|
||||||
|
export interface MuxFfmpegArgsOptions {
|
||||||
|
inputs: string[];
|
||||||
|
filter: string;
|
||||||
|
output: string;
|
||||||
|
codec: string;
|
||||||
|
audioFrequency?: number;
|
||||||
|
audioChannels?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds ffmpeg argument array for muxing audio clips.
|
||||||
|
*/
|
||||||
|
export function buildMuxFfmpegArgs(options: MuxFfmpegArgsOptions): string[] {
|
||||||
|
const args: string[] = ["-y"];
|
||||||
|
|
||||||
|
for (const input of options.inputs) {
|
||||||
|
args.push("-i", input);
|
||||||
|
}
|
||||||
|
|
||||||
|
args.push("-filter_complex", options.filter);
|
||||||
|
args.push("-map", "[out]");
|
||||||
|
args.push("-codec:a", options.codec);
|
||||||
|
|
||||||
|
if (options.audioFrequency !== undefined) {
|
||||||
|
args.push("-ar", String(options.audioFrequency));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.audioChannels !== undefined) {
|
||||||
|
args.push("-ac", String(options.audioChannels));
|
||||||
|
}
|
||||||
|
|
||||||
|
args.push(options.output);
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs ffmpeg with the given arguments.
|
||||||
|
* Resolves on successful (code 0) exit, rejects on error or non-zero exit.
|
||||||
|
*/
|
||||||
|
export function runFfmpeg(args: string[]): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const proc = spawn("ffmpeg", args, {
|
||||||
|
stdio: ["ignore", "inherit", "inherit"],
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on("close", (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error(`ffmpeg exited with code ${code}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on("error", (err) => {
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import ffmpeg from "fluent-ffmpeg";
|
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import { buildMuxFfmpegArgs, runFfmpeg } from "./audio/ffmpegProcess";
|
||||||
|
|
||||||
const recordingsDir = process.env.RECORDINGS_DIR ?? "./recordings";
|
const recordingsDir = process.env.RECORDINGS_DIR ?? "./recordings";
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ async function startMuxingToAup3() {
|
|||||||
|
|
||||||
// Check if OGG file has valid header (starts with "OggS")
|
// Check if OGG file has valid header (starts with "OggS")
|
||||||
const oggBuffer = fs.readFileSync(oggPath);
|
const oggBuffer = fs.readFileSync(oggPath);
|
||||||
const oggHeader = oggBuffer.slice(0, 4).toString();
|
const oggHeader = oggBuffer.subarray(0, 4).toString();
|
||||||
if (oggHeader !== "OggS") {
|
if (oggHeader !== "OggS") {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[muxer-aup3] Skipping invalid OGG file (bad header): ${oggPath}`,
|
`[muxer-aup3] Skipping invalid OGG file (bad header): ${oggPath}`,
|
||||||
@@ -116,15 +116,12 @@ async function startMuxingToAup3() {
|
|||||||
`[muxer-aup3] Found ${clips.length} clips. Base timestamp: ${globalStartTime}`,
|
`[muxer-aup3] Found ${clips.length} clips. Base timestamp: ${globalStartTime}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const command = ffmpeg();
|
|
||||||
const filterParts: string[] = [];
|
const filterParts: string[] = [];
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[muxer-aup3] Creating audio filters for ${clips.length} clips...`,
|
`[muxer-aup3] Creating audio filters for ${clips.length} clips...`,
|
||||||
);
|
);
|
||||||
clips.forEach((clip, index) => {
|
clips.forEach((clip, index) => {
|
||||||
command.input(clip.oggPath);
|
|
||||||
|
|
||||||
// Calculate delay relative to the global start time
|
// Calculate delay relative to the global start time
|
||||||
const delayMs = clip.meta.startTime - globalStartTime;
|
const delayMs = clip.meta.startTime - globalStartTime;
|
||||||
|
|
||||||
@@ -154,33 +151,29 @@ async function startMuxingToAup3() {
|
|||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const wavFilename = path.join(recordingsDir, `muxed-${timestamp}.wav`);
|
const wavFilename = path.join(recordingsDir, `muxed-${timestamp}.wav`);
|
||||||
const aup3Filename = path.join(recordingsDir, `muxed-${timestamp}.aup3`);
|
const aup3Filename = path.join(recordingsDir, `muxed-${timestamp}.aup3`);
|
||||||
|
const inputs = clips.map((clip) => clip.oggPath);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[muxer-aup3] Combining clips to WAV. This might take a while...`,
|
`[muxer-aup3] Combining clips to WAV. This might take a while...`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Using fluent-ffmpeg's complexFilter
|
try {
|
||||||
command
|
const args = buildMuxFfmpegArgs({
|
||||||
.complexFilter(filterParts, "out")
|
inputs,
|
||||||
.audioCodec("pcm_s16le")
|
filter: filterParts.join(";"),
|
||||||
.audioFrequency(44100)
|
output: wavFilename,
|
||||||
.audioChannels(2)
|
codec: "pcm_s16le",
|
||||||
.save(wavFilename)
|
audioFrequency: 44100,
|
||||||
.on("progress", (progress) => {
|
audioChannels: 2,
|
||||||
if (progress.percent) {
|
});
|
||||||
console.log(
|
|
||||||
`[muxer-aup3] WAV Progress: ${progress.percent.toFixed(2)}%`,
|
await runFfmpeg(args);
|
||||||
);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.on("end", () => {
|
|
||||||
console.log(`[muxer-aup3] WAV file created: ${wavFilename}`);
|
console.log(`[muxer-aup3] WAV file created: ${wavFilename}`);
|
||||||
console.log(`[muxer-aup3] Creating AUP3 project file...`);
|
console.log(`[muxer-aup3] Creating AUP3 project file...`);
|
||||||
createAup3Project(wavFilename, aup3Filename, clips, globalStartTime);
|
createAup3Project(wavFilename, aup3Filename, clips, globalStartTime);
|
||||||
})
|
} catch (err) {
|
||||||
.on("error", (err) => {
|
|
||||||
console.error(`[muxer-aup3] FFmpeg Error:`, err);
|
console.error(`[muxer-aup3] FFmpeg Error:`, err);
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createAup3Project(
|
function createAup3Project(
|
||||||
|
|||||||
33
src/muxer.ts
33
src/muxer.ts
@@ -1,6 +1,6 @@
|
|||||||
import ffmpeg from "fluent-ffmpeg";
|
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import { buildMuxFfmpegArgs, runFfmpeg } from "./audio/ffmpegProcess";
|
||||||
|
|
||||||
const recordingsDir = process.env.RECORDINGS_DIR ?? "./recordings";
|
const recordingsDir = process.env.RECORDINGS_DIR ?? "./recordings";
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ async function startMuxing() {
|
|||||||
|
|
||||||
// Check if OGG file has valid header (starts with "OggS")
|
// Check if OGG file has valid header (starts with "OggS")
|
||||||
const oggBuffer = fs.readFileSync(oggPath);
|
const oggBuffer = fs.readFileSync(oggPath);
|
||||||
const oggHeader = oggBuffer.slice(0, 4).toString();
|
const oggHeader = oggBuffer.subarray(0, 4).toString();
|
||||||
if (oggHeader !== "OggS") {
|
if (oggHeader !== "OggS") {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[muxer] Skipping invalid OGG file (bad header): ${oggPath}`,
|
`[muxer] Skipping invalid OGG file (bad header): ${oggPath}`,
|
||||||
@@ -114,13 +114,10 @@ async function startMuxing() {
|
|||||||
`[muxer] Found ${clips.length} clips. Base timestamp: ${globalStartTime}`,
|
`[muxer] Found ${clips.length} clips. Base timestamp: ${globalStartTime}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const command = ffmpeg();
|
|
||||||
const filterParts: string[] = [];
|
const filterParts: string[] = [];
|
||||||
|
|
||||||
console.log(`[muxer] Creating audio filters for ${clips.length} clips...`);
|
console.log(`[muxer] Creating audio filters for ${clips.length} clips...`);
|
||||||
clips.forEach((clip, index) => {
|
clips.forEach((clip, index) => {
|
||||||
command.input(clip.oggPath);
|
|
||||||
|
|
||||||
// Calculate delay relative to the global start time
|
// Calculate delay relative to the global start time
|
||||||
const delayMs = clip.meta.startTime - globalStartTime;
|
const delayMs = clip.meta.startTime - globalStartTime;
|
||||||
|
|
||||||
@@ -148,27 +145,25 @@ async function startMuxing() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const outputFilename = path.join(recordingsDir, `muxed-${Date.now()}.mp3`);
|
const outputFilename = path.join(recordingsDir, `muxed-${Date.now()}.mp3`);
|
||||||
|
const inputs = clips.map((clip) => clip.oggPath);
|
||||||
|
|
||||||
console.log(`[muxer] Combining clips. This might take a while...`);
|
console.log(`[muxer] Combining clips. This might take a while...`);
|
||||||
|
|
||||||
// Using fluent-ffmpeg's complexFilter
|
try {
|
||||||
command
|
const args = buildMuxFfmpegArgs({
|
||||||
.complexFilter(filterParts, "out")
|
inputs,
|
||||||
.audioCodec("libmp3lame")
|
filter: filterParts.join(";"),
|
||||||
.save(outputFilename)
|
output: outputFilename,
|
||||||
.on("progress", (progress) => {
|
codec: "libmp3lame",
|
||||||
if (progress.percent) {
|
});
|
||||||
console.log(`[muxer] Progress: ${progress.percent.toFixed(2)}%`);
|
|
||||||
}
|
await runFfmpeg(args);
|
||||||
})
|
|
||||||
.on("end", () => {
|
|
||||||
console.log(
|
console.log(
|
||||||
`[muxer] Successfully muxed! Output saved to: ${outputFilename}`,
|
`[muxer] Successfully muxed! Output saved to: ${outputFilename}`,
|
||||||
);
|
);
|
||||||
})
|
} catch (err) {
|
||||||
.on("error", (err) => {
|
|
||||||
console.error(`[muxer] FFmpeg Error:`, err);
|
console.error(`[muxer] FFmpeg Error:`, err);
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
startMuxing();
|
startMuxing();
|
||||||
|
|||||||
102
tests/audio/ffmpegProcess.test.ts
Normal file
102
tests/audio/ffmpegProcess.test.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { buildMuxFfmpegArgs, runFfmpeg } from "../../src/audio/ffmpegProcess";
|
||||||
|
|
||||||
|
vi.mock("node:child_process", () => ({
|
||||||
|
spawn: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const spawnMock = vi.mocked(spawn);
|
||||||
|
|
||||||
|
describe("buildMuxFfmpegArgs", () => {
|
||||||
|
it("builds args with multiple inputs and libmp3lame codec", () => {
|
||||||
|
const args = buildMuxFfmpegArgs({
|
||||||
|
inputs: ["a.ogg", "b.ogg"],
|
||||||
|
filter:
|
||||||
|
"[0:a]adelay=0|0[pad0];[1:a]adelay=1000|1000[pad1];[pad0][pad1]amix=inputs=2:dropout_transition=0[out]",
|
||||||
|
output: "out.mp3",
|
||||||
|
codec: "libmp3lame",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(args).toEqual([
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
"a.ogg",
|
||||||
|
"-i",
|
||||||
|
"b.ogg",
|
||||||
|
"-filter_complex",
|
||||||
|
"[0:a]adelay=0|0[pad0];[1:a]adelay=1000|1000[pad1];[pad0][pad1]amix=inputs=2:dropout_transition=0[out]",
|
||||||
|
"-map",
|
||||||
|
"[out]",
|
||||||
|
"-codec:a",
|
||||||
|
"libmp3lame",
|
||||||
|
"out.mp3",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes audio frequency and channel options when provided", () => {
|
||||||
|
const args = buildMuxFfmpegArgs({
|
||||||
|
inputs: ["a.ogg"],
|
||||||
|
filter:
|
||||||
|
"[0:a]adelay=0|0[pad0];[pad0]amix=inputs=1:dropout_transition=0[out]",
|
||||||
|
output: "out.wav",
|
||||||
|
codec: "pcm_s16le",
|
||||||
|
audioFrequency: 44100,
|
||||||
|
audioChannels: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(args).toContain("-ar");
|
||||||
|
expect(args).toContain("44100");
|
||||||
|
expect(args).toContain("-ac");
|
||||||
|
expect(args).toContain("2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not include -ar or -ac when audioFrequency and audioChannels are not provided", () => {
|
||||||
|
const args = buildMuxFfmpegArgs({
|
||||||
|
inputs: ["a.ogg"],
|
||||||
|
filter:
|
||||||
|
"[0:a]adelay=0|0[pad0];[pad0]amix=inputs=1:dropout_transition=0[out]",
|
||||||
|
output: "out.mp3",
|
||||||
|
codec: "libmp3lame",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(args).not.toContain("-ar");
|
||||||
|
expect(args).not.toContain("-ac");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runFfmpeg", () => {
|
||||||
|
it("spawns ffmpeg and resolves on exit code 0", async () => {
|
||||||
|
const proc = new EventEmitter();
|
||||||
|
spawnMock.mockReturnValue(proc as ReturnType<typeof spawn>);
|
||||||
|
|
||||||
|
const result = runFfmpeg(["-version"]);
|
||||||
|
proc.emit("close", 0);
|
||||||
|
|
||||||
|
await expect(result).resolves.toBeUndefined();
|
||||||
|
expect(spawnMock).toHaveBeenCalledWith("ffmpeg", ["-version"], {
|
||||||
|
stdio: ["ignore", "inherit", "inherit"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects on non-zero exit code", async () => {
|
||||||
|
const proc = new EventEmitter();
|
||||||
|
spawnMock.mockReturnValue(proc as ReturnType<typeof spawn>);
|
||||||
|
|
||||||
|
const result = runFfmpeg(["-bad"]);
|
||||||
|
proc.emit("close", 1);
|
||||||
|
|
||||||
|
await expect(result).rejects.toThrow("ffmpeg exited with code 1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects on spawn error", async () => {
|
||||||
|
const proc = new EventEmitter();
|
||||||
|
spawnMock.mockReturnValue(proc as ReturnType<typeof spawn>);
|
||||||
|
|
||||||
|
const result = runFfmpeg(["-version"]);
|
||||||
|
proc.emit("error", new Error("spawn ffmpeg ENOENT"));
|
||||||
|
|
||||||
|
await expect(result).rejects.toThrow("spawn ffmpeg ENOENT");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user