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