@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.
Files changed (126) hide show
  1. package/dist/assets/{hyperframes-player-BLrfwq5o.js → hyperframes-player-BYgqAPib.js} +1 -1
  2. package/dist/assets/{index-BSBk5srs.js → index-Blxia-Lt.js} +1 -1
  3. package/dist/assets/{index-BA9yhzfR.js → index-CIRxodzK.js} +1 -1
  4. package/dist/assets/index-CfBGQdrX.js +428 -0
  5. package/dist/assets/index-DNkh9mbV.css +1 -0
  6. package/dist/index.d.ts +85 -2
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +17696 -12512
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/StudioRightPanel.tsx +18 -1
  12. package/src/components/TimelineToolbar.test.tsx +46 -0
  13. package/src/components/TimelineToolbar.tsx +17 -0
  14. package/src/components/editor/PropertyPanel.test.tsx +5 -1
  15. package/src/components/editor/PropertyPanel.tsx +7 -23
  16. package/src/components/editor/PropertyPanelFlat.tsx +25 -0
  17. package/src/components/editor/audioFxSummary.test.ts +75 -0
  18. package/src/components/editor/audioFxSummary.ts +36 -0
  19. package/src/components/editor/audioFxTelemetry.test.ts +129 -0
  20. package/src/components/editor/audioFxTelemetry.ts +230 -0
  21. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +1830 -0
  22. package/src/components/editor/propertyPanelAudioFxGroup.tsx +299 -0
  23. package/src/components/editor/propertyPanelAudioFxGroupUtils.ts +21 -0
  24. package/src/components/editor/propertyPanelAutomation.test.ts +60 -0
  25. package/src/components/editor/propertyPanelAutomation.ts +97 -0
  26. package/src/components/editor/propertyPanelFlatMediaSection.tsx +37 -9
  27. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  28. package/src/components/editor/propertyPanelFxAddMenu.tsx +209 -0
  29. package/src/components/editor/propertyPanelFxBandRuler.tsx +72 -0
  30. package/src/components/editor/propertyPanelFxCarveModule.tsx +382 -0
  31. package/src/components/editor/propertyPanelFxControls.test.tsx +115 -0
  32. package/src/components/editor/propertyPanelFxControls.tsx +356 -0
  33. package/src/components/editor/propertyPanelFxEqModule.tsx +196 -0
  34. package/src/components/editor/propertyPanelFxFamily.ts +83 -0
  35. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +269 -0
  36. package/src/components/editor/propertyPanelFxNodeParams.tsx +112 -0
  37. package/src/components/editor/propertyPanelFxNodeRow.tsx +339 -0
  38. package/src/components/editor/propertyPanelFxPresetMenu.tsx +159 -0
  39. package/src/components/editor/propertyPanelFxPresetRun.tsx +215 -0
  40. package/src/components/editor/propertyPanelFxPresetStyle.test.ts +142 -0
  41. package/src/components/editor/propertyPanelFxPresetStyle.ts +216 -0
  42. package/src/components/editor/propertyPanelFxRackChain.tsx +202 -0
  43. package/src/components/editor/propertyPanelFxSection.test.tsx +1747 -0
  44. package/src/components/editor/propertyPanelFxSection.tsx +550 -0
  45. package/src/components/editor/propertyPanelFxSectionTypes.ts +91 -0
  46. package/src/components/editor/propertyPanelTypes.ts +5 -0
  47. package/src/components/editor/useFxAudition.ts +95 -0
  48. package/src/components/editor/useFxCarve.ts +0 -0
  49. package/src/components/editor/useFxChainObserved.ts +83 -0
  50. package/src/components/editor/useFxLevelling.ts +235 -0
  51. package/src/components/editor/useVolumeAutomation.test.tsx +122 -0
  52. package/src/components/editor/useVolumeAutomation.ts +54 -0
  53. package/src/contexts/DomEditContext.tsx +11 -0
  54. package/src/hooks/useAppHotkeys.test.ts +198 -0
  55. package/src/hooks/useAppHotkeys.ts +30 -3
  56. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +429 -0
  57. package/src/hooks/useAutomationSelectionKeyboard.ts +371 -0
  58. package/src/hooks/useDomEditAttributeCommits.ts +90 -10
  59. package/src/hooks/useDomEditCommits.test.tsx +38 -0
  60. package/src/hooks/useDomEditCommits.ts +2 -0
  61. package/src/hooks/useDomEditSession.ts +2 -0
  62. package/src/hooks/useDomEditTextCommits.ts +2 -0
  63. package/src/hooks/useElementLifecycleOps.ts +3 -0
  64. package/src/hooks/useLivePlayheadTime.ts +49 -0
  65. package/src/hooks/usePreviewPersistence.ts +7 -0
  66. package/src/hooks/useTimelineEditing.ts +3 -0
  67. package/src/player/components/AutomationSelectionMenu.tsx +76 -0
  68. package/src/player/components/AutomationValueInput.tsx +51 -0
  69. package/src/player/components/LayerDisclosureRow.tsx +5 -4
  70. package/src/player/components/Timeline.test.ts +84 -0
  71. package/src/player/components/TimelineAutomationLane.test.tsx +1720 -0
  72. package/src/player/components/TimelineAutomationLane.tsx +674 -0
  73. package/src/player/components/TimelineAutomationLaneSlot.test.tsx +193 -0
  74. package/src/player/components/TimelineLanes.test.tsx +24 -5
  75. package/src/player/components/TimelineLanes.tsx +142 -154
  76. package/src/player/components/TimelineTrackHeader.test.tsx +212 -1
  77. package/src/player/components/TimelineTrackHeader.tsx +166 -6
  78. package/src/player/components/TimelineTrackRow.tsx +8 -1
  79. package/src/player/components/automationClipboard.test.ts +94 -0
  80. package/src/player/components/automationClipboard.ts +136 -0
  81. package/src/player/components/automationGestureKeys.test.ts +49 -0
  82. package/src/player/components/automationGestureKeys.ts +49 -0
  83. package/src/player/components/automationLaneData.test.ts +270 -0
  84. package/src/player/components/automationLaneData.ts +258 -0
  85. package/src/player/components/automationLaneDragMath.ts +194 -0
  86. package/src/player/components/automationLaneGeometry.test.ts +239 -0
  87. package/src/player/components/automationLaneGeometry.ts +287 -0
  88. package/src/player/components/automationLaneHeight.ts +15 -0
  89. package/src/player/components/automationLanePointer.ts +20 -0
  90. package/src/player/components/automationLaneSelection.test.ts +183 -0
  91. package/src/player/components/automationLaneSelection.ts +135 -0
  92. package/src/player/components/automationShapes.test.ts +82 -0
  93. package/src/player/components/automationShapes.ts +70 -0
  94. package/src/player/components/automationSimplify.test.ts +48 -0
  95. package/src/player/components/automationSimplify.ts +51 -0
  96. package/src/player/components/timelineClipGestureHandlers.ts +223 -0
  97. package/src/player/components/timelineLaneProps.ts +19 -0
  98. package/src/player/components/timelineLayout.ts +11 -5
  99. package/src/player/components/useAutomationEdgeStretch.test.ts +27 -0
  100. package/src/player/components/useAutomationEdgeStretch.ts +269 -0
  101. package/src/player/components/useAutomationLaneGestures.ts +537 -0
  102. package/src/player/components/useAutomationLanes.test.tsx +112 -0
  103. package/src/player/components/useAutomationLanes.ts +170 -0
  104. package/src/player/components/useAutomationRangeDrag.ts +114 -0
  105. package/src/player/components/useTimelineTrackLayout.test.ts +107 -1
  106. package/src/player/components/useTimelineTrackLayout.ts +83 -7
  107. package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
  108. package/src/player/hooks/useTimelinePlayer.ts +13 -1
  109. package/src/player/lib/automationStoreSync.test.ts +83 -0
  110. package/src/player/lib/automationStoreSync.ts +56 -0
  111. package/src/player/lib/timelineDOM.test.ts +33 -0
  112. package/src/player/lib/timelineDOM.ts +12 -0
  113. package/src/player/store/automationSelectionSlice.test.ts +30 -0
  114. package/src/player/store/automationSelectionSlice.ts +54 -0
  115. package/src/player/store/playerStore.test.ts +15 -0
  116. package/src/player/store/playerStore.ts +54 -65
  117. package/src/player/store/timelineElement.ts +78 -0
  118. package/src/styles/studio.css +89 -0
  119. package/src/telemetry/agentRuntime.test.ts +45 -0
  120. package/src/telemetry/agentRuntime.ts +62 -0
  121. package/src/telemetry/system.ts +8 -0
  122. package/src/utils/studioFileHistory.test.ts +49 -0
  123. package/src/utils/studioFileHistory.ts +17 -1
  124. package/src/utils/studioTelemetry.ts +16 -10
  125. package/dist/assets/index-dF-CmLZu.js +0 -428
  126. 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
+ });