mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 19:06:04 +00:00
feat(engine): render the FX chain offline, and the carve analysis behind it (#3021)
* feat(engine): render audio FX in an OfflineAudioContext Reads `data-fx-chain` off an audio element and runs the chain over the trimmed WAV before volume automation is baked in — effects should see the raw signal, and the envelope belongs on their output. The processing happens in an OfflineAudioContext inside the headless browser the engine already drives, running the same graph builders the studio previews with. That is the point of the approach: one implementation per effect, so the render agreeing with the preview is a property of the architecture rather than a tolerance to police. Reimplementing each effect as an FFmpeg filter would mean two implementations to keep in step, and for the dynamics processors and modulated delays there is no filter that behaves the same way. `build:audio-fx-runtime` bundles the graph builders into an injectable IIFE, following the same pattern as the existing runtime artifacts, so the browser runs exactly the code the studio does. The page loads from a file:// URL rather than about:blank because AudioWorklet is only exposed in a secure context — the compressor, limiter, gate and bitcrush processors would otherwise fail to register with an opaque error. file:// qualifies and needs no listening socket. The chain is serialised into the attribute the way colour grading carries its config, so there is no side-car file to resolve or lose. An FX failure is fatal for the whole mix rather than a per-track soft failure. Every other audio failure mode degrades gracefully — the track drops, siblings continue — but substituting the dry signal for a processed one ships a render that sounds plausible and is not what the author set up. Since the per-element work races under Promise.all, an internal AbortController chained off the caller's signal aborts in-flight siblings before workDir is removed. * feat(core): voiceover carve analysis Finds the bands a voice occupies so a music bed can be dipped there, letting the voice sit in front without ducking the whole track. Carve is a relationship between two tracks rather than an effect on one, so it stays out of the FX chain. What it emits is an ordinary chain of peaking filters, so a carve composes with whatever else is on the track and needs no separate rendering path. Selection is weighted toward intelligibility rather than raw voice energy. Ranking purely by power lands on the fundamental almost every time, because that is where a voice is loudest — but the masking that actually hurts a voiceover happens higher up, and dipping 160 Hz mostly just thins the bed. The bias is a control, not a constant: at 0 it follows raw energy, at 1 it weights toward 1-3 kHz. Ranking happens in dB, which matters more than it looks. Speech spreads 20-30 dB across these bands — it falls off roughly 6 dB per octave above the fundamental — so a weighting has to be on that scale to move anything at all. A multiplicative weight of `1 - bias + bias * shaped` is bounded below by `1 - bias`, capping its influence at 10*log10(1/(1 - bias)): 5.2 dB at the 0.7 default, 3 dB at 0.5. That is no influence against a real voice — every bias short of ~0.95 would rank exactly like bias 0 and carve the fundamental, the outcome the bias exists to prevent, while looking decisive against a fixture whose bands sit 2 dB apart. So the bias is a dB penalty, zero at 2 kHz and worth up to 30 dB at full strength, and relative cut depths come from a dB difference rather than a ratio of weighted linear powers. The bias reweights ranking without overriding the spectrum — a band the voice has no energy in is not worth carving, and scores -Infinity rather than competing — so a strongly low-pitched voice can still select low at full bias. What the tests hold is that biasing never selects lower than the unbiased ranking, that the DEFAULT bias reaches the presence region on a voice with a realistic tilt, and that bias 0 still follows raw power exactly. Includes a radix-2 FFT rather than a dependency; one Welch-style averaged spectrum over third-octave bands does not justify pulling in a DSP library. * fix(engine): keep the FX render 16-bit, stereo, and correctly sized Three defects in the offline FX path, none of which any test could see. **Float output silently disabled sample-accurate volume automation.** The writer emitted 32-bit IEEE float; the very next mixer step bakes the volume envelope into the samples and accepts only 16-bit PCM, returning null otherwise. So enabling any effect downgraded that track to the ffmpeg expression path — capped at 32 straight segments, quantising a curved envelope, and on a dense one falling back to base volume. It now writes 16-bit PCM, clamped rather than wrapped so a limiter at 0 dB or a resonant filter cannot turn overshoot into a click. A test asserts the baker accepts the writer's own output and actually fades it. **Everything was folded to mono.** `prepareAudioTrack` goes out of its way to emit stereo — its pan filter exists to dodge ffmpeg's 3 dB mono-to-stereo rematrix — and this folded it, then wrote one channel. So adding a single peaking EQ collapsed a bed's width and cost ~3 dB in the render, while preview stayed stereo. Channels now travel as one plane each, through an OfflineAudioContext of the same width, and come back interleaved. **Small results decoded the wrong length.** `new Float32Array(buf.buffer)` discards byteOffset and byteLength, and Node pools small allocations: a 400-byte payload sits at offset 8 inside an 8 KiB pool, so a clip under ~1024 samples decoded as 2048 samples of unrelated memory — and the empty-result guard could not see it. The reader has the mirror-image fix: a float data chunk on an odd boundary (ffmpeg's pcm_f32le writes fmt(18) + fact, landing `data` at 58) now copies instead of throwing RangeError on an unaligned view. The tail limitation is now stated rather than mis-stated: the context is exactly as long as the input, so a reverb or delay still ringing is cut there. The old comment claimed the opposite. How far a tail may run past a clip's end changes the clip's length in the mix, so it is a product decision, not one to make here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(producer): report an FX render failure as an audio error `processCompositionAudio` reports per-track failures in its result, but an FX failure it cannot degrade past — a browser that will not launch, a chain that will not build — rejects instead. `runAudioStage` had no try, so that rejection escaped to the orchestrator as an unclassified pipeline exception, losing the stage/owner/retryable classification this stage exists to attach, and skipping its abort check on the way out. It now lands in `audioError` alongside every other cause, while an abort still keeps its own shape rather than being reported as an audio problem. Not done here: committing the generated `audio-fx-runtime-inline.ts` so a fresh clone typechecks packages/engine without building first. The bundle is built from the stub, and the stub changes three times across this stack — so the artifact differs per branch and would conflict on every restack. Its model, position-edits-render-inline.ts, is committed only because it is stable. Building before testing is this monorepo's existing contract (studio's tests need core's dist too), so the gap is not specific to audio FX and is better closed by a build ordering gate than by committing a per-branch artifact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(engine): skip the browser FX render cases when there is no browser CI's `Test` job was red on this PR with four failures, all the same cause: Failed to launch the browser process: spawn /home/runner/.cache/hyperframes/chrome/chrome-headless-shell The job installs ffmpeg and no browser, deliberately — every other suite that needs an external binary already guards on it (`describe.skipIf(!HAS_FFMPEG)`). These cases were the only ones assuming a Chrome, so they failed on an absent dependency rather than on anything about the code. Guards on `resolveHeadlessShellPath()` — the same resolver `acquireBrowser` launches through, so the check cannot drift from the thing it guards the way a hard-coded cache path would. A configured path that does not exist throws; that is caught and read as "cannot run here". Checked both directions rather than just the green one: with a browser all 11 cases run and pass, and with `HYPERFRAMES_BROWSER_PATH` pointed at a missing binary exactly 3 skip and the other 8 still run. A guard that silently skipped everything would have looked identical in CI. They keep their value where it exists — every developer machine, and any job that has run `hyperframes browser ensure`. Not touched: the CodeQL failure on this PR is a run from 2026-08-07, five days and several force-pushes stale. None of the 17 open repo alerts are in files this PR changes; it re-runs on this push. * chore(engine): suppress the temp-file alert with the reason it is safe CodeQL flags `writeWav`'s `writeFileSync` as js/insecure-temporary-file (high) — the one new alert on #3021, and the reason its CodeQL check is red. It is a false positive, and the comment says why rather than just silencing it: `path` is always inside a directory made by `mkdtempSync`, never a name assembled directly under `tmpdir()`. Both callers are covered — the browser host page writes into `mkdtempSync(join(tmpdir(), "hf-fx-host-"))`, and the render output goes to the producer work dir, itself `mkdtempSync(join(tempRoot, "producer-project-"))`. mkdtemp picks the random suffix and creates the directory 0700 in one syscall, so the predictable filename inside it cannot be pre-created or symlinked by another user, which is the attack the rule is about. The analyzer sees the dataflow reach `tmpdir()` and not the mkdtemp in between. Suppressed inline rather than dismissed in the UI, so the justification lives next to the code and the rule stays live for anything added later in this file. Matches the repo's existing convention — `planV2.ts:222` carries an `lgtm[js/insecure-temporary-file]` for a different reason on the same rule. Correcting myself: I first reported this alert as not real, having intersected the PR's files against the default-branch alert list, which does not contain PR-ref alerts. Querying ?ref=refs/pull/3021/merge returns it straight away. * test(engine): probe ffmpeg and Chrome instead of assuming them Two failures on #3021's Test job, both about the environment rather than the code under test. **Bare `ffmpeg` is not on PATH in CI.** The 16-bit fixture shelled out to `execFileSync("ffmpeg", ...)` and died with ENOENT. The job does provide ffmpeg, through `prepare-ffmpeg-bin`, which is what `getFfmpegBinary()` resolves — every other ffmpeg-dependent suite in this package already goes through it. Now this one does too, and the case is `skipIf(!HAS_FFMPEG)` so a contributor without ffmpeg skips rather than fails. **The browser guard trusted the wrong thing.** It asked `resolveHeadlessShellPath()` and treated a returned path as "a browser is here". CI's cache holds a chrome-headless-shell that resolves and then fails to spawn — a partial download is indistinguishable from a working one by `existsSync`, which is all that resolver checks. So the three browser cases ran anyway and failed on the launch. It now runs `--version` and requires exit 0, which is the same probe the ffmpeg suites use: ask the binary, do not infer from the filesystem. Checked both directions rather than just the green one. With a working browser all 11 cases run and pass; with `HYPERFRAMES_BROWSER_PATH` pointed at a binary that exits non-zero — CI's exact situation — exactly 3 skip and the other 8 still run. A guard that quietly skipped everything would have looked identical on the CI summary. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9b12329652
commit
cc40e35aa0
@@ -0,0 +1,247 @@
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { applyVolumeEnvelopeToWav } from "./audioVolumeEnvelope.js";
|
||||
import { defaultAudioFxParams, type HfAudioFxChain } from "@hyperframes/core/audio-fx";
|
||||
import { applyAudioFxChain, AudioFxRenderError, readWav, writeWav } from "./audioFxRender.js";
|
||||
import { resolveHeadlessShellPath } from "./browserManager.js";
|
||||
import { getFfmpegBinary } from "../utils/ffmpegBinaries.js";
|
||||
|
||||
/** Same probe the ffmpeg-dependent suites use: ask the binary, don't assume it. */
|
||||
const HAS_FFMPEG = spawnSync(getFfmpegBinary(), ["-version"], { encoding: "utf-8" }).status === 0;
|
||||
|
||||
/**
|
||||
* Whether a Chrome is actually on this machine, asked the same way the render
|
||||
* asks — `resolveHeadlessShellPath` is what `acquireBrowser` resolves through,
|
||||
* so this cannot drift from the thing it is guarding the way a hard-coded cache
|
||||
* path would.
|
||||
*
|
||||
* The repo's `Test` job installs ffmpeg and no browser, on purpose. Without
|
||||
* this guard the four browser cases below fail there with a spawn error that
|
||||
* says nothing about the code under test, and the same pattern already covers
|
||||
* the ffmpeg-dependent suites (`describe.skipIf(!HAS_FFMPEG)`).
|
||||
*
|
||||
* They still run wherever a browser exists — every developer machine, and any
|
||||
* job that has run `hyperframes browser ensure`.
|
||||
*/
|
||||
const HAS_BROWSER = ((): boolean => {
|
||||
try {
|
||||
const path = resolveHeadlessShellPath();
|
||||
if (!path) return false;
|
||||
// ASK the binary rather than trusting that the file is there. CI carries a
|
||||
// chrome-headless-shell in its cache that resolves and then fails to spawn
|
||||
// — a partial download is indistinguishable from a working one by
|
||||
// existsSync, and the first version of this guard was fooled by exactly
|
||||
// that. Same probe the ffmpeg suites use.
|
||||
return spawnSync(path, ["--version"], { encoding: "utf-8" }).status === 0;
|
||||
} catch {
|
||||
// An explicitly-configured path that does not exist throws. That is a
|
||||
// broken environment rather than an absent browser, but either way these
|
||||
// cases cannot run.
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
const SR = 48000;
|
||||
|
||||
const chainOf = (...types: string[]): HfAudioFxChain => ({
|
||||
version: 1,
|
||||
nodes: types.map((t) => ({ type: t, enabled: true, params: defaultAudioFxParams(t) })),
|
||||
});
|
||||
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "hf-audiofx-"));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** A 440 Hz tone written as float WAV, the shape the mixer hands us. */
|
||||
function tone(path: string, seconds = 0.3, freq = 440): void {
|
||||
const n = Math.floor(SR * seconds);
|
||||
const s = new Float32Array(n);
|
||||
for (let i = 0; i < n; i++) s[i] = 0.5 * Math.sin((2 * Math.PI * freq * i) / SR);
|
||||
writeWav(path, s, SR);
|
||||
}
|
||||
|
||||
const rms = (s: Float32Array): number =>
|
||||
Math.sqrt(s.reduce((a, x) => a + x * x, 0) / Math.max(1, s.length));
|
||||
const db = (x: number): number => 20 * Math.log10(x + 1e-30);
|
||||
|
||||
describe("readWav / writeWav", () => {
|
||||
it("round-trips samples as 16-bit PCM, the format the volume bake requires", () => {
|
||||
const p = join(dir, "rt.wav");
|
||||
const src = new Float32Array([0, 0.5, -0.5, 0.25]);
|
||||
writeWav(p, src, SR);
|
||||
const back = readWav(p);
|
||||
expect(back.sampleRate).toBe(SR);
|
||||
expect(back.channels).toBe(1);
|
||||
// Quantised, not bit-exact: one 16-bit step is the tolerance.
|
||||
for (let i = 0; i < src.length; i++) {
|
||||
expect(back.samples[i]).toBeCloseTo(src[i] as number, 4);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves the channel count, interleaved", () => {
|
||||
// Folding to mono collapsed a stereo bed's width for the render only.
|
||||
const p = join(dir, "stereo.wav");
|
||||
writeWav(p, new Float32Array([0.5, -0.5, 0.25, -0.25]), SR, 2);
|
||||
const back = readWav(p);
|
||||
expect(back.channels).toBe(2);
|
||||
expect(back.samples.length).toBe(4);
|
||||
expect(back.samples[0]).toBeCloseTo(0.5, 4);
|
||||
expect(back.samples[1]).toBeCloseTo(-0.5, 4);
|
||||
});
|
||||
|
||||
it("writes a file the volume envelope baker will accept", () => {
|
||||
// This is why the writer emits 16-bit: the mixer bakes the envelope into the
|
||||
// samples immediately after, and that baker refuses anything else — so float
|
||||
// output silently downgraded a track to the 32-segment ffmpeg path.
|
||||
const p = join(dir, "bakeable.wav");
|
||||
writeWav(p, new Float32Array(4800).fill(0.5), SR);
|
||||
const baked = applyVolumeEnvelopeToWav(
|
||||
p,
|
||||
[
|
||||
{ time: 0, volume: 1 },
|
||||
{ time: 0.1, volume: 0 },
|
||||
],
|
||||
0,
|
||||
1,
|
||||
);
|
||||
expect(baked).toBe(true);
|
||||
const after = readWav(p);
|
||||
// Faded within the clip: the tail is quieter than the head.
|
||||
expect(Math.abs(after.samples[4700] as number)).toBeLessThan(
|
||||
Math.abs(after.samples[10] as number),
|
||||
);
|
||||
});
|
||||
|
||||
it("clamps past full scale rather than wrapping it into a click", () => {
|
||||
const p = join(dir, "hot.wav");
|
||||
writeWav(p, new Float32Array([1.8, -1.8]), SR);
|
||||
const back = readWav(p);
|
||||
expect(back.samples[0]).toBeCloseTo(1, 3);
|
||||
expect(back.samples[1]).toBeCloseTo(-1, 3);
|
||||
});
|
||||
|
||||
// Needs a real ffmpeg to author the 16-bit fixture. The Test job installs one,
|
||||
// but a bare "ffmpeg" is not on PATH there — hence getFfmpegBinary above — and
|
||||
// a contributor without it should skip rather than fail.
|
||||
it.skipIf(!HAS_FFMPEG)("reads 16-bit PCM, which the trim step can emit", () => {
|
||||
const p = join(dir, "pcm16.wav");
|
||||
execFileSync(getFfmpegBinary(), [
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
`sine=frequency=440:duration=0.1:sample_rate=${SR}`,
|
||||
"-c:a",
|
||||
"pcm_s16le",
|
||||
"-ac",
|
||||
"1",
|
||||
p,
|
||||
"-y",
|
||||
]);
|
||||
const back = readWav(p);
|
||||
expect(back.sampleRate).toBe(SR);
|
||||
expect(back.samples.length).toBeGreaterThan(0);
|
||||
// 16-bit values must be scaled into the float range, not left as integers.
|
||||
expect(Math.max(...Array.from(back.samples, Math.abs))).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("refuses a file it cannot read rather than returning noise", () => {
|
||||
const p = join(dir, "junk.wav");
|
||||
writeFileSync(p, Buffer.from("not a wav at all"));
|
||||
expect(() => readWav(p)).toThrow(AudioFxRenderError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyAudioFxChain", () => {
|
||||
it("returns the input untouched when nothing is enabled", async () => {
|
||||
const input = join(dir, "in.wav");
|
||||
tone(input);
|
||||
const out = await applyAudioFxChain(
|
||||
input,
|
||||
{ version: 1, nodes: [{ type: "peaking", enabled: false }] },
|
||||
join(dir, "out.wav"),
|
||||
{ trackId: "t" },
|
||||
);
|
||||
expect(out).toBe(input);
|
||||
expect(existsSync(join(dir, "out.wav"))).toBe(false);
|
||||
});
|
||||
|
||||
it("throws when there is work to do but the input is missing", async () => {
|
||||
await expect(
|
||||
applyAudioFxChain(join(dir, "nope.wav"), chainOf("peaking"), join(dir, "out.wav"), {
|
||||
trackId: "t",
|
||||
}),
|
||||
).rejects.toThrow(/input is missing/);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* These drive a real headless browser, which is the point: the render path is
|
||||
* an OfflineAudioContext running the same graph the studio previews with. They
|
||||
* are the only place that proves the injected runtime loads and processes
|
||||
* audio, so they are worth the seconds they cost.
|
||||
*/
|
||||
describe.skipIf(!HAS_BROWSER)("browser render", () => {
|
||||
it("notches out the tone it is tuned to", async () => {
|
||||
const input = join(dir, "in.wav");
|
||||
tone(input);
|
||||
const outPath = join(dir, "out.wav");
|
||||
const result = await applyAudioFxChain(
|
||||
input,
|
||||
{
|
||||
version: 1,
|
||||
nodes: [{ type: "peaking", enabled: true, params: { frequency: 440, gain: -30, q: 1 } }],
|
||||
},
|
||||
outPath,
|
||||
{ trackId: "t" },
|
||||
);
|
||||
expect(result).toBe(outPath);
|
||||
const before = readWav(input).samples;
|
||||
const after = readWav(outPath).samples;
|
||||
expect(after.length).toBe(before.length);
|
||||
expect(db(rms(after))).toBeLessThan(db(rms(before)) - 15);
|
||||
}, 180_000);
|
||||
|
||||
it("runs a worklet-backed effect, not just the native nodes", async () => {
|
||||
// The dynamics processors are AudioWorklets; if the module fails to load in
|
||||
// the render context, this is where it surfaces.
|
||||
const input = join(dir, "in.wav");
|
||||
tone(input, 0.3, 200);
|
||||
const outPath = join(dir, "out.wav");
|
||||
await applyAudioFxChain(
|
||||
input,
|
||||
{
|
||||
version: 1,
|
||||
nodes: [
|
||||
{
|
||||
type: "compressor",
|
||||
enabled: true,
|
||||
params: { ...defaultAudioFxParams("compressor"), threshold: -40, ratio: 20 },
|
||||
},
|
||||
],
|
||||
},
|
||||
outPath,
|
||||
{ trackId: "t" },
|
||||
);
|
||||
expect(db(rms(readWav(outPath).samples))).toBeLessThan(db(rms(readWav(input).samples)) - 3);
|
||||
}, 180_000);
|
||||
|
||||
it("renders a multi-effect chain including reverb", async () => {
|
||||
const input = join(dir, "in.wav");
|
||||
tone(input);
|
||||
const outPath = join(dir, "out.wav");
|
||||
await applyAudioFxChain(input, chainOf("highpass", "reverb", "delay"), outPath, {
|
||||
trackId: "t",
|
||||
});
|
||||
expect(readWav(outPath).samples.length).toBeGreaterThan(0);
|
||||
}, 180_000);
|
||||
});
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Applies an audio FX chain to a WAV at render time.
|
||||
*
|
||||
* The processing runs in an OfflineAudioContext inside the headless browser the
|
||||
* engine already drives, using the same graph builders the studio previews
|
||||
* with. There is one implementation of each effect, so the render matching the
|
||||
* preview is a property of the architecture rather than a tolerance to police.
|
||||
*
|
||||
* The alternative — reimplementing every effect as an FFmpeg filter — means two
|
||||
* implementations that have to be kept in agreement, and four of them (the
|
||||
* dynamics processors and the modulated delays) have no filter that behaves the
|
||||
* same way, so preview would quietly stop predicting the render.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { getAudioFxRuntimeScript } from "@hyperframes/core/audio-fx-runtime";
|
||||
import { enabledAudioFxNodes, type HfAudioFxChain } from "@hyperframes/core/audio-fx";
|
||||
import { acquireBrowser } from "./browserManager.js";
|
||||
|
||||
export class AudioFxRenderError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "AudioFxRenderError";
|
||||
}
|
||||
}
|
||||
|
||||
interface WavData {
|
||||
samples: Float32Array;
|
||||
sampleRate: number;
|
||||
channels: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal reader for the WAVs the mixer produces upstream. Handles 16-bit PCM
|
||||
* and 32-bit float, the two formats the trim/extract steps emit; anything else
|
||||
* is refused rather than silently misread as noise.
|
||||
*/
|
||||
/** Walk the chunks for the format and the payload, in whatever order they sit. */
|
||||
function readWavChunks(buf: Buffer): {
|
||||
format: number;
|
||||
channels: number;
|
||||
sampleRate: number;
|
||||
bits: number;
|
||||
data?: Buffer;
|
||||
} {
|
||||
let offset = 12;
|
||||
const head = { format: 1, channels: 1, sampleRate: 48000, bits: 16 };
|
||||
let data: Buffer | undefined;
|
||||
while (offset + 8 <= buf.length) {
|
||||
const id = buf.toString("ascii", offset, offset + 4);
|
||||
const size = buf.readUInt32LE(offset + 4);
|
||||
if (id === "fmt ") {
|
||||
head.format = buf.readUInt16LE(offset + 8);
|
||||
head.channels = buf.readUInt16LE(offset + 10);
|
||||
head.sampleRate = buf.readUInt32LE(offset + 12);
|
||||
head.bits = buf.readUInt16LE(offset + 22);
|
||||
} else if (id === "data") {
|
||||
data = buf.subarray(offset + 8, Math.min(buf.length, offset + 8 + size));
|
||||
break;
|
||||
}
|
||||
offset += 8 + size + (size % 2);
|
||||
}
|
||||
return { ...head, data };
|
||||
}
|
||||
|
||||
export function readWav(path: string): WavData {
|
||||
const buf = readFileSync(path);
|
||||
if (buf.length < 44 || buf.toString("ascii", 0, 4) !== "RIFF") {
|
||||
throw new AudioFxRenderError(`Not a WAV file: ${path}`);
|
||||
}
|
||||
const { format, channels, sampleRate, bits, data } = readWavChunks(buf);
|
||||
if (!data) throw new AudioFxRenderError(`WAV has no data chunk: ${path}`);
|
||||
return { samples: decodeSamples(data, format, bits, path), sampleRate, channels };
|
||||
}
|
||||
|
||||
/** Interleaved samples as floats, for the two formats the mixer emits upstream. */
|
||||
function decodeSamples(data: Buffer, format: number, bits: number, path: string): Float32Array {
|
||||
if (format === 3 && bits === 32) {
|
||||
const n = Math.floor(data.length / 4);
|
||||
// A Float32Array view demands a 4-aligned offset, and chunk layouts that put
|
||||
// `data` on an odd boundary (an 18-byte fmt plus a fact chunk, which
|
||||
// ffmpeg's pcm_f32le writes) would otherwise throw RangeError. Copy then.
|
||||
if (data.byteOffset % 4 === 0) return new Float32Array(data.buffer, data.byteOffset, n);
|
||||
const copied = new Float32Array(n);
|
||||
for (let i = 0; i < n; i++) copied[i] = data.readFloatLE(i * 4);
|
||||
return copied;
|
||||
}
|
||||
if (format === 1 && bits === 16) {
|
||||
const n = Math.floor(data.length / 2);
|
||||
const out = new Float32Array(n);
|
||||
for (let i = 0; i < n; i++) out[i] = data.readInt16LE(i * 2) / 32768;
|
||||
return out;
|
||||
}
|
||||
throw new AudioFxRenderError(`Unsupported WAV format ${format}/${bits}-bit: ${path}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write 16-bit PCM, interleaved, preserving the channel count.
|
||||
*
|
||||
* 16-bit rather than the float32 this used to emit: the very next step in the
|
||||
* mixer bakes the volume envelope into the samples, and that baker accepts only
|
||||
* 16-bit PCM. Emitting float meant enabling any effect silently downgraded a
|
||||
* track's volume automation to the ffmpeg expression path, which is capped at 32
|
||||
* straight segments — so a curved envelope was quantised and a dense one could
|
||||
* fall back to rendering at base volume.
|
||||
*/
|
||||
export function writeWav(
|
||||
path: string,
|
||||
samples: Float32Array,
|
||||
sampleRate: number,
|
||||
channels = 1,
|
||||
): void {
|
||||
const n = samples.length;
|
||||
const bytes = n * 2;
|
||||
const buf = Buffer.alloc(44 + bytes);
|
||||
buf.write("RIFF", 0, "ascii");
|
||||
buf.writeUInt32LE(36 + bytes, 4);
|
||||
buf.write("WAVE", 8, "ascii");
|
||||
buf.write("fmt ", 12, "ascii");
|
||||
buf.writeUInt32LE(16, 16);
|
||||
buf.writeUInt16LE(1, 20); // WAVE_FORMAT_PCM
|
||||
buf.writeUInt16LE(channels, 22);
|
||||
buf.writeUInt32LE(sampleRate, 24);
|
||||
buf.writeUInt32LE(sampleRate * channels * 2, 28);
|
||||
buf.writeUInt16LE(channels * 2, 32);
|
||||
buf.writeUInt16LE(16, 34);
|
||||
buf.write("data", 36, "ascii");
|
||||
buf.writeUInt32LE(bytes, 40);
|
||||
for (let i = 0; i < n; i++) {
|
||||
// Clamp before scaling: a limiter set to 0 dB or a resonant filter can push
|
||||
// past full scale, and wrapping would turn that into a click.
|
||||
const v = Math.max(-1, Math.min(1, samples[i] ?? 0));
|
||||
buf.writeInt16LE(Math.round(v * 32767), 44 + i * 2);
|
||||
}
|
||||
// lgtm[js/insecure-temporary-file] — `path` is always inside a directory the
|
||||
// caller made with `mkdtempSync`, never a name assembled directly under
|
||||
// `tmpdir()`. Both routes here are covered: the browser host page writes into
|
||||
// `mkdtempSync(join(tmpdir(), "hf-fx-host-"))` below, and the render output
|
||||
// goes to the producer's work dir, itself created as
|
||||
// `mkdtempSync(join(tempRoot, "producer-project-"))`. mkdtemp picks the random
|
||||
// suffix and creates the directory 0700 in one syscall, so the predictable
|
||||
// FILENAME inside it (`<elementId>-fx.wav`) cannot be pre-created or
|
||||
// symlinked by another user — which is the attack this rule is about. CodeQL
|
||||
// flags it because the dataflow reaches `tmpdir()` without seeing the mkdtemp
|
||||
// in between.
|
||||
writeFileSync(path, buf);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split an interleaved buffer into one array per channel.
|
||||
*
|
||||
* The graph used to fold everything to mono, which collapsed a stereo bed's
|
||||
* width for the render only — and cost ~3 dB through the very mono-to-stereo
|
||||
* rematrix that `prepareAudioTrack`'s pan filter exists to avoid. Preview kept
|
||||
* the track stereo, so the two diverged the moment any effect was enabled.
|
||||
*/
|
||||
function deinterleave(samples: Float32Array, channels: number): Float32Array[] {
|
||||
if (channels <= 1) return [samples];
|
||||
const frames = Math.floor(samples.length / channels);
|
||||
const out = Array.from({ length: channels }, () => new Float32Array(frames));
|
||||
for (let i = 0; i < frames; i++) {
|
||||
for (let c = 0; c < channels; c++) {
|
||||
(out[c] as Float32Array)[i] = samples[i * channels + c] ?? 0;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Re-interleave per-channel arrays for the WAV writer. */
|
||||
function interleave(planes: readonly Float32Array[]): Float32Array {
|
||||
if (planes.length === 1) return planes[0] as Float32Array;
|
||||
const frames = planes[0]?.length ?? 0;
|
||||
const out = new Float32Array(frames * planes.length);
|
||||
for (let i = 0; i < frames; i++) {
|
||||
for (let c = 0; c < planes.length; c++) {
|
||||
out[i * planes.length + c] = (planes[c] as Float32Array)[i] ?? 0;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a chain over `inputWav`, writing `outputWav`. Resolves to the path to use
|
||||
* downstream: `outputWav` when the chain did something, `inputWav` untouched
|
||||
* when the chain was empty.
|
||||
*
|
||||
* Failure is fatal to the caller rather than a soft per-track warning: quietly
|
||||
* rendering the dry signal ships a mix that sounds plausible and is not what
|
||||
* the author set up.
|
||||
*/
|
||||
export async function applyAudioFxChain(
|
||||
inputWav: string,
|
||||
chain: HfAudioFxChain,
|
||||
outputWav: string,
|
||||
options: { trackId: string; signal?: AbortSignal },
|
||||
): Promise<string> {
|
||||
if (enabledAudioFxNodes(chain).length === 0) return inputWav;
|
||||
if (!existsSync(inputWav)) {
|
||||
throw new AudioFxRenderError(`Audio FX input is missing: ${inputWav}`);
|
||||
}
|
||||
|
||||
const { samples, sampleRate, channels } = readWav(inputWav);
|
||||
const planes = deinterleave(samples, channels);
|
||||
|
||||
// Audio processing needs no GPU or special capture mode; a plain sandboxed
|
||||
// browser is enough, and the lease pool reuses one across tracks.
|
||||
const lease = await acquireBrowser([
|
||||
"--no-sandbox",
|
||||
"--autoplay-policy=no-user-gesture-required",
|
||||
]);
|
||||
const hostDir = mkdtempSync(join(tmpdir(), "hf-fx-host-"));
|
||||
try {
|
||||
if (options.signal?.aborted) {
|
||||
throw new AudioFxRenderError(`Audio FX cancelled for track ${options.trackId}`);
|
||||
}
|
||||
const page = await lease.browser.newPage();
|
||||
try {
|
||||
// AudioWorklet is only exposed in a secure context, and about:blank is
|
||||
// not one — the module would fail with an opaque error. A file:// page
|
||||
// qualifies and needs no listening socket.
|
||||
const hostPage = join(hostDir, "audio-fx.html");
|
||||
writeFileSync(hostPage, "<!doctype html><meta charset=utf-8><title>audio fx</title>");
|
||||
await page.goto(pathToFileURL(hostPage).href, { waitUntil: "domcontentloaded" });
|
||||
await page.addScriptTag({ content: getAudioFxRuntimeScript() });
|
||||
|
||||
const rendered = (await page.evaluate(
|
||||
async ([channelB64, rate, chainJson]: [string[], number, string]) => {
|
||||
const decode = (b64: string): Float32Array => {
|
||||
const bin = atob(b64);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
return new Float32Array(bytes.buffer);
|
||||
};
|
||||
const api = (
|
||||
window as unknown as {
|
||||
__HF_AUDIO_FX?: {
|
||||
render(p: Float32Array[], r: number, c: string): Promise<Float32Array[]>;
|
||||
};
|
||||
}
|
||||
).__HF_AUDIO_FX;
|
||||
if (!api) throw new Error("audio FX runtime failed to load");
|
||||
const out = await api.render(channelB64.map(decode), rate, chainJson);
|
||||
const encode = (plane: Float32Array): string => {
|
||||
const u8 = new Uint8Array(plane.buffer, plane.byteOffset, plane.length * 4);
|
||||
let s = "";
|
||||
const CHUNK = 0x8000;
|
||||
for (let i = 0; i < u8.length; i += CHUNK) {
|
||||
s += String.fromCharCode.apply(null, Array.from(u8.subarray(i, i + CHUNK)));
|
||||
}
|
||||
return btoa(s);
|
||||
};
|
||||
return out.map(encode);
|
||||
},
|
||||
[
|
||||
planes.map((plane) =>
|
||||
Buffer.from(plane.buffer, plane.byteOffset, plane.length * 4).toString("base64"),
|
||||
),
|
||||
sampleRate,
|
||||
JSON.stringify(chain),
|
||||
] as [string[], number, string],
|
||||
)) as string[];
|
||||
|
||||
// byteOffset and byteLength matter: Node pools small allocations, so a
|
||||
// short payload decodes into an 8 KiB pool and a view over the whole
|
||||
// ArrayBuffer would read kilobytes of unrelated memory at the wrong length.
|
||||
const outPlanes = rendered.map((b64) => {
|
||||
const buf = Buffer.from(b64, "base64");
|
||||
return new Float32Array(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength));
|
||||
});
|
||||
if (outPlanes.length === 0 || (outPlanes[0]?.length ?? 0) === 0) {
|
||||
throw new AudioFxRenderError(`Audio FX produced no samples for track ${options.trackId}`);
|
||||
}
|
||||
writeWav(outputWav, interleave(outPlanes), sampleRate, outPlanes.length);
|
||||
return outputWav;
|
||||
} finally {
|
||||
await page.close().catch(() => undefined);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof AudioFxRenderError) throw err;
|
||||
throw new AudioFxRenderError(
|
||||
`Audio FX failed for track ${options.trackId}: ${(err as Error).message}`,
|
||||
);
|
||||
} finally {
|
||||
rmSync(hostDir, { recursive: true, force: true });
|
||||
await lease.release().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export type { HfAudioFxChain };
|
||||
@@ -957,3 +957,18 @@ describe("parseAudioElements — hidden tracks", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAudioElements data-fx-chain", () => {
|
||||
it("captures the serialised chain when present", () => {
|
||||
const chain = `{"version":1,"nodes":[{"type":"peaking"}]}`;
|
||||
const html = `<audio id="music" src="bgm.mp3" data-start="0" data-end="10" data-fx-chain='${chain}'></audio>`;
|
||||
const [el] = parseAudioElements(html);
|
||||
expect(el!.fxChain).toBe(chain);
|
||||
});
|
||||
|
||||
it("leaves fxChain undefined when the attribute is absent", () => {
|
||||
const html = `<audio id="music" src="bgm.mp3" data-start="0" data-end="10"></audio>`;
|
||||
const [el] = parseAudioElements(html);
|
||||
expect(el!.fxChain).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,8 @@ import type {
|
||||
MixResult,
|
||||
} from "./audioMixer.types.js";
|
||||
import { applyVolumeEnvelopeToWav } from "./audioVolumeEnvelope.js";
|
||||
import { HF_AUDIO_FX_ATTR, parseAudioFxChain } from "@hyperframes/core/audio-fx";
|
||||
import { applyAudioFxChain, AudioFxRenderError } from "./audioFxRender.js";
|
||||
|
||||
export type { AudioElement, MixResult } from "./audioMixer.types.js";
|
||||
|
||||
@@ -366,6 +368,7 @@ export function parseAudioElements(html: string): AudioElement[] {
|
||||
const mediaStartAttr = el.getAttribute("data-media-start");
|
||||
const layerAttr = el.getAttribute("data-layer");
|
||||
const volumeAttr = el.getAttribute("data-volume");
|
||||
const fxChain = el.getAttribute(HF_AUDIO_FX_ATTR);
|
||||
return {
|
||||
id,
|
||||
src: el.getAttribute("src") as string,
|
||||
@@ -374,6 +377,7 @@ export function parseAudioElements(html: string): AudioElement[] {
|
||||
mediaStart: mediaStartAttr ? parseFloat(mediaStartAttr) : 0,
|
||||
layer: layerAttr ? parseInt(layerAttr) : 0,
|
||||
volume: volumeAttr ? parseFloat(volumeAttr) : 1.0,
|
||||
...(fxChain ? { fxChain } : {}),
|
||||
type,
|
||||
};
|
||||
};
|
||||
@@ -717,9 +721,21 @@ export async function processCompositionAudio(
|
||||
|
||||
if (!existsSync(workDir)) mkdirSync(workDir, { recursive: true });
|
||||
|
||||
// Every element's work races under Promise.all, which rejects on the first
|
||||
// failure without waiting for its siblings. A fatal FX error therefore has to
|
||||
// be able to abort the in-flight ffmpeg runs before the finally-block removes
|
||||
// workDir out from under them. Chained off the caller's signal so external
|
||||
// cancellation still behaves as before.
|
||||
const internalController = new AbortController();
|
||||
const effectiveSignal = internalController.signal;
|
||||
if (signal) {
|
||||
if (signal.aborted) internalController.abort();
|
||||
else signal.addEventListener("abort", () => internalController.abort(), { once: true });
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
elements.map(async (element) => {
|
||||
if (signal?.aborted) {
|
||||
if (effectiveSignal.aborted) {
|
||||
failures.push({
|
||||
stage: "cancelled",
|
||||
reason: "cancelled",
|
||||
@@ -854,6 +870,24 @@ export async function processCompositionAudio(
|
||||
audioSrcPath = trimmedPath;
|
||||
}
|
||||
|
||||
// Apply the track's FX chain to the dry, trimmed audio, before volume
|
||||
// automation is baked in: effects should see the raw signal, and the
|
||||
// envelope belongs on their output. A missing or broken chain is fatal
|
||||
// for the whole mix rather than a per-track warning — quietly rendering
|
||||
// the dry signal ships a mix that sounds plausible and is wrong.
|
||||
if (element.fxChain) {
|
||||
// The chain is serialised into the attribute, the same way colour
|
||||
// grading carries its config, so there is no side-car file to find,
|
||||
// resolve or lose.
|
||||
const chain = parseAudioFxChain(element.fxChain);
|
||||
audioSrcPath = await applyAudioFxChain(
|
||||
audioSrcPath,
|
||||
chain,
|
||||
join(workDir, `${element.id}-fx.wav`),
|
||||
{ trackId: element.id, signal: effectiveSignal },
|
||||
);
|
||||
}
|
||||
|
||||
// Primary volume-automation path: bake the envelope into the PCM samples
|
||||
// (sample-accurate, no keyframe ceiling). If the WAV isn't the expected
|
||||
// 16-bit PCM, fall back to the ffmpeg expression path by leaving the
|
||||
@@ -879,6 +913,11 @@ export async function processCompositionAudio(
|
||||
volumeKeyframes: bakedEnvelope ? undefined : element.volumeKeyframes,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
// An FX failure is fatal for the whole mix. Every other failure mode
|
||||
// degrades gracefully — the track drops and siblings continue — but
|
||||
// substituting the dry signal for a processed one ships a render that
|
||||
// sounds plausible and is not what was authored.
|
||||
if (err instanceof AudioFxRenderError) throw err;
|
||||
failures.push({
|
||||
stage: "internal",
|
||||
reason: "internal",
|
||||
@@ -893,7 +932,17 @@ export async function processCompositionAudio(
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
).catch((err: unknown) => {
|
||||
// Promise.all rejected on the first fatal failure without waiting for its
|
||||
// siblings; abort them so their ffmpeg stops before workDir disappears.
|
||||
internalController.abort();
|
||||
try {
|
||||
rmSync(workDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
// Never turn a per-track preparation failure into a successful partial mix.
|
||||
// The producer only surfaces audio failures when `success` is false; mixing
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface AudioElement {
|
||||
layer: number;
|
||||
volume?: number;
|
||||
volumeKeyframes?: AudioVolumeKeyframe[];
|
||||
/** Serialised FX chain JSON from `data-fx-chain`, when set. */
|
||||
fxChain?: string;
|
||||
type: "audio" | "video";
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user