Skip to content

Commit d2c7cb6

Browse files
vanceingallsclaude
andcommitted
feat(core): expose the AudioParams behind automatable FX knobs
Marks the knobs an automation lane can drive and has each graph builder hand back the AudioParam behind them, so a scheduler can write to a running effect without knowing what the effect is. A knob is not always one AudioParam. A wet/dry mix is two gains moving in opposition, and a knob in milliseconds drives a delay time in seconds, so each target carries the mapping out of the knob's own declared unit. What stays unautomatable is stated where it is decided: a WaveShaper curve, a convolution impulse and a one-pole filter's coefficients are all rebuilt wholesale rather than scheduled, and the four worklet effects take values by postMessage rather than through AudioParams. The registry flag is written by hand, so a test builds every effect and checks the exposure both ways — nothing flagged is missing, nothing exposed is unflagged. A flag that lied would offer a lane that silently did nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 0a2f627 commit d2c7cb6

3 files changed

Lines changed: 151 additions & 3 deletions

File tree

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

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,3 +341,67 @@ describe("levels and per-channel state", () => {
341341
expect(buildFxChain(ctx, twoPole(300)).update(twoPole(2000))).toBe(true);
342342
});
343343
});
344+
345+
describe("automatable parameters", () => {
346+
/**
347+
* The registry's `automatable` flag is what the panel and the scheduler both
348+
* trust, and it is written by hand. Build every effect and check that each
349+
* flagged knob really does reach an AudioParam — a flag that lies would
350+
* offer an automation lane that silently does nothing.
351+
*/
352+
it("exposes an AudioParam for every knob the registry marks automatable", () => {
353+
for (const def of HF_AUDIO_FX) {
354+
const ctx = new FakeCtx() as unknown as BaseAudioContext;
355+
const handle = buildFxNode(ctx, def.id, defaultAudioFxParams(def.id));
356+
const flagged = def.params
357+
.filter((p) => p.kind === "number" && p.automatable)
358+
.map((p) => p.key);
359+
for (const key of flagged) {
360+
const targets = handle.automation?.[key];
361+
expect(
362+
targets,
363+
`${def.id}.${key} is flagged automatable but exposes no AudioParam`,
364+
).toBeTruthy();
365+
expect(targets?.length, `${def.id}.${key} exposes an empty target list`).toBeGreaterThan(0);
366+
}
367+
}
368+
});
369+
370+
it("exposes nothing the registry has not flagged", () => {
371+
for (const def of HF_AUDIO_FX) {
372+
const ctx = new FakeCtx() as unknown as BaseAudioContext;
373+
const handle = buildFxNode(ctx, def.id, defaultAudioFxParams(def.id));
374+
const flagged = new Set(
375+
def.params.filter((p) => p.kind === "number" && p.automatable).map((p) => p.key),
376+
);
377+
for (const key of Object.keys(handle.automation ?? {})) {
378+
expect(flagged.has(key), `${def.id}.${key} is exposed but not flagged automatable`).toBe(
379+
true,
380+
);
381+
}
382+
}
383+
});
384+
385+
it("maps a knob's own unit onto the AudioParam it drives", () => {
386+
const ctx = new FakeCtx() as unknown as BaseAudioContext;
387+
const delay = buildFxNode(ctx, "delay", { ...defaultAudioFxParams("delay"), time: 250 });
388+
// The knob reads milliseconds; delayTime is in seconds.
389+
expect(delay.automation?.time?.[0]?.map?.(250)).toBeCloseTo(0.25, 10);
390+
// A wet/dry mix is two gains moving in opposition, not one.
391+
const mix = delay.automation?.mix ?? [];
392+
expect(mix.length).toBe(2);
393+
expect(mix[0]?.map?.(0.3) ?? 0.3).toBeCloseTo(0.3, 10);
394+
expect(mix[1]?.map?.(0.3)).toBeCloseTo(0.7, 10);
395+
});
396+
397+
it("leaves a one-pole filter unexposed, since its coefficients are fixed", () => {
398+
const ctx = new FakeCtx() as unknown as BaseAudioContext;
399+
const twoPole = buildFxNode(ctx, "highpass", defaultAudioFxParams("highpass"));
400+
expect(twoPole.automation?.frequency?.length).toBe(1);
401+
const onePole = buildFxNode(ctx, "highpass", {
402+
...defaultAudioFxParams("highpass"),
403+
poles: "1",
404+
});
405+
expect(onePole.automation?.frequency).toBeUndefined();
406+
});
407+
});

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

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,20 +58,49 @@ export function synthesizeReverbImpulse(
5858
return out;
5959
}
6060

61+
/**
62+
* Where an automation lane writes when it drives one knob.
63+
*
64+
* A knob is not always one AudioParam. A wet/dry mix is two gains moving in
65+
* opposition, and a knob in milliseconds drives a delay time in seconds, so
66+
* each target carries its own mapping out of the knob's declared unit.
67+
*/
68+
export interface FxParamTarget {
69+
param: AudioParam;
70+
map?: (value: number) => number;
71+
}
72+
6173
export interface FxNodeHandle {
6274
input: AudioNode;
6375
output: AudioNode;
6476
update(params: HfAudioFxParamValues): void;
77+
/**
78+
* AudioParams behind the knobs the registry marks `automatable`, keyed by
79+
* parameter key. Absent for a node whose values cannot be scheduled.
80+
*/
81+
automation?: Record<string, FxParamTarget[]>;
6582
dispose(): void;
6683
}
6784

6885
type Builder = (ctx: BaseAudioContext, p: HfAudioFxParamValues) => FxNodeHandle;
6986

7087
const n = (v: number | string | undefined): number => (typeof v === "number" ? v : Number(v ?? 0));
7188

89+
/** Milliseconds on the knob, seconds on the AudioParam. */
90+
const msToSec = (v: number): number => v / 1000;
91+
92+
/** A wet/dry pair: the dry side is whatever the wet side is not. */
93+
function mixTargets(wet: AudioParam, dry: AudioParam): FxParamTarget[] {
94+
return [{ param: wet }, { param: dry, map: (v) => 1 - v }];
95+
}
96+
7297
/** A node that is its own input and output and has nothing to tear down. */
73-
function simple(node: AudioNode, update: (p: HfAudioFxParamValues) => void): FxNodeHandle {
74-
return { input: node, output: node, update, dispose: () => node.disconnect() };
98+
function simple(
99+
node: AudioNode,
100+
update: (p: HfAudioFxParamValues) => void,
101+
automation?: Record<string, FxParamTarget[]>,
102+
): FxNodeHandle {
103+
return { input: node, output: node, update, automation, dispose: () => node.disconnect() };
75104
}
76105

77106
function biquad(type: BiquadFilterType, useGain: boolean): Builder {
@@ -84,7 +113,11 @@ function biquad(type: BiquadFilterType, useGain: boolean): Builder {
84113
if (useGain) f.gain.value = n(v.gain);
85114
};
86115
apply(p);
87-
return simple(f, apply);
116+
return simple(f, apply, {
117+
frequency: [{ param: f.frequency }],
118+
q: [{ param: f.Q }],
119+
...(useGain ? { gain: [{ param: f.gain }] } : {}),
120+
});
88121
};
89122
}
90123

@@ -102,6 +135,8 @@ function onePoleBuilder(kind: "highpass" | "lowpass"): Builder {
102135
? ctx.createIIRFilter([1 / (1 + k), -1 / (1 + k)], [1, (k - 1) / (k + 1)])
103136
: ctx.createIIRFilter([k / (1 + k), k / (1 + k)], [1, (k - 1) / (k + 1)]);
104137
// IIRFilterNode coefficients are immutable; the caller rebuilds on change.
138+
// Nothing here is schedulable either, so a frequency lane on a one-pole
139+
// filter has nowhere to write — the scheduler skips what is not exposed.
105140
return simple(node, () => {});
106141
};
107142
}
@@ -154,6 +189,9 @@ const waveshaper: Builder = (ctx, p) => {
154189
input: preGain,
155190
output: postGain,
156191
update: apply,
192+
// The curve itself is rebuilt wholesale, but the make-up gain after it is
193+
// an ordinary AudioParam.
194+
automation: { output: [{ param: postGain.gain, map: (v) => Math.pow(10, v / 20) }] },
157195
dispose: () => {
158196
preGain.disconnect();
159197
ws.disconnect();
@@ -185,6 +223,11 @@ const delayFeedback: Builder = (ctx, p) => {
185223
input,
186224
output: out,
187225
update: apply,
226+
automation: {
227+
time: [{ param: dl.delayTime, map: (v) => Math.min(5, msToSec(v)) }],
228+
feedback: [{ param: fb.gain }],
229+
mix: mixTargets(wet.gain, dry.gain),
230+
},
188231
dispose: () => [input, out, dl, fb, wet, dry].forEach((x) => x.disconnect()),
189232
};
190233
};
@@ -213,6 +256,12 @@ const chorusLfo: Builder = (ctx, p) => {
213256
input,
214257
output: out,
215258
update: apply,
259+
automation: {
260+
delay: [{ param: dl.delayTime, map: msToSec }],
261+
depth: [{ param: depth.gain, map: msToSec }],
262+
speed: [{ param: lfo.frequency }],
263+
mix: mixTargets(wet.gain, dry.gain),
264+
},
216265
dispose: () => {
217266
try {
218267
lfo.stop();
@@ -279,6 +328,13 @@ const allpassPhaser: Builder = (ctx, p) => {
279328
input,
280329
output: out,
281330
update: apply,
331+
// `delay` and `decay` set the sweep centre, which feeds every stage's
332+
// frequency at once — not one knob, one param — so they stay unautomated.
333+
automation: {
334+
speed: [{ param: lfo.frequency }],
335+
in_gain: [{ param: dry.gain }],
336+
out_gain: [{ param: wet.gain }],
337+
},
282338
dispose: () => {
283339
try {
284340
lfo.stop();
@@ -318,6 +374,9 @@ const convolver: Builder = (ctx, p) => {
318374
input,
319375
output: out,
320376
update: apply,
377+
// Size and damping regenerate the impulse response, so only the wet/dry
378+
// balance is schedulable.
379+
automation: { wet: [{ param: wet.gain }], dry: [{ param: dry.gain }] },
321380
dispose: () => [input, out, conv, wet, dry].forEach((x) => x.disconnect()),
322381
};
323382
};

‎packages/core/src/audioFx.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,15 @@ export interface HfAudioFxNumberParam {
3333
default: number;
3434
/** Frequency-style controls need a log knob to be usable. */
3535
scale?: "linear" | "log";
36+
/**
37+
* The knob is backed by an AudioParam, so an automation lane can drive it.
38+
*
39+
* Not every knob can be: a WaveShaper curve, a convolution impulse and a
40+
* worklet's `processorOptions` are all set wholesale rather than scheduled.
41+
* A graph builder must expose an AudioParam for every parameter flagged here
42+
* — `audioFxGraph.test.ts` builds each effect and checks it.
43+
*/
44+
automatable?: boolean;
3645
/** One line explaining what turning this does, shown on the control. */
3746
hint?: string;
3847
}
@@ -81,6 +90,7 @@ const freq = (
8190
step: 1,
8291
default: def,
8392
scale: "log",
93+
automatable: true,
8494
});
8595

8696
const qParam = (def = 0.707, hint = "Bandwidth — higher is narrower."): HfAudioFxNumberParam => ({
@@ -93,6 +103,7 @@ const qParam = (def = 0.707, hint = "Bandwidth — higher is narrower."): HfAudi
93103
step: 0.01,
94104
default: def,
95105
scale: "log",
106+
automatable: true,
96107
hint,
97108
});
98109

@@ -105,6 +116,7 @@ const gainDb = (min = -40, max = 40, def = 0): HfAudioFxNumberParam => ({
105116
max,
106117
step: 0.1,
107118
default: def,
119+
automatable: true,
108120
});
109121

110122
const poles: HfAudioFxEnumParam = {
@@ -408,6 +420,7 @@ export const HF_AUDIO_FX: readonly HfAudioFxDef[] = [
408420
{
409421
kind: "number",
410422
key: "output",
423+
automatable: true,
411424
label: "Output",
412425
unit: "dB",
413426
min: -24,
@@ -489,6 +502,7 @@ export const HF_AUDIO_FX: readonly HfAudioFxDef[] = [
489502
{
490503
kind: "number",
491504
key: "time",
505+
automatable: true,
492506
label: "Time",
493507
unit: "ms",
494508
min: 1,
@@ -502,6 +516,7 @@ export const HF_AUDIO_FX: readonly HfAudioFxDef[] = [
502516
{
503517
kind: "number",
504518
key: "feedback",
519+
automatable: true,
505520
label: "Feedback",
506521
unit: "",
507522
min: 0.01,
@@ -512,6 +527,7 @@ export const HF_AUDIO_FX: readonly HfAudioFxDef[] = [
512527
{
513528
kind: "number",
514529
key: "mix",
530+
automatable: true,
515531
label: "Mix",
516532
unit: "",
517533
min: 0,
@@ -531,6 +547,7 @@ export const HF_AUDIO_FX: readonly HfAudioFxDef[] = [
531547
{
532548
kind: "number",
533549
key: "delay",
550+
automatable: true,
534551
label: "Delay",
535552
unit: "ms",
536553
min: 1,
@@ -541,6 +558,7 @@ export const HF_AUDIO_FX: readonly HfAudioFxDef[] = [
541558
{
542559
kind: "number",
543560
key: "depth",
561+
automatable: true,
544562
label: "Depth",
545563
unit: "ms",
546564
min: 0,
@@ -551,6 +569,7 @@ export const HF_AUDIO_FX: readonly HfAudioFxDef[] = [
551569
{
552570
kind: "number",
553571
key: "speed",
572+
automatable: true,
554573
label: "Rate",
555574
unit: "Hz",
556575
min: 0.01,
@@ -571,6 +590,7 @@ export const HF_AUDIO_FX: readonly HfAudioFxDef[] = [
571590
{
572591
kind: "number",
573592
key: "mix",
593+
automatable: true,
574594
label: "Mix",
575595
unit: "",
576596
min: 0,
@@ -590,6 +610,7 @@ export const HF_AUDIO_FX: readonly HfAudioFxDef[] = [
590610
{
591611
kind: "number",
592612
key: "in_gain",
613+
automatable: true,
593614
label: "Input",
594615
unit: "",
595616
min: 0,
@@ -600,6 +621,7 @@ export const HF_AUDIO_FX: readonly HfAudioFxDef[] = [
600621
{
601622
kind: "number",
602623
key: "out_gain",
624+
automatable: true,
603625
label: "Output",
604626
unit: "",
605627
min: 0,
@@ -632,6 +654,7 @@ export const HF_AUDIO_FX: readonly HfAudioFxDef[] = [
632654
{
633655
kind: "number",
634656
key: "speed",
657+
automatable: true,
635658
label: "Rate",
636659
unit: "Hz",
637660
min: 0.1,
@@ -683,6 +706,7 @@ export const HF_AUDIO_FX: readonly HfAudioFxDef[] = [
683706
{
684707
kind: "number",
685708
key: "wet",
709+
automatable: true,
686710
label: "Wet",
687711
unit: "",
688712
min: 0,
@@ -693,6 +717,7 @@ export const HF_AUDIO_FX: readonly HfAudioFxDef[] = [
693717
{
694718
kind: "number",
695719
key: "dry",
720+
automatable: true,
696721
label: "Dry",
697722
unit: "",
698723
min: 0,

0 commit comments

Comments
 (0)