Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2befe3b
fix(studio): call an effect the same thing in the menu that adds it
vanceingalls Aug 10, 2026
8281023
feat: offer the job, not the machine — the range IS the module
vanceingalls Aug 10, 2026
96f1e9c
feat(studio): two faces — a module opens on one knob, the rest one cl…
vanceingalls Aug 10, 2026
13f574f
feat: the shared frequency ruler, so the vocabulary gets taught
vanceingalls Aug 10, 2026
a2ea0a6
feat(studio): letter the families, and tint each module inside its own
vanceingalls Aug 10, 2026
e477ec2
feat(studio): draw the rack as the signal path it is
vanceingalls Aug 10, 2026
e47c566
docs(plans): record the design build-out
vanceingalls Aug 10, 2026
12a67ab
feat: one knob for the five effects that cannot honestly have one
vanceingalls Aug 10, 2026
c85c95f
docs(plans): the profiles shipped; record the stack
vanceingalls Aug 10, 2026
e015f83
fix(studio): seed a profiled effect on its curve, not at registry def…
vanceingalls Aug 10, 2026
e727e39
feat(studio): switch a preset off, or take it out, as one thing
vanceingalls Aug 10, 2026
373cdbb
feat: ramp a whole preset with one lane, and make Off its zero
vanceingalls Aug 10, 2026
0880a92
feat(studio): light the hovered preset, and show that it is sounding
vanceingalls Aug 10, 2026
a0cfae3
fix(studio): let an author out of the preset and add-effect menus
vanceingalls Aug 10, 2026
83f09a3
feat(studio): audition a preset from the playhead while paused
vanceingalls Aug 10, 2026
5a0b389
feat(core): add the Doofus Worble preset
vanceingalls Aug 10, 2026
2e70999
fix(core): make the character presets sound like different things
vanceingalls Aug 10, 2026
8960359
feat(studio): fold a preset shut, and give each one its own title
vanceingalls Aug 10, 2026
05c108d
feat(studio): real faces for the preset titles, at a size worth reading
vanceingalls Aug 10, 2026
c858ae3
feat(studio): vibrant preset colours, each with its own background
vanceingalls Aug 10, 2026
5a69069
docs(plans): drop the README's pointers to the removed preview generator
vanceingalls Aug 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/core/package-subpaths.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,18 @@
"types": "./dist/audioFxCopy.d.ts",
"environments": ["browser", "bun", "node"]
},
"./audio-fx-profiles": {
"source": "./src/audioFxProfiles.ts",
"runtime": "./dist/audioFxProfiles.js",
"types": "./dist/audioFxProfiles.d.ts",
"environments": ["browser", "bun", "node"]
},
"./audio-fx-jobs": {
"source": "./src/audioFxJobs.ts",
"runtime": "./dist/audioFxJobs.js",
"types": "./dist/audioFxJobs.d.ts",
"environments": ["browser", "bun", "node"]
},
"./audio-fx-eq": {
"source": "./src/audioFxEq.ts",
"runtime": "./dist/audioFxEq.js",
Expand Down
20 changes: 20 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,18 @@
"import": "./src/audioFxCopy.ts",
"types": "./src/audioFxCopy.ts"
},
"./audio-fx-profiles": {
"bun": "./src/audioFxProfiles.ts",
"node": "./dist/audioFxProfiles.js",
"import": "./src/audioFxProfiles.ts",
"types": "./src/audioFxProfiles.ts"
},
"./audio-fx-jobs": {
"bun": "./src/audioFxJobs.ts",
"node": "./dist/audioFxJobs.js",
"import": "./src/audioFxJobs.ts",
"types": "./src/audioFxJobs.ts"
},
"./audio-fx-eq": {
"bun": "./src/audioFxEq.ts",
"node": "./dist/audioFxEq.js",
Expand Down Expand Up @@ -420,6 +432,14 @@
"import": "./dist/audioFxCopy.js",
"types": "./dist/audioFxCopy.d.ts"
},
"./audio-fx-profiles": {
"import": "./dist/audioFxProfiles.js",
"types": "./dist/audioFxProfiles.d.ts"
},
"./audio-fx-jobs": {
"import": "./dist/audioFxJobs.js",
"types": "./dist/audioFxJobs.d.ts"
},
"./audio-fx-eq": {
"import": "./dist/audioFxEq.js",
"types": "./dist/audioFxEq.d.ts"
Expand Down
15 changes: 13 additions & 2 deletions packages/core/src/audio/audioFxAutomation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,13 +245,24 @@ export function scheduleChainAutomation(
chain: HfAudioFxChain,
nodes: readonly AutomatableNode[],
timing: AutomationTiming,
/** The wet/dry blend around each preset run, from `FxChainHandle.presets`. */
presets?: Record<string, FxParamTarget[]>,
): FxParamTarget[] {
const byId = new Map(nodes.filter((n) => n.id).map((n) => [n.id as string, n.handle]));
const scheduled: FxParamTarget[] = [];
for (const lane of automation.lanes) {
const parsed = parseAutomationTarget(lane.target);
if (!parsed || parsed.kind !== "fx") continue;
const targets = byId.get(parsed.nodeId)?.automation?.[parsed.param];
if (!parsed) continue;
// A whole-preset lane drives the wet/dry blend the graph wrapped its run in,
// rather than any node's parameter — which is the point of it: a preset's
// nodes share no automatable parameter, and its worklet effects expose none
// at all.
const targets =
parsed.kind === "preset"
? presets?.[parsed.presetId]
: parsed.kind === "fx"
? byId.get(parsed.nodeId)?.automation?.[parsed.param]
: undefined;
if (!targets || targets.length === 0) continue;
const range = resolveAutomationRange(lane.target, chain);
if (!range) continue;
Expand Down
74 changes: 74 additions & 0 deletions packages/core/src/audio/audioFxGraph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -624,3 +624,77 @@ describe("chain update keeps ids with their effects", () => {
expect(handle.nodes.map((n) => n.id)).toEqual(["n2", "n1"]);
});
});

describe("a preset's run is wrapped in a wet/dry blend", () => {
/** Two nodes from one preset, with an ordinary effect after them. */
const chainWith = (amount?: number): HfAudioFxChain => ({
version: 1,
nodes: [
{
type: "highpass",
id: "p1",
fromPreset: "telephone",
enabled: true,
...(amount === undefined ? {} : { presetAmount: amount }),
params: defaultAudioFxParams("highpass"),
},
{
type: "lowpass",
id: "p2",
fromPreset: "telephone",
enabled: true,
params: defaultAudioFxParams("lowpass"),
},
{ type: "reverb", id: "own", enabled: true, params: defaultAudioFxParams("reverb") },
],
});

it("exposes one blend for the whole preset, not one per node", () => {
// The reason this exists: a preset's nodes share no automatable parameter,
// and its worklet effects expose no AudioParams at all, so there is nothing
// to aim a lane at node-by-node.
const built = buildFxChain(asCtx(ctx()), chainWith());
expect(Object.keys(built.presets)).toEqual(["telephone"]);
// Two gains in opposition, the same shape an effect's own mix knob has.
expect(built.presets.telephone).toHaveLength(2);
});

it("blends dry against wet at the stored amount", () => {
const built = buildFxChain(asCtx(ctx()), chainWith(0.25));
const [wet, dry] = built.presets.telephone ?? [];
expect(wet?.param.value).toBeCloseTo(0.25, 6);
expect(dry?.param.value).toBeCloseTo(0.75, 6);
});

it("is fully applied when nothing says otherwise", () => {
// Every chain written before this shipped means "all of it".
const [wet, dry] = buildFxChain(asCtx(ctx()), chainWith()).presets.telephone ?? [];
expect(wet?.param.value).toBe(1);
expect(dry?.param.value).toBe(0);
});

it("pushes a changed amount into the running graph rather than rebuilding", () => {
// Switching a preset off is a value change, and a rebuild would restart the
// audio underneath it.
const built = buildFxChain(asCtx(ctx()), chainWith(1));
expect(built.update(chainWith(0))).toBe(true);
const [wet, dry] = built.presets.telephone ?? [];
expect(wet?.param.value).toBe(0);
expect(dry?.param.value).toBe(1);
});

it("wraps nothing around effects the author placed themselves", () => {
const built = buildFxChain(asCtx(ctx()), chain("peaking", "reverb"));
expect(Object.keys(built.presets)).toEqual([]);
});

it("unwires the blend on dispose", () => {
// The wrap belongs to the chain rather than to any effect, so it is not in
// `handles` — without this a rebuild leaves a crossfade connected to the
// graph it used to bridge.
const c = ctx();
buildFxChain(asCtx(c), chainWith(0.5)).dispose();
const live = c.created.filter((n) => n.kind === "gain" && !n.disconnected);
expect(live).toEqual([]);
});
});
110 changes: 105 additions & 5 deletions packages/core/src/audio/audioFxGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
getAudioFxDef,
normalizeAudioFxParams,
type HfAudioFxChain,
type HfAudioFxNode,
type HfAudioFxParamValues,
} from "../audioFx.js";
import { audioFxWorkletsReady, ensureAudioFxWorklets } from "./audioFxWorklets.js";
Expand Down Expand Up @@ -535,11 +536,45 @@ export interface FxChainHandle {
output: AudioNode;
/** Built effects in chain order, carrying the node ids lanes address. */
nodes: { id?: string; type: string; handle: FxNodeHandle }[];
/**
* The wet/dry blend around each preset run, by preset id — where a
* whole-preset lane writes. Two gains in opposition, the same shape
* `mixTargets` builds for an effect's own mix knob.
*/
presets: Record<string, FxParamTarget[]>;
/** Re-parameterise in place when the shape is unchanged; false if a rebuild is needed. */
update(chain: HfAudioFxChain): boolean;
dispose(): void;
}

/**
* Consecutive nodes grouped by the preset that wrote them.
*
* `amount` comes off the nodes themselves — a preset is bypassed by setting its
* members' `enabled` to false everywhere else in the codebase, and the wrap has
* to agree with that or the switch and the lane would fight. Absent means fully
* applied, which is what every chain written before this shipped means.
*/
function presetRuns(
nodes: readonly HfAudioFxNode[],
): { preset?: string; amount: number; nodes: HfAudioFxNode[] }[] {
const out: { preset?: string; amount: number; nodes: HfAudioFxNode[] }[] = [];
for (const node of nodes) {
const preset = node.fromPreset;
const last = out.at(-1);
if (last && last.preset === preset) last.nodes.push(node);
else {
const amount = typeof node.presetAmount === "number" ? node.presetAmount : 1;
out.push({
...(preset ? { preset } : {}),
amount: Math.min(1, Math.max(0, amount)),
nodes: [node],
});
}
}
return out;
}

/**
* A signature of everything that changes the graph's *shape* rather than its
* parameter values. When this is unchanged an update can just push new values
Expand Down Expand Up @@ -581,21 +616,67 @@ export function buildFxChain(
const input = ctx.createGain();
const output = ctx.createGain();
const handles: { id?: string; type: string; handle: FxNodeHandle }[] = [];
const presets: { id: string; entry: GainNode; wet: GainNode; dry: GainNode; join: GainNode }[] =
[];

/**
* A preset's consecutive nodes, wrapped in a wet/dry pair.
*
* The rest of the chain is a strict series, which is right for an effect the
* author placed: it is either in the path or it is not. A preset is not one
* effect, though — it is several the author added as a unit, and "how much of
* it is applied" is a question about the unit. Its nodes share no automatable
* parameter, and the worklet ones expose no AudioParams at all, so there is
* nothing to aim a lane at node-by-node. One crossfade around the run is the
* whole answer, and it cannot go half-wrong the way seven lanes can.
*
* Consecutive only, matching what the rack brackets: a preset pulled apart by
* a reorder is no longer a unit, and wrapping across the gap would route the
* effect between its members through the dry leg too.
*/
const runs = presetRuns(enabledAudioFxNodes(chain));

let tail: AudioNode = input;
for (const node of enabledAudioFxNodes(chain)) {
const handle = buildFxNode(ctx, node.type, node.params ?? {}, elapsed);
tail.connect(handle.input);
tail = handle.output;
handles.push({ ...(node.id ? { id: node.id } : {}), type: node.type, handle });
for (const run of runs) {
let wrap: { entry: GainNode; wet: GainNode; dry: GainNode; join: GainNode } | null = null;
if (run.preset) {
const entry = ctx.createGain();
const dry = ctx.createGain();
const wet = ctx.createGain();
const join = ctx.createGain();
wet.gain.value = run.amount;
dry.gain.value = 1 - run.amount;
tail.connect(entry);
// The dry leg bridges the whole run: it leaves before the first effect and
// rejoins after the last, which is what makes amount 0 the untouched
// signal rather than a quieter version of the processed one.
entry.connect(dry).connect(join);
wrap = { entry, wet, dry, join };
tail = entry;
}
for (const node of run.nodes) {
const handle = buildFxNode(ctx, node.type, node.params ?? {}, elapsed);
tail.connect(handle.input);
tail = handle.output;
handles.push({ ...(node.id ? { id: node.id } : {}), type: node.type, handle });
}
if (wrap && run.preset) {
tail.connect(wrap.wet).connect(wrap.join);
presets.push({ id: run.preset, ...wrap });
tail = wrap.join;
}
}
tail.connect(output);

const shape = shapeOf(chain);

const presetTargets: Record<string, FxParamTarget[]> = {};
for (const p of presets) presetTargets[p.id] = mixTargets(p.wet.gain, p.dry.gain);

return {
input,
output,
presets: presetTargets,
nodes: handles,
update(next) {
if (shapeOf(next) !== shape) return false;
Expand All @@ -612,13 +693,32 @@ export function buildFxChain(
if (node.id === undefined) delete held.id;
else held.id = node.id;
});
// The blend is a value like any other: switching a preset off writes
// `presetAmount`, and pushing it into the running graph is what keeps that
// from being a rebuild — and from restarting the audio underneath it.
for (const run of presetRuns(enabledAudioFxNodes(next))) {
if (!run.preset) continue;
const wrap = presets.find((p) => p.id === run.preset);
if (!wrap) continue;
wrap.wet.gain.value = run.amount;
wrap.dry.gain.value = 1 - run.amount;
}
// `shape` is not reassigned: the early return above already established
// that `shapeOf(next)` equals it, so recomputing was a whole normalise +
// join per observer tick to write back the string that was already there.
return true;
},
dispose() {
for (const { handle } of handles) handle.dispose();
// The wrap is not one of `handles` — it belongs to the chain rather than
// to any effect — so it has to be unwired here or a rebuild leaves a
// crossfade still connected to the graph it used to bridge.
for (const { entry, wet, dry, join } of presets) {
entry.disconnect();
wet.disconnect();
dry.disconnect();
join.disconnect();
}
input.disconnect();
output.disconnect();
},
Expand Down
46 changes: 45 additions & 1 deletion packages/core/src/audioAutomation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,22 @@ export class AudioAutomationError extends Error {

export const VOLUME_TARGET = "volume";

export type HfAutomationTarget = { kind: "volume" } | { kind: "fx"; nodeId: string; param: string };
export type HfAutomationTarget =
| { kind: "volume" }
| { kind: "fx"; nodeId: string; param: string }
| { kind: "preset"; presetId: string };

/** Split a target string. Returns null for anything unrecognised. */
export function parseAutomationTarget(target: string): HfAutomationTarget | null {
if (target === VOLUME_TARGET) return { kind: "volume" };
const parts = target.split(".");
// `fx.preset.<id>` before the 3-part fx form, because it IS a 3-part fx form
// with a reserved node id — an effect can never be called "preset", since ids
// are minted `n1`, `n2`, ….
if (parts.length === 3 && parts[0] === "fx" && parts[1] === PRESET_TARGET_KEY) {
const presetId = parts[2];
return presetId ? { kind: "preset", presetId } : null;
}
if (parts.length !== 3 || parts[0] !== "fx") return null;
const [, nodeId, param] = parts;
if (!nodeId || !param) return null;
Expand All @@ -95,6 +105,33 @@ export function fxAutomationTarget(nodeId: string, param: string): string {
return `fx.${nodeId}.${param}`;
}

/** The reserved node-id slot that marks a whole-preset target. */
const PRESET_TARGET_KEY = "preset";

/**
* How much of a preset is applied, 0..1.
*
* A preset's nodes share no automatable parameter — and its worklet effects
* expose no AudioParams at all — so there is nothing to aim a lane at
* node-by-node. The graph wraps a preset's run in a wet/dry pair instead, and
* this drives the blend: 0 is the dry signal untouched, 1 is the preset fully
* applied, and between them it crossfades.
*/
export function presetAutomationTarget(presetId: string): string {
return `fx.${PRESET_TARGET_KEY}.${presetId}`;
}

/** 0..1 blend, the same shape as a wet/dry mix knob. */
export const PRESET_RANGE: AutomationRange = {
min: 0,
max: 1,
step: 0.01,
unit: "",
label: "Amount",
scale: "linear",
default: 1,
};

/**
* The value range a lane is drawn and clamped against.
*
Expand Down Expand Up @@ -136,6 +173,13 @@ export function resolveAutomationRange(
const parsed = parseAutomationTarget(target);
if (!parsed) return null;
if (parsed.kind === "volume") return VOLUME_RANGE;
if (parsed.kind === "preset") {
// Only for a preset the chain actually carries, so a lane left behind by a
// removed preset resolves to nothing and is dropped at read time — the same
// contract an orphaned node lane has.
const present = chain?.nodes.some((n) => n.fromPreset === parsed.presetId);
return present ? { ...PRESET_RANGE, label: `${parsed.presetId} · Amount` } : null;
}
const node = chain?.nodes.find((n) => n.id === parsed.nodeId);
if (!node) return null;
const def = getAudioFxDef(node.type);
Expand Down
Loading
Loading