From 78bce4dcdc3bd5f4efca1d53706f2e26d9f371ac Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 1 Aug 2026 17:01:21 -0700 Subject: [PATCH] fix(core): register FX worklets before building nodes that need them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/core/src/audio/audioFxGraph.ts | 4 +- packages/core/src/audio/audioFxWorklets.ts | 77 +++++++++++++++------- packages/core/src/runtime/audioFx.ts | 43 ++++++++++-- 3 files changed, 91 insertions(+), 33 deletions(-) diff --git a/packages/core/src/audio/audioFxGraph.ts b/packages/core/src/audio/audioFxGraph.ts index ab8dc34101..c463e0467a 100644 --- a/packages/core/src/audio/audioFxGraph.ts +++ b/packages/core/src/audio/audioFxGraph.ts @@ -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 }; diff --git a/packages/core/src/audio/audioFxWorklets.ts b/packages/core/src/audio/audioFxWorklets.ts index 4a36a938ba..003d3572a1 100644 --- a/packages/core/src/audio/audioFxWorklets.ts +++ b/packages/core/src/audio/audioFxWorklets.ts @@ -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 | 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>(); + +/** True once the processors are usable on this context. */ +export function audioFxWorkletsReady(ctx: BaseAudioContext): boolean { + return readyContexts.has(ctx); +} +const readyContexts = new WeakSet(); /** - * 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 { - 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; -} diff --git a/packages/core/src/runtime/audioFx.ts b/packages/core/src/runtime/audioFx.ts index 1aff1c5640..0c6290a5e3 100644 --- a/packages/core/src/runtime/audioFx.ts +++ b/packages/core/src/runtime/audioFx.ts @@ -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