Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion packages/engine/src/services/aacEncode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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]) {
Expand Down
34 changes: 34 additions & 0 deletions packages/engine/src/services/aacEncode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -55,6 +57,35 @@ function planSegments(totalDuration: number, count: number) {
});
}

const settsDurationSupport = new Map<string, Promise<boolean>>();

/** FFmpeg before 5.0 (Ubuntu 22.04 ships 4.4) has no `duration` option on the setts filter. */
function supportsSettsDuration(binary = getFfmpegBinary()): Promise<boolean> {
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<RunFfmpegResult> {
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,
Expand All @@ -67,6 +98,9 @@ export async function encodeAac(
segments: count = aacSegmentCount(totalDuration),
}: { signal?: AbortSignal; timeout: number; filter?: string; segments?: number },
): Promise<RunFfmpegResult> {
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) => ({
Expand Down
Loading