From 4dacf2842eba000ec8e3854b2907e6df46705b30 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 12 Aug 2026 23:30:27 -0700 Subject: [PATCH] fix(studio): close the typecheck and fallow gaps wa-18b-reschedule opened MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useAutomationLanes.ts's write() assumed gesture-scoped coalescing and a preview-only commit that useDomEditAttributeCommits.ts never grew — backported that option support from its own later commit so the two sides of the API agree. The paste path and its tests were missing the box selection's v0/v1 bounds a sibling commit added to AutomationSelection. The FX panel's carve controls still edited the six mechanism numbers (maxCutDb, bands, intelligibilityBias) after carveProfile() collapsed authoring to one Strength knob, so those fields no longer existed on HfCarveSettings; UI now edits strength, and analyseCarveBands is called with carveProfile(strength). Also closes fallow's complexity, dead-code and duplication findings on this PR's diff: extracted automationLaneDragMath.ts (pure group/point-move math) and useAutomationRangeDrag.ts (the marquee-select gesture) out of useAutomationLaneGestures.ts, pulled a couple of render-loop ternaries and a resolver into named functions, dropped an export nothing outside its file used, and shared a step-simplifier between audioCarve's two envelope builders. The edge-stretch vs. box-select priority test in TimelineAutomationLane.test was still pinning the pre-box-select rule (edge wins over a point sitting on it) that a sibling commit deliberately reversed — a point inside the box is now selected content, so grabbing it drags the group instead. Updated the test to the shipped rule instead of the old one. Co-Authored-By: Claude Opus 5 (1M context) --- .../core/src/audio/audioFxAutomation.test.ts | 45 ++- packages/core/src/audioCarve.ts | 55 ++-- .../editor/propertyPanelAudioFxGroup.test.tsx | 2 +- .../editor/propertyPanelAudioFxGroup.tsx | 7 +- .../editor/propertyPanelFxSection.tsx | 48 +-- .../useAutomationSelectionKeyboard.test.tsx | 41 ++- .../hooks/useAutomationSelectionKeyboard.ts | 13 +- .../src/hooks/useDomEditAttributeCommits.ts | 66 ++++- .../TimelineAutomationLane.test.tsx | 74 ++--- .../components/TimelineAutomationLane.tsx | 26 +- .../player/components/TimelineTrackHeader.tsx | 41 ++- .../player/components/automationLaneData.ts | 2 +- .../components/automationLaneDragMath.ts | 194 ++++++++++++ .../components/useAutomationLaneGestures.ts | 278 ++++-------------- .../components/useAutomationRangeDrag.ts | 114 +++++++ 15 files changed, 621 insertions(+), 385 deletions(-) create mode 100644 packages/studio/src/player/components/automationLaneDragMath.ts create mode 100644 packages/studio/src/player/components/useAutomationRangeDrag.ts diff --git a/packages/core/src/audio/audioFxAutomation.test.ts b/packages/core/src/audio/audioFxAutomation.test.ts index e73c14ede9..c6c92cecf1 100644 --- a/packages/core/src/audio/audioFxAutomation.test.ts +++ b/packages/core/src/audio/audioFxAutomation.test.ts @@ -64,6 +64,23 @@ const ramp: HfAutomationLane = { ], }; +const curvedRamp: HfAutomationLane = { + target: "volume", + points: [ + { t: 0, v: 0, curve: 1 }, + { t: 2, v: 1 }, + ], +}; + +/** Schedule `curvedRamp` and return the fake param it wrote to — the setup + * three tests below share, each checking something different about the + * curve it produces. */ +function scheduleCurved(): { target: FxParamTarget; param: FakeParam } { + const { target, param } = fake(); + scheduleParamLane([target], curvedRamp, "linear", at(0)); + return { target, param }; +} + describe("scheduleParamLane", () => { it("seeds the current value, then ramps to each later point", () => { const { target, param } = fake(); @@ -133,19 +150,7 @@ describe("scheduleParamLane", () => { }); it("samples a curved segment rather than ramping through the bend", () => { - const { target, param } = fake(); - scheduleParamLane( - [target], - { - target: "volume", - points: [ - { t: 0, v: 0, curve: 1 }, - { t: 2, v: 1 }, - ], - }, - "linear", - at(0), - ); + const { param } = scheduleCurved(); const curve = param.calls.find((c) => c.op === "curve"); expect(curve).toBeTruthy(); if (curve?.op !== "curve") throw new Error("expected a curve"); @@ -210,19 +215,7 @@ describe("scheduleParamLane", () => { }); it("does not seed on top of a curve that starts at the same instant", () => { - const { target, param } = fake(); - scheduleParamLane( - [target], - { - target: "volume", - points: [ - { t: 0, v: 0, curve: 1 }, - { t: 2, v: 1 }, - ], - }, - "linear", - at(0), - ); + const { param } = scheduleCurved(); // A value curve may not overlap another event, so there is no set at 10. expect(param.calls.filter((c) => c.op === "set")).toEqual([]); expect(param.calls[1]?.op).toBe("curve"); diff --git a/packages/core/src/audioCarve.ts b/packages/core/src/audioCarve.ts index 7f07b855ed..1844010518 100644 --- a/packages/core/src/audioCarve.ts +++ b/packages/core/src/audioCarve.ts @@ -376,6 +376,34 @@ const SNAP_DB = 0.2; */ const STEP_DB = 0.5; +/** + * Thin a raw per-frame envelope down to the points where its value actually + * moved by `stepDb`, anchoring the run each drop ends. + * + * Segments interpolate, so dropping a run of equal values does not hold them — + * it draws a straight line from wherever the last kept point was, which turned + * a silent stretch before the run into a slow slide into it. Keeping the last + * value of the run pins the plateau flat and puts the whole move where it + * belongs. Shared by the two envelope builders below, which differ only in how + * `raw` gets built. + */ +function simplifyStepPoints( + raw: readonly { t: number; v: number }[], + stepDb: number, +): { t: number; v: number }[] { + const points: { t: number; v: number }[] = [{ t: 0, v: 0 }]; + let lastKept = 0; + let keptIndex = -1; + raw.forEach((pt, i) => { + if (i !== raw.length - 1 && Math.abs(pt.v - lastKept) < stepDb) return; + if (i > 0 && keptIndex !== i - 1) points.push(raw[i - 1]!); + points.push(pt); + lastKept = pt.v; + keptIndex = i; + }); + return points; +} + export interface HfCarveDynamics { /** The band this envelope drives, matching a band from `analyseCarveBands`. */ freq: number; @@ -434,8 +462,6 @@ export function analyseCarveDynamics( const powers = perBand[b]!; const peak = Math.max(...powers); let level = 0; - const points: { t: number; v: number }[] = [{ t: 0, v: 0 }]; - let lastKept = 0; const raw: { t: number; v: number }[] = []; powers.forEach((p, i) => { // Relative to this band's own loudest moment, so a quiet band still gets a @@ -447,19 +473,7 @@ export function analyseCarveDynamics( const v = Math.abs(scaled) < SNAP_DB ? 0 : Number(scaled.toFixed(1)); raw.push({ t: Number(times[i]!.toFixed(3)), v }); }); - let keptIndex = -1; - raw.forEach((pt, i) => { - if (i !== raw.length - 1 && Math.abs(pt.v - lastKept) < STEP_DB) return; - // Anchor the run this point ends. Segments interpolate, so dropping a run - // of equal values does not hold them — it draws a straight line from - // wherever the last kept point was, which turned a silent stretch before - // the first word into a slow 2 dB slide into it. Keeping the last value of - // the run pins the plateau flat and puts the whole move where it belongs. - if (i > 0 && keptIndex !== i - 1) points.push(raw[i - 1]!); - points.push(pt); - lastKept = pt.v; - keptIndex = i; - }); + const points = simplifyStepPoints(raw, STEP_DB); // The last point's value is held for the rest of the bed, so it has to be // no cut and it has to be at the end of the voice. points.push({ t: Number(Math.max(duration, points.at(-1)!.t).toFixed(3)), v: 0 }); @@ -554,16 +568,7 @@ export function analyseCarveDuck( }); } - const points: { t: number; v: number }[] = [{ t: 0, v: 0 }]; - let lastKept = 0; - let keptIndex = -1; - raw.forEach((pt, i) => { - if (i !== raw.length - 1 && Math.abs(pt.v - lastKept) < STEP_DB) return; - if (i > 0 && keptIndex !== i - 1) points.push(raw[i - 1]!); - points.push(pt); - lastKept = pt.v; - keptIndex = i; - }); + const points = simplifyStepPoints(raw, STEP_DB); // Pin the end at no cut, unless the release already got there — a second point // at the same time and value is just noise in the lane. const duration = voice.length / sampleRate; diff --git a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx index de4153689b..e176af0cf3 100644 --- a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx @@ -246,7 +246,7 @@ describe("AudioFxGroup carve", () => { act(() => carveToggle(host).click()); const write = onSetAttributeQuiet.mock.calls.find((c) => c[0] === "data-fx-carve"); expect(write).toBeTruthy(); - expect(JSON.parse(String(write![1])).bands).toBeGreaterThan(0); + expect(JSON.parse(String(write![1])).strength).toBeGreaterThan(0); }); }); diff --git a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx index 0a04892e97..2d56849959 100644 --- a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx +++ b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx @@ -17,6 +17,7 @@ import { import { analyseCarveBands, carveBandsToChain, + carveProfile, HF_AUDIO_CARVE_ATTR, normalizeCarveSettings, type HfCarveSettings, @@ -192,7 +193,11 @@ export function AudioFxGroup({ if (!Ctor) return; const decoder = new Ctor(1, 1, DECODE_SAMPLE_RATE); const buffer = await decoder.decodeAudioData(bytes); - const bands = analyseCarveBands(buffer.getChannelData(0), buffer.sampleRate, carve); + const bands = analyseCarveBands( + buffer.getChannelData(0), + buffer.sampleRate, + carveProfile(carve.strength), + ); const carved = carveBandsToChain(bands); // Carve output is tagged so a re-run replaces it instead of stacking. const kept = chain.nodes.filter((n) => !n.fromCarve); diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.tsx index 56b7b09132..8e629af84b 100644 --- a/packages/studio/src/components/editor/propertyPanelFxSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFxSection.tsx @@ -487,51 +487,19 @@ export function FxSection({ previewCarve({ ...carve, maxCutDb: Number(v) })} - onCommit={(_k, v) => onCarveChange({ ...carve, maxCutDb: Number(v) })} - /> - previewCarve({ ...carve, bands: Number(v) })} - onCommit={(_k, v) => onCarveChange({ ...carve, bands: Number(v) })} - /> - previewCarve({ ...carve, intelligibilityBias: Number(v) })} - onCommit={(_k, v) => onCarveChange({ ...carve, intelligibilityBias: Number(v) })} + onChange={(_k, v) => previewCarve({ ...carve, strength: Number(v) })} + onCommit={(_k, v) => onCarveChange({ ...carve, strength: Number(v) })} />