fix(core): register FX worklets before building nodes that need them

An AudioWorkletNode cannot be constructed before its processor is registered —
it throws, and the surrounding chain is lost with it. `attachElementFxChain`
built the chain first and only then called `ensureAudioFxWorklets`, so every
worklet-backed effect (compressor, limiter, gate, bitcrush) threw on
construction and the track fell back to dry. Instrumenting the preview showed
`hf-compressor: InvalidStateError` with addModule never called at all.

When the module has not landed yet the track now plays dry and the graph is
swapped in once registration resolves, so the effect arrives a moment late
instead of never.

Registration is also tracked per context rather than in one module-level
promise. A processor registered on one AudioContext does not exist on another,
so the shared promise made every context after the first believe it was ready
when it was not — the studio's transport owns its own context, which is exactly
that case.

With the worklets actually running, the compressor's per-sample log10 and pow
became real audio-thread work. Samples below the knee have a gain of exactly
unity and need neither, so the envelope is now compared in the linear domain
and the transcendentals only run for samples that are actually being
compressed.
This commit is contained in:
Vance Ingalls
2026-08-12 00:47:19 -07:00
parent 7e85bfbe59
commit 78bce4dcdc
3 changed files with 91 additions and 33 deletions
+2 -2
View File
@@ -14,7 +14,7 @@ import {
type HfAudioFxChain,
type HfAudioFxParamValues,
} from "../audioFx.js";
import { ensureAudioFxWorklets } from "./audioFxWorklets.js";
import { audioFxWorkletsReady, ensureAudioFxWorklets } from "./audioFxWorklets.js";
/**
* Deterministic reverb impulse, shared by both engines so the browser and the
@@ -432,4 +432,4 @@ export function buildFxChain(ctx: BaseAudioContext, chain: HfAudioFxChain): FxCh
};
}
export { ensureAudioFxWorklets };
export { audioFxWorkletsReady, ensureAudioFxWorklets };
+53 -24
View File
@@ -47,6 +47,13 @@ function kneeGain(envDb, thresholdDb, ratio, kneeDb) {
return over > 0 ? -(over * (1 - 1 / ratio)) : 0;
}
// log10/exp per sample is the single most expensive thing a dynamics processor
// can do on the audio thread, and every sample below the knee needs neither:
// its gain is exactly unity. Comparing envelopes in the linear domain lets the
// quiet majority of samples skip the transcendentals entirely.
const LN10_OVER_20 = Math.LN10 / 20;
const dbToLinFast = (db) => Math.exp(db * LN10_OVER_20);
class HfCompressor extends AudioWorkletProcessor {
constructor(o) {
super();
@@ -65,14 +72,26 @@ class HfCompressor extends AudioWorkletProcessor {
const mix = p.mix ?? 1;
// Knee is expressed as a ratio in FFmpeg; convert to dB width.
const kneeDb = 20 * Math.log10(Math.max(1.0001, p.knee ?? 2.83));
const thresholdDb = p.threshold ?? -24;
const ratio = p.ratio ?? 4;
// Below this the gain computer returns unity, so the sample needs no
// logarithm at all.
const kneeStartLin = dbToLin(thresholdDb - kneeDb);
const dry = 1 - mix;
for (let ch = 0; ch < i.length; ch++) {
const inp = i[ch], out = o[ch];
for (let n = 0; n < inp.length; n++) {
const x = inp[n];
// Per-channel follower, and the sub-knee shortcut: below the knee the
// gain is exactly unity, so the sample needs no logarithm at all.
const env = this.env.push(ch, x);
const envDb = env > 1e-9 ? 20 * Math.log10(env) : -200;
const g = dbToLin(kneeGain(envDb, p.threshold ?? -24, p.ratio ?? 4, kneeDb));
out[n] = x * g * makeup * mix + x * (1 - mix);
if (env <= kneeStartLin) {
out[n] = x * makeup * mix + x * dry;
continue;
}
const envDb = 20 * Math.log10(env);
const g = dbToLinFast(kneeGain(envDb, thresholdDb, ratio, kneeDb));
out[n] = x * g * makeup * mix + x * dry;
}
}
return true;
@@ -139,6 +158,7 @@ class HfGate extends AudioWorkletProcessor {
const x = inp[n];
const env = this.env.push(ch, x);
let target = 1;
// Above the threshold the gate is fully open; skip the pow entirely.
if (env < threshold) {
const under = env > 1e-9 ? threshold / env : 1e9;
target = Math.max(floor, 1 / Math.pow(under, ratio - 1));
@@ -191,31 +211,40 @@ class HfBitcrush extends AudioWorkletProcessor {
registerProcessor("hf-bitcrush", HfBitcrush);
`;
let modulePromise: Promise<void> | undefined;
// Registration is per context, not per module: a processor registered on one
// AudioContext does not exist on another, so caching a single promise made
// every context after the first believe it was ready when it was not.
const registered = new WeakMap<BaseAudioContext, Promise<void>>();
/** True once the processors are usable on this context. */
export function audioFxWorkletsReady(ctx: BaseAudioContext): boolean {
return readyContexts.has(ctx);
}
const readyContexts = new WeakSet<BaseAudioContext>();
/**
* Register the processors on a context. Idempotent per module instance, since
* Register the processors on a context. Idempotent per context, since
* addModule throws if the same processor name is registered twice.
*/
export function ensureAudioFxWorklets(ctx: BaseAudioContext): Promise<void> {
modulePromise ??= (async () => {
if (!ctx.audioWorklet) {
throw new Error(
"AudioWorklet is unavailable — the page needs a secure context (https, localhost or file://)",
);
}
// A data: URL rather than a blob:, because a blob inherits the page origin
// and is treated as opaque on a file:// page, where the module then fails
// to load with an unhelpful AbortError.
const url = `data:text/javascript;base64,${btoa(
String.fromCharCode(...new TextEncoder().encode(AUDIO_FX_WORKLET_SOURCE)),
)}`;
await ctx.audioWorklet.addModule(url);
})();
let modulePromise = registered.get(ctx);
if (!modulePromise) {
modulePromise = (async () => {
if (!ctx.audioWorklet) {
throw new Error(
"AudioWorklet is unavailable — the page needs a secure context (https, localhost or file://)",
);
}
// A data: URL rather than a blob:, because a blob inherits the page origin
// and is treated as opaque on a file:// page, where the module then fails
// to load with an unhelpful AbortError.
const url = `data:text/javascript;base64,${btoa(
String.fromCharCode(...new TextEncoder().encode(AUDIO_FX_WORKLET_SOURCE)),
)}`;
await ctx.audioWorklet.addModule(url);
readyContexts.add(ctx);
})();
registered.set(ctx, modulePromise);
}
return modulePromise;
}
/** Test seam: forget the cached registration so a fresh context can register. */
export function __resetAudioFxWorkletsForTests(): void {
modulePromise = undefined;
}
+36 -7
View File
@@ -11,7 +11,12 @@
*/
import { HF_AUDIO_FX_ATTR, parseAudioFxChain, type HfAudioFxChain } from "../audioFx.js";
import { buildFxChain, chainNeedsWorklets, ensureAudioFxWorklets } from "../audio/audioFxGraph.js";
import {
audioFxWorkletsReady,
buildFxChain,
chainNeedsWorklets,
ensureAudioFxWorklets,
} from "../audio/audioFxGraph.js";
import type { FxChainHandle } from "../audio/audioFxGraph.js";
const EMPTY: HfAudioFxChain = { version: 1, nodes: [] };
@@ -56,6 +61,36 @@ export function attachElementFxChain(
return null;
}
// An AudioWorkletNode cannot be constructed before its processor is
// registered — it throws, and the whole chain is lost. So when the chain
// needs worklets and the module has not landed yet, play dry and swap the
// graph in once registration resolves.
if (chainNeedsWorklets(chain) && !audioFxWorkletsReady(ctx)) {
source.connect(destination);
let cancelled = false;
let pending: FxChainHandle | null = null;
void ensureAudioFxWorklets(ctx)
.then(() => {
if (cancelled) return;
try {
const late = buildFxChain(ctx, chain);
source.disconnect(destination);
source.connect(late.input);
late.output.connect(destination);
pending = late;
} catch {
// Still unbuildable; the dry connection already stands.
}
})
.catch(() => undefined);
return {
dispose: () => {
cancelled = true;
pending?.dispose();
},
};
}
let handle: FxChainHandle;
try {
handle = buildFxChain(ctx, chain);
@@ -68,12 +103,6 @@ export function attachElementFxChain(
source.connect(handle.input);
handle.output.connect(destination);
if (chainNeedsWorklets(chain)) {
// Worklet processors are registered lazily; until the module resolves those
// nodes pass silence, which is a brief dropout rather than a broken track.
void ensureAudioFxWorklets(ctx).catch(() => undefined);
}
// Follow the attribute while the source plays, so dragging a knob is heard
// without rescheduling the track. Values-only changes re-parameterise the
// running graph and land on the next 128-sample quantum; a shape change