diff --git a/packages/studio/src/components/editor/PropertyPanelFlat.tsx b/packages/studio/src/components/editor/PropertyPanelFlat.tsx index dc3f4f7b3f..cf86039fd2 100644 --- a/packages/studio/src/components/editor/PropertyPanelFlat.tsx +++ b/packages/studio/src/components/editor/PropertyPanelFlat.tsx @@ -13,22 +13,11 @@ import { FlatTextSection } from "./propertyPanelFlatTextSection"; import { FlatStyleSection } from "./propertyPanelFlatStyleSections"; import { FlatLayoutSection } from "./propertyPanelFlatLayoutSection"; import { FlatMotionSection } from "./propertyPanelFlatMotionSection"; -import { - HF_AUDIO_FX_ATTR, - parseAudioFxChain, - serializeAudioFxChain, - type HfAudioFxChain, -} from "@hyperframes/core/audio-fx"; -import { - analyseCarveBands, - carveBandsToChain, - HF_AUDIO_CARVE_ATTR, - normalizeCarveSettings, - type HfCarveSettings, -} from "@hyperframes/core/audio-carve"; +import { parseAudioFxChain } from "@hyperframes/core/audio-fx"; +import { AudioFxGroup } from "./propertyPanelAudioFxGroup.js"; +import { useVolumeAutomation } from "./useVolumeAutomation"; import { FlatMediaSection } from "./propertyPanelFlatMediaSection"; import type { DomEditSelection } from "./domEditing"; -import { FxSection, type AudioTrackOption } from "./propertyPanelFxSection"; import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation"; import { createGsapLivePreview } from "./gsapLivePreview"; import { formatTextFieldPreview } from "./propertyPanelSections"; @@ -79,6 +68,7 @@ export function PropertyPanelFlat({ onSetAttribute, onSetAttributes, onSetAttributeLive, + onSetAttributeQuiet, onApplyColorGradingScope, onSetHtmlAttribute, onRemoveBackground, @@ -267,6 +257,8 @@ export function PropertyPanelFlat({ const showMotionEffects = gsapEffectHandlers !== null; const showMotionGroup = showMotionTiming || showMotionEffects; + const volumeAutomation = useVolumeAutomation(element, onSetAttributeQuiet ?? onSetAttributeLive); + const groups: FlatGroupDescriptor[] = []; if (isTextEditable) { groups.push({ @@ -443,7 +435,7 @@ export function PropertyPanelFlat({ content: ( ), @@ -463,6 +455,7 @@ export function PropertyPanelFlat({ onSetAttribute={onSetAttribute} onSetHtmlAttribute={onSetHtmlAttribute} onRemoveBackground={onRemoveBackground} + {...volumeAutomation} /> ), }); @@ -561,112 +554,3 @@ function audioFxSummary(element: DomEditSelection): string { if (carve) parts.push("carve"); return parts.length > 0 ? parts.join(" + ") : "none"; } - -/** - * Bridges the FX panel to the element/attribute world. Chain and carve are - * serialised onto the element the way colour grading carries its config, so - * persistence is an ordinary attribute write with no new server route. - */ -function AudioFxGroup({ - element, - onSetAttribute, - onSetAttributeLive, -}: { - element: DomEditSelection; - onSetAttribute: (attr: string, value: string) => void | Promise; - onSetAttributeLive: (attr: string, value: string | null) => void | Promise; -}) { - const chain = ((): HfAudioFxChain => { - const raw = element.dataAttributes?.["fx-chain"]; - if (!raw) return { version: 1, nodes: [] }; - try { - return parseAudioFxChain(raw); - } catch { - // Show an unreadable chain as empty rather than breaking the panel; the - // attribute is left untouched until the user changes something. - return { version: 1, nodes: [] }; - } - })(); - - const carve = ((): HfCarveSettings | null => { - const raw = element.dataAttributes?.["fx-carve"]; - if (!raw) return null; - try { - return normalizeCarveSettings(JSON.parse(raw)); - } catch { - return null; - } - })(); - - const sourceOptions: AudioTrackOption[] = (() => { - const doc = element.element?.ownerDocument; - if (!doc) return []; - return Array.from(doc.querySelectorAll("audio[id]")) - .filter((a) => a.id !== element.id) - .map((a) => ({ id: a.id, label: a.id })); - })(); - - const [analysing, setAnalysing] = useState(false); - - /** - * Decodes the chosen voice track and turns its spectrum into peaking filters - * on this one. The bands replace any previous carve output but leave - * hand-added effects alone, so re-analysing does not discard other work. - */ - const analyse = async (): Promise => { - if (!carve?.source) return; - const doc = element.element?.ownerDocument; - const voice = doc?.getElementById(carve.source) as HTMLAudioElement | null; - const src = voice?.getAttribute("src"); - if (!src) return; - setAnalysing(true); - try { - const res = await fetch(new URL(src, doc!.baseURI).href); - const bytes = await res.arrayBuffer(); - const Ctor = - window.AudioContext ?? - (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; - if (!Ctor) return; - const ctx = new Ctor(); - try { - const buffer = await ctx.decodeAudioData(bytes); - const bands = analyseCarveBands(buffer.getChannelData(0), buffer.sampleRate, carve); - const carved = carveBandsToChain(bands); - // Carve output is tagged so a re-run replaces it instead of stacking. - const kept = chain.nodes.filter((n) => !n.fromCarve); - const next = { - version: 1, - nodes: [...carved.nodes.map((n) => ({ ...n, fromCarve: true })), ...kept], - }; - onSetAttribute(HF_AUDIO_FX_ATTR, serializeAudioFxChain(next)); - } finally { - void ctx.close().catch(() => undefined); - } - } catch { - // Leave the chain as it was; the button simply re-enables. - } finally { - setAnalysing(false); - } - }; - - return ( - - onSetAttribute(HF_AUDIO_FX_ATTR, next.nodes.length ? serializeAudioFxChain(next) : "") - } - onChainPreview={(next) => - // Live writes skip the preview refresh, so dragging a knob no longer - // reloads the composition and restarts playback on every pixel. - onSetAttributeLive(HF_AUDIO_FX_ATTR, next.nodes.length ? serializeAudioFxChain(next) : null) - } - carve={carve} - onCarveChange={(next) => - onSetAttribute(HF_AUDIO_CARVE_ATTR, next ? JSON.stringify(next) : "") - } - sourceOptions={sourceOptions} - onAnalyseCarve={() => void analyse()} - analysing={analysing} - /> - ); -} diff --git a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx new file mode 100644 index 0000000000..de4153689b --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx @@ -0,0 +1,358 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createRoot } from "react-dom/client"; +import { AudioFxGroup } from "./propertyPanelAudioFxGroup.js"; +import type { DomEditSelection } from "./domEditingTypes"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const CHAIN = JSON.stringify({ + version: 1, + nodes: [{ type: "lowpass", id: "n1", params: { frequency: 900, q: 1.2, poles: "2" } }], +}); + +// Each mount appends its tracks to the document; without clearing, a later +// "only one audio track" case would still find the previous test's sibling. +afterEach(() => { + document.body.innerHTML = ""; +}); + +/** + * A selected `` with a sibling track, so carve — which needs another + * track to listen to — is offered. Pass `alone` for a composition holding just + * this one. + */ +function audioSelection(dataAttributes: Record, alone = false): DomEditSelection { + const bed = document.createElement("audio"); + bed.id = "bed"; + document.body.append(bed); + if (!alone) { + const voice = document.createElement("audio"); + voice.id = "vo"; + document.body.append(voice); + } + return { dataAttributes, id: "bed", element: bed } as unknown as DomEditSelection; +} + +function mount(dataAttributes: Record, alone = false) { + // Every write is quiet: persisted without the preview reload that would + // restart every playing track, but with a selection resync so the panel sees + // what it just wrote. + const onSetAttributeQuiet = vi.fn(); + const onSetAttributeLive = vi.fn(); + const host = document.createElement("div"); + document.body.append(host); + const selection = audioSelection(dataAttributes, alone); + act(() => { + createRoot(host).render( + , + ); + }); + return { host, onSetAttributeQuiet, onSetAttributeLive }; +} + +const rowFor = (host: HTMLElement, label: string): HTMLElement | null => { + for (const row of Array.from(host.querySelectorAll(".hf-fx-row"))) { + if (row.querySelector(".hf-fx-label")?.textContent === label) return row; + } + return null; +}; + +const parseWrite = (call: unknown[]) => JSON.parse(String(call[1])); + +describe("AudioFxGroup automation", () => { + it("renders the chain's parameters", () => { + const { host } = mount({ "fx-chain": CHAIN }); + expect(rowFor(host, "Cutoff")).toBeTruthy(); + expect(rowFor(host, "Q")).toBeTruthy(); + }); + + it("seeds a new lane at the value the control already holds", () => { + // Switching to an envelope must not change the sound — only where the value + // comes from. The chain has frequency at 900, not the registry default. + const { host, onSetAttributeQuiet } = mount({ "fx-chain": CHAIN }); + const button = rowFor(host, "Cutoff")!.querySelector(".hf-fx-automate") as HTMLButtonElement; + act(() => button.click()); + const [attr, value] = onSetAttributeQuiet.mock.calls[0]; + expect(attr).toBe("data-automation"); + expect(JSON.parse(String(value))).toEqual({ + version: 1, + lanes: [{ target: "fx.n1.frequency", points: [{ t: 0, v: 900 }] }], + }); + }); + + it("keeps lanes it is not touching when adding one", () => { + const { host, onSetAttributeQuiet } = mount({ + "fx-chain": CHAIN, + automation: JSON.stringify({ + version: 1, + lanes: [{ target: "volume", points: [{ t: 0, v: 0.5 }] }], + }), + }); + act(() => (rowFor(host, "Q")!.querySelector(".hf-fx-automate") as HTMLButtonElement).click()); + expect( + parseWrite(onSetAttributeQuiet.mock.calls[0]).lanes.map((l: { target: string }) => l.target), + ).toEqual(["volume", "fx.n1.q"]); + }); + + it("disables a control the timeline already drives", () => { + const { host } = mount({ + "fx-chain": CHAIN, + automation: JSON.stringify({ + version: 1, + lanes: [{ target: "fx.n1.frequency", points: [{ t: 0, v: 400 }] }], + }), + }); + const cutoff = rowFor(host, "Cutoff")!; + expect(cutoff.querySelector('input[type="range"]')?.disabled).toBe(true); + expect(cutoff.hasAttribute("data-automated")).toBe(true); + expect( + rowFor(host, "Q")!.querySelector('input[type="range"]')?.disabled, + ).toBe(false); + }); + + it("deletes just that lane, handing the value back to the control", () => { + const { host, onSetAttributeQuiet } = mount({ + "fx-chain": CHAIN, + automation: JSON.stringify({ + version: 1, + lanes: [ + { target: "fx.n1.frequency", points: [{ t: 0, v: 400 }] }, + { target: "volume", points: [{ t: 0, v: 0.5 }] }, + ], + }), + }); + act(() => + (rowFor(host, "Cutoff")!.querySelector(".hf-fx-automate") as HTMLButtonElement).click(), + ); + expect( + parseWrite(onSetAttributeQuiet.mock.calls[0]).lanes.map((l: { target: string }) => l.target), + ).toEqual(["volume"]); + }); + + it("clears the attribute when the last lane goes", () => { + const { host, onSetAttributeQuiet } = mount({ + "fx-chain": CHAIN, + automation: JSON.stringify({ + version: 1, + lanes: [{ target: "fx.n1.frequency", points: [{ t: 0, v: 400 }] }], + }), + }); + act(() => + (rowFor(host, "Cutoff")!.querySelector(".hf-fx-automate") as HTMLButtonElement).click(), + ); + // Null rather than "": the live path removes an attribute it is given null for. + expect(onSetAttributeQuiet.mock.calls[0][1]).toBeNull(); + }); + + it("ignores a lane for an effect that is no longer in the chain", () => { + const { host } = mount({ + "fx-chain": CHAIN, + automation: JSON.stringify({ + version: 1, + lanes: [{ target: "fx.gone.frequency", points: [{ t: 0, v: 400 }] }], + }), + }); + // Nothing is automated, so every control stays live. + expect( + rowFor(host, "Cutoff")!.querySelector('input[type="range"]')?.disabled, + ).toBe(false); + }); +}); + +describe("AudioFxGroup carve", () => { + const carvedChain = JSON.stringify({ + version: 1, + nodes: [ + { type: "peaking", id: "n1", fromCarve: true, params: { frequency: 900, gain: -6, q: 1.4 } }, + { type: "lowpass", id: "n2", params: { frequency: 400, q: 0.9, poles: "2" } }, + ], + }); + + const carveOn = JSON.stringify({ + source: "vo", + maxCutDb: 6, + bands: 3, + q: 1.4, + intelligibilityBias: 0.7, + }); + + const carveToggle = (host: HTMLElement): HTMLButtonElement => { + const block = host.querySelector(".hf-fx-carve")!; + return block.querySelector(".hf-fx-bypass") as HTMLButtonElement; + }; + + it("removes the filters it generated when carve is switched off", async () => { + // Leaving them behind would keep dipping the bed with no carve to explain it. + const { host, onSetAttributeQuiet } = mount({ + "fx-chain": carvedChain, + "fx-carve": carveOn, + }); + await act(async () => { + carveToggle(host).click(); + }); + const chainWrite = onSetAttributeQuiet.mock.calls.find((c) => c[0] === "data-fx-chain"); + expect(chainWrite).toBeTruthy(); + const kept = JSON.parse(String(chainWrite![1])).nodes; + expect(kept.map((n: { type: string }) => n.type)).toEqual(["lowpass"]); + // And the carve settings themselves go — after the chain write, not + // alongside it: both are read-modify-writes of the same file, so fired + // together the later one reads pre-edit content and drops the earlier. + expect(onSetAttributeQuiet.mock.calls.map((c) => c[0])).toEqual([ + "data-fx-chain", + "data-fx-carve", + ]); + expect(onSetAttributeQuiet.mock.calls.find((c) => c[0] === "data-fx-carve")?.[1]).toBeNull(); + }); + + it("leaves a hand-built chain alone when carve is switched off", () => { + const handBuilt = JSON.stringify({ + version: 1, + nodes: [{ type: "lowpass", id: "n1", params: { frequency: 400, q: 0.9, poles: "2" } }], + }); + const { host, onSetAttributeQuiet } = mount({ "fx-chain": handBuilt, "fx-carve": carveOn }); + act(() => carveToggle(host).click()); + expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-chain")).toBe(false); + }); + + it("drags a carve dial live and persists once on release", () => { + // Without the split every pointermove patched the source file and resynced + // the selection, which is what makes the audio stutter mid-drag. + const { host, onSetAttributeQuiet, onSetAttributeLive } = mount({ + "fx-chain": carvedChain, + "fx-carve": carveOn, + }); + const dial = host.querySelector(".hf-fx-carve input[type=range]"); + expect(dial).not.toBeNull(); + act(() => { + // React's value tracker swallows a plain assignment, so go through the + // prototype setter the way the other panel tests do. + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(dial, "0.5"); + dial?.dispatchEvent(new Event("input", { bubbles: true })); + }); + expect(onSetAttributeLive.mock.calls.map((c) => c[0])).toEqual(["data-fx-carve"]); + expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-carve")).toBe(false); + act(() => dial?.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }))); + expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-carve")).toBe(true); + }); + + it("writes carve settings live, so enabling it does not reload the preview", () => { + const { host, onSetAttributeQuiet } = mount({ "fx-chain": carvedChain }); + act(() => carveToggle(host).click()); + const write = onSetAttributeQuiet.mock.calls.find((c) => c[0] === "data-fx-carve"); + expect(write).toBeTruthy(); + expect(JSON.parse(String(write![1])).bands).toBeGreaterThan(0); + }); +}); + +describe("AudioFxGroup successive edits", () => { + const three = JSON.stringify({ + version: 1, + nodes: [ + { type: "peaking", id: "n1", params: { frequency: 900, gain: -6, q: 1.4 } }, + { type: "lowpass", id: "n2", params: { frequency: 400, q: 0.9, poles: "2" } }, + { type: "delay", id: "n3", params: { time: 250, feedback: 0.3, mix: 0.4 } }, + ], + }); + + const removeButtons = (host: HTMLElement) => + Array.from(host.querySelectorAll(".hf-fx-remove")); + + /** + * The panel computes each edit from the attribute it is holding. Written + * without a selection resync, the second delete worked from the pre-delete + * chain and wrote the same result — so after deleting one effect, no further + * delete did anything. + */ + it("deletes a second effect after the first, not from a stale chain", () => { + const first = mount({ "fx-chain": three }); + act(() => removeButtons(first.host)[0]!.click()); + const afterFirst = JSON.parse(String(first.onSetAttributeQuiet.mock.calls[0][1])); + expect(afterFirst.nodes.map((n: { id: string }) => n.id)).toEqual(["n2", "n3"]); + + // The resync hands the panel what it just wrote; the next delete starts there. + const second = mount({ "fx-chain": JSON.stringify(afterFirst) }); + act(() => removeButtons(second.host)[0]!.click()); + const afterSecond = JSON.parse(String(second.onSetAttributeQuiet.mock.calls[0][1])); + expect(afterSecond.nodes.map((n: { id: string }) => n.id)).toEqual(["n3"]); + + const third = mount({ "fx-chain": JSON.stringify(afterSecond) }); + act(() => removeButtons(third.host)[0]!.click()); + // The last one leaves no chain at all. + expect(third.onSetAttributeQuiet.mock.calls[0][1]).toBeNull(); + }); + + it("writes with the commit that resyncs the selection, not the silent one", () => { + // Both skip the preview reload; only this one re-reads the selection, which + // is what makes a following edit see the current value. + const { host, onSetAttributeQuiet } = mount({ "fx-chain": three }); + act(() => removeButtons(host)[0]!.click()); + expect(onSetAttributeQuiet).toHaveBeenCalledTimes(1); + expect(onSetAttributeQuiet.mock.calls[0][0]).toBe("data-fx-chain"); + }); +}); + +describe("AudioFxGroup carve visibility", () => { + it("offers carve when the composition holds another audio track", () => { + const { host } = mount({ "fx-chain": CHAIN }); + expect(host.querySelector(".hf-fx-carve")).toBeTruthy(); + }); + + it("does not offer carve for the only audio track in the composition", () => { + // Nothing to listen to, so the picker would be empty and Analyse inert. + const { host } = mount({ "fx-chain": CHAIN }, true); + expect(host.querySelector(".hf-fx-carve")).toBeNull(); + }); +}); + +describe("AudioFxGroup deleting an effect", () => { + const twoNodes = JSON.stringify({ + version: 1, + nodes: [ + { type: "lowpass", id: "n1", params: { frequency: 400, q: 0.9, poles: "2" } }, + { type: "peaking", id: "n2", params: { frequency: 900, gain: -6, q: 1 } }, + ], + }); + + it("takes the deleted node's lanes 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 — disabled and "Automated" without the author + // ever automating it, and baked into the render. + const { host, onSetAttributeQuiet } = mount({ + "fx-chain": twoNodes, + automation: JSON.stringify({ + version: 1, + lanes: [ + { target: "fx.n1.frequency", points: [{ t: 0, v: 400 }] }, + { target: "fx.n2.gain", points: [{ t: 0, v: -6 }] }, + { target: "volume", points: [{ t: 0, v: 1 }] }, + ], + }), + }); + const remove = host.querySelectorAll(".hf-fx-remove")[0]!; + act(() => remove.click()); + const automationWrite = onSetAttributeQuiet.mock.calls.find((c) => c[0] === "data-automation"); + expect(automationWrite).toBeTruthy(); + expect( + JSON.parse(String(automationWrite![1])).lanes.map((l: { target: string }) => l.target), + ).toEqual(["fx.n2.gain", "volume"]); + }); + + it("leaves automation alone when the deleted node had none", () => { + const { host, onSetAttributeQuiet } = mount({ + "fx-chain": twoNodes, + automation: JSON.stringify({ + version: 1, + lanes: [{ target: "fx.n2.gain", points: [{ t: 0, v: -6 }] }], + }), + }); + act(() => host.querySelectorAll(".hf-fx-remove")[0]!.click()); + expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-automation")).toBe(false); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx new file mode 100644 index 0000000000..0a04892e97 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx @@ -0,0 +1,244 @@ +/** + * The audio FX panel's bridge to the element/attribute world. + * + * Chain, carve and automation are all serialised onto the element the way colour + * grading carries its config, so persistence is an ordinary attribute write with + * no new server route. Split out of PropertyPanelFlat, which is at its size + * budget, and self-contained enough to test on its own. + */ + +import { useState } from "react"; +import { + HF_AUDIO_FX_ATTR, + parseAudioFxChain, + serializeAudioFxChain, + type HfAudioFxChain, +} from "@hyperframes/core/audio-fx"; +import { + analyseCarveBands, + carveBandsToChain, + HF_AUDIO_CARVE_ATTR, + normalizeCarveSettings, + type HfCarveSettings, +} from "@hyperframes/core/audio-carve"; +import { fxAutomationTarget, type HfAutomation } from "@hyperframes/core/audio-automation"; +import { + automatedTargetsOf, + automationAttrValue, + HF_AUDIO_AUTOMATION_ATTR, + readPanelAutomation, + resolveAutomationRange, + withoutLane, + withSeededLane, +} from "./propertyPanelAutomation"; +import type { DomEditSelection } from "./domEditingTypes"; + +/** + * Rate the carve source is decoded at. Analysis is self-consistent because it + * reads the decoded buffer's own rate, so this only has to be a sane audio rate. + */ +const DECODE_SAMPLE_RATE = 48000; +import { FxSection, type AudioTrackOption } from "./propertyPanelFxSection.js"; + +/** + * Bridges the FX panel to the element/attribute world. Chain and carve are + * serialised onto the element the way colour grading carries its config, so + * persistence is an ordinary attribute write with no new server route. + */ +export function AudioFxGroup({ + element, + onSetAttributeQuiet, + onSetAttributeLive, +}: { + element: DomEditSelection; + /** + * Every write here is quiet: it persists to the source and skips the preview + * reload, because the runtime applies chain and automation edits to the + * running graph — a reload would only interrupt the audio to reach the same + * state, which is heard as the track chopping. + * + * It does re-read the selection afterwards, which this panel depends on: each + * edit is computed from the current attribute, so without the resync a second + * edit would work from a pre-edit value and appear to do nothing. + */ + onSetAttributeQuiet: (attr: string, value: string | null) => void | Promise; + /** Continuous, non-persisting write for a dial being dragged. */ + onSetAttributeLive: (attr: string, value: string | null) => void | Promise; +}) { + const chain = ((): HfAudioFxChain => { + const raw = element.dataAttributes?.["fx-chain"]; + if (!raw) return { version: 1, nodes: [] }; + try { + return parseAudioFxChain(raw); + } catch { + // Show an unreadable chain as empty rather than breaking the panel; the + // attribute is left untouched until the user changes something. + return { version: 1, nodes: [] }; + } + })(); + + const automation = readPanelAutomation(element.dataAttributes?.["automation"], chain); + const automatedTargets = automatedTargetsOf(automation); + + // Written through the live path on purpose. It persists to the source just + // like the refreshing one, but skips the preview reload — and a reload + // restarts every playing track, which is heard as the audio chopping. The + // runtime follows the attribute and swaps the graph in place instead. + const writeAutomation = (next: HfAutomation): void => { + void onSetAttributeQuiet(HF_AUDIO_AUTOMATION_ATTR, automationAttrValue(next) || null); + }; + + /** + * Start automating one effect parameter. + * + * The lane is seeded with a single point at the value the control currently + * holds, so switching to an envelope never changes the sound — it only moves + * where the value comes from. The author then adds points in the timeline. + */ + const automateParam = (nodeId: string, paramKey: string): void => { + const target = fxAutomationTarget(nodeId, paramKey); + const node = chain.nodes.find((n) => n.id === nodeId); + const range = resolveAutomationRange(target, chain); + if (!node || !range) return; + const raw = node.params?.[paramKey]; + writeAutomation( + withSeededLane(automation, target, typeof raw === "number" ? raw : range.default), + ); + }; + + /** Stop automating it, handing the value back to the panel control. */ + const removeParamAutomation = (nodeId: string, paramKey: string): void => { + writeAutomation(withoutLane(automation, fxAutomationTarget(nodeId, paramKey))); + }; + + /** + * Turn carve on or off. + * + * Switching off drops the filters it generated — left behind they keep dipping + * the bed with nothing in the panel to explain them — but that is a second + * attribute, and each write is a read-modify-write against the same source + * file. Fired together, both read the same content and the later one drops the + * earlier: either the carve settings went and the filters stayed, or the + * reverse. Awaiting the first means the second reads the file it produced. + * + * One commit carrying both would also close the window where a failure of just + * the second leaves them half-applied; that needs a multi-attribute quiet + * commit, which does not exist yet. + */ + const setCarve = async (next: HfCarveSettings | null): Promise => { + if (!next) { + const kept = chain.nodes.filter((n) => !n.fromCarve); + if (kept.length !== chain.nodes.length) { + await onSetAttributeQuiet( + HF_AUDIO_FX_ATTR, + kept.length ? serializeAudioFxChain({ version: 1, nodes: kept }) : null, + ); + } + } + await onSetAttributeQuiet(HF_AUDIO_CARVE_ATTR, next ? JSON.stringify(next) : null); + }; + + /** Every lane belonging to a node that is going away. */ + const removeNodeAutomation = (nodeId: string): void => { + const prefix = `fx.${nodeId}.`; + const kept = automation.lanes.filter((lane) => !lane.target.startsWith(prefix)); + if (kept.length !== automation.lanes.length) { + writeAutomation({ version: 1, lanes: kept }); + } + }; + + const carve = ((): HfCarveSettings | null => { + const raw = element.dataAttributes?.["fx-carve"]; + if (!raw) return null; + try { + return normalizeCarveSettings(JSON.parse(raw)); + } catch { + return null; + } + })(); + + const sourceOptions: AudioTrackOption[] = (() => { + const doc = element.element?.ownerDocument; + if (!doc) return []; + return Array.from(doc.querySelectorAll("audio[id]")) + .filter((a) => a.id !== element.id) + .map((a) => ({ id: a.id, label: a.id })); + })(); + + const [analysing, setAnalysing] = useState(false); + + /** + * Decodes the chosen voice track and turns its spectrum into peaking filters + * on this one. The bands replace any previous carve output but leave + * hand-added effects alone, so re-analysing does not discard other work. + */ + const analyse = async (): Promise => { + if (!carve?.source) return; + const doc = element.element?.ownerDocument; + const voice = doc?.getElementById(carve.source) as HTMLAudioElement | null; + const src = voice?.getAttribute("src"); + if (!src) return; + setAnalysing(true); + try { + const res = await fetch(new URL(src, doc!.baseURI).href); + const bytes = await res.arrayBuffer(); + // Decoded in an OfflineAudioContext, not a live one. Opening a second + // output device mid-playback makes the running track glitch while the + // hardware is reconfigured; an offline context touches no device. + const Ctor = + window.OfflineAudioContext ?? + (window as unknown as { webkitOfflineAudioContext?: typeof OfflineAudioContext }) + .webkitOfflineAudioContext; + if (!Ctor) return; + const decoder = new Ctor(1, 1, DECODE_SAMPLE_RATE); + const buffer = await decoder.decodeAudioData(bytes); + const bands = analyseCarveBands(buffer.getChannelData(0), buffer.sampleRate, carve); + const carved = carveBandsToChain(bands); + // Carve output is tagged so a re-run replaces it instead of stacking. + const kept = chain.nodes.filter((n) => !n.fromCarve); + const next = { + version: 1, + nodes: [...carved.nodes.map((n) => ({ ...n, fromCarve: true })), ...kept], + }; + // Live, like every other chain write: the runtime swaps the graph in + // place, so a reload would only interrupt the audio to reach the same + // filters. + onSetAttributeQuiet(HF_AUDIO_FX_ATTR, serializeAudioFxChain(next)); + } catch { + // Leave the chain as it was; the button simply re-enables. + } finally { + setAnalysing(false); + } + }; + + return ( + + // Live for the same reason as automation above: adding, removing or + // bypassing an effect is applied to the running graph, so a reload would + // only interrupt the audio to reach the same state. + onSetAttributeQuiet( + HF_AUDIO_FX_ATTR, + next.nodes.length ? serializeAudioFxChain(next) : null, + ) + } + onChainPreview={(next) => + // Live writes skip the preview refresh entirely, so dragging a knob no + // longer reloads the composition and restarts playback on every pixel. + // The gesture-end write above is the one that resyncs. + onSetAttributeLive(HF_AUDIO_FX_ATTR, next.nodes.length ? serializeAudioFxChain(next) : null) + } + carve={carve} + onCarveChange={(next) => void setCarve(next)} + onCarvePreview={(next) => onSetAttributeLive(HF_AUDIO_CARVE_ATTR, JSON.stringify(next))} + sourceOptions={sourceOptions} + onAnalyseCarve={() => void analyse()} + analysing={analysing} + /> + ); +} diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx index b77cae9201..bbd20f63ed 100644 --- a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx @@ -45,6 +45,9 @@ function mount(overrides: Partial[0]> = {}) { onAnalyseCarve={overrides.onAnalyseCarve ?? noop} analysing={overrides.analysing} disabled={overrides.disabled} + automatedTargets={overrides.automatedTargets} + onAutomateParam={overrides.onAutomateParam} + onRemoveParamAutomation={overrides.onRemoveParamAutomation} />, ); return { host, onChainChange, onChainPreview, onCarveChange }; @@ -246,3 +249,161 @@ describe("FxSection carve", () => { } }); }); + +describe("automation in the panel", () => { + const automatable = (chain: HfAudioFxChain, over = {}) => + mount({ + chain, + automatedTargets: new Set(), + onAutomateParam: vi.fn(), + onRemoveParamAutomation: vi.fn(), + ...over, + }); + + const idChain = (type: string, id = "n1"): HfAudioFxChain => ({ + version: 1, + nodes: [{ type, id, enabled: true, params: defaultAudioFxParams(type) }], + }); + + const rowFor = (host: HTMLElement, label: string): HTMLElement | null => { + for (const row of Array.from(host.querySelectorAll(".hf-fx-row"))) { + if (row.querySelector(".hf-fx-label")?.textContent === label) return row; + } + return null; + }; + + it("offers an automate button only for parameters an envelope can drive", () => { + // 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")); + expect(rowFor(host, "Output")?.querySelector(".hf-fx-automate")).toBeTruthy(); + expect(rowFor(host, "Threshold")?.querySelector(".hf-fx-automate")).toBeNull(); + }); + + it("offers nothing for a worklet effect, which exposes no AudioParams", () => { + const { host } = automatable(idChain("compressor")); + expect(host.querySelectorAll(".hf-fx-automate").length).toBe(0); + }); + + it("asks to automate a parameter by node id and key", () => { + const onAutomateParam = vi.fn(); + const { host } = automatable(idChain("lowpass", "n7"), { onAutomateParam }); + const button = rowFor(host, "Cutoff")!.querySelector(".hf-fx-automate") as HTMLButtonElement; + expect(button.hasAttribute("title")).toBe(false); + act(() => button.click()); + expect(onAutomateParam).toHaveBeenCalledWith("n7", "frequency"); + }); + + it("disables an automated control, since a value typed here would be overwritten", () => { + const { host } = automatable(idChain("lowpass"), { + automatedTargets: new Set(["fx.n1.frequency"]), + }); + const row = rowFor(host, "Cutoff")!; + 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. + const q = rowFor(host, "Q")!; + expect(q.querySelector('input[type="range"]')?.disabled).toBe(false); + }); + + it("turns the automated parameter's button into a delete", () => { + const onRemoveParamAutomation = vi.fn(); + const { host } = automatable(idChain("lowpass"), { + automatedTargets: new Set(["fx.n1.frequency"]), + onRemoveParamAutomation, + }); + const button = rowFor(host, "Cutoff")!.querySelector(".hf-fx-automate") as HTMLButtonElement; + expect(button.getAttribute("aria-pressed")).toBe("true"); + expect(button.getAttribute("aria-label")).toMatch(/remove/i); + // The wording lives in the Tooltip component, which only renders its bubble + // on hover; the button itself carries no native title hover. + expect(button.hasAttribute("title")).toBe(false); + act(() => button.click()); + expect(onRemoveParamAutomation).toHaveBeenCalledWith("n1", "frequency"); + }); + + it("shows the wording in a tooltip bubble, not a native browser hover", async () => { + vi.useFakeTimers(); + try { + const { host } = automatable(idChain("lowpass"), { + automatedTargets: new Set(["fx.n1.frequency"]), + }); + const button = rowFor(host, "Cutoff")!.querySelector(".hf-fx-automate") as HTMLButtonElement; + // Tooltip positions itself from the trigger's box and gives up on a 0x0 + // one, which is every element in happy-dom. + vi.spyOn(button, "getBoundingClientRect").mockReturnValue({ + x: 100, + y: 300, + left: 100, + top: 300, + right: 116, + bottom: 316, + width: 16, + height: 16, + toJSON: () => ({}), + } as DOMRect); + // React synthesises pointer-enter from pointerover and delegates focus via + // focusin; focus is also how a keyboard user reaches the same tooltip. + act(() => { + button.dispatchEvent(new Event("focusin", { bubbles: true })); + }); + act(() => { + vi.advanceTimersByTime(600); + }); + const bubble = document.querySelector('[role="tooltip"]'); + expect(bubble?.textContent).toBe("Automated"); + } finally { + vi.useRealTimers(); + } + }); + + it("cannot automate a node with no id, which a lane could not address", () => { + const { host } = automatable({ + version: 1, + nodes: [{ type: "lowpass", enabled: true, params: defaultAudioFxParams("lowpass") }], + }); + expect(host.querySelectorAll(".hf-fx-automate").length).toBe(0); + }); + + it("gives a newly added effect an id, so its parameters can be automated", () => { + const onChainChange = vi.fn(); + const { host } = mount({ chain: { version: 1, nodes: [] }, onChainChange }); + 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", + )!; + act(() => item.click()); + expect(onChainChange.mock.calls[0][0].nodes[0].id).toBe("n1"); + }); +}); + +describe("voiceover carve visibility", () => { + const carveBlock = (host: HTMLElement) => host.querySelector(".hf-fx-carve"); + + it("is hidden when the composition has no other audio track to listen to", () => { + // Carve dips this bed where another track's voice sits. Alone, the control + // could only offer an empty picker. + const { host } = mount({ chain: chainOf("lowpass"), sourceOptions: [] }); + expect(carveBlock(host)).toBeNull(); + }); + + it("is shown once there is another audio track", () => { + const { host } = mount({ + chain: chainOf("lowpass"), + sourceOptions: [{ id: "vo", label: "vo" }], + }); + expect(carveBlock(host)).toBeTruthy(); + }); + + it("stays shown for an existing carve whose voice track has gone", () => { + // Otherwise the setting would keep dipping the bed from out of sight. + const { host } = mount({ + chain: chainOf("lowpass"), + sourceOptions: [], + carve: { ...DEFAULT_CARVE, source: "vo" }, + }); + expect(carveBlock(host)).toBeTruthy(); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.tsx index 16e60ea5af..56b7b09132 100644 --- a/packages/studio/src/components/editor/propertyPanelFxSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFxSection.tsx @@ -13,12 +13,15 @@ import { defaultAudioFxParams, getAudioFxDef, HF_AUDIO_FX, + mintAudioFxNodeId, type HfAudioFxChain, + type HfAudioFxDef, type HfAudioFxGroup, type HfAudioFxNode, type HfAudioFxParamValues, } from "@hyperframes/core/audio-fx"; import { DEFAULT_CARVE, type HfCarveSettings } from "@hyperframes/core/audio-carve"; +import { fxAutomationTarget } from "@hyperframes/core/audio-automation"; import { FxParams, FxParamRow } from "./propertyPanelFxControls.js"; const GROUP_ORDER: HfAudioFxGroup[] = ["filter", "dynamics", "nonlinear", "time"]; @@ -37,6 +40,9 @@ export interface AudioTrackOption { interface FxNodeRowProps { node: HfAudioFxNode; index: number; + automatedTargets?: ReadonlySet; + onAutomateParam?(nodeId: string, paramKey: string): void; + onRemoveParamAutomation?(nodeId: string, paramKey: string): void; open: boolean; /** Last in the chain, so it cannot move further down. */ last: boolean; @@ -142,10 +148,73 @@ function FxNodeHeader({ ); } +/** + * Which of an effect's knobs already have a lane. + * + * A lane addresses a node by id, so a node the panel has not yet given one + * cannot be automated at all. Adding an effect mints the id, so this only + * affects chains written before ids existed. + */ +function automatedKeysOf( + node: HfAudioFxNode, + params: readonly { key: string }[], + automatedTargets: ReadonlySet | undefined, +): Set { + if (!node.id || !automatedTargets) return new Set(); + const nodeId = node.id; + return new Set( + params.filter((p) => automatedTargets.has(fxAutomationTarget(nodeId, p.key))).map((p) => p.key), + ); +} + +/** An open effect's knobs, with whatever automation surface applies to them. */ +function FxNodeParams({ + node, + def, + index, + disabled, + automatedTargets, + onUpdate, + onPreview, + onAutomateParam, + onRemoveParamAutomation, +}: { + node: HfAudioFxNode; + def: HfAudioFxDef; + index: number; + disabled: boolean; + automatedTargets?: ReadonlySet; + onUpdate(index: number, patch: Partial): void; + onPreview(index: number, params: HfAudioFxParamValues): void; + onAutomateParam?(nodeId: string, paramKey: string): void; + onRemoveParamAutomation?(nodeId: string, paramKey: string): void; +}) { + const nodeId = node.id; + return ( + onPreview(index, params)} + onCommit={(params: HfAudioFxParamValues) => onUpdate(index, { params })} + automatedKeys={automatedKeysOf(node, def.params, automatedTargets)} + onAutomate={nodeId && onAutomateParam ? (key) => onAutomateParam(nodeId, key) : undefined} + onRemoveAutomation={ + nodeId && onRemoveParamAutomation + ? (key) => onRemoveParamAutomation(nodeId, key) + : undefined + } + /> + ); +} + /** One effect in the chain: its header controls, and its knobs when open. */ function FxNodeRow({ node, index, + automatedTargets, + onAutomateParam, + onRemoveParamAutomation, open, last, disabled, @@ -176,12 +245,16 @@ function FxNodeRow({ onRemove={() => onRemove(index)} /> {open ? ( - onPreview(index, params)} - onCommit={(params: HfAudioFxParamValues) => onUpdate(index, { params })} + index={index} + disabled={Boolean(disabled) || bypassed} + automatedTargets={automatedTargets} + onUpdate={onUpdate} + onPreview={onPreview} + onAutomateParam={onAutomateParam} + onRemoveParamAutomation={onRemoveParamAutomation} /> ) : null} @@ -190,12 +263,24 @@ function FxNodeRow({ export interface FxSectionProps { chain: HfAudioFxChain; + /** Targets this track already automates, as `fx..` strings. */ + automatedTargets?: ReadonlySet; + /** 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; /** 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; /** Other audio elements that could act as the carve source. */ sourceOptions: AudioTrackOption[]; /** Re-run analysis against the current source audio. */ @@ -206,15 +291,27 @@ export interface FxSectionProps { export function FxSection({ chain, + automatedTargets, + onAutomateParam, + onRemoveParamAutomation, + onRemoveNodeAutomation, onChainChange, onChainPreview, carve, onCarveChange, + onCarvePreview, sourceOptions, onAnalyseCarve, analysing, disabled, }: 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. + const showCarve = sourceOptions.length > 0 || carve !== null; + const [adding, setAdding] = useState(false); const [openNode, setOpenNode] = useState(0); @@ -240,11 +337,14 @@ export function FxSection({ const addEffect = useCallback( (type: string) => { - mutate([...chain.nodes, { type, enabled: true, params: defaultAudioFxParams(type) }]); + mutate([ + ...chain.nodes, + { type, id: mintAudioFxNodeId(chain), enabled: true, params: defaultAudioFxParams(type) }, + ]); setOpenNode(chain.nodes.length); setAdding(false); }, - [chain.nodes, mutate], + [chain, mutate], ); const updateNode = useCallback( @@ -255,10 +355,17 @@ export function FxSection({ 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], + [chain.nodes, mutate, onRemoveNodeAutomation], ); const moveNode = useCallback( @@ -287,6 +394,9 @@ export function FxSection({ key={`${node.type}-${i}`} node={node} index={i} + automatedTargets={automatedTargets} + onAutomateParam={onAutomateParam} + onRemoveParamAutomation={onRemoveParamAutomation} open={openNode === i} last={i === chain.nodes.length - 1} disabled={disabled} @@ -332,98 +442,109 @@ export function FxSection({ )} - - - - Voiceover carve - - onCarveChange(carve ? null : { ...DEFAULT_CARVE })} - > - {carve ? "On" : "Off"} - - - {carve ? ( - <> - - - Listen to - - onCarveChange({ ...carve, source: e.target.value })} - > - Select a voice track… - {sourceOptions.map((o) => ( - - {o.label} - - ))} - - - onCarveChange({ ...carve, maxCutDb: Number(v) })} - /> - onCarveChange({ ...carve, bands: Number(v) })} - /> - onCarveChange({ ...carve, intelligibilityBias: Number(v) })} - /> + {/* Carve is a relationship between two tracks: it dips this bed where + another track's voice sits. With no other audio track in the + composition there is nothing to listen to, so the control would only + offer an empty picker. Still shown when carve is already configured, + so an existing setting cannot be stranded out of sight after its voice + track is removed. */} + {showCarve ? ( + + + + Voiceover carve + onAnalyseCarve?.()} + className="hf-fx-bypass rounded-[3px] border border-panel-border-input px-1.5 py-0.5 font-mono text-[9px] text-panel-text-4 hover:text-panel-text-0 disabled:opacity-40" + aria-pressed={carve !== null} + disabled={disabled} + onClick={() => onCarveChange(carve ? null : { ...DEFAULT_CARVE })} > - {analysing ? "Analysing…" : "Analyse and apply"} + {carve ? "On" : "Off"} - > - ) : null} - + + {carve ? ( + <> + + + Listen to + + onCarveChange({ ...carve, source: e.target.value })} + > + Select a voice track… + {sourceOptions.map((o) => ( + + {o.label} + + ))} + + + previewCarve({ ...carve, maxCutDb: Number(v) })} + onCommit={(_k, v) => onCarveChange({ ...carve, maxCutDb: Number(v) })} + /> + previewCarve({ ...carve, bands: Number(v) })} + onCommit={(_k, v) => onCarveChange({ ...carve, bands: Number(v) })} + /> + previewCarve({ ...carve, intelligibilityBias: Number(v) })} + onCommit={(_k, v) => onCarveChange({ ...carve, intelligibilityBias: Number(v) })} + /> + onAnalyseCarve?.()} + > + {analysing ? "Analysing…" : "Analyse and apply"} + + > + ) : null} + + ) : null} ); }