Skip to content

Commit cd8d016

Browse files
committed
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.
1 parent 39bae14 commit cd8d016

3 files changed

Lines changed: 91 additions & 33 deletions

File tree

‎packages/core/src/audio/audioFxGraph.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
type HfAudioFxChain,
1515
type HfAudioFxParamValues,
1616
} from "../audioFx.js";
17-
import { ensureAudioFxWorklets } from "./audioFxWorklets.js";
17+
import { audioFxWorkletsReady, ensureAudioFxWorklets } from "./audioFxWorklets.js";
1818

1919
/**
2020
* Deterministic reverb impulse, shared by both engines so the browser and the
@@ -432,4 +432,4 @@ export function buildFxChain(ctx: BaseAudioContext, chain: HfAudioFxChain): FxCh
432432
};
433433
}
434434

435-
export { ensureAudioFxWorklets };
435+
export { audioFxWorkletsReady, ensureAudioFxWorklets };

‎packages/core/src/audio/audioFxWorklets.ts‎

Lines changed: 53 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@ function kneeGain(envDb, thresholdDb, ratio, kneeDb) {
4747
return over > 0 ? -(over * (1 - 1 / ratio)) : 0;
4848
}
4949
50+
// log10/exp per sample is the single most expensive thing a dynamics processor
51+
// can do on the audio thread, and every sample below the knee needs neither:
52+
// its gain is exactly unity. Comparing envelopes in the linear domain lets the
53+
// quiet majority of samples skip the transcendentals entirely.
54+
const LN10_OVER_20 = Math.LN10 / 20;
55+
const dbToLinFast = (db) => Math.exp(db * LN10_OVER_20);
56+
5057
class HfCompressor extends AudioWorkletProcessor {
5158
constructor(o) {
5259
super();
@@ -65,14 +72,26 @@ class HfCompressor extends AudioWorkletProcessor {
6572
const mix = p.mix ?? 1;
6673
// Knee is expressed as a ratio in FFmpeg; convert to dB width.
6774
const kneeDb = 20 * Math.log10(Math.max(1.0001, p.knee ?? 2.83));
75+
const thresholdDb = p.threshold ?? -24;
76+
const ratio = p.ratio ?? 4;
77+
// Below this the gain computer returns unity, so the sample needs no
78+
// logarithm at all.
79+
const kneeStartLin = dbToLin(thresholdDb - kneeDb);
80+
const dry = 1 - mix;
6881
for (let ch = 0; ch < i.length; ch++) {
6982
const inp = i[ch], out = o[ch];
7083
for (let n = 0; n < inp.length; n++) {
7184
const x = inp[n];
85+
// Per-channel follower, and the sub-knee shortcut: below the knee the
86+
// gain is exactly unity, so the sample needs no logarithm at all.
7287
const env = this.env.push(ch, x);
73-
const envDb = env > 1e-9 ? 20 * Math.log10(env) : -200;
74-
const g = dbToLin(kneeGain(envDb, p.threshold ?? -24, p.ratio ?? 4, kneeDb));
75-
out[n] = x * g * makeup * mix + x * (1 - mix);
88+
if (env <= kneeStartLin) {
89+
out[n] = x * makeup * mix + x * dry;
90+
continue;
91+
}
92+
const envDb = 20 * Math.log10(env);
93+
const g = dbToLinFast(kneeGain(envDb, thresholdDb, ratio, kneeDb));
94+
out[n] = x * g * makeup * mix + x * dry;
7695
}
7796
}
7897
return true;
@@ -139,6 +158,7 @@ class HfGate extends AudioWorkletProcessor {
139158
const x = inp[n];
140159
const env = this.env.push(ch, x);
141160
let target = 1;
161+
// Above the threshold the gate is fully open; skip the pow entirely.
142162
if (env < threshold) {
143163
const under = env > 1e-9 ? threshold / env : 1e9;
144164
target = Math.max(floor, 1 / Math.pow(under, ratio - 1));
@@ -191,31 +211,40 @@ class HfBitcrush extends AudioWorkletProcessor {
191211
registerProcessor("hf-bitcrush", HfBitcrush);
192212
`;
193213

194-
let modulePromise: Promise<void> | undefined;
214+
// Registration is per context, not per module: a processor registered on one
215+
// AudioContext does not exist on another, so caching a single promise made
216+
// every context after the first believe it was ready when it was not.
217+
const registered = new WeakMap<BaseAudioContext, Promise<void>>();
218+
219+
/** True once the processors are usable on this context. */
220+
export function audioFxWorkletsReady(ctx: BaseAudioContext): boolean {
221+
return readyContexts.has(ctx);
222+
}
223+
const readyContexts = new WeakSet<BaseAudioContext>();
195224

196225
/**
197-
* Register the processors on a context. Idempotent per module instance, since
226+
* Register the processors on a context. Idempotent per context, since
198227
* addModule throws if the same processor name is registered twice.
199228
*/
200229
export function ensureAudioFxWorklets(ctx: BaseAudioContext): Promise<void> {
201-
modulePromise ??= (async () => {
202-
if (!ctx.audioWorklet) {
203-
throw new Error(
204-
"AudioWorklet is unavailable — the page needs a secure context (https, localhost or file://)",
205-
);
206-
}
207-
// A data: URL rather than a blob:, because a blob inherits the page origin
208-
// and is treated as opaque on a file:// page, where the module then fails
209-
// to load with an unhelpful AbortError.
210-
const url = `data:text/javascript;base64,${btoa(
211-
String.fromCharCode(...new TextEncoder().encode(AUDIO_FX_WORKLET_SOURCE)),
212-
)}`;
213-
await ctx.audioWorklet.addModule(url);
214-
})();
230+
let modulePromise = registered.get(ctx);
231+
if (!modulePromise) {
232+
modulePromise = (async () => {
233+
if (!ctx.audioWorklet) {
234+
throw new Error(
235+
"AudioWorklet is unavailable — the page needs a secure context (https, localhost or file://)",
236+
);
237+
}
238+
// A data: URL rather than a blob:, because a blob inherits the page origin
239+
// and is treated as opaque on a file:// page, where the module then fails
240+
// to load with an unhelpful AbortError.
241+
const url = `data:text/javascript;base64,${btoa(
242+
String.fromCharCode(...new TextEncoder().encode(AUDIO_FX_WORKLET_SOURCE)),
243+
)}`;
244+
await ctx.audioWorklet.addModule(url);
245+
readyContexts.add(ctx);
246+
})();
247+
registered.set(ctx, modulePromise);
248+
}
215249
return modulePromise;
216250
}
217-
218-
/** Test seam: forget the cached registration so a fresh context can register. */
219-
export function __resetAudioFxWorkletsForTests(): void {
220-
modulePromise = undefined;
221-
}

‎packages/core/src/runtime/audioFx.ts‎

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@
1111
*/
1212

1313
import { HF_AUDIO_FX_ATTR, parseAudioFxChain, type HfAudioFxChain } from "../audioFx.js";
14-
import { buildFxChain, chainNeedsWorklets, ensureAudioFxWorklets } from "../audio/audioFxGraph.js";
14+
import {
15+
audioFxWorkletsReady,
16+
buildFxChain,
17+
chainNeedsWorklets,
18+
ensureAudioFxWorklets,
19+
} from "../audio/audioFxGraph.js";
1520
import type { FxChainHandle } from "../audio/audioFxGraph.js";
1621

1722
const EMPTY: HfAudioFxChain = { version: 1, nodes: [] };
@@ -56,6 +61,36 @@ export function attachElementFxChain(
5661
return null;
5762
}
5863

64+
// An AudioWorkletNode cannot be constructed before its processor is
65+
// registered — it throws, and the whole chain is lost. So when the chain
66+
// needs worklets and the module has not landed yet, play dry and swap the
67+
// graph in once registration resolves.
68+
if (chainNeedsWorklets(chain) && !audioFxWorkletsReady(ctx)) {
69+
source.connect(destination);
70+
let cancelled = false;
71+
let pending: FxChainHandle | null = null;
72+
void ensureAudioFxWorklets(ctx)
73+
.then(() => {
74+
if (cancelled) return;
75+
try {
76+
const late = buildFxChain(ctx, chain);
77+
source.disconnect(destination);
78+
source.connect(late.input);
79+
late.output.connect(destination);
80+
pending = late;
81+
} catch {
82+
// Still unbuildable; the dry connection already stands.
83+
}
84+
})
85+
.catch(() => undefined);
86+
return {
87+
dispose: () => {
88+
cancelled = true;
89+
pending?.dispose();
90+
},
91+
};
92+
}
93+
5994
let handle: FxChainHandle;
6095
try {
6196
handle = buildFxChain(ctx, chain);
@@ -68,12 +103,6 @@ export function attachElementFxChain(
68103
source.connect(handle.input);
69104
handle.output.connect(destination);
70105

71-
if (chainNeedsWorklets(chain)) {
72-
// Worklet processors are registered lazily; until the module resolves those
73-
// nodes pass silence, which is a brief dropout rather than a broken track.
74-
void ensureAudioFxWorklets(ctx).catch(() => undefined);
75-
}
76-
77106
// Follow the attribute while the source plays, so dragging a knob is heard
78107
// without rescheduling the track. Values-only changes re-parameterise the
79108
// running graph and land on the next 128-sample quantum; a shape change

0 commit comments

Comments
 (0)