diff --git a/src/audio/ffmpegProcess.ts b/src/audio/ffmpegProcess.ts new file mode 100644 index 0000000..9fb5675 --- /dev/null +++ b/src/audio/ffmpegProcess.ts @@ -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 { + 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); + }); + }); +} diff --git a/src/muxer-aup3.ts b/src/muxer-aup3.ts index b7f0fb9..807cecc 100644 --- a/src/muxer-aup3.ts +++ b/src/muxer-aup3.ts @@ -1,6 +1,6 @@ -import ffmpeg from "fluent-ffmpeg"; import fs from "fs"; import path from "path"; +import { buildMuxFfmpegArgs, runFfmpeg } from "./audio/ffmpegProcess"; const recordingsDir = process.env.RECORDINGS_DIR ?? "./recordings"; @@ -73,7 +73,7 @@ async function startMuxingToAup3() { // Check if OGG file has valid header (starts with "OggS") const oggBuffer = fs.readFileSync(oggPath); - const oggHeader = oggBuffer.slice(0, 4).toString(); + const oggHeader = oggBuffer.subarray(0, 4).toString(); if (oggHeader !== "OggS") { console.warn( `[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}`, ); - const command = ffmpeg(); const filterParts: string[] = []; console.log( `[muxer-aup3] Creating audio filters for ${clips.length} clips...`, ); clips.forEach((clip, index) => { - command.input(clip.oggPath); - // Calculate delay relative to the global start time const delayMs = clip.meta.startTime - globalStartTime; @@ -154,33 +151,29 @@ async function startMuxingToAup3() { const timestamp = Date.now(); const wavFilename = path.join(recordingsDir, `muxed-${timestamp}.wav`); const aup3Filename = path.join(recordingsDir, `muxed-${timestamp}.aup3`); + const inputs = clips.map((clip) => clip.oggPath); console.log( `[muxer-aup3] Combining clips to WAV. This might take a while...`, ); - // Using fluent-ffmpeg's complexFilter - command - .complexFilter(filterParts, "out") - .audioCodec("pcm_s16le") - .audioFrequency(44100) - .audioChannels(2) - .save(wavFilename) - .on("progress", (progress) => { - if (progress.percent) { - console.log( - `[muxer-aup3] WAV Progress: ${progress.percent.toFixed(2)}%`, - ); - } - }) - .on("end", () => { - console.log(`[muxer-aup3] WAV file created: ${wavFilename}`); - console.log(`[muxer-aup3] Creating AUP3 project file...`); - createAup3Project(wavFilename, aup3Filename, clips, globalStartTime); - }) - .on("error", (err) => { - console.error(`[muxer-aup3] FFmpeg Error:`, err); + try { + const args = buildMuxFfmpegArgs({ + inputs, + filter: filterParts.join(";"), + output: wavFilename, + codec: "pcm_s16le", + audioFrequency: 44100, + audioChannels: 2, }); + + await runFfmpeg(args); + console.log(`[muxer-aup3] WAV file created: ${wavFilename}`); + console.log(`[muxer-aup3] Creating AUP3 project file...`); + createAup3Project(wavFilename, aup3Filename, clips, globalStartTime); + } catch (err) { + console.error(`[muxer-aup3] FFmpeg Error:`, err); + } } function createAup3Project( diff --git a/src/muxer.ts b/src/muxer.ts index ada832d..070c0eb 100644 --- a/src/muxer.ts +++ b/src/muxer.ts @@ -1,6 +1,6 @@ -import ffmpeg from "fluent-ffmpeg"; import fs from "fs"; import path from "path"; +import { buildMuxFfmpegArgs, runFfmpeg } from "./audio/ffmpegProcess"; const recordingsDir = process.env.RECORDINGS_DIR ?? "./recordings"; @@ -71,7 +71,7 @@ async function startMuxing() { // Check if OGG file has valid header (starts with "OggS") const oggBuffer = fs.readFileSync(oggPath); - const oggHeader = oggBuffer.slice(0, 4).toString(); + const oggHeader = oggBuffer.subarray(0, 4).toString(); if (oggHeader !== "OggS") { console.warn( `[muxer] Skipping invalid OGG file (bad header): ${oggPath}`, @@ -114,13 +114,10 @@ async function startMuxing() { `[muxer] Found ${clips.length} clips. Base timestamp: ${globalStartTime}`, ); - const command = ffmpeg(); const filterParts: string[] = []; console.log(`[muxer] Creating audio filters for ${clips.length} clips...`); clips.forEach((clip, index) => { - command.input(clip.oggPath); - // Calculate delay relative to the global start time const delayMs = clip.meta.startTime - globalStartTime; @@ -148,27 +145,25 @@ async function startMuxing() { ); 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...`); - // Using fluent-ffmpeg's complexFilter - command - .complexFilter(filterParts, "out") - .audioCodec("libmp3lame") - .save(outputFilename) - .on("progress", (progress) => { - if (progress.percent) { - console.log(`[muxer] Progress: ${progress.percent.toFixed(2)}%`); - } - }) - .on("end", () => { - console.log( - `[muxer] Successfully muxed! Output saved to: ${outputFilename}`, - ); - }) - .on("error", (err) => { - console.error(`[muxer] FFmpeg Error:`, err); + try { + const args = buildMuxFfmpegArgs({ + inputs, + filter: filterParts.join(";"), + output: outputFilename, + codec: "libmp3lame", }); + + await runFfmpeg(args); + console.log( + `[muxer] Successfully muxed! Output saved to: ${outputFilename}`, + ); + } catch (err) { + console.error(`[muxer] FFmpeg Error:`, err); + } } startMuxing(); diff --git a/tests/audio/ffmpegProcess.test.ts b/tests/audio/ffmpegProcess.test.ts new file mode 100644 index 0000000..9ee645f --- /dev/null +++ b/tests/audio/ffmpegProcess.test.ts @@ -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); + + 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); + + 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); + + const result = runFfmpeg(["-version"]); + proc.emit("error", new Error("spawn ffmpeg ENOENT")); + + await expect(result).rejects.toThrow("spawn ffmpeg ENOENT"); + }); +});