diff --git a/packages/engine/src/services/aacEncode.test.ts b/packages/engine/src/services/aacEncode.test.ts index b7dcd25ead8..bd7102f1038 100644 --- a/packages/engine/src/services/aacEncode.test.ts +++ b/packages/engine/src/services/aacEncode.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, describe, expect, it, vi } from "vitest"; -import { getFfmpegBinary, getFfprobeBinary } from "../utils/ffmpegBinaries.js"; +import { FFMPEG_PATH_ENV, getFfmpegBinary, getFfprobeBinary } from "../utils/ffmpegBinaries.js"; import { aacSegmentCount, encodeAac, splitAdtsFrames } from "./aacEncode.js"; import * as ffmpegRunner from "../utils/runFfmpeg.js"; @@ -196,6 +196,39 @@ describe.skipIf(!HAS_FFMPEG)("encodeAac", () => { expect(streamEnd(split)).toBe(streamEnd(single)); }, 120_000); + it.skipIf(process.platform === "win32")( + "ends the stream at the duration on an FFmpeg whose setts has no duration option", + async () => { + const source = join(dir, "old-ffmpeg.wav"); + ffmpeg(["-f", "lavfi", "-i", "sine=f=440:d=2.5:r=48000", "-ac", "2", "-y", source]); + const wrapper = join(dir, "ffmpeg-without-setts-duration"); + writeFileSync( + wrapper, + [ + "#!/bin/sh", + 'case "$*" in *"-h bsf=setts"*) exit 0;; esac', + `case "$*" in *"setts=duration"*) echo "Option 'duration' not found" >&2; exit 1;; esac`, + `exec "${getFfmpegBinary()}" "$@"`, + ].join("\n"), + { mode: 0o755 }, + ); + const reference = join(dir, "old-ffmpeg-reference.m4a"); + expect((await encodeAac(source, reference, 2.5, { timeout: 30_000 })).success).toBe(true); + + const saved = process.env[FFMPEG_PATH_ENV]; + process.env[FFMPEG_PATH_ENV] = wrapper; + const coded = join(dir, "old-ffmpeg.m4a"); + try { + expect((await encodeAac(source, coded, 2.5, { timeout: 30_000 })).success).toBe(true); + } finally { + if (saved === undefined) delete process.env[FFMPEG_PATH_ENV]; + else process.env[FFMPEG_PATH_ENV] = saved; + } + expect(streamEnd(coded)).toBe(streamEnd(reference)); + }, + 60_000, + ); + it("keeps a sharp onset at its level when the audio opens quietly, encode after encode", async () => { const growthDb: number[] = []; for (const f0 of [250, 306, 360]) { diff --git a/packages/engine/src/services/aacEncode.ts b/packages/engine/src/services/aacEncode.ts index e4133148421..f700615fcc2 100644 --- a/packages/engine/src/services/aacEncode.ts +++ b/packages/engine/src/services/aacEncode.ts @@ -3,8 +3,10 @@ * so long audio is encoded as frame-aligned segments in parallel and their frames joined. */ +import { execFile } from "node:child_process"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; +import { getFfmpegBinary } from "../utils/ffmpegBinaries.js"; import { runFfmpeg, type RunFfmpegResult } from "../utils/runFfmpeg.js"; const SAMPLE_RATE = 48_000; @@ -55,6 +57,35 @@ function planSegments(totalDuration: number, count: number) { }); } +const settsDurationSupport = new Map>(); + +/** FFmpeg before 5.0 (Ubuntu 22.04 ships 4.4) has no `duration` option on the setts filter. */ +function supportsSettsDuration(binary = getFfmpegBinary()): Promise { + let supported = settsDurationSupport.get(binary); + if (!supported) { + supported = new Promise((resolve) => { + execFile(binary, ["-hide_banner", "-h", "bsf=setts"], (error, stdout) => { + // A binary that cannot even print help fails the encode with its own error, not this one. + resolve(error !== null || stdout.includes("duration")); + }); + }); + settsDurationSupport.set(binary, supported); + } + return supported; +} + +function encodeInOnePass( + input: string, + outputPath: string, + { signal, timeout, filter }: { signal?: AbortSignal; timeout: number; filter?: string }, +): Promise { + const filters = [filter, `aresample=${SAMPLE_RATE}`].filter(Boolean).join(","); + return runFfmpeg(["-i", input, "-af", filters, ...AAC_ARGS, "-y", outputPath], { + signal, + timeout, + }); +} + /** `input`, after `filter`, is audio of exactly `totalDuration`. */ export async function encodeAac( input: string, @@ -67,6 +98,9 @@ export async function encodeAac( segments: count = aacSegmentCount(totalDuration), }: { signal?: AbortSignal; timeout: number; filter?: string; segments?: number }, ): Promise { + if (!(await supportsSettsDuration())) { + return encodeInOnePass(input, outputPath, { signal, timeout, filter }); + } const dir = mkdtempSync(join(dirname(outputPath), ".aac-segments-")); try { const segments = planSegments(totalDuration, count).map((segment, k) => ({