/** * The FX section for an audio element: the chain, plus the voiceover carve. * * The carve is its own module — see `propertyPanelFxCarveModule.tsx` for why it * is not an entry in the chain. */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { defaultAudioFxParams, getAudioFxDef, HF_AUDIO_FX, mintAudioFxNodeId, type HfAudioFxChain, type HfAudioFxGroup, type HfAudioFxNode, type HfAudioFxParamValues, } from "@hyperframes/core/audio-fx"; import { DEFAULT_CARVE, type HfCarveSettings } from "@hyperframes/core/audio-carve"; import { applyAudioFxPreset, getAudioFxPreset } from "@hyperframes/core/audio-fx-presets"; import { addAudioEq, audioEqIds, readAudioEqBands, 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 { FxPresetMenu } from "./propertyPanelFxPresetMenu.js"; import { FxEqModule } from "./propertyPanelFxEqModule.js"; import { FxCarveModule, type AudioTrackOption } from "./propertyPanelFxCarveModule.js"; import { FxNodeRow } from "./propertyPanelFxNodeRow.js"; export type { AudioTrackOption }; const GROUP_ORDER: HfAudioFxGroup[] = ["filter", "dynamics", "nonlinear", "time"]; const GROUP_LABEL: Record = { filter: "Filters", dynamics: "Dynamics", nonlinear: "Non-linear", time: "Time", }; export interface FxSectionProps { chain: HfAudioFxChain; /** Targets this track already automates, as `fx..` strings. */ automatedTargets?: ReadonlySet; /** * What each automated target is worth at the playhead, by the same key. * * An automated parameter's stored number is only the seed the lane replaced, so * a rack that shows it stands still while the carve is audibly working. Absent, * or missing a key, means there is no playhead over this clip and the stored * value is the honest one. */ liveAutomationValues?: ReadonlyMap; /** Add a lane for one effect parameter, seeded at its current value. */ onAutomateParam?(nodeId: string, paramKey: string): void; /** Delete one effect parameter's lane. */ onRemoveParamAutomation?(nodeId: string, paramKey: string): void; /** Delete every lane belonging to a node that is being removed. */ onRemoveNodeAutomation?(nodeId: string): void; /** Measure this track and write the levelling lane. Absent when unavailable. */ onLevel?(): void; /** Take the levelling stage and its lane back out. */ onRemoveLevel?(): void; /** Whether a levelling stage is already on the track. */ levelled?: boolean; /** * Hover-audition of the levelling script: measure this track and play the * result without persisting it, and put it back on `false`. * * Separate from `onChainPreview` because it is the one audition that cannot be * synthesised from the chain in hand — the numbers do not exist until the * audio has been decoded and measured. */ onAuditionLevel?(on: boolean): void; /** Whether that measurement is running, so the button can say so. */ auditioningLevel?: boolean; /** Structural edits and gesture-end writes; this is the one that persists. */ onChainChange(chain: HfAudioFxChain): void; /** Continuous updates while a control is being dragged. */ onChainPreview?(chain: HfAudioFxChain): void; carve: HfCarveSettings | null; /** Gesture-end write; this is the one that persists. */ onCarveChange(carve: HfCarveSettings | null): void; /** Continuous updates while a carve slider is dragged. Without this every * pointermove patched the source file and resynced the selection. */ onCarvePreview?(carve: HfCarveSettings): void; /** * Set when another track's carve listens to this one, naming it. The carve block * is then not offered here at all: this track is the voice, not the bed. */ carvedAgainstBy?: string | null; /** Other audio elements that could act as the carve source. */ sourceOptions: AudioTrackOption[]; analysing?: boolean; disabled?: boolean; } export function FxSection({ chain, automatedTargets, liveAutomationValues, onAutomateParam, onRemoveParamAutomation, onRemoveNodeAutomation, onChainChange, onChainPreview, carve, carvedAgainstBy, onCarveChange, onCarvePreview, sourceOptions, analysing, disabled, onLevel, onRemoveLevel, levelled, onAuditionLevel, auditioningLevel, }: FxSectionProps) { // 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; // Nothing to carve against means nothing to show — see the block below. // Not offered on the voice another track is already carving against — that // track is the far end of someone else's relationship, and a carve of its own // could only name a source it must not. const showCarve = !carvedAgainstBy && (sourceOptions.length > 0 || carve !== null); const [adding, setAdding] = useState(false); 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 && !HF_AUDIO_FX_JOB_TYPES.has(d.id)), jobs: HF_AUDIO_FX_JOBS.filter((job) => getAudioFxDef(job.type)?.group === g), })), [], ); const mutate = useCallback( (nodes: HfAudioFxNode[]) => onChainChange({ ...chain, nodes }), [chain, onChainChange], ); // Dragging a knob previews without persisting; releasing it commits once. const previewNode = useCallback( (index: number, params: HfAudioFxParamValues) => onChainPreview?.({ ...chain, nodes: chain.nodes.map((n, i) => (i === index ? { ...n, params } : n)), }), [chain, onChainPreview], ); /** * The chain as it is really stored, captured when an audition starts. * * Auditioning writes through the preview channel, which does not persist and * does not come back as a new `chain` prop — so reverting has to remember what * was there rather than read it back. Null means nothing is being auditioned, * which is also what makes a stray leave a no-op instead of a write. */ const auditionBase = useRef(null); /** * Play something without committing to it, and put it back on the way out. * * Hearing a preset before choosing it is the strongest affordance in this * panel — see `plans/audio-fx-ux/README.md` §Decided. It costs nothing new: * the preview channel a slider drag already uses rebuilds the running graph * without touching the document. */ const audition = useCallback( (make: ((base: HfAudioFxChain) => HfAudioFxChain) | null) => { if (!onChainPreview) return; if (make) { auditionBase.current ??= chain; onChainPreview(make(auditionBase.current)); } else if (auditionBase.current) { onChainPreview(auditionBase.current); auditionBase.current = null; } }, [chain, onChainPreview], ); /** * The preview handler as of the last render, held rather than closed over. * * The teardown below must run on teardown and at no other time, so its deps * have to be empty — and `onChainPreview` is an inline arrow in the group, * which re-renders on every playhead tick to move the automation readouts. A * dep on it made React tear down and re-run the effect on every one of those * ticks, so an audition reverted itself about 30 times a second while the * pointer was still on the button: the preset was heard for a frame during * playback, which is the exact case the whole affordance exists for. */ const previewRef = useRef(onChainPreview); previewRef.current = onChainPreview; // 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. useEffect( () => () => { if (auditionBase.current) previewRef.current?.(auditionBase.current); }, [], ); const applyPreset = useCallback( (id: string) => { const preset = getAudioFxPreset(id); if (!preset) return; // Appends. Stacking a character preset onto an already-cleaned voice is a // real thing to want, and replacing silently would throw work away — so // the destructive option is a separate gesture, not the default one. const next = applyAudioFxPreset(chain, preset); // The audition WAS this, so there is nothing to put back — and putting the // 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; 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], ); /** * 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: 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; mutate(withJob(chain, job).nodes); setOpenNode(chain.nodes.length); setAdding(false); }, [chain, mutate, withJob], ); const addEffect = useCallback( (type: string) => { auditionBase.current = null; mutate(withEffect(chain, type).nodes); setOpenNode(chain.nodes.length); setAdding(false); }, [chain, mutate, withEffect], ); const updateNode = useCallback( (index: number, patch: Partial) => mutate(chain.nodes.map((n, i) => (i === index ? { ...n, ...patch } : n))), [chain.nodes, mutate], ); const removeNode = useCallback( (index: number) => { // The node's lanes go with it. `resolveAutomation` only hides an orphan at // read time; left in the attribute, and with ids minted lowest-free, the // next effect added takes the same id and inherits the dead envelope — // arriving with its control disabled and "Automated" without the author // ever automating it, and baked into the render. const removedId = chain.nodes[index]?.id; if (removedId) onRemoveNodeAutomation?.(removedId); mutate(chain.nodes.filter((_, i) => i !== index)); setOpenNode(null); }, [chain.nodes, mutate, onRemoveNodeAutomation], ); // Open by default: the module is the carve's whole control surface now, and a // collapsed card would hide the knob the author came here for. const [carveOpen, setCarveOpen] = useState(true); const carveNodes = useMemo(() => chain.nodes.filter((n) => n.fromCarve), [chain.nodes]); /** Everything the author added, with the chain index every edit addresses. */ const handBuilt = useMemo( () => chain.nodes .map((node, i) => ({ node, i })) // Carve and EQ bands belong to their own modules; showing them here too // would put the same filter on screen twice with two ways to edit it. .filter(({ node }) => !node.fromCarve && !node.fromEq), [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]); 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; const { chain: next, eqId } = addAudioEq(chain); mutate(next.nodes); setOpenEq(eqId); setAdding(false); }, [chain, mutate]); // Dragging a fader is heard immediately and written once on release, the same // split every other control in the rack uses. const previewEqBand = useCallback( (eqId: string, band: string, gain: number) => onChainPreview?.(setAudioEqBandGain(chain, eqId, band, gain)), [chain, onChainPreview], ); const commitEqBand = useCallback( (eqId: string, band: string, gain: number) => mutate(setAudioEqBandGain(chain, eqId, band, gain).nodes), [chain, mutate], ); const removeEq = useCallback( (eqId: string) => { for (const node of chain.nodes) { if (node.fromEq === eqId && node.id) onRemoveNodeAutomation?.(node.id); } mutate(removeAudioEq(chain, eqId).nodes); }, [chain, mutate, onRemoveNodeAutomation], ); const moveNode = useCallback( (index: number, delta: number) => { const target = index + delta; if (target < 0 || target >= chain.nodes.length) return; const next = [...chain.nodes]; const [moved] = next.splice(index, 1); next.splice(target, 0, moved!); mutate(next); setOpenNode(target); }, [chain.nodes, mutate], ); 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 only once it has already produced something: a control that materialises after the fact cannot be the thing you reach for to start. */} {showCarve ? ( setCarveOpen((was) => !was)} onCarveChange={onCarveChange} onCarvePreview={previewCarve} /> ) : null} {eqIds.map((eqId) => ( setOpenEq((was) => (was === eqId ? null : eqId))} onPreview={(band, gain) => previewEqBand(eqId, band, gain)} onCommit={(band, gain) => commitEqBand(eqId, band, gain)} onRemove={() => removeEq(eqId)} /> ))} {handBuilt.length === 0 && eqIds.length === 0 ? (

{showCarve ? "No other effects on this track." : "No effects on this track."}

) : ( runs.map((run) => { const rows = run.items.map(({ node, i }) => ( setOpenNode(openNode === i ? null : i)} onUpdate={updateNode} onMove={moveNode} onRemove={removeNode} onPreview={previewNode} /> )); const preset = run.preset ? getAudioFxPreset(run.preset) : null; if (!preset) return rows; return (
{preset.label} {rows}
); }) )}

Out to mix

{adding ? (
{ audition(null); onAuditionLevel?.(false); }} // The keyboard's version of leaving. Tabbing between two entries fires // this and then the next one's focus, so it reverts and re-auditions. onBlur={() => { audition(null); onAuditionLevel?.(false); }} >
Tone {onLevel ? ( ) : null}
{grouped.map(({ group, defs, jobs }) => (
{GROUP_LABEL[group]} {jobs.map((job) => ( ))} {defs.map((d) => ( ))}
))}
) : null} {picking ? ( { const preset = id ? getAudioFxPreset(id) : null; audition(preset ? (base) => applyAudioFxPreset(base, preset) : null); } : undefined } /> ) : null} {adding || picking ? null : (
)}
); }