Skip to content

Commit 9a7e19e

Browse files
committed
chore: merge in the carve UI type-shape fix
2 parents 49c619b + 03c1634 commit 9a7e19e

4 files changed

Lines changed: 34 additions & 255 deletions

File tree

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

Lines changed: 15 additions & 179 deletions
Original file line numberDiff line numberDiff line change
@@ -246,15 +246,15 @@ describe("AudioFxGroup carve", () => {
246246
});
247247
});
248248

249-
describe("AudioFxGroup dynamic carve", () => {
249+
describe("AudioFxGroup carve analysis", () => {
250250
const carvedChain = JSON.stringify({
251251
version: 1,
252252
nodes: [{ type: "lowpass", id: "n1", params: { frequency: 400, q: 0.9, poles: "2" } }],
253253
});
254254
// Strength 0 carves frequencies only — no level ducking — so the spectral
255255
// cases measure just the spectral half. A case that wants the duck raises it.
256-
const settings = (dynamic: boolean, over: Record<string, unknown> = {}) =>
257-
JSON.stringify({ source: "vo", strength: 0, dynamic, ...over });
256+
const settings = (over: Record<string, unknown> = {}) =>
257+
JSON.stringify({ sources: ["vo"], strength: 0, ...over });
258258

259259
/** The value written for one attribute, whatever order the writes landed in. */
260260
const writeFor = (calls: unknown[][], attr: string) =>
@@ -267,9 +267,6 @@ describe("AudioFxGroup dynamic carve", () => {
267267
select.dispatchEvent(new Event("change", { bubbles: true }));
268268
};
269269

270-
const dynamicBox = (host: HTMLElement) =>
271-
host.querySelector<HTMLInputElement>(".hf-fx-carve-dynamic")!;
272-
273270
/** A voice with a pause in it, decoded through a stubbed offline context. */
274271
function stubDecode(): void {
275272
const sampleRate = 48000;
@@ -292,114 +289,11 @@ describe("AudioFxGroup dynamic carve", () => {
292289

293290
afterEach(() => vi.unstubAllGlobals());
294291

295-
it("records the choice in the carve settings", () => {
296-
const { host, onSetAttributeQuiet } = mount({
297-
"fx-chain": carvedChain,
298-
"fx-carve": settings(false),
299-
});
300-
act(() => dynamicBox(host).click());
301-
const write = onSetAttributeQuiet.mock.calls.find((c) => c[0] === "data-fx-carve");
302-
expect(JSON.parse(String(write![1])).dynamic).toBe(true);
303-
});
304-
305-
it("automates the carve filters' gain from the voice, in the bed's own time", async () => {
306-
stubDecode();
307-
// Voice starts 10s into the composition, bed at 0: the envelope is measured
308-
// against the voice but read from the start of the bed, so it has to shift.
309-
const { host, onSetAttributeQuiet } = mount({
310-
"fx-chain": carvedChain,
311-
// No source yet: picking one is what applies the carve.
312-
"fx-carve": settings(true, { source: "" }),
313-
start: "0",
314-
});
315-
const vo = document.getElementById("vo")!;
316-
vo.setAttribute("data-start", "10");
317-
vo.setAttribute("src", "voice.wav");
318-
await act(async () => {
319-
pickSource(host, "vo");
320-
});
321-
322-
// Chain first, then automation: a lane naming a node the chain does not
323-
// carry yet is dropped when it is read back.
324-
const order = onSetAttributeQuiet.mock.calls.map((c) => c[0]);
325-
// The settings land first, then the filters they imply, then the envelopes.
326-
expect(order.indexOf("data-fx-chain")).toBeLessThan(order.indexOf("data-automation"));
327-
328-
const carved = writeFor(onSetAttributeQuiet.mock.calls, "data-fx-chain").nodes;
329-
const carveNode = carved.find((n: { fromCarve?: boolean }) => n.fromCarve);
330-
expect(carveNode.id).toBeTruthy();
331-
332-
const lanes = writeFor(onSetAttributeQuiet.mock.calls, "data-automation").lanes;
333-
const lane = lanes.find((l: { target: string }) => l.target === `fx.${carveNode.id}.gain`) as {
334-
points: { t: number; v: number }[];
335-
};
336-
expect(lane).toBeTruthy();
337-
// Flat at the bed's own start, before the voice exists at all.
338-
expect(lane.points[0]).toMatchObject({ t: 0, v: 0 });
339-
// The voice's pause is at 0-1s of its own clip, so 10-11s of the bed's.
340-
expect(lane.points.find((p) => p.t > 10.5 && p.t < 11)?.v ?? 0).toBe(0);
341-
// And it cuts once the voice speaks, a second later. Depth is per band and
342-
// relative to that band's own peak in the voice, so the invariant is that the
343-
// envelope gets most of the way to what the analysis put on the node — not a
344-
// fixed number of dB, which changes with the band the analysis chose.
345-
const bandGain = Number(carveNode.params?.gain ?? 0);
346-
// At least half the depth the analysis put on the node; the exact floor
347-
// depends on which band it chose and how the envelope was thinned.
348-
expect(Math.min(...lane.points.map((p) => p.v))).toBeLessThanOrEqual(bandGain * 0.5);
349-
// Ends back at no cut, so the bed is not left dipped for the rest of the clip.
350-
expect(lane.points.at(-1)!.v).toBe(0);
351-
});
352-
353-
it("adds a gain stage that ducks the bed under the voice, automated when dynamic", async () => {
354-
// Carving frequencies cannot beat a bed that is simply louder than the
355-
// voice. The level half rides a gain node the carve owns, so the track's own
356-
// volume lane is left alone.
357-
stubDecode();
358-
const { host, onSetAttributeQuiet } = mount({
359-
"fx-chain": carvedChain,
360-
"fx-carve": settings(true, { strength: 1, source: "" }),
361-
start: "0",
362-
});
363-
const vo = document.getElementById("vo")!;
364-
vo.setAttribute("data-start", "0");
365-
vo.setAttribute("src", "voice.wav");
366-
// The bed is measured too — "how far over the voice is it" needs both.
367-
document.getElementById("bed")!.setAttribute("src", "bed.m4a");
368-
await act(async () => {
369-
pickSource(host, "vo");
370-
});
371-
372-
const nodes = writeFor(onSetAttributeQuiet.mock.calls, "data-fx-chain").nodes;
373-
const gain = nodes.find((n: { type: string }) => n.type === "gain");
374-
expect(gain).toBeTruthy();
375-
expect(gain.fromCarve).toBe(true);
376-
// Dynamic hands the value to the envelope, so the static one stays at unity.
377-
expect(gain.params.gain).toBe(0);
378-
379-
const lanes = writeFor(onSetAttributeQuiet.mock.calls, "data-automation").lanes;
380-
const duckLane = lanes.find((l: { target: string }) => l.target === `fx.${gain.id}.gain`);
381-
expect(duckLane).toBeTruthy();
382-
expect(Math.min(...duckLane.points.map((p: { v: number }) => p.v))).toBeLessThan(0);
383-
// Every carved band gets an envelope reaching that band's own analysed depth.
384-
for (const node of nodes.filter((n: { type: string }) => n.type === "peaking")) {
385-
const lane = lanes.find((l: { target: string }) => l.target === `fx.${node.id}.gain`) as
386-
| { points: { v: number }[] }
387-
| undefined;
388-
expect(lane, `band ${node.id} has no envelope`).toBeTruthy();
389-
const deepest = Math.min(...lane!.points.map((p) => p.v));
390-
expect(deepest).toBeLessThanOrEqual(0);
391-
expect(deepest).toBeGreaterThanOrEqual(node.params.gain - 0.2);
392-
expect(deepest).toBeLessThanOrEqual(node.params.gain * 0.5);
393-
}
394-
// The author's own volume lane is not something a carve gets to touch.
395-
expect(lanes.some((l: { target: string }) => l.target === "volume")).toBe(false);
396-
});
397-
398-
it("holds one measured value when the carve is not dynamic", async () => {
292+
it("holds one measured value from the voice and bed", async () => {
399293
stubDecode();
400294
const { host, onSetAttributeQuiet } = mount({
401295
"fx-chain": carvedChain,
402-
"fx-carve": settings(false, { strength: 1, source: "" }),
296+
"fx-carve": settings({ strength: 1, sources: [] }),
403297
start: "0",
404298
});
405299
const vo = document.getElementById("vo")!;
@@ -413,15 +307,15 @@ describe("AudioFxGroup dynamic carve", () => {
413307
const nodes = writeFor(onSetAttributeQuiet.mock.calls, "data-fx-chain").nodes;
414308
const gain = nodes.find((n: { type: string }) => n.type === "gain");
415309
expect(gain.params.gain).toBeLessThan(0);
416-
// Nothing to schedule: a static carve is a value, not an envelope.
310+
// Nothing to schedule: a carve is a value, not an envelope.
417311
expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-automation")).toBe(false);
418312
});
419313

420314
it("carves frequencies only when the duck is off", async () => {
421315
stubDecode();
422316
const { host, onSetAttributeQuiet } = mount({
423317
"fx-chain": carvedChain,
424-
"fx-carve": settings(true, { strength: 0, source: "" }),
318+
"fx-carve": settings({ strength: 0, sources: [] }),
425319
start: "0",
426320
});
427321
document.getElementById("vo")!.setAttribute("src", "voice.wav");
@@ -439,7 +333,7 @@ describe("AudioFxGroup dynamic carve", () => {
439333
stubDecode();
440334
const { host, onSetAttributeQuiet } = mount({
441335
"fx-chain": JSON.stringify({ version: 1, nodes: [] }),
442-
"fx-carve": JSON.stringify({ source: "", strength: 0.25, dynamic: true }),
336+
"fx-carve": JSON.stringify({ sources: [], strength: 0.25 }),
443337
start: "0",
444338
});
445339
document.getElementById("vo")!.setAttribute("src", "voice.wav");
@@ -448,44 +342,17 @@ describe("AudioFxGroup dynamic carve", () => {
448342
pickSource(host, "vo");
449343
});
450344
const written = onSetAttributeQuiet.mock.calls.map((c) => c[0]);
451-
expect(written).toEqual(["data-fx-carve", "data-fx-chain", "data-automation"]);
345+
expect(written).toEqual(["data-fx-carve", "data-fx-chain"]);
452346
const nodes = JSON.parse(
453347
String(onSetAttributeQuiet.mock.calls.find((c) => c[0] === "data-fx-chain")![1]),
454348
).nodes;
455349
expect(nodes.every((n: { fromCarve?: boolean }) => n.fromCarve)).toBe(true);
456350
});
457351

458-
it("re-applies when dynamic is switched on, not just when strength moves", async () => {
459-
stubDecode();
460-
const carvedAlready = JSON.stringify({
461-
version: 1,
462-
nodes: [
463-
{
464-
type: "peaking",
465-
id: "n1",
466-
fromCarve: true,
467-
params: { frequency: 1000, gain: -6, q: 1.4 },
468-
},
469-
],
470-
});
471-
const { host, onSetAttributeQuiet } = mount({
472-
"fx-chain": carvedAlready,
473-
"fx-carve": settings(false, { strength: 0.25 }),
474-
start: "0",
475-
});
476-
document.getElementById("vo")!.setAttribute("src", "voice.wav");
477-
document.getElementById("bed")!.setAttribute("src", "bed.m4a");
478-
await act(async () => {
479-
host.querySelector<HTMLInputElement>(".hf-fx-carve-dynamic")!.click();
480-
});
481-
// Static and dynamic are different chains, so the switch has to rebuild them.
482-
expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-chain")).toBe(true);
483-
});
484-
485352
it("re-applies an existing carve when strength moves", async () => {
486353
// Strength is the whole control surface, so it has to act on what is already
487-
// applied. Left to the button alone, a carve kept the filters and envelopes
488-
// its old strength produced and the knob silently described nothing.
354+
// applied. Left to the button alone, a carve kept the filters its old
355+
// strength produced and the knob silently described nothing.
489356
stubDecode();
490357
const carvedAlready = JSON.stringify({
491358
version: 1,
@@ -501,7 +368,7 @@ describe("AudioFxGroup dynamic carve", () => {
501368
});
502369
const { host, onSetAttributeQuiet } = mount({
503370
"fx-chain": carvedAlready,
504-
"fx-carve": settings(true, { strength: 0.25 }),
371+
"fx-carve": settings({ strength: 0.25 }),
505372
start: "0",
506373
});
507374
document.getElementById("vo")!.setAttribute("src", "voice.wav");
@@ -516,9 +383,8 @@ describe("AudioFxGroup dynamic carve", () => {
516383

517384
const written = onSetAttributeQuiet.mock.calls.map((c) => c[0]);
518385
expect(written).toContain("data-fx-carve");
519-
// The settings land first, then the filters they imply, then the envelopes.
386+
// The settings land first, then the filters they imply.
520387
expect(written.indexOf("data-fx-carve")).toBeLessThan(written.indexOf("data-fx-chain"));
521-
expect(written.indexOf("data-fx-chain")).toBeLessThan(written.indexOf("data-automation"));
522388

523389
const chainWrite = onSetAttributeQuiet.mock.calls.find((c) => c[0] === "data-fx-chain");
524390
const nodes = JSON.parse(String(chainWrite![1])).nodes;
@@ -537,7 +403,7 @@ describe("AudioFxGroup dynamic carve", () => {
537403
stubDecode();
538404
const { host, onSetAttributeQuiet } = mount({
539405
"fx-chain": JSON.stringify({ version: 1, nodes: [] }),
540-
"fx-carve": settings(true, { strength: 0.25, source: "" }),
406+
"fx-carve": settings({ strength: 0.25, sources: [] }),
541407
start: "0",
542408
});
543409
const dial = host.querySelector<HTMLInputElement>(".hf-fx-carve input[type=range]")!;
@@ -566,7 +432,7 @@ describe("AudioFxGroup dynamic carve", () => {
566432
});
567433
const { host, onSetAttributeQuiet, onSetAttributeLive } = mount({
568434
"fx-chain": carvedAlready,
569-
"fx-carve": settings(true, { strength: 0.25 }),
435+
"fx-carve": settings({ strength: 0.25 }),
570436
start: "0",
571437
});
572438
document.getElementById("vo")!.setAttribute("src", "voice.wav");
@@ -580,36 +446,6 @@ describe("AudioFxGroup dynamic carve", () => {
580446
expect(onSetAttributeLive.mock.calls.every((c) => c[0] === "data-fx-carve")).toBe(true);
581447
expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-chain")).toBe(false);
582448
});
583-
584-
it("drops the envelopes when dynamic is switched back off", async () => {
585-
// An automated gain ignores the panel's depth, so leaving the lanes behind
586-
// would keep the filters following a voice with nothing saying they do.
587-
const automation = JSON.stringify({
588-
version: 1,
589-
lanes: [
590-
{ target: "fx.n2.gain", points: [{ t: 0, v: 0 }] },
591-
{ target: "volume", points: [{ t: 0, v: 1 }] },
592-
],
593-
});
594-
const withCarveNode = JSON.stringify({
595-
version: 1,
596-
nodes: [
597-
{ type: "peaking", id: "n2", fromCarve: true, params: { frequency: 1000, gain: -6 } },
598-
],
599-
});
600-
const { host, onSetAttributeQuiet } = mount({
601-
"fx-chain": withCarveNode,
602-
"fx-carve": settings(true),
603-
automation,
604-
});
605-
await act(async () => {
606-
dynamicBox(host).click();
607-
});
608-
const write = onSetAttributeQuiet.mock.calls.find((c) => c[0] === "data-automation");
609-
expect(write).toBeTruthy();
610-
const lanes = JSON.parse(String(write![1])).lanes;
611-
expect(lanes.map((l: { target: string }) => l.target)).toEqual(["volume"]);
612-
});
613449
});
614450

615451
describe("AudioFxGroup successive edits", () => {

0 commit comments

Comments
 (0)