Skip to content

Commit c5f3fff

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 54eb030 commit c5f3fff

4 files changed

Lines changed: 163 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: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,22 +58,59 @@ 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

106+
/**
107+
* Filter types whose Q a BiquadFilterNode actually reads. The spec leaves it
108+
* unused for shelving filters, so the registry offers no shelf Q and the graph
109+
* must expose none either — the exposure invariant would otherwise advertise an
110+
* AudioParam for a knob nobody can set.
111+
*/
112+
const USES_Q: ReadonlySet<BiquadFilterType> = new Set(["peaking", "highpass", "lowpass"]);
113+
77114
function biquad(type: BiquadFilterType, useGain: boolean): Builder {
78115
return (ctx, p) => {
79116
const f = ctx.createBiquadFilter();
@@ -84,7 +121,11 @@ function biquad(type: BiquadFilterType, useGain: boolean): Builder {
84121
if (useGain) f.gain.value = n(v.gain);
85122
};
86123
apply(p);
87-
return simple(f, apply);
124+
return simple(f, apply, {
125+
frequency: [{ param: f.frequency }],
126+
...(USES_Q.has(type) ? { q: [{ param: f.Q }] } : {}),
127+
...(useGain ? { gain: [{ param: f.gain }] } : {}),
128+
});
88129
};
89130
}
90131

@@ -102,6 +143,8 @@ function onePoleBuilder(kind: "highpass" | "lowpass"): Builder {
102143
? ctx.createIIRFilter([1 / (1 + k), -1 / (1 + k)], [1, (k - 1) / (k + 1)])
103144
: ctx.createIIRFilter([k / (1 + k), k / (1 + k)], [1, (k - 1) / (k + 1)]);
104145
// IIRFilterNode coefficients are immutable; the caller rebuilds on change.
146+
// Nothing here is schedulable either, so a frequency lane on a one-pole
147+
// filter has nowhere to write — the scheduler skips what is not exposed.
105148
return simple(node, () => {});
106149
};
107150
}
@@ -154,6 +197,9 @@ const waveshaper: Builder = (ctx, p) => {
154197
input: preGain,
155198
output: postGain,
156199
update: apply,
200+
// The curve itself is rebuilt wholesale, but the make-up gain after it is
201+
// an ordinary AudioParam.
202+
automation: { output: [{ param: postGain.gain, map: (v) => Math.pow(10, v / 20) }] },
157203
dispose: () => {
158204
preGain.disconnect();
159205
ws.disconnect();
@@ -185,6 +231,11 @@ const delayFeedback: Builder = (ctx, p) => {
185231
input,
186232
output: out,
187233
update: apply,
234+
automation: {
235+
time: [{ param: dl.delayTime, map: (v) => Math.min(5, msToSec(v)) }],
236+
feedback: [{ param: fb.gain }],
237+
mix: mixTargets(wet.gain, dry.gain),
238+
},
188239
dispose: () => [input, out, dl, fb, wet, dry].forEach((x) => x.disconnect()),
189240
};
190241
};
@@ -213,6 +264,12 @@ const chorusLfo: Builder = (ctx, p) => {
213264
input,
214265
output: out,
215266
update: apply,
267+
automation: {
268+
delay: [{ param: dl.delayTime, map: msToSec }],
269+
depth: [{ param: depth.gain, map: msToSec }],
270+
speed: [{ param: lfo.frequency }],
271+
mix: mixTargets(wet.gain, dry.gain),
272+
},
216273
dispose: () => {
217274
try {
218275
lfo.stop();
@@ -279,6 +336,13 @@ const allpassPhaser: Builder = (ctx, p) => {
279336
input,
280337
output: out,
281338
update: apply,
339+
// `delay` and `decay` set the sweep centre, which feeds every stage's
340+
// frequency at once — not one knob, one param — so they stay unautomated.
341+
automation: {
342+
speed: [{ param: lfo.frequency }],
343+
in_gain: [{ param: dry.gain }],
344+
out_gain: [{ param: wet.gain }],
345+
},
282346
dispose: () => {
283347
try {
284348
lfo.stop();
@@ -318,6 +382,9 @@ const convolver: Builder = (ctx, p) => {
318382
input,
319383
output: out,
320384
update: apply,
385+
// Size and damping regenerate the impulse response, so only the wet/dry
386+
// balance is schedulable.
387+
automation: { wet: [{ param: wet.gain }], dry: [{ param: dry.gain }] },
321388
dispose: () => [input, out, conv, wet, dry].forEach((x) => x.disconnect()),
322389
};
323390
};

packages/core/src/audioAutomation.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ export interface AutomationRange {
8888
unit: string;
8989
label: string;
9090
scale: "linear" | "log";
91+
/** Where an empty lane draws its flat line, and what a new point starts at. */
92+
default: number;
9193
}
9294

9395
export const VOLUME_RANGE: AutomationRange = {
@@ -97,6 +99,7 @@ export const VOLUME_RANGE: AutomationRange = {
9799
unit: "",
98100
label: "Volume",
99101
scale: "linear",
102+
default: 1,
100103
};
101104

102105
/**
@@ -124,6 +127,7 @@ export function resolveAutomationRange(
124127
unit: p.unit,
125128
label: `${def?.label ?? node.type} · ${p.label}`,
126129
scale: p.scale === "log" && p.min > 0 ? "log" : "linear",
130+
default: p.default,
127131
};
128132
}
129133

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 = {
@@ -411,6 +423,7 @@ export const HF_AUDIO_FX: readonly HfAudioFxDef[] = [
411423
{
412424
kind: "number",
413425
key: "output",
426+
automatable: true,
414427
label: "Output",
415428
unit: "dB",
416429
min: -24,
@@ -482,6 +495,7 @@ export const HF_AUDIO_FX: readonly HfAudioFxDef[] = [
482495
{
483496
kind: "number",
484497
key: "time",
498+
automatable: true,
485499
label: "Time",
486500
unit: "ms",
487501
min: 1,
@@ -495,6 +509,7 @@ export const HF_AUDIO_FX: readonly HfAudioFxDef[] = [
495509
{
496510
kind: "number",
497511
key: "feedback",
512+
automatable: true,
498513
label: "Feedback",
499514
unit: "",
500515
min: 0.01,
@@ -505,6 +520,7 @@ export const HF_AUDIO_FX: readonly HfAudioFxDef[] = [
505520
{
506521
kind: "number",
507522
key: "mix",
523+
automatable: true,
508524
label: "Mix",
509525
unit: "",
510526
min: 0,
@@ -524,6 +540,7 @@ export const HF_AUDIO_FX: readonly HfAudioFxDef[] = [
524540
{
525541
kind: "number",
526542
key: "delay",
543+
automatable: true,
527544
label: "Delay",
528545
unit: "ms",
529546
min: 1,
@@ -534,6 +551,7 @@ export const HF_AUDIO_FX: readonly HfAudioFxDef[] = [
534551
{
535552
kind: "number",
536553
key: "depth",
554+
automatable: true,
537555
label: "Depth",
538556
unit: "ms",
539557
min: 0,
@@ -544,6 +562,7 @@ export const HF_AUDIO_FX: readonly HfAudioFxDef[] = [
544562
{
545563
kind: "number",
546564
key: "speed",
565+
automatable: true,
547566
label: "Rate",
548567
unit: "Hz",
549568
min: 0.01,
@@ -554,6 +573,7 @@ export const HF_AUDIO_FX: readonly HfAudioFxDef[] = [
554573
{
555574
kind: "number",
556575
key: "mix",
576+
automatable: true,
557577
label: "Mix",
558578
unit: "",
559579
min: 0,
@@ -573,6 +593,7 @@ export const HF_AUDIO_FX: readonly HfAudioFxDef[] = [
573593
{
574594
kind: "number",
575595
key: "in_gain",
596+
automatable: true,
576597
label: "Input",
577598
unit: "",
578599
min: 0,
@@ -583,6 +604,7 @@ export const HF_AUDIO_FX: readonly HfAudioFxDef[] = [
583604
{
584605
kind: "number",
585606
key: "out_gain",
607+
automatable: true,
586608
label: "Output",
587609
unit: "",
588610
min: 0,
@@ -615,6 +637,7 @@ export const HF_AUDIO_FX: readonly HfAudioFxDef[] = [
615637
{
616638
kind: "number",
617639
key: "speed",
640+
automatable: true,
618641
label: "Rate",
619642
unit: "Hz",
620643
min: 0.1,
@@ -666,6 +689,7 @@ export const HF_AUDIO_FX: readonly HfAudioFxDef[] = [
666689
{
667690
kind: "number",
668691
key: "wet",
692+
automatable: true,
669693
label: "Wet",
670694
unit: "",
671695
min: 0,
@@ -676,6 +700,7 @@ export const HF_AUDIO_FX: readonly HfAudioFxDef[] = [
676700
{
677701
kind: "number",
678702
key: "dry",
703+
automatable: true,
679704
label: "Dry",
680705
unit: "",
681706
min: 0,

0 commit comments

Comments
 (0)