From 498d03aca6bc942919b91e131f11a430bd28e9c5 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 4 Aug 2026 10:42:21 -0700 Subject: [PATCH 1/4] feat(studio): automate and un-automate each effect parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-parameter surface in the FX panel. An automated parameter's control is disabled — a value typed there would be overwritten by the envelope on the next tick, so the lane is the value now — and the toggle beside it adds or deletes that parameter's lane. Adding seeds the lane with a single point at the value the control already holds, so switching to an envelope never changes the sound, only where the value comes from. Parameters no envelope can drive have no toggle at all: the worklet-backed dynamics expose no AudioParams, a WaveShaper's curve and a convolution impulse are rebuilt wholesale rather than scheduled. Neither does a chain node with no id, since a lane addresses nodes by id — so adding an effect now mints one. Carve moves onto the same non-reloading write, and decodes its source in an `OfflineAudioContext`: opening a second output device mid-playback makes the running track glitch while the hardware is reconfigured. Turning carve off now also drops the filters it generated, which otherwise kept dipping the bed with nothing in the panel to explain it. `AudioFxGroup` moves into its own module — PropertyPanelFlat was at its size budget — which also gave the panel's write behaviour somewhere to be tested: what it writes, seeded at the current value, preserving the lanes it is not touching, and clearing the attribute when the last one goes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../components/editor/PropertyPanelFlat.tsx | 133 +--------- .../editor/propertyPanelAudioFxGroup.test.tsx | 241 ++++++++++++++++++ .../editor/propertyPanelAudioFxGroup.tsx | 218 ++++++++++++++++ .../editor/propertyPanelFxSection.test.tsx | 132 ++++++++++ .../editor/propertyPanelFxSection.tsx | 102 +++++++- 5 files changed, 694 insertions(+), 132 deletions(-) create mode 100644 packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx create mode 100644 packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx diff --git a/packages/studio/src/components/editor/PropertyPanelFlat.tsx b/packages/studio/src/components/editor/PropertyPanelFlat.tsx index dc3f4f7b3f..0b3d963e62 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, onSetAttribute); + const groups: FlatGroupDescriptor[] = []; if (isTextEditable) { groups.push({ @@ -443,8 +435,7 @@ export function PropertyPanelFlat({ content: ( ), }); @@ -463,6 +454,7 @@ export function PropertyPanelFlat({ onSetAttribute={onSetAttribute} onSetHtmlAttribute={onSetHtmlAttribute} onRemoveBackground={onRemoveBackground} + {...volumeAutomation} /> ), }); @@ -561,112 +553,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..974215bf8a --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx @@ -0,0 +1,241 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { 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" } }], +}); + +function mount(dataAttributes: Record) { + // 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 host = document.createElement("div"); + document.body.append(host); + const selection = { dataAttributes, element: null } as unknown as DomEditSelection; + act(() => { + createRoot(host).render( + , + ); + }); + return { host, onSetAttributeQuiet }; +} + +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", () => { + // Leaving them behind would keep dipping the bed with no carve to explain it. + const { host, onSetAttributeQuiet } = mount({ + "fx-chain": carvedChain, + "fx-carve": carveOn, + }); + act(() => 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. + 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("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"); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx new file mode 100644 index 0000000000..ac34e39c95 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx @@ -0,0 +1,218 @@ +/** + * 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, +}: { + 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; +}) { + 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))); + }; + + 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, so dragging a knob no longer + // reloads the composition and restarts playback on every pixel. + onSetAttributeQuiet( + HF_AUDIO_FX_ATTR, + next.nodes.length ? serializeAudioFxChain(next) : null, + ) + } + carve={carve} + onCarveChange={(next) => { + // Turning carve off drops the filters it generated; leaving them behind + // would keep dipping the bed with no carve to explain it. + if (!next) { + const kept = chain.nodes.filter((n) => !n.fromCarve); + if (kept.length !== chain.nodes.length) { + onSetAttributeQuiet( + HF_AUDIO_FX_ATTR, + kept.length ? serializeAudioFxChain({ version: 1, nodes: kept }) : null, + ); + } + } + void onSetAttributeQuiet(HF_AUDIO_CARVE_ATTR, next ? JSON.stringify(next) : null); + }} + 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..f278ec82ec 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,132 @@ 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"); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.tsx index 16e60ea5af..d7c97946cb 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,6 +263,12 @@ 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; /** Structural edits and gesture-end writes; this is the one that persists. */ onChainChange(chain: HfAudioFxChain): void; /** Continuous updates while a control is being dragged. */ @@ -206,6 +285,9 @@ export interface FxSectionProps { export function FxSection({ chain, + automatedTargets, + onAutomateParam, + onRemoveParamAutomation, onChainChange, onChainPreview, carve, @@ -240,11 +322,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( @@ -287,6 +372,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} From e838eaa528e89b07b574e19aba6cf97c05edc4a6 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 4 Aug 2026 12:56:17 -0700 Subject: [PATCH 2/4] feat(studio): only offer voiceover carve when there is a voice to carve against MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carve is a relationship between two tracks — it analyses another track's voice and dips this bed where that voice sits. In a composition with a single audio track there is nothing to listen to, so the block offered an empty source picker and an Analyse button that could never do anything. It is now shown only when the composition holds another audio track, and still shown when carve is already configured: hiding a live setting because its voice track was removed would leave the bed being dipped from out of sight. Co-Authored-By: Claude Opus 5 (1M context) --- .../editor/propertyPanelFxSection.test.tsx | 29 +++ .../editor/propertyPanelFxSection.tsx | 187 +++++++++--------- 2 files changed, 128 insertions(+), 88 deletions(-) diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx index f278ec82ec..bbd20f63ed 100644 --- a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx @@ -378,3 +378,32 @@ describe("automation in the panel", () => { 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 d7c97946cb..d5626d2d6b 100644 --- a/packages/studio/src/components/editor/propertyPanelFxSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFxSection.tsx @@ -297,6 +297,9 @@ export function FxSection({ analysing, disabled, }: FxSectionProps) { + // 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); @@ -420,98 +423,106 @@ export function FxSection({ )} -
-
- - Voiceover carve - - -
- {carve ? ( - <> - - 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 + - - ) : null} -
+
+ {carve ? ( + <> + + onCarveChange({ ...carve, maxCutDb: Number(v) })} + /> + onCarveChange({ ...carve, bands: Number(v) })} + /> + onCarveChange({ ...carve, intelligibilityBias: Number(v) })} + /> + + + ) : null} +
+ ) : null} ); } From afee1e22f15fb10e4910eddab1bbacb28d496096 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 4 Aug 2026 13:21:33 -0700 Subject: [PATCH 3/4] test(studio): cover carve visibility through the real element MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel derives carve's source list from the selected element's document, so a selection with no element has no sources — which the new visibility rule correctly reads as 'nothing to carve against'. The suite mounted exactly that, so it was asserting on a hidden block. Selections now carry a real