mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-14 21:29:34 +00:00
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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user