diff --git a/packages/core/src/audio/audioFxAutomation.ts b/packages/core/src/audio/audioFxAutomation.ts index 94e7cca6b..d818b961b 100644 --- a/packages/core/src/audio/audioFxAutomation.ts +++ b/packages/core/src/audio/audioFxAutomation.ts @@ -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, ): 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; diff --git a/packages/core/src/audio/audioFxGraph.test.ts b/packages/core/src/audio/audioFxGraph.test.ts index 181bf0826..06abfe92f 100644 --- a/packages/core/src/audio/audioFxGraph.test.ts +++ b/packages/core/src/audio/audioFxGraph.test.ts @@ -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([]); + }); +}); diff --git a/packages/core/src/audio/audioFxGraph.ts b/packages/core/src/audio/audioFxGraph.ts index 6adab53b8..ba34c8b58 100644 --- a/packages/core/src/audio/audioFxGraph.ts +++ b/packages/core/src/audio/audioFxGraph.ts @@ -13,6 +13,7 @@ import { getAudioFxDef, normalizeAudioFxParams, type HfAudioFxChain, + type HfAudioFxNode, type HfAudioFxParamValues, } from "../audioFx.js"; import { audioFxWorkletsReady, ensureAudioFxWorklets } from "./audioFxWorklets.js"; @@ -539,11 +540,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; /** 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 @@ -585,21 +620,66 @@ 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 = {}; + 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; @@ -616,13 +696,31 @@ export function buildFxChain( if (node.id === undefined) delete held.id; else held.id = node.id; }); - // `shape` is not reassigned: the early return above already established + // 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(); }, diff --git a/packages/core/src/audioAutomation.ts b/packages/core/src/audioAutomation.ts index 01b0ab7bf..bcd7401e8 100644 --- a/packages/core/src/audioAutomation.ts +++ b/packages/core/src/audioAutomation.ts @@ -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.` 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); diff --git a/packages/core/src/audioFx.ts b/packages/core/src/audioFx.ts index 1c9f08c0b..84f48f613 100644 --- a/packages/core/src/audioFx.ts +++ b/packages/core/src/audioFx.ts @@ -832,10 +832,21 @@ 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. - */ + */; fromLeveller?: boolean; /** Absent means enabled — chain files written before the field existed still load. */ enabled?: boolean; @@ -890,6 +901,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 +919,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 +951,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), })), diff --git a/packages/core/src/audioFxPresets.test.ts b/packages/core/src/audioFxPresets.test.ts index 2f187ce81..11e0a1160 100644 --- a/packages/core/src/audioFxPresets.test.ts +++ b/packages/core/src/audioFxPresets.test.ts @@ -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) { diff --git a/packages/core/src/runtime/audioFx.ts b/packages/core/src/runtime/audioFx.ts index d873bcd91..0068a129e 100644 --- a/packages/core/src/runtime/audioFx.ts +++ b/packages/core/src/runtime/audioFx.ts @@ -191,7 +191,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 diff --git a/packages/core/stubs/audio-fx-runtime-entry.ts b/packages/core/stubs/audio-fx-runtime-entry.ts index d6a78b943..b8ed1e8e1 100644 --- a/packages/core/stubs/audio-fx-runtime-entry.ts +++ b/packages/core/stubs/audio-fx-runtime-entry.ts @@ -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); diff --git a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx index f379a1f00..f6c720bf6 100644 --- a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx +++ b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx @@ -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. * @@ -800,6 +825,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} diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx index 3b4eafa8b..36090f92a 100644 --- a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx @@ -11,7 +11,7 @@ import { DEFAULT_CARVE } from "@hyperframes/core/audio-carve"; import { BANDS, EFFECT_COPY, PRESET_PROBLEM } from "@hyperframes/core/audio-fx-copy"; import { HF_AUDIO_FX_JOBS, HF_AUDIO_FX_JOB_TYPES } from "@hyperframes/core/audio-fx-jobs"; import { audioFxProfileStrength } from "@hyperframes/core/audio-fx-profiles"; -import { getAudioFxPreset } from "@hyperframes/core/audio-fx-presets"; +import { applyAudioFxPreset, getAudioFxPreset } from "@hyperframes/core/audio-fx-presets"; /** * What a knob is CALLED in the panel, looked up rather than spelled out. @@ -87,6 +87,10 @@ function mount(overrides: Partial[0]> = {}) { automatedTargets={overrides.automatedTargets} 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 +418,151 @@ describe("FxSection chain", () => { expect(run?.querySelectorAll(".hf-fx-node")).toHaveLength(written.length); }); + 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"); + if (!preset) throw new Error("no telephone preset"); + // Through the real applier: `fromPreset` is stamped there, not carried in + // the catalogue, and the tag is the whole basis of the bracket. + const withPreset = applyAudioFxPreset({ version: 1, nodes: [] }, preset); + return { + ...withPreset, + nodes: [ + ...withPreset.nodes, + { type: "reverb", id: "own", enabled: true, params: defaultAudioFxParams("reverb") }, + ], + }; + }; + + 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() }); + const run = host.querySelector("[data-fx-preset='telephone']"); + click(run?.querySelector(".hf-fx-preset-run-toggle")); + + const next = onChainChange.mock.calls[0]?.[0] as HfAudioFxChain; + // 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")?.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(".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, presetAmount: 0 } : n, + ); + const { host, onChainChange } = mount({ chain: off }); + const toggle = host + .querySelector("[data-fx-preset='telephone']") + ?.querySelector(".hf-fx-preset-run-toggle"); + expect(toggle?.getAttribute("aria-pressed")).toBe("false"); + click(toggle); + + const next = onChainChange.mock.calls[0]?.[0] as HfAudioFxChain; + const back = next.nodes.filter((n) => n.fromPreset === "telephone"); + 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 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) => + n.fromPreset === "telephone" ? { ...n, presetAmount: 0.2 } : n, + ); + const { host } = mount({ chain: partial }); + expect( + host + .querySelector("[data-fx-preset='telephone']") + ?.querySelector(".hf-fx-preset-run-toggle") + ?.getAttribute("aria-pressed"), + ).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('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 }); + click( + host + .querySelector("[data-fx-preset='telephone']") + ?.querySelector(".hf-fx-preset-run-remove"), + ); + + const next = onChainChange.mock.calls[0]?.[0] as HfAudioFxChain; + expect(next.nodes.filter((n) => n.fromPreset === "telephone")).toEqual([]); + expect(next.nodes.map((n) => n.id)).toEqual(["own"]); + // An orphaned lane keeps driving a parameter that is no longer in the + // graph, and the next effect added inherits it with the id. + expect(onRemoveNodeAutomation).toHaveBeenCalled(); + }); + }); it("brackets only nodes a preset still sits next to", () => { // Pulled apart by a reorder, they are no longer a unit — and a bracket // around the gap would claim an adjacency the signal path does not have. diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.tsx index a301ffb7a..fc0c22c1d 100644 --- a/packages/studio/src/components/editor/propertyPanelFxSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFxSection.tsx @@ -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,24 @@ const GROUP_LABEL: Record = { 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..` strings. */ @@ -67,6 +87,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; /** Measure this track and write the levelling lane. Absent when unavailable. */ onLevel?(): void; /** Take the levelling stage and its lane back out. */ @@ -126,7 +152,11 @@ export function FxSection({ levelled, onAuditionLevel, auditioningLevel, + onAutomatePreset, + onRemovePresetAutomation, + automatedPresets, }: FxSectionProps) { + const presetAutomated = automatedPresets ?? new Set(); // 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; @@ -314,6 +344,50 @@ export function FxSection({ [chain.nodes, mutate], ); + /** + * How much of a preset is applied, 0..1. + * + * 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 setRunAmount = useCallback( + (items: { node: HfAudioFxNode; i: number }[], amount: number, persist = true) => { + const slots = new Set(items.map((item) => item.i)); + 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, mutate, onChainPreview], + ); + + /** + * Take a preset back out whole, lanes and all. + * + * Same contract as removing one node — an orphaned lane keeps driving a + * parameter that is no longer in the graph, and with ids minted lowest-free + * the next effect added inherits it. + */ + const removeRun = useCallback( + (items: { node: HfAudioFxNode; i: number }[]) => { + for (const { node } of items) { + if (node.id) onRemoveNodeAutomation?.(node.id); + } + const slots = new Set(items.map((item) => item.i)); + mutate(chain.nodes.filter((_, i) => !slots.has(i))); + setOpenNode(null); + }, + [chain.nodes, mutate, onRemoveNodeAutomation], + ); + const removeNode = useCallback( (index: number) => { // The node's lanes go with it. `resolveAutomation` only hides an orphan at @@ -501,15 +575,68 @@ export function FxSection({ )); const preset = run.preset ? getAudioFxPreset(run.preset) : null; if (!preset) return rows; + // 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. + // 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 (
- - {preset.label} - +
+ + {preset.label} + + {/* The whole preset, on or off. Partly-bypassed reads as off, + because "some of it is running" is not a state an author + set — it is one they arrived at, and the switch is how they + get back out of it. */} + + +
+ {/* The same value the switch sets, so an author can put the + preset half in — and the lane below ramps it continuously. */} + 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}
);