mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-11 19:09:20 +00:00
feat: ramp a whole preset with one lane, and make Off its zero
A preset's nodes share no automatable parameter, and its worklet effects (compressor, limiter) expose no AudioParams at all — Clean Voice could only ever have automated 3 of its 5 nodes. So there was nothing to aim a lane at, and no way to bring a preset in gradually. **The graph wraps each preset's run in a wet/dry pair.** The rest of the chain stays 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 — it is several added as a unit, and "how much of it is applied" is a question about the unit. One crossfade answers it for every preset including the worklet ones, and it cannot go half-wrong the way seven lanes can. The dry leg bridges the whole run, so amount 0 is the untouched signal rather than a quieter version of the processed one. Consecutive nodes only, matching what the rack already 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. **`fx.preset.<id>` is a new lane target.** Parsed before the 3-part fx form, which it structurally is — with a reserved node id an effect can never have, since ids are minted `n1`, `n2`, …. It resolves only for a preset the chain actually carries, so a lane left behind by a removed preset is dropped at read time, the same contract an orphaned node lane has. **Off is amount 0, not `enabled: false`.** The switch and the lane are now the same value — one notion of how much of a preset is applied, with the switch at its two ends. Writing `enabled` would take the nodes out of the graph, which a lane cannot do part-way and cannot do without a rebuild that restarts the audio. The panel also grows an Amount slider, so half-applied is something an author can just set. Changing the amount is a values-only update, pushed into the running graph. A `presetAmount` in a chain is clamped to 0..1 on the way in: two gains in opposition, so past 1 the dry leg goes negative rather than the preset getting louder. Falsified: a blend ignoring the stored amount, an amount not pushed on update, and the clamp each fail a test. The round-trip — the §4 invariant that a new node field must be in BOTH the parser and the writer — had no test until a mutation survived one; dropping it from either half now fails. core 1770 (113 files) · studio 3703 + 18 todo · engine services 739 + 3.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getAudioFxDef,
|
||||
normalizeAudioFxParams,
|
||||
type HfAudioFxChain,
|
||||
type HfAudioFxNode,
|
||||
type HfAudioFxParamValues,
|
||||
} from "../audioFx.js";
|
||||
import { audioFxWorkletsReady, ensureAudioFxWorklets } from "./audioFxWorklets.js";
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -612,6 +693,16 @@ 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.
|
||||
@@ -619,6 +710,15 @@ export function buildFxChain(
|
||||
},
|
||||
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();
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
*
|
||||
@@ -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);
|
||||
|
||||
@@ -832,6 +832,18 @@ export interface HfAudioFxNode {
|
||||
* effect type and its bands stay ordinary filters underneath.
|
||||
*/
|
||||
fromEq?: string;
|
||||
|
||||
/**
|
||||
* How much of this node's preset is applied, 0..1 — the wet/dry blend the
|
||||
* graph wraps its run in.
|
||||
*
|
||||
* On every node of the run rather than beside the chain, because the chain has
|
||||
* nowhere else to put it: `HfAudioFxChain` is a version and a list of nodes,
|
||||
* and a preset is defined by which nodes carry its tag. The graph reads it off
|
||||
* the first node of each run. Absent means fully applied, which is what every
|
||||
* chain written before this means.
|
||||
*/
|
||||
presetAmount?: number;
|
||||
/**
|
||||
* Set on the gain stage the leveller writes, so re-running replaces it rather
|
||||
* than stacking a second one — the same contract `fromCarve` has.
|
||||
@@ -890,6 +902,7 @@ export function parseAudioFxChain(json: string): HfAudioFxChain {
|
||||
label?: unknown;
|
||||
fromEq?: unknown;
|
||||
fromLeveller?: unknown;
|
||||
presetAmount?: unknown;
|
||||
};
|
||||
if (typeof node.type !== "string" || !BY_ID.has(node.type)) {
|
||||
throw new AudioFxChainError(`Node ${i} has unknown effect type: ${String(node.type)}`);
|
||||
@@ -907,6 +920,11 @@ export function parseAudioFxChain(json: string): HfAudioFxChain {
|
||||
...(typeof node.label === "string" && node.label ? { label: node.label } : {}),
|
||||
...(typeof node.fromEq === "string" && node.fromEq ? { fromEq: node.fromEq } : {}),
|
||||
...(node.fromLeveller === true ? { fromLeveller: true as const } : {}),
|
||||
// Clamped on the way in: the blend is two gains in opposition, and a value
|
||||
// outside 0..1 makes the dry leg negative rather than simply loud.
|
||||
...(typeof node.presetAmount === "number" && Number.isFinite(node.presetAmount)
|
||||
? { presetAmount: Math.min(1, Math.max(0, node.presetAmount)) }
|
||||
: {}),
|
||||
enabled: node.enabled !== false,
|
||||
params: normalizeAudioFxParams(
|
||||
node.type,
|
||||
@@ -934,6 +952,11 @@ export function serializeAudioFxChain(chain: HfAudioFxChain): string {
|
||||
...(node.label ? { label: node.label } : {}),
|
||||
...(node.fromEq ? { fromEq: node.fromEq } : {}),
|
||||
...(node.fromLeveller === true ? { fromLeveller: true } : {}),
|
||||
// Omitted when fully applied, so an untouched preset does not grow a field
|
||||
// in every chain that carries one.
|
||||
...(typeof node.presetAmount === "number" && node.presetAmount !== 1
|
||||
? { presetAmount: node.presetAmount }
|
||||
: {}),
|
||||
...(node.enabled === false ? { enabled: false } : {}),
|
||||
params: normalizeAudioFxParams(node.type, node.params),
|
||||
})),
|
||||
|
||||
@@ -120,6 +120,45 @@ describe("the catalogue is internally valid", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("round-trips how much of the preset is applied", () => {
|
||||
// `presetAmount` is what the switch writes and what a lane ramps, so losing
|
||||
// it on reload silently turns every part-applied or switched-off preset back
|
||||
// on — the same failure `fromPreset` had, one field along. The INVARIANT: a
|
||||
// new HfAudioFxNode field goes in BOTH parseAudioFxChain and
|
||||
// serializeAudioFxChain.
|
||||
const preset = HF_AUDIO_FX_PRESETS[0];
|
||||
if (!preset) throw new Error("empty catalogue");
|
||||
for (const amount of [0, 0.4, 1]) {
|
||||
const chain = applyAudioFxPreset(empty(), preset);
|
||||
const withAmount = {
|
||||
...chain,
|
||||
nodes: chain.nodes.map((n) => ({ ...n, presetAmount: amount })),
|
||||
};
|
||||
const back = parseAudioFxChain(serializeAudioFxChain(withAmount));
|
||||
// 1 is the absent case — a fully applied preset should not grow a field in
|
||||
// every chain that carries one — and reads back as fully applied.
|
||||
const expected = amount === 1 ? undefined : amount;
|
||||
expect(
|
||||
back.nodes.map((n) => n.presetAmount),
|
||||
`amount ${amount} did not survive`,
|
||||
).toEqual(chain.nodes.map(() => expected));
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses an amount outside the blend it drives", () => {
|
||||
// Two gains in opposition: past 1 the dry leg goes negative rather than the
|
||||
// preset simply getting louder.
|
||||
const preset = HF_AUDIO_FX_PRESETS[0];
|
||||
if (!preset) throw new Error("empty catalogue");
|
||||
const chain = applyAudioFxPreset(empty(), preset);
|
||||
const raw = JSON.parse(serializeAudioFxChain(chain)) as {
|
||||
nodes: { presetAmount?: number }[];
|
||||
};
|
||||
raw.nodes = raw.nodes.map((n) => ({ ...n, presetAmount: 4 }));
|
||||
const back = parseAudioFxChain(JSON.stringify(raw));
|
||||
for (const node of back.nodes) expect(node.presetAmount).toBe(1);
|
||||
});
|
||||
|
||||
it("names every node for the job it is doing", () => {
|
||||
for (const p of HF_AUDIO_FX_PRESETS) {
|
||||
for (const node of p.nodes) {
|
||||
|
||||
@@ -179,7 +179,9 @@ export function attachElementFxChain(
|
||||
|
||||
const scheduleFor = (next: HfAudioFxChain, at: AutomationTiming | null): void => {
|
||||
automated =
|
||||
at && handle ? scheduleChainAutomation(readAutomation(el, next), next, handle.nodes, at) : [];
|
||||
at && handle
|
||||
? scheduleChainAutomation(readAutomation(el, next), next, handle.nodes, at, handle.presets)
|
||||
: [];
|
||||
};
|
||||
|
||||
// The reference frame every later reschedule measures from. Mutable because a
|
||||
|
||||
@@ -103,11 +103,13 @@ async function render(
|
||||
// time is offline time — the envelope needs no offset here. Same scheduler as
|
||||
// preview, which is what makes the two agree.
|
||||
if (parsedAutomation) {
|
||||
scheduleChainAutomation(parsedAutomation, chain, fx.nodes, {
|
||||
scheduledAt: 0,
|
||||
elapsed: 0,
|
||||
rate: 1,
|
||||
});
|
||||
scheduleChainAutomation(
|
||||
parsedAutomation,
|
||||
chain,
|
||||
fx.nodes,
|
||||
{ scheduledAt: 0, elapsed: 0, rate: 1 },
|
||||
fx.presets,
|
||||
);
|
||||
}
|
||||
|
||||
source.connect(fx.input);
|
||||
|
||||
@@ -34,6 +34,8 @@ import {
|
||||
} from "@hyperframes/core/audio-carve";
|
||||
import {
|
||||
fxAutomationTarget,
|
||||
parseAutomationTarget,
|
||||
presetAutomationTarget,
|
||||
sampleAutomationLane,
|
||||
type HfAutomation,
|
||||
type HfAutomationLane,
|
||||
@@ -192,6 +194,29 @@ export function AudioFxGroup({
|
||||
writeAutomation(withoutLane(automation, fxAutomationTarget(nodeId, paramKey)));
|
||||
};
|
||||
|
||||
/**
|
||||
* Automate a whole preset's amount — the wet/dry blend around its run.
|
||||
*
|
||||
* Seeded where the preset already sits, so switching to a lane never changes
|
||||
* the sound; the author then draws the ramp in the timeline. Same contract as
|
||||
* automating one parameter, one level up.
|
||||
*/
|
||||
const automatePreset = (presetId: string, amount: number): void => {
|
||||
writeAutomation(withSeededLane(automation, presetAutomationTarget(presetId), amount));
|
||||
};
|
||||
|
||||
const removePresetAutomation = (presetId: string): void => {
|
||||
writeAutomation(withoutLane(automation, presetAutomationTarget(presetId)));
|
||||
};
|
||||
|
||||
/** Presets a lane already drives, so the panel shows a readout not a slider. */
|
||||
const automatedPresets = new Set(
|
||||
automation.lanes
|
||||
.map((lane) => parseAutomationTarget(lane.target))
|
||||
.filter((t): t is { kind: "preset"; presetId: string } => t?.kind === "preset")
|
||||
.map((t) => t.presetId),
|
||||
);
|
||||
|
||||
/**
|
||||
* Turn carve on or off.
|
||||
*
|
||||
@@ -796,6 +821,9 @@ export function AudioFxGroup({
|
||||
onLevel={() => void runLeveller()}
|
||||
onRemoveLevel={removeLeveller}
|
||||
levelled={chain.nodes.some((n) => n.fromLeveller)}
|
||||
onAutomatePreset={automatePreset}
|
||||
onRemovePresetAutomation={removePresetAutomation}
|
||||
automatedPresets={automatedPresets}
|
||||
onAuditionLevel={(on) => void auditionLevel(on)}
|
||||
auditioningLevel={auditioningLevel}
|
||||
carvedAgainstBy={carvedAgainstBy}
|
||||
|
||||
@@ -88,6 +88,9 @@ function mount(overrides: Partial<Parameters<typeof FxSection>[0]> = {}) {
|
||||
onAutomateParam={overrides.onAutomateParam}
|
||||
onRemoveParamAutomation={overrides.onRemoveParamAutomation}
|
||||
onRemoveNodeAutomation={overrides.onRemoveNodeAutomation}
|
||||
onAutomatePreset={overrides.onAutomatePreset}
|
||||
onRemovePresetAutomation={overrides.onRemovePresetAutomation}
|
||||
automatedPresets={overrides.automatedPresets}
|
||||
onLevel={overrides.onLevel}
|
||||
onRemoveLevel={overrides.onRemoveLevel}
|
||||
levelled={overrides.levelled}
|
||||
@@ -414,6 +417,22 @@ describe("FxSection chain", () => {
|
||||
});
|
||||
|
||||
describe("a preset is one thing to switch off or take away", () => {
|
||||
/**
|
||||
* The run's own Amount row — not a member module's.
|
||||
*
|
||||
* It is a direct child of the bracket; a member's rows are nested inside its
|
||||
* own card, which is what makes the distinction structural rather than
|
||||
* positional.
|
||||
*/
|
||||
const amountRow = (host: HTMLElement): HTMLElement | null => {
|
||||
const bracket = host.querySelector("[data-fx-preset='telephone']");
|
||||
// A direct-child walk rather than `:scope >`, which happy-dom's matcher
|
||||
// does not support — it returns nothing rather than erroring, which reads
|
||||
// as "the control is missing".
|
||||
return (Array.from(bracket?.children ?? []).find((c) => c.classList.contains("hf-fx-row")) ??
|
||||
null) as HTMLElement | null;
|
||||
};
|
||||
|
||||
/** A telephone preset applied, plus one hand-built effect beside it. */
|
||||
const applied = (): HfAudioFxChain => {
|
||||
const preset = getAudioFxPreset("telephone");
|
||||
@@ -430,7 +449,7 @@ describe("FxSection chain", () => {
|
||||
};
|
||||
};
|
||||
|
||||
it("bypasses every node it wrote, in one gesture", () => {
|
||||
it("switches the whole preset off in one gesture", () => {
|
||||
// Reaching into five modules and toggling each is exactly the bookkeeping
|
||||
// the bracket exists to remove.
|
||||
const { host, onChainChange } = mount({ chain: applied() });
|
||||
@@ -438,17 +457,37 @@ describe("FxSection chain", () => {
|
||||
click(run?.querySelector(".hf-fx-preset-run-toggle"));
|
||||
|
||||
const next = onChainChange.mock.calls[0]?.[0] as HfAudioFxChain;
|
||||
expect(next.nodes.filter((n) => n.fromPreset === "telephone").every((n) => !n.enabled)).toBe(
|
||||
true,
|
||||
);
|
||||
// Amount, not `enabled`: the switch and the lane are the same value, so
|
||||
// Off is one end of the control a ramp moves along rather than a second
|
||||
// way of silencing the preset. Writing `enabled` would take the nodes out
|
||||
// of the graph, which a lane cannot do part-way.
|
||||
const members = next.nodes.filter((n) => n.fromPreset === "telephone");
|
||||
expect(members.every((n) => n.presetAmount === 0)).toBe(true);
|
||||
// Still in the graph, so the settings survive and it can come back.
|
||||
expect(members.every((n) => n.enabled !== false)).toBe(true);
|
||||
// And leaves what the author added themselves alone.
|
||||
expect(next.nodes.find((n) => n.id === "own")?.enabled).toBe(true);
|
||||
expect(next.nodes.find((n) => n.id === "own")?.presetAmount).toBeUndefined();
|
||||
});
|
||||
|
||||
it("puts the whole preset half in", () => {
|
||||
// The point of the blend: a preset is not only on or off, and the same
|
||||
// value a lane ramps is one an author can just set.
|
||||
const { host, onChainChange } = mount({ chain: applied() });
|
||||
const input = amountRow(host)?.querySelector<HTMLInputElement>(".hf-fx-number");
|
||||
if (!input) throw new Error("no amount control");
|
||||
typeInto(input, "0.4");
|
||||
act(() => input.dispatchEvent(new FocusEvent("focusout", { bubbles: true })));
|
||||
|
||||
const next = onChainChange.mock.calls.at(-1)?.[0] as HfAudioFxChain;
|
||||
expect(
|
||||
next.nodes.filter((n) => n.fromPreset === "telephone").every((n) => n.presetAmount === 0.4),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("switches back on rather than deleting, so the settings survive", () => {
|
||||
const off = applied();
|
||||
off.nodes = off.nodes.map((n) =>
|
||||
n.fromPreset === "telephone" ? { ...n, enabled: false } : n,
|
||||
n.fromPreset === "telephone" ? { ...n, presetAmount: 0 } : n,
|
||||
);
|
||||
const { host, onChainChange } = mount({ chain: off });
|
||||
const toggle = host
|
||||
@@ -459,17 +498,18 @@ describe("FxSection chain", () => {
|
||||
|
||||
const next = onChainChange.mock.calls[0]?.[0] as HfAudioFxChain;
|
||||
const back = next.nodes.filter((n) => n.fromPreset === "telephone");
|
||||
expect(back.every((n) => n.enabled)).toBe(true);
|
||||
expect(back.every((n) => n.presetAmount === 1)).toBe(true);
|
||||
// Nothing was thrown away.
|
||||
expect(back).toHaveLength(off.nodes.filter((n) => n.fromPreset === "telephone").length);
|
||||
});
|
||||
|
||||
it("reads as on while any of it is still running", () => {
|
||||
// "Some of it is bypassed" is not a state an author set — it is one they
|
||||
// arrived at, and the switch has to offer to stop it rather than claim it
|
||||
// has already stopped.
|
||||
it("reads as on while any of it is still applied", () => {
|
||||
// Anything above zero is a preset that is doing something, and the switch
|
||||
// has to offer to stop it rather than claim it has already stopped.
|
||||
const partial = applied();
|
||||
partial.nodes = partial.nodes.map((n, i) => (i === 0 ? { ...n, enabled: false } : n));
|
||||
partial.nodes = partial.nodes.map((n) =>
|
||||
n.fromPreset === "telephone" ? { ...n, presetAmount: 0.2 } : n,
|
||||
);
|
||||
const { host } = mount({ chain: partial });
|
||||
expect(
|
||||
host
|
||||
@@ -479,6 +519,31 @@ describe("FxSection chain", () => {
|
||||
).toBe("true");
|
||||
});
|
||||
|
||||
it("hands the whole preset to a lane, seeded where it already sits", () => {
|
||||
// The reason a preset needs its own target at all: its nodes share no
|
||||
// automatable parameter, and its worklet effects expose none. One lane on
|
||||
// the blend is what lets a preset ramp in over time.
|
||||
const onAutomatePreset = vi.fn();
|
||||
const half = applied();
|
||||
half.nodes = half.nodes.map((n) =>
|
||||
n.fromPreset === "telephone" ? { ...n, presetAmount: 0.6 } : n,
|
||||
);
|
||||
const { host } = mount({ chain: half, onAutomatePreset });
|
||||
click(amountRow(host)?.querySelector(".hf-fx-automate"));
|
||||
// Seeded where it sits, so switching to a lane never changes the sound.
|
||||
expect(onAutomatePreset).toHaveBeenCalledWith("telephone", 0.6);
|
||||
});
|
||||
|
||||
it("shows an automated preset as driven rather than offering a slider", () => {
|
||||
const { host } = mount({
|
||||
chain: applied(),
|
||||
automatedPresets: new Set(["telephone"]),
|
||||
});
|
||||
const row = amountRow(host);
|
||||
expect(row?.hasAttribute("data-automated")).toBe(true);
|
||||
expect(row?.querySelector<HTMLInputElement>('input[type="range"]')?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("takes the preset back out whole, with its lanes", () => {
|
||||
const onRemoveNodeAutomation = vi.fn();
|
||||
const { host, onChainChange } = mount({ chain: applied(), onRemoveNodeAutomation });
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type HfAudioFxChain,
|
||||
type HfAudioFxGroup,
|
||||
type HfAudioFxNode,
|
||||
type HfAudioFxParam,
|
||||
type HfAudioFxParamValues,
|
||||
} from "@hyperframes/core/audio-fx";
|
||||
import { DEFAULT_CARVE, type HfCarveSettings } from "@hyperframes/core/audio-carve";
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
HF_AUDIO_FX_JOB_TYPES,
|
||||
type HfAudioFxJob,
|
||||
} from "@hyperframes/core/audio-fx-jobs";
|
||||
import { FxParamRow } from "./propertyPanelFxControls.js";
|
||||
import { FxPresetMenu } from "./propertyPanelFxPresetMenu.js";
|
||||
import { FxEqModule } from "./propertyPanelFxEqModule.js";
|
||||
import { FxCarveModule, type AudioTrackOption } from "./propertyPanelFxCarveModule.js";
|
||||
@@ -48,6 +50,25 @@ const GROUP_LABEL: Record<HfAudioFxGroup, string> = {
|
||||
time: "Time",
|
||||
};
|
||||
|
||||
/**
|
||||
* The one control over a whole preset: how much of it is applied.
|
||||
*
|
||||
* Not in the effect registry — a preset is not an effect — so the row is
|
||||
* fabricated the same way the derived one-knob control is, and rendered by the
|
||||
* ordinary controls.
|
||||
*/
|
||||
const PRESET_AMOUNT_PARAM: HfAudioFxParam = {
|
||||
kind: "number",
|
||||
key: "amount",
|
||||
label: "Amount",
|
||||
unit: "",
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.01,
|
||||
default: 1,
|
||||
hint: "How much of this preset is applied. Automate it to bring the whole preset in or out over time.",
|
||||
};
|
||||
|
||||
export interface FxSectionProps {
|
||||
chain: HfAudioFxChain;
|
||||
/** Targets this track already automates, as `fx.<nodeId>.<param>` strings. */
|
||||
@@ -67,6 +88,12 @@ export interface FxSectionProps {
|
||||
onRemoveParamAutomation?(nodeId: string, paramKey: string): void;
|
||||
/** Delete every lane belonging to a node that is being removed. */
|
||||
onRemoveNodeAutomation?(nodeId: string): void;
|
||||
/** Add a lane for a whole preset's amount, seeded where it sits now. */
|
||||
onAutomatePreset?(presetId: string, amount: number): void;
|
||||
/** Delete that lane. */
|
||||
onRemovePresetAutomation?(presetId: string): void;
|
||||
/** Presets whose amount a lane already drives. */
|
||||
automatedPresets?: ReadonlySet<string>;
|
||||
/** Measure this track and write the levelling lane. Absent when unavailable. */
|
||||
onLevel?(): void;
|
||||
/** Take the levelling stage and its lane back out. */
|
||||
@@ -126,7 +153,11 @@ export function FxSection({
|
||||
levelled,
|
||||
onAuditionLevel,
|
||||
auditioningLevel,
|
||||
onAutomatePreset,
|
||||
onRemovePresetAutomation,
|
||||
automatedPresets,
|
||||
}: FxSectionProps) {
|
||||
const presetAutomated = automatedPresets ?? new Set<string>();
|
||||
// Falls back to the persisting write when no preview handler is supplied, which
|
||||
// keeps the control working rather than going dead.
|
||||
const previewCarve = onCarvePreview ?? onCarveChange;
|
||||
@@ -315,19 +346,28 @@ export function FxSection({
|
||||
);
|
||||
|
||||
/**
|
||||
* Bypass or restore every node a preset wrote, as one gesture.
|
||||
* How much of a preset is applied, 0..1.
|
||||
*
|
||||
* A preset is one thing the author added, so it has to be one thing they can
|
||||
* switch off — reaching into five modules and toggling each is the bookkeeping
|
||||
* the bracket exists to remove. Off is a bypass, not a delete: the settings
|
||||
* survive, which is what makes it worth trying rather than committing to.
|
||||
* The switch and the lane are the same value, not two ways of silencing a
|
||||
* preset: `presetAmount` drives the wet/dry blend the graph wraps the run in,
|
||||
* so Off is amount 0 and a lane ramping 0 → 1 is the same control moving
|
||||
* continuously. Writing `enabled` instead would take the nodes out of the
|
||||
* graph, which a lane cannot do part-way and cannot do without a rebuild.
|
||||
*
|
||||
* On every node of the run because that is where the chain can hold it — see
|
||||
* `HfAudioFxNode.presetAmount`.
|
||||
*/
|
||||
const toggleRun = useCallback(
|
||||
(items: { node: HfAudioFxNode; i: number }[], on: boolean) => {
|
||||
const setRunAmount = useCallback(
|
||||
(items: { node: HfAudioFxNode; i: number }[], amount: number, persist = true) => {
|
||||
const slots = new Set(items.map((item) => item.i));
|
||||
mutate(chain.nodes.map((n, i) => (slots.has(i) ? { ...n, enabled: on } : n)));
|
||||
const next = {
|
||||
...chain,
|
||||
nodes: chain.nodes.map((n, i) => (slots.has(i) ? { ...n, presetAmount: amount } : n)),
|
||||
};
|
||||
if (persist) mutate(next.nodes);
|
||||
else onChainPreview?.(next);
|
||||
},
|
||||
[chain.nodes, mutate],
|
||||
[chain, mutate, onChainPreview],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -539,7 +579,11 @@ export function FxSection({
|
||||
// On unless every node in it is bypassed: one switched back on means
|
||||
// the preset is doing something, and the switch has to offer to stop
|
||||
// it rather than claiming it has already stopped.
|
||||
const runOn = run.items.some(({ node }) => node.enabled !== false);
|
||||
// How much of it is applied. Any node of the run carries it, and the
|
||||
// first is the one the graph reads.
|
||||
const amount = run.items[0]?.node.presetAmount;
|
||||
const runAmount = typeof amount === "number" ? amount : 1;
|
||||
const runOn = runAmount > 0;
|
||||
return (
|
||||
<div
|
||||
key={`preset-${run.preset}-${run.items[0]?.i}`}
|
||||
@@ -558,9 +602,9 @@ export function FxSection({
|
||||
type="button"
|
||||
className="hf-fx-preset-run-toggle rounded-[3px] border border-panel-border-input px-1.5 py-0.5 font-mono text-[9px] text-panel-text-4 hover:text-panel-text-0 disabled:opacity-40"
|
||||
aria-pressed={runOn}
|
||||
title={runOn ? `Bypass ${preset.label}` : `Switch ${preset.label} back on`}
|
||||
title={runOn ? `Switch ${preset.label} off` : `Switch ${preset.label} back on`}
|
||||
disabled={disabled}
|
||||
onClick={() => toggleRun(run.items, !runOn)}
|
||||
onClick={() => setRunAmount(run.items, runOn ? 0 : 1)}
|
||||
>
|
||||
{runOn ? "On" : "Off"}
|
||||
</button>
|
||||
@@ -574,6 +618,26 @@ export function FxSection({
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{/* The same value the switch sets, so an author can put the
|
||||
preset half in — and the lane below ramps it continuously. */}
|
||||
<FxParamRow
|
||||
param={PRESET_AMOUNT_PARAM}
|
||||
value={runAmount}
|
||||
disabled={disabled || presetAutomated.has(run.preset ?? "")}
|
||||
automated={presetAutomated.has(run.preset ?? "")}
|
||||
onChange={(_k, v) => setRunAmount(run.items, Number(v), false)}
|
||||
onCommit={(_k, v) => setRunAmount(run.items, Number(v))}
|
||||
onAutomate={
|
||||
run.preset && onAutomatePreset && !presetAutomated.has(run.preset)
|
||||
? () => onAutomatePreset(run.preset ?? "", runAmount)
|
||||
: undefined
|
||||
}
|
||||
onRemoveAutomation={
|
||||
run.preset && onRemovePresetAutomation && presetAutomated.has(run.preset)
|
||||
? () => onRemovePresetAutomation(run.preset ?? "")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{rows}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user