fix(studio): lock the FX rack while the carve is measuring

`analyse` captures the chain and the automation before its fetch and decode,
then rewrites the whole `data-fx-chain` from that snapshot. Anything committed
during those seconds — adding an effect, moving a knob — landed first and was
silently discarded when the analysis returned. Only the Analyse control was
gated on `analysing`; every other control in the rack stayed live throughout.

FxSection already accepts `disabled` and threads it to every row; the carve
panel simply never passed it. Refusing the edit for the duration is the honest
answer — merging it into a measurement that did not account for it is not, and
last-write-wins across an async gap is what this was.

The test had to stub OfflineAudioContext as well as fetch: without a
constructor, `analyse` returns before the decode and the window under test
never opens, which is why the existing carve tests never saw this.

Does not cover a write arriving from somewhere other than this panel — a
timeline lane edit, or an undo — during the same gap. That needs the write to
re-read rather than the reader to lock.
This commit is contained in:
Vance Ingalls
2026-08-08 11:10:55 -07:00
parent 05d081c61d
commit 371920695c
2 changed files with 71 additions and 0 deletions
@@ -1456,3 +1456,66 @@ describe("AudioFxGroup carve against a deleted voice", () => {
).toEqual(["fx.k1.frequency"]);
});
});
describe("AudioFxGroup while the carve is measuring", () => {
/**
* `analyse` captures the chain and the automation before its fetch and decode,
* then rewrites the whole `data-fx-chain` from that snapshot. An effect added
* — or a knob committed — during those seconds landed first and was silently
* discarded when the analysis returned. Only the Analyse control was gated;
* every other control in the rack stayed live throughout.
*/
it("locks the rack, so an edit cannot be made against a snapshot that is moving", async () => {
// A fetch that never settles holds the panel in its analysing state, which
// is exactly the window the race lives in.
const hang = vi.fn(() => new Promise<Response>(() => {}));
vi.stubGlobal("fetch", hang);
// happy-dom has no Web Audio, and without a constructor `analyse` returns
// before it ever reaches the decode — closing the window under test.
vi.stubGlobal(
"OfflineAudioContext",
class {
decodeAudioData() {
return new Promise(() => {});
}
},
);
try {
const bed = document.createElement("audio");
bed.id = "bed";
bed.setAttribute("src", "bed.wav");
document.body.append(bed);
const voice = document.createElement("audio");
voice.id = "narration";
voice.setAttribute("src", "vo.wav");
document.body.append(voice);
const host = document.createElement("div");
document.body.append(host);
await act(async () => {
createRoot(host).render(
<AudioFxGroup
element={
{
dataAttributes: { "fx-chain": CHAIN },
id: "bed",
element: bed,
} as unknown as DomEditSelection
}
onSetAttributeQuiet={vi.fn()}
onSetAttributeLive={vi.fn()}
/>,
);
});
// The bed carves itself against its one candidate, which starts the
// decode — and the whole rack goes read-only until it lands.
expect(hang).toHaveBeenCalled();
const controls = Array.from(host.querySelectorAll<HTMLInputElement>(".hf-fx-slider"));
expect(controls.length).toBeGreaterThan(0);
expect(controls.every((c) => c.disabled)).toBe(true);
} finally {
vi.unstubAllGlobals();
}
});
});
@@ -609,6 +609,14 @@ export function AudioFxGroup({
return (
<FxSection
// Locked while the carve is measuring. `analyse` captures the chain and
// the automation BEFORE its fetch and decode, then rewrites the whole
// attribute from that snapshot — so an effect added, or a knob committed,
// during those seconds was silently discarded when the analysis landed.
// Only the Analyse button was disabled, so every other control in the rack
// stayed live throughout. Refusing the edit is honest; merging it into a
// measurement that did not account for it would not be.
disabled={analysing}
chain={chain}
automatedTargets={automatedTargets}
liveAutomationValues={liveAutomationValues}