Skip to content

Commit de15099

Browse files
committed
fix(studio): keep FX panel writes from clobbering each other
Three writes in the audio panel each read the source file, mutate one attribute and write it back. Fired without ordering they read the same content and the last one lands, dropping the others. - Deleting an effect left its automation lanes in the attribute. Ids are minted lowest-free, so the next effect added took the same id and inherited the dead envelope: disabled and "Automated" without the author ever automating it, and baked into the render. - Switching carve off wrote the chain (dropping the filters it generated) and the carve settings at once, so either the filters stayed with no carve to explain them or the settings survived with no filters. - The three carve dials committed per input event, patching the source and resyncing the selection dozens of times per drag. They now preview live and persist on release, like the FX knobs already do. Volume automation reads through the quiet commit too, so removing a lane resyncs the panel instead of leaving the slider disabled.
1 parent 5752936 commit de15099

4 files changed

Lines changed: 160 additions & 30 deletions

File tree

packages/studio/src/components/editor/PropertyPanelFlat.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ export function PropertyPanelFlat({
257257
const showMotionEffects = gsapEffectHandlers !== null;
258258
const showMotionGroup = showMotionTiming || showMotionEffects;
259259

260-
const volumeAutomation = useVolumeAutomation(element, onSetAttribute);
260+
const volumeAutomation = useVolumeAutomation(element, onSetAttributeQuiet ?? onSetAttributeLive);
261261

262262
const groups: FlatGroupDescriptor[] = [];
263263
if (isTextEditable) {
@@ -436,6 +436,7 @@ export function PropertyPanelFlat({
436436
<AudioFxGroup
437437
element={element}
438438
onSetAttributeQuiet={onSetAttributeQuiet ?? onSetAttributeLive}
439+
onSetAttributeLive={onSetAttributeLive}
439440
/>
440441
),
441442
});

packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx

Lines changed: 86 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,15 +40,20 @@ function mount(dataAttributes: Record<string, string>, alone = false) {
4040
// restart every playing track, but with a selection resync so the panel sees
4141
// what it just wrote.
4242
const onSetAttributeQuiet = vi.fn();
43+
const onSetAttributeLive = vi.fn();
4344
const host = document.createElement("div");
4445
document.body.append(host);
4546
const selection = audioSelection(dataAttributes, alone);
4647
act(() => {
4748
createRoot(host).render(
48-
<AudioFxGroup element={selection} onSetAttributeQuiet={onSetAttributeQuiet} />,
49+
<AudioFxGroup
50+
element={selection}
51+
onSetAttributeQuiet={onSetAttributeQuiet}
52+
onSetAttributeLive={onSetAttributeLive}
53+
/>,
4954
);
5055
});
51-
return { host, onSetAttributeQuiet };
56+
return { host, onSetAttributeQuiet, onSetAttributeLive };
5257
}
5358

5459
const rowFor = (host: HTMLElement, label: string): HTMLElement | null => {
@@ -182,18 +187,26 @@ describe("AudioFxGroup carve", () => {
182187
return block.querySelector(".hf-fx-bypass") as HTMLButtonElement;
183188
};
184189

185-
it("removes the filters it generated when carve is switched off", () => {
190+
it("removes the filters it generated when carve is switched off", async () => {
186191
// Leaving them behind would keep dipping the bed with no carve to explain it.
187192
const { host, onSetAttributeQuiet } = mount({
188193
"fx-chain": carvedChain,
189194
"fx-carve": carveOn,
190195
});
191-
act(() => carveToggle(host).click());
196+
await act(async () => {
197+
carveToggle(host).click();
198+
});
192199
const chainWrite = onSetAttributeQuiet.mock.calls.find((c) => c[0] === "data-fx-chain");
193200
expect(chainWrite).toBeTruthy();
194201
const kept = JSON.parse(String(chainWrite![1])).nodes;
195202
expect(kept.map((n: { type: string }) => n.type)).toEqual(["lowpass"]);
196-
// And the carve settings themselves go.
203+
// And the carve settings themselves go — after the chain write, not
204+
// alongside it: both are read-modify-writes of the same file, so fired
205+
// together the later one reads pre-edit content and drops the earlier.
206+
expect(onSetAttributeQuiet.mock.calls.map((c) => c[0])).toEqual([
207+
"data-fx-chain",
208+
"data-fx-carve",
209+
]);
197210
expect(onSetAttributeQuiet.mock.calls.find((c) => c[0] === "data-fx-carve")?.[1]).toBeNull();
198211
});
199212

@@ -207,6 +220,27 @@ describe("AudioFxGroup carve", () => {
207220
expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-chain")).toBe(false);
208221
});
209222

223+
it("drags a carve dial live and persists once on release", () => {
224+
// Without the split every pointermove patched the source file and resynced
225+
// the selection, which is what makes the audio stutter mid-drag.
226+
const { host, onSetAttributeQuiet, onSetAttributeLive } = mount({
227+
"fx-chain": carvedChain,
228+
"fx-carve": carveOn,
229+
});
230+
const dial = host.querySelector<HTMLInputElement>(".hf-fx-carve input[type=range]");
231+
expect(dial).not.toBeNull();
232+
act(() => {
233+
// React's value tracker swallows a plain assignment, so go through the
234+
// prototype setter the way the other panel tests do.
235+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(dial, "0.5");
236+
dial?.dispatchEvent(new Event("input", { bubbles: true }));
237+
});
238+
expect(onSetAttributeLive.mock.calls.map((c) => c[0])).toEqual(["data-fx-carve"]);
239+
expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-carve")).toBe(false);
240+
act(() => dial?.dispatchEvent(new PointerEvent("pointerup", { bubbles: true })));
241+
expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-carve")).toBe(true);
242+
});
243+
210244
it("writes carve settings live, so enabling it does not reload the preview", () => {
211245
const { host, onSetAttributeQuiet } = mount({ "fx-chain": carvedChain });
212246
act(() => carveToggle(host).click());
@@ -275,3 +309,50 @@ describe("AudioFxGroup carve visibility", () => {
275309
expect(host.querySelector(".hf-fx-carve")).toBeNull();
276310
});
277311
});
312+
313+
describe("AudioFxGroup deleting an effect", () => {
314+
const twoNodes = JSON.stringify({
315+
version: 1,
316+
nodes: [
317+
{ type: "lowpass", id: "n1", params: { frequency: 400, q: 0.9, poles: "2" } },
318+
{ type: "peaking", id: "n2", params: { frequency: 900, gain: -6, q: 1 } },
319+
],
320+
});
321+
322+
it("takes the deleted node's lanes with it", () => {
323+
// resolveAutomation only hides an orphan at read time. Left in the attribute,
324+
// and with ids minted lowest-free, the next effect added takes the same id and
325+
// inherits the dead envelope — disabled and "Automated" without the author
326+
// ever automating it, and baked into the render.
327+
const { host, onSetAttributeQuiet } = mount({
328+
"fx-chain": twoNodes,
329+
automation: JSON.stringify({
330+
version: 1,
331+
lanes: [
332+
{ target: "fx.n1.frequency", points: [{ t: 0, v: 400 }] },
333+
{ target: "fx.n2.gain", points: [{ t: 0, v: -6 }] },
334+
{ target: "volume", points: [{ t: 0, v: 1 }] },
335+
],
336+
}),
337+
});
338+
const remove = host.querySelectorAll<HTMLButtonElement>(".hf-fx-remove")[0]!;
339+
act(() => remove.click());
340+
const automationWrite = onSetAttributeQuiet.mock.calls.find((c) => c[0] === "data-automation");
341+
expect(automationWrite).toBeTruthy();
342+
expect(
343+
JSON.parse(String(automationWrite![1])).lanes.map((l: { target: string }) => l.target),
344+
).toEqual(["fx.n2.gain", "volume"]);
345+
});
346+
347+
it("leaves automation alone when the deleted node had none", () => {
348+
const { host, onSetAttributeQuiet } = mount({
349+
"fx-chain": twoNodes,
350+
automation: JSON.stringify({
351+
version: 1,
352+
lanes: [{ target: "fx.n2.gain", points: [{ t: 0, v: -6 }] }],
353+
}),
354+
});
355+
act(() => host.querySelectorAll<HTMLButtonElement>(".hf-fx-remove")[0]!.click());
356+
expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-automation")).toBe(false);
357+
});
358+
});

packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx

Lines changed: 46 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import { FxSection, type AudioTrackOption } from "./propertyPanelFxSection.js";
4848
export function AudioFxGroup({
4949
element,
5050
onSetAttributeQuiet,
51+
onSetAttributeLive,
5152
}: {
5253
element: DomEditSelection;
5354
/**
@@ -61,6 +62,8 @@ export function AudioFxGroup({
6162
* edit would work from a pre-edit value and appear to do nothing.
6263
*/
6364
onSetAttributeQuiet: (attr: string, value: string | null) => void | Promise<void>;
65+
/** Continuous, non-persisting write for a dial being dragged. */
66+
onSetAttributeLive: (attr: string, value: string | null) => void | Promise<void>;
6467
}) {
6568
const chain = ((): HfAudioFxChain => {
6669
const raw = element.dataAttributes?.["fx-chain"];
@@ -108,6 +111,42 @@ export function AudioFxGroup({
108111
writeAutomation(withoutLane(automation, fxAutomationTarget(nodeId, paramKey)));
109112
};
110113

114+
/**
115+
* Turn carve on or off.
116+
*
117+
* Switching off drops the filters it generated — left behind they keep dipping
118+
* the bed with nothing in the panel to explain them — but that is a second
119+
* attribute, and each write is a read-modify-write against the same source
120+
* file. Fired together, both read the same content and the later one drops the
121+
* earlier: either the carve settings went and the filters stayed, or the
122+
* reverse. Awaiting the first means the second reads the file it produced.
123+
*
124+
* One commit carrying both would also close the window where a failure of just
125+
* the second leaves them half-applied; that needs a multi-attribute quiet
126+
* commit, which does not exist yet.
127+
*/
128+
const setCarve = async (next: HfCarveSettings | null): Promise<void> => {
129+
if (!next) {
130+
const kept = chain.nodes.filter((n) => !n.fromCarve);
131+
if (kept.length !== chain.nodes.length) {
132+
await onSetAttributeQuiet(
133+
HF_AUDIO_FX_ATTR,
134+
kept.length ? serializeAudioFxChain({ version: 1, nodes: kept }) : null,
135+
);
136+
}
137+
}
138+
await onSetAttributeQuiet(HF_AUDIO_CARVE_ATTR, next ? JSON.stringify(next) : null);
139+
};
140+
141+
/** Every lane belonging to a node that is going away. */
142+
const removeNodeAutomation = (nodeId: string): void => {
143+
const prefix = `fx.${nodeId}.`;
144+
const kept = automation.lanes.filter((lane) => !lane.target.startsWith(prefix));
145+
if (kept.length !== automation.lanes.length) {
146+
writeAutomation({ version: 1, lanes: kept });
147+
}
148+
};
149+
111150
const carve = ((): HfCarveSettings | null => {
112151
const raw = element.dataAttributes?.["fx-carve"];
113152
if (!raw) return null;
@@ -178,6 +217,7 @@ export function AudioFxGroup({
178217
automatedTargets={automatedTargets}
179218
onAutomateParam={automateParam}
180219
onRemoveParamAutomation={removeParamAutomation}
220+
onRemoveNodeAutomation={removeNodeAutomation}
181221
onChainChange={(next) =>
182222
// Live for the same reason as automation above: adding, removing or
183223
// bypassing an effect is applied to the running graph, so a reload would
@@ -188,28 +228,14 @@ export function AudioFxGroup({
188228
)
189229
}
190230
onChainPreview={(next) =>
191-
// Live writes skip the preview refresh, so dragging a knob no longer
192-
// reloads the composition and restarts playback on every pixel.
193-
onSetAttributeQuiet(
194-
HF_AUDIO_FX_ATTR,
195-
next.nodes.length ? serializeAudioFxChain(next) : null,
196-
)
231+
// Live writes skip the preview refresh entirely, so dragging a knob no
232+
// longer reloads the composition and restarts playback on every pixel.
233+
// The gesture-end write above is the one that resyncs.
234+
onSetAttributeLive(HF_AUDIO_FX_ATTR, next.nodes.length ? serializeAudioFxChain(next) : null)
197235
}
198236
carve={carve}
199-
onCarveChange={(next) => {
200-
// Turning carve off drops the filters it generated; leaving them behind
201-
// would keep dipping the bed with no carve to explain it.
202-
if (!next) {
203-
const kept = chain.nodes.filter((n) => !n.fromCarve);
204-
if (kept.length !== chain.nodes.length) {
205-
onSetAttributeQuiet(
206-
HF_AUDIO_FX_ATTR,
207-
kept.length ? serializeAudioFxChain({ version: 1, nodes: kept }) : null,
208-
);
209-
}
210-
}
211-
void onSetAttributeQuiet(HF_AUDIO_CARVE_ATTR, next ? JSON.stringify(next) : null);
212-
}}
237+
onCarveChange={(next) => void setCarve(next)}
238+
onCarvePreview={(next) => onSetAttributeLive(HF_AUDIO_CARVE_ATTR, JSON.stringify(next))}
213239
sourceOptions={sourceOptions}
214240
onAnalyseCarve={() => void analyse()}
215241
analysing={analysing}

packages/studio/src/components/editor/propertyPanelFxSection.tsx

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -269,12 +269,18 @@ export interface FxSectionProps {
269269
onAutomateParam?(nodeId: string, paramKey: string): void;
270270
/** Delete one effect parameter's lane. */
271271
onRemoveParamAutomation?(nodeId: string, paramKey: string): void;
272+
/** Delete every lane belonging to a node that is being removed. */
273+
onRemoveNodeAutomation?(nodeId: string): void;
272274
/** Structural edits and gesture-end writes; this is the one that persists. */
273275
onChainChange(chain: HfAudioFxChain): void;
274276
/** Continuous updates while a control is being dragged. */
275277
onChainPreview?(chain: HfAudioFxChain): void;
276278
carve: HfCarveSettings | null;
279+
/** Gesture-end write; this is the one that persists. */
277280
onCarveChange(carve: HfCarveSettings | null): void;
281+
/** Continuous updates while a carve slider is dragged. Without this every
282+
* pointermove patched the source file and resynced the selection. */
283+
onCarvePreview?(carve: HfCarveSettings): void;
278284
/** Other audio elements that could act as the carve source. */
279285
sourceOptions: AudioTrackOption[];
280286
/** Re-run analysis against the current source audio. */
@@ -288,15 +294,21 @@ export function FxSection({
288294
automatedTargets,
289295
onAutomateParam,
290296
onRemoveParamAutomation,
297+
onRemoveNodeAutomation,
291298
onChainChange,
292299
onChainPreview,
293300
carve,
294301
onCarveChange,
302+
onCarvePreview,
295303
sourceOptions,
296304
onAnalyseCarve,
297305
analysing,
298306
disabled,
299307
}: FxSectionProps) {
308+
// Falls back to the persisting write when no preview handler is supplied, which
309+
// keeps the control working rather than going dead.
310+
const previewCarve = onCarvePreview ?? onCarveChange;
311+
300312
// Nothing to carve against means nothing to show — see the block below.
301313
const showCarve = sourceOptions.length > 0 || carve !== null;
302314

@@ -343,10 +355,17 @@ export function FxSection({
343355

344356
const removeNode = useCallback(
345357
(index: number) => {
358+
// The node's lanes go with it. `resolveAutomation` only hides an orphan at
359+
// read time; left in the attribute, and with ids minted lowest-free, the
360+
// next effect added takes the same id and inherits the dead envelope —
361+
// arriving with its control disabled and "Automated" without the author
362+
// ever automating it, and baked into the render.
363+
const removedId = chain.nodes[index]?.id;
364+
if (removedId) onRemoveNodeAutomation?.(removedId);
346365
mutate(chain.nodes.filter((_, i) => i !== index));
347366
setOpenNode(null);
348367
},
349-
[chain.nodes, mutate],
368+
[chain.nodes, mutate, onRemoveNodeAutomation],
350369
);
351370

352371
const moveNode = useCallback(
@@ -478,7 +497,8 @@ export function FxSection({
478497
}}
479498
value={carve.maxCutDb}
480499
disabled={disabled}
481-
onChange={(_k, v) => onCarveChange({ ...carve, maxCutDb: Number(v) })}
500+
onChange={(_k, v) => previewCarve({ ...carve, maxCutDb: Number(v) })}
501+
onCommit={(_k, v) => onCarveChange({ ...carve, maxCutDb: Number(v) })}
482502
/>
483503
<FxParamRow
484504
param={{
@@ -493,7 +513,8 @@ export function FxSection({
493513
}}
494514
value={carve.bands}
495515
disabled={disabled}
496-
onChange={(_k, v) => onCarveChange({ ...carve, bands: Number(v) })}
516+
onChange={(_k, v) => previewCarve({ ...carve, bands: Number(v) })}
517+
onCommit={(_k, v) => onCarveChange({ ...carve, bands: Number(v) })}
497518
/>
498519
<FxParamRow
499520
param={{
@@ -509,7 +530,8 @@ export function FxSection({
509530
}}
510531
value={carve.intelligibilityBias}
511532
disabled={disabled}
512-
onChange={(_k, v) => onCarveChange({ ...carve, intelligibilityBias: Number(v) })}
533+
onChange={(_k, v) => previewCarve({ ...carve, intelligibilityBias: Number(v) })}
534+
onCommit={(_k, v) => onCarveChange({ ...carve, intelligibilityBias: Number(v) })}
513535
/>
514536
<button
515537
type="button"

0 commit comments

Comments
 (0)