@hyperframes/studio 0.7.107 → 0.7.109
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-BLrfwq5o.js → hyperframes-player-BYgqAPib.js} +1 -1
- package/dist/assets/{index-BSBk5srs.js → index-Blxia-Lt.js} +1 -1
- package/dist/assets/{index-BA9yhzfR.js → index-CIRxodzK.js} +1 -1
- package/dist/assets/index-CfBGQdrX.js +428 -0
- package/dist/assets/index-DNkh9mbV.css +1 -0
- package/dist/index.d.ts +85 -2
- package/dist/index.html +2 -2
- package/dist/index.js +17696 -12512
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioRightPanel.tsx +18 -1
- package/src/components/TimelineToolbar.test.tsx +46 -0
- package/src/components/TimelineToolbar.tsx +17 -0
- package/src/components/editor/PropertyPanel.test.tsx +5 -1
- package/src/components/editor/PropertyPanel.tsx +7 -23
- package/src/components/editor/PropertyPanelFlat.tsx +25 -0
- package/src/components/editor/audioFxSummary.test.ts +75 -0
- package/src/components/editor/audioFxSummary.ts +36 -0
- package/src/components/editor/audioFxTelemetry.test.ts +129 -0
- package/src/components/editor/audioFxTelemetry.ts +230 -0
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +1830 -0
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +299 -0
- package/src/components/editor/propertyPanelAudioFxGroupUtils.ts +21 -0
- package/src/components/editor/propertyPanelAutomation.test.ts +60 -0
- package/src/components/editor/propertyPanelAutomation.ts +97 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +37 -9
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelFxAddMenu.tsx +209 -0
- package/src/components/editor/propertyPanelFxBandRuler.tsx +72 -0
- package/src/components/editor/propertyPanelFxCarveModule.tsx +382 -0
- package/src/components/editor/propertyPanelFxControls.test.tsx +115 -0
- package/src/components/editor/propertyPanelFxControls.tsx +356 -0
- package/src/components/editor/propertyPanelFxEqModule.tsx +196 -0
- package/src/components/editor/propertyPanelFxFamily.ts +83 -0
- package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +269 -0
- package/src/components/editor/propertyPanelFxNodeParams.tsx +112 -0
- package/src/components/editor/propertyPanelFxNodeRow.tsx +339 -0
- package/src/components/editor/propertyPanelFxPresetMenu.tsx +159 -0
- package/src/components/editor/propertyPanelFxPresetRun.tsx +215 -0
- package/src/components/editor/propertyPanelFxPresetStyle.test.ts +142 -0
- package/src/components/editor/propertyPanelFxPresetStyle.ts +216 -0
- package/src/components/editor/propertyPanelFxRackChain.tsx +202 -0
- package/src/components/editor/propertyPanelFxSection.test.tsx +1747 -0
- package/src/components/editor/propertyPanelFxSection.tsx +550 -0
- package/src/components/editor/propertyPanelFxSectionTypes.ts +91 -0
- package/src/components/editor/propertyPanelTypes.ts +5 -0
- package/src/components/editor/useFxAudition.ts +95 -0
- package/src/components/editor/useFxCarve.ts +0 -0
- package/src/components/editor/useFxChainObserved.ts +83 -0
- package/src/components/editor/useFxLevelling.ts +235 -0
- package/src/components/editor/useVolumeAutomation.test.tsx +122 -0
- package/src/components/editor/useVolumeAutomation.ts +54 -0
- package/src/contexts/DomEditContext.tsx +11 -0
- package/src/hooks/useAppHotkeys.test.ts +198 -0
- package/src/hooks/useAppHotkeys.ts +30 -3
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +429 -0
- package/src/hooks/useAutomationSelectionKeyboard.ts +371 -0
- package/src/hooks/useDomEditAttributeCommits.ts +90 -10
- package/src/hooks/useDomEditCommits.test.tsx +38 -0
- package/src/hooks/useDomEditCommits.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -0
- package/src/hooks/useDomEditTextCommits.ts +2 -0
- package/src/hooks/useElementLifecycleOps.ts +3 -0
- package/src/hooks/useLivePlayheadTime.ts +49 -0
- package/src/hooks/usePreviewPersistence.ts +7 -0
- package/src/hooks/useTimelineEditing.ts +3 -0
- package/src/player/components/AutomationSelectionMenu.tsx +76 -0
- package/src/player/components/AutomationValueInput.tsx +51 -0
- package/src/player/components/LayerDisclosureRow.tsx +5 -4
- package/src/player/components/Timeline.test.ts +84 -0
- package/src/player/components/TimelineAutomationLane.test.tsx +1720 -0
- package/src/player/components/TimelineAutomationLane.tsx +674 -0
- package/src/player/components/TimelineAutomationLaneSlot.test.tsx +193 -0
- package/src/player/components/TimelineLanes.test.tsx +24 -5
- package/src/player/components/TimelineLanes.tsx +142 -154
- package/src/player/components/TimelineTrackHeader.test.tsx +212 -1
- package/src/player/components/TimelineTrackHeader.tsx +166 -6
- package/src/player/components/TimelineTrackRow.tsx +8 -1
- package/src/player/components/automationClipboard.test.ts +94 -0
- package/src/player/components/automationClipboard.ts +136 -0
- package/src/player/components/automationGestureKeys.test.ts +49 -0
- package/src/player/components/automationGestureKeys.ts +49 -0
- package/src/player/components/automationLaneData.test.ts +270 -0
- package/src/player/components/automationLaneData.ts +258 -0
- package/src/player/components/automationLaneDragMath.ts +194 -0
- package/src/player/components/automationLaneGeometry.test.ts +239 -0
- package/src/player/components/automationLaneGeometry.ts +287 -0
- package/src/player/components/automationLaneHeight.ts +15 -0
- package/src/player/components/automationLanePointer.ts +20 -0
- package/src/player/components/automationLaneSelection.test.ts +183 -0
- package/src/player/components/automationLaneSelection.ts +135 -0
- package/src/player/components/automationShapes.test.ts +82 -0
- package/src/player/components/automationShapes.ts +70 -0
- package/src/player/components/automationSimplify.test.ts +48 -0
- package/src/player/components/automationSimplify.ts +51 -0
- package/src/player/components/timelineClipGestureHandlers.ts +223 -0
- package/src/player/components/timelineLaneProps.ts +19 -0
- package/src/player/components/timelineLayout.ts +11 -5
- package/src/player/components/useAutomationEdgeStretch.test.ts +27 -0
- package/src/player/components/useAutomationEdgeStretch.ts +269 -0
- package/src/player/components/useAutomationLaneGestures.ts +537 -0
- package/src/player/components/useAutomationLanes.test.tsx +112 -0
- package/src/player/components/useAutomationLanes.ts +170 -0
- package/src/player/components/useAutomationRangeDrag.ts +114 -0
- package/src/player/components/useTimelineTrackLayout.test.ts +107 -1
- package/src/player/components/useTimelineTrackLayout.ts +83 -7
- package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
- package/src/player/hooks/useTimelinePlayer.ts +13 -1
- package/src/player/lib/automationStoreSync.test.ts +83 -0
- package/src/player/lib/automationStoreSync.ts +56 -0
- package/src/player/lib/timelineDOM.test.ts +33 -0
- package/src/player/lib/timelineDOM.ts +12 -0
- package/src/player/store/automationSelectionSlice.test.ts +30 -0
- package/src/player/store/automationSelectionSlice.ts +54 -0
- package/src/player/store/playerStore.test.ts +15 -0
- package/src/player/store/playerStore.ts +54 -65
- package/src/player/store/timelineElement.ts +78 -0
- package/src/styles/studio.css +89 -0
- package/src/telemetry/agentRuntime.test.ts +45 -0
- package/src/telemetry/agentRuntime.ts +62 -0
- package/src/telemetry/system.ts +8 -0
- package/src/utils/studioFileHistory.test.ts +49 -0
- package/src/utils/studioFileHistory.ts +17 -1
- package/src/utils/studioTelemetry.ts +16 -10
- package/dist/assets/index-dF-CmLZu.js +0 -428
- package/dist/assets/index-zQ4JFwwB.css +0 -1
|
@@ -0,0 +1,1830 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { AudioFxGroup } from "./propertyPanelAudioFxGroup.js";
|
|
6
|
+
import type { DomEditSelection } from "./domEditingTypes";
|
|
7
|
+
import { EFFECT_COPY } from "@hyperframes/core/audio-fx-copy";
|
|
8
|
+
import { liveTime, usePlayerStore } from "../../player";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* What a knob is CALLED in the panel, looked up rather than spelled out.
|
|
12
|
+
*
|
|
13
|
+
* The rack speaks the plain-language layer now, so a row is addressed by the
|
|
14
|
+
* parameter it belongs to and the copy decides the words. Hard-coding them here
|
|
15
|
+
* would make every copy edit a test edit, and these tests are about which row
|
|
16
|
+
* carries the automate button — not about how it reads.
|
|
17
|
+
*/
|
|
18
|
+
function plainLabel(effectId: string, key: string): string {
|
|
19
|
+
const label = EFFECT_COPY[effectId]?.params[key]?.label;
|
|
20
|
+
if (!label) throw new Error(`no copy for ${effectId}.${key}`);
|
|
21
|
+
return label;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
25
|
+
|
|
26
|
+
const CHAIN = JSON.stringify({
|
|
27
|
+
version: 1,
|
|
28
|
+
nodes: [{ type: "lowpass", id: "n1", params: { frequency: 900, q: 1.2, poles: "2" } }],
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Each mount appends its tracks to the document; without clearing, a later
|
|
32
|
+
// "only one audio track" case would still find the previous test's sibling.
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
document.body.innerHTML = "";
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A selected `<audio>` with sibling tracks, so carve — which needs another track
|
|
39
|
+
* to listen to — is offered.
|
|
40
|
+
*
|
|
41
|
+
* TWO siblings by default, on purpose. One candidate voice is unambiguous and the
|
|
42
|
+
* panel carves the bed by itself, which is right in the product and wrong as a
|
|
43
|
+
* background condition for a test about something else: every write assertion
|
|
44
|
+
* would have to account for a carve nobody in the test asked for. Two leaves the
|
|
45
|
+
* choice open, so nothing is applied until a test picks. `voices: 1` is how the
|
|
46
|
+
* auto-apply tests opt in, `alone` for a composition holding just this track.
|
|
47
|
+
*/
|
|
48
|
+
function audioSelection(
|
|
49
|
+
dataAttributes: Record<string, string>,
|
|
50
|
+
alone = false,
|
|
51
|
+
voices = 2,
|
|
52
|
+
): DomEditSelection {
|
|
53
|
+
const bed = document.createElement("audio");
|
|
54
|
+
bed.id = "bed";
|
|
55
|
+
document.body.append(bed);
|
|
56
|
+
if (!alone) {
|
|
57
|
+
for (let i = 0; i < voices; i += 1) {
|
|
58
|
+
const voice = document.createElement("audio");
|
|
59
|
+
// The first keeps the id every existing test names.
|
|
60
|
+
voice.id = i === 0 ? "vo" : `vo${i + 1}`;
|
|
61
|
+
document.body.append(voice);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return { dataAttributes, id: "bed", element: bed } as unknown as DomEditSelection;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Open a module's Details, where every control that is not the primary one now
|
|
69
|
+
* lives — a module opens on one knob and the rest is one click away.
|
|
70
|
+
*/
|
|
71
|
+
function openDetails(host: HTMLElement, index = 0): void {
|
|
72
|
+
const buttons = Array.from(host.querySelectorAll<HTMLButtonElement>(".hf-fx-node-details"));
|
|
73
|
+
const button = buttons[index];
|
|
74
|
+
if (!button) throw new Error("no Details disclosure to open");
|
|
75
|
+
act(() => button.click());
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A button found by the text it contains, since several now read as sentences. */
|
|
79
|
+
function byTextButton(host: HTMLElement, text: string): HTMLButtonElement | undefined {
|
|
80
|
+
return Array.from(host.querySelectorAll("button")).find((b) => b.textContent?.includes(text));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function mount(dataAttributes: Record<string, string>, alone = false, voices = 2) {
|
|
84
|
+
// Every write is quiet: persisted without the preview reload that would
|
|
85
|
+
// restart every playing track, but with a selection resync so the panel sees
|
|
86
|
+
// what it just wrote.
|
|
87
|
+
const onSetAttributeQuiet = vi.fn();
|
|
88
|
+
const onSetAttributeLive = vi.fn();
|
|
89
|
+
const host = document.createElement("div");
|
|
90
|
+
document.body.append(host);
|
|
91
|
+
const selection = audioSelection(dataAttributes, alone, voices);
|
|
92
|
+
act(() => {
|
|
93
|
+
createRoot(host).render(
|
|
94
|
+
<AudioFxGroup
|
|
95
|
+
element={selection}
|
|
96
|
+
onSetAttributeQuiet={onSetAttributeQuiet}
|
|
97
|
+
onSetAttributeLive={onSetAttributeLive}
|
|
98
|
+
/>,
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
return { host, onSetAttributeQuiet, onSetAttributeLive };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const rowFor = (host: HTMLElement, label: string): HTMLElement | null => {
|
|
105
|
+
for (const row of Array.from(host.querySelectorAll<HTMLElement>(".hf-fx-row"))) {
|
|
106
|
+
if (row.querySelector(".hf-fx-label")?.textContent === label) return row;
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const parseWrite = (call: unknown[]) => JSON.parse(String(call[1]));
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The last write to one attribute.
|
|
115
|
+
*
|
|
116
|
+
* Positional indexing broke once a bed with voices above it started carving itself
|
|
117
|
+
* on mount: the carve's own writes share the queue with whatever the test did.
|
|
118
|
+
*/
|
|
119
|
+
const writeTo = (calls: unknown[][], attr: string): unknown[] | undefined =>
|
|
120
|
+
calls.filter((c) => c[0] === attr).at(-1);
|
|
121
|
+
|
|
122
|
+
describe("AudioFxGroup automation", () => {
|
|
123
|
+
it("renders the chain's parameters", () => {
|
|
124
|
+
const { host } = mount({ "fx-chain": CHAIN });
|
|
125
|
+
// The one knob that carries the module is on the open face; the rest are one
|
|
126
|
+
// click away, which is what Details is.
|
|
127
|
+
expect(rowFor(host, plainLabel("lowpass", "frequency"))).toBeTruthy();
|
|
128
|
+
expect(rowFor(host, plainLabel("lowpass", "q"))).toBeNull();
|
|
129
|
+
openDetails(host);
|
|
130
|
+
expect(rowFor(host, plainLabel("lowpass", "q"))).toBeTruthy();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("seeds a new lane at the value the control already holds", () => {
|
|
134
|
+
// Switching to an envelope must not change the sound — only where the value
|
|
135
|
+
// comes from. The chain has frequency at 900, not the registry default.
|
|
136
|
+
const { host, onSetAttributeQuiet } = mount({ "fx-chain": CHAIN });
|
|
137
|
+
const button = rowFor(host, plainLabel("lowpass", "frequency"))!.querySelector(
|
|
138
|
+
".hf-fx-automate",
|
|
139
|
+
) as HTMLButtonElement;
|
|
140
|
+
act(() => button.click());
|
|
141
|
+
const write = writeTo(onSetAttributeQuiet.mock.calls, "data-automation");
|
|
142
|
+
expect(write).toBeTruthy();
|
|
143
|
+
expect(JSON.parse(String(write![1]))).toEqual({
|
|
144
|
+
version: 1,
|
|
145
|
+
lanes: [{ target: "fx.n1.frequency", points: [{ t: 0, v: 900 }] }],
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("keeps lanes it is not touching when adding one", () => {
|
|
150
|
+
const { host, onSetAttributeQuiet } = mount({
|
|
151
|
+
"fx-chain": CHAIN,
|
|
152
|
+
automation: JSON.stringify({
|
|
153
|
+
version: 1,
|
|
154
|
+
lanes: [{ target: "volume", points: [{ t: 0, v: 0.5 }] }],
|
|
155
|
+
}),
|
|
156
|
+
});
|
|
157
|
+
openDetails(host);
|
|
158
|
+
act(() =>
|
|
159
|
+
(
|
|
160
|
+
rowFor(host, plainLabel("lowpass", "q"))!.querySelector(
|
|
161
|
+
".hf-fx-automate",
|
|
162
|
+
) as HTMLButtonElement
|
|
163
|
+
).click(),
|
|
164
|
+
);
|
|
165
|
+
expect(
|
|
166
|
+
parseWrite(writeTo(onSetAttributeQuiet.mock.calls, "data-automation")!).lanes.map(
|
|
167
|
+
(l: { target: string }) => l.target,
|
|
168
|
+
),
|
|
169
|
+
).toEqual(["volume", "fx.n1.q"]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("disables a control the timeline already drives", () => {
|
|
173
|
+
const { host } = mount({
|
|
174
|
+
"fx-chain": CHAIN,
|
|
175
|
+
automation: JSON.stringify({
|
|
176
|
+
version: 1,
|
|
177
|
+
lanes: [{ target: "fx.n1.frequency", points: [{ t: 0, v: 400 }] }],
|
|
178
|
+
}),
|
|
179
|
+
});
|
|
180
|
+
const cutoff = rowFor(host, plainLabel("lowpass", "frequency"))!;
|
|
181
|
+
expect(cutoff.querySelector<HTMLInputElement>('input[type="range"]')?.disabled).toBe(true);
|
|
182
|
+
expect(cutoff.hasAttribute("data-automated")).toBe(true);
|
|
183
|
+
openDetails(host);
|
|
184
|
+
expect(
|
|
185
|
+
rowFor(host, plainLabel("lowpass", "q"))!.querySelector<HTMLInputElement>(
|
|
186
|
+
'input[type="range"]',
|
|
187
|
+
)?.disabled,
|
|
188
|
+
).toBe(false);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("deletes just that lane, handing the value back to the control", () => {
|
|
192
|
+
const { host, onSetAttributeQuiet } = mount({
|
|
193
|
+
"fx-chain": CHAIN,
|
|
194
|
+
automation: JSON.stringify({
|
|
195
|
+
version: 1,
|
|
196
|
+
lanes: [
|
|
197
|
+
{ target: "fx.n1.frequency", points: [{ t: 0, v: 400 }] },
|
|
198
|
+
{ target: "volume", points: [{ t: 0, v: 0.5 }] },
|
|
199
|
+
],
|
|
200
|
+
}),
|
|
201
|
+
});
|
|
202
|
+
act(() =>
|
|
203
|
+
(
|
|
204
|
+
rowFor(host, plainLabel("lowpass", "frequency"))!.querySelector(
|
|
205
|
+
".hf-fx-automate",
|
|
206
|
+
) as HTMLButtonElement
|
|
207
|
+
).click(),
|
|
208
|
+
);
|
|
209
|
+
expect(
|
|
210
|
+
parseWrite(writeTo(onSetAttributeQuiet.mock.calls, "data-automation")!).lanes.map(
|
|
211
|
+
(l: { target: string }) => l.target,
|
|
212
|
+
),
|
|
213
|
+
).toEqual(["volume"]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("clears the attribute when the last lane goes", () => {
|
|
217
|
+
const { host, onSetAttributeQuiet } = mount({
|
|
218
|
+
"fx-chain": CHAIN,
|
|
219
|
+
automation: JSON.stringify({
|
|
220
|
+
version: 1,
|
|
221
|
+
lanes: [{ target: "fx.n1.frequency", points: [{ t: 0, v: 400 }] }],
|
|
222
|
+
}),
|
|
223
|
+
});
|
|
224
|
+
act(() =>
|
|
225
|
+
(
|
|
226
|
+
rowFor(host, plainLabel("lowpass", "frequency"))!.querySelector(
|
|
227
|
+
".hf-fx-automate",
|
|
228
|
+
) as HTMLButtonElement
|
|
229
|
+
).click(),
|
|
230
|
+
);
|
|
231
|
+
// Null rather than "": the live path removes an attribute it is given null for.
|
|
232
|
+
expect(writeTo(onSetAttributeQuiet.mock.calls, "data-automation")![1]).toBeNull();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("ignores a lane for an effect that is no longer in the chain", () => {
|
|
236
|
+
const { host } = mount({
|
|
237
|
+
"fx-chain": CHAIN,
|
|
238
|
+
automation: JSON.stringify({
|
|
239
|
+
version: 1,
|
|
240
|
+
lanes: [{ target: "fx.gone.frequency", points: [{ t: 0, v: 400 }] }],
|
|
241
|
+
}),
|
|
242
|
+
});
|
|
243
|
+
// Nothing is automated, so every control stays live.
|
|
244
|
+
expect(
|
|
245
|
+
rowFor(host, plainLabel("lowpass", "frequency"))!.querySelector<HTMLInputElement>(
|
|
246
|
+
'input[type="range"]',
|
|
247
|
+
)?.disabled,
|
|
248
|
+
).toBe(false);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe("AudioFxGroup carve", () => {
|
|
253
|
+
const carvedChain = JSON.stringify({
|
|
254
|
+
version: 1,
|
|
255
|
+
nodes: [
|
|
256
|
+
{ type: "peaking", id: "n1", fromCarve: true, params: { frequency: 900, gain: -6, q: 1.4 } },
|
|
257
|
+
{ type: "lowpass", id: "n2", params: { frequency: 400, q: 0.9, poles: "2" } },
|
|
258
|
+
],
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const carveOn = JSON.stringify({ sources: ["vo"], strength: 0.5 });
|
|
262
|
+
|
|
263
|
+
const carveToggle = (host: HTMLElement): HTMLButtonElement => {
|
|
264
|
+
const block = host.querySelector(".hf-fx-carve")!;
|
|
265
|
+
return block.querySelector(".hf-fx-bypass") as HTMLButtonElement;
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
it("removes the filters it generated when carve is switched off", async () => {
|
|
269
|
+
// Leaving them behind would keep dipping the bed with no carve to explain it.
|
|
270
|
+
const { host, onSetAttributeQuiet } = mount({
|
|
271
|
+
"fx-chain": carvedChain,
|
|
272
|
+
"fx-carve": carveOn,
|
|
273
|
+
});
|
|
274
|
+
await act(async () => {
|
|
275
|
+
carveToggle(host).click();
|
|
276
|
+
});
|
|
277
|
+
const chainWrite = onSetAttributeQuiet.mock.calls.find((c) => c[0] === "data-fx-chain");
|
|
278
|
+
expect(chainWrite).toBeTruthy();
|
|
279
|
+
const kept = JSON.parse(String(chainWrite![1])).nodes;
|
|
280
|
+
expect(kept.map((n: { type: string }) => n.type)).toEqual(["lowpass"]);
|
|
281
|
+
// The settings stay, marked off — after the chain write, not alongside it: both
|
|
282
|
+
// are read-modify-writes of the same file, so fired together the later one reads
|
|
283
|
+
// pre-edit content and drops the earlier. Kept rather than erased because an
|
|
284
|
+
// absent carve reads as never-configured, and a bed with one voice above it is
|
|
285
|
+
// carved by default: erasing would re-apply it on the next selection.
|
|
286
|
+
expect(onSetAttributeQuiet.mock.calls.map((c) => c[0])).toEqual([
|
|
287
|
+
"data-fx-chain",
|
|
288
|
+
"data-fx-carve",
|
|
289
|
+
]);
|
|
290
|
+
const carveWrite = onSetAttributeQuiet.mock.calls.find((c) => c[0] === "data-fx-carve");
|
|
291
|
+
expect(JSON.parse(String(carveWrite![1])).enabled).toBe(false);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("leaves a hand-built chain alone when carve is switched off", () => {
|
|
295
|
+
const handBuilt = JSON.stringify({
|
|
296
|
+
version: 1,
|
|
297
|
+
nodes: [{ type: "lowpass", id: "n1", params: { frequency: 400, q: 0.9, poles: "2" } }],
|
|
298
|
+
});
|
|
299
|
+
const { host, onSetAttributeQuiet } = mount({ "fx-chain": handBuilt, "fx-carve": carveOn });
|
|
300
|
+
act(() => carveToggle(host).click());
|
|
301
|
+
expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-chain")).toBe(false);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("drags a carve dial live and persists once on release", () => {
|
|
305
|
+
// Without the split every pointermove patched the source file and resynced
|
|
306
|
+
// the selection, which is what makes the audio stutter mid-drag.
|
|
307
|
+
const { host, onSetAttributeQuiet, onSetAttributeLive } = mount({
|
|
308
|
+
"fx-chain": carvedChain,
|
|
309
|
+
"fx-carve": carveOn,
|
|
310
|
+
});
|
|
311
|
+
const dial = host.querySelector<HTMLInputElement>(".hf-fx-carve input[type=range]");
|
|
312
|
+
expect(dial).not.toBeNull();
|
|
313
|
+
act(() => {
|
|
314
|
+
// React's value tracker swallows a plain assignment, so go through the
|
|
315
|
+
// prototype setter the way the other panel tests do.
|
|
316
|
+
// A different value than the carve holds; setting the same one is not a change.
|
|
317
|
+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(dial, "0.8");
|
|
318
|
+
dial?.dispatchEvent(new Event("input", { bubbles: true }));
|
|
319
|
+
});
|
|
320
|
+
expect(onSetAttributeLive.mock.calls.map((c) => c[0])).toEqual(["data-fx-carve"]);
|
|
321
|
+
expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-carve")).toBe(false);
|
|
322
|
+
act(() => dial?.dispatchEvent(new PointerEvent("pointerup", { bubbles: true })));
|
|
323
|
+
expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-carve")).toBe(true);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("writes carve settings quietly, so switching it does not reload the preview", async () => {
|
|
327
|
+
// A reload restarts every playing track, which is heard as the audio chopping.
|
|
328
|
+
// Awaited because switching off drops the generated filters first, and both
|
|
329
|
+
// writes touch the same file — the settings land on the next microtask.
|
|
330
|
+
const { host, onSetAttributeQuiet } = mount({ "fx-chain": carvedChain, "fx-carve": carveOn });
|
|
331
|
+
await act(async () => {
|
|
332
|
+
carveToggle(host).click();
|
|
333
|
+
});
|
|
334
|
+
const write = onSetAttributeQuiet.mock.calls.find((c) => c[0] === "data-fx-carve");
|
|
335
|
+
expect(write).toBeTruthy();
|
|
336
|
+
expect(JSON.parse(String(write![1])).enabled).toBe(false);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
describe("AudioFxGroup dynamic carve", () => {
|
|
341
|
+
const carvedChain = JSON.stringify({
|
|
342
|
+
version: 1,
|
|
343
|
+
nodes: [{ type: "lowpass", id: "n1", params: { frequency: 400, q: 0.9, poles: "2" } }],
|
|
344
|
+
});
|
|
345
|
+
// Strength 0 carves frequencies only — no level ducking — so the spectral
|
|
346
|
+
// cases measure just the spectral half. A case that wants the duck raises it.
|
|
347
|
+
const settings = (dynamic: boolean, over: Record<string, unknown> = {}) =>
|
|
348
|
+
JSON.stringify({ sources: ["vo"], strength: 0, dynamic, ...over });
|
|
349
|
+
|
|
350
|
+
/** The value written for one attribute, whatever order the writes landed in. */
|
|
351
|
+
const writeFor = (calls: unknown[][], attr: string) =>
|
|
352
|
+
JSON.parse(String(calls.find((c) => c[0] === attr)![1]));
|
|
353
|
+
|
|
354
|
+
/** Choose a voice track the way the select does. */
|
|
355
|
+
/**
|
|
356
|
+
* Include one voice in the carve.
|
|
357
|
+
*
|
|
358
|
+
* A set of things to include rather than a choice between them, since every named
|
|
359
|
+
* voice is analysed together — so this ticks a box instead of picking an option.
|
|
360
|
+
*/
|
|
361
|
+
const pickSource = (host: HTMLElement, id: string) => {
|
|
362
|
+
const box = host.querySelector<HTMLInputElement>(`[data-carve-source="${id}"]`)!;
|
|
363
|
+
box.click();
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
/** A voice with a pause in it, decoded through a stubbed offline context. */
|
|
367
|
+
function stubDecode(): void {
|
|
368
|
+
const sampleRate = 48000;
|
|
369
|
+
const data = new Float32Array(sampleRate * 4);
|
|
370
|
+
for (let i = 0; i < data.length; i++) {
|
|
371
|
+
const t = i / sampleRate;
|
|
372
|
+
data[i] = t > 1 && t < 3 ? 0.7 * Math.sin(2 * Math.PI * 1000 * t) : 0;
|
|
373
|
+
}
|
|
374
|
+
vi.stubGlobal(
|
|
375
|
+
"fetch",
|
|
376
|
+
vi.fn(async () => ({ arrayBuffer: async () => new ArrayBuffer(8) })),
|
|
377
|
+
);
|
|
378
|
+
vi.stubGlobal(
|
|
379
|
+
"OfflineAudioContext",
|
|
380
|
+
class {
|
|
381
|
+
decodeAudioData = async () => ({ sampleRate, getChannelData: () => data });
|
|
382
|
+
},
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* The same voice, but the decode does not finish until it is let go.
|
|
388
|
+
*
|
|
389
|
+
* Hover-auditioning the leveller is the one path where the result can arrive
|
|
390
|
+
* after the author has moved on, so the tests that cover that need to hold the
|
|
391
|
+
* decode open across a second gesture.
|
|
392
|
+
*/
|
|
393
|
+
function stubGatedDecode(): { release: () => void; decoded: Promise<void> } {
|
|
394
|
+
const sampleRate = 48000;
|
|
395
|
+
const data = new Float32Array(sampleRate * 4);
|
|
396
|
+
for (let i = 0; i < data.length; i++) {
|
|
397
|
+
const t = i / sampleRate;
|
|
398
|
+
data[i] = t > 1 && t < 3 ? 0.7 * Math.sin(2 * Math.PI * 1000 * t) : 0;
|
|
399
|
+
}
|
|
400
|
+
let release = (): void => {};
|
|
401
|
+
const decoded = new Promise<void>((r) => {
|
|
402
|
+
release = r;
|
|
403
|
+
});
|
|
404
|
+
vi.stubGlobal(
|
|
405
|
+
"fetch",
|
|
406
|
+
vi.fn(async () => ({ arrayBuffer: async () => new ArrayBuffer(8) })),
|
|
407
|
+
);
|
|
408
|
+
vi.stubGlobal(
|
|
409
|
+
"OfflineAudioContext",
|
|
410
|
+
class {
|
|
411
|
+
async decodeAudioData() {
|
|
412
|
+
await decoded;
|
|
413
|
+
return { sampleRate, getChannelData: () => data };
|
|
414
|
+
}
|
|
415
|
+
},
|
|
416
|
+
);
|
|
417
|
+
return { release: () => release(), decoded };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Let the held decode finish, and the measurement it feeds after it. */
|
|
421
|
+
async function settleDecode(release: () => void, decoded: Promise<void>): Promise<void> {
|
|
422
|
+
await act(async () => {
|
|
423
|
+
release();
|
|
424
|
+
await decoded;
|
|
425
|
+
await Promise.resolve();
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Hover-auditioning the leveller has to measure before there is anything to
|
|
433
|
+
* hear, and measuring a long voiceover takes seconds — by which time the
|
|
434
|
+
* pointer has usually moved on. Applying then would put levelling on a track
|
|
435
|
+
* nobody asked to level, through a channel that does not persist: audible,
|
|
436
|
+
* absent from the document, and gone on the next reload.
|
|
437
|
+
*/
|
|
438
|
+
it("levels the part of the file the clip plays, not the file from its start", async () => {
|
|
439
|
+
// A lane's `t` is seconds from the start of the CLIP, but the decode is the
|
|
440
|
+
// whole file — so a trimmed clip got an envelope offset by exactly
|
|
441
|
+
// `media-start`, and every correction landed early.
|
|
442
|
+
//
|
|
443
|
+
// The file is loud 0-2s, quiet 2-5s, loud again 5-8s, and the clip trims the
|
|
444
|
+
// first 2s. Measured from the clip's own zero, t=0.5 sits in the quiet
|
|
445
|
+
// passage and wants a real lift; measured from the file's zero it sits in
|
|
446
|
+
// the loud head and wants none. That gap is the bug.
|
|
447
|
+
const sampleRate = 48000;
|
|
448
|
+
const data = new Float32Array(sampleRate * 8);
|
|
449
|
+
for (let i = 0; i < data.length; i++) {
|
|
450
|
+
const t = i / sampleRate;
|
|
451
|
+
const amp = t < 2 ? 0.5 : t < 5 ? 0.05 : 0.5;
|
|
452
|
+
data[i] = amp * Math.sin(2 * Math.PI * 300 * t);
|
|
453
|
+
}
|
|
454
|
+
vi.stubGlobal(
|
|
455
|
+
"fetch",
|
|
456
|
+
vi.fn(async () => ({ arrayBuffer: async () => new ArrayBuffer(8) })),
|
|
457
|
+
);
|
|
458
|
+
vi.stubGlobal(
|
|
459
|
+
"OfflineAudioContext",
|
|
460
|
+
class {
|
|
461
|
+
decodeAudioData = async () => ({ sampleRate, getChannelData: () => data });
|
|
462
|
+
},
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
const { host, onSetAttributeQuiet } = mount({
|
|
466
|
+
"fx-chain": CHAIN,
|
|
467
|
+
"media-start": "2",
|
|
468
|
+
duration: "6",
|
|
469
|
+
});
|
|
470
|
+
document.getElementById("bed")?.setAttribute("src", "bed.wav");
|
|
471
|
+
act(() => byTextButton(host, "Audio FX")?.click());
|
|
472
|
+
act(() => byTextButton(host, "Add effect")?.click());
|
|
473
|
+
await act(async () => {
|
|
474
|
+
byTextButton(host, "Even Out Levels")?.click();
|
|
475
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
476
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
const write = onSetAttributeQuiet.mock.calls.filter((c) => c[0] === "data-automation").at(-1);
|
|
480
|
+
if (!write) throw new Error("no levelling lane written");
|
|
481
|
+
const lane = (
|
|
482
|
+
JSON.parse(String(write[1])).lanes as { target: string; points: { t: number; v: number }[] }[]
|
|
483
|
+
).find((l) => l.target.startsWith("fx."));
|
|
484
|
+
if (!lane) throw new Error("no fx lane");
|
|
485
|
+
const near = (t: number) =>
|
|
486
|
+
lane.points.reduce((best, p) => (Math.abs(p.t - t) < Math.abs(best.t - t) ? p : best));
|
|
487
|
+
// The quiet passage, from the clip's zero, gets its lift.
|
|
488
|
+
expect(near(0.5).v).toBeGreaterThan(4);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it("removes every lane a preset owned, not just the last node's", () => {
|
|
492
|
+
// Each write is computed from the same render-time snapshot and replaces the
|
|
493
|
+
// whole attribute, so removing lanes one node at a time kept only the final
|
|
494
|
+
// write — the earlier nodes' lanes survived as orphans, and with ids minted
|
|
495
|
+
// lowest-free the next effect added inherited one, arriving "Automated" with
|
|
496
|
+
// an envelope nobody drew and baked into the render.
|
|
497
|
+
const chain = {
|
|
498
|
+
version: 1,
|
|
499
|
+
nodes: [
|
|
500
|
+
{
|
|
501
|
+
type: "highpass",
|
|
502
|
+
id: "n1",
|
|
503
|
+
fromPreset: "telephone",
|
|
504
|
+
params: { frequency: 300, q: 0.707, poles: "2" },
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
type: "peaking",
|
|
508
|
+
id: "n2",
|
|
509
|
+
fromPreset: "telephone",
|
|
510
|
+
params: { frequency: 1200, gain: 6, q: 1.2 },
|
|
511
|
+
},
|
|
512
|
+
],
|
|
513
|
+
};
|
|
514
|
+
const automation = {
|
|
515
|
+
version: 1,
|
|
516
|
+
lanes: [
|
|
517
|
+
{ target: "fx.n1.frequency", points: [{ t: 0, v: 300 }] },
|
|
518
|
+
{ target: "fx.n2.gain", points: [{ t: 0, v: 6 }] },
|
|
519
|
+
{ target: "fx.preset.telephone", points: [{ t: 0, v: 1 }] },
|
|
520
|
+
{ target: "volume", points: [{ t: 0, v: 0.5 }] },
|
|
521
|
+
],
|
|
522
|
+
};
|
|
523
|
+
const { host, onSetAttributeQuiet } = mount({
|
|
524
|
+
"fx-chain": JSON.stringify(chain),
|
|
525
|
+
automation: JSON.stringify(automation),
|
|
526
|
+
});
|
|
527
|
+
act(() => byTextButton(host, "Audio FX")?.click());
|
|
528
|
+
act(() => host.querySelector<HTMLElement>(".hf-fx-preset-run-remove")?.click());
|
|
529
|
+
|
|
530
|
+
const write = onSetAttributeQuiet.mock.calls.filter((c) => c[0] === "data-automation").at(-1);
|
|
531
|
+
const lanes = JSON.parse(String(write?.[1] ?? '{"lanes":[]}')).lanes as { target: string }[];
|
|
532
|
+
const targets = lanes.map((l) => l.target);
|
|
533
|
+
// Both nodes gone, and the whole-preset lane with them.
|
|
534
|
+
expect(targets).not.toContain("fx.n1.frequency");
|
|
535
|
+
expect(targets).not.toContain("fx.n2.gain");
|
|
536
|
+
expect(targets).not.toContain("fx.preset.telephone");
|
|
537
|
+
// The track's own volume lane is untouched.
|
|
538
|
+
expect(targets).toContain("volume");
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
describe("auditioning starts the transport when it has to", () => {
|
|
542
|
+
const store = () => usePlayerStore.getState();
|
|
543
|
+
|
|
544
|
+
const hoverPreset = (host: HTMLElement) => {
|
|
545
|
+
act(() => byTextButton(host, "Presets")?.click());
|
|
546
|
+
act(() => host.querySelector<HTMLElement>(".hf-fx-preset-item")?.focus());
|
|
547
|
+
};
|
|
548
|
+
const leaveShelf = (host: HTMLElement) =>
|
|
549
|
+
act(() => {
|
|
550
|
+
host
|
|
551
|
+
.querySelector(".hf-fx-preset-menu")
|
|
552
|
+
?.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it("plays from the playhead, then puts it back exactly where it was", () => {
|
|
556
|
+
// Browsing the shelf must not cost the author their place: hovering is not
|
|
557
|
+
// an edit, so the playhead it borrows has to be returned.
|
|
558
|
+
act(() => usePlayerStore.setState({ isPlaying: false, currentTime: 42 }));
|
|
559
|
+
const { host } = mount({ "fx-chain": CHAIN });
|
|
560
|
+
hoverPreset(host);
|
|
561
|
+
expect(store().playbackRequest?.playing).toBe(true);
|
|
562
|
+
|
|
563
|
+
leaveShelf(host);
|
|
564
|
+
expect(store().playbackRequest?.playing).toBe(false);
|
|
565
|
+
expect(store().playbackRequest?.returnTo).toBe(42);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it("leaves a transport the author started alone", () => {
|
|
569
|
+
// Stopping their playback because they passed over a preset would be the
|
|
570
|
+
// panel taking a decision nobody offered it.
|
|
571
|
+
act(() => usePlayerStore.setState({ isPlaying: true, currentTime: 12 }));
|
|
572
|
+
const { host } = mount({ "fx-chain": CHAIN });
|
|
573
|
+
const before = store().playbackRequest?.nonce ?? 0;
|
|
574
|
+
hoverPreset(host);
|
|
575
|
+
leaveShelf(host);
|
|
576
|
+
expect(store().playbackRequest?.nonce ?? 0).toBe(before);
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it("drops a levelling measurement that lands after the pointer has gone", async () => {
|
|
581
|
+
const { release, decoded } = stubGatedDecode();
|
|
582
|
+
const { host, onSetAttributeLive } = mount({ "fx-chain": CHAIN });
|
|
583
|
+
document.getElementById("bed")?.setAttribute("src", "bed.wav");
|
|
584
|
+
act(() => byTextButton(host, "Add effect")?.click());
|
|
585
|
+
const level = byTextButton(host, "Even Out Levels");
|
|
586
|
+
expect(level, "the levelling button was not offered").toBeTruthy();
|
|
587
|
+
act(() => level?.focus());
|
|
588
|
+
// Gone again before the decode finishes.
|
|
589
|
+
act(() => {
|
|
590
|
+
host
|
|
591
|
+
.querySelector(".hf-fx-add-menu")
|
|
592
|
+
?.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
|
|
593
|
+
});
|
|
594
|
+
await settleDecode(release, decoded);
|
|
595
|
+
|
|
596
|
+
// The revert on the way out is allowed to write; a levelling stage is not.
|
|
597
|
+
const levelled = onSetAttributeLive.mock.calls.filter((c) =>
|
|
598
|
+
String(c[1] ?? "").includes("fromLeveller"),
|
|
599
|
+
);
|
|
600
|
+
expect(levelled).toEqual([]);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Sliding from the leveller to the effect beside it is not leaving the menu,
|
|
605
|
+
* so the shelf's own leave never fires — and the measurement already in flight
|
|
606
|
+
* used to land on top of whatever was being auditioned next, writing a
|
|
607
|
+
* levelled version of the chain as it was through a channel the document never
|
|
608
|
+
* sees. Every entry in the shelf calls its neighbours' auditions off.
|
|
609
|
+
*/
|
|
610
|
+
it("calls the levelling measurement off when the pointer moves to the effect beside it", async () => {
|
|
611
|
+
const { release, decoded } = stubGatedDecode();
|
|
612
|
+
const { host, onSetAttributeLive } = mount({ "fx-chain": CHAIN });
|
|
613
|
+
document.getElementById("bed")?.setAttribute("src", "bed.wav");
|
|
614
|
+
act(() => byTextButton(host, "Add effect")?.click());
|
|
615
|
+
act(() => byTextButton(host, "Even Out Levels")?.focus());
|
|
616
|
+
// Straight to a neighbour, without ever leaving the shelf.
|
|
617
|
+
act(() =>
|
|
618
|
+
byTextButton(host, EFFECT_COPY.reverb?.title ?? "")?.dispatchEvent(
|
|
619
|
+
new MouseEvent("mouseover", { bubbles: true }),
|
|
620
|
+
),
|
|
621
|
+
);
|
|
622
|
+
await settleDecode(release, decoded);
|
|
623
|
+
|
|
624
|
+
expect(
|
|
625
|
+
onSetAttributeLive.mock.calls.filter((c) => String(c[1] ?? "").includes("fromLeveller")),
|
|
626
|
+
).toEqual([]);
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it("automates the carve filters' gain from the voice, in the bed's own time", async () => {
|
|
630
|
+
stubDecode();
|
|
631
|
+
// Voice starts 10s into the composition, bed at 0: the envelope is measured
|
|
632
|
+
// against the voice but read from the start of the bed, so it has to shift.
|
|
633
|
+
const { host, onSetAttributeQuiet } = mount({
|
|
634
|
+
"fx-chain": carvedChain,
|
|
635
|
+
// No source yet: picking one is what applies the carve.
|
|
636
|
+
"fx-carve": settings(true, { sources: [] }),
|
|
637
|
+
start: "0",
|
|
638
|
+
});
|
|
639
|
+
const vo = document.getElementById("vo")!;
|
|
640
|
+
vo.setAttribute("data-start", "10");
|
|
641
|
+
vo.setAttribute("src", "voice.wav");
|
|
642
|
+
await act(async () => {
|
|
643
|
+
pickSource(host, "vo");
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
// Chain first, then automation: a lane naming a node the chain does not
|
|
647
|
+
// carry yet is dropped when it is read back.
|
|
648
|
+
const order = onSetAttributeQuiet.mock.calls.map((c) => c[0]);
|
|
649
|
+
// The settings land first, then the filters they imply, then the envelopes.
|
|
650
|
+
expect(order.indexOf("data-fx-chain")).toBeLessThan(order.indexOf("data-automation"));
|
|
651
|
+
|
|
652
|
+
const carved = writeFor(onSetAttributeQuiet.mock.calls, "data-fx-chain").nodes;
|
|
653
|
+
const carveNode = carved.find((n: { fromCarve?: boolean }) => n.fromCarve);
|
|
654
|
+
expect(carveNode.id).toBeTruthy();
|
|
655
|
+
|
|
656
|
+
const lanes = writeFor(onSetAttributeQuiet.mock.calls, "data-automation").lanes;
|
|
657
|
+
const lane = lanes.find((l: { target: string }) => l.target === `fx.${carveNode.id}.gain`) as {
|
|
658
|
+
points: { t: number; v: number }[];
|
|
659
|
+
};
|
|
660
|
+
expect(lane).toBeTruthy();
|
|
661
|
+
// Flat at the bed's own start, before the voice exists at all.
|
|
662
|
+
expect(lane.points[0]).toMatchObject({ t: 0, v: 0 });
|
|
663
|
+
// The voice's pause is at 0-1s of its own clip, so 10-11s of the bed's.
|
|
664
|
+
expect(lane.points.find((p) => p.t > 10.5 && p.t < 11)?.v ?? 0).toBe(0);
|
|
665
|
+
// And it cuts once the voice speaks, a second later. Depth is per band and
|
|
666
|
+
// relative to that band's own peak in the voice, so the invariant is that the
|
|
667
|
+
// envelope gets most of the way to what the analysis put on the node — not a
|
|
668
|
+
// fixed number of dB, which changes with the band the analysis chose.
|
|
669
|
+
const bandGain = Number(carveNode.params?.gain ?? 0);
|
|
670
|
+
// At least half the depth the analysis put on the node; the exact floor
|
|
671
|
+
// depends on which band it chose and how the envelope was thinned.
|
|
672
|
+
expect(Math.min(...lane.points.map((p) => p.v))).toBeLessThanOrEqual(bandGain * 0.5);
|
|
673
|
+
// Ends back at no cut, so the bed is not left dipped for the rest of the clip.
|
|
674
|
+
expect(lane.points.at(-1)!.v).toBe(0);
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
it("adds a gain stage that ducks the bed under the voice, automated when dynamic", async () => {
|
|
678
|
+
// Carving frequencies cannot beat a bed that is simply louder than the
|
|
679
|
+
// voice. The level half rides a gain node the carve owns, so the track's own
|
|
680
|
+
// volume lane is left alone.
|
|
681
|
+
stubDecode();
|
|
682
|
+
const { host, onSetAttributeQuiet } = mount({
|
|
683
|
+
"fx-chain": carvedChain,
|
|
684
|
+
"fx-carve": settings(true, { strength: 1, sources: [] }),
|
|
685
|
+
start: "0",
|
|
686
|
+
});
|
|
687
|
+
const vo = document.getElementById("vo")!;
|
|
688
|
+
vo.setAttribute("data-start", "0");
|
|
689
|
+
vo.setAttribute("src", "voice.wav");
|
|
690
|
+
// The bed is measured too — "how far over the voice is it" needs both.
|
|
691
|
+
document.getElementById("bed")!.setAttribute("src", "bed.m4a");
|
|
692
|
+
await act(async () => {
|
|
693
|
+
pickSource(host, "vo");
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
const nodes = writeFor(onSetAttributeQuiet.mock.calls, "data-fx-chain").nodes;
|
|
697
|
+
const gain = nodes.find((n: { type: string }) => n.type === "gain");
|
|
698
|
+
expect(gain).toBeTruthy();
|
|
699
|
+
expect(gain.fromCarve).toBe(true);
|
|
700
|
+
// Dynamic hands the value to the envelope, so the static one stays at unity.
|
|
701
|
+
expect(gain.params.gain).toBe(0);
|
|
702
|
+
|
|
703
|
+
const lanes = writeFor(onSetAttributeQuiet.mock.calls, "data-automation").lanes;
|
|
704
|
+
const duckLane = lanes.find((l: { target: string }) => l.target === `fx.${gain.id}.gain`);
|
|
705
|
+
expect(duckLane).toBeTruthy();
|
|
706
|
+
expect(Math.min(...duckLane.points.map((p: { v: number }) => p.v))).toBeLessThan(0);
|
|
707
|
+
// Every carved band gets an envelope reaching that band's own analysed depth.
|
|
708
|
+
for (const node of nodes.filter((n: { type: string }) => n.type === "peaking")) {
|
|
709
|
+
const lane = lanes.find((l: { target: string }) => l.target === `fx.${node.id}.gain`) as
|
|
710
|
+
| { points: { v: number }[] }
|
|
711
|
+
| undefined;
|
|
712
|
+
expect(lane, `band ${node.id} has no envelope`).toBeTruthy();
|
|
713
|
+
const deepest = Math.min(...lane!.points.map((p) => p.v));
|
|
714
|
+
expect(deepest).toBeLessThanOrEqual(0);
|
|
715
|
+
expect(deepest).toBeGreaterThanOrEqual(node.params.gain - 0.2);
|
|
716
|
+
expect(deepest).toBeLessThanOrEqual(node.params.gain * 0.5);
|
|
717
|
+
}
|
|
718
|
+
// The author's own volume lane is not something a carve gets to touch.
|
|
719
|
+
expect(lanes.some((l: { target: string }) => l.target === "volume")).toBe(false);
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
it("carves frequencies only when the duck is off", async () => {
|
|
723
|
+
stubDecode();
|
|
724
|
+
const { host, onSetAttributeQuiet } = mount({
|
|
725
|
+
"fx-chain": carvedChain,
|
|
726
|
+
"fx-carve": settings(true, { strength: 0, sources: [] }),
|
|
727
|
+
start: "0",
|
|
728
|
+
});
|
|
729
|
+
document.getElementById("vo")!.setAttribute("src", "voice.wav");
|
|
730
|
+
document.getElementById("bed")!.setAttribute("src", "bed.m4a");
|
|
731
|
+
await act(async () => {
|
|
732
|
+
pickSource(host, "vo");
|
|
733
|
+
});
|
|
734
|
+
const nodes = writeFor(onSetAttributeQuiet.mock.calls, "data-fx-chain").nodes;
|
|
735
|
+
expect(nodes.some((n: { type: string }) => n.type === "gain")).toBe(false);
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it("analyses when the module is switched back on", async () => {
|
|
739
|
+
// Off drops the filters, so On has nothing to hear until they are rebuilt. It
|
|
740
|
+
// used to restore the setting and leave the bed uncarved — the switch looked
|
|
741
|
+
// like it had worked and the mix was unchanged.
|
|
742
|
+
stubDecode();
|
|
743
|
+
const { host, onSetAttributeQuiet } = mount({
|
|
744
|
+
"fx-chain": JSON.stringify({ version: 1, nodes: [] }),
|
|
745
|
+
"fx-carve": JSON.stringify({ enabled: false, sources: ["vo"], strength: 0.25 }),
|
|
746
|
+
start: "0",
|
|
747
|
+
});
|
|
748
|
+
document.getElementById("vo")!.setAttribute("src", "voice.wav");
|
|
749
|
+
document.getElementById("bed")!.setAttribute("src", "bed.m4a");
|
|
750
|
+
await act(async () => {
|
|
751
|
+
host.querySelector<HTMLButtonElement>(".hf-fx-carve-toggle")!.click();
|
|
752
|
+
});
|
|
753
|
+
const chainWrite = onSetAttributeQuiet.mock.calls
|
|
754
|
+
.filter((c) => c[0] === "data-fx-chain")
|
|
755
|
+
.at(-1);
|
|
756
|
+
expect(chainWrite).toBeTruthy();
|
|
757
|
+
const nodes = JSON.parse(String(chainWrite![1])).nodes as {
|
|
758
|
+
type: string;
|
|
759
|
+
fromCarve?: boolean;
|
|
760
|
+
}[];
|
|
761
|
+
expect(nodes.filter((n) => n.fromCarve).length).toBeGreaterThan(0);
|
|
762
|
+
expect(nodes.some((n) => n.type === "peaking")).toBe(true);
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
it("analyses every named voice, not just the first", async () => {
|
|
766
|
+
// The point of a list: a bed running under a narrator and an interview answer
|
|
767
|
+
// should make room for both, so both are decoded and summed onto the bed's clock
|
|
768
|
+
// before a single band is chosen.
|
|
769
|
+
stubDecode();
|
|
770
|
+
const { host, onSetAttributeQuiet } = mount(
|
|
771
|
+
{
|
|
772
|
+
"fx-chain": JSON.stringify({ version: 1, nodes: [] }),
|
|
773
|
+
"fx-carve": JSON.stringify({ enabled: true, sources: ["vo", "vo2"], strength: 0.3 }),
|
|
774
|
+
start: "0",
|
|
775
|
+
},
|
|
776
|
+
false,
|
|
777
|
+
2,
|
|
778
|
+
);
|
|
779
|
+
document.getElementById("vo")!.setAttribute("src", "voice.wav");
|
|
780
|
+
document.getElementById("vo2")!.setAttribute("src", "guest.wav");
|
|
781
|
+
document.getElementById("bed")!.setAttribute("src", "bed.m4a");
|
|
782
|
+
// Nudge strength so the analysis runs against the stored two-voice list.
|
|
783
|
+
const dial = host.querySelector<HTMLInputElement>(".hf-fx-carve input[type=range]")!;
|
|
784
|
+
await act(async () => {
|
|
785
|
+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(dial, "0.6");
|
|
786
|
+
dial.dispatchEvent(new Event("input", { bubbles: true }));
|
|
787
|
+
dial.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
|
|
788
|
+
});
|
|
789
|
+
const fetched = (
|
|
790
|
+
globalThis.fetch as unknown as { mock: { calls: unknown[][] } }
|
|
791
|
+
).mock.calls.map((c) => String(c[0]));
|
|
792
|
+
expect(fetched.some((u) => u.includes("voice.wav"))).toBe(true);
|
|
793
|
+
expect(fetched.some((u) => u.includes("guest.wav"))).toBe(true);
|
|
794
|
+
const chainWrite = writeTo(onSetAttributeQuiet.mock.calls, "data-fx-chain");
|
|
795
|
+
expect(chainWrite).toBeTruthy();
|
|
796
|
+
const nodes = parseWrite(chainWrite!).nodes as { type: string; fromCarve?: boolean }[];
|
|
797
|
+
expect(nodes.some((n) => n.fromCarve && n.type === "peaking")).toBe(true);
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
it("applies as soon as another voice is included, with no second step", async () => {
|
|
801
|
+
// Including a voice is the whole gesture: a carve naming a track with no filters
|
|
802
|
+
// behind it is a setting nobody applied.
|
|
803
|
+
stubDecode();
|
|
804
|
+
const { host, onSetAttributeQuiet } = mount(
|
|
805
|
+
{
|
|
806
|
+
"fx-chain": JSON.stringify({ version: 1, nodes: [] }),
|
|
807
|
+
"fx-carve": JSON.stringify({ enabled: true, sources: ["vo"], strength: 0.25 }),
|
|
808
|
+
start: "0",
|
|
809
|
+
},
|
|
810
|
+
false,
|
|
811
|
+
2,
|
|
812
|
+
);
|
|
813
|
+
document.getElementById("vo")!.setAttribute("src", "voice.wav");
|
|
814
|
+
document.getElementById("vo2")!.setAttribute("src", "voice2.wav");
|
|
815
|
+
document.getElementById("bed")!.setAttribute("src", "bed.m4a");
|
|
816
|
+
const before = onSetAttributeQuiet.mock.calls.length;
|
|
817
|
+
await act(async () => {
|
|
818
|
+
pickSource(host, "vo2");
|
|
819
|
+
});
|
|
820
|
+
const after = onSetAttributeQuiet.mock.calls.slice(before).map((c) => c[0]);
|
|
821
|
+
// Both voices recorded, and the filters rebuilt from the two of them together.
|
|
822
|
+
expect(after).toContain("data-fx-carve");
|
|
823
|
+
expect(after).toContain("data-fx-chain");
|
|
824
|
+
const carveWrite = writeTo(onSetAttributeQuiet.mock.calls, "data-fx-carve");
|
|
825
|
+
expect(JSON.parse(String(carveWrite![1])).sources).toEqual(["vo", "vo2"]);
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
it("re-applies an existing carve when strength moves", async () => {
|
|
829
|
+
// Strength is the whole control surface, so it has to act on what is already
|
|
830
|
+
// applied. Left to the button alone, a carve kept the filters and envelopes
|
|
831
|
+
// its old strength produced and the knob silently described nothing.
|
|
832
|
+
stubDecode();
|
|
833
|
+
const carvedAlready = JSON.stringify({
|
|
834
|
+
version: 1,
|
|
835
|
+
nodes: [
|
|
836
|
+
{
|
|
837
|
+
type: "peaking",
|
|
838
|
+
id: "n1",
|
|
839
|
+
fromCarve: true,
|
|
840
|
+
params: { frequency: 1000, gain: -6, q: 1.4 },
|
|
841
|
+
},
|
|
842
|
+
{ type: "gain", id: "n2", fromCarve: true, params: { gain: -6 } },
|
|
843
|
+
],
|
|
844
|
+
});
|
|
845
|
+
const { host, onSetAttributeQuiet } = mount({
|
|
846
|
+
"fx-chain": carvedAlready,
|
|
847
|
+
"fx-carve": settings(true, { strength: 0.25 }),
|
|
848
|
+
start: "0",
|
|
849
|
+
});
|
|
850
|
+
document.getElementById("vo")!.setAttribute("src", "voice.wav");
|
|
851
|
+
document.getElementById("bed")!.setAttribute("src", "bed.m4a");
|
|
852
|
+
|
|
853
|
+
const dial = host.querySelector<HTMLInputElement>(".hf-fx-carve input[type=range]")!;
|
|
854
|
+
await act(async () => {
|
|
855
|
+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(dial, "1");
|
|
856
|
+
dial.dispatchEvent(new Event("input", { bubbles: true }));
|
|
857
|
+
dial.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
const written = onSetAttributeQuiet.mock.calls.map((c) => c[0]);
|
|
861
|
+
expect(written).toContain("data-fx-carve");
|
|
862
|
+
// The settings land first, then the filters they imply, then the envelopes.
|
|
863
|
+
expect(written.indexOf("data-fx-carve")).toBeLessThan(written.indexOf("data-fx-chain"));
|
|
864
|
+
expect(written.indexOf("data-fx-chain")).toBeLessThan(written.indexOf("data-automation"));
|
|
865
|
+
|
|
866
|
+
const chainWrite = onSetAttributeQuiet.mock.calls.find((c) => c[0] === "data-fx-chain");
|
|
867
|
+
const nodes = JSON.parse(String(chainWrite![1])).nodes;
|
|
868
|
+
// Full strength: deeper than the 6 dB the quarter-strength carve had.
|
|
869
|
+
const deepest = Math.min(
|
|
870
|
+
...nodes
|
|
871
|
+
.filter((n: { type: string }) => n.type === "peaking")
|
|
872
|
+
.map((n: { params: { gain: number } }) => n.params.gain),
|
|
873
|
+
);
|
|
874
|
+
expect(deepest).toBeLessThan(-6);
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
it("does not re-analyse on every pixel of a drag", async () => {
|
|
878
|
+
// Only the release re-applies. Analysing per pointermove would decode both
|
|
879
|
+
// tracks on each pixel.
|
|
880
|
+
stubDecode();
|
|
881
|
+
const carvedAlready = JSON.stringify({
|
|
882
|
+
version: 1,
|
|
883
|
+
nodes: [
|
|
884
|
+
{
|
|
885
|
+
type: "peaking",
|
|
886
|
+
id: "n1",
|
|
887
|
+
fromCarve: true,
|
|
888
|
+
params: { frequency: 1000, gain: -6, q: 1.4 },
|
|
889
|
+
},
|
|
890
|
+
],
|
|
891
|
+
});
|
|
892
|
+
const { host, onSetAttributeQuiet, onSetAttributeLive } = mount({
|
|
893
|
+
"fx-chain": carvedAlready,
|
|
894
|
+
"fx-carve": settings(true, { strength: 0.25 }),
|
|
895
|
+
start: "0",
|
|
896
|
+
});
|
|
897
|
+
document.getElementById("vo")!.setAttribute("src", "voice.wav");
|
|
898
|
+
const dial = host.querySelector<HTMLInputElement>(".hf-fx-carve input[type=range]")!;
|
|
899
|
+
await act(async () => {
|
|
900
|
+
for (const v of ["0.4", "0.6", "0.8"]) {
|
|
901
|
+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(dial, v);
|
|
902
|
+
dial.dispatchEvent(new Event("input", { bubbles: true }));
|
|
903
|
+
}
|
|
904
|
+
});
|
|
905
|
+
expect(onSetAttributeLive.mock.calls.every((c) => c[0] === "data-fx-carve")).toBe(true);
|
|
906
|
+
expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-chain")).toBe(false);
|
|
907
|
+
});
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
describe("AudioFxGroup successive edits", () => {
|
|
911
|
+
const three = JSON.stringify({
|
|
912
|
+
version: 1,
|
|
913
|
+
nodes: [
|
|
914
|
+
{ type: "peaking", id: "n1", params: { frequency: 900, gain: -6, q: 1.4 } },
|
|
915
|
+
{ type: "lowpass", id: "n2", params: { frequency: 400, q: 0.9, poles: "2" } },
|
|
916
|
+
{ type: "delay", id: "n3", params: { time: 250, feedback: 0.3, mix: 0.4 } },
|
|
917
|
+
],
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
const removeButtons = (host: HTMLElement) =>
|
|
921
|
+
Array.from(host.querySelectorAll<HTMLButtonElement>(".hf-fx-remove"));
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* The panel computes each edit from the attribute it is holding. Written
|
|
925
|
+
* without a selection resync, the second delete worked from the pre-delete
|
|
926
|
+
* chain and wrote the same result — so after deleting one effect, no further
|
|
927
|
+
* delete did anything.
|
|
928
|
+
*/
|
|
929
|
+
it("deletes a second effect after the first, not from a stale chain", () => {
|
|
930
|
+
const first = mount({ "fx-chain": three });
|
|
931
|
+
act(() => removeButtons(first.host)[0]!.click());
|
|
932
|
+
const afterFirst = parseWrite(writeTo(first.onSetAttributeQuiet.mock.calls, "data-fx-chain")!);
|
|
933
|
+
expect(afterFirst.nodes.map((n: { id: string }) => n.id)).toEqual(["n2", "n3"]);
|
|
934
|
+
|
|
935
|
+
// The resync hands the panel what it just wrote; the next delete starts there.
|
|
936
|
+
const second = mount({ "fx-chain": JSON.stringify(afterFirst) });
|
|
937
|
+
act(() => removeButtons(second.host)[0]!.click());
|
|
938
|
+
const afterSecond = parseWrite(
|
|
939
|
+
writeTo(second.onSetAttributeQuiet.mock.calls, "data-fx-chain")!,
|
|
940
|
+
);
|
|
941
|
+
expect(afterSecond.nodes.map((n: { id: string }) => n.id)).toEqual(["n3"]);
|
|
942
|
+
|
|
943
|
+
const third = mount({ "fx-chain": JSON.stringify(afterSecond) });
|
|
944
|
+
act(() => removeButtons(third.host)[0]!.click());
|
|
945
|
+
// The last one leaves no chain at all.
|
|
946
|
+
expect(writeTo(third.onSetAttributeQuiet.mock.calls, "data-fx-chain")![1]).toBeNull();
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
it("writes with the commit that resyncs the selection, not the silent one", () => {
|
|
950
|
+
// Both skip the preview reload; only this one re-reads the selection, which
|
|
951
|
+
// is what makes a following edit see the current value.
|
|
952
|
+
const { host, onSetAttributeQuiet } = mount({ "fx-chain": three });
|
|
953
|
+
act(() => removeButtons(host)[0]!.click());
|
|
954
|
+
// One chain write, through the resyncing path. The carve's own writes share the
|
|
955
|
+
// queue now, so this counts the ones this edit made.
|
|
956
|
+
expect(onSetAttributeQuiet.mock.calls.filter((c) => c[0] === "data-fx-chain")).toHaveLength(1);
|
|
957
|
+
});
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
describe("AudioFxGroup carve visibility", () => {
|
|
961
|
+
/**
|
|
962
|
+
* A carve is a relationship: a bed is carved *against* a voice. The voice is the
|
|
963
|
+
* other end of it, so offering the same control there is offering to carve a
|
|
964
|
+
* track against itself by proxy — and switching it on left a setting that could
|
|
965
|
+
* never do anything.
|
|
966
|
+
*/
|
|
967
|
+
it("does not offer carve on a track another track is carving against", () => {
|
|
968
|
+
const bed = document.createElement("audio");
|
|
969
|
+
bed.id = "bed";
|
|
970
|
+
bed.setAttribute("src", "bed.m4a");
|
|
971
|
+
bed.setAttribute("data-fx-carve", JSON.stringify({ sources: ["vo"], strength: 0.25 }));
|
|
972
|
+
document.body.append(bed);
|
|
973
|
+
const voice = document.createElement("audio");
|
|
974
|
+
voice.id = "vo";
|
|
975
|
+
voice.setAttribute("src", "voice.wav");
|
|
976
|
+
document.body.append(voice);
|
|
977
|
+
|
|
978
|
+
const host = document.createElement("div");
|
|
979
|
+
document.body.append(host);
|
|
980
|
+
const selection = {
|
|
981
|
+
dataAttributes: {},
|
|
982
|
+
id: "vo",
|
|
983
|
+
element: voice,
|
|
984
|
+
} as unknown as DomEditSelection;
|
|
985
|
+
act(() => {
|
|
986
|
+
createRoot(host).render(
|
|
987
|
+
<AudioFxGroup
|
|
988
|
+
element={selection}
|
|
989
|
+
onSetAttributeQuiet={vi.fn()}
|
|
990
|
+
onSetAttributeLive={vi.fn()}
|
|
991
|
+
/>,
|
|
992
|
+
);
|
|
993
|
+
});
|
|
994
|
+
expect(host.querySelector(".hf-fx-carve")).toBeNull();
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
it("still offers it on the bed doing the carving", () => {
|
|
998
|
+
const bed = document.createElement("audio");
|
|
999
|
+
bed.id = "bed";
|
|
1000
|
+
bed.setAttribute("src", "bed.m4a");
|
|
1001
|
+
bed.setAttribute("data-fx-carve", JSON.stringify({ sources: ["vo"], strength: 0.25 }));
|
|
1002
|
+
document.body.append(bed);
|
|
1003
|
+
const voice = document.createElement("audio");
|
|
1004
|
+
voice.id = "vo";
|
|
1005
|
+
voice.setAttribute("src", "voice.wav");
|
|
1006
|
+
document.body.append(voice);
|
|
1007
|
+
|
|
1008
|
+
const host = document.createElement("div");
|
|
1009
|
+
document.body.append(host);
|
|
1010
|
+
const selection = {
|
|
1011
|
+
dataAttributes: { "fx-carve": JSON.stringify({ sources: ["vo"], strength: 0.25 }) },
|
|
1012
|
+
id: "bed",
|
|
1013
|
+
element: bed,
|
|
1014
|
+
} as unknown as DomEditSelection;
|
|
1015
|
+
act(() => {
|
|
1016
|
+
createRoot(host).render(
|
|
1017
|
+
<AudioFxGroup
|
|
1018
|
+
element={selection}
|
|
1019
|
+
onSetAttributeQuiet={vi.fn()}
|
|
1020
|
+
onSetAttributeLive={vi.fn()}
|
|
1021
|
+
/>,
|
|
1022
|
+
);
|
|
1023
|
+
});
|
|
1024
|
+
expect(host.querySelector(".hf-fx-carve")).not.toBeNull();
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
it("offers carve when the composition holds another audio track", () => {
|
|
1028
|
+
const { host } = mount({ "fx-chain": CHAIN });
|
|
1029
|
+
expect(host.querySelector(".hf-fx-carve")).toBeTruthy();
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
it("does not offer carve for the only audio track in the composition", () => {
|
|
1033
|
+
// Nothing to listen to, so the picker would be empty and Analyse inert.
|
|
1034
|
+
const { host } = mount({ "fx-chain": CHAIN }, true);
|
|
1035
|
+
expect(host.querySelector(".hf-fx-carve")).toBeNull();
|
|
1036
|
+
});
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
describe("AudioFxGroup deleting an effect", () => {
|
|
1040
|
+
const twoNodes = JSON.stringify({
|
|
1041
|
+
version: 1,
|
|
1042
|
+
nodes: [
|
|
1043
|
+
{ type: "lowpass", id: "n1", params: { frequency: 400, q: 0.9, poles: "2" } },
|
|
1044
|
+
{ type: "peaking", id: "n2", params: { frequency: 900, gain: -6, q: 1 } },
|
|
1045
|
+
],
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
it("takes the deleted node's lanes with it", () => {
|
|
1049
|
+
// resolveAutomation only hides an orphan at read time. Left in the attribute,
|
|
1050
|
+
// and with ids minted lowest-free, the next effect added takes the same id and
|
|
1051
|
+
// inherits the dead envelope — disabled and "Automated" without the author
|
|
1052
|
+
// ever automating it, and baked into the render.
|
|
1053
|
+
const { host, onSetAttributeQuiet } = mount({
|
|
1054
|
+
"fx-chain": twoNodes,
|
|
1055
|
+
automation: JSON.stringify({
|
|
1056
|
+
version: 1,
|
|
1057
|
+
lanes: [
|
|
1058
|
+
{ target: "fx.n1.frequency", points: [{ t: 0, v: 400 }] },
|
|
1059
|
+
{ target: "fx.n2.gain", points: [{ t: 0, v: -6 }] },
|
|
1060
|
+
{ target: "volume", points: [{ t: 0, v: 1 }] },
|
|
1061
|
+
],
|
|
1062
|
+
}),
|
|
1063
|
+
});
|
|
1064
|
+
const remove = host.querySelectorAll<HTMLButtonElement>(".hf-fx-remove")[0]!;
|
|
1065
|
+
act(() => remove.click());
|
|
1066
|
+
const automationWrite = onSetAttributeQuiet.mock.calls.find((c) => c[0] === "data-automation");
|
|
1067
|
+
expect(automationWrite).toBeTruthy();
|
|
1068
|
+
expect(
|
|
1069
|
+
JSON.parse(String(automationWrite![1])).lanes.map((l: { target: string }) => l.target),
|
|
1070
|
+
).toEqual(["fx.n2.gain", "volume"]);
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
it("leaves automation alone when the deleted node had none", () => {
|
|
1074
|
+
const { host, onSetAttributeQuiet } = mount({
|
|
1075
|
+
"fx-chain": twoNodes,
|
|
1076
|
+
automation: JSON.stringify({
|
|
1077
|
+
version: 1,
|
|
1078
|
+
lanes: [{ target: "fx.n2.gain", points: [{ t: 0, v: -6 }] }],
|
|
1079
|
+
}),
|
|
1080
|
+
});
|
|
1081
|
+
act(() => host.querySelectorAll<HTMLButtonElement>(".hf-fx-remove")[0]!.click());
|
|
1082
|
+
expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-automation")).toBe(false);
|
|
1083
|
+
});
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
describe("AudioFxGroup carve module readouts", () => {
|
|
1087
|
+
/**
|
|
1088
|
+
* A carve on a bed running 0–10 s, with a lane that ramps its 400 Hz band from
|
|
1089
|
+
* no cut to −6 dB across the first five seconds. The stored gain is −1, which is
|
|
1090
|
+
* deliberately not a value the lane ever passes through: it stands in for the
|
|
1091
|
+
* seed a lane leaves behind, so a readout showing it can only mean the playhead
|
|
1092
|
+
* was not consulted.
|
|
1093
|
+
*/
|
|
1094
|
+
const carved = {
|
|
1095
|
+
start: "0",
|
|
1096
|
+
duration: "10",
|
|
1097
|
+
"fx-chain": JSON.stringify({
|
|
1098
|
+
version: 1,
|
|
1099
|
+
nodes: [
|
|
1100
|
+
{
|
|
1101
|
+
type: "peaking",
|
|
1102
|
+
id: "n1",
|
|
1103
|
+
fromCarve: true,
|
|
1104
|
+
params: { frequency: 400, gain: -1, q: 1.4 },
|
|
1105
|
+
},
|
|
1106
|
+
],
|
|
1107
|
+
}),
|
|
1108
|
+
automation: JSON.stringify({
|
|
1109
|
+
version: 1,
|
|
1110
|
+
lanes: [
|
|
1111
|
+
{
|
|
1112
|
+
target: "fx.n1.gain",
|
|
1113
|
+
points: [
|
|
1114
|
+
{ t: 0, v: 0 },
|
|
1115
|
+
{ t: 5, v: -6 },
|
|
1116
|
+
],
|
|
1117
|
+
},
|
|
1118
|
+
],
|
|
1119
|
+
}),
|
|
1120
|
+
"fx-carve": JSON.stringify({ sources: ["vo"], strength: 0.25 }),
|
|
1121
|
+
};
|
|
1122
|
+
|
|
1123
|
+
/** Park the playhead somewhere, paused — a scrub is the same question as playback. */
|
|
1124
|
+
const seek = (time: number) => {
|
|
1125
|
+
act(() => {
|
|
1126
|
+
usePlayerStore.setState({ currentTime: time, isPlaying: false });
|
|
1127
|
+
});
|
|
1128
|
+
};
|
|
1129
|
+
|
|
1130
|
+
const gainReadout = (host: HTMLElement): HTMLElement | null => {
|
|
1131
|
+
for (const span of Array.from(host.querySelectorAll<HTMLElement>(".hf-fx-carve-member span"))) {
|
|
1132
|
+
if (span.textContent?.startsWith("Gain")) return span;
|
|
1133
|
+
}
|
|
1134
|
+
return null;
|
|
1135
|
+
};
|
|
1136
|
+
|
|
1137
|
+
/** Ensure the module is open. It starts open, so this only acts if something closed it. */
|
|
1138
|
+
const openModule = (host: HTMLElement) => {
|
|
1139
|
+
const head = host.querySelector<HTMLButtonElement>(".hf-fx-carve-module .hf-fx-node-name");
|
|
1140
|
+
if (head?.getAttribute("aria-expanded") === "false") act(() => head.click());
|
|
1141
|
+
};
|
|
1142
|
+
|
|
1143
|
+
afterEach(() => {
|
|
1144
|
+
usePlayerStore.setState({ currentTime: 0, isPlaying: false });
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
it("shows the envelope's value at the playhead, not the stored seed", () => {
|
|
1148
|
+
seek(2.5);
|
|
1149
|
+
const { host } = mount(carved);
|
|
1150
|
+
openModule(host);
|
|
1151
|
+
const gain = gainReadout(host);
|
|
1152
|
+
// Halfway along a 0 → −6 dB ramp.
|
|
1153
|
+
expect(gain?.textContent).toContain("-3 dB");
|
|
1154
|
+
expect(gain?.hasAttribute("data-automation-live")).toBe(true);
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
it("follows the playhead as it moves", () => {
|
|
1158
|
+
seek(0);
|
|
1159
|
+
const { host } = mount(carved);
|
|
1160
|
+
openModule(host);
|
|
1161
|
+
expect(gainReadout(host)?.textContent).toContain("0 dB");
|
|
1162
|
+
seek(5);
|
|
1163
|
+
expect(gainReadout(host)?.textContent).toContain("-6 dB");
|
|
1164
|
+
seek(1);
|
|
1165
|
+
expect(gainReadout(host)?.textContent).toContain("-1.2 dB");
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
it("follows the transport during playback, off the live-time channel", async () => {
|
|
1169
|
+
// The RAF loop deliberately keeps every frame out of the store — it notifies
|
|
1170
|
+
// `liveTime` instead — so a panel that only watched the store would sit still
|
|
1171
|
+
// for the whole take and then jump when playback stopped.
|
|
1172
|
+
seek(0);
|
|
1173
|
+
const { host } = mount(carved);
|
|
1174
|
+
openModule(host);
|
|
1175
|
+
act(() => {
|
|
1176
|
+
usePlayerStore.setState({ isPlaying: true });
|
|
1177
|
+
});
|
|
1178
|
+
act(() => liveTime.notify(4));
|
|
1179
|
+
// Throttled to 30 Hz rather than rendered per frame, so the readout lands on
|
|
1180
|
+
// the next tick and not in this one.
|
|
1181
|
+
await act(async () => {
|
|
1182
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
1183
|
+
});
|
|
1184
|
+
expect(gainReadout(host)?.textContent).toContain("-4.8 dB");
|
|
1185
|
+
|
|
1186
|
+
act(() => liveTime.notify(5));
|
|
1187
|
+
await act(async () => {
|
|
1188
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
1189
|
+
});
|
|
1190
|
+
expect(gainReadout(host)?.textContent).toContain("-6 dB");
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1193
|
+
it("reserves the same width whatever the value reads", () => {
|
|
1194
|
+
// The readout updates 30 times a second while the transport runs, and a value
|
|
1195
|
+
// one character narrower shunts everything after it sideways. So the width comes
|
|
1196
|
+
// from what the parameter CAN read, not from what it currently does.
|
|
1197
|
+
seek(2.5);
|
|
1198
|
+
const { host } = mount(carved);
|
|
1199
|
+
openModule(host);
|
|
1200
|
+
const widthAt = (): string | undefined =>
|
|
1201
|
+
gainReadout(host)?.querySelector<HTMLElement>(".tabular-nums")?.style.minWidth;
|
|
1202
|
+
const narrow = widthAt();
|
|
1203
|
+
expect(narrow).toBe("8ch"); // -40..40 dB at one decimal: "-12.5 dB"
|
|
1204
|
+
seek(5); // -6 dB — two characters shorter than -3 dB was
|
|
1205
|
+
expect(gainReadout(host)?.textContent).toContain("-6 dB");
|
|
1206
|
+
expect(widthAt()).toBe(narrow);
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
it("moves a hand-built effect's own slider and number, not just the carve rack", () => {
|
|
1210
|
+
// Every automated value follows its lane, whatever put the effect there. This
|
|
1211
|
+
// one is a delay the author added and automated by hand: its control is locked
|
|
1212
|
+
// (the lane owns the value), so the fader has no drag to fight and can simply
|
|
1213
|
+
// show the truth.
|
|
1214
|
+
const { host } = mount({
|
|
1215
|
+
start: "0",
|
|
1216
|
+
duration: "10",
|
|
1217
|
+
"fx-chain": JSON.stringify({
|
|
1218
|
+
version: 1,
|
|
1219
|
+
nodes: [{ type: "delay", id: "n1", params: { time: 250, feedback: 0.35, mix: 0.4 } }],
|
|
1220
|
+
}),
|
|
1221
|
+
automation: JSON.stringify({
|
|
1222
|
+
version: 1,
|
|
1223
|
+
lanes: [
|
|
1224
|
+
{
|
|
1225
|
+
target: "fx.n1.mix",
|
|
1226
|
+
points: [
|
|
1227
|
+
{ t: 0, v: 0 },
|
|
1228
|
+
{ t: 4, v: 1 },
|
|
1229
|
+
],
|
|
1230
|
+
},
|
|
1231
|
+
],
|
|
1232
|
+
}),
|
|
1233
|
+
});
|
|
1234
|
+
const mixRow = rowFor(host, plainLabel("delay", "mix"));
|
|
1235
|
+
const number = mixRow?.querySelector<HTMLInputElement>(".hf-fx-number");
|
|
1236
|
+
const slider = mixRow?.querySelector<HTMLInputElement>(".hf-fx-slider");
|
|
1237
|
+
expect(number?.disabled).toBe(true); // the lane owns it
|
|
1238
|
+
|
|
1239
|
+
seek(1); // a quarter along a 0 → 1 ramp
|
|
1240
|
+
expect(Number(number?.value)).toBeCloseTo(0.25, 2);
|
|
1241
|
+
const quarter = Number(slider?.value);
|
|
1242
|
+
|
|
1243
|
+
seek(3);
|
|
1244
|
+
expect(Number(number?.value)).toBeCloseTo(0.75, 2);
|
|
1245
|
+
expect(Number(slider?.value)).toBeGreaterThan(quarter);
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
it("shows the lane's own edge value off the clip, not the stored seed", () => {
|
|
1249
|
+
// Past the bed's end, where a lane holds its last value — which is what would
|
|
1250
|
+
// play if the playhead came back. The stored -1 dB is a seed the lane replaced
|
|
1251
|
+
// and nothing will ever use it, so putting it on screen only made the fader
|
|
1252
|
+
// jump when the clip came under the playhead.
|
|
1253
|
+
seek(20);
|
|
1254
|
+
const { host } = mount(carved);
|
|
1255
|
+
openModule(host);
|
|
1256
|
+
const gain = gainReadout(host);
|
|
1257
|
+
expect(gain?.textContent).toContain("-6 dB"); // the ramp's last point
|
|
1258
|
+
expect(gain?.hasAttribute("data-automation-live")).toBe(true);
|
|
1259
|
+
expect(gain?.hasAttribute("data-automated")).toBe(true);
|
|
1260
|
+
});
|
|
1261
|
+
|
|
1262
|
+
it("holds the lane's first value before the clip starts", () => {
|
|
1263
|
+
// Same rule at the other end: a lane opens on its first point, so that is what
|
|
1264
|
+
// the fader should read while the playhead is still upstream of the clip.
|
|
1265
|
+
seek(-5);
|
|
1266
|
+
const { host } = mount(carved);
|
|
1267
|
+
openModule(host);
|
|
1268
|
+
expect(gainReadout(host)?.textContent).toContain("0 dB");
|
|
1269
|
+
});
|
|
1270
|
+
});
|
|
1271
|
+
|
|
1272
|
+
describe("AudioFxGroup carve by default", () => {
|
|
1273
|
+
const parse = (calls: unknown[][], attr: string) => {
|
|
1274
|
+
const call = calls.find((c) => c[0] === attr);
|
|
1275
|
+
return call ? JSON.parse(String(call[1])) : null;
|
|
1276
|
+
};
|
|
1277
|
+
|
|
1278
|
+
it("carves a bed that has exactly one voice above it, unasked", () => {
|
|
1279
|
+
// Carving is what a bed under narration wants. Making the author find the
|
|
1280
|
+
// control, pick the only possible voice and set a strength before hearing the
|
|
1281
|
+
// thing they already wanted is ceremony.
|
|
1282
|
+
const { onSetAttributeQuiet } = mount({ "fx-chain": "" }, false, 1);
|
|
1283
|
+
const carve = parse(onSetAttributeQuiet.mock.calls, "data-fx-carve");
|
|
1284
|
+
expect(carve).toMatchObject({ enabled: true, sources: ["vo"] });
|
|
1285
|
+
expect(carve.strength).toBe(0.25);
|
|
1286
|
+
});
|
|
1287
|
+
|
|
1288
|
+
it("applies the default carve exactly once for a single candidate", () => {
|
|
1289
|
+
// The regression: the multi-candidate effect and the single-candidate effect
|
|
1290
|
+
// both passed their guards for exactly one candidate (the first only checks
|
|
1291
|
+
// sourceOptions.length === 0, not === 1), so a bed with one narrator above it
|
|
1292
|
+
// fired two identical setCarve calls — two decodes, two FFT runs, two
|
|
1293
|
+
// concurrent attribute writes.
|
|
1294
|
+
const { onSetAttributeQuiet } = mount({ "fx-chain": "" }, false, 1);
|
|
1295
|
+
const carveWrites = onSetAttributeQuiet.mock.calls.filter((c) => c[0] === "data-fx-carve");
|
|
1296
|
+
expect(carveWrites).toHaveLength(1);
|
|
1297
|
+
});
|
|
1298
|
+
|
|
1299
|
+
it("makes room for every voice above the bed, not one of them", () => {
|
|
1300
|
+
// A bed usually runs under a whole sequence. Carving against one speaker leaves
|
|
1301
|
+
// the others fighting it, and choosing between them was never the question — so
|
|
1302
|
+
// several candidates is no longer a reason to refuse.
|
|
1303
|
+
const { onSetAttributeQuiet } = mount({ "fx-chain": "" }, false, 2);
|
|
1304
|
+
const write = writeTo(onSetAttributeQuiet.mock.calls, "data-fx-carve");
|
|
1305
|
+
expect(write).toBeTruthy();
|
|
1306
|
+
expect(JSON.parse(String(write![1])).sources).toEqual(["vo", "vo2"]);
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
it("does not carve a track with nothing to listen to", () => {
|
|
1310
|
+
const { host, onSetAttributeQuiet } = mount({ "fx-chain": "" }, true);
|
|
1311
|
+
expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-carve")).toBe(false);
|
|
1312
|
+
// And offers nothing: a carve needs a second track to be a relationship with.
|
|
1313
|
+
expect(host.querySelector(".hf-fx-carve-module")).toBeNull();
|
|
1314
|
+
});
|
|
1315
|
+
|
|
1316
|
+
it("stays off once switched off, rather than re-applying itself", () => {
|
|
1317
|
+
// The reason `enabled` exists. With "off" represented by an absent attribute,
|
|
1318
|
+
// selecting the clip again would read it as never-configured and carve it back.
|
|
1319
|
+
const { onSetAttributeQuiet } = mount(
|
|
1320
|
+
{
|
|
1321
|
+
"fx-carve": JSON.stringify({ enabled: false, sources: ["vo"], strength: 0.25 }),
|
|
1322
|
+
},
|
|
1323
|
+
false,
|
|
1324
|
+
1,
|
|
1325
|
+
);
|
|
1326
|
+
expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-carve")).toBe(false);
|
|
1327
|
+
expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-chain")).toBe(false);
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1330
|
+
it("does not carve the voice track itself", () => {
|
|
1331
|
+
// This track is the far end of someone else's relationship. Carving it against
|
|
1332
|
+
// its own bed is a feedback loop nobody asked for.
|
|
1333
|
+
const bed = document.createElement("audio");
|
|
1334
|
+
bed.id = "other-bed";
|
|
1335
|
+
bed.setAttribute(
|
|
1336
|
+
"data-fx-carve",
|
|
1337
|
+
JSON.stringify({ enabled: true, sources: ["bed"], strength: 0.3 }),
|
|
1338
|
+
);
|
|
1339
|
+
document.body.append(bed);
|
|
1340
|
+
const { host, onSetAttributeQuiet } = mount({ "fx-chain": "" }, false, 0);
|
|
1341
|
+
expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-carve")).toBe(false);
|
|
1342
|
+
expect(host.querySelector(".hf-fx-carve-module")).toBeNull();
|
|
1343
|
+
});
|
|
1344
|
+
});
|
|
1345
|
+
|
|
1346
|
+
describe("AudioFxGroup carve source list", () => {
|
|
1347
|
+
/** Mount a bed alongside tracks named however the test needs. */
|
|
1348
|
+
const mountWith = (
|
|
1349
|
+
tracks: { id: string; src?: string; start?: string; duration?: string }[],
|
|
1350
|
+
bedAttrs: Record<string, string> = {},
|
|
1351
|
+
) => {
|
|
1352
|
+
const bed = document.createElement("audio");
|
|
1353
|
+
bed.id = "bed";
|
|
1354
|
+
for (const [k, v] of Object.entries(bedAttrs)) bed.setAttribute(`data-${k}`, v);
|
|
1355
|
+
document.body.append(bed);
|
|
1356
|
+
for (const t of tracks) {
|
|
1357
|
+
const el = document.createElement("audio");
|
|
1358
|
+
el.id = t.id;
|
|
1359
|
+
if (t.src) el.setAttribute("src", t.src);
|
|
1360
|
+
if (t.start !== undefined) el.setAttribute("data-start", t.start);
|
|
1361
|
+
if (t.duration !== undefined) el.setAttribute("data-duration", t.duration);
|
|
1362
|
+
document.body.append(el);
|
|
1363
|
+
}
|
|
1364
|
+
const onSetAttributeQuiet = vi.fn();
|
|
1365
|
+
const host = document.createElement("div");
|
|
1366
|
+
document.body.append(host);
|
|
1367
|
+
act(() => {
|
|
1368
|
+
createRoot(host).render(
|
|
1369
|
+
<AudioFxGroup
|
|
1370
|
+
element={
|
|
1371
|
+
{ dataAttributes: bedAttrs, id: "bed", element: bed } as unknown as DomEditSelection
|
|
1372
|
+
}
|
|
1373
|
+
onSetAttributeQuiet={onSetAttributeQuiet}
|
|
1374
|
+
onSetAttributeLive={vi.fn()}
|
|
1375
|
+
/>,
|
|
1376
|
+
);
|
|
1377
|
+
});
|
|
1378
|
+
// What the panel is willing to listen to, however it presents it: a picker's
|
|
1379
|
+
// options, or the single track it reads out when there is nothing to choose.
|
|
1380
|
+
const offered = Array.from(host.querySelectorAll<HTMLElement>("[data-carve-source]"));
|
|
1381
|
+
const options = offered.map((el) => el.dataset["carveSource"] ?? "");
|
|
1382
|
+
// Boxes to tick when there is a set of voices; a plain readout when there is one
|
|
1383
|
+
// and nothing to decide.
|
|
1384
|
+
const boxes = offered.filter((el): el is HTMLInputElement => el instanceof HTMLInputElement);
|
|
1385
|
+
return { host, options, boxes, onSetAttributeQuiet };
|
|
1386
|
+
};
|
|
1387
|
+
|
|
1388
|
+
it("reads the one voice out instead of offering a picker with one entry", () => {
|
|
1389
|
+
// A question with one answer is not a question. It is also the common case: a
|
|
1390
|
+
// narration and a bed, with a couple of stings that cannot be the voice.
|
|
1391
|
+
const { host, boxes } = mountWith([
|
|
1392
|
+
{ id: "narration" },
|
|
1393
|
+
{ id: "music-bed" },
|
|
1394
|
+
{ id: "sfx-boom" },
|
|
1395
|
+
]);
|
|
1396
|
+
expect(boxes).toHaveLength(0);
|
|
1397
|
+
expect(host.querySelector("[data-carve-source]")?.textContent).toBe("narration");
|
|
1398
|
+
});
|
|
1399
|
+
|
|
1400
|
+
it("lists every voice as something to include once there is more than one", () => {
|
|
1401
|
+
const { boxes, options, onSetAttributeQuiet } = mountWith([
|
|
1402
|
+
{ id: "narration" },
|
|
1403
|
+
{ id: "interview-guest" },
|
|
1404
|
+
]);
|
|
1405
|
+
expect(options).toEqual(["narration", "interview-guest"]);
|
|
1406
|
+
expect(boxes).toHaveLength(2);
|
|
1407
|
+
// Both included, by default. Asserted on the write rather than on the ticks: this
|
|
1408
|
+
// fixture never resyncs the attribute back, so the boxes still read the empty
|
|
1409
|
+
// list the panel started from.
|
|
1410
|
+
const write = writeTo(onSetAttributeQuiet.mock.calls, "data-fx-carve");
|
|
1411
|
+
expect(JSON.parse(String(write![1])).sources).toEqual(["narration", "interview-guest"]);
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
it("keeps the picker when the stored voice is not among the candidates", () => {
|
|
1415
|
+
// The stored track is still there but no longer classifies as a voice.
|
|
1416
|
+
// Reading the one remaining candidate out would quietly claim the carve
|
|
1417
|
+
// listens to it. (A stored track that is GONE is a different case — see the
|
|
1418
|
+
// deleted-voice tests, which re-analyse rather than sit on a measurement of
|
|
1419
|
+
// something that is not there.)
|
|
1420
|
+
const bed = document.createElement("audio");
|
|
1421
|
+
bed.id = "bed";
|
|
1422
|
+
bed.setAttribute(
|
|
1423
|
+
"data-fx-carve",
|
|
1424
|
+
JSON.stringify({ enabled: true, sources: ["backing-music"], strength: 0.25 }),
|
|
1425
|
+
);
|
|
1426
|
+
document.body.append(bed);
|
|
1427
|
+
const stored = document.createElement("audio");
|
|
1428
|
+
stored.id = "backing-music";
|
|
1429
|
+
document.body.append(stored);
|
|
1430
|
+
const voice = document.createElement("audio");
|
|
1431
|
+
voice.id = "narration";
|
|
1432
|
+
document.body.append(voice);
|
|
1433
|
+
const host = document.createElement("div");
|
|
1434
|
+
document.body.append(host);
|
|
1435
|
+
act(() => {
|
|
1436
|
+
createRoot(host).render(
|
|
1437
|
+
<AudioFxGroup
|
|
1438
|
+
element={
|
|
1439
|
+
{
|
|
1440
|
+
dataAttributes: {
|
|
1441
|
+
"fx-carve": JSON.stringify({
|
|
1442
|
+
enabled: true,
|
|
1443
|
+
sources: ["backing-music"],
|
|
1444
|
+
strength: 0.25,
|
|
1445
|
+
}),
|
|
1446
|
+
},
|
|
1447
|
+
id: "bed",
|
|
1448
|
+
element: bed,
|
|
1449
|
+
} as unknown as DomEditSelection
|
|
1450
|
+
}
|
|
1451
|
+
onSetAttributeQuiet={vi.fn()}
|
|
1452
|
+
onSetAttributeLive={vi.fn()}
|
|
1453
|
+
/>,
|
|
1454
|
+
);
|
|
1455
|
+
});
|
|
1456
|
+
// Boxes rather than a readout, so the mismatch is visible and fixable.
|
|
1457
|
+
const boxes = Array.from(host.querySelectorAll<HTMLInputElement>("[data-carve-source]"));
|
|
1458
|
+
expect(boxes).toHaveLength(1);
|
|
1459
|
+
expect(boxes[0]!.checked).toBe(false);
|
|
1460
|
+
});
|
|
1461
|
+
|
|
1462
|
+
it("leaves music and effects out of what it will listen to", () => {
|
|
1463
|
+
// A bed is the thing being carved and a 200 ms sting has no speech in it, so
|
|
1464
|
+
// offering either is offering an answer that cannot be right.
|
|
1465
|
+
const { options } = mountWith([
|
|
1466
|
+
{ id: "narration" },
|
|
1467
|
+
{ id: "music-bed" },
|
|
1468
|
+
{ id: "sfx-explosion" },
|
|
1469
|
+
{ id: "whoosh" },
|
|
1470
|
+
]);
|
|
1471
|
+
expect(options).toEqual(["narration"]);
|
|
1472
|
+
});
|
|
1473
|
+
|
|
1474
|
+
it("keeps tracks whose names say nothing, and sorts speech first", () => {
|
|
1475
|
+
// A name is a hint, not a fact: hiding `a1` could hide the only voice there is.
|
|
1476
|
+
const { options, boxes } = mountWith([{ id: "a1" }, { id: "bgm" }, { id: "vo-take2" }]);
|
|
1477
|
+
expect(boxes).toHaveLength(2);
|
|
1478
|
+
expect(options).toEqual(["vo-take2", "a1"]);
|
|
1479
|
+
});
|
|
1480
|
+
|
|
1481
|
+
it("reads the filename when the id says nothing", () => {
|
|
1482
|
+
const { options } = mountWith([
|
|
1483
|
+
{ id: "a1", src: "assets/narration-final.mp3" },
|
|
1484
|
+
{ id: "a2", src: "assets/bgm_loop.m4a" },
|
|
1485
|
+
]);
|
|
1486
|
+
expect(options).toEqual(["a1"]);
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1489
|
+
it("offers everything rather than nothing when no track looks like a voice", () => {
|
|
1490
|
+
// Filtering to an empty picker would make the carve unusable on a composition
|
|
1491
|
+
// whose tracks are all named like music.
|
|
1492
|
+
const { options } = mountWith([{ id: "music-bed" }, { id: "bgm-2" }]);
|
|
1493
|
+
expect(options).toEqual(["music-bed", "bgm-2"]);
|
|
1494
|
+
});
|
|
1495
|
+
|
|
1496
|
+
it("carves by itself once the effects are filtered out of the count", () => {
|
|
1497
|
+
// The payoff: a voice, a bed and two stings used to read as four candidates,
|
|
1498
|
+
// which is ambiguous, so nothing was applied. One plausible voice carves.
|
|
1499
|
+
const { onSetAttributeQuiet } = mountWith([
|
|
1500
|
+
{ id: "narration" },
|
|
1501
|
+
{ id: "sfx-boom" },
|
|
1502
|
+
{ id: "sfx-riser" },
|
|
1503
|
+
]);
|
|
1504
|
+
const write = onSetAttributeQuiet.mock.calls.find((c) => c[0] === "data-fx-carve");
|
|
1505
|
+
expect(write).toBeTruthy();
|
|
1506
|
+
expect(JSON.parse(String(write![1]))).toMatchObject({ enabled: true, sources: ["narration"] });
|
|
1507
|
+
});
|
|
1508
|
+
});
|
|
1509
|
+
|
|
1510
|
+
describe("AudioFxGroup carve source range", () => {
|
|
1511
|
+
const spanned = (tracks: { id: string; start?: string; duration?: string }[]): string[] => {
|
|
1512
|
+
const bed = document.createElement("audio");
|
|
1513
|
+
bed.id = "bed";
|
|
1514
|
+
document.body.append(bed);
|
|
1515
|
+
for (const t of tracks) {
|
|
1516
|
+
const el = document.createElement("audio");
|
|
1517
|
+
el.id = t.id;
|
|
1518
|
+
if (t.start !== undefined) el.setAttribute("data-start", t.start);
|
|
1519
|
+
if (t.duration !== undefined) el.setAttribute("data-duration", t.duration);
|
|
1520
|
+
document.body.append(el);
|
|
1521
|
+
}
|
|
1522
|
+
const host = document.createElement("div");
|
|
1523
|
+
document.body.append(host);
|
|
1524
|
+
act(() => {
|
|
1525
|
+
createRoot(host).render(
|
|
1526
|
+
<AudioFxGroup
|
|
1527
|
+
element={
|
|
1528
|
+
{
|
|
1529
|
+
dataAttributes: { start: "0", duration: "100" },
|
|
1530
|
+
id: "bed",
|
|
1531
|
+
element: bed,
|
|
1532
|
+
} as unknown as DomEditSelection
|
|
1533
|
+
}
|
|
1534
|
+
onSetAttributeQuiet={vi.fn()}
|
|
1535
|
+
onSetAttributeLive={vi.fn()}
|
|
1536
|
+
/>,
|
|
1537
|
+
);
|
|
1538
|
+
});
|
|
1539
|
+
return Array.from(host.querySelectorAll<HTMLElement>("[data-carve-source]")).map(
|
|
1540
|
+
(el) => el.dataset["carveSource"] ?? "",
|
|
1541
|
+
);
|
|
1542
|
+
};
|
|
1543
|
+
|
|
1544
|
+
it("ignores a voice that never plays while the bed does", () => {
|
|
1545
|
+
// It cannot mask what is not sounding, so including it would feed the analysis
|
|
1546
|
+
// silence and leave the author wondering why it changed nothing.
|
|
1547
|
+
expect(
|
|
1548
|
+
spanned([
|
|
1549
|
+
{ id: "narration", start: "0", duration: "20" },
|
|
1550
|
+
{ id: "outtake", start: "500", duration: "30" },
|
|
1551
|
+
]),
|
|
1552
|
+
).toEqual(["narration"]);
|
|
1553
|
+
});
|
|
1554
|
+
|
|
1555
|
+
it("keeps a voice that only partly overlaps the bed", () => {
|
|
1556
|
+
// Half a sentence over the bed is still half a sentence to make room for.
|
|
1557
|
+
expect(spanned([{ id: "narration", start: "90", duration: "40" }])).toEqual(["narration"]);
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
it("keeps a voice whose length the composition does not write down", () => {
|
|
1561
|
+
// Refusing it would drop the commonest case there is: a clip whose duration is
|
|
1562
|
+
// left to the media. `Number(null)` being 0 made exactly that mistake once.
|
|
1563
|
+
expect(spanned([{ id: "narration", start: "10" }])).toEqual(["narration"]);
|
|
1564
|
+
});
|
|
1565
|
+
});
|
|
1566
|
+
|
|
1567
|
+
describe("AudioFxGroup carve across tracks", () => {
|
|
1568
|
+
it("considers every voice wherever it sits on the timeline", () => {
|
|
1569
|
+
// A track is a row to draw on — `data-track-index` is parsed in one place, to
|
|
1570
|
+
// decide layout — so where a voice lives says nothing about whether it masks the
|
|
1571
|
+
// bed. An author may put four narration slices on one row or on four; the carve
|
|
1572
|
+
// has to see all of them either way.
|
|
1573
|
+
const bed = document.createElement("audio");
|
|
1574
|
+
bed.id = "music-bed";
|
|
1575
|
+
bed.setAttribute("data-track-index", "8");
|
|
1576
|
+
document.body.append(bed);
|
|
1577
|
+
const rows = ["11", "12", "13", "3"];
|
|
1578
|
+
rows.forEach((row, i) => {
|
|
1579
|
+
const voice = document.createElement("audio");
|
|
1580
|
+
voice.id = `narration-${i + 1}`;
|
|
1581
|
+
voice.setAttribute("data-track-index", row);
|
|
1582
|
+
voice.setAttribute("data-start", String(i * 10));
|
|
1583
|
+
document.body.append(voice);
|
|
1584
|
+
});
|
|
1585
|
+
const host = document.createElement("div");
|
|
1586
|
+
document.body.append(host);
|
|
1587
|
+
const onSetAttributeQuiet = vi.fn();
|
|
1588
|
+
act(() => {
|
|
1589
|
+
createRoot(host).render(
|
|
1590
|
+
<AudioFxGroup
|
|
1591
|
+
element={
|
|
1592
|
+
{
|
|
1593
|
+
dataAttributes: { start: "0", duration: "200", "track-index": "8" },
|
|
1594
|
+
id: "music-bed",
|
|
1595
|
+
element: bed,
|
|
1596
|
+
} as unknown as DomEditSelection
|
|
1597
|
+
}
|
|
1598
|
+
onSetAttributeQuiet={onSetAttributeQuiet}
|
|
1599
|
+
onSetAttributeLive={vi.fn()}
|
|
1600
|
+
/>,
|
|
1601
|
+
);
|
|
1602
|
+
});
|
|
1603
|
+
const offered = Array.from(host.querySelectorAll<HTMLElement>("[data-carve-source]")).map(
|
|
1604
|
+
(el) => el.dataset["carveSource"] ?? "",
|
|
1605
|
+
);
|
|
1606
|
+
expect(offered).toEqual(["narration-1", "narration-2", "narration-3", "narration-4"]);
|
|
1607
|
+
// And it carves against all four without being asked.
|
|
1608
|
+
const write = onSetAttributeQuiet.mock.calls.find((c) => c[0] === "data-fx-carve");
|
|
1609
|
+
expect(JSON.parse(String(write![1])).sources).toEqual([
|
|
1610
|
+
"narration-1",
|
|
1611
|
+
"narration-2",
|
|
1612
|
+
"narration-3",
|
|
1613
|
+
"narration-4",
|
|
1614
|
+
]);
|
|
1615
|
+
});
|
|
1616
|
+
});
|
|
1617
|
+
|
|
1618
|
+
/**
|
|
1619
|
+
* The filters and envelopes a carve produces are a MEASUREMENT of specific
|
|
1620
|
+
* tracks. Delete one and they describe something nobody can hear any more — the
|
|
1621
|
+
* bed keeps ducking for a voice that is gone.
|
|
1622
|
+
*/
|
|
1623
|
+
describe("AudioFxGroup carve against a deleted voice", () => {
|
|
1624
|
+
const CARVED_CHAIN = JSON.stringify({
|
|
1625
|
+
version: 1,
|
|
1626
|
+
nodes: [
|
|
1627
|
+
{ type: "peaking", id: "c1", enabled: true, fromCarve: true, params: { frequency: 1000 } },
|
|
1628
|
+
{ type: "lowpass", id: "k1", enabled: true, params: { frequency: 8000 } },
|
|
1629
|
+
],
|
|
1630
|
+
});
|
|
1631
|
+
const CARVED_AUTOMATION = JSON.stringify({
|
|
1632
|
+
version: 1,
|
|
1633
|
+
lanes: [
|
|
1634
|
+
{ target: "fx.c1.gain", points: [{ t: 0, v: -6 }] },
|
|
1635
|
+
{ target: "fx.k1.frequency", points: [{ t: 0, v: 8000 }] },
|
|
1636
|
+
],
|
|
1637
|
+
});
|
|
1638
|
+
|
|
1639
|
+
/**
|
|
1640
|
+
* A bed carving against `sources`, with only `present` still in the composition.
|
|
1641
|
+
*
|
|
1642
|
+
* The timeline is what says a track is gone — not the preview DOM, which keeps
|
|
1643
|
+
* a deleted element around — so the store is seeded and the document is left
|
|
1644
|
+
* holding every track, which is exactly the mismatch the studio produces.
|
|
1645
|
+
*/
|
|
1646
|
+
function mountCarved(sources: string[], present: string[]) {
|
|
1647
|
+
const carve = JSON.stringify({ enabled: true, sources, strength: 0.25 });
|
|
1648
|
+
const bed = document.createElement("audio");
|
|
1649
|
+
bed.id = "bed";
|
|
1650
|
+
document.body.append(bed);
|
|
1651
|
+
for (const id of new Set([...sources, ...present])) {
|
|
1652
|
+
const el = document.createElement("audio");
|
|
1653
|
+
el.id = id;
|
|
1654
|
+
document.body.append(el);
|
|
1655
|
+
}
|
|
1656
|
+
usePlayerStore.setState({
|
|
1657
|
+
elements: [
|
|
1658
|
+
{ id: "bed", tag: "audio", start: 0, duration: 10, track: 0 },
|
|
1659
|
+
...present.map((id) => ({ id, tag: "audio", start: 0, duration: 10, track: 1 })),
|
|
1660
|
+
] as never,
|
|
1661
|
+
});
|
|
1662
|
+
const onSetAttributeQuiet = vi.fn();
|
|
1663
|
+
const host = document.createElement("div");
|
|
1664
|
+
document.body.append(host);
|
|
1665
|
+
act(() => {
|
|
1666
|
+
createRoot(host).render(
|
|
1667
|
+
<AudioFxGroup
|
|
1668
|
+
element={
|
|
1669
|
+
{
|
|
1670
|
+
dataAttributes: {
|
|
1671
|
+
"fx-carve": carve,
|
|
1672
|
+
"fx-chain": CARVED_CHAIN,
|
|
1673
|
+
automation: CARVED_AUTOMATION,
|
|
1674
|
+
},
|
|
1675
|
+
id: "bed",
|
|
1676
|
+
element: bed,
|
|
1677
|
+
} as unknown as DomEditSelection
|
|
1678
|
+
}
|
|
1679
|
+
onSetAttributeQuiet={onSetAttributeQuiet}
|
|
1680
|
+
onSetAttributeLive={vi.fn()}
|
|
1681
|
+
/>,
|
|
1682
|
+
);
|
|
1683
|
+
});
|
|
1684
|
+
return { host, onSetAttributeQuiet };
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
it("re-analyses against the voices that are left", () => {
|
|
1688
|
+
// Two voices were measured together into one set of bands. With one gone that
|
|
1689
|
+
// set answers a question nobody asked; the survivor has to be measured again.
|
|
1690
|
+
const { onSetAttributeQuiet } = mountCarved(["narration", "guest"], ["narration"]);
|
|
1691
|
+
const write = writeTo(onSetAttributeQuiet.mock.calls, "data-fx-carve");
|
|
1692
|
+
expect(JSON.parse(String(write![1]))).toMatchObject({
|
|
1693
|
+
enabled: true,
|
|
1694
|
+
sources: ["narration"],
|
|
1695
|
+
});
|
|
1696
|
+
});
|
|
1697
|
+
|
|
1698
|
+
it("leaves a carve alone while every voice it names is still there", () => {
|
|
1699
|
+
const { onSetAttributeQuiet } = mountCarved(["narration", "guest"], ["narration", "guest"]);
|
|
1700
|
+
expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-carve")).toBe(false);
|
|
1701
|
+
});
|
|
1702
|
+
|
|
1703
|
+
it("does not fall back to carving against an effect when no voice is left", async () => {
|
|
1704
|
+
// Found in the studio, not here: deleting the narration emptied the source
|
|
1705
|
+
// list, and the panel filled it again with the only audio in the composition
|
|
1706
|
+
// — a 200 ms explosion. The picker's fallback (offer everything rather than
|
|
1707
|
+
// hide the track somebody needs) is for the AUTHOR to choose from. The panel
|
|
1708
|
+
// choosing off it is the panel deciding, and that is never the answer.
|
|
1709
|
+
const carve = JSON.stringify({ enabled: true, sources: [], strength: 0.25 });
|
|
1710
|
+
const bed = document.createElement("audio");
|
|
1711
|
+
bed.id = "bed";
|
|
1712
|
+
bed.setAttribute("data-fx-carve", carve);
|
|
1713
|
+
document.body.append(bed);
|
|
1714
|
+
const sfx = document.createElement("audio");
|
|
1715
|
+
sfx.id = "sfx-explosion";
|
|
1716
|
+
document.body.append(sfx);
|
|
1717
|
+
const onSetAttributeQuiet = vi.fn();
|
|
1718
|
+
const host = document.createElement("div");
|
|
1719
|
+
document.body.append(host);
|
|
1720
|
+
act(() => {
|
|
1721
|
+
createRoot(host).render(
|
|
1722
|
+
<AudioFxGroup
|
|
1723
|
+
element={
|
|
1724
|
+
{
|
|
1725
|
+
dataAttributes: { "fx-carve": carve },
|
|
1726
|
+
id: "bed",
|
|
1727
|
+
element: bed,
|
|
1728
|
+
} as unknown as DomEditSelection
|
|
1729
|
+
}
|
|
1730
|
+
onSetAttributeQuiet={onSetAttributeQuiet}
|
|
1731
|
+
onSetAttributeLive={vi.fn()}
|
|
1732
|
+
/>,
|
|
1733
|
+
);
|
|
1734
|
+
});
|
|
1735
|
+
await act(async () => {});
|
|
1736
|
+
// Nothing written: the carve waits rather than picking the explosion.
|
|
1737
|
+
expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-carve")).toBe(false);
|
|
1738
|
+
// Still offered, so the author can say "actually, listen to that one".
|
|
1739
|
+
expect(
|
|
1740
|
+
Array.from(host.querySelectorAll("[data-carve-source]")).map((e) =>
|
|
1741
|
+
e.getAttribute("data-carve-source"),
|
|
1742
|
+
),
|
|
1743
|
+
).toEqual(["sfx-explosion"]);
|
|
1744
|
+
});
|
|
1745
|
+
|
|
1746
|
+
it("drops what it generated when the last voice goes and none is left to pick", async () => {
|
|
1747
|
+
// Staying on with nothing to listen to is honest — a voice may come back, and
|
|
1748
|
+
// "off" is a different thing the author chose. What cannot stay is the output:
|
|
1749
|
+
// those filters and that envelope are making room for nobody.
|
|
1750
|
+
const { onSetAttributeQuiet } = mountCarved(["narration"], []);
|
|
1751
|
+
// The three writes are sequenced, not fired together: each is a
|
|
1752
|
+
// read-modify-write against the same file, so the carve write lands only
|
|
1753
|
+
// after the two that strip its output.
|
|
1754
|
+
await act(async () => {});
|
|
1755
|
+
const carve = writeTo(onSetAttributeQuiet.mock.calls, "data-fx-carve");
|
|
1756
|
+
expect(JSON.parse(String(carve![1]))).toMatchObject({ enabled: true, sources: [] });
|
|
1757
|
+
|
|
1758
|
+
const chain = writeTo(onSetAttributeQuiet.mock.calls, "data-fx-chain");
|
|
1759
|
+
// The hand-added low-pass survives; only what the carve minted goes.
|
|
1760
|
+
expect(JSON.parse(String(chain![1])).nodes.map((n: { id: string }) => n.id)).toEqual(["k1"]);
|
|
1761
|
+
|
|
1762
|
+
const automation = writeTo(onSetAttributeQuiet.mock.calls, "data-automation");
|
|
1763
|
+
expect(
|
|
1764
|
+
JSON.parse(String(automation![1])).lanes.map((l: { target: string }) => l.target),
|
|
1765
|
+
).toEqual(["fx.k1.frequency"]);
|
|
1766
|
+
});
|
|
1767
|
+
});
|
|
1768
|
+
|
|
1769
|
+
describe("AudioFxGroup while the carve is measuring", () => {
|
|
1770
|
+
/**
|
|
1771
|
+
* `analyse` captures the chain and the automation before its fetch and decode,
|
|
1772
|
+
* then rewrites the whole `data-fx-chain` from that snapshot. An effect added
|
|
1773
|
+
* — or a knob committed — during those seconds landed first and was silently
|
|
1774
|
+
* discarded when the analysis returned. Only the Analyse control was gated;
|
|
1775
|
+
* every other control in the rack stayed live throughout.
|
|
1776
|
+
*/
|
|
1777
|
+
it("locks the rack, so an edit cannot be made against a snapshot that is moving", async () => {
|
|
1778
|
+
// A fetch that never settles holds the panel in its analysing state, which
|
|
1779
|
+
// is exactly the window the race lives in.
|
|
1780
|
+
const hang = vi.fn(() => new Promise<Response>(() => {}));
|
|
1781
|
+
vi.stubGlobal("fetch", hang);
|
|
1782
|
+
// happy-dom has no Web Audio, and without a constructor `analyse` returns
|
|
1783
|
+
// before it ever reaches the decode — closing the window under test.
|
|
1784
|
+
vi.stubGlobal(
|
|
1785
|
+
"OfflineAudioContext",
|
|
1786
|
+
class {
|
|
1787
|
+
decodeAudioData() {
|
|
1788
|
+
return new Promise(() => {});
|
|
1789
|
+
}
|
|
1790
|
+
},
|
|
1791
|
+
);
|
|
1792
|
+
try {
|
|
1793
|
+
const bed = document.createElement("audio");
|
|
1794
|
+
bed.id = "bed";
|
|
1795
|
+
bed.setAttribute("src", "bed.wav");
|
|
1796
|
+
document.body.append(bed);
|
|
1797
|
+
const voice = document.createElement("audio");
|
|
1798
|
+
voice.id = "narration";
|
|
1799
|
+
voice.setAttribute("src", "vo.wav");
|
|
1800
|
+
document.body.append(voice);
|
|
1801
|
+
|
|
1802
|
+
const host = document.createElement("div");
|
|
1803
|
+
document.body.append(host);
|
|
1804
|
+
await act(async () => {
|
|
1805
|
+
createRoot(host).render(
|
|
1806
|
+
<AudioFxGroup
|
|
1807
|
+
element={
|
|
1808
|
+
{
|
|
1809
|
+
dataAttributes: { "fx-chain": CHAIN },
|
|
1810
|
+
id: "bed",
|
|
1811
|
+
element: bed,
|
|
1812
|
+
} as unknown as DomEditSelection
|
|
1813
|
+
}
|
|
1814
|
+
onSetAttributeQuiet={vi.fn()}
|
|
1815
|
+
onSetAttributeLive={vi.fn()}
|
|
1816
|
+
/>,
|
|
1817
|
+
);
|
|
1818
|
+
});
|
|
1819
|
+
|
|
1820
|
+
// The bed carves itself against its one candidate, which starts the
|
|
1821
|
+
// decode — and the whole rack goes read-only until it lands.
|
|
1822
|
+
expect(hang).toHaveBeenCalled();
|
|
1823
|
+
const controls = Array.from(host.querySelectorAll<HTMLInputElement>(".hf-fx-slider"));
|
|
1824
|
+
expect(controls.length).toBeGreaterThan(0);
|
|
1825
|
+
expect(controls.every((c) => c.disabled)).toBe(true);
|
|
1826
|
+
} finally {
|
|
1827
|
+
vi.unstubAllGlobals();
|
|
1828
|
+
}
|
|
1829
|
+
});
|
|
1830
|
+
});
|