diff --git a/packages/core/package-subpaths.json b/packages/core/package-subpaths.json index 3422498b1d..a8948eeef3 100644 --- a/packages/core/package-subpaths.json +++ b/packages/core/package-subpaths.json @@ -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", diff --git a/packages/core/package.json b/packages/core/package.json index b95101cde3..5961af0917 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", @@ -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" diff --git a/packages/core/src/audio/audioFxAutomation.ts b/packages/core/src/audio/audioFxAutomation.ts index 94e7cca6b2..d818b961b6 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 181bf08269..06abfe92f8 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 c49db5e468..b61b014352 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"; @@ -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; /** 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 = {}; + 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(); }, diff --git a/packages/core/src/audioAutomation.ts b/packages/core/src/audioAutomation.ts index fe6909b719..5c5bb7c194 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 1c9f08c0b4..0e13828648 100644 --- a/packages/core/src/audioFx.ts +++ b/packages/core/src/audioFx.ts @@ -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), })), diff --git a/packages/core/src/audioFxCopy.test.ts b/packages/core/src/audioFxCopy.test.ts index 95c9d979f2..2cdf20f574 100644 --- a/packages/core/src/audioFxCopy.test.ts +++ b/packages/core/src/audioFxCopy.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { defaultAudioFxParams, HF_AUDIO_FX } from "./audioFx.js"; import { HF_AUDIO_FX_PRESETS } from "./audioFxPresets.js"; -import { BANDS, EFFECT_COPY, PRESET_PROBLEM, SUMMARY } from "./audioFxCopy.js"; +import { audioBandAt, BANDS, EFFECT_COPY, PRESET_PROBLEM, SUMMARY } from "./audioFxCopy.js"; /** * The copy layer is only worth having if it covers everything that ships. A gap @@ -73,3 +73,33 @@ it("covers the spectrum without a gap or an overlap", () => { expect(BANDS[i]?.from, `gap or overlap before ${BANDS[i]?.name}`).toBe(BANDS[i - 1]?.to); } }); + +describe("audioBandAt", () => { + it("names the range a frequency sits in", () => { + expect(audioBandAt(50)?.name).toBe("Rumble"); + expect(audioBandAt(250)?.name).toBe("Mud"); + expect(audioBandAt(3000)?.name).toBe("Presence"); + expect(audioBandAt(12000)?.name).toBe("Air"); + }); + + it("puts a boundary in the band it opens, not the one it closes", () => { + // Off by one here means a filter at exactly 250 Hz reads as "Weight" while + // the ruler beside it highlights Mud. + for (let i = 1; i < BANDS.length; i++) { + const edge = BANDS[i]?.from; + if (edge === undefined) continue; + expect(audioBandAt(edge)?.name).toBe(BANDS[i]?.name); + } + }); + + it("clamps past both ends rather than going nameless", () => { + // A filter parked at the edge of its range still has to say where it works. + expect(audioBandAt(5)?.name).toBe(BANDS[0]?.name); + expect(audioBandAt(30000)?.name).toBe(BANDS.at(-1)?.name); + expect(audioBandAt(20000)?.name).toBe(BANDS.at(-1)?.name); + }); + + it("has no answer for a value that is not a frequency", () => { + expect(audioBandAt(Number.NaN)).toBeUndefined(); + }); +}); diff --git a/packages/core/src/audioFxCopy.ts b/packages/core/src/audioFxCopy.ts index 2dbcd3f4c8..12c23571d9 100644 --- a/packages/core/src/audioFxCopy.ts +++ b/packages/core/src/audioFxCopy.ts @@ -294,6 +294,24 @@ export const BANDS: { from: number; to: number; name: string; says: string }[] = { from: 10000, to: 20000, name: "Air", says: "sparkle, openness" }, ]; +/** + * Which named range a frequency falls in. + * + * The whole point of `BANDS` is that the words get taught, and they only get + * taught if a module can say which one it is working in. Below the first band + * and above the last both clamp rather than returning nothing: 15 Hz is still + * rumble to anybody who can hear it, and the alternative is a filter at the edge + * of its range having no name at all. + */ +export function audioBandAt(hz: number): (typeof BANDS)[number] | undefined { + if (!Number.isFinite(hz)) return undefined; + const first = BANDS[0]; + const last = BANDS.at(-1); + if (first && hz < first.from) return first; + if (last && hz >= last.to) return last; + return BANDS.find((band) => hz >= band.from && hz < band.to); +} + /** Which everyday complaint each preset answers. Presets ARE the product here. */ export const PRESET_PROBLEM: Record = { "voice-clean": "My voice sounds amateur", @@ -309,6 +327,7 @@ export const PRESET_PROBLEM: Record = { "lofi-tape": "Make it sound like an old tape", "pa-system": "Make it sound like a station announcement", intercom: "Make it sound like a door intercom", + "doofus-worble": "Make it wobble like it is seasick", "room-tight": "It sounds dry and stuck to the speaker", "room-natural": "It should sound like a real place", hall: "It should sound far away and big", diff --git a/packages/core/src/audioFxJobs.test.ts b/packages/core/src/audioFxJobs.test.ts new file mode 100644 index 0000000000..4b31cc05d0 --- /dev/null +++ b/packages/core/src/audioFxJobs.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { getAudioFxDef } from "./audioFx.js"; +import { HF_AUDIO_FX_PRESETS } from "./audioFxPresets.js"; +import { audioFxJobNode, getAudioFxJob, HF_AUDIO_FX_JOBS } from "./audioFxJobs.js"; + +const EMPTY = { version: 1 as const, nodes: [] }; + +describe("named jobs", () => { + it("makes an ordinary effect node, named for the work", () => { + const job = getAudioFxJob("reduce-mud"); + if (!job) throw new Error("no such job"); + const node = audioFxJobNode(job, EMPTY); + // Ordinary underneath: the author can open it and find the filter they could + // have added by hand, at the frequency the job picked for them. + expect(node.type).toBe("peaking"); + expect(node.params?.frequency).toBe(250); + expect(node.label).toBe("Reduce Mud"); + expect(node.enabled).toBe(true); + expect(node.id).toBeTruthy(); + }); + + it("gives every job a value for every parameter its effect declares", () => { + // A job names a range and leaves the rest alone, so the omitted parameters + // have to come from the registry — a node missing `q` renders at whatever + // the graph builder falls back to rather than what the panel shows. + for (const job of HF_AUDIO_FX_JOBS) { + const def = getAudioFxDef(job.type); + expect(def, `${job.id} names an effect that does not exist`).toBeDefined(); + const node = audioFxJobNode(job, EMPTY); + for (const param of def?.params ?? []) { + expect(node.params?.[param.key], `${job.id} has no ${param.key}`).toBeDefined(); + } + } + }); + + it("mints an id that does not collide with what is already there", () => { + const job = getAudioFxJob("add-clarity"); + if (!job) throw new Error("no such job"); + const first = audioFxJobNode(job, EMPTY); + const second = audioFxJobNode(job, { version: 1, nodes: [first] }); + // Two of the same job is a real thing to want, and a shared id would give + // them each other's automation lanes. + expect(second.id).not.toBe(first.id); + }); + + it("names jobs the preset catalogue already ships", () => { + // The point of the list is to name the vocabulary the presets were written + // in, not to invent a second one beside it. A job nobody's preset uses is a + // guess about what authors want; one a preset uses has already been chosen. + const shipped = new Set( + HF_AUDIO_FX_PRESETS.flatMap((preset) => preset.nodes.map((node) => node.label)), + ); + for (const job of HF_AUDIO_FX_JOBS) { + expect(shipped, `${job.label} is not a job any preset does`).toContain(job.label); + } + }); + + it("has an id for every job and no duplicates", () => { + const ids = HF_AUDIO_FX_JOBS.map((job) => job.id); + expect(new Set(ids).size).toBe(ids.length); + for (const id of ids) expect(getAudioFxJob(id)?.id).toBe(id); + }); +}); diff --git a/packages/core/src/audioFxJobs.ts b/packages/core/src/audioFxJobs.ts new file mode 100644 index 0000000000..fe619c53a9 --- /dev/null +++ b/packages/core/src/audioFxJobs.ts @@ -0,0 +1,117 @@ +/** + * Named jobs: the range IS the module. + * + * "Shape One Range" has three controls — where, how much, how wide — and no + * honest way to nominate one of them as the knob that matters. Nominating *how + * much* is incoherent, because boosting an unspecified frequency means nothing: + * the range is the first decision, not the second. + * + * So the menu offers the decision instead of the machine. Each job is a peaking + * filter with its frequency already chosen — Reduce Mud, Add Clarity, Soften + * Harshness — and picking the module IS picking the range, which makes one knob + * honest rather than a simplification hiding the real choice. It also dissolves + * the duplicate-name problem at the root: a preset that cuts mud and then lifts + * clarity used to read "Shape One Range" twice with nothing to tell them apart. + * + * Every job here is one the preset catalogue already ships, at the settings it + * ships them with, so this names the vocabulary the presets were written in + * rather than inventing a second one. Reasoning in + * `plans/audio-fx-ux/README.md` §"The hole in the single-knob rule". + * + * The frequency is a starting point, not a cage — it is an ordinary peaking node + * underneath, and Details opens on the same three controls it always had. + */ + +import { + defaultAudioFxParams, + mintAudioFxNodeId, + normalizeAudioFxParams, + type HfAudioFxChain, + type HfAudioFxNode, + type HfAudioFxParamValues, +} from "./audioFx.js"; + +export interface HfAudioFxJob { + id: string; + /** What the rack calls the node this makes. */ + label: string; + /** The complaint that leads here, in the author's words. */ + does: string; + /** The registry effect underneath. */ + type: string; + /** Where it acts, and how hard — the decision the author is spared. */ + params: HfAudioFxParamValues; +} + +/** + * Ordered low to high, which is the order an author hears them in: weight and + * mud at the bottom, clarity and harshness at the top. + */ +export const HF_AUDIO_FX_JOBS: readonly HfAudioFxJob[] = [ + { + id: "tame-boominess", + label: "Tame Boominess", + does: "Too much chest — it booms.", + type: "peaking", + params: { frequency: 200, gain: -4, q: 1.4 }, + }, + { + id: "reduce-mud", + label: "Reduce Mud", + does: "Muffled, like it is behind cardboard.", + type: "peaking", + params: { frequency: 250, gain: -3, q: 1.2 }, + }, + { + id: "reduce-boxiness", + label: "Reduce Boxiness", + does: "Sounds like a small room, or a box.", + type: "peaking", + params: { frequency: 400, gain: -3, q: 1.4 }, + }, + { + id: "add-clarity", + label: "Add Clarity", + does: "Words are hard to make out.", + type: "peaking", + params: { frequency: 3000, gain: 2.5, q: 1 }, + }, + { + id: "soften-harshness", + label: "Soften Harshness", + does: "Harsh and tiring to listen to.", + type: "peaking", + params: { frequency: 3200, gain: -3, q: 1.6 }, + }, +]; + +export function getAudioFxJob(id: string): HfAudioFxJob | undefined { + return HF_AUDIO_FX_JOBS.find((job) => job.id === id); +} + +/** Effect ids the job list covers, which the add menu offers as jobs instead. */ +export const HF_AUDIO_FX_JOB_TYPES: ReadonlySet = new Set( + HF_AUDIO_FX_JOBS.map((job) => job.type), +); + +/** + * The node a job adds: an ordinary effect carrying the job's name. + * + * `label` is the field a preset already uses to name a node for the work it + * does, so a job needs nothing new — and a job node and a preset node are + * indistinguishable afterwards, which is right. They are the same idea. + */ +export function audioFxJobNode(job: HfAudioFxJob, chain: HfAudioFxChain): HfAudioFxNode { + return { + type: job.type, + id: mintAudioFxNodeId(chain), + enabled: true, + label: job.label, + // Through the registry, so a job cannot ship a value the effect would clamp + // or a key it does not have. + params: normalizeAudioFxParams(job.type, { + ...defaultAudioFxParams(job.type), + ...job.params, + }), + }; +} diff --git a/packages/core/src/audioFxPresets.test.ts b/packages/core/src/audioFxPresets.test.ts index 2f187ce814..11e0a1160e 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/audioFxPresets.ts b/packages/core/src/audioFxPresets.ts index 4f49d45772..0a20738a15 100644 --- a/packages/core/src/audioFxPresets.ts +++ b/packages/core/src/audioFxPresets.ts @@ -197,19 +197,32 @@ export const HF_AUDIO_FX_PRESETS: readonly HfAudioFxPreset[] = [ { type: "saturate", label: "Circuit Grit", - params: { type: "tanh", threshold: -18, output: -2 }, + params: { type: "tanh", threshold: -9, output: -2 }, }, ], ), preset("radio-am", "character", "AM Radio", "Narrow, gritty and a little crushed.", [ - { type: "highpass", label: "Strip the Bass", params: { frequency: 400, q: 0.707, poles: "2" } }, + // Narrower than the megaphone at BOTH ends, which is most of the difference + // between them: an AM channel is a few kHz wide and the receiver rolls off + // well before a horn does. + { type: "highpass", label: "Strip the Bass", params: { frequency: 220, q: 0.707, poles: "2" } }, { type: "lowpass", label: "Strip the Treble", - params: { frequency: 3000, q: 0.707, poles: "2" }, + params: { frequency: 2200, q: 0.707, poles: "2" }, }, - { type: "saturate", label: "Radio Grit", params: { type: "tanh", threshold: -15, output: -2 } }, - { type: "bitcrush", label: "Crunch", params: { bits: 10, samples: 1, mix: 0.25 } }, + // Where the telephone honks, a receiver DIPS: the IF filter's droop, and the + // reason the two stop sounding alike. Telephone's band is its identity (it + // is the G.712 passband), so the radio is what moves. + { type: "peaking", label: "IF Droop", params: { frequency: 1200, gain: -5, q: 0.9 } }, + // And a lift at the bottom of the band — AM is boxy where a phone is thin. + { type: "lowshelf", label: "Boxy", params: { frequency: 500, gain: 4 } }, + // Soft — a receiver compressing, not a driver being overdriven. `tanh` + // rounds the peaks where the megaphone's `hard` clips them flat. + { type: "saturate", label: "Radio Grit", params: { type: "tanh", threshold: -8, output: -1 } }, + // The crush is the AM signature: quantisation noise reads as carrier hiss, + // and it is the one thing the megaphone has none of. + { type: "bitcrush", label: "Carrier Hiss", params: { bits: 8, samples: 1, mix: 0.45 } }, ]), preset( "megaphone", @@ -220,20 +233,30 @@ export const HF_AUDIO_FX_PRESETS: readonly HfAudioFxPreset[] = [ { type: "highpass", label: "Strip the Bass", - params: { frequency: 500, q: 0.707, poles: "2" }, + params: { frequency: 700, q: 0.707, poles: "2" }, }, { type: "lowpass", label: "Strip the Treble", params: { frequency: 4000, q: 0.707, poles: "2" }, }, - { type: "peaking", label: "Horn Honk", params: { frequency: 1800, gain: 8, q: 1.5 } }, + // A horn is a resonant tube and that resonance IS the sound: one narrow + // peak with a second ringing above it, where the radio has none at all. + { type: "peaking", label: "Horn Honk", params: { frequency: 1900, gain: 14, q: 3 } }, + { type: "peaking", label: "Horn Ring", params: { frequency: 3200, gain: 6, q: 3 } }, + // A driver pushed past its limit — flat-topped, not rounded. Measured on a + // log sweep, the threshold is the whole ballgame: at -14 the clipper + // flattened the response to a dead -19 dB line and ERASED the horn peaks + // above, leaving this indistinguishable from AM Radio. Backed off until + // the resonance survives the clipping that is supposed to sit on top of it. { type: "saturate", label: "Overdrive", - params: { type: "hard", threshold: -12, output: -3 }, + params: { type: "hard", threshold: -5, output: -3 }, }, - { type: "delay", label: "Horn Slap", params: { time: 40, feedback: 0.15, mix: 0.15 } }, + // The outdoor reflection that comes back off whatever is being shouted at. + // Far enough to be a slap rather than a thickening. + { type: "delay", label: "Horn Slap", params: { time: 65, feedback: 0.2, mix: 0.28 } }, ], ), preset( @@ -259,22 +282,24 @@ export const HF_AUDIO_FX_PRESETS: readonly HfAudioFxPreset[] = [ ], ), preset("pa-system", "character", "Tannoy", "Announced across a concourse.", [ - { type: "highpass", label: "Strip the Bass", params: { frequency: 350, q: 0.707, poles: "2" } }, + { type: "highpass", label: "Strip the Bass", params: { frequency: 250, q: 0.707, poles: "2" } }, { type: "lowpass", label: "Strip the Treble", - params: { frequency: 3500, q: 0.707, poles: "2" }, + params: { frequency: 5000, q: 0.707, poles: "2" }, }, - { type: "peaking", label: "Tannoy Honk", params: { frequency: 1500, gain: 5, q: 1.2 } }, + // Higher and harder than the telephone's honk, which is what a big horn + // does — and it keeps the top the phone throws away. + { type: "peaking", label: "Tannoy Honk", params: { frequency: 2400, gain: 9, q: 2 } }, { type: "saturate", label: "Driver Grit", - params: { type: "tanh", threshold: -16, output: -1 }, + params: { type: "tanh", threshold: -10, output: -1 }, }, { type: "reverb", label: "Concourse", - params: { size: 0.5, damping: 0.7, wet: 0.25, dry: 0.8 }, + params: { size: 0.54, damping: 0.48, wet: 0.25, dry: 0.8 }, }, ]), preset("intercom", "character", "Intercom", "Buzzed through a door panel, squelch and all.", [ @@ -292,6 +317,22 @@ export const HF_AUDIO_FX_PRESETS: readonly HfAudioFxPreset[] = [ { type: "peaking", label: "Panel Honk", params: { frequency: 2000, gain: 6, q: 2 } }, { type: "bitcrush", label: "Crunch", params: { bits: 11, samples: 1, mix: 0.3 } }, ]), + // The chorus with its wobble dialled up until it stops being width and starts + // being the effect: fast (10 Hz, the top of the range) and fully wet, so none + // of the straight signal is left to anchor the pitch. + preset( + "doofus-worble", + "character", + "Doofus Worble", + "Seasick and wobbling — no straight signal left.", + [ + { + type: "chorus", + label: "Worble", + params: { delay: 14.6, depth: 2.57, speed: 10, mix: 1 }, + }, + ], + ), // ---------------------------------------------------------------- space -- preset("room-tight", "space", "Tight Room", "A small hard room — presence without wash.", [ diff --git a/packages/core/src/audioFxProfiles.test.ts b/packages/core/src/audioFxProfiles.test.ts new file mode 100644 index 0000000000..a9f7e6c1ca --- /dev/null +++ b/packages/core/src/audioFxProfiles.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import { defaultAudioFxParams, getAudioFxDef } from "./audioFx.js"; +import { EFFECT_COPY } from "./audioFxCopy.js"; +import { + applyAudioFxProfile, + audioFxProfileStrength, + getAudioFxProfile, + HF_AUDIO_FX_PROFILES, +} from "./audioFxProfiles.js"; + +describe("derived one-knob profiles", () => { + it("covers exactly the effects whose copy asks for one", () => { + // `primary: "strength"` is the copy layer saying "this module has no real + // parameter that can honestly be its face". A profile missing for one of + // those leaves the module opening on all seven of its controls; a profile + // for anything else is a knob nobody asked for. + const asked = Object.entries(EFFECT_COPY) + .filter(([, copy]) => copy.primary === "strength") + .map(([id]) => id) + .sort(); + expect(Object.keys(HF_AUDIO_FX_PROFILES).sort()).toEqual(asked); + }); + + it("derives only parameters the effect actually has", () => { + for (const [id, profile] of Object.entries(HF_AUDIO_FX_PROFILES)) { + const keys = getAudioFxDef(id)?.params.map((p) => p.key) ?? []; + for (const key of profile.derives) { + expect(keys, `${id} derives "${key}", which it does not have`).toContain(key); + } + // And the curve sets everything it claims to. + for (const key of profile.derives) { + expect(profile.at(0.5)[key], `${id}.${key} is not set at 0.5`).toBeDefined(); + } + } + }); + + it("stays inside every parameter's declared range across the whole knob", () => { + // A profile that runs past a range is not clipped by the panel — it is + // clamped on the way into the chain, so the knob would silently stop + // meaning anything past that point. + for (const [id, profile] of Object.entries(HF_AUDIO_FX_PROFILES)) { + const def = getAudioFxDef(id); + for (let s = 0; s <= 1.0001; s += 0.05) { + const derived = profile.at(s); + for (const param of def?.params ?? []) { + const value = derived[param.key]; + if (value === undefined || param.kind !== "number") continue; + expect(value, `${id}.${param.key} at ${s.toFixed(2)}`).toBeGreaterThanOrEqual(param.min); + expect(value, `${id}.${param.key} at ${s.toFixed(2)}`).toBeLessThanOrEqual(param.max); + } + } + } + }); + + it("moves every derived parameter monotonically", () => { + // The whole promise of one knob: everything under it moves together and in + // one direction. A parameter that turns around mid-sweep means the knob + // makes the effect stronger and then weaker, which is unusable and is the + // failure a hand-tuned table invites. + for (const [id, profile] of Object.entries(HF_AUDIO_FX_PROFILES)) { + for (const key of profile.derives) { + const series: number[] = []; + for (let s = 0; s <= 1.0001; s += 0.05) { + const value = profile.at(s)[key]; + if (typeof value === "number") series.push(value); + } + const up = series.every((v, i) => i === 0 || v >= (series[i - 1] ?? v)); + const down = series.every((v, i) => i === 0 || v <= (series[i - 1] ?? v)); + expect(up || down, `${id}.${key} turns around mid-sweep`).toBe(true); + } + } + }); + + it("keeps what the author set under Details", () => { + // A profile names only the parameters it derives. A compressor's knee and a + // saturation's curve type are the author's, and the knob must not reach in + // and reset them on the way past. + const params = { ...defaultAudioFxParams("compressor"), knee: 6, mix: 0.5 }; + const next = applyAudioFxProfile("compressor", 0.8, params); + expect(next.knee).toBe(6); + expect(next.mix).toBe(0.5); + expect(next.ratio).not.toBe(params.ratio); + }); + + it("reads a strength back out of the values a chain stores", () => { + // A chain stores mechanism, not the knob — the mechanism is what renders, + // so it has to be authoritative. Reopening a project has to put the knob + // back where it was. + for (const [id, profile] of Object.entries(HF_AUDIO_FX_PROFILES)) { + for (const s of [0, 0.25, 0.5, 0.75, 1]) { + const params = applyAudioFxProfile(id, s, defaultAudioFxParams(id)); + expect(audioFxProfileStrength(id, params), `${id} at ${s}`).toBeCloseTo(s, 1); + } + void profile; + } + }); + + it("passes through the figures the design proposed", () => { + // The curves replaced a three-point table, and these are the points it + // named. Continuous beats three settings, but not at the price of landing + // somewhere else than the design said. + expect(getAudioFxProfile("compressor")?.at(0).threshold).toBe(-12); + expect(getAudioFxProfile("compressor")?.at(0.5).ratio).toBe(4); + expect(getAudioFxProfile("compressor")?.at(1).release).toBe(90); + expect(getAudioFxProfile("gate")?.at(0).threshold).toBe(-55); + expect(getAudioFxProfile("saturate")?.at(1).threshold).toBe(-18); + expect(getAudioFxProfile("reverb")?.at(0).size).toBe(0.25); + expect(getAudioFxProfile("reverb")?.at(0.5).size).toBe(0.55); + expect(getAudioFxProfile("reverb")?.at(1).size).toBe(0.9); + expect(getAudioFxProfile("bitcrush")?.at(1).bits).toBe(6); + }); + + it("takes a value that is not a number as the middle", () => { + // The knob has to land somewhere, and silence is the one answer that is + // never right. + expect(getAudioFxProfile("reverb")?.at(Number.NaN).size).toBe( + getAudioFxProfile("reverb")?.at(0.5).size, + ); + expect(audioFxProfileStrength("reverb", {})).toBe(0.5); + expect(audioFxProfileStrength("not-an-effect", {})).toBe(0.5); + }); +}); diff --git a/packages/core/src/audioFxProfiles.ts b/packages/core/src/audioFxProfiles.ts new file mode 100644 index 0000000000..0ec7cd745f --- /dev/null +++ b/packages/core/src/audioFxProfiles.ts @@ -0,0 +1,209 @@ +/** + * One knob over several, for the five effects that cannot honestly have one + * real parameter nominated as the control that matters. + * + * A compressor has seven controls and an author wants one. The rest of the rack + * opens on a single real parameter — a filter's frequency, a delay's mix — but + * these five have no such parameter: threshold means nothing without ratio, + * ratio means nothing without make-up gain, and picking any one of them as the + * face of the module would be a knob that lies about what it sets. + * + * So they get a derived one, exactly as the carve already does: `carveProfile` + * turns a single 0..1 into six numbers, and these do the same for the rest of + * the rack. `EFFECT_COPY[id].primary` is `"strength"` for precisely these five, + * which is what says a module wants this treatment. + * + * Continuous rather than the three-point tables the design proposed. A table + * makes gentle/middle/strong three settings an author picks between, and the + * thing being modelled is not three settings — it is one axis, and the carve's + * knob has proved that reads. The proposal's figures survive as the anchors: + * each curve passes through them at 0, 0.5 and 1. + * + * Every number below is expressed in the parameter's own registry unit and then + * clamped by `normalizeAudioFxParams` on the way into the chain, so a profile + * cannot ship a value the effect would refuse. + */ + +import { normalizeAudioFxParams, type HfAudioFxParamValues } from "./audioFx.js"; + +/** 0..1, whatever arrives. NaN reads as the middle rather than as silence. */ +function clamp01(strength: number): number { + return Number.isFinite(strength) ? Math.min(1, Math.max(0, strength)) : 0.5; +} + +/** Two decimal places, so a derived value reads as a setting and not as float noise. */ +function to2(value: number): number { + return Number(value.toFixed(2)); +} + +export interface HfAudioFxProfile { + /** What the one knob is called. Never the DSP name of anything it drives. */ + label: string; + /** What the two ends sound like — the question an author actually has. */ + ends: { low: string; high: string }; + /** The parameters it sets. Everything else keeps the effect's own default. */ + derives: readonly string[]; + /** The mechanism values at a given strength. */ + at(strength: number): HfAudioFxParamValues; +} + +export const HF_AUDIO_FX_PROFILES: Record = { + compressor: { + label: "Evenness", + ends: { low: "Barely touched", high: "Very even, quite squashed" }, + derives: ["threshold", "ratio", "attack", "release", "makeup"], + at(strength) { + const s = clamp01(strength); + return { + // Lower threshold and higher ratio together: more of the signal is + // caught, and what is caught is held harder. Moving one without the + // other is the pair of knobs this exists to stop an author meeting. + threshold: to2(-12 - s * 18), + ratio: to2(2 + s * 4), + // Faster as it gets firmer, because a firm compressor that lets peaks + // through is doing the audible half of its job and not the useful half. + attack: to2(25 - s * 20), + release: to2(300 - s * 210), + // Compression makes things quieter; this is the level put back, and it + // has to rise with the amount taken off or the knob reads as a volume + // control that goes the wrong way. + // + // Solved from measurement rather than proposed, and it is not linear: + // gain reduction accelerates as the threshold drops and the ratio rises + // together, so a straight line is too loud in the middle and too quiet + // at the top. The design's 1/3/7 dB left the track +0.9 dB at rest, + // +1.3 dB at the middle and −2.5 dB at full. Numbers in + // `~/audio-fx-profiles-ab/README.md`. + makeup: to2(s * s * 9.5), + }; + }, + }, + + gate: { + label: "Tightness", + ends: { low: "Only true silence", high: "Cuts quiet words too" }, + derives: ["threshold", "range", "release"], + at(strength) { + const s = clamp01(strength); + return { + threshold: to2(-55 + s * 23), + // How far the gaps are ducked, never to silence: a room that stops dead + // between sentences sounds broken rather than clean. + range: to2(-10 - s * 20), + // Release has to come along, which the design did not have — and it + // dominates: swept against a fixed threshold and range, the gaps move + // 0.2 dB at 140 ms and 13.4 dB at 10 ms. Left at the effect's 100 ms + // default the gate was very nearly inaudible whatever else it was told. + // + // Measured on narration, speech level is unmoved (−24.0 dB) at every + // release down to 10 ms, so the usual reason to stay slow — clipping + // word endings — does not bite on this material at these thresholds. + release: to2(120 - s * 105), + }; + }, + }, + + saturate: { + label: "Warmth", + ends: { low: "Just a sheen", high: "Openly distorted" }, + derives: ["threshold", "output"], + at(strength) { + const s = clamp01(strength); + return { + // Drive: the lower the threshold, the more of the signal meets the + // curve. + threshold: to2(-3 - s * 15), + // Up, not down — which reverses the design's figure, on the measurement + // that motivated taking one. A soft clipper at -18 dB threshold IS a + // limiter at -18 dB: peak fell to 0.089 from 0.496 and RMS almost + // halved, so the proposed trim of −3 dB made "warmer" mean "much + // quieter" and an author would have heard the level, not the warmth. + // Accelerating for the same reason as the compressor's make-up. + output: to2(s * s * 2.8), + }; + }, + }, + + reverb: { + label: "Space", + ends: { low: "A small tight room", high: "A big open hall" }, + derives: ["size", "wet", "dry"], + at(strength) { + const s = clamp01(strength); + return { + // Anchored at the design's three figures — 0.25 / 0.55 / 0.90 — which a + // single linear run cannot hit, because 0.55 is not their midpoint. + size: to2(s <= 0.5 ? 0.25 + s * 0.6 : 0.55 + (s - 0.5) * 0.7), + wet: to2(0.15 + s * 0.3), + // Dry comes down as wet goes up, but not by the same amount: the two + // legs sum, and matching them exactly makes a big room quieter than a + // small one instead of further away. + dry: to2(0.92 - s * 0.2), + }; + }, + }, + + bitcrush: { + label: "Crush", + ends: { low: "Slightly gritty", high: "Destroyed" }, + derives: ["bits", "samples", "mix"], + at(strength) { + const s = clamp01(strength); + return { + // Fewer steps and longer holds. `bits` runs DOWN as the knob runs up, + // which is why it cannot be the module's face on its own. + bits: to2(14 - s * 8), + samples: Math.max(1, Math.round(1 + s * 3)), + mix: to2(0.25 + s * 0.75), + }; + }, + }, +}; + +export function getAudioFxProfile(type: string): HfAudioFxProfile | undefined { + return HF_AUDIO_FX_PROFILES[type]; +} + +/** + * The parameters a profile sets at this strength, merged over what is there. + * + * Merged rather than replacing: a profile names only the parameters it derives, + * and the rest — a compressor's knee, a saturation's curve type — are the + * author's to set under Details and must survive the knob moving. + */ +export function applyAudioFxProfile( + type: string, + strength: number, + params: HfAudioFxParamValues, +): HfAudioFxParamValues { + const profile = getAudioFxProfile(type); + if (!profile) return params; + return normalizeAudioFxParams(type, { ...params, ...profile.at(strength) }); +} + +/** + * The strength a set of parameters reads as, by inverting the profile's own + * curve on its most characteristic parameter. + * + * A chain stores mechanism values, not the knob — the same contract + * `normalizeCarveSettings` has, and for the same reason: the mechanism is what + * renders, so it is what must be authoritative. Reading the knob back means one + * parameter has to be nominated as the one that says most about intent. + * + * A hand-edited chain therefore lands the knob at the nearest strength that + * would have produced its most telling value, which is the honest answer — the + * alternative is a knob parked at a default while the effect is set to + * something else entirely. + */ +export function audioFxProfileStrength(type: string, params: HfAudioFxParamValues): number { + const profile = getAudioFxProfile(type); + if (!profile) return 0.5; + const key = profile.derives[0]; + if (key === undefined) return 0.5; + const value = params[key]; + if (typeof value !== "number") return 0.5; + const low = profile.at(0)[key]; + const high = profile.at(1)[key]; + if (typeof low !== "number" || typeof high !== "number" || low === high) return 0.5; + return to2(Math.min(1, Math.max(0, (value - low) / (high - low)))); +} diff --git a/packages/core/src/runtime/audioFx.ts b/packages/core/src/runtime/audioFx.ts index 22de1dc2e1..07486de1e6 100644 --- a/packages/core/src/runtime/audioFx.ts +++ b/packages/core/src/runtime/audioFx.ts @@ -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 diff --git a/packages/core/stubs/audio-fx-runtime-entry.ts b/packages/core/stubs/audio-fx-runtime-entry.ts index d6a78b9439..b8ed1e8e16 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.test.tsx b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx index 509a0b642c..e4336f81f2 100644 --- a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx @@ -64,6 +64,17 @@ function audioSelection( return { dataAttributes, id: "bed", element: bed } as unknown as DomEditSelection; } +/** + * Open a module's Details, where every control that is not the primary one now + * lives — a module opens on one knob and the rest is one click away. + */ +function openDetails(host: HTMLElement, index = 0): void { + const buttons = Array.from(host.querySelectorAll(".hf-fx-node-details")); + const button = buttons[index]; + if (!button) throw new Error("no Details disclosure to open"); + act(() => button.click()); +} + /** A button found by the text it contains, since several now read as sentences. */ function byTextButton(host: HTMLElement, text: string): HTMLButtonElement | undefined { return Array.from(host.querySelectorAll("button")).find((b) => b.textContent?.includes(text)); @@ -111,7 +122,11 @@ const writeTo = (calls: unknown[][], attr: string): unknown[] | undefined => describe("AudioFxGroup automation", () => { it("renders the chain's parameters", () => { const { host } = mount({ "fx-chain": CHAIN }); + // The one knob that carries the module is on the open face; the rest are one + // click away, which is what Details is. expect(rowFor(host, plainLabel("lowpass", "frequency"))).toBeTruthy(); + expect(rowFor(host, plainLabel("lowpass", "q"))).toBeNull(); + openDetails(host); expect(rowFor(host, plainLabel("lowpass", "q"))).toBeTruthy(); }); @@ -139,6 +154,7 @@ describe("AudioFxGroup automation", () => { lanes: [{ target: "volume", points: [{ t: 0, v: 0.5 }] }], }), }); + openDetails(host); act(() => ( rowFor(host, plainLabel("lowpass", "q"))!.querySelector( @@ -164,6 +180,7 @@ describe("AudioFxGroup automation", () => { const cutoff = rowFor(host, plainLabel("lowpass", "frequency"))!; expect(cutoff.querySelector('input[type="range"]')?.disabled).toBe(true); expect(cutoff.hasAttribute("data-automated")).toBe(true); + openDetails(host); expect( rowFor(host, plainLabel("lowpass", "q"))!.querySelector( 'input[type="range"]', @@ -418,6 +435,45 @@ describe("AudioFxGroup dynamic carve", () => { * nobody asked to level, through a channel that does not persist: audible, * absent from the document, and gone on the next reload. */ + describe("auditioning starts the transport when it has to", () => { + const store = () => usePlayerStore.getState(); + + const hoverPreset = (host: HTMLElement) => { + act(() => byTextButton(host, "Presets")?.click()); + act(() => host.querySelector(".hf-fx-preset-item")?.focus()); + }; + const leaveShelf = (host: HTMLElement) => + act(() => { + host + .querySelector(".hf-fx-preset-menu") + ?.dispatchEvent(new FocusEvent("focusout", { bubbles: true })); + }); + + it("plays from the playhead, then puts it back exactly where it was", () => { + // Browsing the shelf must not cost the author their place: hovering is not + // an edit, so the playhead it borrows has to be returned. + act(() => usePlayerStore.setState({ isPlaying: false, currentTime: 42 })); + const { host } = mount({ "fx-chain": CHAIN }); + hoverPreset(host); + expect(store().playbackRequest?.playing).toBe(true); + + leaveShelf(host); + expect(store().playbackRequest?.playing).toBe(false); + expect(store().playbackRequest?.returnTo).toBe(42); + }); + + it("leaves a transport the author started alone", () => { + // Stopping their playback because they passed over a preset would be the + // panel taking a decision nobody offered it. + act(() => usePlayerStore.setState({ isPlaying: true, currentTime: 12 })); + const { host } = mount({ "fx-chain": CHAIN }); + const before = store().playbackRequest?.nonce ?? 0; + hoverPreset(host); + leaveShelf(host); + expect(store().playbackRequest?.nonce ?? 0).toBe(before); + }); + }); + it("drops a levelling measurement that lands after the pointer has gone", async () => { const { release, decoded } = stubGatedDecode(); const { host, onSetAttributeLive } = mount({ "fx-chain": CHAIN }); @@ -456,7 +512,9 @@ describe("AudioFxGroup dynamic carve", () => { act(() => byTextButton(host, "Even Out Levels")?.focus()); // Straight to a neighbour, without ever leaving the shelf. act(() => - byTextButton(host, "Reverb")?.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })), + byTextButton(host, EFFECT_COPY.reverb?.title ?? "")?.dispatchEvent( + new MouseEvent("mouseover", { bubbles: true }), + ), ); await settleDecode(release, decoded); diff --git a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx index 8585c8c880..bf2df33c1d 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. * @@ -549,6 +574,40 @@ export function AudioFxGroup({ } }; + /** + * Where the playhead was when an audition started the transport, so leaving + * can put it back. Null means this audition did not start playback — the + * transport was already running and must be left alone. + */ + const auditionReturn = useRef(null); + + /** + * Start playback for an audition, and stop it again on the way out. + * + * An audition writes the preset to the running graph, which is silent while + * the transport is paused — so a paused author hovering a preset heard + * nothing at all, and the whole affordance only worked mid-playback. Hovering + * now plays from the playhead, and leaving stops and rewinds to exactly where + * it started: browsing the shelf must not cost the author their place. + * + * Already playing, this does nothing in either direction. The author started + * that, and stopping their transport because they passed over a preset would + * be the panel taking a decision that was not offered to it. + */ + const auditionTransport = (on: boolean): void => { + const store = usePlayerStore.getState(); + if (on) { + if (store.isPlaying || auditionReturn.current !== null) return; + auditionReturn.current = store.currentTime; + store.requestPlayback(true); + return; + } + const returnTo = auditionReturn.current; + if (returnTo === null) return; + auditionReturn.current = null; + store.requestPlayback(false, returnTo); + }; + const [auditioningLevel, setAuditioningLevel] = useState(false); /** * Bumped on every enter and leave, so a measurement can tell whether the @@ -783,6 +842,7 @@ export function AudioFxGroup({ next.nodes.length ? serializeAudioFxChain(next) : null, ) } + onAuditionTransport={auditionTransport} onChainPreview={(next) => // Live writes skip the preview refresh entirely, so dragging a knob no // longer reloads the composition and restarts playback on every pixel. @@ -796,6 +856,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/propertyPanelFxBandRuler.tsx b/packages/studio/src/components/editor/propertyPanelFxBandRuler.tsx new file mode 100644 index 0000000000..a5e569b429 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFxBandRuler.tsx @@ -0,0 +1,72 @@ +/** + * The shared frequency ruler. + * + * Frequencies mean nothing to somebody who has not been taught them, and the + * rack speaks entirely in them. `BANDS` names the ranges in the words the same + * person would use unprompted — rumble, weight, mud, middle, presence, edge, + * air — and every spectral module shows where it acts on this one ruler, so + * naming them once teaches them everywhere they appear. + * + * Two things at once, deliberately. The bar says where this module works + * relative to everything else, which is the spatial fact; the caption under it + * names the range and what lives there, which is the vocabulary. A bar alone + * would be decoration and a caption alone would not teach the shape. + * + * Log-spaced, because hearing is: 20–200 Hz is as much of the range to an ear as + * 2–20 kHz, and a linear ruler would crush six of the seven bands into a corner. + */ + +import { audioBandAt, BANDS } from "@hyperframes/core/audio-fx-copy"; + +const LOW = BANDS[0]?.from ?? 20; +const HIGH = BANDS.at(-1)?.to ?? 20000; + +/** Where a frequency sits across the ruler, 0..1. */ +function positionOf(hz: number): number { + const span = Math.log10(HIGH) - Math.log10(LOW); + const at = (Math.log10(Math.min(HIGH, Math.max(LOW, hz))) - Math.log10(LOW)) / span; + return Math.min(1, Math.max(0, at)); +} + +export interface FxBandRulerProps { + /** The range this effect can act over, from its copy. */ + band: readonly [number, number]; + /** Where it is acting right now. */ + at: number; +} + +export function FxBandRuler({ band, at }: FxBandRulerProps) { + const here = audioBandAt(at); + if (!here) return null; + const [from, to] = band; + return ( +
+
+ {BANDS.map((range) => { + // Reachable at all, and where it is now: a module that can only work in + // the bottom three bands should not look like it could move anywhere. + const reachable = range.to > from && range.from < to; + return ( + + ); + })} +
+

+ {here.name} — {here.says} +

+
+ ); +} diff --git a/packages/studio/src/components/editor/propertyPanelFxCarveModule.tsx b/packages/studio/src/components/editor/propertyPanelFxCarveModule.tsx index b2630f0068..d6ba3a7ddf 100644 --- a/packages/studio/src/components/editor/propertyPanelFxCarveModule.tsx +++ b/packages/studio/src/components/editor/propertyPanelFxCarveModule.tsx @@ -17,6 +17,7 @@ import { import { DEFAULT_CARVE, type HfCarveSettings } from "@hyperframes/core/audio-carve"; import { fxAutomationTarget } from "@hyperframes/core/audio-automation"; import { FxParamRow } from "./propertyPanelFxControls.js"; +import { FX_FAMILY_TYPE, fxFamilyTint } from "./propertyPanelFxFamily.js"; // Shared with the timeline's lane labels: a band is named by its frequency in // both places, and two formatters would drift. import { formatHz } from "../../player/components/automationLaneData"; @@ -201,16 +202,21 @@ export function FxCarveModule({ : "pick a voice"; return (
+ ) : ( +

+ Details — {registryDef.label} +

+ )} + {details || !oneKnob ? ( + + ) : null} ) : null}
diff --git a/packages/studio/src/components/editor/propertyPanelFxPresetMenu.tsx b/packages/studio/src/components/editor/propertyPanelFxPresetMenu.tsx index e661024c79..257a3c28de 100644 --- a/packages/studio/src/components/editor/propertyPanelFxPresetMenu.tsx +++ b/packages/studio/src/components/editor/propertyPanelFxPresetMenu.tsx @@ -68,7 +68,8 @@ export function FxPresetMenu({ onPick, onAudition }: FxPresetMenuProps) { ))}
diff --git a/packages/studio/src/components/editor/propertyPanelFxPresetStyle.test.ts b/packages/studio/src/components/editor/propertyPanelFxPresetStyle.test.ts new file mode 100644 index 0000000000..61c3f10e10 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFxPresetStyle.test.ts @@ -0,0 +1,116 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from "vitest"; +import { HF_AUDIO_FX_PRESETS } from "@hyperframes/core/audio-fx-presets"; +import { + FX_PRESET_STYLE, + FX_PRESET_STYLE_DEFAULT, + fxPresetBackground, + fxPresetStyle, +} from "./propertyPanelFxPresetStyle.js"; + +describe("per-preset title treatments", () => { + it("styles every preset the catalogue ships", () => { + // An unstyled preset is not broken — it falls back — but it is a row that + // silently opts out of the design, which nobody would notice. + const missing = HF_AUDIO_FX_PRESETS.filter((p) => !FX_PRESET_STYLE[p.id]).map((p) => p.id); + expect(missing).toEqual([]); + }); + + it("styles no preset the catalogue does not", () => { + // Dead entries for renamed or removed presets read as coverage. + const shipped = new Set(HF_AUDIO_FX_PRESETS.map((p) => p.id)); + expect(Object.keys(FX_PRESET_STYLE).filter((id) => !shipped.has(id))).toEqual([]); + }); + + it("gives the character presets treatments that differ from each other", () => { + // The whole point: a preset is a character, and Telephone should not look + // like Megaphone. The corrective families may legitimately share a look. + const character = HF_AUDIO_FX_PRESETS.filter((p) => p.family === "character"); + const looks = new Set( + character.map((p) => `${fxPresetStyle(p.id).type}|${fxPresetStyle(p.id).family}`), + ); + expect(looks.size).toBe(character.length); + }); + + it("names a real font stack, ending in a generic the browser always has", () => { + // The studio has no webfont pipeline, so these are system faces — and a + // machine without the named one has to land somewhere deliberate rather + // than on the browser's default serif. + const generic = /(sans-serif|serif|monospace|fantasy|cursive|ui-monospace)\s*$/; + for (const [id, style] of Object.entries(FX_PRESET_STYLE)) { + expect(style.family, `${id} names no face`).toBeTruthy(); + expect(style.family, `${id} has no generic fallback`).toMatch(generic); + // More than one option, or it is a single point of failure. + expect((style.family ?? "").split(",").length, `${id} has no fallback chain`).toBeGreaterThan( + 2, + ); + } + }); + + it("sets a title size on every preset, and keeps it legible", () => { + // The panel's own rows are 10px; a title at that size is not a title. The + // ceiling is what still fits the bracket without wrapping. + for (const [id, style] of Object.entries(FX_PRESET_STYLE)) { + const size = /text-\[(\d+)px\]/.exec(style.type); + expect(size, `${id} sets no title size`).toBeTruthy(); + const px = Number(size?.[1]); + expect(px, `${id} is too small to read as a title`).toBeGreaterThanOrEqual(12); + expect(px, `${id} is too large for the bracket`).toBeLessThanOrEqual(18); + } + }); + + it("keeps every colour vibrant, and light enough to read on the panel", () => { + // The rack sits on #0C0C0E. A title has to carry real colour to be worth + // having, and still clear contrast against near-black. + for (const [id, style] of Object.entries(FX_PRESET_STYLE)) { + const match = /hsl\(\s*(\d+),\s*(\d+)%,\s*(\d+)%\s*\)/.exec(style.color); + expect(match, `${id} is not a plain hsl() colour`).toBeTruthy(); + const saturation = Number(match?.[1 + 1]); + const lightness = Number(match?.[3]); + expect(saturation, `${id} is too washed out to read as a colour`).toBeGreaterThanOrEqual(60); + expect(lightness, `${id} is too dark against the panel`).toBeGreaterThanOrEqual(58); + expect(lightness, `${id} is so light the hue disappears`).toBeLessThanOrEqual(78); + } + }); + + it("keeps the title hues clear of the accent, which means something else", () => { + // The panel spends #3CE6AC (hue 160) on "automated" and "playing". A title + // sitting on that hue reads as a status the preset does not have. + for (const [id, style] of Object.entries(FX_PRESET_STYLE)) { + const hue = Number(/hsl\(\s*(\d+),/.exec(style.color)?.[1]); + const distance = Math.min(Math.abs(hue - 160), 360 - Math.abs(hue - 160)); + expect(distance, `${id} sits on the accent's hue`).toBeGreaterThan(20); + } + }); + + it("backs each preset with its own hue, dark enough to sit under the panel", () => { + // Derived from the title rather than picked, so a background cannot drift + // away from the title it belongs to. + const seen = new Set(); + for (const [id, style] of Object.entries(FX_PRESET_STYLE)) { + const bg = fxPresetBackground(id); + expect(bg, `${id} has no background`).toBeTruthy(); + const titleHue = /hsl\(\s*(\d+),/.exec(style.color)?.[1]; + expect(bg, `${id}'s background is a different hue from its title`).toContain( + `hsl(${titleHue},`, + ); + const lightness = Number(/,\s*(\d+)%\s*\)/.exec(bg ?? "")?.[1]); + expect(lightness, `${id}'s background would fight the controls on it`).toBeLessThanOrEqual( + 16, + ); + seen.add(bg ?? ""); + } + // Presets that share a title colour share a background — the repair family + // is deliberately uniform — but the character ones must not. + const character = HF_AUDIO_FX_PRESETS.filter((p) => p.family === "character"); + expect(new Set(character.map((p) => fxPresetBackground(p.id))).size).toBe(character.length); + }); + + it("has no background for a preset it does not know", () => { + expect(fxPresetBackground("not-a-preset")).toBeNull(); + }); + + it("falls back rather than failing for a preset it does not know", () => { + expect(fxPresetStyle("not-a-preset")).toBe(FX_PRESET_STYLE_DEFAULT); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFxPresetStyle.ts b/packages/studio/src/components/editor/propertyPanelFxPresetStyle.ts new file mode 100644 index 0000000000..65a46d0e4d --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFxPresetStyle.ts @@ -0,0 +1,207 @@ +/** + * A title treatment per preset — the label as a small piece of design rather + * than eighteen rows of the same condensed caps. + * + * The rack already letters by FAMILY (`propertyPanelFxFamily.ts`), which answers + * "what kind of thing is this". This answers a different question: a preset is a + * character, and the point of Telephone or Megaphone is that you know what it + * sounds like before you play it. Type can carry that — a bullhorn's name should + * look shouted, a tape's should look worn. + * + * Deliberately NOT a per-preset colour free-for-all. Each hue sits in the same + * narrow lightness band as the family tints so nothing reads as a status colour, + * and the panel already spends saturation on "automated" and "bypassed". + * + * A preset with no entry falls back to the neutral treatment, so the catalogue + * can grow without this file — an unstyled preset looks plain, not broken. + */ + +export interface FxPresetStyle { + /** Tailwind classes for the title: weight, case, tracking, size. */ + type: string; + /** The title's colour, and the bracket's left edge. */ + color: string; + /** + * A real font stack, when the character wants one. + * + * Tailwind ships three generic families, and a set of presets styled only + * with those ends up variations of the same two faces. These are system + * faces with a documented fallback chain: the studio has no webfont + * pipeline, and the Google Fonts cache under `~/.cache/hyperframes` belongs + * to the CLI's composition build — reaching into it from the panel would be + * inventing a second one. + * + * Every stack ends in a generic keyword, so a machine without the named face + * still lands somewhere deliberate. + */ + family?: string; +} + +/** Faces that ship with macOS and/or Windows, grouped by what they read as. */ +const FACE = { + /** Narrow industrial caps — signage, stencils, equipment panels. */ + condensed: '"Haettenschweiler", "Arial Narrow", Impact, sans-serif', + /** Geometric and mechanical; reads as a machine rather than a voice. */ + geometric: '"Futura", "Century Gothic", "Avenir Next", sans-serif', + /** Typewriter — struck, worn, slightly irregular. */ + typewriter: '"American Typewriter", "Courier New", Courier, monospace', + /** High-contrast editorial serif; broadcast and print authority. */ + editorial: '"Didot", "Bodoni 72", "Playfair Display", Georgia, serif', + /** Old-style serif with real warmth — books, not headlines. */ + bookish: '"Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif', + /** Display face with no restraint at all. Theatrical, and a bit absurd. */ + theatrical: '"Luminari", "Papyrus", "Comic Sans MS", fantasy', + /** Engraved caps — plaques, institutions, things bolted to walls. */ + engraved: '"Copperplate", "Optima", "Perpetua Titling MT", serif', + /** Terminal type: fixed, plain, no personality of its own. */ + terminal: '"SF Mono", Consolas, Menlo, ui-monospace, monospace', +} as const; + +/** Plain, and what any preset without its own entry gets. */ +export const FX_PRESET_STYLE_DEFAULT: FxPresetStyle = { + type: "text-[12px] uppercase tracking-wide", + color: "hsl(220, 24%, 72%)", + family: FACE.terminal, +}; + +export const FX_PRESET_STYLE: Record = { + // --- voice: reach for these to sound like yourself, only better ----------- + // Restrained rather than plain. These are corrective, and a costume would + // promise a character they deliberately do not add — but they still get a + // face, because "no styling at all" is what every other row in the panel has. + "voice-clean": { + type: "text-[13px] font-medium tracking-tight", + color: "hsl(202, 82%, 66%)", + family: FACE.geometric, + }, + "voice-broadcast": { + type: "text-[13px] font-bold uppercase tracking-[0.14em]", + color: "hsl(214, 84%, 70%)", + family: FACE.editorial, + }, + "voice-warm": { + type: "text-[14px] italic tracking-normal", + color: "hsl(32, 88%, 66%)", + family: FACE.bookish, + }, + + // --- repair: workshop labels. Fixed, plain, nothing decorative ------------ + "rumble-cut": { + type: "text-[12px] uppercase tracking-[0.18em]", + color: "hsl(196, 78%, 64%)", + family: FACE.terminal, + }, + "room-gate": { + type: "text-[12px] uppercase tracking-[0.18em]", + color: "hsl(196, 78%, 64%)", + family: FACE.terminal, + }, + "boom-tame": { + type: "text-[12px] uppercase tracking-[0.18em]", + color: "hsl(196, 78%, 64%)", + family: FACE.terminal, + }, + "harsh-tame": { + type: "text-[12px] uppercase tracking-[0.18em]", + color: "hsl(196, 78%, 64%)", + family: FACE.terminal, + }, + + // --- character: the costumes. This is where type does the work ------------ + // A phone's band is narrow; so is the tracking, on a face with no warmth. + telephone: { + type: "text-[13px] uppercase tracking-[0.3em]", + color: "hsl(186, 85%, 62%)", + family: FACE.terminal, + }, + // A dial face: high-contrast serif caps, spaced like printed frequencies. + "radio-am": { + type: "text-[14px] uppercase tracking-[0.24em]", + color: "hsl(42, 92%, 62%)", + family: FACE.editorial, + }, + // Shouted through a horn — the heaviest, narrowest thing available, leaning. + megaphone: { + type: "text-[17px] font-black italic uppercase tracking-tight", + color: "hsl(12, 90%, 64%)", + family: FACE.condensed, + }, + // Struck on a machine, played back years later. + "lofi-tape": { + type: "text-[13px] tracking-wide", + color: "hsl(28, 72%, 62%)", + family: FACE.typewriter, + }, + // Bolted to a wall in a station concourse. + "pa-system": { + type: "text-[13px] uppercase tracking-[0.26em]", + color: "hsl(222, 80%, 70%)", + family: FACE.engraved, + }, + // Small, squeezed through a grille, no room for anything but the letters. + intercom: { + type: "text-[12px] font-bold uppercase tracking-tighter", + color: "hsl(96, 68%, 60%)", + family: FACE.terminal, + }, + // The name is a joke and the type is in on it. + "doofus-worble": { + type: "text-[16px] tracking-[0.1em]", + color: "hsl(288, 85%, 72%)", + family: FACE.theatrical, + }, + + // --- space: rooms. Light and wide, because that is what space looks like --- + "room-tight": { + type: "text-[13px] uppercase tracking-[0.2em]", + color: "hsl(252, 74%, 72%)", + family: FACE.geometric, + }, + "room-natural": { + type: "text-[13px] uppercase tracking-[0.26em]", + color: "hsl(258, 78%, 72%)", + family: FACE.geometric, + }, + // The biggest room gets the widest setting — the word itself opens out. + hall: { + type: "text-[15px] uppercase tracking-[0.4em]", + color: "hsl(246, 84%, 74%)", + family: FACE.engraved, + }, + "slap-echo": { + type: "text-[13px] uppercase tracking-[0.28em]", + color: "hsl(274, 76%, 70%)", + family: FACE.geometric, + }, + "dub-throw": { + type: "text-[14px] italic tracking-[0.3em]", + color: "hsl(312, 78%, 70%)", + family: FACE.editorial, + }, +}; + +export function fxPresetStyle(presetId: string): FxPresetStyle { + return FX_PRESET_STYLE[presetId] ?? FX_PRESET_STYLE_DEFAULT; +} + +/** + * The wash behind a preset's bracket, derived from its title colour. + * + * Derived rather than picked: nineteen hand-chosen pairs is nineteen chances + * for one to clash with its own title, and a hue rotation cannot. Same hue, + * saturation pulled right down and lightness taken to near-black, so the panel + * reads as tinted rather than coloured — the rack sits on `#0C0C0E` and + * anything with real lightness here would fight every control on top of it. + * + * Returns a CSS colour, or null when the preset has no character of its own. + */ +export function fxPresetBackground(presetId: string): string | null { + const style = FX_PRESET_STYLE[presetId]; + if (!style) return null; + const hsl = /hsl\(\s*(\d+),\s*(\d+)%,\s*(\d+)%\s*\)/.exec(style.color); + if (!hsl) return null; + const hue = hsl[1]; + // 22% saturation at 11% lightness: present enough to tell two brackets apart + // at a glance, dark enough that white body text still clears WCAG AA on it. + return `hsl(${hue}, 22%, 11%)`; +} diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx index c012466cd8..281946a1bc 100644 --- a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx @@ -8,8 +8,11 @@ import { type HfAudioFxChain, } from "@hyperframes/core/audio-fx"; import { DEFAULT_CARVE } from "@hyperframes/core/audio-carve"; -import { EFFECT_COPY, PRESET_PROBLEM } from "@hyperframes/core/audio-fx-copy"; -import { getAudioFxPreset } from "@hyperframes/core/audio-fx-presets"; +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 { fxPresetStyle } from "./propertyPanelFxPresetStyle.js"; +import { applyAudioFxPreset, getAudioFxPreset } from "@hyperframes/core/audio-fx-presets"; /** * What a knob is CALLED in the panel, looked up rather than spelled out. @@ -85,6 +88,11 @@ function mount(overrides: Partial[0]> = {}) { automatedTargets={overrides.automatedTargets} onAutomateParam={overrides.onAutomateParam} onRemoveParamAutomation={overrides.onRemoveParamAutomation} + onRemoveNodeAutomation={overrides.onRemoveNodeAutomation} + onAutomatePreset={overrides.onAutomatePreset} + onRemovePresetAutomation={overrides.onRemovePresetAutomation} + onAuditionTransport={overrides.onAuditionTransport} + automatedPresets={overrides.automatedPresets} onLevel={overrides.onLevel} onRemoveLevel={overrides.onRemoveLevel} levelled={overrides.levelled} @@ -101,6 +109,17 @@ const click = (el: Element | null | undefined) => { (el as HTMLElement).click(); }); }; +/** + * Open a module's Details, where every control that is not the primary one now + * lives — a module opens on one knob and the rest is one click away. + */ +function openDetails(host: HTMLElement, index = 0): void { + const buttons = Array.from(host.querySelectorAll(".hf-fx-node-details")); + const button = buttons[index]; + if (!button) throw new Error("no Details disclosure to open"); + act(() => button.click()); +} + const byText = (host: HTMLElement, sel: string, text: string) => Array.from(host.querySelectorAll(sel)).find((e) => e.textContent?.trim() === text); @@ -150,19 +169,73 @@ describe("FxSection chain", () => { const items = Array.from(host.querySelectorAll(".hf-fx-add-item")).map((e) => e.textContent?.trim(), ); - expect(items).toHaveLength(HF_AUDIO_FX.length); - for (const def of HF_AUDIO_FX) expect(items).toContain(def.label); + // Every effect is reachable, but not every one under its own name: the jobs + // stand in for the effect they are made of, because picking `peaking` is + // picking a machine and leaving the real decision for afterwards. + const standIns = HF_AUDIO_FX.filter((d) => HF_AUDIO_FX_JOB_TYPES.has(d.id)); + expect(items).toHaveLength(HF_AUDIO_FX.length - standIns.length + HF_AUDIO_FX_JOBS.length); + for (const def of HF_AUDIO_FX) { + if (HF_AUDIO_FX_JOB_TYPES.has(def.id)) { + // Not offered as itself, and it must not be — two doors to the same + // effect, one of them the incoherent one, is worse than either alone. + expect(items).not.toContain(EFFECT_COPY[def.id]?.title); + continue; + } + // By the name the RACK will use, not the registry's — picking "High-pass" + // and getting a module called "Remove Rumble" is the inconsistency this + // whole layer exists to remove. + expect(items).toContain(EFFECT_COPY[def.id]?.title); + } + for (const job of HF_AUDIO_FX_JOBS) expect(items).toContain(job.label); + }); + + it("adds a job as an ordinary effect, already named and already aimed", () => { + // The range IS the module: one knob is honest here because the decision the + // knob depends on has already been made. + const { host, onChainChange } = mount({ chain: { version: 1, nodes: [] } }); + click(host.querySelector(".hf-fx-add")); + click(byText(host, ".hf-fx-add-item", "Reduce Mud")); + + const next = onChainChange.mock.calls[0]?.[0] as HfAudioFxChain; + expect(next.nodes).toHaveLength(1); + expect(next.nodes[0]?.type).toBe("peaking"); + expect(next.nodes[0]?.label).toBe("Reduce Mud"); + expect(next.nodes[0]?.params?.frequency).toBe(250); + }); + + it("shows two jobs over the same effect as the different things they are", () => { + // The whole point. Read down the rack, "Shape One Range" twice was two rows + // an author could not tell apart — and one of them was cutting while the + // other was boosting. + const { host } = mount({ + chain: { + version: 1, + nodes: [ + { type: "peaking", label: "Reduce Mud", params: { frequency: 250, gain: -3, q: 1.2 } }, + { type: "peaking", label: "Add Clarity", params: { frequency: 3000, gain: 2.5, q: 1 } }, + ], + } as unknown as HfAudioFxChain, + }); + const names = Array.from(host.querySelectorAll(".hf-fx-node-name")).map((e) => + e.textContent?.trim(), + ); + expect(names).toContain("Reduce Mud"); + expect(names).toContain("Add Clarity"); + expect(names).not.toContain(EFFECT_COPY.peaking?.title); }); it("adds an effect seeded with its declared defaults", () => { + // An effect with no derived knob arrives exactly as the registry declares + // it. The five that DO have one are seeded on their curve instead — see + // "adds a profiled effect on its curve" below. const { host, onChainChange } = mount(); click(host.querySelector(".hf-fx-add")); - click(byText(host, ".hf-fx-add-item", "Compressor")); + click(byText(host, ".hf-fx-add-item", EFFECT_COPY.delay?.title ?? "")); expect(onChainChange).toHaveBeenCalledTimes(1); const next = onChainChange.mock.calls[0]![0] as HfAudioFxChain; expect(next.nodes).toHaveLength(1); - expect(next.nodes[0]!.type).toBe("compressor"); - expect(next.nodes[0]!.params).toEqual(defaultAudioFxParams("compressor")); + expect(next.nodes[0]!.type).toBe("delay"); + expect(next.nodes[0]!.params).toEqual(defaultAudioFxParams("delay")); }); it("renders a control for every parameter the effect declares", () => { @@ -171,11 +244,21 @@ describe("FxSection chain", () => { const labels = Array.from(host.querySelectorAll(".hf-fx-label")).map((e) => e.textContent?.trim(), ); - for (const p of def.params) expect(labels).toContain(plainLabel("compressor", p.key)); + // Under Details: a compressor's face is one derived knob, and its seven real + // controls are one click in. + openDetails(host); + const opened = Array.from(host.querySelectorAll(".hf-fx-label")).map((e) => + e.textContent?.trim(), + ); + for (const p of def.params) expect(opened).toContain(plainLabel("compressor", p.key)); + void labels; }); it("uses a select for an enum parameter and a slider for a number", () => { const { host } = mount({ chain: chainOf("saturate") }); + // Its curve type is an enum and lives under Details, since saturation's one + // knob is derived rather than being any single parameter. + openDetails(host); expect(host.querySelector(".hf-fx-select")).toBeTruthy(); expect(host.querySelector(".hf-fx-slider")).toBeTruthy(); }); @@ -232,6 +315,9 @@ describe("FxSection chain", () => { }); render(chainOfNodes(a, b)); + // Frequency is behind Details for a peaking node — the module opens on how + // much, now that picking the module is what picks the range. + openDetails(host); // Only the first card is open, which is the one being edited. const openFrequency = (): HTMLInputElement => host.querySelector(".hf-fx-node .hf-fx-number")!; @@ -243,6 +329,9 @@ describe("FxSection chain", () => { // The author moves that effect down; the other one takes the open slot. render(chainOfNodes(b, a)); + // Its own Details, not the one that was open: the disclosure is per module, + // so the effect arriving in the slot arrives closed like any other. + openDetails(host); expect(openFrequency().value).toBe("1600"); }); @@ -279,10 +368,323 @@ describe("FxSection chain", () => { expect(name).not.toBe(getAudioFxDef("highpass")?.label); // And a sentence under it, so the rack reads top to bottom. expect(node.querySelector(".hf-fx-node-summary")?.textContent).toContain("Cutting everything"); - // The first node is open by default, which is where the DSP name lives. - expect(node.querySelector(".hf-fx-node-mechanism")?.textContent).toContain( - getAudioFxDef("highpass")?.label, + // Open, it says what it is for and offers ONE knob — the rest is behind a + // disclosure, which is also the only place the DSP name appears. + expect(node.querySelector(".hf-fx-node-does")?.textContent).toBe(EFFECT_COPY.highpass?.does); + expect(node.querySelectorAll(".hf-fx-row")).toHaveLength(1); + const details = node.querySelector(".hf-fx-node-details"); + expect(details?.textContent).toContain(getAudioFxDef("highpass")?.label); + expect(details?.getAttribute("aria-expanded")).toBe("false"); + + openDetails(host); + expect(node.querySelectorAll(".hf-fx-row").length).toBe( + getAudioFxDef("highpass")?.params.length, + ); + }); + + it("draws the rack as a signal path, with both ends named", () => { + // Order is audible here, and a list does not look ordered. Numbering the + // steps and naming the two ends is what makes "move up" read as the most + // consequential control in the panel rather than a cosmetic one. + const { host } = mount({ chain: chainOf("highpass", "limiter") }); + const terms = Array.from(host.querySelectorAll(".hf-fx-term")).map((e) => e.textContent); + expect(terms).toHaveLength(2); + expect(terms[0]).toContain("In"); + expect(terms[1]).toContain("Out"); + // Counted over what the rack SHOWS: the carve module leads it, so the first + // hand-built effect is 02. + const numbers = Array.from(host.querySelectorAll(".hf-fx-node-index")).map((e) => + e.textContent?.trim(), ); + expect(numbers).toEqual(["02", "03"]); + }); + + it("draws a preset's nodes as the one thing that was added", () => { + // Applying a preset drops five rows into the rack with nothing saying they + // arrived together — the same failure the carve module exists to fix, one + // level down. + const { host, onChainChange } = mount({ chain: { version: 1, nodes: [] } }); + click(byText(host, "button", "Presets")); + click(presetButton(host, "telephone")); + // Applying does not re-render this mount — the chain comes back as a prop — + // so the rack is read from what was written. + const applied = onChainChange.mock.calls[0]?.[0] as HfAudioFxChain | undefined; + const written = applied?.nodes ?? []; + const { host: after } = mount({ chain: { version: 1, nodes: written } }); + + const run = after.querySelector("[data-fx-preset='telephone']"); + expect(run).toBeTruthy(); + // The label carries a disclosure caret, so match the name inside it. + expect(run?.querySelector(".hf-fx-preset-run-label")?.textContent).toContain("Telephone"); + 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. + const { host } = mount({ + chain: { + version: 1, + nodes: [ + { type: "highpass", fromPreset: "telephone", params: defaultAudioFxParams("highpass") }, + { type: "reverb", params: defaultAudioFxParams("reverb") }, + { type: "lowpass", fromPreset: "telephone", params: defaultAudioFxParams("lowpass") }, + ], + } as unknown as HfAudioFxChain, + }); + const runs = Array.from(host.querySelectorAll("[data-fx-preset='telephone']")); + expect(runs).toHaveLength(2); + for (const run of runs) expect(run.querySelectorAll(".hf-fx-node")).toHaveLength(1); + }); + + it("letters each family differently, so the kind reads before the word does", () => { + // A rack of eight modules is eight lines of text. Reading it should not mean + // reading eight names — the shape of the line carries what KIND of module + // this is, and the word only confirms it. + const { host } = mount({ chain: chainOf("lowpass", "compressor", "saturate", "delay") }); + const families = Array.from(host.querySelectorAll("[data-fx-family]")).map((e) => + e.getAttribute("data-fx-family"), + ); + // The carve module leads the rack and is smart; then the four registry ones. + expect(families).toEqual(["smart", "filter", "dynamics", "nonlinear", "time"]); + + const names = Array.from(host.querySelectorAll(".hf-fx-node-name")).map((e) => e.className); + // Four families told apart by the sans, and the serif spent on the one that + // generates signal rather than measuring or shaping what is there. + expect(names.filter((c) => c.includes("font-serif"))).toHaveLength(1); + expect(names[3]).toContain("font-serif"); + expect(new Set(names.map((c) => c.replace(/^.*?(?=font-)/, "")))).toHaveProperty("size", 5); + }); + + it("tints two modules of the same family apart without changing family", () => { + const { host } = mount({ chain: chainOf("lowpass", "highpass") }); + const cards = Array.from(host.querySelectorAll("[data-fx-family='filter']")); + expect(cards).toHaveLength(2); + // Same hue, different step: two filters, visibly two modules. + const tints = cards.map((c) => c.style.borderLeftColor); + expect(tints[0]).not.toBe(tints[1]); + for (const tint of tints) expect(tint).toContain("205"); + }); + + it("says where a filter is working, in the words the rack shares", () => { + // Frequencies mean nothing to somebody who has not been taught them, and the + // rack speaks entirely in them. The ruler is where they get taught. + const { host } = mount({ + chain: { + version: 1, + nodes: [{ type: "highpass", params: { frequency: 250, q: 0.707, poles: "2" } }], + } as unknown as HfAudioFxChain, + }); + const ruler = fxCard(host).querySelector(".hf-fx-ruler"); + expect(ruler?.getAttribute("data-band")).toBe("Mud"); + expect(ruler?.querySelector(".hf-fx-ruler-name")?.textContent).toBe("Mud"); + // Every named range is on the bar, or it is not a shared ruler. + const segments = Array.from(ruler?.querySelectorAll(".hf-fx-ruler-band") ?? []); + expect(segments).toHaveLength(BANDS.length); + // Log-spaced, because hearing is. Rumble is 20-80 Hz — three tenths of one + // percent of the range linearly, and a fifth of it by ear. Laid out linearly + // the bottom six bands collapse into a sliver and the ruler teaches nothing. + const rumble = Number.parseFloat(segments[0]?.style.width ?? "0"); + expect(rumble).toBeGreaterThan(10); + }); + + it("puts no ruler under an effect that does not act on a range", () => { + // A limiter has no frequency to place, and a bar under one would be a + // decoration claiming to be information. + const { host } = mount({ chain: chainOf("limiter") }); + expect(fxCard(host).querySelector(".hf-fx-ruler")).toBeNull(); + }); + + it("adds a profiled effect on its curve, not at registry defaults", () => { + // The registry's defaults are not a point on the profile's curve, so an + // effect seeded with them opened reading a strength it was not set to: a + // compressor arrived showing Evenness 0.67 with its make-up gain at 0 dB — + // the "quieter as you turn it up" bug the profiles exist to prevent, on the + // very first frame. Caught in a running studio, not by these tests. + const { host, onChainChange } = mount({ chain: { version: 1, nodes: [] } }); + click(host.querySelector(".hf-fx-add")); + click(byText(host, ".hf-fx-add-item", EFFECT_COPY.compressor?.title ?? "")); + + const written = onChainChange.mock.calls[0]?.[0] as HfAudioFxChain | undefined; + const added = written?.nodes[0]?.params ?? {}; + expect(audioFxProfileStrength("compressor", added)).toBeCloseTo(0.5, 2); + // And the mechanism agrees with the knob rather than sitting at its default. + expect(added.makeup).not.toBe(defaultAudioFxParams("compressor").makeup); + }); + + it("gives a module with no single real control a derived one", () => { + // A compressor has seven controls and an author wants one, but no single one + // of them can be its face: threshold means nothing without ratio. So the + // knob is derived, and it sets all five at once. + const { host, onChainChange } = mount({ chain: chainOf("compressor") }); + const node = fxCard(host); + expect(EFFECT_COPY.compressor?.primary).toBe("strength"); + // One control on the open face, named for the outcome. + const rows = Array.from(node.querySelectorAll(".hf-fx-row")); + expect(rows).toHaveLength(1); + expect(rows[0]?.querySelector(".hf-fx-label")?.textContent).toBe("Evenness"); + expect(node.querySelector(".hf-fx-node-details")).toBeTruthy(); + + // Moving it moves the mechanism underneath. + const before = defaultAudioFxParams("compressor"); + typeInto(node.querySelector(".hf-fx-number")!, "0.9"); + act(() => + node + .querySelector(".hf-fx-number") + ?.dispatchEvent(new FocusEvent("focusout", { bubbles: true })), + ); + const next = onChainChange.mock.calls.at(-1)?.[0] as HfAudioFxChain; + const params = next.nodes[0]?.params ?? {}; + expect(params.ratio).not.toBe(before.ratio); + expect(params.threshold).not.toBe(before.threshold); + // And leaves alone what the author set under Details. + expect(params.knee).toBe(before.knee); }); it("offers presets as the complaint they answer", () => { @@ -296,6 +698,213 @@ describe("FxSection chain", () => { expect(item?.querySelector(".hf-fx-preset-name")?.textContent).toBe("Telephone"); }); + describe("folding a preset shut", () => { + const applied = (): HfAudioFxChain => { + const preset = getAudioFxPreset("telephone"); + if (!preset) throw new Error("no telephone preset"); + return applyAudioFxPreset({ version: 1, nodes: [] }, preset); + }; + const bracket = (host: HTMLElement) => host.querySelector("[data-fx-preset='telephone']"); + + it("hides what it contains, and says how much is in there", () => { + // A preset is one thing the author added; once it is set, the seven + // modules inside are detail. Two presets in a rack was thirteen cards + // deep before anything hand-built appeared. + const { host } = mount({ chain: applied() }); + const nodes = bracket(host)?.querySelectorAll(".hf-fx-node").length ?? 0; + expect(nodes).toBeGreaterThan(1); + + click(bracket(host)?.querySelector(".hf-fx-preset-run-label")); + expect(bracket(host)?.querySelectorAll(".hf-fx-node")).toHaveLength(0); + // The count is what says it is still a chain rather than one opaque effect. + expect(bracket(host)?.querySelector(".hf-fx-preset-run-count")?.textContent).toBe( + String(nodes), + ); + }); + + it("arrives open, so nobody has to discover it is a chain", () => { + const { host } = mount({ chain: applied() }); + expect(bracket(host)?.hasAttribute("data-collapsed")).toBe(false); + expect( + bracket(host)?.querySelector(".hf-fx-preset-run-label")?.getAttribute("aria-expanded"), + ).toBe("true"); + }); + + it("keeps the whole-preset controls reachable while folded", () => { + // Collapsing hides the detail, not the preset — switching it off or + // taking it out has to stay possible without unfolding first. + const { host } = mount({ chain: applied() }); + click(bracket(host)?.querySelector(".hf-fx-preset-run-label")); + expect(bracket(host)?.querySelector(".hf-fx-preset-run-toggle")).toBeTruthy(); + expect(bracket(host)?.querySelector(".hf-fx-preset-run-remove")).toBeTruthy(); + }); + + it("gives each preset its own title treatment", () => { + // A preset is a character, and the point of Telephone or Megaphone is + // that you know what it sounds like before you play it. Type carries that. + const { host } = mount({ chain: applied() }); + const label = bracket(host)?.querySelector(".hf-fx-preset-run-label"); + const styled = fxPresetStyle("telephone"); + expect(label?.className).toContain("tracking-[0.3em]"); + expect(label?.style.color).toBeTruthy(); + // And it differs from another preset's, or it is not a treatment. + expect(styled.type).not.toBe(fxPresetStyle("megaphone").type); + expect(styled.color).not.toBe(fxPresetStyle("megaphone").color); + }); + }); + + describe("auditioning while the transport is paused", () => { + it("starts playback so a paused author can hear the preset at all", () => { + // The audition is written to the running graph, which is silent while the + // transport is paused — so without this, hovering a preset did nothing + // whatsoever unless the author happened to be mid-playback. + const onAuditionTransport = vi.fn(); + const { host } = mount({ chain: chainOf("peaking"), onAuditionTransport }); + click(byText(host, "button", "Presets")); + act(() => (presetButton(host, "telephone") as HTMLElement | null)?.focus()); + expect(onAuditionTransport).toHaveBeenLastCalledWith(true); + }); + + it("stops it again on the way out", () => { + const onAuditionTransport = vi.fn(); + const { host } = mount({ chain: chainOf("peaking"), onAuditionTransport }); + click(byText(host, "button", "Presets")); + act(() => (presetButton(host, "telephone") as HTMLElement | null)?.focus()); + act(() => { + host + .querySelector(".hf-fx-preset-menu") + ?.dispatchEvent(new FocusEvent("focusout", { bubbles: true })); + }); + expect(onAuditionTransport).toHaveBeenLastCalledWith(false); + }); + + it("stops it when the preset is applied, rather than playing on", () => { + // The click means "keep this", not "and carry on playing from wherever + // the audition reached". + const onAuditionTransport = vi.fn(); + const { host } = mount({ chain: { version: 1, nodes: [] }, onAuditionTransport }); + click(byText(host, "button", "Presets")); + act(() => (presetButton(host, "telephone") as HTMLElement | null)?.focus()); + click(presetButton(host, "telephone")); + expect(onAuditionTransport).toHaveBeenLastCalledWith(false); + }); + + it("stops it if the panel goes away mid-audition", () => { + const onAuditionTransport = vi.fn(); + const { host, root } = mount({ chain: chainOf("peaking"), onAuditionTransport }); + click(byText(host, "button", "Presets")); + act(() => (presetButton(host, "telephone") as HTMLElement | null)?.focus()); + act(() => root.unmount()); + expect(onAuditionTransport).toHaveBeenLastCalledWith(false); + }); + }); + + describe("getting back out of a menu", () => { + /** Escape, from inside the section, the way a keystroke really arrives. */ + const escape = (host: HTMLElement) => + act(() => { + host + .querySelector(".hf-fx-section") + ?.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + }); + + it("closes the preset shelf with the button that opened it", () => { + // Opening a menu used to hide both buttons, and picking something was the + // only thing that set them back — so an author who changed their mind had + // to add an effect they did not want, or deselect the clip. + const { host } = mount(); + click(byText(host, "button", "Presets")); + expect(host.querySelector(".hf-fx-preset-menu")).toBeTruthy(); + + click(byText(host, "button", "Close")); + expect(host.querySelector(".hf-fx-preset-menu")).toBeNull(); + expect(byText(host, "button", "Presets")).toBeTruthy(); + }); + + it("closes the add menu the same way", () => { + const { host } = mount(); + click(host.querySelector(".hf-fx-add")); + expect(host.querySelector(".hf-fx-add-menu")).toBeTruthy(); + + click(host.querySelector(".hf-fx-add")); + expect(host.querySelector(".hf-fx-add-menu")).toBeNull(); + expect(byText(host, "button", "Add effect")).toBeTruthy(); + }); + + it("opens one menu in place of the other", () => { + // Two open at once is two surfaces covering the rack, and neither says + // which one the next click belongs to. + const { host } = mount(); + click(byText(host, "button", "Presets")); + click(host.querySelector(".hf-fx-add")); + expect(host.querySelector(".hf-fx-add-menu")).toBeTruthy(); + expect(host.querySelector(".hf-fx-preset-menu")).toBeNull(); + + // And back the other way, which is a separate handler. + click(byText(host, "button", "Presets")); + expect(host.querySelector(".hf-fx-preset-menu")).toBeTruthy(); + expect(host.querySelector(".hf-fx-add-menu")).toBeNull(); + }); + + it("closes on Escape, which is what anyone reaches for first", () => { + const { host } = mount(); + click(byText(host, "button", "Presets")); + escape(host); + expect(host.querySelector(".hf-fx-preset-menu")).toBeNull(); + + click(host.querySelector(".hf-fx-add")); + escape(host); + expect(host.querySelector(".hf-fx-add-menu")).toBeNull(); + }); + + it("puts the chain back when a closing menu was auditioning", () => { + // Leaving the shelf by closing it is still leaving it, and an audition + // left playing is audible over a chain the document does not have. + const { host, onChainPreview } = mount({ chain: chainOf("peaking") }); + click(byText(host, "button", "Presets")); + act(() => (presetButton(host, "telephone") as HTMLElement | null)?.focus()); + click(byText(host, "button", "Close")); + + const back = onChainPreview.mock.calls.at(-1)?.[0] as HfAudioFxChain; + expect(back.nodes.map((n) => n.type)).toEqual(["peaking"]); + }); + + it("leaves Escape alone when no menu is open", () => { + // The panel has its own Escape handling; swallowing the key when this has + // nothing to close would break it. + const { host } = mount(); + let reached = false; + host.addEventListener("keydown", () => { + reached = true; + }); + escape(host); + expect(reached).toBe(true); + }); + }); + + it("shows a wave on a preset only when hovering it can be heard", () => { + // Hovering plays the preset, and playing is otherwise invisible — the panel + // looks identical whether the audition is sounding or the pointer is just + // resting there. Without an audition channel there is no audio to claim. + const { host } = mount({ chain: { version: 1, nodes: [] } }); + click(byText(host, "button", "Presets")); + expect(presetButton(host, "telephone")?.querySelector(".hf-fx-preset-wave")).toBeTruthy(); + + // Rendered directly rather than through `mount`, which supplies a preview + // handler by default — the case being covered is a section that has none. + const { host: dry } = renderInto( + , + ); + click(byText(dry, "button", "Presets")); + expect(presetButton(dry, "telephone")?.querySelector(".hf-fx-preset-wave")).toBeNull(); + }); + describe("hover-audition", () => { /** Focus is the keyboard's hover, and both go through the same handler. */ const enter = (el: Element | null | undefined) => { @@ -391,7 +1000,7 @@ describe("FxSection chain", () => { it("auditions an effect the add menu is offering", () => { const { host, onChainPreview, onChainChange } = mount({ chain: chainOf("peaking") }); click(byText(host, "button", "Add effect")); - enter(byText(host, "button", "Reverb")); + enter(byText(host, "button", EFFECT_COPY.reverb?.title ?? "")); const heard = onChainPreview.mock.calls.at(-1)?.[0] as HfAudioFxChain; expect(heard.nodes.map((n) => n.type)).toEqual(["peaking", "reverb"]); @@ -602,6 +1211,9 @@ describe("FxSection chain", () => { // Persisting on every input event refreshes the preview, which reloads the // composition and restarts audio — that is what made playback stutter. const { host, onChainChange, onChainPreview } = mount({ chain: chainOf("peaking") }); + // Details, because this is about the drag mechanics on a real control and + // the frequency it asserts on is not the one knob the module opens with. + openDetails(host); const slider = fxCard(host).querySelector(".hf-fx-slider")!; act(() => slider.dispatchEvent(new Event("pointerdown", { bubbles: true }))); for (const v of ["5000", "10000", "15000"]) { @@ -653,6 +1265,7 @@ describe("FxSection chain", () => { sourceOptions: [{ id: "vo", label: "Voiceover" }], }; const { host, root } = renderInto(); + openDetails(host); const slider = fxCard(host).querySelector(".hf-fx-slider")!; const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; act(() => slider.dispatchEvent(new Event("pointerdown", { bubbles: true }))); @@ -684,6 +1297,7 @@ describe("FxSection chain", () => { it("commits an enum immediately, since a select has no drag", () => { const { host, onChainChange } = mount({ chain: chainOf("saturate") }); + openDetails(host); const select = fxCard(host).querySelector(".hf-fx-select")!; const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set; act(() => { @@ -695,6 +1309,7 @@ describe("FxSection chain", () => { it("clamps a typed value into the renderable range", () => { const { host, onChainChange } = mount({ chain: chainOf("peaking") }); + openDetails(host); const input = fxCard(host).querySelector(".hf-fx-number")!; typeInto(input, "999999"); // React delegates onBlur through focusout, which is the event that bubbles. @@ -940,6 +1555,9 @@ describe("automation in the panel", () => { // Saturate: `output` is a make-up gain, but the curve's type and threshold // are rebuilt wholesale and cannot be scheduled. const { host } = automatable(idChain("saturate")); + // Under Details, where the real parameters are: the derived knob on the open + // face has no AudioParam behind it and nothing to automate. + openDetails(host); expect( rowFor(host, plainLabel("saturate", "output"))?.querySelector(".hf-fx-automate"), ).toBeTruthy(); @@ -972,7 +1590,9 @@ describe("automation in the panel", () => { expect(row.querySelector('input[type="range"]')?.disabled).toBe(true); expect(row.querySelector('input[type="number"]')?.disabled).toBe(true); expect(row.hasAttribute("data-automated")).toBe(true); - // A sibling parameter on the same effect stays editable. + // A sibling parameter on the same effect stays editable — one click in, + // which is where every control that is not the primary one lives. + openDetails(host); const q = rowFor(host, plainLabel("lowpass", "q"))!; expect(q.querySelector('input[type="range"]')?.disabled).toBe(false); }); @@ -1046,7 +1666,7 @@ describe("automation in the panel", () => { const add = host.querySelector(".hf-fx-add") as HTMLButtonElement; act(() => add.click()); const item = Array.from(host.querySelectorAll(".hf-fx-add-item")).find( - (b) => b.textContent === "Low-pass", + (b) => b.textContent === EFFECT_COPY.lowpass?.title, )!; act(() => item.click()); expect(onChainChange.mock.calls[0][0].nodes[0].id).toBe("n1"); diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.tsx index d632847618..23453a68bb 100644 --- a/packages/studio/src/components/editor/propertyPanelFxSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFxSection.tsx @@ -5,14 +5,16 @@ * is not an entry in the chain. */ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; import { defaultAudioFxParams, + getAudioFxDef, HF_AUDIO_FX, mintAudioFxNodeId, 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"; @@ -24,6 +26,16 @@ import { removeAudioEq, setAudioEqBandGain, } from "@hyperframes/core/audio-fx-eq"; +import { EFFECT_COPY } from "@hyperframes/core/audio-fx-copy"; +import { applyAudioFxProfile, getAudioFxProfile } from "@hyperframes/core/audio-fx-profiles"; +import { + audioFxJobNode, + HF_AUDIO_FX_JOBS, + HF_AUDIO_FX_JOB_TYPES, + type HfAudioFxJob, +} from "@hyperframes/core/audio-fx-jobs"; +import { FxParamRow } from "./propertyPanelFxControls.js"; +import { fxPresetBackground, fxPresetStyle } from "./propertyPanelFxPresetStyle.js"; import { FxPresetMenu } from "./propertyPanelFxPresetMenu.js"; import { FxEqModule } from "./propertyPanelFxEqModule.js"; import { FxCarveModule, type AudioTrackOption } from "./propertyPanelFxCarveModule.js"; @@ -39,6 +51,25 @@ 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. */ @@ -58,6 +89,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. */ @@ -75,6 +112,14 @@ export interface FxSectionProps { onAuditionLevel?(on: boolean): void; /** Whether that measurement is running, so the button can say so. */ auditioningLevel?: boolean; + /** + * Start the transport for an audition, and stop it on the way out. + * + * An audition is written to the running graph, which is silent while the + * transport is paused — so without this, hovering a preset does nothing at all + * for a paused author. + */ + onAuditionTransport?(on: boolean): void; /** Structural edits and gesture-end writes; this is the one that persists. */ onChainChange(chain: HfAudioFxChain): void; /** Continuous updates while a control is being dragged. */ @@ -117,7 +162,12 @@ export function FxSection({ levelled, onAuditionLevel, auditioningLevel, + onAutomatePreset, + onRemovePresetAutomation, + automatedPresets, + onAuditionTransport, }: 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; @@ -132,8 +182,20 @@ export function FxSection({ const [picking, setPicking] = useState(false); const [openNode, setOpenNode] = useState(0); + /** + * The add menu, with the jobs standing in for the effect they are made of. + * + * `peaking` is not offered as itself: picking it is picking a machine and + * leaving the real decision — which range — for afterwards. The jobs are that + * decision, already made. See `audioFxJobs.ts`. + */ const grouped = useMemo( - () => GROUP_ORDER.map((g) => ({ group: g, defs: HF_AUDIO_FX.filter((d) => d.group === g) })), + () => + GROUP_ORDER.map((g) => ({ + group: g, + defs: HF_AUDIO_FX.filter((d) => d.group === g && !HF_AUDIO_FX_JOB_TYPES.has(d.id)), + jobs: HF_AUDIO_FX_JOBS.filter((job) => getAudioFxDef(job.type)?.group === g), + })), [], ); @@ -176,12 +238,18 @@ export function FxSection({ if (make) { auditionBase.current ??= chain; onChainPreview(make(auditionBase.current)); + // After the chain is in the graph, not before: starting the transport + // first plays a moment of the un-auditioned mix. + onAuditionTransport?.(true); } else if (auditionBase.current) { + // Stop before reverting, for the mirror of that reason — the last thing + // heard should be the preset, not a frame of the chain coming back. + onAuditionTransport?.(false); onChainPreview(auditionBase.current); auditionBase.current = null; } }, - [chain, onChainPreview], + [chain, onChainPreview, onAuditionTransport], ); /** @@ -201,9 +269,14 @@ export function FxSection({ // Leaving by any route other than the pointer — the element deselected, the // panel closed — would otherwise leave the audition playing over a chain the // document does not have. + const transportRef = useRef(onAuditionTransport); + transportRef.current = onAuditionTransport; useEffect( () => () => { - if (auditionBase.current) previewRef.current?.(auditionBase.current); + if (auditionBase.current) { + transportRef.current?.(false); + previewRef.current?.(auditionBase.current); + } }, [], ); @@ -220,35 +293,74 @@ export function FxSection({ // old chain back over the write that just landed is a race the author // hears as the preset arriving and then leaving again. auditionBase.current = null; + onAuditionTransport?.(false); mutate(next.nodes); // Land on the first node the preset wrote, so the author can hear what // arrived and immediately see what it is made of. setOpenNode(next.nodes.findIndex((n) => n.fromPreset === preset.id)); setPicking(false); }, - [chain, mutate], + [chain, mutate, onAuditionTransport], ); - /** One effect at its defaults, appended — what both adding and auditioning do. */ + /** + * One effect appended, at the values its module opens on. + * + * For most effects that is the registry's defaults. For the five with a + * derived knob it is NOT: the registry defaults are not a point on the + * profile's curve, so the module opened reading a strength it was not set to — + * a compressor arrived showing Evenness 0.67 with its make-up gain at 0 dB, + * which is the "quieter as you turn it up" bug the profiles exist to prevent, + * on the very first frame. Seeding through the profile puts the knob and the + * mechanism in agreement from the start. + */ const withEffect = useCallback( (base: HfAudioFxChain, type: string): HfAudioFxChain => ({ ...base, nodes: [ ...base.nodes, - { type, id: mintAudioFxNodeId(base), enabled: true, params: defaultAudioFxParams(type) }, + { + type, + id: mintAudioFxNodeId(base), + enabled: true, + params: getAudioFxProfile(type) + ? applyAudioFxProfile(type, 0.5, defaultAudioFxParams(type)) + : defaultAudioFxParams(type), + }, ], }), [], ); + /** The same, for a job — an ordinary node that arrives already named and aimed. */ + const withJob = useCallback( + (base: HfAudioFxChain, job: HfAudioFxJob): HfAudioFxChain => ({ + ...base, + nodes: [...base.nodes, audioFxJobNode(job, base)], + }), + [], + ); + + const addJob = useCallback( + (job: HfAudioFxJob) => { + auditionBase.current = null; + onAuditionTransport?.(false); + mutate(withJob(chain, job).nodes); + setOpenNode(chain.nodes.length); + setAdding(false); + }, + [chain, mutate, withJob, onAuditionTransport], + ); + const addEffect = useCallback( (type: string) => { auditionBase.current = null; + onAuditionTransport?.(false); mutate(withEffect(chain, type).nodes); setOpenNode(chain.nodes.length); setAdding(false); }, - [chain, mutate, withEffect], + [chain, mutate, withEffect, onAuditionTransport], ); const updateNode = useCallback( @@ -257,6 +369,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 @@ -287,16 +443,62 @@ export function FxSection({ [chain.nodes], ); + /** + * The hand-built list cut into runs, so a preset reads as one thing. + * + * Applying a preset drops five rows into the rack with nothing saying they + * arrived together — which is the same failure the carve module was built to + * fix, one level down. Consecutive only: a preset whose nodes have been pulled + * apart by a reorder is no longer a unit, and drawing a bracket around the gap + * would claim an adjacency the signal path does not have. + */ + const runs = useMemo(() => { + const out: { preset?: string; items: { node: HfAudioFxNode; i: number }[] }[] = []; + for (const item of handBuilt) { + const preset = item.node.fromPreset; + const last = out.at(-1); + if (last && last.preset === preset) last.items.push(item); + else out.push({ ...(preset ? { preset } : {}), items: [item] }); + } + return out; + }, [handBuilt]); + + /** + * Preset runs the author has folded shut. + * + * A preset is one thing they added, and once it is set the seven modules + * inside are detail — a rack with two presets in it was thirteen cards deep + * before anything hand-built appeared. Collapsed by id rather than by index so + * it survives a reorder, and open by default: a preset that arrives already + * hidden is one nobody learns is a chain they can edit. + */ + const [collapsedRuns, setCollapsedRuns] = useState>(new Set()); + const eqIds = useMemo(() => audioEqIds(chain), [chain]); + + /** + * The number each row wears, counted over what the rack actually shows. + * + * Not the chain index: the carve's filters and an EQ's bands are inside their + * own modules, so counting raw nodes would leave the visible rack jumping from + * 02 to 07 and the numbers would look like a bug rather than a position. + */ + const positions = useMemo(() => { + const map = new Map(); + let at = (showCarve ? 1 : 0) + eqIds.length; + for (const { i } of handBuilt) map.set(i, ++at); + return map; + }, [handBuilt, eqIds.length, showCarve]); const [openEq, setOpenEq] = useState(null); const addEq = useCallback(() => { auditionBase.current = null; + onAuditionTransport?.(false); const { chain: next, eqId } = addAudioEq(chain); mutate(next.nodes); setOpenEq(eqId); setAdding(false); - }, [chain, mutate]); + }, [chain, mutate, onAuditionTransport]); // Dragging a fader is heard immediately and written once on release, the same // split every other control in the rack uses. @@ -333,9 +535,42 @@ export function FxSection({ [chain.nodes, mutate], ); + /** + * Escape closes whichever menu is open. + * + * The first thing anyone reaches for, and on a surface that covers the rack it + * is the one that needs no discovering. Bound on the section rather than the + * window: a keystroke aimed at the timeline is not aimed at this. + */ + const closeMenus = useCallback( + (event: KeyboardEvent) => { + if (event.key !== "Escape" || (!adding && !picking)) return; + // Stops the panel's own Escape handling from also firing — closing a menu + // and deselecting the clip on one keystroke loses the author their place. + event.stopPropagation(); + audition(null); + onAuditionLevel?.(false); + setAdding(false); + setPicking(false); + }, + [adding, picking, audition, onAuditionLevel], + ); + return ( -
+
+ {/* The rack IS the signal path, and saying so costs two lines. Without + them the order reads as a list, which is the one reading that makes + "move up" look cosmetic — it is the most consequential control here. */} +

+ In + this track +

{/* Carve leads the rack, which is also where its effects sit in the signal path — corrective work before anything the author added. Present whenever there is a voice for it to listen to, rather than appearing @@ -374,8 +609,8 @@ export function FxSection({ {showCarve ? "No other effects on this track." : "No effects on this track."}

) : ( - handBuilt.map(({ node, i }) => { - return ( + runs.map((run) => { + const rows = run.items.map(({ node, i }) => ( + )); + 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; + const runKey = `${run.preset}-${run.items[0]?.i ?? 0}`; + const collapsed = collapsedRuns.has(runKey); + const style = fxPresetStyle(run.preset ?? ""); + const background = fxPresetBackground(run.preset ?? ""); + return ( +
+
+ + {/* 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 + } + /> + {collapsed ? null : rows} +
); }) )} +

+ Out + to mix +

{adding ? ( @@ -474,17 +832,43 @@ export function FxSection({ Tone (EQ)
- {grouped.map(({ group, defs }) => ( + {grouped.map(({ group, defs, jobs }) => (
{GROUP_LABEL[group]} + {jobs.map((job) => ( + + ))} {defs.map((d) => ( ))}
@@ -520,26 +904,44 @@ export function FxSection({ /> ) : null} - {adding || picking ? null : ( -
- - -
- )} + {/* The buttons stay while their menu is open, and close it — an author who + opened one and changed their mind had no way back: picking something + was the only thing that set these false, so the only exits were adding + an effect they did not want or deselecting the clip. */} +
+ + +
); } diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index 23972c1c0d..5d60f68f14 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -383,8 +383,20 @@ export function useTimelinePlayer() { seek(state.requestedSeekTime); usePlayerStore.getState().clearSeekRequest(); } + // Play or stop from outside the loop — the FX rack auditioning a preset + // while paused, which is silent otherwise. `returnTo` puts the playhead + // back where the request found it: hovering is not an edit. + const request = state.playbackRequest; + if (request && request.nonce !== prev.playbackRequest?.nonce) { + if (request.playing) play(); + else { + pause(); + if (request.returnTo !== null) seek(request.returnTo); + } + usePlayerStore.getState().clearPlaybackRequest(); + } }); - }, [seek]); + }, [seek, play, pause]); const { playbackKeyDownRef, playbackKeyUpRef, attachIframeShortcutListeners, togglePlay } = usePlaybackKeyboard({ iframeRef, diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index f7d8abe4a2..167d5b8618 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -163,6 +163,22 @@ interface PlayerState extends KeyframeSlice, AutomationSelectionSlice { requestSeek: (time: number) => void; clearSeekRequest: () => void; + /** + * Request the transport start or stop from outside the player loop. + * + * The FX rack auditions a preset by writing it to the running graph, which is + * silent while the transport is paused — so hovering one has to start + * playback, and leaving has to put the playhead back where it was. Hovering is + * not an edit and must not cost the author their place. + * + * A nonce rather than a bare boolean: two hovers in a row both want play, and + * without it the second request is indistinguishable from the first having + * already been served. + */ + playbackRequest: { playing: boolean; returnTo: number | null; nonce: number } | null; + requestPlayback: (playing: boolean, returnTo?: number | null) => void; + clearPlaybackRequest: () => void; + /** * Request the timeline to scroll a clip into view (e.g. clicking an * already-added asset card in the sidebar). Consumed and cleared by @@ -336,6 +352,13 @@ export const usePlayerStore = create((set, get) => ({ requestSeek: (time) => set({ requestedSeekTime: time }), clearSeekRequest: () => set({ requestedSeekTime: null }), + playbackRequest: null, + requestPlayback: (playing, returnTo = null) => + set((s) => ({ + playbackRequest: { playing, returnTo, nonce: (s.playbackRequest?.nonce ?? 0) + 1 }, + })), + clearPlaybackRequest: () => set({ playbackRequest: null }), + clipRevealRequest: null, requestClipReveal: (elementId) => set((s) => ({ diff --git a/packages/studio/src/styles/studio.css b/packages/studio/src/styles/studio.css index 50e3a02fe9..ed97c1eb6e 100644 --- a/packages/studio/src/styles/studio.css +++ b/packages/studio/src/styles/studio.css @@ -469,3 +469,92 @@ body { animation: none; } } + +/* Preset shelf: the hovered row, and the wave that says it is being heard. + * + * Hovering a preset auditions it on the running audio, which is invisible — the + * panel looks identical whether the audition is playing or the pointer just + * happens to be resting there. The bars are that feedback: they run only while a + * preset is actually sounding, so they are a readout of the audio rather than a + * hover decoration. */ +.hf-fx-preset-item { + position: relative; +} + +@media (prefers-reduced-motion: no-preference) { + .hf-fx-preset-item { + transition: + background-color 120ms ease, + color 120ms ease; + } +} + +.hf-fx-preset-item:hover, +.hf-fx-preset-item:focus-visible { + /* The accent at low alpha rather than a grey: the row is lit by the thing that + is playing, and it should read as the same event as the bars. */ + background-color: rgba(60, 230, 172, 0.12); +} + +.hf-fx-preset-wave { + position: absolute; + top: 50%; + right: 0.5rem; + display: flex; + align-items: center; + gap: 2px; + height: 0.75rem; + transform: translateY(-50%); + opacity: 0; + transition: opacity 120ms ease; + pointer-events: none; +} + +.hf-fx-preset-item:hover .hf-fx-preset-wave, +.hf-fx-preset-item:focus-visible .hf-fx-preset-wave { + opacity: 1; +} + +.hf-fx-preset-wave span { + width: 2px; + height: 100%; + border-radius: 1px; + background: #3ce6ac; + transform: scaleY(0.25); + transform-origin: center; +} + +@media (prefers-reduced-motion: no-preference) { + .hf-fx-preset-wave span { + animation: hf-fx-preset-wave 900ms ease-in-out infinite; + } + /* Staggered so it reads as a waveform travelling rather than four bars + pumping in unison, which reads as a loading spinner. */ + .hf-fx-preset-wave span:nth-child(2) { + animation-delay: 150ms; + } + .hf-fx-preset-wave span:nth-child(3) { + animation-delay: 300ms; + } + .hf-fx-preset-wave span:nth-child(4) { + animation-delay: 450ms; + } +} + +/* Still visible without motion — it is telling the author something is + playing, and that fact does not go away because the bars hold still. */ +@media (prefers-reduced-motion: reduce) { + .hf-fx-preset-wave span { + transform: scaleY(0.7); + } +} + +@keyframes hf-fx-preset-wave { + 0%, + 100% { + transform: scaleY(0.25); + } + 50% { + transform: scaleY(1); + } +} diff --git a/plans/audio-fx-ux/README.md b/plans/audio-fx-ux/README.md index c231dd07fa..63589a49c2 100644 --- a/plans/audio-fx-ux/README.md +++ b/plans/audio-fx-ux/README.md @@ -5,16 +5,16 @@ routing, what is driven versus set. But information a casual author cannot read is decoration, and the rack speaks entirely in Hz, dB and ratios. So the drawing stays and the **language changes**. -The plain-language layer over every effect in the registry now ships as -`packages/core/src/audioFxCopy.ts`, with the coverage that used to gate this -page — every effect, parameter and preset must have copy — as -`audioFxCopy.test.ts`. `build-preview.mts` renders the review page from it -**plus the real registry and preset catalogue**. Only `PROFILES` is still a -proposal, and it is all that is left in `copy.mts`. +The plain-language layer over every effect in the registry ships as +`packages/core/src/audioFxCopy.ts`, and the coverage that used to gate a review +page — every effect, parameter and preset must have copy — is now +`audioFxCopy.test.ts`, so it runs on every commit rather than when somebody +remembers to regenerate a page. -```bash -bun plans/audio-fx-ux/build-preview.mts /tmp/rack-ux.html -``` +This document is the design record. What it describes is built: read it against +the FX rack in the studio, or against `audioFxCopy.ts`, `audioFxJobs.ts`, +`audioFxProfiles.ts` and the panel components under +`packages/studio/src/components/editor/propertyPanelFx*`. ## The three rules @@ -211,15 +211,47 @@ down thirty times a second. Applying must NOT revert, since the audition *was* the thing applied. And moving between two entries in a shelf is not leaving it, so each entry has to call its neighbours' auditions off itself. -What is still not wired: `PROFILES`, below. +**The three rules are built too**, and so is the visual direction: + +- *Two faces.* A module opens on its name, what it is for, and the one control + that carries it — `EFFECT_COPY.primary`, with `primaryEnds` saying what its + two ends sound like. Everything else is behind a Details disclosure, which is + also where the DSP name lives. Ten of fifteen effects; the five whose primary + is "strength" open on all their controls until `PROFILES` ships, which is + honest — inventing one knob for them now would be a knob that lies. +- *One knob that matters.* `packages/core/src/audioFxProfiles.ts`. Five effects + get a derived control over several parameters, continuous rather than the + three-point tables proposed here. **Three of the five figures were wrong** and + only rendering showed it — the write-up is `~/audio-fx-profiles-ab/README.md`. +- *Name the outcome.* Modules, knobs, the add menu and the preset shelf. + +- **The range IS the module.** The add menu offers five named jobs — Tame + Boominess, Reduce Mud, Reduce Boxiness, Add Clarity, Soften Harshness — and + `peaking` is not offered as itself. `packages/core/src/audioFxJobs.ts`. Every + one is a job the preset catalogue already ships, at the settings it ships it + with, so the list names the vocabulary the presets were written in rather than + inventing a second one. +- **The shared ruler.** Every spectral module shows where it acts across the + seven named ranges, log-spaced, with the range it is in named underneath. +- **Family lettering and the tint step.** Four families told apart by the sans, + the serif spent on non-linear, monospace for the measuring modules, and a + lightness step per module derived from registry position. +- **The schematic**, translated to one column: IN and OUT terminals, every step + numbered over what the rack shows, and a preset's consecutive nodes bracketed + as the one thing that was added. + +Everything in this document is now built. It has no entry for Tone or for the levelling module, because both carry their own copy in core (`audioEqSummary`, `levellingSummary`). That is the right home for it: a summary that has to read the chain belongs beside the code that writes it. -The `PROFILES` figures — what one knob derives at gentle/middle/strong — are -**still proposed values, not measured ones**, which is why they stayed behind in -`copy.mts` rather than going to core with the rest. They want the same -before/after listen the clip-before-duck fix got before a knob is wired to -them. +The `PROFILES` figures — what one knob derives at gentle/middle/strong — have +**shipped and been corrected**. They are `HF_AUDIO_FX_PROFILES` in +`packages/core/src/audioFxProfiles.ts`, continuous rather than three-point, and +three of the five were wrong as proposed: the compressor's make-up left the +track quieter at full evenness, saturation's trim went the wrong way and made +"Warmth" mean "much quieter", and the gate did essentially nothing because +`release` was not in the profile at all. Measurements, method and the sweep that +found the last one are in `~/audio-fx-profiles-ab/README.md`.