diff --git a/packages/studio/src/components/editor/propertyPanelFxEqModule.tsx b/packages/studio/src/components/editor/propertyPanelFxEqModule.tsx new file mode 100644 index 0000000000..36629bca22 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFxEqModule.tsx @@ -0,0 +1,191 @@ +/** + * The Tone module: a multi-band EQ as one control surface over several nodes. + * + * Faders rather than the rack's usual horizontal sliders, because a row of them + * around a centre detent is what an equaliser looks like to everybody who has + * met one. Recognising the control is most of the value — an author who has + * never opened a mixer has still used bass, middle and treble. + */ + +import { useCallback, useEffect, useState } from "react"; +import { + audioEqSummary, + HF_AUDIO_EQ_RANGE_DB, + type HfAudioEqBand, +} from "@hyperframes/core/audio-fx-eq"; + +export interface FxEqModuleProps { + eqId: string; + bands: HfAudioEqBand[]; + open: boolean; + disabled?: boolean; + onToggleOpen(): void; + /** Dragging: heard immediately, not persisted. */ + onPreview(bandName: string, gain: number): void; + /** Release: the write that persists. */ + onCommit(bandName: string, gain: number): void; + onRemove(): void; +} + +/** Fader travel as a percentage from the top, with 0 dB at the centre. */ +function offsetFor(gain: number): number { + const clamped = Math.max(-HF_AUDIO_EQ_RANGE_DB, Math.min(HF_AUDIO_EQ_RANGE_DB, gain)); + return 50 - (clamped / (HF_AUDIO_EQ_RANGE_DB * 2)) * 100; +} + +const shown = (gain: number): string => { + const v = Number(gain.toFixed(1)); + return v > 0 ? `+${v}` : String(v); +}; + +function Fader({ + band, + disabled, + onPreview, + onCommit, +}: { + band: HfAudioEqBand; + disabled?: boolean; + onPreview(gain: number): void; + onCommit(gain: number): void; +}) { + /** + * Held locally for the length of the gesture. + * + * The module is driven by the chain, and dragging only PREVIEWS — it does + * not write — so a purely controlled input re-renders back to the old value + * on the first move and the fader snaps out from under the pointer. Same + * split the rack's other controls already make. + */ + const [local, setLocal] = useState(band.gain); + const [dragging, setDragging] = useState(false); + useEffect(() => { + if (!dragging) setLocal(band.gain); + }, [band.gain, dragging]); + + const value = dragging ? local : band.gain; + const pct = offsetFor(value); + const moved = Math.abs(value) >= 0.05; + + const move = (next: number) => { + setDragging(true); + setLocal(next); + onPreview(next); + }; + const settle = () => { + if (!dragging) return; + setDragging(false); + onCommit(local); + }; + + // A range input rotated into a fader: it keeps keyboard control, focus and + // the platform's own pointer handling, which a div with pointer events would + // all have to reimplement badly. + return ( +
+
+ + = 0 ? { top: `${pct}%`, bottom: "50%" } : { top: "50%", bottom: `${100 - pct}%` } + } + /> + move(Number(e.target.value))} + onPointerUp={settle} + onKeyUp={settle} + onBlur={settle} + /> +
+ + {band.name} + + + {moved ? shown(value) : "0"} + +
+ ); +} + +export function FxEqModule({ + bands, + open, + disabled, + onToggleOpen, + onPreview, + onCommit, + onRemove, +}: FxEqModuleProps) { + const preview = useCallback((name: string, gain: number) => onPreview(name, gain), [onPreview]); + const commit = useCallback((name: string, gain: number) => onCommit(name, gain), [onCommit]); + + return ( +
+
+ + {bands.length}-band + +
+ + {open ? ( +
+
+ {bands.map((band) => ( + preview(band.name, g)} + onCommit={(g) => commit(band.name, g)} + /> + ))} +
+
+ CUT + BOOST +
+
+ ) : ( + // Closed, it reads like every other module: a sentence about the sound + // rather than a list of values. +

+ {audioEqSummary(bands)} +

+ )} +
+ ); +} diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx index 8b3ee12306..77e2133fb7 100644 --- a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx @@ -284,6 +284,110 @@ describe("FxSection chain", () => { expect(names).not.toContain("Peaking EQ"); }); + it("adds a Tone EQ as three ordinary filters on one control surface", () => { + const { host, onChainChange } = mount({ chain: { version: 1, nodes: [] } }); + click(host.querySelector(".hf-fx-add")); + click(byText(host, ".hf-fx-add-composite", "Tone (EQ)")); + + const next = onChainChange.mock.calls[0]![0] as HfAudioFxChain; + expect(next.nodes.map((n) => n.type)).toEqual(["lowshelf", "peaking", "highshelf"]); + expect(next.nodes.map((n) => n.label)).toEqual(["Bass", "Middle", "Treble"]); + expect(next.nodes.every((n) => n.fromEq === "eq1")).toBe(true); + }); + + it("shows the EQ as one module, not as its individual bands", () => { + // The bands belong to the Tone module. Listing them again in the rack would + // put the same filter on screen twice with two ways to edit it. + const { host } = mount({ + chain: { + version: 1, + nodes: [ + { + type: "lowshelf", + id: "a", + fromEq: "eq1", + label: "Bass", + enabled: true, + params: defaultAudioFxParams("lowshelf"), + }, + { + type: "peaking", + id: "b", + fromEq: "eq1", + label: "Middle", + enabled: true, + params: defaultAudioFxParams("peaking"), + }, + { + type: "highshelf", + id: "c", + fromEq: "eq1", + label: "Treble", + enabled: true, + params: defaultAudioFxParams("highshelf"), + }, + ], + } as HfAudioFxChain, + }); + expect(host.querySelectorAll(".hf-fx-eq-module")).toHaveLength(1); + const names = Array.from(host.querySelectorAll(".hf-fx-node-name")).map((e) => + e.textContent?.trim(), + ); + expect(names).toContain("Tone"); + expect(names).not.toContain("Bass"); + // Closed, it says what it is doing rather than listing three zeroes. + expect(host.querySelector(".hf-fx-eq-summary")?.textContent).toMatch(/^Flat/); + }); + + it("moves one band without persisting until the fader is released", () => { + const { host, onChainChange, onChainPreview } = mount({ + chain: { + version: 1, + nodes: [ + { + type: "lowshelf", + id: "a", + fromEq: "eq1", + label: "Bass", + enabled: true, + params: defaultAudioFxParams("lowshelf"), + }, + { + type: "peaking", + id: "b", + fromEq: "eq1", + label: "Middle", + enabled: true, + params: defaultAudioFxParams("peaking"), + }, + { + type: "highshelf", + id: "c", + fromEq: "eq1", + label: "Treble", + enabled: true, + params: defaultAudioFxParams("highshelf"), + }, + ], + } as HfAudioFxChain, + }); + // The carve module leads the rack, so its header is the first one — open + // the EQ's own. + click(host.querySelector(".hf-fx-eq-module .hf-fx-node-name")); + const fader = host.querySelectorAll(".hf-fx-eq-fader")[0]!; + expect(fader, "the EQ did not open").toBeTruthy(); + typeInto(fader, "4"); + // Heard, not written — a persisting write per drag event reloads the + // composition and restarts the audio. + expect(onChainPreview).toHaveBeenCalled(); + expect(onChainChange).not.toHaveBeenCalled(); + + act(() => fader.dispatchEvent(new Event("pointerup", { bubbles: true }))); + const next = onChainChange.mock.calls[0]![0] as HfAudioFxChain; + expect(next.nodes.find((n) => n.label === "Bass")!.params!.gain).toBe(4); + expect(next.nodes.find((n) => n.label === "Middle")!.params!.gain).toBe(0); + }); + it("cannot move the ends past themselves", () => { const { host } = mount({ chain: chainOf("peaking", "reverb") }); const ups = host.querySelectorAll('.hf-fx-move[title="Move up"]'); diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.tsx index dfab7346bd..d60f552ac6 100644 --- a/packages/studio/src/components/editor/propertyPanelFxSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFxSection.tsx @@ -23,9 +23,17 @@ import { } 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 { fxAutomationTarget } from "@hyperframes/core/audio-automation"; import { FxParams, FxParamRow } from "./propertyPanelFxControls.js"; import { FxPresetMenu } from "./propertyPanelFxPresetMenu.js"; +import { FxEqModule } from "./propertyPanelFxEqModule.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"; @@ -769,10 +777,47 @@ export function FxSection({ 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 })).filter(({ node }) => !node.fromCarve), + () => + 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], ); + const eqIds = useMemo(() => audioEqIds(chain), [chain]); + const [openEq, setOpenEq] = useState(null); + + const addEq = useCallback(() => { + 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; @@ -809,7 +854,20 @@ export function FxSection({ onCarvePreview={previewCarve} /> ) : null} - {handBuilt.length === 0 ? ( + {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."}

@@ -846,6 +904,22 @@ export function FxSection({ {adding ? (
+
+ + Tone + + +
{grouped.map(({ group, defs }) => (