From 91db93aa345a8dfc2b68ec0ac18c81065a805cd9 Mon Sep 17 00:00:00 2001
From: Vance Ingalls
Date: Wed, 12 Aug 2026 23:55:50 -0700
Subject: [PATCH 1/4] fix(core): escape `<` in the compiler-emitted variables
script (#3071)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(core): escape `<` in the compiler-emitted variables script
`` terminated the element
early and the remainder was parsed as markup, corrupting the compiled
document.
Rewrite `<` to its JSON unicode escape. This is transparent to JSON.parse,
so the table the runtime reads is unchanged.
Co-Authored-By: Claude Fable 5
* fix(core): centralize the JSON-in-script escape for every emitted literal
Escaping only the variables table left the composition id exploitable through
the wrapper it is emitted beside: wrapScopedCompositionScript serializes the
comp id, timeline comp id, authored root id, scope-selector override, error
label and two derived selector patterns with a bare JSON.stringify, and
wrapInlineScriptWithErrorBoundary does the same for the composition's own
source. All land in the same raw-text
---
.../src/compiler/compositionScoping.test.ts | 139 +++++++++++++++++-
.../core/src/compiler/compositionScoping.ts | 46 ++++--
2 files changed, 172 insertions(+), 13 deletions(-)
diff --git a/packages/core/src/compiler/compositionScoping.test.ts b/packages/core/src/compiler/compositionScoping.test.ts
index 7985c9f809..b9d7e72d79 100644
--- a/packages/core/src/compiler/compositionScoping.test.ts
+++ b/packages/core/src/compiler/compositionScoping.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { parseHTML } from "linkedom";
import {
+ buildVariablesByCompScript,
scopeCssToComposition,
wrapInlineScriptWithErrorBoundary,
wrapScopedCompositionScript,
@@ -699,13 +700,18 @@ window.__afterTimeline = window.__timelines.scene;
});
it("wraps unscoped composition script source as a string literal", () => {
+ const source = 'window.payload = "";';
const wrapped = wrapInlineScriptWithErrorBoundary(
- 'window.payload = "";',
+ source,
"[HyperFrames] composition script error:",
);
expect(wrapped).toContain("Function(");
- expect(wrapped).toContain('\\"\\"');
+ // The literal carries the source verbatim, with `<` escaped so it cannot end the
+ // raw-text ` {
@@ -886,3 +892,132 @@ window.__timelines['intro'] = tl;
expect(gsapTargets).toEqual([["HELLO"]]);
});
});
+
+/**
+ * The emitted statement is placed inside a ``),
+ * so a payload whose breakout is not the first `<` is the realistic one.
+ */
+const SCRIPT_BREAKOUT = "x {
+ const body = buildVariablesByCompScript({
+ "comp-a": { [SCRIPT_BREAKOUT]: "x" },
+ });
+ expect(body).not.toContain(" {
+ const body = buildVariablesByCompScript({
+ [SCRIPT_BREAKOUT]: { a: "x" },
+ });
+ expect(body).not.toContain(" {
+ // Run the statement the way the browser does rather than string-slicing it.
+ const variables = { "comp-a": { greeting: "a b c " } };
+ const body = buildVariablesByCompScript(variables) ?? "";
+ const fakeWindow: Record = {};
+ new Function("window", body)(fakeWindow);
+ expect(fakeWindow.__hfVariablesByComp).toEqual(variables);
+ });
+
+ it("returns null when there are no per-instance values", () => {
+ expect(buildVariablesByCompScript({})).toBeNull();
+ });
+});
+
+/**
+ * The variables table is not the only attacker-reachable literal emitted into a
+ * ` {
+ const body = wrapScopedCompositionScript("console.log(1);", SCRIPT_BREAKOUT);
+ const literal = /var __hfCompId = (.*);/.exec(body)?.[1];
+ expect(literal).toBeDefined();
+ expect(JSON.parse(literal ?? "")).toBe(SCRIPT_BREAKOUT);
+ });
+
+ it("does not let the AUTHORED ROOT ID close the script element", () => {
+ const body = wrapScopedCompositionScript(
+ "console.log(1);",
+ "comp-a",
+ LABEL,
+ undefined,
+ "comp-a",
+ SCRIPT_BREAKOUT,
+ );
+ expect(body).not.toContain(" {
+ const body = wrapScopedCompositionScript("console.log(1);", "comp-a", LABEL, SCRIPT_BREAKOUT);
+ expect(body).not.toContain(" {
+ const body = wrapScopedCompositionScript("console.log(1);", "comp-a", SCRIPT_BREAKOUT);
+ expect(body).not.toContain(" breakout", () => {
+ it("does not let the wrapped SOURCE close the script element", () => {
+ const body = wrapInlineScriptWithErrorBoundary(`var a = "${SCRIPT_BREAKOUT}";`, "[err]");
+ expect(body).not.toContain(" {
+ const body = wrapInlineScriptWithErrorBoundary("var a = 1;", SCRIPT_BREAKOUT);
+ expect(body).not.toContain("` element.
+ *
+ * `` would close
+ * the element early and have the remainder parsed as markup. Rewriting every
+ * `<` to `<` removes the only byte that can start a closing tag, and is
+ * transparent to both `JSON.parse` and the JS string grammar, so the value the
+ * runtime reads is unchanged.
+ *
+ * Every dynamic literal in an emitted script body must go through here: a
+ * per-value guard on this surface has already been missed once, since the
+ * composition id reaches the emitted script through four separate literals.
+ */
+function jsonScriptLiteral(value: unknown): string {
+ return JSON.stringify(value).replace(/>,
): string | null {
if (!variablesByComp || Object.keys(variablesByComp).length === 0) return null;
- return `window.__hfVariablesByComp = Object.assign({}, window.__hfVariablesByComp || {}, ${JSON.stringify(variablesByComp)});`;
+ const json = jsonScriptLiteral(variablesByComp);
+ return `window.__hfVariablesByComp = Object.assign({}, window.__hfVariablesByComp || {}, ${json});`;
}
From f9a20692f8a0ee10330d0a95de1abd422e92d74a Mon Sep 17 00:00:00 2001
From: Vance Ingalls
Date: Thu, 13 Aug 2026 00:17:35 -0700
Subject: [PATCH 2/4] fix(core): escape `<` in compiler-emitted composition
variable CSS (#3072)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(core): escape `<` in the compiler-emitted variables script
`` terminated the element
early and the remainder was parsed as markup, corrupting the compiled
document.
Rewrite `<` to its JSON unicode escape. This is transparent to JSON.parse,
so the table the runtime reads is unchanged.
Co-Authored-By: Claude Fable 5
* fix(core): centralize the JSON-in-script escape for every emitted literal
Escaping only the variables table left the composition id exploitable through
the wrapper it is emitted beside: wrapScopedCompositionScript serializes the
comp id, timeline comp id, authored root id, scope-selector override, error
label and two derived selector patterns with a bare JSON.stringify, and
wrapInlineScriptWithErrorBoundary does the same for the composition's own
source. All land in the same raw-text ` element. ``
therefore terminated the stylesheet and the remainder was parsed as markup.
Escape `<` to `\\3c ` in `compositionVariablesCssBlock`. That is the CSS
escape for `<`, valid in every value position — including inside an
unquoted `url()`, whose grammar permits escape sequences — so rendering is
unchanged. Variable ids need no equivalent: `cssVariableName` slugifies them.
Co-Authored-By: Claude Fable 5
* fix(core): close the comp-id selector and match the runtime scalar contract
Escaping the variable value left two holes on the same stylesheet.
The composition id reaches the raw-text ` therefore injected markup; escaping `<` there covers all
three call sites at once and still matches the same attribute value.
The value path also diverged from the runtime. applyVariableBindings strips
[;{}<>\r\n] from a scalar before it reaches a var() site, and
docs/concepts/variables.mdx promises that, but the compiler removed only `<` —
so `red; } body { … }` emitted a real sibling rule at compile time that the
runtime would have refused. The compiler now reuses that exported sanitizer, so
a rendered MP4 cannot diverge from the preview it was approved from.
Both are verified by mutation: reverting either escape resurrects an executing
script or the smuggled `body` rule.
---------
Co-authored-by: Claude Fable 5
---
.../core/src/compiler/htmlBundler.test.ts | 75 ++++++++++++++++++-
packages/core/src/compiler/htmlBundler.ts | 45 ++++++++++-
.../core/src/runtime/applyVariableBindings.ts | 7 +-
3 files changed, 123 insertions(+), 4 deletions(-)
diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts
index 3a4f86da70..d28f7622fc 100644
--- a/packages/core/src/compiler/htmlBundler.test.ts
+++ b/packages/core/src/compiler/htmlBundler.test.ts
@@ -4,8 +4,9 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { parseHTML } from "linkedom";
import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
-import { bundleToSingleHtml } from "./htmlBundler";
+import { bundleToSingleHtml, emitRootCompositionVariableStyles } from "./htmlBundler";
import { resetUnknownEnumWarnings } from "../runtime/getVariables";
+import { sanitizeCssValue } from "../runtime/applyVariableBindings";
import { getHyperframeRuntimeScript } from "../generated/runtime-inline";
function makeTempProject(files: Record): string {
@@ -1510,3 +1511,75 @@ describe("bundleToSingleHtml unknown enum values", () => {
]);
});
});
+
+/**
+ * Composition variable values are emitted as CSS declarations inside a ` breakout", () => {
+ /**
+ * The payload leads with a benign `<` before its ` {
+ // `sanitizeCssValue` is the runtime contract for a scalar folded into
+ // `background: var(--x)`; the compile path has to reach the same result, or a
+ // rendered MP4 diverges from the preview it was approved from.
+ const smuggle = "red; } body { background-image: url(//evil?data=1) } x { y:z";
+ const { css } = scriptsAfterRoundTrip({ "comp-a": { brand: smuggle } });
+
+ expect(css).not.toContain("body {");
+ // One rule, one declaration: with no `;{}` left in the value there is nothing to
+ // close the declaration with, so no sibling rule can be opened.
+ expect(css.match(/\}/g) ?? []).toHaveLength(1);
+ expect(css).toContain(`--brand: ${sanitizeCssValue(smuggle)};`);
+ });
+
+ it("strips '<' from a value the way the runtime does", () => {
+ const { css, scripts } = scriptsAfterRoundTrip({ "comp-a": { brand: "a {
+ const { css } = scriptsAfterRoundTrip({ "comp-a": { brand: "#ff0066" } });
+ expect(css).toContain("#ff0066");
+ });
+});
diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts
index c3fddf249c..1eff4f9624 100644
--- a/packages/core/src/compiler/htmlBundler.ts
+++ b/packages/core/src/compiler/htmlBundler.ts
@@ -1,6 +1,7 @@
import { markFlattenedInnerRoot } from "../runtime/flattenedRoot";
export { FLATTENED_INNER_ROOT_STRIP_ATTRS } from "../runtime/flattenedRoot";
import { parseHostVariableValues, warnUnknownEnumValues } from "../runtime/getVariables";
+import { sanitizeCssValue } from "../runtime/applyVariableBindings";
import { cssVariableName } from "../tokenSlug";
import { readFileSync, existsSync } from "fs";
import { resolve, relative, dirname, isAbsolute, sep } from "path";
@@ -389,8 +390,17 @@ function rewriteCssUrlsWithInlinedAssets(cssText: string, projectDir: string): s
);
}
+/**
+ * Selectors built here are serialized inside a `` element, which is a RAW
+ * TEXT element: HTML serialization does not escape its content and the
+ * tokenizer ends it at the first `,
@@ -1143,7 +1184,7 @@ function compositionVariablesCssBlock(
const lines: string[] = [];
for (const [id, value] of Object.entries(variables)) {
if ((typeof value === "string" && value !== "") || typeof value === "number") {
- lines.push(` ${cssVariableName(id)}: ${String(value)};`);
+ lines.push(` ${cssVariableName(id)}: ${cssSafeVariableValue(value)};`);
}
}
if (lines.length === 0) return null;
diff --git a/packages/core/src/runtime/applyVariableBindings.ts b/packages/core/src/runtime/applyVariableBindings.ts
index 7b704f9338..10e961f54b 100644
--- a/packages/core/src/runtime/applyVariableBindings.ts
+++ b/packages/core/src/runtime/applyVariableBindings.ts
@@ -75,8 +75,13 @@ function isSafeMediaUrl(url: string): boolean {
* characters is legal in a scalar variable value (string, number, color, font
* family), so removing them is lossless for real inputs and neutralizes the
* declaration/URL-exfiltration channel.
+ *
+ * Exported because the static compiler bakes the same scalars into a stylesheet
+ * at build time and has to reach the same result: a value that the runtime
+ * strips but a compile-time emit passes through would make the rendered MP4
+ * differ from the preview, which is the more dangerous of the two directions.
*/
-function sanitizeCssValue(value: string): string {
+export function sanitizeCssValue(value: string): string {
return value.replace(/[;{}<>\r\n]/g, "");
}
From 69face9dc6a8faab5c728860cc7a3f7170d17e07 Mon Sep 17 00:00:00 2001
From: Vance Ingalls
Date: Thu, 13 Aug 2026 01:10:29 -0700
Subject: [PATCH 3/4] fix(core): make audio automation survive being
rescheduled (#3208)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* refactor(studio): split the FX node row out of FxSection
Clears the health findings the FX stack left behind: the chain-node render
callback was a 70-line closure over half of FxSection's state, and the two
reorder arrows were the same button written twice.
Also drops two exports with no consumers, and registers the audio FX runtime
stub as an entry point — it is bundled by file path, so nothing imports it.
Co-Authored-By: Claude Opus 5 (1M context)
* refactor(studio): lift the audio FX group out of PropertyPanelFlat
`PropertyPanelFlat.tsx` was 672 lines against the repo's 600-line cap, so
the required File size check was red — the sole reason #3014 and #3022 are
blocked. Both reviews say the same thing: "mechanical fix, not a design
problem. Code itself is LGTM."
Moves `AudioFxGroup` and `audioFxSummary` into
`propertyPanelAudioFxGroup.tsx`, which is where a later branch puts them
anyway — done here so the file is under the cap from the point it first
crosses it, rather than ten branches later.
533 lines now. The four audio imports it no longer needs go with it.
Not fixed here: three `FxSection carve` tests fail on this branch with
"Cannot read properties of undefined (reading 'toFixed')". Confirmed
pre-existing by stashing this change and re-running — that is the separate
`Test` failure the review also flags.
* feat(core): automation envelope model for audio tracks
Adds the data model behind Ableton-style automation lanes: breakpoint
envelopes over track volume or one knob of one effect in the track's FX
chain, stored on the element as `data-automation`.
Times are clip-local, so an envelope travels with the clip when it moves —
the clip-envelope model rather than arrangement automation.
`sampleAutomationLane` is the single interpolator. The lane drawing, the
preview scheduler and the render bake all call it, so the picture and the
sound cannot disagree about the curve. Log-scaled parameters interpolate in
log space, matching what their own knob already promises.
FX nodes gain a stable `id`, minted by count rather than randomly so the
document is the same on every machine. Lanes address nodes by id, so
reordering a chain never re-points a lane at a different effect, and a lane
whose effect was deleted is dropped rather than left to reattach.
Also warns when a track carries both a volume lane and a GSAP volume tween,
since only the lane is heard and the tween silently does nothing.
Co-Authored-By: Claude Opus 5 (1M context)
* feat(core): expose the AudioParams behind automatable FX knobs
Marks the knobs an automation lane can drive and has each graph builder hand
back the AudioParam behind them, so a scheduler can write to a running effect
without knowing what the effect is.
A knob is not always one AudioParam. A wet/dry mix is two gains moving in
opposition, and a knob in milliseconds drives a delay time in seconds, so
each target carries the mapping out of the knob's own declared unit.
What stays unautomatable is stated where it is decided: a WaveShaper curve, a
convolution impulse and a one-pole filter's coefficients are all rebuilt
wholesale rather than scheduled, and the four worklet effects take values by
postMessage rather than through AudioParams.
The registry flag is written by hand, so a test builds every effect and
checks the exposure both ways — nothing flagged is missing, nothing exposed
is unflagged. A flag that lied would offer a lane that silently did nothing.
Co-Authored-By: Claude Opus 5 (1M context)
* feat(core): play automation envelopes in preview
Schedules each lane onto the AudioParams behind its knob using native ramps
and value curves. Nothing evaluates the envelope per frame: it is handed to
the audio thread once, so it stays sample-accurate however busy the main
thread is, and the offline render will schedule it the same way.
Timing comes from the transport, so an envelope survives seeking into the
middle of a clip, a clip that has not started yet, and a playback rate that
compresses clip seconds into context seconds.
A straight line is only scheduled as a ramp when nothing bends it — no
curvature, a linear parameter scale, and no unit mapping. Log-scaled
parameters and mapped ones are sampled instead, since a delay knob in
milliseconds and a wet/dry pair moving in opposition are not linear in the
parameter they drive.
Lanes with nowhere to write are skipped rather than reported: a one-pole
filter exposes no frequency param, and the worklet effects expose none at
all. Editing an envelope mid-playback re-aims it at the live playhead rather
than restarting the track.
Co-Authored-By: Claude Opus 5 (1M context)
* feat(engine): bake automation envelopes into the render
The offline render schedules FX lanes with the same scheduler preview uses,
inside the OfflineAudioContext that already runs the same graph builders. The
input WAV is the clip's own audio from its first sample, so clip-local time
is offline time and the envelope needs no offset.
Volume lanes take the existing PCM bake rather than a second mechanism: the
lane is converted to keyframes, so a straight fade stays two of them and only
a bent segment is sampled — the baker interpolates linearly and would
otherwise quietly straighten the curve. A volume lane supersedes keyframes
probed from the timeline, which `lint` already warns about.
A browser test sweeps a lowpass from below a 2 kHz tone to well above it and
measures both ends. Parsing the envelope is not the same as scheduling it,
and only running the real thing tells the two apart.
Co-Authored-By: Claude Opus 5 (1M context)
* fix(core): make the volume lane audible in preview
The envelope was scheduled onto the transport's gain AudioParam, but the
runtime rewrites that gain every tick from `data-volume` and the GSAP-seeked
value — so it was erased within a frame. Volume automation was correct in the
render and inaudible while previewing.
The lane now feeds the per-tick path where the probed volume keyframes already
sit, checked ahead of them so the two cannot fight, and the transport no
longer schedules volume at all: one mechanism instead of two racing.
The cost is honest — in preview the level steps per tick rather than per
sample, exactly as the existing keyframe path does. The render still bakes it
into the PCM sample-accurately, and FX parameters are still scheduled on their
own AudioParams, since nothing rewrites those.
Parsed lanes are cached by attribute text: the runtime asks once per tick per
track, and parsing there would run the JSON parser 60 times a second for a
value that only changes on an edit.
Co-Authored-By: Claude Opus 5 (1M context)
* fix(core): apply chain edits to the running graph
A structural edit — an effect added, removed, bypassed, or a filter's pole
count switched — was dropped. `buildFxChain`'s update reports false when the
change is not merely new values, and the attribute observer ignored that, so
the edit only took hold when the persisting write reloaded the composition.
That reload restarted every playing track, which is what was heard as the
audio chopping.
The graph is now swapped in place: the old effects are detached, the new ones
built and connected between the same source and gain, and any lanes
re-scheduled onto the new nodes. The source node is never touched, so playback
does not restart.
A track with no chain is watched too, rather than wired through and forgotten,
so adding its first effect is heard the same way. That means the function
always returns a disposer instead of null for the empty case.
Co-Authored-By: Claude Opus 5 (1M context)
* feat(studio): the geometry and plumbing behind automation lanes
Everything an automation lane needs before there is a lane to look at, kept
apart from the component so the maths can be read and tested without a pointer.
`automationLaneGeometry` is pure: which parameters a clip can automate (its
fader, then each automatable knob of each effect that carries a node id), how a
value maps to a position in the lane, and how a lane is edited. Two decisions
live here and are worth review:
- A log-read knob maps on its own log scale, so the middle of a 100 Hz–20 kHz
lane is the geometric mean. Dragging and drawing then agree with what the
knob's own scale already promises.
- `withLane` replaces a lane in place rather than appending. A lane with no
explicitly chosen parameter shows whichever comes first, so moving the edited
one to the end would switch the lane out from under the pointer on the first
edit.
`automationLaneData` parses the two attributes, cached by their text so the
identity only changes when the text does — the lane holds an optimistic draft
while a point is dragged and compares against that identity, and a fresh object
on every playhead tick would throw the drag away. It binds automation to the
chain the way preview and the render bind it, so a lane whose effect was deleted
is dropped rather than drawn against the wrong axis.
`useAutomationLanes` routes edits through the DOM edit session, targeting the
selected element because that is what the attribute commit path writes to.
Row height reserves each lane at its own height rather than counting it as
another keyframe lane, and `TimelinePropertyLanes` gains a footer slot so the
lanes share the keyframe disclosure — and its `aria-controls`.
`TimelineElement` moves to its own module: playerStore had reached the 600-line
studio ceiling exactly and could not carry another field. It is re-exported from
there, so no importer changes.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
* fix(studio): make automationLaneData reviewable, and evict one entry not all
The cache key held a literal NUL byte instead of its escape, so git classified
the whole module as binary: it landed as `Bin 0 -> 2677 bytes` with zero
diffable lines, invisible to review, to grep, and to any textual merge. The
escape is behaviour-identical.
With the file readable, two things in it needed fixing.
Eviction cleared the entire map. Clearing changes the identity of every lane's
automation at once, and a lane compares its drag draft against that identity —
so one unrelated element arriving at the limit would release an in-progress drag
and snap the point back. It now drops the oldest entry, and a hit is re-inserted
so it counts as recently used.
Nothing tested this module, which is what let the binary blob through. Now
covered: identity stability, re-parsing when the chain changes but the
automation text does not, a hot entry surviving 40 evictions, and an unreadable
attribute reading as nothing.
The geometry module's exports are ignored for dead-code while its consumer sits
one PR upstack, following the convention already used for the fast-capture
stack.
Co-Authored-By: Claude Opus 5 (1M context)
* fix(studio): carry the audio FX attributes onto every timeline row
Both element builders read `data-fx-chain` and `data-automation` off the host
element, and an expanded sub-composition child is built without one — so an audio
track inside a sub-composition reserved no automation height and drew no lanes,
while the property panel, which reads the live DOM selection rather than the row,
still showed its chain and its toggles. `hostElementState` exists to re-inherit
exactly this class of host-only field; it now covers these two alongside
`hidden`, `timelineLocked` and `timelineRole`.
`parseTimelineFromDOM` had the same gap and now reads both directly.
Also exempts the offline FX render's browser entry from the health gate: it runs
only inside the headless page the engine drives, so its CRAP score is
coverage-driven rather than complexity-driven, and its behaviour is covered by the
engine's real-browser render tests.
Co-Authored-By: Claude Opus 5 (1M context)
* feat(studio): the automation lane itself
Draws each automated parameter as its own lane under the audio clip, on the same
disclosure caret the keyframe lanes use — that caret is the DAW automation
triangle. One lane per parameter rather than a selector to swap between them, so
two envelopes can be read and edited without hiding either.
Double-click the line to add a point, drag to shape it, right-click a point to
remove it.
Three things here took more than one attempt, and the comments say why:
- **A dragged point did not move.** The live write deliberately skips the preview
refresh — that is what keeps dragging from restarting playback — so the stored
value does not move under the pointer. The lane keeps a local draft.
- **Releasing snapped it back.** The draft was dropped when the drag ended, which
is before the persisted write comes around; it now lives until the automation
it was drawn over actually changes.
- **A press was eaten.** Not stopping propagation let the timeline start its own
gesture and swallow the second half of a double-click. The lane owns the press
once it is live — and when it is not, it selects its clip instead, since lanes
sit below the clip bar where the timeline's own selection handler never sees
them.
The envelope is inset by the grab radius so a point at the clip's first or last
frame is drawn whole rather than half outside the lane, and clip time still lines
up with screen position because the inset and the offset cancel.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
* fix(studio): let a track disclose its automation without a tween
The lane was mounted inside the property-lanes wrapper, which renders only for a
track's GSAP keyframe clip — so an audio clip with no tween resolved to nothing:
no disclosure caret, no reserved height, no lanes. Verified on a composition with
one ``, an envelope, and no tweens anywhere: 0 carets, 0 lanes. The
attribute still wrote and the render still baked it, so the feature failed
silently for exactly the tracks it exists for. Same composition now: 1 caret, and
expanding it draws the Volume lane.
Automation counts as something to disclose. `resolveTrackKeyframeClip` takes a
counter alongside the keyframe lane counts and qualifies a clip on either; the
header asks the same counter about the clip it already holds. A function rather
than another map threaded through the props: every caller then reads one cached
parse, so the height a row reserves and the lanes drawn in it cannot drift apart.
That drift is also fixed for the lane's own offset, which passed the raw tween
count where every other consumer uses distinct property groups. Two tweens on one
property drew one keyframe lane but pushed the automation lane down by two,
spilling into the next track; one tween on two properties did the inverse and
drew it over a diamond lane, stealing its pointer events. It now reads the same
`laneCounts` map the reserved height and the drawn lanes use.
Co-Authored-By: Claude Opus 5 (1M context)
* feat(studio): automate a parameter without reloading the preview
The write path and the volume half of the panel surface.
**A commit that persists without reloading.** For attributes the runtime applies
to the live graph itself — an FX chain, its automation — a reload would only
interrupt playback to reach the state the preview already has. `skipRefresh` and
`refreshAfter` were already independent options; this exposes the combination
that skips the reload but still re-reads the selection.
Both halves are needed, and they were fighting each other. Without the reload,
audio no longer chops on an edit. Without the resync, the panel keeps reading the
selection snapshot it was built with, so a second edit computes from a pre-edit
value and appears to do nothing — deleting one effect made every later delete a
no-op. `handleDomAttributeLiveCommit` is untouched and still used for knob
dragging, where a per-move re-render is exactly what you do not want.
**Volume.** An automated track's slider is disabled, since a level set there
would be overwritten by the envelope on the next tick, and the toggle beside it
adds or deletes the lane. Adding seeds it with a single point at the level the
slider already shows, so automating a track never changes how loud it is.
**One shared reader** for both panel sections, which is what surfaced that
resolving against an absent chain would have deleted every FX lane the moment
someone automated a volume: the volume section does not parse the chain, so
"no chain" now means "do not resolve" rather than "drop what cannot be resolved".
The toggle itself lives with the FX controls it is shared with, and says
`Automated` / `Automate` through the studio's own Tooltip rather than a native
browser hover.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
* fix(studio): stop the last two automation writes reloading the preview
The quiet commit added here was only used by the FX group. Two writers still went
through the refreshing one, so they reloaded the preview and restarted every
playing track — the exact chop the live write during a drag exists to avoid:
- releasing a dragged breakpoint, so the audio hitched at the end of every point
you moved;
- clicking the volume toggle, while the same click on an effect parameter was
already silent.
Both are quiet now: still persisted, still resyncing the selection so a following
edit computes from the value just written.
Also fixes the seeded volume. `Number(dataAttributes.volume ?? "1")` is 0 for an
attribute that is present but empty, so automating such a track started its lane
at silence while the engine read the same empty value as unity.
Co-Authored-By: Claude Opus 5 (1M context)
* feat(studio): automate and un-automate each effect parameter
The per-parameter surface in the FX panel.
An automated parameter's control is disabled — a value typed there would be
overwritten by the envelope on the next tick, so the lane is the value now — and
the toggle beside it adds or deletes that parameter's lane. Adding seeds the lane
with a single point at the value the control already holds, so switching to an
envelope never changes the sound, only where the value comes from.
Parameters no envelope can drive have no toggle at all: the worklet-backed
dynamics expose no AudioParams, a WaveShaper's curve and a convolution impulse
are rebuilt wholesale rather than scheduled. Neither does a chain node with no
id, since a lane addresses nodes by id — so adding an effect now mints one.
Carve moves onto the same non-reloading write, and decodes its source in an
`OfflineAudioContext`: opening a second output device mid-playback makes the
running track glitch while the hardware is reconfigured. Turning carve off now
also drops the filters it generated, which otherwise kept dipping the bed with
nothing in the panel to explain it.
`AudioFxGroup` moves into its own module — PropertyPanelFlat was at its size
budget — which also gave the panel's write behaviour somewhere to be tested: what
it writes, seeded at the current value, preserving the lanes it is not touching,
and clearing the attribute when the last one goes.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
* feat(studio): only offer voiceover carve when there is a voice to carve against
Carve is a relationship between two tracks — it analyses another track's voice
and dips this bed where that voice sits. In a composition with a single audio
track there is nothing to listen to, so the block offered an empty source picker
and an Analyse button that could never do anything.
It is now shown only when the composition holds another audio track, and still
shown when carve is already configured: hiding a live setting because its voice
track was removed would leave the bed being dipped from out of sight.
Co-Authored-By: Claude Opus 5 (1M context)
* test(studio): cover carve visibility through the real element
The panel derives carve's source list from the selected element's document, so
a selection with no element has no sources — which the new visibility rule
correctly reads as 'nothing to carve against'. The suite mounted exactly that,
so it was asserting on a hidden block.
Selections now carry a real with a sibling track, and the two cases the
rule exists for are pinned directly.
Co-Authored-By: Claude Opus 5 (1M context)
* fix(studio): keep FX panel writes from clobbering each other
Three writes in the audio panel each read the source file, mutate one
attribute and write it back. Fired without ordering they read the same
content and the last one lands, dropping the others.
- Deleting an effect left its automation lanes in the attribute. Ids are
minted lowest-free, so the next effect added took the same id and
inherited the dead envelope: disabled and "Automated" without the
author ever automating it, and baked into the render.
- Switching carve off wrote the chain (dropping the filters it generated)
and the carve settings at once, so either the filters stayed with no
carve to explain them or the settings survived with no filters.
- The three carve dials committed per input event, patching the source
and resyncing the selection dozens of times per drag. They now preview
live and persist on release, like the FX knobs already do.
Volume automation reads through the quiet commit too, so removing a lane
resyncs the panel instead of leaving the slider disabled.
* feat(engine): let an FX tail decay instead of cutting it at the clip
The offline render ended at the last input sample, so a reverb or a delay
was still ringing when the context stopped. Measured on a 1.5 s tone
through a default reverb, the render cut at 1.524 s while the tail was
still at -29.7 dB — an audible chop, and the one place the render did not
match preview.
The length does not have to be guessed. Every tail here follows from its
own settings: a convolution is exactly as long as its impulse, and
`synthesizeReverbImpulse` derives that from room size; a delay's repeats
fall by `feedback` every `time`, so the count down to -60 dB is a log.
Everything else settles with its input — an all-pass chain has group
delay, not a tail, and a 9-second compressor release has no signal to
release once the clip stops.
`chainTailSeconds` sums them (the chain is serial, so a delay in front of
a reverb hands each repeat to the room), reads a lane's maximum rather
than the static knob where one is automated, and caps at 5 s — 5 s
between repeats at 0.95 feedback is eleven minutes of decay, and the
panel can dial exactly that.
The mixer's per-track atrim now allows the clip plus its tail; the atrim
after apad still holds every track to the composition's length, so a tail
can run over what follows but never extends the video.
Same fixture after: a smooth decay to -72 dB, last non-zero sample at
3.306 s against the 3.4 s the settings predict.
* feat(studio): curve, snap and type a value in an automation lane
Four gestures from Ableton's envelope editor, which is the muscle memory
an automation lane inherits.
Alt-drag the line between two breakpoints to bend it, Alt-double-click to
straighten. `curve` was already honoured everywhere it is read — drawn in
the lane, sampled in preview, baked into the render through
setValueCurveAtTime — with no gesture anywhere that could set it, so every
envelope anyone could draw was linear in practice. The curve is solved,
not accumulated (x^e = f, so e = ln f / ln x), which keeps the segment
under the pointer instead of drifting away over a long drag; the test
asserts that by sampling with the renderer's own sampler.
Shift locks a drag to one axis and fines the vertical travel to a quarter.
Which axis won is decided in pixels — seconds and dB are not comparable
numbers, and comparing them would make the lock depend on the zoom.
A dragged point snaps to the beat grid and to its neighbouring points,
with Alt to ignore it. The radius is tight on purpose: a lane is often a
few seconds wide, where a generous radius makes a point unplaceable
between two beats.
Double-click a point to type its value. -6.0 dB is not a pixel you can
find, and there was no way to enter one.
The gesture layer moves to useAutomationLaneGestures and the path builder
to envelopePath: the component was at the studio's 600-line ceiling, and
both are worth testing without a render. trackShowsBeatStrip comes out of
TimelineLanes for the same reason.
* feat(studio): pure range ops for automation lane selections
Add pointsIn() and replaceRange() functions for managing automation envelope
edits within a time range. The key invariant: envelope values outside the
selection never move. Implemented by anchoring the boundaries at t0 and t1
by sampling the original lane, so cutting middle sections cannot reshape
the rest. Inner points from shape generators can suppress redundant anchors
at merge distance.
* fix(studio): budget replaceRange's inner points before capping, not after
* feat(studio): automation selection slice
* feat(studio): drag-select a time range on an automation lane
Dragging on an automation lane's empty background now arms a range
selection, snapped to the beat grid and clamped to the lane duration; a
sub-3px drag counts as a click and clears instead. Point drags and
Alt-drag segment bends still take priority, since the range arm only
runs where the existing point/segment hit-test already returned null.
useAutomationLanes binds the selection slice per element/lane so the
rect renders from the store, matching the read pattern the writes
already use.
* feat(studio): delete an automation selection from the keyboard
Escape clears the active automation-lane time selection; Delete/Backspace
empties it via replaceRange(..., inner: []), which pins anchor points at
both edges and leaves the envelope outside the selection untouched. Mounted
in TimelineLanes.tsx next to the useAutomationLanes() call that already
lives there. Also adds a stale-selection guard in TimelineAutomationLaneSlot
that clears the selection if its lane's target stops existing on the bound
element's automation (e.g. the automated effect was deleted).
* test(studio): cover the automation selection stale-target guard
* feat(studio): ramp, swell and dip generators for automation selections
* fix(studio): let an automation range keep Delete from the clip
useAppHotkeys listens on window/capture, so it runs before
useAutomationSelectionKeyboard's document/capture handler. With a range
selected, Delete fell straight through to the clip-delete branch and
destroyed the whole audio clip the lane belongs to; Backspace hit the
reset-keyframes branch on the way and wiped the clip's keyframes.
Guard both by returning early when automationSelection is set, mirroring
the selectedKeyframes precedent six lines above. No preventDefault: the
downstream handler still needs the key.
dispatchPlainKey is exported so the arbitration between keyframes, an
automation range and the clip can be pinned without standing up the hook.
Co-Authored-By: Claude Opus 5 (1M context)
* chore: suppress unused export for automationShapes (consumed upstack)
* feat(studio): simplify dense automation runs
Implements Ramer-Douglas-Peucker point-thinning for audio automation
lane breakpoints, working in unit space for correct log-scaled
parameter handling.
Co-Authored-By: Claude Sonnet 5
* chore: add automationSimplify to fallow complexity ignore
The Ramer-Douglas-Peucker algorithm in automationSimplify.ts has
inherent complexity (12 cyclomatic / 20 cognitive) that is by design
and not refactorable. Added to health.ignore list and ignoreExports
list since it's consumed by the UI layer one PR upstack.
Co-Authored-By: Claude Sonnet 5
* feat(studio): internal clipboard for automation ranges
* feat(studio): copy and paste automation ranges across lanes
Extends the automation-selection keyboard hook with Cmd/Ctrl+C (copy the
active range) and Cmd/Ctrl+V (paste onto the selected clip's lane, at the
selection's start or the playhead, chaining the selection to the pasted
span so a second paste lands right after the first). Paste falls through
untouched when no target lane resolves, so clip-level paste keeps working.
Also fixes a latent test-isolation bug: setup() never unmounted the
previous test's Host, so document keydown listeners leaked across tests
and could consume later events before the current test's own listener ran.
* feat(studio): shape and simplify menu on an automation selection
Right-click inside an active time-selection rectangle on an automation
lane now opens a menu offering the four utility shapes (Ramp up, Ramp
down, Swell, Dip) and Simplify, composing generateShape/simplifyPoints
with pointsIn/replaceRange from the prior selection tasks. A point's own
right-click still stops propagation and deletes it, unaffected.
* feat(studio): retime an automation selection
Add retimeRange pure operation that scales interior points proportionally
into a new time span, then uses replaceRange to update the lane while
preserving the envelope outside the union of old and new ranges.
Co-Authored-By: Claude Sonnet 5
* fix(studio): repair the automation paste path and finish the key arbitration
Paste was the least safe path in this feature: it resolved its target from
the player store but committed through a different, asynchronously-lagging
channel. Six review findings against this branch, plus the Cmd+C/Cmd+V half
of the arbitration wa-15 started for Delete.
- Write channel: resolvePasteTarget bails unless the binding's
commitTargetKey equals the element it resolved. useAutomationLanes exposes
that key, resolved through resolveTimelineIdForSelection — the same
resolver applyDomSelection uses — and read in the same render as the
commit handlers, so a handler and the key cannot describe different
moments. Before this, clicking clip B then immediately pasting serialized
B's automation onto A and left B untouched.
- Chaining: the paste anchor comes from sel.t1, not sel.t0, so a second
Cmd+V lands after the first instead of on top of it. The old comment
claimed the new behaviour while the code did the opposite, and no test
pressed Cmd+V twice.
- Empty copy: copyRange returns false rather than arming a clipboard whose
every paste is a destructive flatten, and samples the range's edges so
copying a smooth stretch yields a real segment instead of no points.
- Playhead: the playhead branch requires the playhead to be inside the clip
rather than silently clamping an out-of-clip playhead to the clip's start.
- Keys: one chord helper normalizes with toLowerCase() and gates on
!shiftKey && !altKey, matching useAppHotkeys. CapsLock no longer kills the
shortcut and Ctrl+Alt+V no longer pastes where the app declines.
- Arbitration: useAppHotkeys consults automationOwnsKey before its c/v
branch, so an active range keeps Cmd+C/Cmd+V from the clip clipboard the
same way it keeps Delete. Without it Cmd+V duplicated the clip while the
automation paste wrote the same file, and Cmd+C armed both clipboards.
It returns without preventDefault — the downstream handler needs the key —
and declines when the automation clipboard is empty so clip paste still
works. dispatchModifierKey is exported to pin this, like dispatchPlainKey.
- Double-action: the hook now returns early on e.defaultPrevented.
useAppHotkeys is on window/capture and deliberately lets a keyframe
selection outrank a range on Delete; without this that press deleted the
keyframes there AND emptied the range here.
- Project scoping: the clipboard scopes itself. Every entry point carries the
project it speaks for and a mismatch empties the module, the shape
keyframeSlice already uses to discard a request from a previous session.
Scoping it inside the module rather than clearing it from the session seam
is deliberate: the failure is silent and destructive — a range copied in
project A pasted into B is remapped through A's captured sourceRange for an
FX node B may not have, and the keystroke is consumed so clip paste never
runs — so no future caller should be able to forget the guard. The mark
isLastPasteSpan reads is scoped transitively, through the same check.
- Session reset: createTimelineResetState clears automationSelection. It is
as ephemeral as selectedKeyframes, and a range surviving a project switch
can match a same-keyed clip in the new project and redirect a paste through
sel.elementKey === paste.elementKey to a stale t0.
Five of the six paste fixes above shipped without a test that fails without
them, which is how the branch reached review with a comment describing
chaining that the code did not do. Each now has one: a second Cmd+V landing
after the first, a commit-target mismatch declining, an out-of-clip playhead
declining, an empty-lane copy leaving an earlier clipboard intact, and Cmd+V
with CapsLock on. All five fail against this branch's parent.
* test(studio): probe retimeRange's actual guarantee, not sample-continuity past a moved edge
The failing test probed t=5.1, which sits inside the reshaped transition
segment between the new edge (t=5) and the existing point (t=6). When
growing past an existing breakpoint, the transition TO that point
legitimately reshapes — the edge moved (t=3→t=5) even though the far
point (t=6) did not.
The real guarantee: all BREAKPOINTS strictly outside the union keep
exact (t, v) values. Corrected test to:
1. Verify sample continuity on unaffected side: t=[0,1,1.9]
2. Verify the breakpoint at t=6 keeps exact value: (t:6, v:0)
Co-Authored-By: Claude Sonnet 5
* feat(studio): stretch an automation selection by its edges
Add an edge-handle drag to a selection's rect: grabbing within 8px of
either edge retimes the selection via the already-landed retimeRange,
scaling interior points proportionally and clamping the dragged edge
against its partner and the clip's duration. Priority is point-drag >
curve-drag > edge-stretch > new-range-select, so a point sitting on an
edge still wins the press. Cursor shows col-resize while hovering or
dragging a handle.
Co-Authored-By: Claude Sonnet 5
* fix(studio): retime edge-stretch from a fixed points snapshot
moveEdge fed retimeRange the live draft on every pointermove while
origin.t0/t1 stayed pinned to the drag's start. retimeRange is a
relative transform that scales a lane's own current point positions,
so repeated pointermoves compounded the scale factor (interior points
drift toward the far edge) and could drop points that retimed past the
selection's original bound out of the next move's `inner` set entirely.
Snapshot lane.points at arm time (armBackgroundGesture) alongside the
existing frozen origin, and always retime from that snapshot in
moveEdge instead of the live draft. finishEdgeDrag is unchanged: it
already just persists the last (now-correct) preview.
Adds a regression test asserting a multi-pointermove edge-drag (both
edges) lands on the exact same final points as a single-shot drag to
the same target — the case that exposed the bug, since the existing
suite only ever tested a single move.
* fix(studio): clamp selection-start paste, sharpen clipboard test, cleanup
- useAutomationSelectionKeyboard: clamp the selection-start paste branch
to [0, element.duration - clip.span], same as the playhead branch
already does. An unclamped paste near a clip's end could write points
past element.duration and leave the resulting selection's edge
ungrabbable off the visible lane.
- automationClipboard.test.ts: swap the cross-parameter mapping test's
target from fx.r.wet (numerically identical to VOLUME_RANGE) to the
log-scaled fx.n1.frequency, so the test actually discriminates real
unit-space mapping from a linear guess or a verbatim value copy.
- automationLaneSelection.ts: drop the lone `!` non-null assertion in
decimateEvenly's budget-of-1 branch for a guarded pattern, matching
the loop right below it and the repo's no-`!` convention.
- .fallowrc.jsonc: remove the two ignoreExports entries for
AUTOMATION_SHAPES and simplifyPoints — both are now genuinely
consumed (AutomationSelectionMenu.tsx, TimelineAutomationLane.tsx).
- AutomationSelectionMenu.tsx: port TrackGapContextMenu's viewport-edge
clamping so a right-click near the bottom/right of the timeline
doesn't render the shape/simplify menu partially off-screen.
* fix(studio): give edge-stretch the gesture contract the other four follow
Seven review findings against this branch, five of which were one defect:
edge-stretch was added as a fifth mutually-exclusive gesture on the lane
without joining the threshold / live-preview / revert-on-cancel contract the
point drag, curve bend, range drag and double-click all obey. Patching them
one at a time would have been more code and less coherent, so this makes the
stretch structurally parallel to its sibling range drag instead, and extracts
it to useAutomationEdgeStretch on the way out — the gestures file had ~60
lines of headroom under the 600-line studio cap, and shaving comments to fit
a refactor in is not a plan.
- Threshold. A press within the 8px halo of either edge used to persist a
no-op commit and push an undo entry that changed nothing (commitDataAttribute
has no unchanged-value short-circuit). Worse, it made the pre-existing "click
the background to clear the selection" escape unreachable anywhere near an
edge. Below 3px of travel — the same threshold the range drag uses — the
press now clears the selection and writes nothing at all.
- Live preview. moveEdge never fired onRangeSelect and the hook discarded the
drag's live position, so the highlight rect and both edge lines stayed pinned
at the pre-drag bounds for the whole gesture and snapped into place on
release: the user dragged an invisible handle. It now reports bounds on every
move, exactly as the marquee drag does and for the same reason.
- Revert on cancel. pointercancel means the browser abandoned the gesture; it
was routed to the same handler as pointerup, which persisted whatever partial
retime it had reached. It now restores the arm-time snapshot through the
preview channel — there is nothing persisted to undo — and puts the selection
back. A new cancelDrag handler owns that, so a release and an abandonment are
no longer the same event.
- Lost capture. capturePointer took the capture on e.target, i.e. whichever
child the press landed on. A child that unmounts mid-drag takes the capture
with it, silently, with no pointercancel — after which edgeDrag stayed
non-null and every later button-less pointermove kept retiming and writing.
Capture is now taken on the svg, which outlives every gesture on it, and a
move reporting no buttons held ends the drag as a cancel.
- Hit priority. A breakpoint sitting exactly on the selection's edge used to
win the press. Since replaceRange pins an anchor at the union bound and
finishEdgeDrag leaves the selection edge at that same time, EVERY range
operation — stretch, delete, shape insert — leaves a point exactly on the
edge it just created: the second stretch of the same edge resolved to a
point-drag, at the one height (on the envelope) where a user naturally grabs
it. The feature was not repeatable. An active selection's edge now outranks a
point on it; clearing the selection reaches the point again, which is tested.
- Clamp order. The dragged edge was bounded against its partner AFTER the
0-floor, so a selection thinner than the minimum width yielded a negative t0,
which core's cleanPoint then collapses onto a duplicate t=0 on the serialize
round-trip — silent envelope corruption. The floor is now applied last. The
minimum width is its own MIN_SELECTION_SEC rather than a borrowed
POINT_MERGE_SEC: when two breakpoints are the same breakpoint is a different
question from how thin a time selection may get.
One finding does not survive: edgeAt's `d0 <= d1` tiebreak was reported as
making the t1 edge ungrabbable on a narrow selection, but that comparison IS
nearest-wins, and a press right of the midpoint already resolved to t1. The
midpoint split here is the same rule written so it is legible rather than
inferred, and the test for it is labelled as characterizing behaviour, not
fixing it. What was genuinely unreachable inside a narrow halo — starting a
fresh range, or clearing the old one without Escape — the threshold above fixes.
Also settles what retimeRange does with a breakpoint sitting ON a dragged
edge, which was never decided: pointsIn is endpoint-inclusive, so it is
interior and travels with the stretch. It has to be, because the commonest
stretch of all is grabbing an edge to drag exactly that point outward, and
anchoring it would delete it and flatten the span instead. The price is that
the retimed point lands on the union's own boundary where a preservation anchor
would go, and anchor() stands down within a merge radius — one time cannot hold
two values — so the segment leaving the union reshapes. That is the one place
replaceRange's outside-never-moves invariant bends, and both halves are now
pinned: the exact points and the sampled slope for the on-edge case, and the
full two-sided invariant for a selection whose edges are off any breakpoint.
The earlier right-side probe at t=5.1 that caught this was deleted during
development as inherent; it was reporting the real behaviour.
* fix(core): make audio automation survive being rescheduled mid-playback
Anything landing inside a running value curve is refused unless the parameter is
cancelled first, and two paths were not cancelling: the chain observer wrote each
knob straight onto its AudioParam before rescheduling, and a bent segment read as
straight because only the curve exponent was checked, never the via point the
timeline actually writes. The first threw NotSupportedError into the console and
abandoned the rest of the envelope; the second played a dragged bend as a ramp.
Measured against Chrome, in a live context and in an offline one suspended
mid-curve: any cancel frees the span, and only a missing cancel is refused.
clearParamLane takes the strongest form on purpose, because curve-over-curve
refusals were reported with a cancel at the new schedule time already in place
and have never reproduced; emit keeps a ramp fallback as the backstop for
whatever that mechanism turns out to be.
Dynamic carve is what exercises all of it, so it lands here too:
- a `gain` primitive, so a carve can match levels as well as carve bands
- carve settings collapse to one `strength`, with carveProfile deriving the six
numbers that always moved together anyway
- analyseCarveDynamics / analyseCarveDuck turn the analysis into envelopes, with
a slow release so the bed does not snap back the instant a word ends
- worklets are awaited inside attach, so adding a compressor to a carved bed no
longer kills its envelopes and freezes every later edit
- per-track failure detail in the render's audio stage, which was being discarded
Co-Authored-By: Claude Opus 5 (1M context)
* feat(studio): select automation points with a box, and stop them crossing
Replaces the time-range selection with a rectangle. A lane selection is a set of
breakpoints, not a span, so it now has value bounds as well as time bounds and a
point is caught only if it falls inside both — which is what lets you take the
peaks of an envelope and leave the dips between them. Delete, the group drag and
the rings drawn on caught points all read the one rule, so what looks selected is
exactly what those act on. Copy, paste, shape insert and simplify still work on
the box's time span, because they act on the envelope over a stretch of time.
Dragging is bounded by its neighbours in both the single and group cases. A point
cannot cross another, and cannot land exactly on one either: the lane collapses
points that share a `t`, keeping the later one, so arriving on top of a neighbour
deleted it. It stops a millisecond short, which is under a pixel at any zoom the
lane offers and keeps both points. Only stationary neighbours constrain a group,
per member rather than per end, since a box can select a non-contiguous set.
Edge-stretch is removed rather than fixed. Dragging a selection's edges to retime
the points inside it was the feature this branch opened for, and it is not wanted:
the hook, retimeRange, the edge handles, the col-resize cursor and the pointercancel
revert path all go, along with the ~360 lines of tests that pinned them.
Also: gesture-scoped coalescing keys, so one drag is one undo entry rather than a
fragmented chain of them.
Co-Authored-By: Claude Opus 5 (1M context)
* fix(studio): put #3207's edge-stretch back, folded into the unified hook
The review blocks this PR for deleting a feature two PRs downstack:
"#3209 deletes #3207 edge-stretch instead of folding it into the unified
hook... merging the stack would ship #3207 and then silently remove its
user-facing retime/edge-stretch feature."
Verified, all four claims: `useAutomationEdgeStretch.ts` (248 lines) and its
test were deleted, `retimeRange` was dropped from
`automationLaneSelection.ts`, and the consolidated hook has no edge
hit-test, arm/move/finish path, or resize cursor.
Restored: the module, `retimeRange`, the selection regressions, and the
lane wiring (`col-resize` cursor, `pointercancel` reverting a partial
retime rather than persisting it).
It is not a straight revert, because #3209 changed the selection from a
time range to a box. Edge-stretch now takes `{t0,t1,v0,v1}` and moves only
the time edges — the value extent rides through untouched, which keeps it
the same gesture it was.
**One arbitration call worth a second opinion.** #3207's rule was that a
selection's edge outranks a point sitting on it, because every range
operation leaves a breakpoint exactly on the edge it created — a
point-first rule made the second stretch of an edge resolve to a
point-drag. Under a box that rule now contradicts #3209's own test
("stops the group at a point it did not select"), which presses at t=0
v=1 — simultaneously the t0 edge and a selected point.
I inverted it: selected content wins, the edge stretches everywhere it is
not also selected content. The reasoning is that a box makes the point
visibly part of the selection, and dragging selected content has to move
it. That restores #3209's test and keeps the stretch usable along the rest
of the edge — but it is a product decision between two deliberate designs,
so flag it if #3207's original precedence was load-bearing.
954 player tests pass, including the 17 restored ones.
* feat(engine): render audio FX in an OfflineAudioContext
Reads `data-fx-chain` off an audio element and runs the chain over the trimmed
WAV before volume automation is baked in — effects should see the raw signal,
and the envelope belongs on their output.
The processing happens in an OfflineAudioContext inside the headless browser
the engine already drives, running the same graph builders the studio previews
with. That is the point of the approach: one implementation per effect, so the
render agreeing with the preview is a property of the architecture rather than
a tolerance to police. Reimplementing each effect as an FFmpeg filter would
mean two implementations to keep in step, and for the dynamics processors and
modulated delays there is no filter that behaves the same way.
`build:audio-fx-runtime` bundles the graph builders into an injectable IIFE,
following the same pattern as the existing runtime artifacts, so the browser
runs exactly the code the studio does.
The page loads from a file:// URL rather than about:blank because AudioWorklet
is only exposed in a secure context — the compressor, limiter, gate and
bitcrush processors would otherwise fail to register with an opaque error.
file:// qualifies and needs no listening socket.
The chain is serialised into the attribute the way colour grading carries its
config, so there is no side-car file to resolve or lose.
An FX failure is fatal for the whole mix rather than a per-track soft failure.
Every other audio failure mode degrades gracefully — the track drops, siblings
continue — but substituting the dry signal for a processed one ships a render
that sounds plausible and is not what the author set up. Since the per-element
work races under Promise.all, an internal AbortController chained off the
caller's signal aborts in-flight siblings before workDir is removed.
* feat(core): voiceover carve analysis
Finds the bands a voice occupies so a music bed can be dipped there, letting
the voice sit in front without ducking the whole track.
Carve is a relationship between two tracks rather than an effect on one, so it
stays out of the FX chain. What it emits is an ordinary chain of peaking
filters, so a carve composes with whatever else is on the track and needs no
separate rendering path.
Selection is weighted toward intelligibility rather than raw voice energy.
Ranking purely by power lands on the fundamental almost every time, because
that is where a voice is loudest — but the masking that actually hurts a
voiceover happens higher up, and dipping 160 Hz mostly just thins the bed. The
bias is a control, not a constant: at 0 it follows raw energy, at 1 it weights
toward 1-3 kHz.
Ranking happens in dB, which matters more than it looks. Speech spreads 20-30 dB
across these bands — it falls off roughly 6 dB per octave above the fundamental
— so a weighting has to be on that scale to move anything at all. A
multiplicative weight of `1 - bias + bias * shaped` is bounded below by
`1 - bias`, capping its influence at 10*log10(1/(1 - bias)): 5.2 dB at the 0.7
default, 3 dB at 0.5. That is no influence against a real voice — every bias
short of ~0.95 would rank exactly like bias 0 and carve the fundamental, the
outcome the bias exists to prevent, while looking decisive against a fixture
whose bands sit 2 dB apart. So the bias is a dB penalty, zero at 2 kHz and worth
up to 30 dB at full strength, and relative cut depths come from a dB difference
rather than a ratio of weighted linear powers.
The bias reweights ranking without overriding the spectrum — a band the voice
has no energy in is not worth carving, and scores -Infinity rather than
competing — so a strongly low-pitched voice can still select low at full bias.
What the tests hold is that biasing never selects lower than the unbiased
ranking, that the DEFAULT bias reaches the presence region on a voice with a
realistic tilt, and that bias 0 still follows raw power exactly.
Includes a radix-2 FFT rather than a dependency; one Welch-style averaged
spectrum over third-octave bands does not justify pulling in a DSP library.
* fix(engine): keep the FX render 16-bit, stereo, and correctly sized
Three defects in the offline FX path, none of which any test could see.
**Float output silently disabled sample-accurate volume automation.** The writer
emitted 32-bit IEEE float; the very next mixer step bakes the volume envelope
into the samples and accepts only 16-bit PCM, returning null otherwise. So
enabling any effect downgraded that track to the ffmpeg expression path — capped
at 32 straight segments, quantising a curved envelope, and on a dense one falling
back to base volume. It now writes 16-bit PCM, clamped rather than wrapped so a
limiter at 0 dB or a resonant filter cannot turn overshoot into a click. A test
asserts the baker accepts the writer's own output and actually fades it.
**Everything was folded to mono.** `prepareAudioTrack` goes out of its way to
emit stereo — its pan filter exists to dodge ffmpeg's 3 dB mono-to-stereo
rematrix — and this folded it, then wrote one channel. So adding a single peaking
EQ collapsed a bed's width and cost ~3 dB in the render, while preview stayed
stereo. Channels now travel as one plane each, through an OfflineAudioContext of
the same width, and come back interleaved.
**Small results decoded the wrong length.** `new Float32Array(buf.buffer)`
discards byteOffset and byteLength, and Node pools small allocations: a 400-byte
payload sits at offset 8 inside an 8 KiB pool, so a clip under ~1024 samples
decoded as 2048 samples of unrelated memory — and the empty-result guard could
not see it. The reader has the mirror-image fix: a float data chunk on an odd
boundary (ffmpeg's pcm_f32le writes fmt(18) + fact, landing `data` at 58) now
copies instead of throwing RangeError on an unaligned view.
The tail limitation is now stated rather than mis-stated: the context is exactly
as long as the input, so a reverb or delay still ringing is cut there. The old
comment claimed the opposite. How far a tail may run past a clip's end changes
the clip's length in the mix, so it is a product decision, not one to make here.
Co-Authored-By: Claude Opus 5 (1M context)
* fix(producer): report an FX render failure as an audio error
`processCompositionAudio` reports per-track failures in its result, but an FX
failure it cannot degrade past — a browser that will not launch, a chain that
will not build — rejects instead. `runAudioStage` had no try, so that rejection
escaped to the orchestrator as an unclassified pipeline exception, losing the
stage/owner/retryable classification this stage exists to attach, and skipping
its abort check on the way out.
It now lands in `audioError` alongside every other cause, while an abort still
keeps its own shape rather than being reported as an audio problem.
Not done here: committing the generated `audio-fx-runtime-inline.ts` so a fresh
clone typechecks packages/engine without building first. The bundle is built from
the stub, and the stub changes three times across this stack — so the artifact
differs per branch and would conflict on every restack. Its model,
position-edits-render-inline.ts, is committed only because it is stable. Building
before testing is this monorepo's existing contract (studio's tests need core's
dist too), so the gap is not specific to audio FX and is better closed by a build
ordering gate than by committing a per-branch artifact.
Co-Authored-By: Claude Opus 5 (1M context)
* test(engine): skip the browser FX render cases when there is no browser
CI's `Test` job was red on this PR with four failures, all the same cause:
Failed to launch the browser process: spawn
/home/runner/.cache/hyperframes/chrome/chrome-headless-shell
The job installs ffmpeg and no browser, deliberately — every other suite
that needs an external binary already guards on it
(`describe.skipIf(!HAS_FFMPEG)`). These cases were the only ones assuming
a Chrome, so they failed on an absent dependency rather than on anything
about the code.
Guards on `resolveHeadlessShellPath()` — the same resolver
`acquireBrowser` launches through, so the check cannot drift from the
thing it guards the way a hard-coded cache path would. A configured path
that does not exist throws; that is caught and read as "cannot run here".
Checked both directions rather than just the green one: with a browser all
11 cases run and pass, and with `HYPERFRAMES_BROWSER_PATH` pointed at a
missing binary exactly 3 skip and the other 8 still run. A guard that
silently skipped everything would have looked identical in CI.
They keep their value where it exists — every developer machine, and any
job that has run `hyperframes browser ensure`.
Not touched: the CodeQL failure on this PR is a run from 2026-08-07, five
days and several force-pushes stale. None of the 17 open repo alerts are
in files this PR changes; it re-runs on this push.
* chore(engine): suppress the temp-file alert with the reason it is safe
CodeQL flags `writeWav`'s `writeFileSync` as js/insecure-temporary-file
(high) — the one new alert on #3021, and the reason its CodeQL check is
red.
It is a false positive, and the comment says why rather than just silencing
it: `path` is always inside a directory made by `mkdtempSync`, never a
name assembled directly under `tmpdir()`. Both callers are covered — the
browser host page writes into `mkdtempSync(join(tmpdir(), "hf-fx-host-"))`,
and the render output goes to the producer work dir, itself
`mkdtempSync(join(tempRoot, "producer-project-"))`. mkdtemp picks the
random suffix and creates the directory 0700 in one syscall, so the
predictable filename inside it cannot be pre-created or symlinked by
another user, which is the attack the rule is about. The analyzer sees the
dataflow reach `tmpdir()` and not the mkdtemp in between.
Suppressed inline rather than dismissed in the UI, so the justification
lives next to the code and the rule stays live for anything added later in
this file. Matches the repo's existing convention — `planV2.ts:222`
carries an `lgtm[js/insecure-temporary-file]` for a different reason on
the same rule.
Correcting myself: I first reported this alert as not real, having
intersected the PR's files against the default-branch alert list, which
does not contain PR-ref alerts. Querying ?ref=refs/pull/3021/merge returns
it straight away.
* test(engine): probe ffmpeg and Chrome instead of assuming them
Two failures on #3021's Test job, both about the environment rather than
the code under test.
**Bare `ffmpeg` is not on PATH in CI.** The 16-bit fixture shelled out to
`execFileSync("ffmpeg", ...)` and died with ENOENT. The job does provide
ffmpeg, through `prepare-ffmpeg-bin`, which is what `getFfmpegBinary()`
resolves — every other ffmpeg-dependent suite in this package already goes
through it. Now this one does too, and the case is `skipIf(!HAS_FFMPEG)`
so a contributor without ffmpeg skips rather than fails.
**The browser guard trusted the wrong thing.** It asked
`resolveHeadlessShellPath()` and treated a returned path as "a browser is
here". CI's cache holds a chrome-headless-shell that resolves and then
fails to spawn — a partial download is indistinguishable from a working
one by `existsSync`, which is all that resolver checks. So the three
browser cases ran anyway and failed on the launch.
It now runs `--version` and requires exit 0, which is the same probe the
ffmpeg suites use: ask the binary, do not infer from the filesystem.
Checked both directions rather than just the green one. With a working
browser all 11 cases run and pass; with `HYPERFRAMES_BROWSER_PATH` pointed
at a binary that exits non-zero — CI's exact situation — exactly 3 skip
and the other 8 still run. A guard that quietly skipped everything would
have looked identical on the CI summary.
* feat(core): register the audio-fx-rack canary at 0%
Lands the rollout switch dark, per the registry's own procedure: "Start at
percentage: 0 and merge that — a canary at 0 is dead code you can land
safely and ramp without a code review."
Declared at the bottom of the stack so every branch above can read it. The
gate itself goes in at wa-4-fx-panel, where the rack first appears.
Scope is deliberate and stated in the description: it gates the AUTHORING
surface only. A composition that already carries `data-fx-chain` still
plays and renders it. A canary should stage who can REACH a feature, not
make an attribute somebody already wrote silently inert — an agent that
writes a chain through the skill would otherwise produce a file whose audio
processing vanishes with no error.
* feat(studio): audio FX panel generated from the registry
Controls for the whole chain: add, remove, reorder, bypass, and every knob each
effect declares.
Nothing in the panel knows what a compressor is. The registry supplies each
parameter's range, step, unit and scale and the panel renders what it finds, so
adding an effect or a knob upstream needs no change here, and the panel cannot
offer a value the renderer would reject — a typed-in figure is clamped into the
declared range on the way through.
Frequency and time controls span three or four decades, so those declare a log
scale and the slider maps exponentially; a linear slider would spend most of
its travel somewhere useless.
Reorder is a first-class control because chain order changes the sound: a
reverb before a compressor is not the same as after.
Carve gets its own block rather than an entry in the add menu, with a picker
for the voice track to listen to. It processes this track based on another one,
which is how a sidechain control works — it lives on the track that changes,
and names the source.
* feat(studio): show the Audio FX section on audio tracks
Adds `audioFx` to the editing-affordances contract and renders the FX panel in
the inspector when an `` element is selected.
The section is audio-only. A `` carries its sound on a separate
`` element, so an FX chain on the video would have nothing to process.
Chain and carve settings are written straight back onto the element as
serialised attributes, the way colour grading carries its config, so
persistence is an ordinary attribute write and needs no new server route. A
chain that cannot be parsed renders as empty rather than breaking the panel,
and the attribute is left untouched until the user changes something.
The collapsed group summarises what is on the track ("2 effects + carve") so
the state is visible without expanding it.
Wired into PropertyPanelFlat rather than PropertyPanel: STUDIO_FLAT_INSPECTOR_ENABLED
defaults to true, so the flat inspector is what actually renders.
* refactor(studio): lift audioFxSummary out of PropertyPanelFlat
`PropertyPanelFlat.tsx` is 612 lines here against the repo's 600-line cap,
so the required File size check is red — the sole reason this PR is
blocked. The review says as much: "mechanical fix (~5 min), not a design
problem. Code itself is LGTM."
Moves `audioFxSummary` to `audioFxSummary.ts`, the same file a later
branch creates for it. Deliberately the smallest cut that clears the cap
rather than the whole `AudioFxGroup` extraction: every later commit in the
stack edits AudioFxGroup, so moving it here would collide with each of
them, while almost nothing touches this function.
595 lines.
* feat(core,studio): hear the FX chain in preview, and run the carve analysis
Splices an element's FX chain into the playback graph so preview stops being
silent about effects, and wires the carve button that was previously inert.
The chain goes between the decoded source and its gain stage: effects see the
raw signal and volume automation rides on their output, matching the order the
offline render uses. Since preview and render call the same graph builders,
what is heard while scrubbing is what gets written.
The splice lives in the transport rather than on the `` element. The
transport plays each track from a decoded AudioBuffer and mutes the element to
avoid doubling, so capturing the element with createMediaElementSource would
have processed a stream nothing is listening to — it looked like it worked
because the call succeeded, and the audio was unchanged.
A chain that cannot be built plays dry rather than silencing the track, which
is the right failure in preview: the author keeps working and hears the source.
The render still refuses, because shipping the dry signal there would be wrong.
Carve now analyses for real: it decodes the chosen voice track, ranks its bands
and writes the resulting peaking filters onto this track. Generated nodes are
tagged `fromCarve`, so re-running replaces the previous carve instead of
stacking another set on top of hand-added effects.
Known limitation: the graph is built when a source is scheduled, so a knob
turned mid-playback takes effect on the next play or seek rather than
immediately. Live re-parameterisation needs the transport to hold the handle
and forward updates.
* fix(studio,core): stop parameter drags from restarting playback
Dragging a knob wrote the chain through the persisting attribute path on every
input event. That path refreshes the preview, which reloads the composition and
reschedules audio — so a single drag reloaded dozens of times and playback
stuttered the whole way.
Drags now go through `onSetAttributeLive`, the same path colour grading uses for
scrubs: it coalesces undo entries and sets `skipRefresh`, so no reload happens.
The persisting write fires once, when the gesture ends — pointer-up or blur for
a slider, Enter or blur for a typed value. A select commits immediately since
there is no drag to wait for.
While dragging, the control is driven from local state. Waiting for the value to
round-trip through the element attribute made the knob lag behind the pointer.
For the change to be audible without a reload, the graph now follows the
attribute: the chain installed by the transport observes the element and
re-parameterises itself in place, so a value change lands on the next
128-sample quantum. A shape change (effect added, bypassed, pole count) cannot
be patched into a running graph, so it still waits for the next schedule rather
than cutting the audio mid-play.
The regression test drags a slider through several values and asserts the
persisting handler is untouched until release.
* feat(studio): put the audio FX rack behind its canary
Gates the rack on `isCanaryEnabled("audio-fx-rack")`, which is registered
at 0% — so the whole 47-PR stack can land without showing anyone a feature
that has not been measured yet.
The gate sits on the AUTHORING surface and nowhere else. The runtime and
the render still honour a `data-fx-chain` already on an element, so a
composition written through the skill or by `carve.mjs` keeps its
processing rather than going silently dry for anyone outside the cohort. A
canary should stage who can REACH a feature, not make an attribute somebody
already wrote stop working with no error.
Gated at the panel rather than in `resolveEditingSections`: the affordance
resolver is a pure function in core describing what an element CAN support,
and rollout state is not a property of an `` tag.
Pinned the 0% with a test, and checked it fails at 25 — a ramp should have
to break something that says "this ships dark" out loud.
One gap, stated rather than papered over: the gate itself has no unit test.
I wrote one and deleted it, because `PropertyPanel.test.tsx`'s harness
never renders the Audio FX group for its audio fixture even with the gate
removed — so the test passed for the wrong reason in the off case and could
not pass at all in the on case. A test that cannot fail for the right
reason is worse than none. Verifying the gate needs the panel harness to
mount that section first, which is its own change.
* fix(core): register FX worklets before building nodes that need them
An AudioWorkletNode cannot be constructed before its processor is registered —
it throws, and the surrounding chain is lost with it. `attachElementFxChain`
built the chain first and only then called `ensureAudioFxWorklets`, so every
worklet-backed effect (compressor, limiter, gate, bitcrush) threw on
construction and the track fell back to dry. Instrumenting the preview showed
`hf-compressor: InvalidStateError` with addModule never called at all.
When the module has not landed yet the track now plays dry and the graph is
swapped in once registration resolves, so the effect arrives a moment late
instead of never.
Registration is also tracked per context rather than in one module-level
promise. A processor registered on one AudioContext does not exist on another,
so the shared promise made every context after the first believe it was ready
when it was not — the studio's transport owns its own context, which is exactly
that case.
With the worklets actually running, the compressor's per-sample log10 and pow
became real audio-thread work. Samples below the knee have a gain of exactly
unity and need neither, so the envelope is now compared in the linear domain
and the transcendentals only run for samples that are actually being
compressed.
* refactor(studio): split the FX node row out of FxSection
Clears the health findings the FX stack left behind: the chain-node render
callback was a 70-line closure over half of FxSection's state, and the two
reorder arrows were the same button written twice.
Also drops two exports with no consumers, and registers the audio FX runtime
stub as an entry point — it is bundled by file path, so nothing imports it.
Co-Authored-By: Claude Opus 5 (1M context)
* feat(core): automation envelope model for audio tracks
Adds the data model behind Ableton-style automation lanes: breakpoint
envelopes over track volume or one knob of one effect in the track's FX
chain, stored on the element as `data-automation`.
Times are clip-local, so an envelope travels with the clip when it moves —
the clip-envelope model rather than arrangement automation.
`sampleAutomationLane` is the single interpolator. The lane drawing, the
preview scheduler and the render bake all call it, so the picture and the
sound cannot disagree about the curve. Log-scaled parameters interpolate in
log space, matching what their own knob already promises.
FX nodes gain a stable `id`, minted by count rather than randomly so the
document is the same on every machine. Lanes address nodes by id, so
reordering a chain never re-points a lane at a different effect, and a lane
whose effect was deleted is dropped rather than left to reattach.
Also warns when a track carries both a volume lane and a GSAP volume tween,
since only the lane is heard and the tween silently does nothing.
Co-Authored-By: Claude Opus 5 (1M context)
* refactor(studio): lift the audio FX group out of PropertyPanelFlat
`PropertyPanelFlat.tsx` was 672 lines against the repo's 600-line cap, so
the required File size check was red — the sole reason #3014 and #3022 are
blocked. Both reviews say the same thing: "mechanical fix, not a design
problem. Code itself is LGTM."
Moves `AudioFxGroup` and `audioFxSummary` into
`propertyPanelAudioFxGroup.tsx`, which is where a later branch puts them
anyway — done here so the file is under the cap from the point it first
crosses it, rather than ten branches later.
533 lines now. The four audio imports it no longer needs go with it.
Not fixed here: three `FxSection carve` tests fail on this branch with
"Cannot read properties of undefined (reading 'toFixed')". Confirmed
pre-existing by stashing this change and re-running — that is the separate
`Test` failure the review also flags.
* feat(core): expose the AudioParams behind automatable FX knobs
Marks the knobs an automation lane can drive and has each graph builder hand
back the AudioParam behind them, so a scheduler can write to a running effect
without knowing what the effect is.
A knob is not always one AudioParam. A wet/dry mix is two gains moving in
opposition, and a knob in milliseconds drives a delay time in seconds, so
each target carries the mapping out of the knob's own declared unit.
What stays unautomatable is stated where it is decided: a WaveShaper curve, a
convolution impulse and a one-pole filter's coefficients are all rebuilt
wholesale rather than scheduled, and the four worklet effects take values by
postMessage rather than through AudioParams.
The registry flag is written by hand, so a test builds every effect and
checks the exposure both ways — nothing flagged is missing, nothing exposed
is unflagged. A flag that lied would offer a lane that silently did nothing.
Co-Authored-By: Claude Opus 5 (1M context)
* feat(core): play automation envelopes in preview
Schedules each lane onto the AudioParams behind its knob using native ramps
and value curves. Nothing evaluates the envelope per frame: it is handed to
the audio thread once, so it stays sample-accurate however busy the main
thread is, and the offline render will schedule it the same way.
Timing comes from the transport, so an envelope survives seeking into the
middle of a clip, a clip that has not started yet, and a playback rate that
compresses clip seconds into context seconds.
A straight line is only scheduled as a ramp when nothing bends it — no
curvature, a linear parameter scale, and no unit mapping. Log-scaled
parameters and mapped ones are sampled instead, since a delay knob in
milliseconds and a wet/dry pair moving in opposition are not linear in the
parameter they drive.
Lanes with nowhere to write are skipped rather than reported: a one-pole
filter exposes no frequency param, and the worklet effects expose none at
all. Editing an envelope mid-playback re-aims it at the live playhead rather
than restarting the track.
Co-Authored-By: Claude Opus 5 (1M context)
* fix(core): make the volume lane audible in preview
The envelope was scheduled onto the transport's gain AudioParam, but the
runtime rewrites that gain every tick from `data-volume` and the GSAP-seeked
value — so it was erased within a frame. Volume automation was correct in the
render and inaudible while previewing.
The lane now feeds the per-tick path where the probed volume keyframes already
sit, checked ahead of them so the two cannot fight, and the transport no
longer schedules volume at all: one mechanism instead of two racing.
The cost is honest — in preview the level steps per tick rather than per
sample, exactly as the existing keyframe path does. The render still bakes it
into the PCM sample-accurately, and FX parameters are still scheduled on their
own AudioParams, since nothing rewrites those.
Parsed lanes are cached by attribute text: the runtime asks once per tick per
track, and parsing there would run the JSON parser 60 times a second for a
value that only changes on an edit.
Co-Authored-By: Claude Opus 5 (1M context)
* feat(engine): bake automation envelopes into the render
The offline render schedules FX lanes with the same scheduler preview uses,
inside the OfflineAudioContext that already runs the same graph builders. The
input WAV is the clip's own audio from its first sample, so clip-local time
is offline time and the envelope needs no offset.
Volume lanes take the existing PCM bake rather than a second mechanism: the
lane is converted to keyframes, so a straight fade stays two of them and only
a bent segment is sampled — the baker interpolates linearly and would
otherwise quietly straighten the curve. A volume lane supersedes keyframes
probed from the timeline, which `lint` already warns about.
A browser test sweeps a lowpass from below a 2 kHz tone to well above it and
measures both ends. Parsing the envelope is not the same as scheduling it,
and only running the real thing tells the two apart.
Co-Authored-By: Claude Opus 5 (1M context)
* fix(core): apply chain edits to the running graph
A structural edit — an effect added, removed, bypassed, or a filter's pole
count switched — was dropped. `buildFxChain`'s update reports false when the
change is not merely new values, and the attribute observer ignored that, so
the edit only took hold when the persisting write reloaded the composition.
That reload restarted every playing track, which is what was heard as the
audio chopping.
The graph is now swapped in place: the old effects are detached, the new ones
built and connected between the same source and gain, and any lanes
re-scheduled onto the new nodes. The source node is never touched, so playback
does not restart.
A track with no chain is watched too, rather than wired through and forgotten,
so adding its first effect is heard the same way. That means the function
always returns a disposer instead of null for the empty case.
Co-Authored-By: Claude Opus 5 (1M context)
* fix(studio): drop the FX panel's dead __testables export
Fallow audit flagged it — no test imports the module.
* fix(core,studio): clear the remaining Fallow audit findings on the FX panel
- Split FxSection's per-node row into FxNodeRow + FxNodeControls so the
CRAP score (31.6, threshold 30) splits across two smaller units instead
of moving wholesale with one extraction.
- Dedupe the repeated "open the add menu, read its items" block in
propertyPanelFxSection.test.tsx into openAddMenuItems().
- Merge build-audio-fx-runtime.ts and build-position-edits-render.ts into
one build-inline-artifact.ts, config-selected by CLI arg — the two
scripts were a byte-for-byte clone save for names.
- Exempt canary.test.ts's rawFnv (a deliberate independent
reimplementation used to cross-check canaryBucket, per its own
docstring) and the property-panel test files' shared renderInto/mount
scaffolding (pre-existing across 9 files, 2 outside this stack) in
.fallowrc.jsonc, consistent with this file's existing exemptions for
the same class of intentional/pre-existing duplication.
* fix(ci): allowlist the build-script consolidation in the no-main-deletions guard
build-audio-fx-runtime.ts and build-position-edits-render.ts were merged into
build-inline-artifact.ts to kill a fallow duplication finding; the deletion
guard flagged that as an accidental loss since main still has both originals.
* fix(core): dedupe the wet/dry mix math between delayFeedback and chorusLfo
Both effect builders set wet.gain to the mix and dry.gain to its complement
in identical two-line blocks; fallow kept re-flagging it as a 10-line clone
on every unrelated change. Extracted setWetDryMix.
* fix(core): remove the build-audio-fx-runtime.ts stray resurrected by a main merge
An earlier merge with main brought this deleted file back (git's merge/delete
handling on an unchanged-on-one-side file); package.json already points at
build-inline-artifact.ts, so it sat unreachable and duplicating that file's
config, both of which fallow flagged.
* fix(studio): pull TimelineLanes under the 600-line cap
TimelineLanes.tsx hit 620 lines. Extracted the three per-clip pointer
gestures (resize-start, pointer-down move-arm, click/razor-split) into
createClipGestureHandlers — one factory call per rendered clip instead of
~120 lines of inline handler bodies in the render loop. 529 lines now.
* fix(studio): split the extracted pointerdown handler under the CRAP threshold
Moving the ~120-line gesture logic into timelineClipGestureHandlers.ts
concentrated it into two functions fallow flagged (onPointerDown at CRAP
63.6, onResizeStart at 31.6). Split the decision logic (which gesture a
pointerdown implies) into a pure resolvePointerDownAction, then split
its own intent-blocking check into isIntentBlocked. onResizeStart's guard
moved into canStartResize. Every function now scores under 30.
* fix(studio): drop the unused DomEditSelection import in PropertyPanelFlat
CI caught it on PR #3026 (wa-12-panel-params); a later refactor in the
stack removed the last use of the type here without removing the import.
* fix(studio): close the typecheck and fallow gaps wa-18b-reschedule opened
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)
* fix(core): cap the via conic's weight so an edge-clamped via point can't NaN
A via point pulled out past the segment (viaX: 5, viaY: -3) clamps to
(0.999, 0.001) — exactly on the steady region's edge, where edge - viaX is 0.
viaConic divided by that zero to get an infinite weight, and shapeVia turned
Infinity into NaN a few steps later (Infinity - Infinity in the quadratic
coefficient). NaN reaching setValueCurveAtTime silences the automated
parameter for the rest of the render.
Capped the weight at 1e6 instead of leaving it unbounded — past that point
the arc already reads as touching the via point, so nothing visible is lost.
Also hardened shapeVia's existing denominator guard (`<= 0`) to `!(> 0)`,
since NaN fails the original comparison and fell through it.
Review by Miga (PR #3208).
* fix(studio): widen PropertyPanel's resetModules render timeout again
The 20s margin (already once widened for the same reason) is timing out in
CI's full-monorepo Test run — the resetModules()+fresh-import render this
test needs is uncached and competes with every other package's test suite
for the same worker pool, and the same test passes in well under 2s
standalone. Went to 45s rather than re-tuning to whatever number happens to
clear the current CI load, since that number moves every time CI gains a
package.
---------
Co-authored-by: Claude Opus 5 (1M context)
---
.../core/src/audio/audioFxAutomation.test.ts | 313 ++++++-
packages/core/src/audio/audioFxAutomation.ts | 96 ++-
packages/core/src/audio/audioFxGraph.test.ts | 33 +
packages/core/src/audio/audioFxGraph.ts | 21 +
packages/core/src/audioAutomation.test.ts | 271 +++++++
packages/core/src/audioAutomation.ts | 179 +++-
packages/core/src/audioCarve.test.ts | 298 ++++++-
packages/core/src/audioCarve.ts | 377 ++++++++-
packages/core/src/audioFx.ts | 10 +
packages/core/src/runtime/audioFx.test.ts | 483 +++++++++++
packages/core/src/runtime/audioFx.ts | 65 +-
.../src/services/render/stages/audioStage.ts | 23 +-
.../src/services/renderOrchestrator.ts | 1 +
.../components/editor/PropertyPanel.test.tsx | 6 +-
.../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 | 766 ++++++++++++++++--
.../components/TimelineAutomationLane.tsx | 237 ++++--
.../TimelineAutomationLaneSlot.test.tsx | 4 +-
.../src/player/components/TimelineLanes.tsx | 21 +
.../components/TimelineTrackHeader.test.tsx | 116 ++-
.../player/components/TimelineTrackHeader.tsx | 154 +++-
.../components/automationGestureKeys.test.ts | 49 ++
.../components/automationGestureKeys.ts | 49 ++
.../components/automationLaneData.test.ts | 97 ++-
.../player/components/automationLaneData.ts | 85 +-
.../components/automationLaneDragMath.ts | 194 +++++
.../components/automationLaneGeometry.test.ts | 102 ++-
.../components/automationLaneGeometry.ts | 80 +-
.../player/components/automationLaneHeight.ts | 8 +-
.../components/automationLaneSelection.ts | 20 +-
.../components/useAutomationEdgeStretch.ts | 37 +-
.../components/useAutomationLaneGestures.ts | 371 +++++----
.../components/useAutomationLanes.test.tsx | 5 +-
.../player/components/useAutomationLanes.ts | 49 +-
.../components/useAutomationRangeDrag.ts | 114 +++
.../player/lib/automationStoreSync.test.ts | 83 ++
.../src/player/lib/automationStoreSync.ts | 56 ++
.../store/automationSelectionSlice.test.ts | 24 +-
.../player/store/automationSelectionSlice.ts | 22 +-
44 files changed, 4580 insertions(+), 516 deletions(-)
create mode 100644 packages/studio/src/player/components/automationGestureKeys.test.ts
create mode 100644 packages/studio/src/player/components/automationGestureKeys.ts
create mode 100644 packages/studio/src/player/components/automationLaneDragMath.ts
create mode 100644 packages/studio/src/player/components/useAutomationRangeDrag.ts
create mode 100644 packages/studio/src/player/lib/automationStoreSync.test.ts
create mode 100644 packages/studio/src/player/lib/automationStoreSync.ts
diff --git a/packages/core/src/audio/audioFxAutomation.test.ts b/packages/core/src/audio/audioFxAutomation.test.ts
index d8748f9f45..c6c92cecf1 100644
--- a/packages/core/src/audio/audioFxAutomation.test.ts
+++ b/packages/core/src/audio/audioFxAutomation.test.ts
@@ -2,6 +2,7 @@
import { describe, expect, it } from "vitest";
import {
cancelParamLane,
+ clearParamLane,
scheduleChainAutomation,
scheduleParamLane,
volumeLane,
@@ -63,12 +64,33 @@ 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();
scheduleParamLane([target], ramp, "linear", at(0));
expect(param.calls).toEqual([
- { op: "cancel", time: 10 },
+ // Cleared from zero, not held: holding leaves the span of a running curve
+ // booked, and Chrome refuses the next curve — or a plain `.value` write —
+ // that lands inside it. The seed below restores the value in the same pass,
+ // so clearing costs nothing audible.
+ { op: "cancel", time: 0 },
// Before the first point the envelope holds that point's value.
{ op: "set", value: 0.2, time: 10 },
{ op: "ramp", value: 0.8, time: 13 },
@@ -87,7 +109,7 @@ describe("scheduleParamLane", () => {
const { target, param } = fake();
scheduleParamLane([target], ramp, "linear", at(9));
expect(param.calls).toEqual([
- { op: "cancel", time: 10 },
+ { op: "cancel", time: 0 },
{ op: "set", value: 0.8, time: 10 },
]);
});
@@ -122,25 +144,13 @@ describe("scheduleParamLane", () => {
at(0),
);
expect(param.calls).toEqual([
- { op: "cancel", time: 10 },
+ { op: "cancel", time: 0 },
{ op: "set", value: 0.4, time: 10 },
]);
});
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");
@@ -152,6 +162,31 @@ describe("scheduleParamLane", () => {
expect(curve.values[Math.floor(curve.values.length / 2)]).toBeLessThan(0.4);
});
+ it("samples a segment bent by a via point, not just one bent by `curve`", () => {
+ // Both express the same thing — a segment that is not a straight line — and
+ // the via form is what the timeline writes when a bend is dragged. Read as
+ // straight, the whole bend was played as a linear ramp: the envelope drawn in
+ // the lane and the envelope heard were different shapes.
+ const { target, param } = fake();
+ scheduleParamLane(
+ [target],
+ {
+ target: "volume",
+ points: [
+ { t: 0, v: 0, viaX: 0.5, viaY: 0.9 },
+ { t: 2, v: 1 },
+ ],
+ },
+ "linear",
+ at(0),
+ );
+ const curve = param.calls.find((c) => c.op === "curve");
+ expect(curve, "a via-bent segment must be sampled, not ramped").toBeTruthy();
+ if (curve?.op !== "curve") throw new Error("expected a curve");
+ // Through the via point: 90% of the way up at the halfway mark.
+ expect(curve.values[Math.floor(curve.values.length / 2)]).toBeGreaterThan(0.8);
+ });
+
it("samples a log-scaled sweep, which a linear ramp would get wrong", () => {
const { target, param } = fake();
scheduleParamLane(
@@ -180,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");
@@ -232,6 +255,238 @@ describe("scheduleParamLane", () => {
});
});
+/**
+ * An AudioParam with Chrome's own overlap rule, and its own cancel semantics.
+ *
+ * The distinction that matters: `cancelScheduledValues(t)` drops events at or
+ * after `t` but leaves a value curve that is already running — the spec only
+ * special-cases an in-progress curve for `cancelAndHoldAtTime`, which truncates
+ * it. Schedule a curve inside one that is still live and the browser throws.
+ */
+class OverlapAwareParam {
+ curves: { time: number; duration: number }[] = [];
+ value = 0;
+ setValueAtTime(): void {}
+ linearRampToValueAtTime(): void {}
+ setValueCurveAtTime(_values: Float32Array, time: number, duration: number): void {
+ const clash = this.curves.find((c) => time < c.time + c.duration && time + duration > c.time);
+ if (clash) {
+ throw new Error(
+ `Failed to execute 'setValueCurveAtTime' on 'AudioParam': ` +
+ `setValueCurveAtTime(..., ${time}, ${duration}) overlaps ` +
+ `setValueCurveAtTime(..., ${clash.time}, ${clash.duration})`,
+ );
+ }
+ this.curves.push({ time, duration });
+ }
+ cancelScheduledValues(time: number): void {
+ // Events at or after the cancel; a curve already under way is untouched.
+ this.curves = this.curves.filter((c) => c.time < time);
+ }
+ cancelAndHoldAtTime(time: number): void {
+ this.curves = this.curves
+ .filter((c) => c.time < time)
+ .map((c) => ({ time: c.time, duration: Math.min(c.duration, time - c.time) }));
+ }
+}
+
+describe("rescheduling over a curve that is still playing", () => {
+ const bent: HfAutomationLane = {
+ target: "volume",
+ points: [
+ { t: 0, v: 1, curve: 1 },
+ { t: 8, v: 0.2, curve: 1 },
+ { t: 12, v: 1 },
+ ],
+ };
+
+ it("takes over from an in-progress curve instead of throwing", () => {
+ // Two schedule passes a few milliseconds apart is ordinary: an attribute edit
+ // lands, the graph is re-parameterised, and the envelope is re-aimed at the
+ // live playhead. The second pass has to displace the curve the first one
+ // started, which `cancelScheduledValues` does not do for a curve that is
+ // already running — the browser then refuses the new curve outright.
+ const param = new OverlapAwareParam();
+ const target = { param: param as unknown as AudioParam };
+ scheduleParamLane([target], bent, "linear", at(0, 1, 9.109333));
+ expect(() =>
+ scheduleParamLane([target], bent, "linear", at(0.005334, 1, 9.114667)),
+ ).not.toThrow();
+ });
+
+ it("survives a burst of reschedules, as a dragged knob produces", () => {
+ const param = new OverlapAwareParam();
+ const target = { param: param as unknown as AudioParam };
+ for (let i = 0; i < 12; i++) {
+ const when = 9.1 + i * 0.005;
+ expect(() =>
+ scheduleParamLane([target], bent, "linear", at(i * 0.005, 1, when)),
+ ).not.toThrow();
+ }
+ });
+});
+
+describe("a curve the browser refuses", () => {
+ /**
+ * A param that rejects any curve overlapping one it has been given, and — like
+ * Chrome — keeps counting a held curve's original span. Holding stops what is
+ * audible; it does not free the slot for overlap checking.
+ */
+ class UnforgivingParam {
+ curves: { time: number; duration: number }[] = [];
+ ramps: { time: number; value: number }[] = [];
+ sets: { time: number; value: number }[] = [];
+ value = 0;
+ setValueAtTime(value: number, time: number): void {
+ this.sets.push({ time, value });
+ }
+ linearRampToValueAtTime(value: number, time: number): void {
+ this.ramps.push({ time, value });
+ }
+ setValueCurveAtTime(_v: Float32Array, time: number, duration: number): void {
+ const clash = this.curves.find((c) => time < c.time + c.duration && time + duration > c.time);
+ if (clash) {
+ throw new Error(
+ `Failed to execute 'setValueCurveAtTime' on 'AudioParam': ` +
+ `setValueCurveAtTime(..., ${time}, ${duration}) overlaps ` +
+ `setValueCurveAtTime(..., ${clash.time}, ${clash.duration})`,
+ );
+ }
+ this.curves.push({ time, duration });
+ }
+ // Measured against Chrome, in a live running context and in an offline one
+ // suspended mid-curve: either cancel frees the span of a curve already under
+ // way, so only a write with no cancel at all is refused.
+ cancelScheduledValues(time: number): void {
+ this.curves = this.curves.filter((c) => c.time + c.duration < time);
+ }
+ cancelAndHoldAtTime(time: number): void {
+ this.cancelScheduledValues(time);
+ }
+ }
+
+ const bent: HfAutomationLane = {
+ target: "volume",
+ points: [
+ { t: 0, v: 1, curve: 1 },
+ { t: 0.25, v: 0.4, curve: 1 },
+ { t: 0.6, v: 1, curve: 1 },
+ { t: 1.2, v: 0.3 },
+ ],
+ };
+
+ it("reschedules mid-playback with its curves intact", () => {
+ // Applying a carve while the transport runs reschedules into curves that are
+ // already playing — one render quantum apart is the common case. Cancelling
+ // the parameter first is what lets the new pass keep its shape instead of
+ // being refused; this does not discriminate the strength of the cancel, which
+ // the no-cancel test below is for.
+ const param = new UnforgivingParam();
+ const target = { param: param as unknown as AudioParam };
+ scheduleParamLane([target], bent, "linear", at(0, 1, 1492.016));
+ const first = param.curves.length;
+ expect(() =>
+ scheduleParamLane([target], bent, "linear", at(0.005333, 1, 1492.021333)),
+ ).not.toThrow();
+ expect(param.curves.length).toBeGreaterThan(0);
+ // Curves, not ramps: nothing had to be degraded.
+ expect(param.ramps).toHaveLength(0);
+ expect(first).toBeGreaterThan(0);
+ });
+
+ it("still degrades to a ramp where the span cannot be freed at all", () => {
+ // The last line of defence, for a hypothetical param that refuses to give the
+ // span up. No engine has been measured behaving this way; it stands in for the
+ // unexplained curve-over-curve refusals reported from the field. The envelope
+ // loses a bend rather than the exception escaping and abandoning the rest.
+ class ImmovableParam extends UnforgivingParam {
+ override cancelScheduledValues(): void {
+ // Nothing is ever freed.
+ }
+ }
+ const param = new ImmovableParam();
+ const target = { param: param as unknown as AudioParam };
+ scheduleParamLane([target], bent, "linear", at(0, 1, 100));
+ expect(() =>
+ scheduleParamLane([target], bent, "linear", at(0.005333, 1, 100.005333)),
+ ).not.toThrow();
+ expect(param.ramps.length).toBeGreaterThan(0);
+ });
+});
+
+describe("a curve that starts at this very instant", () => {
+ /**
+ * Chrome's behaviour at the boundary, which is where this bit: holding at a
+ * time does not free a curve that *begins* at that time, so both a fresh curve
+ * and a plain `.value` write at the same instant are refused. Only a cancel
+ * clears it.
+ */
+ class BoundaryParam {
+ curves: { time: number; duration: number }[] = [];
+ #value = 0;
+ constructor(private clock: { currentTime: number }) {}
+ get value(): number {
+ return this.#value;
+ }
+ set value(v: number) {
+ const t = this.clock.currentTime;
+ const clash = this.curves.find((c) => t >= c.time && t <= c.time + c.duration);
+ if (clash) {
+ throw new Error(
+ `Failed to set the 'value' property on 'AudioParam': setValueAtTime(${v}, ${t}) ` +
+ `overlaps setValueCurveAtTime(..., ${clash.time}, ${clash.duration})`,
+ );
+ }
+ this.#value = v;
+ }
+ setValueAtTime(v: number): void {
+ this.#value = v;
+ }
+ linearRampToValueAtTime(): void {}
+ setValueCurveAtTime(_v: Float32Array, time: number, duration: number): void {
+ this.curves.push({ time, duration });
+ }
+ // As Chrome behaves: either cancel frees a running curve's span.
+ cancelScheduledValues(time: number): void {
+ this.curves = this.curves.filter((c) => c.time + c.duration < time);
+ }
+ cancelAndHoldAtTime(time: number): void {
+ this.cancelScheduledValues(time);
+ }
+ }
+
+ const bent: HfAutomationLane = {
+ target: "volume",
+ points: [
+ { t: 0, v: 1, curve: 1 },
+ { t: 0.04, v: 0.2, curve: 1 },
+ { t: 4, v: 1 },
+ ],
+ };
+
+ it("clears the booked span so the next pass can schedule at all", () => {
+ const clock = { currentTime: 91.069 };
+ const param = new BoundaryParam(clock);
+ const target = { param: param as unknown as AudioParam };
+ scheduleParamLane([target], bent, "linear", at(0, 1, 91.069));
+ expect(() => scheduleParamLane([target], bent, "linear", at(0, 1, 91.069))).not.toThrow();
+ });
+
+ it("leaves the parameter writable, which is what a chain edit does next", () => {
+ // The re-parameterise after an edit writes every knob straight onto its param.
+ // Landing inside a span the scheduler booked at this instant is the error the
+ // console kept reporting, with the gain stage's own unity value in it.
+ const clock = { currentTime: 91.069 };
+ const param = new BoundaryParam(clock);
+ const target = { param: param as unknown as AudioParam };
+ scheduleParamLane([target], bent, "linear", at(0, 1, 91.069));
+ clearParamLane([target]);
+ expect(() => {
+ (param as unknown as { value: number }).value = 1;
+ }).not.toThrow();
+ });
+});
+
describe("cancelParamLane", () => {
it("holds the value the envelope had reached rather than snapping back", () => {
const { target, param } = fake();
diff --git a/packages/core/src/audio/audioFxAutomation.ts b/packages/core/src/audio/audioFxAutomation.ts
index 766c14ece8..94e7cca6b2 100644
--- a/packages/core/src/audio/audioFxAutomation.ts
+++ b/packages/core/src/audio/audioFxAutomation.ts
@@ -14,6 +14,7 @@ import {
sampleAutomationLane,
type HfAutomation,
type HfAutomationLane,
+ type HfAutomationPoint,
} from "../audioAutomation.js";
import { parseAutomationTarget, VOLUME_TARGET } from "../audioAutomation.js";
import type { HfAudioFxChain } from "../audioFx.js";
@@ -46,16 +47,23 @@ type Op =
| { kind: "curve"; time: number; duration: number; values: Float32Array };
/**
- * A segment is a straight line only when nothing bends it: no curvature on the
- * point it leaves, a linear parameter scale, and no unit mapping that could be
- * non-linear. Everything else is sampled.
+ * A segment is a straight line only when nothing bends it: no curvature and no
+ * via point on the point it leaves, a linear parameter scale, and no unit
+ * mapping that could be non-linear. Everything else is sampled.
+ *
+ * Both bend forms have to be checked. `curve` is an exponent; a via point names
+ * an interior point the segment passes through, and it is what the timeline
+ * writes when a bend is dragged. Testing only the exponent read every dragged
+ * bend as straight and played it as a linear ramp — the lane drew one shape and
+ * the ear got another.
*/
function isStraight(
- curve: number | undefined,
+ point: Pick,
scale: "linear" | "log",
map: FxParamTarget["map"],
): boolean {
- return !curve && scale === "linear" && !map;
+ const bent = Boolean(point.curve) || (point.viaX !== undefined && point.viaY !== undefined);
+ return !bent && scale === "linear" && !map;
}
function curvePointCount(duration: number): number {
@@ -86,7 +94,7 @@ function segmentOp(
const duration = toTime - fromTime;
if (duration <= 0) return null;
- if (isStraight(a.curve, scale, map)) {
+ if (isStraight(a, scale, map)) {
return { kind: "ramp", time: toTime, value: apply(b.v) };
}
// Sample from wherever the segment is entered, which is mid-segment when the
@@ -128,11 +136,66 @@ function planOps(
return ops;
}
+/**
+ * Hand the plan to the AudioParam, degrading a curve the browser will not take.
+ *
+ * A value curve may not overlap another, and this has been seen refused in the
+ * field while rescheduling mid-playback — two passes landing a render quantum
+ * apart, which is what applying a carve while the transport runs does. Every
+ * caller here cancels the parameter first, and measured against Chrome that is
+ * enough to free even a curve already under way, so the mechanism behind those
+ * reports is not understood and does not reproduce. This catch is the backstop
+ * for it: unhandled, the exception reached the console and abandoned every
+ * remaining segment of the envelope.
+ *
+ * A ramp to the same destination is always accepted, since it books no span. The
+ * segment loses its bend until the next full reschedule, which is a far better
+ * failure than the rest of the envelope never being scheduled at all.
+ */
function emit(param: AudioParam, ops: readonly Op[]): void {
for (const op of ops) {
if (op.kind === "set") param.setValueAtTime(op.value, op.time);
else if (op.kind === "ramp") param.linearRampToValueAtTime(op.value, op.time);
- else param.setValueCurveAtTime(op.values, op.time, op.duration);
+ else {
+ try {
+ param.setValueCurveAtTime(op.values, op.time, op.duration);
+ } catch {
+ const end = op.values[op.values.length - 1] ?? 0;
+ param.linearRampToValueAtTime(end, op.time + op.duration);
+ }
+ }
+ }
+}
+
+/**
+ * Drop every event booked on a parameter, including a curve already under way.
+ *
+ * Anything landing inside a running curve is refused — a fresh curve, or the
+ * plain `.value` write that re-parameterising a chain performs — so the param has
+ * to be cancelled first. Measured against Chrome, any cancel frees the span; this
+ * takes the strongest form on purpose, because curve-over-curve refusals were
+ * reported from the field with a cancel at the new schedule time already in place
+ * and have never reproduced. Cancelling from zero cannot leave a stale event
+ * behind whatever that mechanism is.
+ *
+ * Safe on these parameters because this scheduler is their only writer: they
+ * belong to effect nodes it built, and the caller re-seeds the value in the same
+ * breath, so nothing clicks.
+ */
+export function clearParamLane(targets: readonly FxParamTarget[]): void {
+ for (const target of targets) target.param.cancelScheduledValues(0);
+}
+
+/** Stop an envelope, leaving the parameter wherever it currently sits. */
+export function cancelParamLane(targets: readonly FxParamTarget[], from: number): void {
+ for (const target of targets) {
+ // Keep the value the ramp had reached rather than snapping back to the
+ // last explicitly-set one, which would click.
+ if (typeof target.param.cancelAndHoldAtTime === "function") {
+ target.param.cancelAndHoldAtTime(from);
+ } else {
+ target.param.cancelScheduledValues(from);
+ }
}
}
@@ -150,7 +213,11 @@ export function scheduleParamLane(
): void {
if (lane.points.length === 0) return;
for (const target of targets) {
- target.param.cancelScheduledValues(timing.scheduledAt);
+ // Cleared from zero rather than from this instant: a reschedule mid-playback
+ // lands inside the previous pass's still-running curve, and the field reports
+ // of that being refused had a cancel at this instant already in place. The
+ // plan below re-seeds the value here, so clearing costs nothing audible.
+ clearParamLane([target]);
if (isConstantLane(lane)) {
const value = (target.map ?? identity)(lane.points[0]!.v);
target.param.setValueAtTime(value, timing.scheduledAt);
@@ -160,19 +227,6 @@ export function scheduleParamLane(
}
}
-/** Stop an envelope, leaving the parameter wherever it currently sits. */
-export function cancelParamLane(targets: readonly FxParamTarget[], from: number): void {
- for (const target of targets) {
- // Keep the value the ramp had reached rather than snapping back to the
- // last explicitly-set one, which would click.
- if (typeof target.param.cancelAndHoldAtTime === "function") {
- target.param.cancelAndHoldAtTime(from);
- } else {
- target.param.cancelScheduledValues(from);
- }
- }
-}
-
/** One built effect, paired with the chain node id lanes address it by. */
export interface AutomatableNode {
id?: string;
diff --git a/packages/core/src/audio/audioFxGraph.test.ts b/packages/core/src/audio/audioFxGraph.test.ts
index 6339ba5f23..9c54f12bc9 100644
--- a/packages/core/src/audio/audioFxGraph.test.ts
+++ b/packages/core/src/audio/audioFxGraph.test.ts
@@ -342,6 +342,39 @@ describe("levels and per-channel state", () => {
});
});
+describe("gain stage", () => {
+ /**
+ * A level control the chain owns, so a carve can make broadband room for a
+ * voice without touching the track's own volume lane — which belongs to the
+ * author, and which a carve re-run would otherwise have to overwrite.
+ */
+ it("sets a GainNode from a value in dB", () => {
+ const c = ctx();
+ buildFxNode(c as unknown as BaseAudioContext, "gain", { gain: -6 });
+ const node = c.created.find((x) => x.kind === "gain");
+ expect(node).toBeTruthy();
+ expect(node!.gain.value).toBeCloseTo(0.501, 3);
+ });
+
+ it("passes unity through at 0 dB", () => {
+ const c = ctx();
+ buildFxNode(c as unknown as BaseAudioContext, "gain", { gain: 0 });
+ expect(c.created.find((x) => x.kind === "gain")!.gain.value).toBeCloseTo(1, 6);
+ });
+
+ it("maps an automation lane's dB into the AudioParam's linear units", () => {
+ // The lane is authored in the knob's own unit — dB, like every other gain in
+ // the registry — but a GainNode's param is a linear multiplier. Without the
+ // mapping, a lane point of -12 would schedule a gain of -12: a phase flip
+ // twelve times too loud, not a cut.
+ const handle = buildFxNode(ctx() as unknown as BaseAudioContext, "gain", { gain: 0 });
+ const target = handle.automation?.gain?.[0];
+ expect(target?.map).toBeTypeOf("function");
+ expect(target!.map!(-12)).toBeCloseTo(0.251, 3);
+ expect(target!.map!(0)).toBeCloseTo(1, 6);
+ });
+});
+
describe("automatable parameters", () => {
/**
* The registry's `automatable` flag is what the panel and the scheduler both
diff --git a/packages/core/src/audio/audioFxGraph.ts b/packages/core/src/audio/audioFxGraph.ts
index 8c2ce8dfb1..9e5172f82d 100644
--- a/packages/core/src/audio/audioFxGraph.ts
+++ b/packages/core/src/audio/audioFxGraph.ts
@@ -117,6 +117,26 @@ function simple(
*/
const USES_Q: ReadonlySet = new Set(["peaking", "highpass", "lowpass"]);
+/** dB on the knob, a linear multiplier on the AudioParam. */
+const dbToLinear = (db: number): number => Math.pow(10, db / 20);
+
+/**
+ * A plain level stage.
+ *
+ * Every other gain in the registry sits on a BiquadFilterNode, whose `gain`
+ * param is already in dB. A GainNode's is a linear multiplier, so both the
+ * initial value and anything an automation lane schedules have to be converted —
+ * which is what `FxParamTarget.map` is for.
+ */
+const gainStage: Builder = (ctx, p) => {
+ const g = ctx.createGain();
+ const apply = (v: HfAudioFxParamValues): void => {
+ g.gain.value = dbToLinear(n(v.gain));
+ };
+ apply(p);
+ return simple(g, apply, { gain: [{ param: g.gain, map: dbToLinear }] });
+};
+
function biquad(type: BiquadFilterType, useGain: boolean): Builder {
return (ctx, p) => {
const f = ctx.createBiquadFilter();
@@ -394,6 +414,7 @@ const convolver: Builder = (ctx, p) => {
};
const BUILDERS: Record = {
+ "gain-node": gainStage,
"biquad-peaking": biquad("peaking", true),
"biquad-lowshelf": biquad("lowshelf", true),
"biquad-highshelf": biquad("highshelf", true),
diff --git a/packages/core/src/audioAutomation.test.ts b/packages/core/src/audioAutomation.test.ts
index 4a59caae8a..b73ef35cd9 100644
--- a/packages/core/src/audioAutomation.test.ts
+++ b/packages/core/src/audioAutomation.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
applyCurve,
+ shapeProgress,
fxAutomationTarget,
isConstantLane,
parseAutomation,
@@ -10,6 +11,7 @@ import {
sampleAutomationCurve,
sampleAutomationLane,
serializeAutomation,
+ steadyViaPoint,
VOLUME_RANGE,
type HfAutomationLane,
} from "./audioAutomation.js";
@@ -152,6 +154,54 @@ describe("normalisation", () => {
};
expect(parseAutomation(serializeAutomation(source))).toEqual(source);
});
+
+ it("round-trips a via point, and keeps it as a pair", () => {
+ const source = {
+ version: 1,
+ lanes: [
+ lane([
+ // A via point with no `curve` at all: the bend is entirely described by
+ // where the segment goes. It has to survive on its own, or a bend
+ // dragged near a breakpoint silently straightens on the next load.
+ { t: 0, v: 0.8, viaX: 0.7, viaY: 0.6 },
+ { t: 2, v: 0.2, curve: 0.5, viaX: 0.3, viaY: 0.44 },
+ { t: 3, v: 0.5 },
+ ]),
+ ],
+ };
+ expect(parseAutomation(serializeAutomation(source))).toEqual(source);
+ });
+
+ it("drops a via point that says nothing, and clamps one off the segment", () => {
+ const parsed = parseAutomation(
+ JSON.stringify({
+ version: 1,
+ lanes: [
+ {
+ target: "volume",
+ points: [
+ // Half a via point describes no shape; taking one coordinate on its
+ // own would leave the segment's shape depending on which half
+ // survived a hand edit.
+ { t: 0, v: 0.5, viaX: 0.4 },
+ // On the diagonal: this IS the straight line, so storing it would
+ // claim a bend the segment does not have.
+ { t: 1, v: 0.6, viaX: 0.4, viaY: 0.4 },
+ // Outside the segment entirely: pulled back to the steady region
+ // rather than describing a shape no curve can draw.
+ { t: 2, v: 0.7, viaX: 5, viaY: -3 },
+ { t: 3, v: 1 },
+ ],
+ },
+ ],
+ }),
+ );
+ const points = parsed.lanes[0]!.points;
+ expect(points[0]).not.toHaveProperty("viaX");
+ expect(points[1]).not.toHaveProperty("viaX");
+ expect(points[2]!.viaX).toBeCloseTo(0.999, 6);
+ expect(points[2]!.viaY).toBeCloseTo(0.001, 6);
+ });
});
describe("resolveAutomation", () => {
@@ -230,6 +280,227 @@ describe("sampling", () => {
expect(applyCurve(0.5, 0)).toBe(0.5);
});
+ it("leaves a legacy curve-only point sampling exactly as it always did", () => {
+ for (const x of [0, 0.1, 0.25, 0.5, 0.75, 0.9, 1]) {
+ expect(shapeProgress(x, { curve: 0.5 })).toBe(Math.pow(x, Math.pow(2, 1)));
+ }
+ expect(shapeProgress(0.5, {})).toBe(0.5);
+ });
+
+ it("passes exactly through any via point, however deep or off-centre", () => {
+ // The contract the drag depends on: the pointer's position IS the shape. No
+ // depth cap and no position cap — an earlier version held bends to a maximum
+ // slope, which capped how far the line could be pulled and, worse, could clamp a
+ // bend onto the straight line and flatten it mid-drag.
+ for (const [vx, vy] of [
+ [0.5, 0.7],
+ [0.5, 0.95],
+ [0.2, 0.8],
+ [0.1, 0.9],
+ [0.05, 0.95],
+ [0.9, 0.15],
+ [0.3, 0.05],
+ [0.95, 0.55],
+ ] as const) {
+ expect(shapeProgress(vx, { viaX: vx, viaY: vy })).toBeCloseTo(vy, 6);
+ }
+ });
+
+ it("puts the curve's furthest point from straight exactly at the via point", () => {
+ // "The cursor is the apex": not near it, at it. The conic passes through the via
+ // point at its own midparameter, and its two halves are symmetric in parameter,
+ // so the deepest departure from the straight line lands there by construction.
+ for (const [vx, vy] of [
+ [0.1, 0.9],
+ [0.2, 0.8],
+ [0.5, 0.95],
+ [0.8, 0.3],
+ [0.9, 0.15],
+ [0.95, 0.55],
+ ] as const) {
+ let deepest = 0;
+ let at = 0;
+ for (let k = 1; k < 1000; k++) {
+ const x = k / 1000;
+ const gap = Math.abs(shapeProgress(x, { viaX: vx, viaY: vy }) - x);
+ if (gap > deepest) {
+ deepest = gap;
+ at = x;
+ }
+ }
+ expect(at).toBeCloseTo(vx, 2);
+ expect(deepest).toBeCloseTo(Math.abs(vy - vx), 2);
+ }
+ });
+
+ it("reaches a bend far deeper than a plain quadratic could", () => {
+ // A plain quadratic needs its control point at 2Q - M, which leaves the segment
+ // once the via point is past the middle half — so it cannot pass through a deep
+ // point at all. The conic's weight is what buys the reach.
+ expect(shapeProgress(0.1, { viaX: 0.1, viaY: 0.9 })).toBeCloseTo(0.9, 6);
+ expect(shapeProgress(0.05, { viaX: 0.05, viaY: 0.95 })).toBeCloseTo(0.95, 6);
+ });
+
+ it("never returns NaN for a via point dragged past the segment edge", () => {
+ // A via point pulled out to (5, -3) clamps to (0.999, 0.001) — exactly on the
+ // steady region's edge, where `edge - viaX` is 0 and the conic's weight used
+ // to divide out to Infinity, then NaN a few steps later. NaN reaching
+ // setValueCurveAtTime silences the node for the rest of the render.
+ for (const x of [0, 0.1, 0.25, 0.5, 0.75, 0.9, 1]) {
+ const y = shapeProgress(x, { viaX: 5, viaY: -3 });
+ expect(Number.isFinite(y)).toBe(true);
+ }
+ });
+
+ it("never flattens a bend that is pulled harder", () => {
+ // The regression. Holding a bend's depth inside the steady region by clamping
+ // its coordinates one at a time snapped the curve flat mid-drag: near the ends
+ // of a segment almost every legal value sits on ONE side of the straight line,
+ // so a bend pulled the other way got clamped onto the line itself and vanished.
+ // Pulling further has to keep bending the way the pointer asked, always.
+ for (const viaX of [0.1, 0.3, 0.5, 0.7, 0.9]) {
+ for (const viaY of [0.9, 0.6, 0.45, 0.3, 0.05, -0.5, 1.5]) {
+ if (Math.abs(viaY - viaX) < 0.02) continue;
+ const via = steadyViaPoint(viaX, viaY);
+ expect(via).not.toBeNull();
+ if (!via) continue;
+ // Same side of the straight line as the pointer asked for.
+ expect(Math.sign(via.viaY - via.viaX)).toBe(Math.sign(viaY - viaX));
+ // And a bend worth seeing, not a hair off straight.
+ expect(Math.abs(via.viaY - via.viaX)).toBeGreaterThan(0.01);
+ }
+ }
+ });
+
+ it("stays a steady curve through the via point, with no corner to see", () => {
+ // A shape built from two curves meeting AT the via point can only be as smooth
+ // as that join, and keeping such a join monotone forces its tangent down to a
+ // fraction of the slope the curve arrives with — which reads as a sharp corner
+ // exactly where the pointer is. One conic arc has no join: slope is continuous
+ // by construction, so the ratio across the via point stays near 1 even for the
+ // extreme bends where a spliced curve kinked hardest.
+ const slopeAt = (x: number, viaX: number, viaY: number): number => {
+ const h = 1e-4;
+ return (
+ (shapeProgress(x + h, { viaX, viaY }) - shapeProgress(x - h, { viaX, viaY })) / (2 * h)
+ );
+ };
+ for (const [viaX, viaY] of [
+ [0.1, 0.9],
+ [0.9, 0.1],
+ [0.15, 0.6],
+ [0.8, 0.95],
+ [0.5, 0.95],
+ [0.5, 0.05],
+ ] as const) {
+ const before = slopeAt(viaX - 0.02, viaX, viaY);
+ const after = slopeAt(viaX + 0.02, viaX, viaY);
+ expect(before).toBeGreaterThan(0);
+ expect(after).toBeGreaterThan(0);
+ // Within a factor of 2.2 across a 4% window either side — that much is real
+ // curvature on a tight bend. Two cubics spliced at the via point measured
+ // 27.6 on the first of these, and 2.4-3.6 on the gentler ones.
+ const ratio = before > after ? before / after : after / before;
+ expect(ratio).toBeLessThan(2.2);
+ }
+ });
+
+ it("never changes slope abruptly anywhere along the segment", () => {
+ // The same property swept rather than probed at the via point, so a corner
+ // introduced anywhere else would fail too.
+ for (const [viaX, viaY] of [
+ [0.2, 0.8],
+ [0.85, 0.35],
+ [0.5, 0.9],
+ [0.1, 0.9],
+ [0.9, 0.08],
+ ] as const) {
+ let previous: number | null = null;
+ for (let k = 2; k < 98; k++) {
+ const x = k / 100;
+ const h = 1e-3;
+ const slope =
+ (shapeProgress(x + h, { viaX, viaY }) - shapeProgress(x - h, { viaX, viaY })) / (2 * h);
+ if (previous !== null) {
+ const ratio = slope > previous ? slope / previous : previous / slope;
+ // 1% of the segment at a time: a steady curve changes slope gradually.
+ // The spliced version measured 2.2 here, and 14.5 with the via point
+ // dragged into a corner.
+ expect(ratio).toBeLessThan(1.7);
+ }
+ previous = slope;
+ }
+ }
+ });
+
+ it("keeps the segment pinned at both breakpoints", () => {
+ for (const [vx, vy] of [
+ [0.2, 0.7],
+ [0.85, 0.3],
+ ] as const) {
+ expect(shapeProgress(0, { viaX: vx, viaY: vy })).toBeCloseTo(0, 9);
+ expect(shapeProgress(1, { viaX: vx, viaY: vy })).toBeCloseTo(1, 9);
+ }
+ });
+
+ it("stays monotone for every via point, so a render never sags mid-segment", () => {
+ // Not cosmetic: a segment is baked into setValueCurveAtTime, and progress
+ // that dipped backwards is a rising fader audibly dropping. This is the
+ // property the Fritsch-Carlson tangent limit is there to guarantee, so it is
+ // swept rather than spot-checked.
+ for (let ix = 1; ix < 20; ix++) {
+ for (let iy = 1; iy < 20; iy++) {
+ const viaX = ix / 20;
+ const viaY = iy / 20;
+ let previous = -Infinity;
+ for (let k = 0; k <= 60; k++) {
+ const y = shapeProgress(k / 60, { viaX, viaY });
+ expect(y).toBeGreaterThanOrEqual(previous - 1e-9);
+ previous = y;
+ }
+ }
+ }
+ });
+
+ it("puts the bend where the via point is, not always on the same side", () => {
+ // The complaint this replaced: every upward bend a single exponent could draw
+ // deviated most in the first fifth of the segment. Now the peak deviation
+ // tracks the via point across the whole span.
+ const apexOf = (viaX: number, viaY: number): number => {
+ let best = 0;
+ let at = 0;
+ for (let k = 1; k < 100; k++) {
+ const x = k / 100;
+ const gap = Math.abs(shapeProgress(x, { viaX, viaY }) - x);
+ if (gap > best) {
+ best = gap;
+ at = x;
+ }
+ }
+ return at;
+ };
+ // Near, not exactly at: the deviation is smooth through the knot, so its peak
+ // can sit slightly inside. What matters is that it tracks the via point
+ // across the whole segment instead of parking in the first fifth.
+ for (const viaX of [0.3, 0.4, 0.5, 0.6, 0.7]) {
+ expect(Math.abs(apexOf(viaX, viaX + 0.06) - viaX)).toBeLessThan(0.12);
+ }
+ expect(apexOf(0.3, 0.36)).toBeLessThan(apexOf(0.7, 0.76));
+ });
+
+ it("bends either way from the same via progress", () => {
+ const at = (viaY: number) =>
+ sampleAutomationLane(
+ lane([
+ { t: 0, v: 0, viaX: 0.7, viaY },
+ { t: 1, v: 1 },
+ ]),
+ 0.7,
+ );
+ expect(at(0.56)).toBeCloseTo(0.56, 6);
+ expect(at(0.73)).toBeCloseTo(0.73, 6);
+ });
+
it("walks a dense lane by bisection, not by scanning", () => {
const points = Array.from({ length: 200 }, (_, i) => ({ t: i, v: i % 2 }));
const dense = lane(points);
diff --git a/packages/core/src/audioAutomation.ts b/packages/core/src/audioAutomation.ts
index f6363f5426..a47edf13fe 100644
--- a/packages/core/src/audioAutomation.ts
+++ b/packages/core/src/audioAutomation.ts
@@ -35,6 +35,25 @@ export interface HfAutomationPoint {
* straight line; positive holds low then rises late, negative rises early.
*/
curve?: number;
+ /**
+ * An interior point the segment *leaving* this point passes through, in
+ * normalised segment space: `viaX` is progress 0..1 between the two
+ * breakpoints, `viaY` is how far the value has travelled by then. Both are
+ * needed for either to mean anything; without them the segment falls back to
+ * `curve` above, so everything authored before this existed reads unchanged.
+ *
+ * Two numbers rather than one because `curve` answers only "how hard", and an
+ * exponent cannot say "and where". Every upward bend a single exponent can
+ * draw has its deepest deviation in the first fifth of the segment, whatever
+ * the author aimed at, and an exponent steep enough to reach a point near
+ * either end runs past the ±1 the model accepts — so a bend dragged near the
+ * right-hand breakpoint bulged on the left and then stopped following the
+ * pointer at all. Naming the point the curve goes through makes both the
+ * height and the position of the bend the author's to choose, and takes the
+ * saturation with it: any interior point is reachable exactly.
+ */
+ viaX?: number;
+ viaY?: number;
}
export interface HfAutomationLane {
@@ -153,6 +172,67 @@ function clampCurve(v: unknown): number {
return Math.min(1, Math.max(-1, n));
}
+/**
+ * A via point, or null when there isn't a usable one.
+ *
+ * Only two things disqualify one: a coordinate outside the segment, which names a
+ * point the curve does not travel through, and a point ON the straight line, which
+ * is a straight line — storing that would claim a bend the segment does not have.
+ *
+ * Depth is deliberately NOT limited. An earlier version held bends to a maximum
+ * slope so a segment could never leave a breakpoint steeply, and it was wrong twice
+ * over: it capped how far a curve could be pulled, and clamping the two coordinates
+ * separately could land a bend exactly on the straight line and flatten it mid-drag.
+ * A steep departure is a smooth curve doing what it was asked; what must never
+ * happen is a crease, and that is the sampler's job below, not a limit here.
+ */
+function clampVia(rawX: unknown, rawY: unknown): { x: number; y: number } | null {
+ const x = numberOrNull(rawX);
+ const y = numberOrNull(rawY);
+ if (x === null || y === null) return null;
+ const inside = (n: number): number => Math.min(0.999, Math.max(0.001, n));
+ const cx = inside(x);
+ const cy = inside(y);
+ if (Math.abs(cx - cy) < 1e-6) return null;
+ return { x: cx, y: cy };
+}
+
+/**
+ * The via point a segment will actually be drawn with, given one that was asked
+ * for — pulled into the steady region, or null when it describes no bend.
+ *
+ * Exported so an editor can write what the model will honour rather than what the
+ * pointer happened to ask for. Without it a lane's attribute claims a shape the
+ * renderer quietly declines to draw, and the two only agree once the file makes a
+ * round trip.
+ */
+export function steadyViaPoint(
+ viaX: number | undefined,
+ viaY: number | undefined,
+): { viaX: number; viaY: number } | null {
+ const via = clampVia(viaX, viaY);
+ return via ? { viaX: via.x, viaY: via.y } : null;
+}
+
+/**
+ * The shape fields a point carries, cleaned: whichever of `curve` and the via
+ * pair survive, each omitted when it describes nothing.
+ *
+ * The via pair is kept or dropped together. Half a via point says nothing, and
+ * letting one coordinate through would leave the segment's shape depending on
+ * which half survived a hand edit.
+ */
+function shapeFields(
+ p: HfAutomationPoint | undefined,
+): Pick {
+ const curve = clampCurve(p?.curve);
+ const via = clampVia(p?.viaX, p?.viaY);
+ return {
+ ...(curve ? { curve } : {}),
+ ...(via ? { viaX: via.x, viaY: via.y } : {}),
+ };
+}
+
/**
* Clean one point, or reject it.
*
@@ -167,8 +247,7 @@ function cleanPoint(
const v = numberOrNull(p?.v);
if (t === null || v === null) return null;
const clamped = range ? Math.min(range.max, Math.max(range.min, v)) : v;
- const curve = clampCurve(p?.curve);
- return { t: Math.max(0, t), v: clamped, ...(curve ? { curve } : {}) };
+ return { t: Math.max(0, t), v: clamped, ...shapeFields(p) };
}
/**
@@ -281,6 +360,7 @@ export function serializeAutomation(automation: HfAutomation): string {
t: p.t,
v: p.v,
...(p.curve ? { curve: p.curve } : {}),
+ ...(p.viaX !== undefined && p.viaY !== undefined ? { viaX: p.viaX, viaY: p.viaY } : {}),
})),
})),
});
@@ -298,6 +378,99 @@ export function applyCurve(x: number, curve: number | undefined): number {
return Math.pow(x, Math.pow(2, 2 * curve));
}
+/**
+ * The conic that carries a segment through its via point: control point and weight
+ * of a rational quadratic Bezier from (0,0) to (1,1).
+ *
+ * A rational quadratic at its own midparameter is `(P0 + 2wC + P1) / (2 + 2w)`, so
+ * demanding that equal the via point `Q` fixes the control point for any weight:
+ * `C = Q + (Q - M) / w`, with `M` the straight midpoint. The curve therefore passes
+ * exactly through `Q` whatever the weight, and — because it does so at the
+ * midparameter, with the two halves symmetric in parameter — its furthest departure
+ * from the straight line is at `Q` too. The pointer is the apex, by construction.
+ *
+ * The weight is what buys reach. A plain quadratic (`w = 1`) needs its control point
+ * at `2Q - M`, which leaves the segment as soon as `Q` is past the middle half — so
+ * a plain quadratic simply cannot pass through a deep or off-centre point. Raising
+ * the weight draws the control point back toward `Q`, so take the smallest weight
+ * that keeps `C` inside the segment: inside is what makes progress monotone, and
+ * smallest-that-fits is the broadest curve available through that point.
+ *
+ * Deep bends therefore come out tight and steep near the breakpoint they lean on.
+ * That is the shape being asked for, and it stays a single smooth arc while doing
+ * it — no join, no inflection, no crease.
+ */
+function viaConic(viaX: number, viaY: number): { cx: number; cy: number; w: number } {
+ const dx = viaX - 0.5;
+ const dy = viaY - 0.5;
+ const edge = 0.999;
+ // Per axis, `C` stays inside when the weight is at least the pull divided by the
+ // room left in the pull's own direction. A via point clamped to `edge` itself
+ // leaves zero room — `edge - viaX` is exactly 0 — which would divide out to
+ // Infinity and carry NaN into every downstream sample. Capped rather than left
+ // unbounded: past this weight the arc already reads as touching the via point,
+ // so the cap costs no visible reach.
+ const MAX_WEIGHT = 1e6;
+ const needX = dx > 0 ? dx / (edge - viaX) : dx < 0 ? -dx / (viaX - (1 - edge)) : 0;
+ const needY = dy > 0 ? dy / (edge - viaY) : dy < 0 ? -dy / (viaY - (1 - edge)) : 0;
+ const w = Math.min(MAX_WEIGHT, Math.max(1, needX, needY));
+ return { cx: viaX + dx / w, cy: viaY + dy / w, w };
+}
+
+/**
+ * Progress through a segment that passes through its via point: one conic arc, from
+ * breakpoint to breakpoint.
+ *
+ * One curve, not two joined at the via point, because anything joined there can only
+ * be as smooth as the join — and keeping such a join monotone forces its tangent
+ * down to a fraction of the slope the curve arrives with, creasing the segment
+ * exactly where the pointer is. A conic has no join and no inflection: its slope
+ * moves one way from end to end, which is what makes it read as one curve rather
+ * than a shape with features in it.
+ */
+function shapeVia(x: number, viaX: number, viaY: number): number {
+ const { cx, cy, w } = viaConic(viaX, viaY);
+ // Both coordinates are quadratics over one shared denominator, so `x(t) = x`
+ // rearranges into an ordinary quadratic in `t`: closed form, and the same answer on
+ // every machine, which a render that has to match its own preview depends on.
+ const spread = 2 - 2 * w;
+ const a = x * spread - 1 + 2 * w * cx;
+ const b = -x * spread - 2 * w * cx;
+ const t = conicParam(a, b, x);
+ const rest = 1 - t;
+ const denominator = rest * rest + 2 * w * t * rest + t * t;
+ if (!(denominator > 0)) return x;
+ return (2 * w * cy * t * rest + t * t) / denominator;
+}
+
+/** The root of `a*t^2 + b*t + c` that lies on the arc the segment travels. */
+function conicParam(a: number, b: number, c: number): number {
+ if (Math.abs(a) < 1e-12) return Math.abs(b) < 1e-12 ? c : -c / b;
+ const root = Math.sqrt(Math.max(0, b * b - 4 * a * c));
+ const first = (-b + root) / (2 * a);
+ const second = (-b - root) / (2 * a);
+ const onArc = (t: number): boolean => t >= -1e-9 && t <= 1 + 1e-9;
+ if (onArc(first)) return Math.min(1, Math.max(0, first));
+ if (onArc(second)) return Math.min(1, Math.max(0, second));
+ return c;
+}
+
+/**
+ * The progress a segment leaving `point` has reached at normalised `x`.
+ *
+ * A via point wins when it is there, since it says strictly more than an
+ * exponent can. Everything authored before via points existed carries only
+ * `curve` and takes the exponent path unchanged.
+ */
+export function shapeProgress(
+ x: number,
+ point: { curve?: number | undefined; viaX?: number | undefined; viaY?: number | undefined },
+): number {
+ const via = clampVia(point.viaX, point.viaY);
+ if (via) return shapeVia(Math.min(1, Math.max(0, x)), via.x, via.y);
+ return applyCurve(x, point.curve);
+}
+
function lerpValue(a: number, b: number, x: number, scale: "linear" | "log"): number {
// A frequency sweep interpolated linearly spends almost all its time in the
// top octave. Log-scaled parameters interpolate in log space so a 200 Hz to
@@ -338,7 +511,7 @@ export function sampleAutomationLane(
const b = pts[hi]!;
const span = b.t - a.t;
if (span <= 0) return b.v;
- return lerpValue(a.v, b.v, applyCurve((t - a.t) / span, a.curve), scale);
+ return lerpValue(a.v, b.v, shapeProgress((t - a.t) / span, a), scale);
}
/** True when the lane is a single value, i.e. worth setting once and not scheduling. */
diff --git a/packages/core/src/audioCarve.test.ts b/packages/core/src/audioCarve.test.ts
index 5778a1731d..cbd337520c 100644
--- a/packages/core/src/audioCarve.test.ts
+++ b/packages/core/src/audioCarve.test.ts
@@ -1,11 +1,18 @@
import { describe, expect, it } from "vitest";
+import { MAX_AUTOMATION_POINTS, sampleAutomationLane } from "./audioAutomation.js";
import {
analyseCarveBands,
+ analyseCarveDuck,
+ analyseCarveDynamics,
carveBandsToChain,
+ carveProfile,
DEFAULT_CARVE,
normalizeCarveSettings,
} from "./audioCarve.js";
+/** The profile the panel's default strength produces — what the separate controls defaulted to. */
+const PROFILE = carveProfile(DEFAULT_CARVE.strength);
+
const SR = 48000;
/** A tone-plus-harmonics stand-in for a voice, centred on `f0`. */
@@ -25,28 +32,99 @@ function voiceLike(f0: number, seconds = 0.5): Float32Array {
describe("normalizeCarveSettings", () => {
it("fills defaults and clamps nonsense", () => {
expect(normalizeCarveSettings(undefined)).toEqual(DEFAULT_CARVE);
- const v = normalizeCarveSettings({ maxCutDb: 500, bands: 99, q: 0, intelligibilityBias: -3 });
- expect(v.maxCutDb).toBe(24);
- expect(v.bands).toBe(6);
- expect(v.q).toBe(0.3);
- expect(v.intelligibilityBias).toBe(0);
+ expect(normalizeCarveSettings({ strength: 40 }).strength).toBe(1);
+ expect(normalizeCarveSettings({ strength: -3 }).strength).toBe(0);
+ expect(normalizeCarveSettings({ strength: Number.NaN }).strength).toBe(DEFAULT_CARVE.strength);
+ });
+
+ it("reads a carve written before the controls collapsed into one", () => {
+ // Depth is the number that said most about intent, so it maps back onto the
+ // strength that reproduces it rather than being dropped.
+ expect(normalizeCarveSettings({ maxCutDb: 6, bands: 3, q: 1.4 }).strength).toBe(0.25);
+ expect(carveProfile(normalizeCarveSettings({ maxCutDb: 6 }).strength).maxCutDb).toBe(6);
+ expect(normalizeCarveSettings({ maxCutDb: 10 }).strength).toBe(0.5);
+ expect(normalizeCarveSettings({ maxCutDb: 24 }).strength).toBe(1);
+ });
+
+ it("keeps an explicit strength over a legacy depth", () => {
+ expect(normalizeCarveSettings({ strength: 0.25, maxCutDb: 10 }).strength).toBe(0.25);
+ });
+});
+
+describe("carveProfile", () => {
+ it("lands on the old defaults at the strength the panel starts at", () => {
+ // The separate controls shipped 6 dB, 3 bands, Q 1.4, bias 0.7, and the duck
+ // pair defaulted to 6/9 — a project carved before the collapse must sound the
+ // same after it. That point is a quarter of the way up, since the knob's range
+ // was later doubled above it.
+ expect(DEFAULT_CARVE.strength).toBe(0.25);
+ expect(PROFILE).toEqual({
+ maxCutDb: 6,
+ bands: 3,
+ q: 1.4,
+ intelligibilityBias: 0.7,
+ duckDb: 6,
+ headroomDb: 9,
+ });
+ });
+
+ it("moves every number the same direction, monotonically", () => {
+ const steps = [0, 0.25, 0.5, 0.75, 1].map(carveProfile);
+ for (let i = 1; i < steps.length; i++) {
+ const a = steps[i - 1]!;
+ const b = steps[i]!;
+ expect(b.maxCutDb).toBeGreaterThan(a.maxCutDb);
+ expect(b.bands).toBeGreaterThanOrEqual(a.bands);
+ expect(b.q).toBeGreaterThan(a.q);
+ expect(b.duckDb).toBeGreaterThan(a.duckDb);
+ expect(b.headroomDb).toBeGreaterThan(a.headroomDb);
+ }
+ });
+
+ it("carves frequencies only at zero, and goes hard at the top", () => {
+ expect(carveProfile(0).duckDb).toBe(0);
+ expect(carveProfile(0).bands).toBe(1);
+ expect(carveProfile(1).maxCutDb).toBe(18);
+ expect(carveProfile(1).bands).toBe(6);
+ expect(carveProfile(1).duckDb).toBe(24);
+ // The bias is a 0..1 weight, so the top of the knob is all the way over
+ // rather than past it.
+ expect(carveProfile(1).intelligibilityBias).toBe(1);
+ });
+
+ it("puts the old top of the knob at the halfway mark", () => {
+ // The range was doubled because full strength could not sit a bed under a
+ // loud voice. Everything the knob used to reach is now the bottom half.
+ expect(carveProfile(0.5)).toEqual({
+ maxCutDb: 10,
+ bands: 4,
+ q: 1.7,
+ intelligibilityBias: 0.8,
+ duckDb: 12,
+ headroomDb: 12,
+ });
+ });
+
+ it("clamps a strength outside the knob's own range", () => {
+ expect(carveProfile(9)).toEqual(carveProfile(1));
+ expect(carveProfile(-9)).toEqual(carveProfile(0));
});
});
describe("analyseCarveBands", () => {
it("returns nothing for silence-length input", () => {
- expect(analyseCarveBands(new Float32Array(0), SR, DEFAULT_CARVE)).toEqual([]);
+ expect(analyseCarveBands(new Float32Array(0), SR, PROFILE)).toEqual([]);
});
it("returns the requested number of bands, ascending, all cuts", () => {
- const bands = analyseCarveBands(voiceLike(250), SR, { ...DEFAULT_CARVE, bands: 3 });
+ const bands = analyseCarveBands(voiceLike(250), SR, { ...PROFILE, bands: 3 });
expect(bands).toHaveLength(3);
expect(bands.map((b) => b.freq)).toEqual([...bands.map((b) => b.freq)].sort((a, b) => a - b));
for (const b of bands) expect(b.gainDb).toBeLessThan(0);
});
it("never cuts deeper than the configured maximum", () => {
- const bands = analyseCarveBands(voiceLike(400), SR, { ...DEFAULT_CARVE, maxCutDb: 5 });
+ const bands = analyseCarveBands(voiceLike(400), SR, { ...PROFILE, maxCutDb: 5 });
for (const b of bands) expect(Math.abs(b.gainDb)).toBeLessThanOrEqual(5);
});
@@ -121,7 +199,7 @@ describe("analyseCarveBands", () => {
// not where the voice is loudest. DEFAULT_CARVE is what a user gets from
// clicking "Analyse and apply", so the default has to do this — a knob that
// only works at its extreme does not work.
- const bands = analyseCarveBands(tiltedVoice(), SR, { ...DEFAULT_CARVE, bands: 3 });
+ const bands = analyseCarveBands(tiltedVoice(), SR, { ...PROFILE, bands: 3 });
expect(bands.map((b) => b.freq).some((f) => f >= 1000)).toBe(true);
});
@@ -151,7 +229,7 @@ describe("analyseCarveBands", () => {
}
return out;
})();
- const one = { ...DEFAULT_CARVE, bands: 1 };
+ const one = { ...PROFILE, bands: 1 };
expect(analyseCarveBands(twoTone, SR, { ...one, intelligibilityBias: 0 })[0]!.freq).toBe(250);
expect(analyseCarveBands(twoTone, SR, one)[0]!.freq).toBe(1600);
});
@@ -180,3 +258,203 @@ describe("carveBandsToChain", () => {
expect(carveBandsToChain([]).nodes).toEqual([]);
});
});
+
+describe("analyseCarveDuck", () => {
+ const SR8 = 8000;
+
+ /** Steady tone at a given amplitude — stands in for a bed or a voice. */
+ function tone(amp: number, seconds: number, f = 300, sr = SR8): Float32Array {
+ const out = new Float32Array(Math.floor(sr * seconds));
+ for (let i = 0; i < out.length; i++) out[i] = amp * Math.sin(2 * Math.PI * f * (i / sr));
+ return out;
+ }
+
+ /** Silence, then speech-level tone, then silence. */
+ function voiceWithPause(amp: number, seconds: number, from: number, to: number): Float32Array {
+ const out = new Float32Array(Math.floor(SR8 * seconds));
+ for (let i = 0; i < out.length; i++) {
+ const t = i / SR8;
+ out[i] = t >= from && t < to ? amp * Math.sin(2 * Math.PI * 700 * t) : 0;
+ }
+ return out;
+ }
+
+ const at = (points: { t: number; v: number }[], t: number): number =>
+ sampleAutomationLane({ target: "fx.n1.gain", points }, t);
+
+ const settings = (over: Partial = {}) => ({
+ ...PROFILE,
+ duckDb: 12,
+ headroomDb: 9,
+ ...over,
+ });
+
+ it("leaves the bed alone where the voice is silent", () => {
+ // The whole point of a follower: no voice, nothing to make room for. Before
+ // the first word it is exactly flat; afterwards it eases back rather than
+ // snapping, so the tail is checked by the release cases below.
+ const duck = analyseCarveDuck(voiceWithPause(0.5, 16, 3, 6), tone(0.5, 16), SR8, settings(), 0);
+ expect(at(duck, 1)).toBe(0);
+ // A window is half a second wide and its value is stamped at its centre, so
+ // the duck begins up to half a window before the first word — checked clear
+ // of that rather than right against it.
+ expect(at(duck, 2)).toBe(0);
+ // Back to inaudible by the end of a long tail rather than held down for the
+ // clip; the envelope's last point is pinned at exactly no cut.
+ expect(at(duck, 15)).toBeCloseTo(0, 1);
+ expect(duck.at(-1)?.v).toBe(0);
+ });
+
+ it("ducks a loud bed under a quiet voice", () => {
+ // Bed 12 dB above the voice with 9 dB of headroom asked for: the bed has to
+ // come down by about 21 dB, which the cap holds at 12.
+ const duck = analyseCarveDuck(
+ voiceWithPause(0.12, 8, 2, 7),
+ tone(0.5, 8),
+ SR8,
+ settings({ duckDb: 24 }),
+ 0,
+ );
+ expect(at(duck, 4.5)).toBeLessThan(-15);
+ });
+
+ it("barely touches a bed already sitting under the voice", () => {
+ const duck = analyseCarveDuck(voiceWithPause(0.8, 8, 2, 7), tone(0.05, 8), SR8, settings(), 0);
+ expect(at(duck, 4.5)).toBeGreaterThan(-1);
+ });
+
+ it("lets the bed come back slowly after the voice stops", () => {
+ // A bed that jumps back to full the instant a word ends is heard as the
+ // effect switching off. It has to ease back over seconds — and the envelope's
+ // own step is ~0.3s on a long voice, so a release near that lands the whole
+ // recovery inside one step, which is exactly the flip this pins against.
+ const voice = voiceWithPause(0.5, 14, 2, 6);
+ const duck = analyseCarveDuck(voice, tone(0.5, 14), SR8, settings({ duckDb: 12 }), 0);
+ const atSpeech = at(duck, 5.5);
+ expect(atSpeech).toBeLessThan(-6);
+
+ // Still most of the way down a third of a second after the last word...
+ expect(at(duck, 6.3)).toBeLessThan(atSpeech * 0.6);
+ // ...noticeably recovered by a second and a half...
+ expect(at(duck, 7.5)).toBeGreaterThan(atSpeech * 0.5);
+ // ...and within a hair of flat a few seconds later, rather than held down.
+ expect(at(duck, 11)).toBeGreaterThan(-0.6);
+ });
+
+ it("finishes the release past the last word instead of snapping at the end", () => {
+ // A lane's last point has to be no cut, or the bed stays dipped for the rest
+ // of the clip. Jumping there from full duck is the same flip the slow release
+ // exists to prevent, just moved to the end of the narration — so the envelope
+ // keeps decaying past the voice and pins once it is inaudible.
+ const voice = voiceWithPause(0.5, 8, 1, 7.9);
+ const duck = analyseCarveDuck(voice, tone(0.5, 12), SR8, settings({ duckDb: 12 }), 0);
+ const steps = duck.slice(1).map((p, i) => Math.abs(p.v - duck[i]!.v));
+ expect(Math.max(...steps.slice(-4))).toBeLessThan(2);
+ // Still ducked a moment after the voice stops, and flat well after.
+ expect(at(duck, 8.2)).toBeLessThan(-2);
+ expect(duck.at(-1)?.v).toBe(0);
+ expect(duck.at(-1)!.t).toBeGreaterThan(8);
+ });
+
+ it("comes back up more slowly than it ducks", () => {
+ // Asymmetric on purpose: the cut has to be there by the time a word is
+ // audible, and gone slowly enough that the bed does not pump between
+ // sentences.
+ const voice = voiceWithPause(0.5, 14, 4, 8);
+ const duck = analyseCarveDuck(voice, tone(0.5, 14), SR8, settings({ duckDb: 12 }), 0);
+ const floor = Math.min(...duck.map((p) => p.v));
+ const toHalfDown = duck.find((p) => p.v <= floor * 0.5)?.t ?? Infinity;
+ const backToHalfUp =
+ [...duck].reverse().find((p) => p.v <= floor * 0.5)?.t ?? Number.NEGATIVE_INFINITY;
+ const attackSpan = toHalfDown - 4;
+ const releaseSpan = backToHalfUp - 8;
+ expect(releaseSpan).toBeGreaterThan(attackSpan * 2);
+ });
+
+ it("never exceeds the configured maximum", () => {
+ const duck = analyseCarveDuck(
+ voiceWithPause(0.02, 8, 2, 7),
+ tone(0.9, 8),
+ SR8,
+ settings({ duckDb: 6 }),
+ 0,
+ );
+ expect(Math.min(...duck.map((p) => p.v))).toBeGreaterThanOrEqual(-6);
+ });
+
+ it("reads the bed at the offset between the two clips", () => {
+ // The bed starts before the voice, so voice-local 0 is bed-local `offset`.
+ // Given a bed that is loud only in its first half, a voice that starts 4s
+ // into it must see the quiet half and duck less than the same voice at 0.
+ const bed = new Float32Array(Math.floor(SR8 * 8));
+ for (let i = 0; i < bed.length; i++) {
+ const t = i / SR8;
+ bed[i] = (t < 4 ? 0.9 : 0.02) * Math.sin(2 * Math.PI * 300 * t);
+ }
+ const voice = voiceWithPause(0.3, 4, 0.5, 3.5);
+ const aligned = analyseCarveDuck(voice, bed, SR8, settings({ duckDb: 24 }), 0);
+ const shifted = analyseCarveDuck(voice, bed, SR8, settings({ duckDb: 24 }), 4);
+ expect(at(aligned, 2)).toBeLessThan(at(shifted, 2));
+ });
+
+ it("returns nothing without a voice, and stays inside the point budget", () => {
+ expect(analyseCarveDuck(new Float32Array(0), tone(0.5, 2), SR8, settings(), 0)).toEqual([]);
+ const long = analyseCarveDuck(
+ voiceWithPause(0.4, 200, 5, 195),
+ tone(0.5, 200),
+ SR8,
+ settings(),
+ 0,
+ );
+ expect(long.length).toBeLessThanOrEqual(MAX_AUTOMATION_POINTS);
+ });
+});
+
+describe("analyseCarveDynamics", () => {
+ const BAND = { freq: 1000, gainDb: -8, q: 1.4 };
+
+ /** Silence, then a 1 kHz burst, then silence again. */
+ function burst(sampleRate: number, seconds: number, from: number, to: number): Float32Array {
+ const out = new Float32Array(Math.floor(sampleRate * seconds));
+ for (let i = 0; i < out.length; i++) {
+ const t = i / sampleRate;
+ out[i] = t >= from && t < to ? 0.7 * Math.sin(2 * Math.PI * 1000 * t) : 0;
+ }
+ return out;
+ }
+
+ const at = (points: { t: number; v: number }[], t: number): number =>
+ sampleAutomationLane({ target: "fx.n1.gain", points }, t);
+
+ it("holds the filter flat where the voice is silent and cuts where it speaks", () => {
+ const [lane] = analyseCarveDynamics(burst(SR, 6, 2, 4), SR, [BAND]);
+ expect(lane!.freq).toBe(1000);
+ expect(at(lane!.points, 0.7)).toBeCloseTo(0, 1);
+ expect(at(lane!.points, 3)).toBeLessThan(-6);
+ expect(at(lane!.points, 5.3)).toBeCloseTo(0, 1);
+ });
+
+ it("pins both ends of the envelope to no cut", () => {
+ const [lane] = analyseCarveDynamics(burst(SR, 6, 2, 4), SR, [BAND]);
+ const points = lane!.points;
+ expect(points[0]).toMatchObject({ t: 0, v: 0 });
+ expect(points.at(-1)!.v).toBe(0);
+ // Held past the end, so the bed comes back up rather than staying dipped.
+ expect(points.at(-1)!.t).toBeGreaterThanOrEqual(5.9);
+ });
+
+ it("never exceeds the point budget a lane can hold", () => {
+ const long = burst(8000, 200, 10, 190);
+ const [lane] = analyseCarveDynamics(long, 8000, [BAND]);
+ expect(lane!.points.length).toBeLessThanOrEqual(MAX_AUTOMATION_POINTS);
+ expect(lane!.points.length).toBeGreaterThan(2);
+ });
+
+ it("returns one envelope per band, and nothing for no input", () => {
+ const sig = burst(SR, 3, 0.5, 2.5);
+ expect(analyseCarveDynamics(sig, SR, [BAND, { freq: 2500, gainDb: -4, q: 1.4 }])).toHaveLength(
+ 2,
+ );
+ expect(analyseCarveDynamics(new Float32Array(0), SR, [BAND])).toEqual([]);
+ });
+});
diff --git a/packages/core/src/audioCarve.ts b/packages/core/src/audioCarve.ts
index 386aadf64b..1844010518 100644
--- a/packages/core/src/audioCarve.ts
+++ b/packages/core/src/audioCarve.ts
@@ -32,44 +32,125 @@ export interface HfCarveBand {
q: number;
}
+/**
+ * What the author sets: which voice to listen to, how hard to work, and whether
+ * the work follows the voice moment to moment.
+ *
+ * One number for the strength of the effect, not six for its mechanism. The
+ * mechanism has six numbers — how deep to cut, how many bands, how wide, how far
+ * to favour intelligibility over raw energy, how far the level may drop, how far
+ * under the voice to aim — and every one of them was a control nobody could set
+ * without knowing what the analysis does with it. They move together anyway: a
+ * gentle carve is a shallow cut in few bands with little ducking, a hard one is
+ * deeper in more bands with more. `carveProfile` is that relationship, written
+ * once.
+ */
export interface HfCarveSettings {
/** Element id of the voice track to analyse. */
source: string;
+ /** How hard to carve, 0..1. */
+ strength: number;
+ /**
+ * Follow the voice rather than sitting at a fixed depth.
+ *
+ * A static carve holds its cuts for the whole clip, including every pause — the
+ * bed is thinned where there is nothing to make room for. Dynamic turns every
+ * value into an envelope of the voice's own level, so silence leaves the bed
+ * alone and a loud passage pushes the carve to full depth.
+ */
+ dynamic: boolean;
+}
+
+/** The numbers the analysis actually works in, all derived from `strength`. */
+export interface HfCarveProfile {
/** Deepest cut applied to the strongest band. */
maxCutDb: number;
/** How many bands to dip. */
bands: number;
q: number;
/**
- * Weight selection toward intelligibility rather than raw voice energy.
+ * Weight band selection toward intelligibility rather than raw voice energy.
*
* Ranking purely by voice power lands on the fundamental almost every time,
- * because that is where a voice is loudest — but masking that actually hurts
- * a voiceover happens higher up, and dipping 160 Hz mostly just thins the
- * bed. Weighting pushes selection toward 1-3 kHz where intelligibility lives.
+ * because that is where a voice is loudest — but masking that actually hurts a
+ * voiceover happens higher up, and dipping 160 Hz mostly just thins the bed.
*/
intelligibilityBias: number;
+ /** How far the bed's whole level may come down to make room, in dB. */
+ duckDb: number;
+ /** How far under the voice the bed should sit while the voice speaks, in dB. */
+ headroomDb: number;
}
export const DEFAULT_CARVE: HfCarveSettings = {
source: "",
- maxCutDb: 6,
- bands: 3,
- q: 1.4,
- intelligibilityBias: 0.7,
+ // A quarter, because the knob's range was doubled and this is the point on the
+ // new scale that produces what the panel has always defaulted to. Switching
+ // carve on sounds the same as it did; the extra range is above, not under.
+ strength: 0.25,
+ dynamic: false,
};
-export function normalizeCarveSettings(raw: Partial | undefined): HfCarveSettings {
- const clamp = (v: unknown, lo: number, hi: number, dflt: number): number => {
- const n = typeof v === "number" ? v : Number(v);
- return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : dflt;
+/**
+ * Strength as the six numbers the analysis needs.
+ *
+ * Each is a straight line from "barely there" to as far as the effect goes. The
+ * slopes are twice what they first were: the top of the knob was not strong
+ * enough to sit a bed under a loud voice, so what used to be full strength is now
+ * the halfway point and everything above it is new range. Quarter strength is
+ * therefore where the separate controls' own defaults land, which is what the
+ * panel defaults to.
+ */
+export function carveProfile(strength: number): HfCarveProfile {
+ const s = Number.isFinite(strength) ? Math.min(1, Math.max(0, strength)) : 0.5;
+ return {
+ // 2 dB is audible-but-subtle. Past about 10 the hole starts being heard as
+ // the effect rather than as room for the voice — which is a price worth
+ // paying at the top of the knob, and why the range now runs past it.
+ maxCutDb: Number((2 + s * 16).toFixed(2)),
+ // One band carves the single worst collision; six is every candidate band
+ // speech meaningfully occupies.
+ bands: Math.max(1, Math.min(6, Math.round(1 + s * 6))),
+ // Wider at low strength so a gentle carve is a tilt rather than a notch.
+ q: Number((1.1 + s * 1.2).toFixed(2)),
+ // Always weighted toward where masking hurts; at the top, entirely.
+ intelligibilityBias: Number(Math.min(1, 0.6 + s * 0.4).toFixed(2)),
+ // No level ducking at all at zero: the carve is then purely spectral.
+ duckDb: Number((s * 24).toFixed(2)),
+ // How far under the voice to aim. Deeper targets at higher strength.
+ headroomDb: Number((6 + s * 12).toFixed(2)),
};
+}
+
+/**
+ * Read carve settings from an attribute.
+ *
+ * Projects written before the collapse to one knob carry the six mechanism
+ * numbers instead; their depth is the one that says most about intent, so it maps
+ * back onto strength rather than being dropped. Everything else about such a
+ * carve is re-derived, which is the point of having one control.
+ */
+export function normalizeCarveSettings(
+ raw: Partial | undefined,
+): HfCarveSettings {
+ const num = (v: unknown): number | null => {
+ const n = typeof v === "number" ? v : Number(v);
+ return Number.isFinite(n) ? n : null;
+ };
+ const strength = num(raw?.strength);
+ const legacyDepth = num(raw?.maxCutDb);
+ const resolved =
+ strength !== null
+ ? strength
+ : legacyDepth !== null
+ ? // The inverse of the depth line in `carveProfile`, so the 6 dB default
+ // reads back as the strength that produces 6 dB.
+ (legacyDepth - 2) / 16
+ : DEFAULT_CARVE.strength;
return {
source: typeof raw?.source === "string" ? raw.source : "",
- maxCutDb: clamp(raw?.maxCutDb, 0, 24, DEFAULT_CARVE.maxCutDb),
- bands: Math.round(clamp(raw?.bands, 1, 6, DEFAULT_CARVE.bands)),
- q: clamp(raw?.q, 0.3, 8, DEFAULT_CARVE.q),
- intelligibilityBias: clamp(raw?.intelligibilityBias, 0, 1, DEFAULT_CARVE.intelligibilityBias),
+ strength: Math.min(1, Math.max(0, resolved)),
+ dynamic: raw?.dynamic === true,
};
}
@@ -206,7 +287,7 @@ function intelligibilityPenaltyDb(center: number, bias: number): number {
export function analyseCarveBands(
voice: Float32Array,
sampleRate: number,
- settings: HfCarveSettings,
+ profile: HfCarveProfile,
): HfCarveBand[] {
if (voice.length === 0) return [];
const { freqs, power } = powerSpectrum(voice, sampleRate);
@@ -222,12 +303,12 @@ export function analyseCarveBands(
scoreDb:
bandPowerAt > 0
? 10 * Math.log10(bandPowerAt) -
- intelligibilityPenaltyDb(center, settings.intelligibilityBias)
+ intelligibilityPenaltyDb(center, profile.intelligibilityBias)
: Number.NEGATIVE_INFINITY,
};
}).sort((a, b) => b.scoreDb - a.scoreDb);
- const selected = scored.slice(0, Math.max(1, settings.bands)).filter((b) => b.hasEnergy);
+ const selected = scored.slice(0, Math.max(1, profile.bands)).filter((b) => b.hasEnergy);
if (selected.length === 0) return [];
const topDb = selected[0]!.scoreDb;
@@ -237,14 +318,266 @@ export function analyseCarveBands(
// under the strongest gets half its cut — read off the dB difference.
const relative = Math.pow(10, (scoreDb - topDb) / 10);
const depth = Math.min(
- settings.maxCutDb,
- Math.max(settings.maxCutDb / 2, settings.maxCutDb * relative),
+ profile.maxCutDb,
+ Math.max(profile.maxCutDb / 2, profile.maxCutDb * relative),
);
- return { freq: center, gainDb: -Number(depth.toFixed(2)), q: settings.q };
+ return { freq: center, gainDb: -Number(depth.toFixed(2)), q: profile.q };
})
.sort((a, b) => a.freq - b.freq);
}
+/**
+ * How far below a band's own loudest moment counts as nothing to carve. A voice
+ * 30 dB down on its peak is a room tone or a breath, not speech, and the bed
+ * should be flat there.
+ */
+const DYNAMIC_RANGE_DB = 30;
+
+/**
+ * Envelope follower time constants, in seconds. Asymmetric on purpose, the way
+ * a ducker is: the cut has to be there by the time a word is audible, and has to
+ * leave slowly enough that the bed does not pump between syllables.
+ */
+const ATTACK_S = 0.05;
+const RELEASE_S = 0.25;
+
+/**
+ * Release for the level envelope, which is far slower than the filters' own.
+ *
+ * A notch closing quickly is inaudible — nothing about the bed's loudness
+ * changes. The whole bed coming back is not: at a filter's 0.25s the music
+ * jumped to full the instant a word ended, and what you hear then is the effect
+ * switching off rather than a mix breathing. Slower than any gap inside a
+ * sentence, so it rides through the pauses between words and only recovers
+ * between sentences.
+ *
+ * It also has to be well clear of the envelope's own step. A long voice is
+ * measured about every 0.3s, so a release anywhere near that puts the entire
+ * recovery inside one step — a jump, whatever the constant claims.
+ */
+const DUCK_RELEASE_S = 1.6;
+
+/**
+ * Windows an envelope may spend. Held under `MAX_AUTOMATION_POINTS` with room
+ * for the two end points, because `normalizeAutomation` truncates an over-budget
+ * lane rather than thinning it — losing the tail would leave the last cut held
+ * for the rest of the bed, which is the one failure this feature must not have.
+ */
+const POINT_BUDGET = 400;
+
+/** Below this a cut is inaudible, so it is recorded as no cut at all. */
+const SNAP_DB = 0.2;
+
+/**
+ * Smallest move worth its own breakpoint. Coarse enough that a lane stays
+ * editable by hand — a syllable-rate envelope at the analysis hop draws a point
+ * every 85 ms, which is a wall of handles nobody can grab — and fine enough that
+ * the shape is unchanged, since the segments either side interpolate.
+ */
+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;
+ /** Gain in dB over time, in seconds from the start of the *voice* clip. */
+ points: { t: number; v: number }[];
+}
+
+/**
+ * Turn each carved band into an envelope that follows the voice's level in that
+ * same band.
+ *
+ * The depth from `analyseCarveBands` becomes the envelope's ceiling rather than a
+ * constant: full depth where that band is at its loudest in the voice, flat where
+ * the voice is silent, scaled in dB between the two. Written as automation on the
+ * filters' gain, so playback and render both already know how to follow it.
+ *
+ * Times are relative to the voice clip, since that is what was measured. A caller
+ * placing these on another element shifts them by the gap between the two clips'
+ * starts. This assumes the voice plays from its own beginning at its `data-start`
+ * — a clip with a media offset would need that added.
+ */
+export function analyseCarveDynamics(
+ voice: Float32Array,
+ sampleRate: number,
+ bands: HfCarveBand[],
+): HfCarveDynamics[] {
+ if (voice.length === 0 || bands.length === 0) return [];
+
+ const window = new Float32Array(FRAME);
+ for (let i = 0; i < FRAME; i++) window[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (FRAME - 1));
+
+ // One window per emitted point, so the hop is what keeps the lane in budget.
+ const hop = Math.max(FRAME, Math.ceil(voice.length / POINT_BUDGET));
+ const bins = FRAME / 2 + 1;
+ const freqs: number[] = [];
+ for (let k = 0; k < bins; k++) freqs.push((k * sampleRate) / FRAME);
+
+ const times: number[] = [];
+ const perBand = bands.map(() => [] as number[]);
+ for (let start = 0; start < voice.length; start += hop) {
+ const re = new Float64Array(FRAME);
+ const im = new Float64Array(FRAME);
+ for (let i = 0; i < FRAME; i++) re[i] = (voice[start + i] ?? 0) * window[i]!;
+ fft(re, im);
+ const power: number[] = [];
+ for (let k = 0; k < bins; k++) power.push(re[k]! * re[k]! + im[k]! * im[k]!);
+ times.push((start + FRAME / 2) / sampleRate);
+ bands.forEach((band, b) => perBand[b]!.push(bandPower(freqs, power, band.freq)));
+ }
+
+ const duration = voice.length / sampleRate;
+ const attack = 1 - Math.exp(-(hop / sampleRate) / ATTACK_S);
+ const release = 1 - Math.exp(-(hop / sampleRate) / RELEASE_S);
+
+ return bands.map((band, b) => {
+ const powers = perBand[b]!;
+ const peak = Math.max(...powers);
+ let level = 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
+ // full envelope rather than a permanently shallow one.
+ const target =
+ p > 0 && peak > 0 ? Math.max(0, 1 + (10 * Math.log10(p / peak)) / DYNAMIC_RANGE_DB) : 0;
+ level += (target > level ? attack : release) * (target - level);
+ const scaled = band.gainDb * level;
+ const v = Math.abs(scaled) < SNAP_DB ? 0 : Number(scaled.toFixed(1));
+ raw.push({ t: Number(times[i]!.toFixed(3)), v });
+ });
+ 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 });
+ return { freq: band.freq, points };
+ });
+}
+
+/** Level in dB of one window, or -Infinity for silence. */
+function windowDb(samples: Float32Array, from: number, count: number): number {
+ let sum = 0;
+ let n = 0;
+ for (let i = from; i < from + count; i++) {
+ const s = samples[i];
+ if (s === undefined) break;
+ sum += s * s;
+ n += 1;
+ }
+ if (n === 0) return Number.NEGATIVE_INFINITY;
+ const rms = Math.sqrt(sum / n);
+ return rms > 0 ? 20 * Math.log10(rms) : Number.NEGATIVE_INFINITY;
+}
+
+/**
+ * The level envelope that keeps a bed under a voice.
+ *
+ * The spectral carve makes room in the frequency domain, which does nothing
+ * about a bed that is simply louder than the voice: the notches sit in the right
+ * places while the level goes on winning. This measures both tracks over the
+ * same windows and returns the gain the bed needs to sit `headroomDb` under the
+ * voice — no more than `duckDb`, and nothing at all where the voice is not
+ * speaking, so pauses stay open rather than being held down.
+ *
+ * Times are in seconds from the start of the *voice* clip; `offsetSeconds` is
+ * how far into the bed the voice's start falls, so the two are read at the same
+ * moment of the composition even when the clips begin at different times.
+ */
+export function analyseCarveDuck(
+ voice: Float32Array,
+ bed: Float32Array,
+ sampleRate: number,
+ profile: HfCarveProfile,
+ offsetSeconds: number,
+): { t: number; v: number }[] {
+ if (voice.length === 0 || profile.duckDb <= 0) return [];
+
+ const hop = Math.max(FRAME, Math.ceil(voice.length / POINT_BUDGET));
+ const bedOffset = Math.round(offsetSeconds * sampleRate);
+
+ const voiceDbs: number[] = [];
+ const bedDbs: number[] = [];
+ const times: number[] = [];
+ for (let start = 0; start < voice.length; start += hop) {
+ voiceDbs.push(windowDb(voice, start, FRAME));
+ bedDbs.push(windowDb(bed, start + bedOffset, FRAME));
+ times.push((start + FRAME / 2) / sampleRate);
+ }
+
+ // Speaking, rather than merely non-zero: a room tone 30 dB under the voice's
+ // own peak is not something to duck for, and gating on it is what keeps the
+ // bed up through pauses.
+ const voicePeak = Math.max(...voiceDbs);
+ const speakingFloor = voicePeak - DYNAMIC_RANGE_DB;
+
+ const attack = 1 - Math.exp(-(hop / sampleRate) / ATTACK_S);
+ const release = 1 - Math.exp(-(hop / sampleRate) / DUCK_RELEASE_S);
+
+ let level = 0;
+ const raw: { t: number; v: number }[] = [];
+ voiceDbs.forEach((vDb, i) => {
+ const bDb = bedDbs[i] ?? Number.NEGATIVE_INFINITY;
+ // How far over the line the bed is, right now.
+ const over = vDb > speakingFloor && Number.isFinite(bDb) ? bDb - (vDb - profile.headroomDb) : 0;
+ const target = Math.min(profile.duckDb, Math.max(0, over));
+ level += (target > level ? attack : release) * (target - level);
+ const v = level < SNAP_DB ? 0 : -Number(level.toFixed(1));
+ raw.push({ t: Number(times[i]!.toFixed(3)), v });
+ });
+
+ // Let the release finish past the last word. A lane's final point has to be no
+ // cut — otherwise the bed stays dipped for the rest of the clip — but jumping
+ // there from a full duck is the same flip the slow release exists to prevent,
+ // moved to the end of the narration. So the decay carries on at its own rate
+ // beyond the voice until it is inaudible, and only then pins.
+ const hopSeconds = hop / sampleRate;
+ let tailTime = times.at(-1) ?? 0;
+ while (level >= SNAP_DB) {
+ level += release * -level;
+ tailTime += hopSeconds;
+ raw.push({
+ t: Number(tailTime.toFixed(3)),
+ v: level < SNAP_DB ? 0 : -Number(level.toFixed(1)),
+ });
+ }
+
+ 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;
+ const last = points.at(-1)!;
+ const endTime = Number(Math.max(duration, last.t).toFixed(3));
+ if (!(last.v === 0 && last.t >= endTime)) points.push({ t: endTime, v: 0 });
+ return points;
+}
+
/** Carve bands as an ordinary FX chain of peaking filters. */
export function carveBandsToChain(bands: HfCarveBand[]): HfAudioFxChain {
const nodes: HfAudioFxNode[] = bands.map((b) => ({
diff --git a/packages/core/src/audioFx.ts b/packages/core/src/audioFx.ts
index 2eb2db83d4..1e38c4f298 100644
--- a/packages/core/src/audioFx.ts
+++ b/packages/core/src/audioFx.ts
@@ -136,6 +136,16 @@ const poles: HfAudioFxEnumParam = {
* a value that survives `normalizeAudioFxParams` is always safe to realise.
*/
export const HF_AUDIO_FX: readonly HfAudioFxDef[] = [
+ {
+ id: "gain",
+ label: "Gain",
+ group: "dynamics",
+ description: "Raise or lower the whole signal. Automate it to duck under something else.",
+ // Cuts go deep, boosts stay modest: this is a level stage for making room,
+ // and a chain that can add 40 dB clips long before it is useful.
+ params: [gainDb(-60, 12, 0)],
+ web: "gain-node",
+ },
{
id: "peaking",
label: "Peaking EQ",
diff --git a/packages/core/src/runtime/audioFx.test.ts b/packages/core/src/runtime/audioFx.test.ts
index 46ac76139f..9774515810 100644
--- a/packages/core/src/runtime/audioFx.test.ts
+++ b/packages/core/src/runtime/audioFx.test.ts
@@ -1,6 +1,7 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { attachElementFxChain } from "./audioFx.js";
+import { defaultAudioFxParams, HF_AUDIO_FX } from "../audioFx.js";
/**
* The DSP is proven in a real browser by the engine's render tests. What needs
@@ -187,6 +188,122 @@ describe("attachElementFxChain", () => {
expect(before.disconnected).toBe(true);
});
+ const violations: string[] = [];
+
+ /**
+ * An AudioParam with the browser's own scheduling rule: writing `.value`
+ * while a value curve covers the current time throws, because setting the
+ * property is a `setValueAtTime` at that instant and a curve may not overlap
+ * another event. Chrome words it exactly as this does.
+ */
+ class SchedParam {
+ curves: { time: number; duration: number }[] = [];
+ sets: number[] = [];
+ #value = 0;
+ constructor(private clock: { currentTime: number }) {}
+ get value(): number {
+ return this.#value;
+ }
+ set value(v: number) {
+ const t = this.clock.currentTime;
+ const covering = this.curves.find((c) => t >= c.time && t <= c.time + c.duration);
+ if (covering) {
+ // Recorded as well as thrown: the throw happens inside a
+ // MutationObserver callback, where nothing rethrows it into the test —
+ // which is exactly why this only ever showed up as an uncaught error
+ // in the preview console.
+ const message =
+ `Failed to set the 'value' property on 'AudioParam': setValueAtTime(${v}, ${t}) ` +
+ `overlaps setValueCurveAtTime(..., ${covering.time}, ${covering.duration})`;
+ violations.push(message);
+ throw new Error(message);
+ }
+ this.#value = v;
+ }
+ setValueAtTime(v: number, t: number): void {
+ this.sets.push(t);
+ this.#value = v;
+ }
+ linearRampToValueAtTime(v: number): void {
+ this.#value = v;
+ }
+ setValueCurveAtTime(_v: Float32Array, time: number, duration: number): void {
+ this.curves.push({ time, duration });
+ }
+ cancelScheduledValues(from: number): void {
+ this.curves = this.curves.filter((c) => c.time + c.duration < from);
+ }
+ cancelAndHoldAtTime(from: number): void {
+ this.cancelScheduledValues(from);
+ }
+ }
+
+ it("edits a chain whose parameters an envelope is mid-curve on", async () => {
+ // The level stage a carve adds is automated, so a chain edit lands while a
+ // curve is running. Writing the new value straight onto the param throws
+ // NotSupportedError, which surfaced in the console as the preview refusing
+ // every edit after the first.
+ const clock = { currentTime: 0 };
+ class SchedNode extends Node {
+ override frequency = new SchedParam(clock) as unknown as { value: number };
+ override Q = new SchedParam(clock) as unknown as { value: number };
+ override gain = new SchedParam(clock) as unknown as { value: number };
+ }
+ class SchedCtx extends Ctx {
+ get currentTime(): number {
+ return clock.currentTime;
+ }
+ override createGain(): Node {
+ return new SchedNode();
+ }
+ override createBiquadFilter(): Node {
+ return new SchedNode();
+ }
+ }
+ // A gain stage, because its dB-to-linear mapping is what makes the
+ // scheduler emit a value curve instead of a ramp — and a curve is what a
+ // `.value` write may not land inside.
+ const chainWith = (freq: number) => ({
+ version: 1,
+ nodes: [
+ { type: "gain", id: "n1", params: { gain: 0 } },
+ { type: "peaking", id: "n2", params: { frequency: freq, gain: -6, q: 1 } },
+ ],
+ });
+ const node = audioEl(chainWith(1000));
+ // A bent segment, so the scheduler emits a curve rather than a ramp.
+ node.setAttribute(
+ "data-automation",
+ JSON.stringify({
+ version: 1,
+ lanes: [
+ {
+ target: "fx.n1.gain",
+ points: [
+ { t: 0, v: 0 },
+ { t: 8, v: -12 },
+ ],
+ },
+ ],
+ }),
+ );
+ attachElementFxChain(
+ new SchedCtx() as unknown as BaseAudioContext,
+ node,
+ new Node() as never,
+ new Node() as never,
+ { scheduledAt: 0, elapsed: 0, rate: 1 },
+ );
+
+ // Mid-curve, which is when the edit has to survive.
+ clock.currentTime = 3;
+ node.setAttribute("data-fx-chain", JSON.stringify(chainWith(1200)));
+ await settle();
+
+ expect(violations).toEqual([]);
+ expect(node.getAttribute("data-fx-chain")).toContain("1200");
+ });
+
it("keeps playing dry when an edit leaves the chain unreadable", async () => {
const src = new Node();
const dst = new Node();
@@ -205,4 +322,370 @@ describe("attachElementFxChain", () => {
handle!.dispose();
expect((src.connections[0] as Node).disconnected).toBe(true);
});
+
+ /**
+ * A limiter, a compressor, a gate and a bitcrush are AudioWorklet processors,
+ * and a worklet node cannot be constructed before its module has registered. So
+ * a chain containing one plays dry for a moment and swaps in when registration
+ * resolves — and that path used to end there: no automation scheduled, no
+ * observer watching the attribute. Adding a compressor to a carved bed
+ * therefore killed the carve's envelopes and froze every later edit until the
+ * composition reloaded.
+ */
+ describe("a chain that needs worklets before it can be built", () => {
+ /**
+ * Constructs only once the module has registered on that context, which is
+ * what Chrome does — and the whole reason this path exists. A stub that always
+ * succeeds hides the bug: the build then works even when nothing registered.
+ */
+ class WorkletNodeStub extends Node {
+ port = { postMessage: () => {} };
+ constructor(ctx: { workletsReady?: boolean }, name: string) {
+ super("worklet");
+ if (!ctx.workletsReady) {
+ throw new Error(`AudioWorkletNode: processor "${name}" is not registered`);
+ }
+ }
+ }
+
+ /** A param that records whether an envelope was ever scheduled onto it. */
+ class RecordingParam {
+ scheduled = false;
+ value = 0;
+ setValueAtTime(): void {
+ this.scheduled = true;
+ }
+ linearRampToValueAtTime(): void {
+ this.scheduled = true;
+ }
+ setValueCurveAtTime(): void {
+ this.scheduled = true;
+ }
+ cancelScheduledValues(): void {}
+ cancelAndHoldAtTime(): void {}
+ }
+
+ /**
+ * A context whose worklet module registers when asked, and which hands out
+ * filters whose gain reports being automated.
+ */
+ class WorkletCtx extends Ctx {
+ currentTime = 0;
+ workletsReady = false;
+ audioWorklet = {
+ addModule: async () => {
+ this.workletsReady = true;
+ },
+ };
+ biquads: { gain: RecordingParam }[] = [];
+ override createBiquadFilter() {
+ const node = new Node("biquad") as unknown as Node & { gain: RecordingParam };
+ node.gain = new RecordingParam();
+ this.biquads.push(node);
+ return node as unknown as Node;
+ }
+ }
+
+ const carved = {
+ version: 1,
+ nodes: [
+ {
+ type: "peaking",
+ id: "n1",
+ fromCarve: true,
+ params: { frequency: 1000, gain: -6, q: 1.4 },
+ },
+ { type: "compressor", id: "n2", params: {} },
+ ],
+ };
+ const lane = JSON.stringify({
+ version: 1,
+ lanes: [
+ {
+ target: "fx.n1.gain",
+ points: [
+ { t: 0, v: 0 },
+ { t: 4, v: -6 },
+ ],
+ },
+ ],
+ });
+
+ const audioEl = (): HTMLElement => {
+ const node = document.createElement("audio");
+ node.setAttribute("data-fx-chain", JSON.stringify(carved));
+ node.setAttribute("data-automation", lane);
+ document.body.append(node);
+ return node;
+ };
+ const settle = async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ await new Promise((r) => setTimeout(r, 0));
+ };
+
+ it("schedules the carve's envelopes once the processors land", async () => {
+ const original = (globalThis as { AudioWorkletNode?: unknown }).AudioWorkletNode;
+ (globalThis as { AudioWorkletNode?: unknown }).AudioWorkletNode = WorkletNodeStub;
+ try {
+ const worklets = new WorkletCtx();
+ const src = new Node();
+ const dst = new Node();
+ const node = audioEl();
+ attachElementFxChain(
+ worklets as unknown as BaseAudioContext,
+ node,
+ src as never,
+ dst as never,
+ {
+ scheduledAt: 0,
+ elapsed: 0,
+ rate: 1,
+ },
+ );
+ // Dry to begin with, which is the whole reason for this path.
+ expect(src.connections).toContain(dst);
+ await settle();
+ // Now routed through the chain — and the envelope is playing, which is
+ // what "the carve still works" means.
+ expect(src.connections.at(-1)).not.toBe(dst);
+ const filter = worklets.biquads[0];
+ expect(filter, "the carve's filter was never built").toBeTruthy();
+ expect(filter!.gain.scheduled).toBe(true);
+ } finally {
+ (globalThis as { AudioWorkletNode?: unknown }).AudioWorkletNode = original;
+ }
+ });
+
+ it("keeps the carve alive when a compressor is added to a playing track", async () => {
+ // The reported case, and the worse of the two paths: adding a worklet effect
+ // to an already-attached chain rebuilt it, the build threw because nothing
+ // on that path had registered the module, and the catch wired the source
+ // straight to its gain — dropping the carve's filters along with the
+ // compressor. Both symptoms, one cause.
+ const original = (globalThis as { AudioWorkletNode?: unknown }).AudioWorkletNode;
+ (globalThis as { AudioWorkletNode?: unknown }).AudioWorkletNode = WorkletNodeStub;
+ try {
+ const worklets = new WorkletCtx();
+ const src = new Node();
+ const dst = new Node();
+ // Starts with the carve only: no worklets, so it attaches immediately.
+ const node = document.createElement("audio");
+ node.setAttribute(
+ "data-fx-chain",
+ JSON.stringify({
+ version: 1,
+ nodes: [
+ {
+ type: "peaking",
+ id: "n1",
+ fromCarve: true,
+ params: { frequency: 1000, gain: -6, q: 1.4 },
+ },
+ ],
+ }),
+ );
+ node.setAttribute("data-automation", lane);
+ document.body.append(node);
+ attachElementFxChain(
+ worklets as unknown as BaseAudioContext,
+ node,
+ src as never,
+ dst as never,
+ { scheduledAt: 0, elapsed: 0, rate: 1 },
+ );
+ expect(worklets.biquads[0]!.gain.scheduled).toBe(true);
+
+ // Now add the compressor, as the panel's "Add effect" does.
+ node.setAttribute("data-fx-chain", JSON.stringify(carved));
+ await settle();
+
+ // Still routed through a chain rather than dry, the carve's filter rebuilt,
+ // and its envelope scheduled onto the new one.
+ expect(src.connections.at(-1)).not.toBe(dst);
+ const rebuilt = worklets.biquads.at(-1)!;
+ expect(rebuilt.gain.scheduled).toBe(true);
+ } finally {
+ (globalThis as { AudioWorkletNode?: unknown }).AudioWorkletNode = original;
+ }
+ });
+
+ it("follows the attribute after the late build, as any other chain does", async () => {
+ const original = (globalThis as { AudioWorkletNode?: unknown }).AudioWorkletNode;
+ (globalThis as { AudioWorkletNode?: unknown }).AudioWorkletNode = WorkletNodeStub;
+ try {
+ const worklets = new WorkletCtx() as unknown as BaseAudioContext;
+ const src = new Node();
+ const dst = new Node();
+ const node = audioEl();
+ attachElementFxChain(worklets, node, src as never, dst as never, {
+ scheduledAt: 0,
+ elapsed: 0,
+ rate: 1,
+ });
+ await settle();
+ const before = src.connections.at(-1);
+ node.setAttribute(
+ "data-fx-chain",
+ JSON.stringify({
+ version: 1,
+ nodes: [{ type: "lowpass", id: "n9", params: { frequency: 900, q: 1, poles: "2" } }],
+ }),
+ );
+ await settle();
+ expect(src.connections.at(-1)).not.toBe(before);
+ } finally {
+ (globalThis as { AudioWorkletNode?: unknown }).AudioWorkletNode = original;
+ }
+ });
+ });
+
+ /**
+ * Every effect in the registry, one at a time.
+ *
+ * The limiter/compressor bug was not really about worklets: it was a build that
+ * threw inside `attach`, whose catch wires the source straight to its gain —
+ * dropping every other effect in the chain, the carve's filters included. Any
+ * effect whose construction can fail has the same shape, so this asserts the two
+ * things that were broken for all fourteen: the chain reaches the signal path,
+ * and an envelope on it gets scheduled.
+ */
+ describe("every registry effect, in the signal path", () => {
+ class RecordingParam {
+ scheduled = false;
+ value = 0;
+ setValueAtTime(): void {
+ this.scheduled = true;
+ }
+ linearRampToValueAtTime(): void {
+ this.scheduled = true;
+ }
+ setValueCurveAtTime(): void {
+ this.scheduled = true;
+ }
+ cancelScheduledValues(): void {}
+ cancelAndHoldAtTime(): void {}
+ }
+ class RecordingNode extends Node {
+ override frequency = new RecordingParam() as unknown as { value: number };
+ override Q = new RecordingParam() as unknown as { value: number };
+ override gain = new RecordingParam() as unknown as { value: number };
+ override delayTime = new RecordingParam() as unknown as { value: number };
+ port = { postMessage: () => {} };
+ }
+ class RichCtx extends Ctx {
+ currentTime = 0;
+ workletsReady = false;
+ made: RecordingNode[] = [];
+ audioWorklet = {
+ addModule: async () => {
+ this.workletsReady = true;
+ },
+ };
+ private make(): Node {
+ const node = new RecordingNode("node");
+ this.made.push(node);
+ return node as unknown as Node;
+ }
+ override createGain() {
+ return this.make();
+ }
+ override createBiquadFilter() {
+ return this.make();
+ }
+ override createIIRFilter() {
+ return this.make();
+ }
+ override createDelay() {
+ return this.make();
+ }
+ override createOscillator() {
+ return this.make();
+ }
+ override createWaveShaper() {
+ return this.make();
+ }
+ override createConvolver() {
+ return this.make();
+ }
+ }
+ class WorkletNode extends RecordingNode {
+ constructor(ctx: { workletsReady?: boolean }, name: string) {
+ super("worklet");
+ if (!ctx.workletsReady) {
+ throw new Error(`AudioWorkletNode: processor "${name}" is not registered`);
+ }
+ }
+ }
+ const settle = async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ await new Promise((r) => setTimeout(r, 0));
+ };
+
+ for (const def of HF_AUDIO_FX) {
+ const automatable = def.params.find((p) => p.kind === "number" && p.automatable);
+
+ it(`routes ${def.id} and plays its envelope`, async () => {
+ const original = (globalThis as { AudioWorkletNode?: unknown }).AudioWorkletNode;
+ (globalThis as { AudioWorkletNode?: unknown }).AudioWorkletNode = WorkletNode;
+ try {
+ const ctxRich = new RichCtx();
+ const src = new Node();
+ const dst = new Node();
+ const node = document.createElement("audio");
+ node.setAttribute(
+ "data-fx-chain",
+ JSON.stringify({
+ version: 1,
+ nodes: [{ type: def.id, id: "n1", params: defaultAudioFxParams(def.id) }],
+ }),
+ );
+ if (automatable && automatable.kind === "number") {
+ node.setAttribute(
+ "data-automation",
+ JSON.stringify({
+ version: 1,
+ lanes: [
+ {
+ target: `fx.n1.${automatable.key}`,
+ points: [
+ { t: 0, v: automatable.default },
+ { t: 4, v: automatable.max },
+ ],
+ },
+ ],
+ }),
+ );
+ }
+ document.body.append(node);
+ attachElementFxChain(
+ ctxRich as unknown as BaseAudioContext,
+ node,
+ src as never,
+ dst as never,
+ { scheduledAt: 0, elapsed: 0, rate: 1 },
+ );
+ await settle();
+
+ // Not dry: the effect is between the source and its gain.
+ expect(src.connections.at(-1), `${def.id} was left out of the path`).not.toBe(dst);
+ if (automatable) {
+ const scheduled = ctxRich.made.some((n) =>
+ [n.frequency, n.Q, n.gain, n.delayTime].some(
+ (p) => (p as unknown as RecordingParam).scheduled,
+ ),
+ );
+ // A worklet exposes no AudioParams, so a lane on one has nowhere to
+ // write — that is by design and the scheduler skips it.
+ if (!def.web.startsWith("worklet-")) {
+ expect(scheduled, `${def.id}'s envelope was never scheduled`).toBe(true);
+ }
+ }
+ } finally {
+ (globalThis as { AudioWorkletNode?: unknown }).AudioWorkletNode = original;
+ }
+ });
+ }
+ });
});
diff --git a/packages/core/src/runtime/audioFx.ts b/packages/core/src/runtime/audioFx.ts
index dd9b7b7dcb..835aec965c 100644
--- a/packages/core/src/runtime/audioFx.ts
+++ b/packages/core/src/runtime/audioFx.ts
@@ -19,6 +19,7 @@ import {
} from "../audioAutomation.js";
import {
cancelParamLane,
+ clearParamLane,
scheduleChainAutomation,
type AutomationTiming,
} from "../audio/audioFxAutomation.js";
@@ -103,41 +104,15 @@ export function attachElementFxChain(
): { dispose(): void } | null {
const { chain } = readChain(el);
- // An AudioWorkletNode cannot be constructed before its processor is
- // registered — it throws, and the whole chain is lost. So when the chain
- // needs worklets and the module has not landed yet, play dry and swap the
- // graph in once registration resolves.
- if (chainNeedsWorklets(chain) && !audioFxWorkletsReady(ctx)) {
- source.connect(destination);
- let cancelled = false;
- let pending: FxChainHandle | null = null;
- void ensureAudioFxWorklets(ctx)
- .then(() => {
- if (cancelled) return;
- try {
- const late = buildFxChain(ctx, chain);
- source.disconnect(destination);
- source.connect(late.input);
- late.output.connect(destination);
- pending = late;
- } catch {
- // Still unbuildable; the dry connection already stands.
- }
- })
- .catch(() => undefined);
- return {
- dispose: () => {
- cancelled = true;
- pending?.dispose();
- },
- };
- }
-
// Null means the source runs straight into its gain: an empty chain, or one
// that could not be realised. Mutable because a structural edit swaps the
// whole graph rather than re-parameterising it.
let handle: FxChainHandle | null = null;
let automated: FxParamTarget[] = [];
+ let disposed = false;
+ /** Bumped per attach, so a worklet wait that resolves late cannot revive a
+ * chain the element has since moved on from. */
+ let workletGeneration = 0;
/** Take the current graph out of the path, leaving the source connected dry. */
const detach = (): void => {
@@ -166,6 +141,28 @@ export function attachElementFxChain(
source.connect(destination);
return;
}
+ // An AudioWorkletNode cannot be constructed before its processor is
+ // registered — it throws, and the whole chain is lost. So a chain holding a
+ // limiter, compressor, gate or bitcrush plays dry until the module lands and
+ // then takes the ordinary rebuild path.
+ //
+ // That wait used to return early from the whole function, which left the
+ // track with no automation scheduled and no observer on the attribute: adding
+ // a compressor to a carved bed killed the carve's envelopes and froze every
+ // later edit until the composition reloaded. Rebuilding through the same path
+ // an edit uses is what keeps those two working.
+ if (chainNeedsWorklets(next) && !audioFxWorkletsReady(ctx)) {
+ source.connect(destination);
+ const generation = ++workletGeneration;
+ void ensureAudioFxWorklets(ctx)
+ .then(() => {
+ // Stale if the element was disposed or the chain changed while waiting.
+ if (disposed || generation !== workletGeneration) return;
+ rebuild(readChain(el).chain);
+ })
+ .catch(() => undefined);
+ return;
+ }
try {
const built = buildFxChain(ctx, next);
source.connect(built.input);
@@ -235,6 +232,13 @@ export function attachElementFxChain(
) {
observer = new MutationObserver(() => {
const next = readChain(el);
+ // Clear the booked envelopes before touching the graph. `update` writes each
+ // knob straight onto its AudioParam, and a write landing inside a running
+ // curve is refused with NotSupportedError unless the param is cancelled
+ // first — so an edit made while one was playing threw instead of applying,
+ // and the console filled with uncaught errors. The reschedule below puts the
+ // envelope back from the current playhead.
+ if (automated.length > 0) clearParamLane(automated);
// `update` reports false when the change is structural rather than a new
// set of values, which is the signal to swap the graph.
if (!handle || !handle.update(next.chain)) rebuild(next.chain);
@@ -248,6 +252,7 @@ export function attachElementFxChain(
return {
dispose: () => {
+ disposed = true;
observer?.disconnect();
if (automated.length > 0) {
cancelParamLane(automated, typeof ctx.currentTime === "number" ? ctx.currentTime : 0);
diff --git a/packages/producer/src/services/render/stages/audioStage.ts b/packages/producer/src/services/render/stages/audioStage.ts
index 89739aede6..ac243dd825 100644
--- a/packages/producer/src/services/render/stages/audioStage.ts
+++ b/packages/producer/src/services/render/stages/audioStage.ts
@@ -24,6 +24,7 @@ import {
type AudioProcessingFailure,
} from "@hyperframes/engine";
import type { CompositionMetadata } from "../shared.js";
+import type { ProducerLogger } from "../../../logger.js";
export interface AudioStageInput {
projectDir: string;
@@ -36,6 +37,8 @@ export interface AudioStageInput {
audios: CompositionMetadata["audios"];
abortSignal: AbortSignal | undefined;
assertNotAborted: () => void;
+ /** Where a per-track failure's detail goes. Optional so tests need not pass one. */
+ log?: ProducerLogger;
}
export interface AudioStageResult {
@@ -56,7 +59,7 @@ export interface AudioStageResult {
}
export async function runAudioStage(input: AudioStageInput): Promise {
- const { projectDir, workDir, compiledDir, duration, audios, abortSignal, assertNotAborted } =
+ const { projectDir, workDir, compiledDir, duration, audios, abortSignal, assertNotAborted, log } =
input;
const stage3Start = Date.now();
@@ -103,7 +106,23 @@ export async function runAudioStage(input: AudioStageInput): Promise {
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) })}
/>
(sel: T): T & { v0: number; v1: number } {
+ return { ...sel, v0: Number.NEGATIVE_INFINITY, v1: Number.POSITIVE_INFINITY };
+}
+
/** Minimal valid fixture — TimelineElement only requires these five fields. */
const bgmElement: TimelineElement = {
id: "bgm",
@@ -110,7 +121,7 @@ describe("useAutomationSelectionKeyboard", () => {
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
usePlayerStore
.getState()
- .setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 1, t1: 3 });
+ .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 1, t1: 3 }));
const { onCommit } = setup({});
key("Delete");
const written = onCommit.mock.calls.at(-1)?.[0];
@@ -121,7 +132,7 @@ describe("useAutomationSelectionKeyboard", () => {
it("Escape clears the selection", () => {
usePlayerStore
.getState()
- .setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 1, t1: 3 });
+ .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 1, t1: 3 }));
setup({});
key("Escape");
expect(usePlayerStore.getState().automationSelection).toBeNull();
@@ -130,7 +141,7 @@ describe("useAutomationSelectionKeyboard", () => {
it("is inert while a text input has focus", () => {
usePlayerStore
.getState()
- .setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 1, t1: 3 });
+ .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 1, t1: 3 }));
const { onCommit } = setup({});
const input = document.createElement("input");
document.body.append(input);
@@ -145,7 +156,7 @@ describe("useAutomationSelectionKeyboard", () => {
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
usePlayerStore
.getState()
- .setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 });
+ .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
setup({});
combo("c");
const entry = readClipboard(null);
@@ -165,7 +176,7 @@ describe("useAutomationSelectionKeyboard", () => {
});
usePlayerStore
.getState()
- .setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 });
+ .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
const { onCommit } = setup({});
combo("c");
expect(readClipboard(null)?.span).toBe(2);
@@ -183,6 +194,8 @@ describe("useAutomationSelectionKeyboard", () => {
target: "volume",
t0: 5,
t1: 7,
+ v0: VOLUME_RANGE.min,
+ v1: VOLUME_RANGE.max,
});
});
@@ -196,7 +209,7 @@ describe("useAutomationSelectionKeyboard", () => {
});
usePlayerStore
.getState()
- .setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 });
+ .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
const { onCommit } = setup({});
combo("c");
@@ -218,6 +231,8 @@ describe("useAutomationSelectionKeyboard", () => {
target: "volume",
t0: 4,
t1: 6,
+ v0: VOLUME_RANGE.min,
+ v1: VOLUME_RANGE.max,
});
});
@@ -230,7 +245,7 @@ describe("useAutomationSelectionKeyboard", () => {
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
usePlayerStore
.getState()
- .setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 });
+ .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
const { onCommit } = setup({});
combo("c");
expect(readClipboard(null)?.span).toBe(2);
@@ -238,7 +253,7 @@ describe("useAutomationSelectionKeyboard", () => {
// A 0.1s-wide selection right near the clip's 6s end.
usePlayerStore
.getState()
- .setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 5.5, t1: 5.6 });
+ .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 5.5, t1: 5.6 }));
combo("v");
const written = onCommit.mock.calls.at(-1)?.[0];
const times = (written?.lanes?.[0]?.points ?? []).map((p: { t: number }) => p.t);
@@ -252,6 +267,8 @@ describe("useAutomationSelectionKeyboard", () => {
target: "volume",
t0: 4,
t1: 6,
+ v0: VOLUME_RANGE.min,
+ v1: VOLUME_RANGE.max,
});
});
@@ -264,7 +281,7 @@ describe("useAutomationSelectionKeyboard", () => {
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
usePlayerStore
.getState()
- .setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 });
+ .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
const { onCommit } = setup({ commitTargetKey: "some-other-clip" });
const e = combo("v");
expect(e.defaultPrevented).toBe(false);
@@ -282,7 +299,7 @@ describe("useAutomationSelectionKeyboard", () => {
});
usePlayerStore
.getState()
- .setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 });
+ .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
const { onCommit } = setup({});
combo("c");
usePlayerStore.getState().clearAutomationSelection();
@@ -302,7 +319,7 @@ describe("useAutomationSelectionKeyboard", () => {
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
usePlayerStore
.getState()
- .setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 1, t1: 2 });
+ .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 1, t1: 2 }));
setup({ automation: { version: 1, lanes: [{ target: "volume", points: [] }] } });
const e = combo("c");
@@ -315,7 +332,7 @@ describe("useAutomationSelectionKeyboard", () => {
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
usePlayerStore
.getState()
- .setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 });
+ .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
const { onCommit } = setup({});
combo("C");
expect(readClipboard(null)?.span).toBe(2);
diff --git a/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts b/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts
index 354fd7342b..8dfb9704ac 100644
--- a/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts
+++ b/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts
@@ -246,8 +246,17 @@ function handlePaste(
paste.binding.onCommit(withLane(paste.binding.automation, { target: paste.target, points }));
// Select the pasted span — the only feedback that it landed — and mark it, so
// an immediate second Cmd+V recognises this selection as the paste's own and
- // chains right after it instead of overwriting it.
- const mark = { elementKey: paste.elementKey, target: paste.target, t0: atT, t1 };
+ // chains right after it instead of overwriting it. Full-height box over the
+ // pasted span: this path only ever reads t0/t1, so the box's value bounds are
+ // cosmetic — but the parameter's own range keeps it a sensible box to draw.
+ const mark = {
+ elementKey: paste.elementKey,
+ target: paste.target,
+ t0: atT,
+ t1,
+ v0: paste.range.min,
+ v1: paste.range.max,
+ };
state.setAutomationSelection(mark);
markLastPaste(mark);
return true;
diff --git a/packages/studio/src/hooks/useDomEditAttributeCommits.ts b/packages/studio/src/hooks/useDomEditAttributeCommits.ts
index d4e76c2cb8..7b7a96de93 100644
--- a/packages/studio/src/hooks/useDomEditAttributeCommits.ts
+++ b/packages/studio/src/hooks/useDomEditAttributeCommits.ts
@@ -8,6 +8,7 @@ import {
import type { PersistDomEditOperations } from "./domEditCommitTypes";
import { reportDomEditPersistFailure } from "./domEditPersistFailure";
import { bumpDomEditCommitMapVersion, runDomEditCommit } from "./domEditCommitRunner";
+import { syncStoredAutomationFromPreview } from "../player/lib/automationStoreSync";
// ── Types ──
@@ -26,6 +27,26 @@ interface DataAttributeCommitOptions {
skipRefresh: boolean;
refreshAfter?: boolean;
onSettled?: (ok: boolean) => void;
+ /**
+ * Undo grouping for a gesture that spans several commits.
+ *
+ * Without it the key is derived from the prefix, attribute and element, and the
+ * window is history's own 300ms — so a drag's moves and the release that ends it
+ * landed in different entries, and a drag slower than the window split further.
+ * A caller that knows a gesture is in progress passes one key for all of it.
+ */
+ coalesce?: { key: string; ms: number };
+ /**
+ * Apply to the preview and stop there — no file write, no history entry.
+ *
+ * What a gesture wants from every pointermove: the preview document and the
+ * audio graph following the pointer, with the file written once on release.
+ * Persisting each move put a fragment of one drag in the undo stack, and since
+ * those writes race, a follow-up's "before" was often not the previous entry's
+ * "after" — history refuses to coalesce across that gap, so undo took back a
+ * few milliseconds of the gesture and looked like it had done nothing.
+ */
+ previewOnly?: boolean;
}
function resolveFullAttrName(attr: string, prefixData: boolean | undefined): string {
@@ -108,7 +129,9 @@ export function useDomEditAttributeCommits({
if (!domEditSelection) return;
const iframe = previewIframeRef.current;
const fullAttr = resolveFullAttrName(attr, true);
- const commitKey = `${options.coalescePrefix}:${attr}:${getDomEditTargetKey(domEditSelection)}`;
+ const commitKey =
+ options.coalesce?.key ??
+ `${options.coalescePrefix}:${attr}:${getDomEditTargetKey(domEditSelection)}`;
const isLatestCommit = bumpDomEditCommitMapVersion(
domAttributeCommitVersionRef.current,
commitKey,
@@ -134,12 +157,15 @@ export function useDomEditAttributeCommits({
const nextValue = value === null || value === "" ? null : value;
setOrRemovePreviewAttribute(editedElement, fullAttr, nextValue);
},
- persist: () =>
- persistDomEditOperations(domEditSelection, [op], {
- label: options.label,
- coalesceKey: commitKey,
- skipRefresh: options.skipRefresh,
- }),
+ persist: options.previewOnly
+ ? async () => {}
+ : () =>
+ persistDomEditOperations(domEditSelection, [op], {
+ label: options.label,
+ coalesceKey: commitKey,
+ ...(options.coalesce ? { coalesceMs: options.coalesce.ms } : {}),
+ skipRefresh: options.skipRefresh,
+ }),
shouldRevert: () => isLatestCommit(),
revert: () => {
if (!editedElement) return;
@@ -147,7 +173,19 @@ export function useDomEditAttributeCommits({
},
onError: (error) => reportDomEditPersistFailure(domEditSelection, [op], error, showToast),
shouldResync: () => isLatestCommit() && !!options.refreshAfter,
- resync: () => refreshDomEditSelectionFromPreview(domEditSelection),
+ resync: () => {
+ refreshDomEditSelectionFromPreview(domEditSelection);
+ // The player store keeps its own copy of each element's attributes, and
+ // that copy is what the timeline's automation lanes draw from. Nothing
+ // else refreshes it: a commit patches the preview document and the file,
+ // and resyncs the dom-edit SELECTION for the panel. So every writer that
+ // did not also update the store by hand — the FX panel's automate and
+ // un-automate buttons, the keyboard Delete, a paste — changed the file and
+ // the audio while the lane went on drawing what it had, until a reload.
+ // One sink here rather than a sync in each writer, because three of them
+ // shipped without one.
+ syncStoredAutomationFromPreview(previewIframeRef.current?.contentDocument ?? null);
+ },
onSettled: options.onSettled,
});
},
@@ -273,12 +311,19 @@ export function useDomEditAttributeCommits({
);
const handleDomAttributeLiveCommit = useCallback(
- async (attr: string, value: string | null, onSettled?: (ok: boolean) => void) => {
+ async (
+ attr: string,
+ value: string | null,
+ onSettled?: (ok: boolean) => void,
+ live?: { coalesce?: { key: string; ms: number }; previewOnly?: boolean },
+ ) => {
await commitDataAttribute(attr, value, {
label: `Edit ${attr.replace(/^(data-)?/, "").replace(/-/g, " ")}`,
coalescePrefix: "attr-live",
skipRefresh: true,
onSettled,
+ ...(live?.coalesce ? { coalesce: live.coalesce } : {}),
+ ...(live?.previewOnly ? { previewOnly: true } : {}),
});
},
[commitDataAttribute],
@@ -294,12 +339,13 @@ export function useDomEditAttributeCommits({
* edit computes from a pre-edit value and appears to do nothing.
*/
const handleDomAttributeQuietCommit = useCallback(
- async (attr: string, value: string | null) => {
+ async (attr: string, value: string | null, coalesce?: { key: string; ms: number }) => {
await commitDataAttribute(attr, value, {
label: `Edit ${attr.replace(/^(data-)?/, "").replace(/-/g, " ")}`,
coalescePrefix: "attr-quiet",
skipRefresh: true,
refreshAfter: true,
+ ...(coalesce ? { coalesce } : {}),
});
},
[commitDataAttribute],
diff --git a/packages/studio/src/player/components/TimelineAutomationLane.test.tsx b/packages/studio/src/player/components/TimelineAutomationLane.test.tsx
index 961c5f42b6..d162ca4b0a 100644
--- a/packages/studio/src/player/components/TimelineAutomationLane.test.tsx
+++ b/packages/studio/src/player/components/TimelineAutomationLane.test.tsx
@@ -7,6 +7,7 @@ import { PAD_X } from "./automationLaneGeometry";
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
import {
+ normalizeAutomation,
resolveAutomationRange,
VOLUME_RANGE,
type HfAutomation,
@@ -89,6 +90,7 @@ function fire(
buttons?: number;
altKey?: boolean;
shiftKey?: boolean;
+ key?: string;
} = {},
): void {
const event = new Event(type, { bubbles: true, cancelable: true });
@@ -197,24 +199,65 @@ describe("TimelineAutomationLane", () => {
expect(svgWidth).toBe(400 + PAD * 2);
});
- it("shows the parameter name in full, never clamped to a narrow gutter", () => {
- // A clip starting at zero leaves no gutter; the label used to be clamped to
- // 60px there and read "Low-pass ...".
+ it("draws no plate behind the envelope", () => {
+ // The lane used to darken its clip's width, which put a box inside the row and
+ // made a stack of lanes read as tiles rather than rows of one timeline. Only the
+ // selection rectangle may fill area now, and only while a range is selected.
+ const { container } = render( );
+ expect(container.querySelectorAll("rect")).toHaveLength(0);
+ });
+
+ it("still fills the selected range, which is the one rectangle it draws", () => {
+ const { container } = render(
+ ,
+ );
+ const rects = container.querySelectorAll("rect");
+ expect(rects).toHaveLength(1);
+ expect(rects[0]?.getAttribute("data-automation-selection")).toBe("");
+ });
+
+ it("divides one lane from the next with a separator", () => {
+ // A stack of envelopes with no rule between them reads as one tall field, and a
+ // point near a boundary looks like it belongs to either lane.
+ const { container } = render( );
+ const border = container.querySelector("[data-automation-lane-border]");
+ expect(border).not.toBeNull();
+ expect(border?.style.height).toBe("1px");
+ // Full row width, not just the clip's: it is a lane divider, not part of the clip.
+ expect(border?.style.left).toBe("0px");
+ expect(border?.style.right).toBe("0px");
+ // Cannot eat a press aimed at the envelope underneath it.
+ expect(border?.className).toContain("pointer-events-none");
+ });
+
+ it("keeps the lane's own height, so the separator cannot shift the geometry", () => {
+ // Drawn as an overlay rather than a CSS border for this reason: hit testing maps
+ // pointer positions through the svg's box, and a border would move it by a pixel.
+ const { container } = render( );
+ const lane = container.querySelector(".hf-automation-lane");
+ const svg = container.querySelector("svg");
+ expect(lane?.style.height).toBe(`${AUTOMATION_LANE_H}px`);
+ expect(svg?.getAttribute("height")).toBe(String(AUTOMATION_LANE_H));
+ });
+
+ it("draws no name of its own — the label column owns it", () => {
+ // Over the envelope the name obscured the curve it described, and it scrolled
+ // with the canvas away from the row it belonged to. TimelineTrackHeader names
+ // each lane in the sticky column instead, on the keyframe rows' own tree.
const { container } = render(
,
);
- const name = container.querySelector(".hf-automation-name")!;
- expect(name.textContent).toBe("Low-pass · Cutoff");
- expect(name.className).not.toMatch(/truncate/);
- expect(name.style.maxWidth).toBe("");
+ expect(container.querySelector(".hf-automation-name")).toBeNull();
+ expect(container.textContent).not.toMatch(/Cutoff/);
});
- it("names the parameter it draws, rather than offering a control to swap it", () => {
+ it("offers no control to swap which parameter it draws", () => {
const { container } = render(
,
);
expect(container.querySelector("select")).toBeNull();
- expect(container.querySelector(".hf-automation-name")?.textContent).toMatch(/Cutoff/);
});
it("adds a point on double-click, at the value the pointer was at", () => {
@@ -395,6 +438,75 @@ describe("TimelineAutomationLane", () => {
expect(onPreview).not.toHaveBeenCalled();
});
+ it("marks the points a range has caught, by the same rule Delete uses", () => {
+ // Feedback the tinted rectangle cannot give: which POINTS are in the range. The
+ // rule has to match the delete path exactly, edges included, or the highlight
+ // promises something different from what the key does.
+ const dense: HfAutomation = {
+ version: 1,
+ lanes: [
+ {
+ target: "volume",
+ points: [
+ { t: 0, v: 1 },
+ { t: 1, v: 0.8 },
+ { t: 2, v: 0.5 },
+ { t: 3, v: 0.2 },
+ { t: 4, v: 0 },
+ ],
+ },
+ ],
+ };
+ const { container } = render(
+ ,
+ );
+ const marked = Array.from(container.querySelectorAll("[data-automation-point-in-range]"));
+ expect(marked).toHaveLength(3); // t=1 and t=3 sit ON the edges and count
+ const radii = marked.map((c) => Number(c.getAttribute("r")));
+ const plain = Array.from(container.querySelectorAll("[data-automation-point]"))
+ .filter((c) => !c.hasAttribute("data-automation-point-in-range"))
+ .map((c) => Number(c.getAttribute("r")));
+ // Bigger than the points around them, and ringed rather than recoloured — the
+ // fill is the parameter's own colour and stays that way.
+ expect(Math.min(...radii)).toBeGreaterThan(Math.max(...plain));
+ expect(marked[0]?.getAttribute("stroke")).toBe("#fff");
+ });
+
+ it("marks nothing when there is no range", () => {
+ const { container } = render( );
+ expect(container.querySelectorAll("[data-automation-point-in-range]")).toHaveLength(0);
+ });
+
+ it("draws a range on the very first drag over a read-only lane", () => {
+ // The papercut: the press that selects the clip used to be spent entirely on
+ // selecting, so the first drag over a lane did nothing visible and a range took
+ // two gestures. One gesture now selects AND draws the range — the selection is
+ // ephemeral store state, and nothing can be written through it while the lane
+ // is still read-only.
+ const onSelect = vi.fn();
+ const onRangeSelect = vi.fn();
+ const { svg, props } = mount(ramp, { readOnly: true, onSelect, onRangeSelect });
+ fire(svg, "pointerdown", at(1, 0.5));
+ fire(svg, "pointermove", at(2, 0.5));
+ fire(svg, "pointermove", at(3, 0.5));
+ fire(svg, "pointerup", at(3, 0.5));
+
+ expect(onSelect).toHaveBeenCalledTimes(1);
+ // A flat drag draws a flat box: same value at both corners, since the pointer
+ // never left that height.
+ expect(onRangeSelect).toHaveBeenLastCalledWith(
+ expect.closeTo(1, 1),
+ expect.closeTo(3, 1),
+ expect.closeTo(0.5, 1),
+ expect.closeTo(0.5, 1),
+ );
+ // Still read-only: selecting is a separate thing from editing.
+ expect(props.onCommit).not.toHaveBeenCalled();
+ expect(props.onPreview).not.toHaveBeenCalled();
+ });
+
it("selects the clip when pressed read-only, the only route to editing it", () => {
// The lane sits below the clip bar, so the timeline's own selection handler
// never sees this press. Without selecting here the lane could never be
@@ -487,6 +599,64 @@ const mount = (automation: HfAutomation, over: Record = {}) =>
return { container, svg, props };
};
+describe("TimelineAutomationLane point visibility", () => {
+ const opacityOf = (container: HTMLElement, i = 0): string => {
+ const circle = container.querySelectorAll("[data-automation-point]")[i]!;
+ return circle.style.opacity;
+ };
+ const svgOf = (container: HTMLElement): SVGSVGElement =>
+ container.querySelector(".hf-automation-svg")!;
+ // React synthesises onPointerEnter/Leave from pointerover/pointerout, so those
+ // are the events a hover has to be driven with.
+ const enter = (el: Element) => fire(el, "pointerover");
+ const leave = (el: Element) => fire(el, "pointerout");
+
+ it("keeps its breakpoints out of sight until the lane is pointed at", () => {
+ // A stack of lanes across a long clip is hundreds of discs; at rest the shape
+ // of each envelope is what matters, and the handles are only wanted by
+ // someone about to grab one.
+ const { container } = render( );
+ expect(opacityOf(container)).toBe("0");
+ // The line itself is not hidden — the envelope still reads at rest.
+ expect(container.querySelector("path")?.style.opacity).not.toBe("0");
+ });
+
+ it("shows them on hover and hides them again on leave", () => {
+ const { container } = render( );
+ enter(svgOf(container));
+ expect(opacityOf(container)).toBe("1");
+ leave(svgOf(container));
+ expect(opacityOf(container)).toBe("0");
+ });
+
+ it("keeps a selected range's points visible with the pointer away", () => {
+ // The range is the subject of a pending Delete, and which points it caught is
+ // the thing the author is checking — it cannot depend on where the mouse is.
+ const { container } = render(
+ ,
+ );
+ expect(opacityOf(container)).toBe("1");
+ });
+
+ it("keeps them visible through a drag that leaves the lane", () => {
+ // Dragging a point past the lane's edge fires pointerleave; losing the handle
+ // mid-gesture would be the worst moment for it to disappear.
+ const { container } = render( );
+ const svg = svgOf(container);
+ // happy-dom boxes are zero-sized, and the lane maps a pointer through its own
+ // box to find the point under it.
+ stubBox(svg, { left: 0, top: 0, width: 400 + PAD * 2, height: AUTOMATION_LANE_H });
+ enter(svg);
+ // The first point of `ramp` is t=0 at full value: the lane's left edge, top.
+ fire(svg, "pointerdown", { clientX: PAD, clientY: 6, buttons: 1 });
+ fire(svg, "pointermove", { clientX: PAD + 40, clientY: 30, buttons: 1 });
+ leave(svg);
+ expect(opacityOf(container)).toBe("1");
+ });
+});
+
/** `mount`, plus the re-render a real store update causes — the persisted
* automation and the new selection coming back down as props. */
const mountRerenderable = (automation: HfAutomation, over: Record = {}) => {
@@ -510,15 +680,16 @@ const mountRerenderable = (automation: HfAutomation, over: Record {
it("bends a segment when it is Alt-dragged, and leaves the points where they were", () => {
- // `curve` was honoured everywhere it is read — drawn, sampled in preview,
- // baked into the render — with no gesture that could set it.
+ // The shape is honoured everywhere it is read — drawn, sampled in preview,
+ // baked into the render — and this is the gesture that sets it. What it
+ // writes is the via point: where the pointer took the line.
const { svg, props } = mount(ramp);
fire(svg, "pointerdown", { ...at(2, 0.5), altKey: true });
fire(svg, "pointermove", { ...at(2, 0.85), altKey: true });
const previewed = props.onPreview.mock.calls.at(-1)?.[0] as HfAutomation | undefined;
const points = previewed?.lanes[0]?.points ?? [];
- expect(points[0]?.curve).toBeDefined();
- expect(points[0]?.curve).not.toBe(0);
+ expect(points[0]?.viaX).toBeCloseTo(0.5, 2);
+ expect(points[0]?.viaY).toBeCloseTo(0.15, 2); // dragged to 0.85 on a falling ramp
// The breakpoints themselves are untouched: only the shape between them moved.
expect(points.map((p) => [p.t, p.v])).toEqual([
[0, 1],
@@ -526,6 +697,82 @@ describe("TimelineAutomationLane modifiers", () => {
]);
});
+ it("bends toward the pointer's own end of the segment", () => {
+ // The complaint this model replaced: one exponent always bulged the line near
+ // the left-hand breakpoint, wherever it was grabbed. Now grabbing near either
+ // end puts the bend there, which is visible in the via point it writes.
+ const nearB = mount(ramp);
+ fire(nearB.svg, "pointerdown", { ...at(3.4, 0.5), altKey: true });
+ fire(nearB.svg, "pointermove", { ...at(3.4, 0.4), altKey: true });
+ const right = (nearB.props.onPreview.mock.calls.at(-1)?.[0] as HfAutomation | undefined)
+ ?.lanes[0]?.points[0];
+ expect(right?.viaX).toBeCloseTo(0.85, 2);
+
+ const nearA = mount(ramp);
+ fire(nearA.svg, "pointerdown", { ...at(0.6, 0.5), altKey: true });
+ fire(nearA.svg, "pointermove", { ...at(0.6, 0.6), altKey: true });
+ const left = (nearA.props.onPreview.mock.calls.at(-1)?.[0] as HfAutomation | undefined)
+ ?.lanes[0]?.points[0];
+ expect(left?.viaX).toBeCloseTo(0.15, 2);
+ });
+
+ it("deletes a point on Shift+click", () => {
+ // Right-click already removes one, but it is not a gesture a trackpad reaches
+ // for. Shift+click is the second route.
+ const { svg, props } = mount(ramp);
+ fire(svg, "pointerdown", { ...at(0, 1), shiftKey: true });
+ fire(svg, "pointerup", { ...at(0, 1), shiftKey: true });
+ const written = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation;
+ expect(written.lanes[0]?.points.map((p) => p.t)).toEqual([4]);
+ });
+
+ it("still locks the axis when Shift is held through a drag", () => {
+ // Decided on release, not on the press: acting on the press would take the
+ // Shift axis-lock away, since both gestures start identically.
+ const { svg, props } = mount(ramp);
+ fire(svg, "pointerdown", { ...at(0, 1), shiftKey: true });
+ fire(svg, "pointermove", { ...at(1.5, 0.4), shiftKey: true });
+ fire(svg, "pointerup", { ...at(1.5, 0.4), shiftKey: true });
+ const written = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation;
+ // Both points still there — a drag is a drag, however it ends.
+ expect(written.lanes[0]?.points).toHaveLength(2);
+ });
+
+ it("leaves a point alone on a plain click", () => {
+ const { svg, props } = mount(ramp);
+ fire(svg, "pointerdown", at(0, 1));
+ fire(svg, "pointerup", at(0, 1));
+ const written = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation;
+ expect(written.lanes[0]?.points).toHaveLength(2);
+ });
+
+ it("closes the value field when the lane is pressed, applying what was typed", () => {
+ // The field commits on blur, but the lane calls preventDefault on its own
+ // pointerdown to keep the timeline from scrubbing — and that suppresses the focus
+ // change the blur would have come from, so the field sat open until Enter however
+ // far away the next click landed.
+ const { svg, container, props } = mount(ramp);
+ fire(svg, "dblclick", at(0, 1));
+ const input = container.querySelector(".hf-automation-value");
+ expect(input).not.toBeNull();
+ act(() => {
+ if (!input) return;
+ // React tracks the value it set, so assigning `.value` directly is ignored;
+ // going through the native setter is what makes onChange fire.
+ const setter = Object.getOwnPropertyDescriptor(
+ window.HTMLInputElement.prototype,
+ "value",
+ )?.set;
+ setter?.call(input, "0.4");
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+
+ fire(svg, "pointerdown", at(2, 0.5));
+ expect(container.querySelector(".hf-automation-value")).toBeNull();
+ const written = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation;
+ expect(written.lanes[0]?.points[0]?.v).toBeCloseTo(0.4, 5);
+ });
+
it("straightens a segment on Alt-double-click", () => {
const curved: HfAutomation = {
version: 1,
@@ -558,6 +805,65 @@ describe("TimelineAutomationLane modifiers", () => {
expect(moved?.v).toBe(1);
});
+ /** Four points, so the middle two have a neighbour on either side. */
+ const four: HfAutomation = {
+ version: 1,
+ lanes: [
+ {
+ target: "volume",
+ points: [
+ { t: 0, v: 1 },
+ { t: 1, v: 0.8 },
+ { t: 2, v: 0.6 },
+ { t: 3.5, v: 0.2 },
+ ],
+ },
+ ],
+ };
+ const draggedTimes = (props: { onPreview: ReturnType }) => {
+ const points = props.onPreview.mock.calls.at(-1)?.[0].lanes[0].points as
+ | { t: number }[]
+ | undefined;
+ return (points ?? []).map((p) => Number(p.t.toFixed(2)));
+ };
+
+ it("stops a dragged point at its neighbour rather than letting it cross", () => {
+ // Order is the lane's contract: an envelope is a sequence, and a point that
+ // swaps places with its neighbour mid-drag pulls the shape inside out under
+ // the pointer. Reaching it is allowed — two points at one instant is a step.
+ const { svg, props } = mount(four);
+ fire(svg, "pointerdown", { ...at(1, 0.8), buttons: 1 });
+ // Aimed a whole second past the point at t=2.
+ fire(svg, "pointermove", { ...at(3, 0.8), buttons: 1, altKey: true });
+ expect(draggedTimes(props)).toEqual([0, 2, 2, 3.5]);
+ });
+
+ it("keeps the neighbour it was dragged into, through a normalising round trip", () => {
+ // The bug this pins: the lane collapses points that share a `t`, keeping the
+ // later one — so a point dragged fully onto its neighbour deleted that
+ // neighbour. Stopping a millisecond short is what keeps both, and the check has
+ // to run through normalisation because that is where the loss happened, not in
+ // the drag.
+ const { svg, props } = mount(four);
+ fire(svg, "pointerdown", { ...at(1, 0.8), buttons: 1 });
+ fire(svg, "pointermove", { ...at(3, 0.8), buttons: 1, altKey: true });
+ fire(svg, "pointerup", { ...at(3, 0.8), buttons: 0, altKey: true });
+ const committed = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation | undefined;
+ const normalized = normalizeAutomation(committed!);
+ const times = normalized.lanes[0]!.points.map((p) => p.t);
+ expect(times).toHaveLength(4);
+ // It arrived just under its neighbour rather than on it.
+ expect(times[1]).toBeLessThan(times[2]!);
+ expect(times[2]! - times[1]!).toBeCloseTo(0.001, 4);
+ });
+
+ it("stops it at the neighbour on the way back too", () => {
+ const { svg, props } = mount(four);
+ fire(svg, "pointerdown", { ...at(2, 0.6), buttons: 1 });
+ fire(svg, "pointermove", { ...at(-1, 0.6), buttons: 1, altKey: true });
+ expect(draggedTimes(props)).toEqual([0, 1, 1, 3.5]);
+ });
+
it("snaps a dragged point to the beat grid", () => {
// By eye, "on the beat" and "20 ms off the beat" look identical.
const { svg, props } = mount(ramp, { snapTimes: [2] });
@@ -632,7 +938,22 @@ describe("TimelineAutomationLane modifiers", () => {
});
});
-describe("TimelineAutomationLane range selection", () => {
+/** Two peaks with a dip between them, so a box can take the peaks alone. */
+const stacked: HfAutomation = {
+ version: 1,
+ lanes: [
+ {
+ target: "volume",
+ points: [
+ { t: 1, v: 0.9 },
+ { t: 2, v: 0.1 },
+ { t: 3, v: 0.85 },
+ ],
+ },
+ ],
+};
+
+describe("TimelineAutomationLane selection box", () => {
it("drag on the background selects a range, snapped to the grid", () => {
const onRangeSelect = vi.fn();
const { svg } = mount(ramp, { snapTimes: [1], onRangeSelect });
@@ -654,8 +975,60 @@ describe("TimelineAutomationLane range selection", () => {
expect(onRangeClear).toHaveBeenCalled();
});
+ it("reports both axes of the box a diagonal drag draws", () => {
+ // The gesture the whole feature is: a box round some points, not a span of
+ // time. Ordered on the way out, so dragging up-and-left is the same box as
+ // dragging down-and-right.
+ const onRangeSelect = vi.fn();
+ const { svg } = mount(ramp, { onRangeSelect });
+ fire(svg, "pointerdown", at(3, 0.8));
+ fire(svg, "pointermove", at(1, 0.2));
+ const last = onRangeSelect.mock.calls.at(-1);
+ expect(last?.[0]).toBeCloseTo(1, 1);
+ expect(last?.[1]).toBeCloseTo(3, 1);
+ expect(last?.[2]).toBeCloseTo(0.2, 1);
+ expect(last?.[3]).toBeCloseTo(0.8, 1);
+ });
+
+ it("a purely vertical drag still crosses the threshold", () => {
+ // The threshold is what tells a box from a click that should clear one, and it
+ // has to watch both axes: measured on time alone, a straight-down drag over a
+ // dense passage read as a click and cleared the selection instead of drawing.
+ const onRangeSelect = vi.fn();
+ const onRangeClear = vi.fn();
+ const { svg } = mount(ramp, { onRangeSelect, onRangeClear });
+ fire(svg, "pointerdown", at(2, 0.9));
+ fire(svg, "pointermove", at(2, 0.1));
+ fire(svg, "pointerup", at(2, 0.1));
+ expect(onRangeSelect).toHaveBeenCalled();
+ expect(onRangeClear).not.toHaveBeenCalled();
+ });
+
+ it("catches the points inside the box and not one at the same time outside it", () => {
+ // What a box is FOR, and the one thing a time range could never do: pick the
+ // top of an envelope out of a passage without taking the bottom with it.
+ const { container } = mount(stacked, {
+ rangeSelection: { t0: 0.5, t1: 3.5, v0: 0.6, v1: 1 },
+ });
+ const caught = Array.from(container.querySelectorAll("[data-automation-point-in-range]")).map(
+ (el) => el.getAttribute("data-automation-point"),
+ );
+ expect(caught).toEqual(["0", "2"]); // the dip at t=2, v=0.1 is below the box
+ });
+
+ it("draws the rect bounded on the value axis, not down the whole lane", () => {
+ const { container } = mount(ramp, {
+ rangeSelection: { t0: 1, t1: 3, v0: 0.25, v1: 0.75 },
+ });
+ const rect = container.querySelector("[data-automation-selection]");
+ const inner = AUTOMATION_LANE_H - 12;
+ // v1 is the upper bound, so it is the smaller y.
+ expect(Number(rect?.getAttribute("y"))).toBeCloseTo(6 + 0.25 * inner, 0);
+ expect(Number(rect?.getAttribute("height"))).toBeCloseTo(0.5 * inner, 0);
+ });
+
it("draws the selection rect between its endpoints", () => {
- const { container } = mount(ramp, { rangeSelection: { t0: 1, t1: 3 } });
+ const { container } = mount(ramp, { rangeSelection: { t0: 1, t1: 3, v0: 0, v1: 1 } });
const rect = container.querySelector("[data-automation-selection]");
expect(rect).not.toBeNull();
expect(Number(rect?.getAttribute("x"))).toBeCloseTo(PAD + 100, 0); // xOf(1) at 400px/4s
@@ -673,9 +1046,292 @@ describe("TimelineAutomationLane range selection", () => {
});
});
+describe("TimelineAutomationLane group drag", () => {
+ /** Four points; the middle two sit inside the range the tests select. */
+ const four: HfAutomation = {
+ version: 1,
+ lanes: [
+ {
+ target: "volume",
+ points: [
+ { t: 0, v: 1 },
+ { t: 1, v: 0.8 },
+ { t: 2, v: 0.6 },
+ { t: 3.5, v: 0.2 },
+ ],
+ },
+ ],
+ };
+ const pointsOf = (props: { onPreview: ReturnType }) =>
+ props.onPreview.mock.calls.at(-1)?.[0].lanes[0].points as { t: number; v: number }[];
+
+ it("moves every selected point by the same distance", () => {
+ // Dragging one of a selected set has to move the set: nudging a carve's
+ // envelope a beat later, or lifting a passage, is one gesture on a selection,
+ // not one drag per point.
+ const { svg, props } = mount(four, { rangeSelection: { t0: 0.9, t1: 2.1, v0: 0, v1: 1 } });
+ fire(svg, "pointerdown", { ...at(1, 0.8), buttons: 1 });
+ // A whole second later and a fifth of the lane's height lower, with Alt so the
+ // grid does not round the deltas out from under the assertion.
+ fire(svg, "pointermove", { ...at(2, 0.6), buttons: 1, altKey: true });
+ const points = pointsOf(props);
+ expect(points[1]!.t).toBeCloseTo(2, 2);
+ expect(points[2]!.t).toBeCloseTo(3, 2);
+ expect(points[1]!.v).toBeCloseTo(0.6, 2);
+ expect(points[2]!.v).toBeCloseTo(0.4, 2);
+ });
+
+ it("leaves points outside the selection where they are", () => {
+ const { svg, props } = mount(four, { rangeSelection: { t0: 0.9, t1: 2.1, v0: 0, v1: 1 } });
+ fire(svg, "pointerdown", { ...at(1, 0.8), buttons: 1 });
+ fire(svg, "pointermove", { ...at(1.5, 0.8), buttons: 1, altKey: true });
+ const points = pointsOf(props);
+ expect(points[0]).toEqual({ t: 0, v: 1 });
+ expect(points.at(-1)).toEqual({ t: 3.5, v: 0.2 });
+ });
+
+ it("moves only the one under the pointer when it is not in the selection", () => {
+ // The selection is elsewhere, so this is an ordinary single-point drag.
+ const { svg, props } = mount(four, { rangeSelection: { t0: 3, t1: 3.6, v0: 0, v1: 1 } });
+ fire(svg, "pointerdown", { ...at(1, 0.8), buttons: 1 });
+ fire(svg, "pointermove", { ...at(1.5, 0.5), buttons: 1, altKey: true });
+ const points = pointsOf(props);
+ expect(points[1]!.t).toBeCloseTo(1.5, 2);
+ expect(points[2]).toEqual({ t: 2, v: 0.6 });
+ expect(points[3]).toEqual({ t: 3.5, v: 0.2 });
+ });
+
+ it("stops the whole group at the lane's edge rather than piling points up", () => {
+ // Clamping each point on its own would squash the shape flat against the
+ // boundary; the group has to stop when its first member reaches it.
+ const { svg, props } = mount(four, { rangeSelection: { t0: 0.9, t1: 2.1, v0: 0, v1: 1 } });
+ fire(svg, "pointerdown", { ...at(1, 0.8), buttons: 1 });
+ fire(svg, "pointermove", { ...at(-4, 0.8), buttons: 1, altKey: true });
+ const points = pointsOf(props);
+ // The group stopped with its earliest member on zero, a second apart as it
+ // began — and the unselected point that was already at zero is still there,
+ // which is why the moved pair are the middle two after the sort.
+ expect(points.map((p) => Number(p.t.toFixed(2)))).toEqual([0, 0, 1, 3.5]);
+ });
+
+ it("clamps the group's values to the lane's own floor and ceiling", () => {
+ const { svg, props } = mount(four, { rangeSelection: { t0: 0.9, t1: 2.1, v0: 0, v1: 1 } });
+ fire(svg, "pointerdown", { ...at(1, 0.8), buttons: 1 });
+ fire(svg, "pointermove", { ...at(1, 4), buttons: 1, altKey: true });
+ const points = pointsOf(props);
+ expect(Math.max(...points.map((p) => p.v))).toBeLessThanOrEqual(1);
+ // The gap between the two moved points survives the clamp.
+ expect(points[1]!.v - points[2]!.v).toBeCloseTo(0.2, 2);
+ });
+
+ it("carries the selection along, so the same points stay selected", () => {
+ // Otherwise a second nudge is no longer a group drag, and the highlight sits
+ // where the points used to be.
+ const onRangeSelect = vi.fn();
+ const { svg } = mount(four, {
+ rangeSelection: { t0: 0.9, t1: 2.1, v0: 0, v1: 1 },
+ onRangeSelect,
+ });
+ fire(svg, "pointerdown", { ...at(1, 0.8), buttons: 1 });
+ fire(svg, "pointermove", { ...at(2, 0.8), buttons: 1, altKey: true });
+ const last = onRangeSelect.mock.calls.at(-1);
+ expect(last?.[0]).toBeCloseTo(1.9, 1);
+ expect(last?.[1]).toBeCloseTo(3.1, 1);
+ });
+
+ it("stops the group at a point it did not select", () => {
+ // A box can catch a non-contiguous set — two peaks and not the dip between
+ // them — so the constraint is per member, not just the ends of the group.
+ // Here the two peaks each have an unselected neighbour a second ahead.
+ const peaks: HfAutomation = {
+ version: 1,
+ lanes: [
+ {
+ target: "volume",
+ points: [
+ { t: 0, v: 1 },
+ { t: 1, v: 0.2 },
+ { t: 2, v: 0.9 },
+ { t: 3, v: 0.5 },
+ ],
+ },
+ ],
+ };
+ const { svg, props } = mount(peaks, {
+ rangeSelection: { t0: 0, t1: 2, v0: 0.8, v1: 1 },
+ });
+ fire(svg, "pointerdown", { ...at(0, 1), buttons: 1 });
+ // Aimed two seconds later; the dip at t=1 is a second away and stays put.
+ fire(svg, "pointermove", { ...at(2, 1), buttons: 1, altKey: true });
+ fire(svg, "pointerup", { ...at(2, 1), buttons: 0, altKey: true });
+ const points = pointsOf(props);
+ expect(points.map((p) => Number(p.t.toFixed(2)))).toEqual([1, 1, 3, 3]);
+ // And short of them, not onto them: a group drag must not consume what it runs
+ // into either, which normalisation is where it would have shown.
+ const committed = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation | undefined;
+ const times = normalizeAutomation(committed!).lanes[0]!.points.map((p) => p.t);
+ expect(times).toHaveLength(4);
+ expect(times[0]).toBeLessThan(times[1]!);
+ expect(times[2]).toBeLessThan(times[3]!);
+ });
+
+ it("carries the box's value bounds along too", () => {
+ // Both axes, or a vertical nudge slides its own points out of the box that
+ // caught them and the second nudge moves fewer of them.
+ const onRangeSelect = vi.fn();
+ const { svg } = mount(four, {
+ rangeSelection: { t0: 0.9, t1: 2.1, v0: 0.5, v1: 0.9 },
+ onRangeSelect,
+ });
+ fire(svg, "pointerdown", { ...at(1, 0.8), buttons: 1 });
+ fire(svg, "pointermove", { ...at(1, 0.6), buttons: 1, altKey: true });
+ const last = onRangeSelect.mock.calls.at(-1);
+ expect(last?.[2]).toBeCloseTo(0.3, 1);
+ expect(last?.[3]).toBeCloseTo(0.7, 1);
+ });
+});
+
+describe("TimelineAutomationLane shift axis lock", () => {
+ const two: HfAutomation = {
+ version: 1,
+ lanes: [
+ {
+ target: "volume",
+ points: [
+ { t: 1, v: 0.5 },
+ { t: 3, v: 0.5 },
+ ],
+ },
+ ],
+ };
+ const lastPoints = (props: { onPreview: ReturnType }) =>
+ props.onPreview.mock.calls.at(-1)?.[0].lanes[0].points as { t: number; v: number }[];
+
+ it("holds the value when the pointer travels mostly sideways", () => {
+ const { svg, props } = mount(two);
+ fire(svg, "pointerdown", { ...at(1, 0.5), buttons: 1 });
+ fire(svg, "pointermove", { ...at(2, 0.62), buttons: 1, shiftKey: true });
+ const moved = lastPoints(props)[0]!;
+ expect(moved.t).toBeCloseTo(2, 1);
+ expect(moved.v).toBeCloseTo(0.5, 3);
+ });
+
+ it("holds the time when the pointer travels mostly up or down", () => {
+ const { svg, props } = mount(two);
+ fire(svg, "pointerdown", { ...at(1, 0.5), buttons: 1 });
+ fire(svg, "pointermove", { ...at(1.05, 0.95), buttons: 1, shiftKey: true });
+ const moved = lastPoints(props).find((p) => p.v !== 0.5)!;
+ expect(moved.t).toBeCloseTo(1, 3);
+ expect(moved.v).toBeGreaterThan(0.5);
+ });
+
+ it("keeps the axis it locked, rather than swapping as the pointer wobbles", () => {
+ // The lock has to be decided once and held: recomputed per event, whichever
+ // axis the last move happened to favour wins, so a hand that drifts up while
+ // dragging sideways sees the point move in both — which is no lock at all.
+ const { svg, props } = mount(two);
+ fire(svg, "pointerdown", { ...at(1, 0.5), buttons: 1 });
+ fire(svg, "pointermove", { ...at(1.6, 0.52), buttons: 1, shiftKey: true });
+ // Now drift further vertically than the horizontal travel so far.
+ fire(svg, "pointermove", { ...at(1.7, 0.95), buttons: 1, shiftKey: true });
+ const moved = lastPoints(props)[0]!;
+ expect(moved.v).toBeCloseTo(0.5, 3);
+ expect(moved.t).toBeCloseTo(1.7, 1);
+ });
+
+ it("holds a value lock through sideways drift too", () => {
+ const { svg, props } = mount(two);
+ fire(svg, "pointerdown", { ...at(1, 0.5), buttons: 1 });
+ fire(svg, "pointermove", { ...at(1.02, 0.8), buttons: 1, shiftKey: true });
+ fire(svg, "pointermove", { ...at(2.5, 0.85), buttons: 1, shiftKey: true });
+ const moved = lastPoints(props).find((p) => p.v !== 0.5)!;
+ expect(moved.t).toBeCloseTo(1, 3);
+ });
+
+ it("locks an axis for a group drag too", () => {
+ const four: HfAutomation = {
+ version: 1,
+ lanes: [
+ {
+ target: "volume",
+ points: [
+ { t: 0, v: 1 },
+ { t: 1, v: 0.8 },
+ { t: 2, v: 0.6 },
+ { t: 3.5, v: 0.2 },
+ ],
+ },
+ ],
+ };
+ const { svg, props } = mount(four, { rangeSelection: { t0: 0.9, t1: 2.1, v0: 0, v1: 1 } });
+ fire(svg, "pointerdown", { ...at(1, 0.8), buttons: 1 });
+ fire(svg, "pointermove", { ...at(2, 0.9), buttons: 1, shiftKey: true });
+ const points = lastPoints(props);
+ // Sideways wins, so both selected points keep the values they started with.
+ expect(points[1]!.v).toBeCloseTo(0.8, 3);
+ expect(points[2]!.v).toBeCloseTo(0.6, 3);
+ expect(points[1]!.t).toBeCloseTo(2, 1);
+ });
+});
+
+describe("TimelineAutomationLane gesture commits", () => {
+ /**
+ * One gesture, one persisting commit — which is one undo step.
+ *
+ * Every pointermove previews; only the release writes. Persisting each move put
+ * fragments of one drag in the undo stack, and because those writes race, the
+ * chain history needs to coalesce them was broken as often as not: undo took
+ * back a few milliseconds of the gesture and read as doing nothing.
+ */
+ const four: HfAutomation = {
+ version: 1,
+ lanes: [
+ {
+ target: "volume",
+ points: [
+ { t: 0, v: 1 },
+ { t: 1, v: 0.8 },
+ { t: 2, v: 0.6 },
+ { t: 3.5, v: 0.2 },
+ ],
+ },
+ ],
+ };
+
+ it("commits once for a single-point drag, however many moves it took", () => {
+ const { svg, props } = mount(four);
+ fire(svg, "pointerdown", { ...at(1, 0.8), buttons: 1 });
+ for (const t of [1.2, 1.4, 1.6, 1.8]) fire(svg, "pointermove", { ...at(t, 0.7), buttons: 1 });
+ fire(svg, "pointerup", { ...at(1.8, 0.7), buttons: 0 });
+ expect(props.onPreview.mock.calls.length).toBeGreaterThan(1);
+ expect(props.onCommit).toHaveBeenCalledTimes(1);
+ });
+
+ it("commits once for a group drag", () => {
+ const { svg, props } = mount(four, { rangeSelection: { t0: 0.9, t1: 2.1, v0: 0, v1: 1 } });
+ fire(svg, "pointerdown", { ...at(1, 0.8), buttons: 1 });
+ for (const t of [1.2, 1.5, 1.8]) fire(svg, "pointermove", { ...at(t, 0.8), buttons: 1 });
+ fire(svg, "pointerup", { ...at(1.8, 0.8), buttons: 0 });
+ expect(props.onCommit).toHaveBeenCalledTimes(1);
+ // And what it commits is the moved set, not the shape it started from.
+ const committed = props.onCommit.mock.calls[0]![0].lanes[0].points as { t: number }[];
+ expect(committed[1]!.t).toBeGreaterThan(1);
+ });
+
+ it("commits once for a bend", () => {
+ const { svg, props } = mount(four);
+ // Alt-drag the line between two points curves that segment.
+ fire(svg, "pointerdown", { ...at(1.5, 0.7), buttons: 1, altKey: true });
+ for (const v of [0.6, 0.5, 0.4])
+ fire(svg, "pointermove", { ...at(1.5, v), buttons: 1, altKey: true });
+ fire(svg, "pointerup", { ...at(1.5, 0.4), buttons: 0, altKey: true });
+ expect(props.onCommit).toHaveBeenCalledTimes(1);
+ });
+});
+
describe("TimelineAutomationLane selection menu", () => {
it("right-click inside the selection opens the shape menu", () => {
- const { container, svg } = mount(ramp, { rangeSelection: { t0: 1, t1: 3 } });
+ const { container, svg } = mount(ramp, { rangeSelection: { t0: 1, t1: 3, v0: 0, v1: 1 } });
fire(svg, "contextmenu", at(2, 0.5));
expect(document.querySelector(".hf-automation-menu")).not.toBeNull();
// The menu portals to document.body, outside `container` — dismiss it via
@@ -690,7 +1346,7 @@ describe("TimelineAutomationLane selection menu", () => {
});
it("inserting a swell replaces the range and commits once", () => {
- const { svg, props } = mount(ramp, { rangeSelection: { t0: 1, t1: 3 } });
+ const { svg, props } = mount(ramp, { rangeSelection: { t0: 1, t1: 3, v0: 0, v1: 1 } });
fire(svg, "contextmenu", at(2, 0.5));
const swell = Array.from(
document.querySelectorAll(".hf-automation-menu button"),
@@ -704,7 +1360,7 @@ describe("TimelineAutomationLane selection menu", () => {
});
it("right-click outside the selection does not open it", () => {
- const { svg } = mount(ramp, { rangeSelection: { t0: 1, t1: 3 } });
+ const { svg } = mount(ramp, { rangeSelection: { t0: 1, t1: 3, v0: 0, v1: 1 } });
fire(svg, "contextmenu", at(3.8, 0.5));
expect(document.querySelector(".hf-automation-menu")).toBeNull();
});
@@ -744,7 +1400,7 @@ describe("TimelineAutomationLane stretch", () => {
it("dragging the right edge retimes the interior and persists on release", () => {
const onRangeSelect = vi.fn();
const { svg, props } = mount(stretchable, {
- rangeSelection: { t0: 0.5, t1: 2.5 },
+ rangeSelection: { t0: 0.5, t1: 2.5, v0: 0, v1: 1 },
onRangeSelect,
});
dragRightEdge(svg, 2.5, 3.3); // off any point, dragged out to 3.3
@@ -756,7 +1412,7 @@ describe("TimelineAutomationLane stretch", () => {
expect(points.some((p) => Math.abs(p.t - 1.2) < 0.01 && p.v === 0.5)).toBe(true);
expect(points.some((p) => Math.abs(p.t - 2.6) < 0.01 && p.v === 0.8)).toBe(true);
- expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, expect.closeTo(3.3, 1));
+ expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, expect.closeTo(3.3, 1), 0, 1);
});
it("moves the selection with the pointer instead of snapping it on release", () => {
@@ -766,14 +1422,14 @@ describe("TimelineAutomationLane stretch", () => {
// gesture. The marquee drag fires live for exactly this reason.
const onRangeSelect = vi.fn();
const { svg } = mount(stretchable, {
- rangeSelection: { t0: 0.5, t1: 2.5 },
+ rangeSelection: { t0: 0.5, t1: 2.5, v0: 0, v1: 1 },
onRangeSelect,
});
fire(svg, "pointerdown", at(2.5, 0.5));
fire(svg, "pointermove", at(3, 0.5));
- expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, expect.closeTo(3, 1));
+ expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, expect.closeTo(3, 1), 0, 1);
fire(svg, "pointermove", at(3.3, 0.5));
- expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, expect.closeTo(3.3, 1));
+ expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, expect.closeTo(3.3, 1), 0, 1);
});
it("a bare click on an edge clears the selection instead of committing a no-op", () => {
@@ -783,7 +1439,7 @@ describe("TimelineAutomationLane stretch", () => {
const onRangeSelect = vi.fn();
const onRangeClear = vi.fn();
const { svg, props } = mount(stretchable, {
- rangeSelection: { t0: 0.5, t1: 2.5 },
+ rangeSelection: { t0: 0.5, t1: 2.5, v0: 0, v1: 1 },
onRangeSelect,
onRangeClear,
});
@@ -798,7 +1454,7 @@ describe("TimelineAutomationLane stretch", () => {
it("a press that jitters under the threshold still clears rather than retiming", () => {
const onRangeClear = vi.fn();
const { svg, props } = mount(stretchable, {
- rangeSelection: { t0: 0.5, t1: 2.5 },
+ rangeSelection: { t0: 0.5, t1: 2.5, v0: 0, v1: 1 },
onRangeClear,
});
fire(svg, "pointerdown", at(2.5, 0.5));
@@ -816,7 +1472,7 @@ describe("TimelineAutomationLane stretch", () => {
// with no way back other than undo.
const onRangeSelect = vi.fn();
const { svg, props } = mount(stretchable, {
- rangeSelection: { t0: 0.5, t1: 2.5 },
+ rangeSelection: { t0: 0.5, t1: 2.5, v0: 0, v1: 1 },
onRangeSelect,
});
fire(svg, "pointerdown", at(2.5, 0.5));
@@ -829,14 +1485,14 @@ describe("TimelineAutomationLane stretch", () => {
const reverted = (props.onPreview.mock.calls.at(-1)?.[0] as HfAutomation | undefined)?.lanes[0]
?.points;
expect(reverted).toEqual(stretchable.lanes[0]?.points);
- expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, 2.5);
+ expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, 2.5, 0, 1);
});
it("gives up a stretch whose pointer capture vanished without a cancel", () => {
// A capture taken on a child that unmounts mid-drag is lost silently: no
// pointercancel, no pointerup. Every later hover kept retiming and writing.
const { svg, props } = mount(stretchable, {
- rangeSelection: { t0: 0.5, t1: 2.5 },
+ rangeSelection: { t0: 0.5, t1: 2.5, v0: 0, v1: 1 },
});
fire(svg, "pointerdown", at(2.5, 0.5));
fire(svg, "pointermove", { ...at(3.3, 0.5), buttons: 1 });
@@ -853,7 +1509,7 @@ describe("TimelineAutomationLane stretch", () => {
const onPreview = vi.fn();
const onCommit = vi.fn();
const { svg } = mount(stretchable, {
- rangeSelection: { t0: 0.5, t1: 2.5 },
+ rangeSelection: { t0: 0.5, t1: 2.5, v0: 0, v1: 1 },
onPreview,
onCommit,
});
@@ -884,20 +1540,28 @@ describe("TimelineAutomationLane stretch", () => {
],
};
- it("the selection's edge wins over a point sitting exactly on it", () => {
- // The regression this pins is the feature not being repeatable: stretching
- // pins a breakpoint on the edge it just created, so a point-first rule made
- // the SECOND stretch of that edge a point-drag every time — at the one
- // height (on the envelope) where the user naturally grabs it.
+ it("a point sitting exactly on the edge is selected content, not the edge", () => {
+ // #3207's rule was the opposite — the edge won, so a repeated stretch of the
+ // same edge (which pins a breakpoint there) always resolved to a point-drag.
+ // A box selection changes the question: with value bounds as well as time,
+ // a point on the edge is visibly INSIDE the box, and dragging selected
+ // content has to move it or the box means nothing. So the press below drags
+ // the group the box already caught — both points inside it — rather than
+ // stretching the edge; the box travels with them.
const onRangeSelect = vi.fn();
- const { svg } = mount(pointOnEdge, {
- rangeSelection: { t0: 1, t1: 2 },
+ const { svg, props } = mount(pointOnEdge, {
+ rangeSelection: { t0: 1, t1: 2, v0: 0, v1: 1 },
onRangeSelect,
});
fire(svg, "pointerdown", at(2, 0.8)); // the point at t=2, which is also the right edge
fire(svg, "pointermove", at(3, 0.8));
fire(svg, "pointerup", at(3, 0.8));
- expect(onRangeSelect).toHaveBeenLastCalledWith(1, expect.closeTo(3, 1));
+ expect(onRangeSelect).toHaveBeenLastCalledWith(2, expect.closeTo(3, 1), 0, 1);
+ const written = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation;
+ const times = (written.lanes[0]?.points ?? []).map((p) => p.t);
+ // Both points the box caught (t=1.5 and t=2) moved by the same +1 delta.
+ expect(times.some((t) => Math.abs(t - 2.5) < 0.01)).toBe(true);
+ expect(times.some((t) => Math.abs(t - 3) < 0.01)).toBe(true);
});
it("leaves that point reachable once the selection is gone", () => {
@@ -918,19 +1582,19 @@ describe("TimelineAutomationLane stretch", () => {
// right edge, the second grabbing the breakpoint the first one left there.
const onRangeSelect = vi.fn();
const { svg, props, rerender } = mountRerenderable(stretchable, {
- rangeSelection: { t0: 0.5, t1: 2 },
+ rangeSelection: { t0: 0.5, t1: 2, v0: 0, v1: 1 },
onRangeSelect,
});
dragRightEdge(svg, 2, 2.6);
- expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, expect.closeTo(2.6, 1));
+ expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, expect.closeTo(2.6, 1), 0, 1);
const afterFirst = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation;
// A breakpoint landed on the new edge, which is what used to disarm it.
expect((afterFirst.lanes[0]?.points ?? []).some((p) => Math.abs(p.t - 2.6) < 0.05)).toBe(true);
// The store comes back with the persisted envelope and the new selection.
- rerender({ automation: afterFirst, rangeSelection: { t0: 0.5, t1: 2.6 } });
+ rerender({ automation: afterFirst, rangeSelection: { t0: 0.5, t1: 2.6, v0: 0, v1: 1 } });
dragRightEdge(svg, 2.6, 3.4);
- expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, expect.closeTo(3.4, 1));
+ expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, expect.closeTo(3.4, 1), 0, 1);
expect(props.onCommit).toHaveBeenCalledTimes(2);
});
@@ -942,20 +1606,20 @@ describe("TimelineAutomationLane stretch", () => {
// the rule never comes up.
const onRangeSelect = vi.fn();
const { svg } = mount(stretchable, {
- rangeSelection: { t0: 2, t1: 2.08 }, // 8px wide at 100 px/s: both halos overlap
+ rangeSelection: { t0: 2, t1: 2.08, v0: 0, v1: 1 }, // 8px wide at 100 px/s: both halos overlap
onRangeSelect,
});
fire(svg, "pointerdown", at(2.07, 0.5)); // nearer t1, inside t0's halo too
fire(svg, "pointermove", at(3, 0.5));
fire(svg, "pointerup", at(3, 0.5));
// t0 stayed put and t1 moved out: the press resolved to the right edge.
- expect(onRangeSelect).toHaveBeenLastCalledWith(2, expect.closeTo(3, 1));
+ expect(onRangeSelect).toHaveBeenLastCalledWith(2, expect.closeTo(3, 1), 0, 1);
});
it("clamps the dragged edge so it cannot cross its partner", () => {
const onRangeSelect = vi.fn();
const { svg } = mount(stretchable, {
- rangeSelection: { t0: 0.5, t1: 2.5 },
+ rangeSelection: { t0: 0.5, t1: 2.5, v0: 0, v1: 1 },
onRangeSelect,
});
dragRightEdge(svg, 2.5, 0.3); // dragged past the left edge (t0=0.5)
@@ -966,7 +1630,7 @@ describe("TimelineAutomationLane stretch", () => {
it("clamps the dragged edge to the lane's own duration", () => {
const onRangeSelect = vi.fn();
const { svg } = mount(stretchable, {
- rangeSelection: { t0: 0.5, t1: 2.5 },
+ rangeSelection: { t0: 0.5, t1: 2.5, v0: 0, v1: 1 },
onRangeSelect,
});
dragRightEdge(svg, 2.5, 10); // far past the clip's own duration (4s)
@@ -983,7 +1647,7 @@ describe("TimelineAutomationLane stretch", () => {
// this asserts the FINAL preview is identical regardless of how many.
const onPreviewSingle = vi.fn();
const single = mount(stretchable, {
- rangeSelection: { t0: 0.5, t1: 2.5 },
+ rangeSelection: { t0: 0.5, t1: 2.5, v0: 0, v1: 1 },
onPreview: onPreviewSingle,
});
fire(single.svg, "pointerdown", at(2.5, 0.5));
@@ -994,7 +1658,7 @@ describe("TimelineAutomationLane stretch", () => {
const onPreviewMulti = vi.fn();
const multi = mount(stretchable, {
- rangeSelection: { t0: 0.5, t1: 2.5 },
+ rangeSelection: { t0: 0.5, t1: 2.5, v0: 0, v1: 1 },
onPreview: onPreviewMulti,
});
fire(multi.svg, "pointerdown", at(2.5, 0.5));
@@ -1018,7 +1682,7 @@ describe("TimelineAutomationLane stretch", () => {
it("retimes identically whether the left edge arrives in one move or several", () => {
const onPreviewSingle = vi.fn();
const single = mount(stretchable, {
- rangeSelection: { t0: 1, t1: 3 },
+ rangeSelection: { t0: 1, t1: 3, v0: 0, v1: 1 },
onPreview: onPreviewSingle,
});
fire(single.svg, "pointerdown", at(1, 0.5));
@@ -1029,7 +1693,7 @@ describe("TimelineAutomationLane stretch", () => {
const onPreviewMulti = vi.fn();
const multi = mount(stretchable, {
- rangeSelection: { t0: 1, t1: 3 },
+ rangeSelection: { t0: 1, t1: 3, v0: 0, v1: 1 },
onPreview: onPreviewMulti,
});
fire(multi.svg, "pointerdown", at(1, 0.5));
@@ -1043,13 +1707,13 @@ describe("TimelineAutomationLane stretch", () => {
});
it("shows a resize cursor when hovering an edge with nothing else live", () => {
- const { svg } = mount(ramp, { rangeSelection: { t0: 1, t1: 3 } });
+ const { svg } = mount(ramp, { rangeSelection: { t0: 1, t1: 3, v0: 0, v1: 1 } });
fire(svg, "pointermove", at(3, 0.5)); // near the right edge, nothing pressed
expect(svg.style.cursor).toBe("col-resize");
});
it("keeps the normal cursor away from the selection's edges", () => {
- const { svg } = mount(ramp, { rangeSelection: { t0: 1, t1: 3 } });
+ const { svg } = mount(ramp, { rangeSelection: { t0: 1, t1: 3, v0: 0, v1: 1 } });
fire(svg, "pointermove", at(2, 0.5)); // middle of the selection, not an edge
expect(svg.style.cursor).not.toBe("col-resize");
});
diff --git a/packages/studio/src/player/components/TimelineAutomationLane.tsx b/packages/studio/src/player/components/TimelineAutomationLane.tsx
index cdd702991c..8aa2de83ed 100644
--- a/packages/studio/src/player/components/TimelineAutomationLane.tsx
+++ b/packages/studio/src/player/components/TimelineAutomationLane.tsx
@@ -1,13 +1,17 @@
/**
* Breakpoint automation over an audio clip, edited the way a DAW edits it:
- * double-click the line to add a point, drag one to shape it, right-click a
- * point to remove it, Alt-drag the line between two points to bend it, and
- * double-click a point to type an exact value.
+ * double-click the line to add a point, drag one to shape it, right-click or
+ * Shift+click a point to remove it, Alt-drag the line between two points to bend
+ * it, and double-click a point to type an exact value.
*
* Modifiers follow Ableton's, because that is the muscle memory an automation
* lane inherits: Shift locks a drag to one axis and fines the value down, Alt
* over a segment curves it, and Alt during a point drag ignores the grid.
*
+ * Drag the background to draw a selection box around a set of breakpoints, then
+ * Delete to remove them, drag any one of them to move the whole set, or
+ * right-click inside the box for shapes over its span.
+ *
* The lane knows nothing about any particular effect. Which parameters it can
* offer, their ranges, units and whether they read logarithmically all come
* from the FX registry, so an effect gained upstream needs no change here — the
@@ -30,29 +34,66 @@ import {
type HfAutomationLane,
type HfAutomationPoint,
} from "@hyperframes/core/audio-automation";
-import {
- envelopePath,
- fromUnit,
- GRAB_PX,
- laneFor,
- PAD_X,
- toUnit,
- withLane,
-} from "./automationLaneGeometry";
+import { envelopePath, fromUnit, laneFor, PAD_X, toUnit, withLane } from "./automationLaneGeometry";
import { useAutomationLaneGestures } from "./useAutomationLaneGestures";
import { AutomationValueInput } from "./AutomationValueInput";
import { AutomationSelectionMenu } from "./AutomationSelectionMenu";
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
import { generateShape, type AutomationShapeId } from "./automationShapes";
import { simplifyPoints } from "./automationSimplify";
-import { pointsIn, replaceRange } from "./automationLaneSelection";
+import { pointInSelection, pointsIn, replaceRange } from "./automationLaneSelection";
import { getTimelineLaneTop } from "./timelineLayout";
+import { defaultTimelineTheme } from "./timelineTheme";
import type { TimelineElement } from "../store/playerStore";
import type { UseAutomationLanesResult } from "./useAutomationLanes";
-/** Pointer shape: a stretch handle wins over everything else it might also
- * sit above, a read-only lane can only be selected, a live one edited. */
+/**
+ * Drawn radius of a breakpoint.
+ *
+ * Independent of the grab radius, which is how close a pointer has to be to catch
+ * one: the two were the same number scaled, and tying them meant a dot could not be
+ * made easier to see without also changing what it caught. A touch over half the
+ * grab radius reads clearly at lane height without swallowing a dense run.
+ */
+const POINT_R = 4.9;
+
+/** Separator between stacked lanes — the same token a track row divides with. Read
+ * off the default theme rather than threaded as a prop: nothing overrides the
+ * timeline theme for lanes today, and a prop for one colour is plumbing to nowhere. */
+const LANE_BORDER = defaultTimelineTheme.rowBorder;
+
+/** Selection box on one lane. Value bounds included: a point at the right time
+ * but the wrong value is not in it. */
+type SelectionBox = { t0: number; t1: number; v0: number; v1: number };
+
+/** Is this breakpoint inside the selection box? The rule itself is shared with
+ * Delete and with the group drag, so what is drawn as caught is exactly what
+ * those act on; this only adds tolerance for there being no selection at all. */
+function pointInBox(point: HfAutomationPoint, box: SelectionBox | null | undefined): boolean {
+ return !!box && pointInSelection(point, box);
+}
+
+/** A breakpoint's drawn radius and stroke, pulled out of the render loop so the
+ * map callback stays a single JSX return — the ternaries below are what pushed
+ * it over the complexity budget when they lived inline. */
+function pointCircleStyle(
+ inRange: boolean,
+ dragging: boolean,
+): { radius: number; stroke: string; strokeWidth: number } {
+ return {
+ radius: POINT_R * (dragging ? 1.3 : inRange ? 1.15 : 1),
+ // A white ring rather than a different fill: the fill is the parameter's
+ // own colour, and a lane with two envelopes on it is read by colour first.
+ stroke: inRange ? "#fff" : "rgba(0,0,0,0.5)",
+ strokeWidth: inRange ? 1.5 : 1,
+ };
+}
+
+/** Pointer shape: a read-only lane can only be selected, a live one edited. */
function laneCursor(readOnly: boolean | undefined, dragging: boolean, stretching: boolean): string {
+ // A stretch handle wins over everything it might also sit above: the handle is
+ // a few px wide and always overlaps whatever is under the selection edge, so
+ // any other cursor there would advertise a gesture the press will not start.
if (stretching) return "col-resize";
if (readOnly) return "pointer";
return dragging ? "grabbing" : "crosshair";
@@ -85,9 +126,9 @@ export interface TimelineAutomationLaneProps {
readOnly?: boolean;
/** Called when a read-only lane is pressed: selects the clip so it goes live. */
onSelect?(): void;
- /** Active selection on THIS lane, or null. */
- rangeSelection?: { t0: number; t1: number } | null | undefined;
- onRangeSelect?: ((t0: number, t1: number) => void) | undefined;
+ /** Active selection box on THIS lane, or null. */
+ rangeSelection?: SelectionBox | null | undefined;
+ onRangeSelect?: ((t0: number, t1: number, v0: number, v1: number) => void) | undefined;
onRangeClear?: (() => void) | undefined;
}
@@ -223,6 +264,16 @@ export function TimelineAutomationLane({
/** Client-coordinate position of an open selection menu, or null when closed. */
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
+ /**
+ * Whether the pointer is over this lane, which is what decides if the
+ * breakpoints are drawn.
+ *
+ * A stack of envelopes across a long clip is hundreds of discs, and at rest the
+ * shape of each is the thing worth reading — the handles matter only to someone
+ * about to grab one. The line stays visible either way; this hides just the
+ * points.
+ */
+ const [hovered, setHovered] = useState(false);
const insertShape = useCallback(
(shape: AutomationShapeId): void => {
@@ -234,7 +285,10 @@ export function TimelineAutomationLane({
t0: rangeSelection.t0,
t1: rangeSelection.t1,
});
- commitPoints(replaceRange({ lane, range, ...rangeSelection, inner }), true);
+ commitPoints(
+ replaceRange({ lane, range, t0: rangeSelection.t0, t1: rangeSelection.t1, inner }),
+ true,
+ );
},
[rangeSelection, lane, range, commitPoints],
);
@@ -242,7 +296,10 @@ export function TimelineAutomationLane({
const simplifySelection = useCallback((): void => {
if (!rangeSelection) return;
const inner = simplifyPoints(pointsIn(lane, rangeSelection.t0, rangeSelection.t1), range);
- commitPoints(replaceRange({ lane, range, ...rangeSelection, inner }), true);
+ commitPoints(
+ replaceRange({ lane, range, t0: rangeSelection.t0, t1: rangeSelection.t1, inner }),
+ true,
+ );
}, [rangeSelection, lane, range, commitPoints]);
// A point's own right-click already stops propagation and still deletes;
@@ -251,8 +308,10 @@ export function TimelineAutomationLane({
const onSvgContextMenu = useCallback(
(e: ReactMouseEvent): void => {
if (readOnly || !rangeSelection) return;
- const { t } = pointAt(e.clientX, e.clientY);
- if (t < rangeSelection.t0 || t > rangeSelection.t1) return;
+ // Inside the drawn box, not merely inside its span: the menu's own actions
+ // read the span, but a right-click well above or below the box is a press
+ // on empty lane as far as the author can see.
+ if (!pointInSelection(pointAt(e.clientX, e.clientY), rangeSelection)) return;
e.preventDefault();
setMenuAt({ x: e.clientX, y: e.clientY });
},
@@ -270,16 +329,19 @@ export function TimelineAutomationLane({
style={{ top: topPx, left: 0, right: 0, height: h }}
data-automation-lane={target}
>
- {/* Name at the lane's top-left, like a DAW's lane header. Shown in full —
- a clip starting at zero leaves no gutter to clamp it into — and
- click-through, so it can sit over the envelope without blocking it. */}
+ {/* Separator above each lane, in the same colour a track row divides with, so
+ a stack of envelopes reads as rows rather than as one tall field. Drawn as
+ an overlay rather than a CSS border: the lane's height is fixed and its svg
+ is positioned against the same box, so a border would shift the drawing a
+ pixel off the geometry every hit test is computed from. */}
- {range.label}
-
-
+ data-automation-lane-border=""
+ className="hf-automation-lane-border pointer-events-none absolute"
+ style={{ top: 0, left: 0, right: 0, height: 1, background: LANE_BORDER, zIndex: 1 }}
+ />
+ {/* No name drawn here: the label column carries it, on the same tree
+ connector as the keyframe rows. Painted in the lane it sat on top of the
+ envelope it described and scrolled horizontally away from its own row. */}
setHovered(true)}
+ onPointerLeave={() => setHovered(false)}
onPointerDown={gestures.onPointerDown}
onPointerMove={gestures.onPointerMove}
onPointerUp={gestures.endDrag}
@@ -309,10 +373,13 @@ export function TimelineAutomationLane({
>
{readOnly
- ? "Click to select this clip, then double-click to add a point"
- : "Double-click to add a point, drag to shape, double-click a point to type a value, right-click to remove. Alt-drag the line to curve it. Shift locks an axis; Alt ignores the grid."}
+ ? "Drag a box to select points, which also selects this clip; then double-click to add a point"
+ : "Double-click to add a point, drag to shape, double-click a point to type a value, right-click or Shift+click to remove it. Drag the background to draw a box around points, then Delete to remove them or drag one to move them all. Alt-drag the line to curve it. Shift locks an axis mid-drag; Alt ignores the grid."}
-
+ {/* No plate behind the envelope: the lane used to darken its clip's width,
+ which drew a box inside the row and made a stack of lanes read as tiles
+ rather than as rows of one timeline. The row background shows through, and
+ the separator above each lane is what divides them now. */}
{/* Mid rail, so a value reads against something. */}
{rangeSelection ? (
- <>
-
- {[rangeSelection.t0, rangeSelection.t1].map((t) => (
-
- ))}
- >
+
) : null}
- {lane.points.map((p, i) => (
- {
- e.preventDefault();
- e.stopPropagation();
- removeAt(i);
- }}
- />
- ))}
+ {lane.points.map((p, i) => {
+ // Endpoint-inclusive, the same rule Delete uses, so what looks caught by
+ // the range is exactly what the range will remove. The tinted rectangle
+ // says where the selection is; this says which points it has.
+ const inRange = pointInBox(p, rangeSelection);
+ const { radius, stroke, strokeWidth } = pointCircleStyle(inRange, i === dragIndex);
+ return (
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ removeAt(i);
+ }}
+ />
+ );
+ })}
{playheadSec !== null && currentValue !== null ? (
bound.onRangeSelect(lane.target, t0, t1)}
+ onRangeSelect={(t0, t1, v0, v1) => bound.onRangeSelect(lane.target, t0, t1, v0, v1)}
onRangeClear={bound.onRangeClear}
/>
);
diff --git a/packages/studio/src/player/components/TimelineAutomationLaneSlot.test.tsx b/packages/studio/src/player/components/TimelineAutomationLaneSlot.test.tsx
index 3684a85524..d30c2390a6 100644
--- a/packages/studio/src/player/components/TimelineAutomationLaneSlot.test.tsx
+++ b/packages/studio/src/player/components/TimelineAutomationLaneSlot.test.tsx
@@ -56,14 +56,14 @@ function mountSlot(binding: Partial) {
describe("TimelineAutomationLaneSlot stale-selection guard", () => {
it("clears the selection when its lane's target no longer exists", () => {
const { onRangeClear } = mountSlot({
- selection: { elementKey: "bgm", target: "fx.gone.wet", t0: 1, t1: 2 },
+ selection: { elementKey: "bgm", target: "fx.gone.wet", t0: 1, t1: 2, v0: 0, v1: 1 },
});
expect(onRangeClear).toHaveBeenCalledTimes(1);
});
it("leaves an in-scope selection alone", () => {
const { onRangeClear } = mountSlot({
- selection: { elementKey: "bgm", target: "volume", t0: 1, t1: 2 },
+ selection: { elementKey: "bgm", target: "volume", t0: 1, t1: 2, v0: 0, v1: 1 },
});
expect(onRangeClear).not.toHaveBeenCalled();
});
diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx
index 6764b32c94..e7902160ce 100644
--- a/packages/studio/src/player/components/TimelineLanes.tsx
+++ b/packages/studio/src/player/components/TimelineLanes.tsx
@@ -196,6 +196,26 @@ export function TimelineLanes({
keyframeClipKey != null && expandedClipIds.has(keyframeClipKey);
// Link the sticky caret to the canvas lanes with a stable display-row id.
const lanesId = `${lanesIdPrefix}-track-${row}`;
+ // The header's remove buttons write through the same binding the lanes
+ // themselves edit through, so a deletion persists exactly like dragging
+ // a point does — and the binding reports read-only for an unselected
+ // clip, which is what leaves the buttons off rather than offering one
+ // that cannot act.
+ const headerLanes =
+ keyframeClip && keyframeClipKey
+ ? automationLanes.bind(
+ keyframeClip,
+ selectedElementId === keyframeClipKey || selectedElementIds.has(keyframeClipKey),
+ )
+ : null;
+ const removeAutomationLane =
+ headerLanes && !headerLanes.readOnly
+ ? (target: string) =>
+ headerLanes.onCommit({
+ version: 1,
+ lanes: headerLanes.lanes.filter((lane) => lane.target !== target),
+ })
+ : undefined;
return (
diff --git a/packages/studio/src/player/components/TimelineTrackHeader.test.tsx b/packages/studio/src/player/components/TimelineTrackHeader.test.tsx
index bd591054e6..af763b7258 100644
--- a/packages/studio/src/player/components/TimelineTrackHeader.test.tsx
+++ b/packages/studio/src/player/components/TimelineTrackHeader.test.tsx
@@ -9,7 +9,8 @@ import { TimelineTrackHeader } from "./TimelineTrackHeader";
import { defaultTimelineTheme } from "./timelineTheme";
import type { TimelineElement } from "../store/playerStore";
import type { TimelineEditCallbacks } from "./timelineCallbacks";
-import { LABEL_COL_W } from "./timelineLayout";
+import { getTimelineLaneTop, LABEL_COL_W } from "./timelineLayout";
+import { AUTOMATION_LANE_H } from "./automationLaneHeight";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -68,6 +69,7 @@ interface RenderHeaderOptions {
onSeek?: (time: number) => void;
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
onToggleTrackHidden?: TimelineEditCallbacks["onToggleTrackHidden"];
+ onRemoveAutomationLane?: (target: string) => void;
}
function renderHeader(options: RenderHeaderOptions = {}): {
@@ -100,6 +102,7 @@ function renderHeader(options: RenderHeaderOptions = {}): {
onToggleClipExpanded={vi.fn()}
onToggleTrackHidden={next.onToggleTrackHidden ?? vi.fn()}
onTogglePropertyGroupKeyframe={next.onTogglePropertyGroupKeyframe}
+ onRemoveAutomationLane={next.onRemoveAutomationLane}
onSeek={next.onSeek}
/>,
);
@@ -414,4 +417,115 @@ describe("TimelineTrackHeader", () => {
assertAligned([POSITION, OPACITY]);
act(() => view.root.unmount());
});
+
+ /**
+ * Automation lanes are named in the label column, on the same tree as the
+ * keyframe rows — not painted inside the lane, where the name sat on top of
+ * the envelope it belonged to and scrolled away from its own row.
+ */
+ describe("audio automation rows", () => {
+ const BED: TimelineElement = {
+ id: "bed",
+ label: "Music Bed",
+ tag: "audio",
+ start: 0,
+ duration: 10,
+ track: 0,
+ fxChain: JSON.stringify({
+ version: 1,
+ nodes: [{ type: "peaking", id: "n1", params: { frequency: 1600, gain: -6, q: 1.4 } }],
+ }),
+ automation: JSON.stringify({
+ version: 1,
+ lanes: [
+ {
+ target: "volume",
+ points: [
+ { t: 0, v: 1 },
+ { t: 5, v: 0.4 },
+ ],
+ },
+ {
+ target: "fx.n1.gain",
+ points: [
+ { t: 0, v: 0 },
+ { t: 5, v: -6 },
+ ],
+ },
+ ],
+ }),
+ } as TimelineElement;
+
+ it("names every envelope in the label column", () => {
+ const { host, root } = renderHeader({ keyframeClip: BED, animations: [] });
+ const rows = Array.from(host.querySelectorAll("[data-automation-lane-label]"));
+ expect(rows.map((r) => r.getAttribute("data-automation-lane-label"))).toEqual([
+ "fx.n1.gain",
+ "volume",
+ ]);
+ // A band is named by its frequency: with several of them, "Peaking EQ" says
+ // nothing about which is which. Bands sit above the level lanes.
+ // Two lines per row: what the effect is, then which knob the envelope
+ // drives. One line truncated mid-word in a column this narrow.
+ expect(rows.map((r) => r.querySelector("[data-automation-lane-name]")?.textContent)).toEqual([
+ "Peaking EQ 1.6 kHz",
+ "Volume",
+ ]);
+ expect(rows.map((r) => r.querySelector("[data-automation-lane-param]")?.textContent)).toEqual(
+ [
+ "Gain",
+ // Volume has no effect behind it, so it has no second line at all.
+ undefined,
+ ],
+ );
+ act(() => root.unmount());
+ });
+
+ it("hides them when the track is collapsed", () => {
+ const { host, root } = renderHeader({
+ keyframeClip: BED,
+ animations: [],
+ expanded: false,
+ });
+ expect(host.querySelectorAll("[data-automation-lane-label]")).toHaveLength(0);
+ act(() => root.unmount());
+ });
+
+ it("removes just that envelope from the label column", () => {
+ // The panel's automate toggle can only reach a parameter it still shows; a
+ // carve's own lanes are not in it at all, so without this an envelope could
+ // be created and never deleted.
+ const onRemoveAutomationLane = vi.fn();
+ const { host, root } = renderHeader({
+ keyframeClip: BED,
+ animations: [],
+ onRemoveAutomationLane,
+ });
+ const button = host.querySelector(
+ 'button[aria-label="Remove Peaking EQ 1.6 kHz · Gain automation"]',
+ );
+ expect(button).not.toBeNull();
+ act(() => button?.click());
+ expect(onRemoveAutomationLane).toHaveBeenCalledWith("fx.n1.gain");
+ act(() => root.unmount());
+ });
+
+ it("offers no remove button when the lanes are read-only", () => {
+ const { host, root } = renderHeader({ keyframeClip: BED, animations: [] });
+ expect(host.querySelectorAll('button[aria-label$="automation"]')).toHaveLength(0);
+ act(() => root.unmount());
+ });
+
+ it("stacks each envelope's row where its lane is drawn", () => {
+ // Same rhythm the canvas uses: automation begins below the keyframe lanes
+ // and steps by its own taller row height.
+ const { host, root } = renderHeader({ keyframeClip: BED, animations: [OPACITY] });
+ const tops = Array.from(
+ host.querySelectorAll("[data-automation-lane-label]"),
+ ).map((r) => r.style.top);
+ const base = getTimelineLaneTop(1);
+ expect(tops).toEqual([`${base}px`, `${base + AUTOMATION_LANE_H}px`]);
+ act(() => root.unmount());
+ });
+ });
});
diff --git a/packages/studio/src/player/components/TimelineTrackHeader.tsx b/packages/studio/src/player/components/TimelineTrackHeader.tsx
index 707c809c77..8241e8e1fd 100644
--- a/packages/studio/src/player/components/TimelineTrackHeader.tsx
+++ b/packages/studio/src/player/components/TimelineTrackHeader.tsx
@@ -5,6 +5,13 @@ import type { TimelineElement } from "../store/playerStore";
import type { TimelineEditCallbacks } from "./timelineCallbacks";
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
import { automationLaneCountOf } from "./useTimelineTrackLayout";
+import {
+ automationLaneLabel,
+ automationLaneLabelParts,
+ elementAutomationLanes,
+ elementFxChain,
+} from "./automationLaneData";
+import { AUTOMATION_LANE_H } from "./automationLaneHeight";
import { clipTimingStart } from "../../hooks/gsapShared";
import { LayerDisclosureRow } from "./LayerDisclosureRow";
import { TrackClipCount } from "./TrackClipCount";
@@ -19,6 +26,32 @@ import { valueReadout } from "./trackHeaderLaneValues";
import { trackDisplaySuffix } from "./timelineTrackDisplay";
import { timelineLogicalRowCellId, timelinePropertyRowId } from "./timelineNavigationIdentity";
+/** One envelope's label-column row content, resolved against the FX chain. */
+interface AutomationRowInfo {
+ target: string;
+ label: string;
+ name: string;
+ param: string;
+}
+
+/**
+ * Each envelope's name, resolved against the chain the same way the lane
+ * resolves its axis — a band is named by its frequency, not by its effect. The
+ * lane list is already in drawing order, which is the order these rows have to
+ * follow: a name beside the wrong envelope is worse than an awkward order.
+ */
+function resolveAutomationRows(keyframeClip: TimelineElement | null): AutomationRowInfo[] {
+ if (!keyframeClip) return [];
+ const chain = elementFxChain(keyframeClip);
+ return elementAutomationLanes(keyframeClip).flatMap((lane) => {
+ const parts = automationLaneLabelParts(lane.target, chain);
+ const label = automationLaneLabel(lane.target, chain);
+ return parts && label
+ ? [{ target: lane.target, label, name: parts.name, param: parts.param }]
+ : [];
+ });
+}
+
interface TimelineTrackHeaderProps {
/** The track's real key: a FRACTIONAL z-order sort value. Routes callbacks;
* never shown or announced. */
@@ -48,6 +81,9 @@ interface TimelineTrackHeaderProps {
onToggleClipExpanded: () => void;
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
+ /** Drop one envelope. Absent while the lanes are read-only, which is what
+ * hides the control rather than offering a button that cannot act. */
+ onRemoveAutomationLane?: (target: string) => void;
onSeek?: (time: number) => void;
}
@@ -292,6 +328,102 @@ function PropertyGroupHeaderRow({
);
}
+/**
+ * One envelope's row in the label column.
+ *
+ * Named here rather than inside the lane, on the same tree connector the
+ * keyframe rows use: an automation lane is a child of its clip exactly as a
+ * property group is, and drawing its name over the envelope put the label on top
+ * of the curve it describes and scrolled it away from its own row.
+ */
+function AutomationLaneHeaderRow({
+ target,
+ label,
+ name,
+ param,
+ top,
+ isLastLane,
+ gutterBackground,
+ columnWidth,
+ onRemove,
+}: {
+ target: string;
+ /** The whole thing on one line, for the tooltip and the remove button's name. */
+ label: string;
+ /** What the effect is — "Peaking EQ 1.6 kHz". */
+ name: string;
+ /** Which knob the envelope drives. Empty when there is no second line to draw. */
+ param: string;
+ top: number;
+ isLastLane: boolean;
+ gutterBackground: string;
+ columnWidth: number;
+ onRemove?: (target: string) => void;
+}) {
+ return (
+
+ {/* Tree connector, as the keyframe rows draw it: spine down the row, branch
+ tick at the name's own height. */}
+
+
+
+
+ {/* Two lines: what the effect is, then which knob the envelope drives. On
+ one line a band's own name was the first thing truncated in a column this
+ narrow — "Peaking EQ 1.6 k…" — losing exactly the part that tells two
+ bands apart. */}
+
+
+ {name}
+
+ {param ? (
+
+ {param}
+
+ ) : null}
+
+ {/* Beside the name it labels, because that is the only place an envelope is
+ named at all: a carve writes its own lanes, and the FX panel's automate
+ toggle can only reach a parameter it still lists — so without this an
+ envelope could be created and never removed. */}
+ {onRemove && (
+ event.stopPropagation()}
+ onClick={(event) => {
+ // A control in the label column owns its click; it does not also hit
+ // the track row behind it and change the selection.
+ event.stopPropagation();
+ onRemove(target);
+ }}
+ >
+ ×
+
+ )}
+
+ );
+}
+
export function TimelineTrackHeader({
trackNumber,
trackDisplayNumber,
@@ -309,6 +441,7 @@ export function TimelineTrackHeader({
onToggleClipExpanded,
onToggleTrackHidden,
onTogglePropertyGroupKeyframe,
+ onRemoveAutomationLane,
onSeek,
rovingTargetId = null,
}: TimelineTrackHeaderProps) {
@@ -327,6 +460,7 @@ export function TimelineTrackHeader({
// left an audio clip's envelopes unreachable, since the track could not expand.
const disclosable =
lanes.length > 0 || (keyframeClip ? automationLaneCountOf(keyframeClip) : 0) > 0;
+ const automationRows = resolveAutomationRows(keyframeClip);
const isKeyframeLayer = !!keyframeClip && disclosable;
return (
@@ -396,7 +530,7 @@ export function TimelineTrackHeader({
lanesId={lanesId}
lane={lane}
laneIndex={laneIndex}
- isLastLane={laneIndex === lanes.length - 1}
+ isLastLane={laneIndex === lanes.length - 1 && automationRows.length === 0}
expandedElement={keyframeClip}
currentTime={currentTime}
clipPercentage={clipPercentage}
@@ -407,6 +541,24 @@ export function TimelineTrackHeader({
rovingTargetId={rovingTargetId}
/>
))}
+ {/* Below the keyframe rows and stepping by its own height, which is how
+ TimelineAutomationLaneSlot lays the envelopes out on the canvas. The
+ two have to agree or a name labels the wrong curve. */}
+ {isExpanded &&
+ automationRows.map((row, index) => (
+
+ ))}
>
)}
diff --git a/packages/studio/src/player/components/automationGestureKeys.test.ts b/packages/studio/src/player/components/automationGestureKeys.test.ts
new file mode 100644
index 0000000000..c88cfd2bd3
--- /dev/null
+++ b/packages/studio/src/player/components/automationGestureKeys.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from "vitest";
+import { createAutomationGestureKeys } from "./automationGestureKeys";
+
+const keys = () => {
+ let n = 0;
+ return createAutomationGestureKeys(() => `g${++n}`);
+};
+
+describe("createAutomationGestureKeys", () => {
+ it("holds one key across every move of a drag", () => {
+ // Each move persists; history merges same-key entries inside 300ms. Separate
+ // keys, or a window that can expire mid-drag, is what made undo take back a
+ // fragment of the move instead of the move.
+ const g = keys();
+ expect([g.live().key, g.live().key, g.live().key]).toEqual(["g1", "g1", "g1"]);
+ });
+
+ it("never lets the window expire", () => {
+ expect(keys().live().ms).toBe(Number.POSITIVE_INFINITY);
+ expect(keys().commit().ms).toBe(Number.POSITIVE_INFINITY);
+ });
+
+ it("ends the gesture on the release, under the same key", () => {
+ const g = keys();
+ g.live();
+ expect(g.commit().key).toBe("g1");
+ });
+
+ it("starts a fresh key for the next drag", () => {
+ const g = keys();
+ g.live();
+ g.commit();
+ expect(g.live().key).toBe("g2");
+ });
+
+ it("gives a standalone commit its own key", () => {
+ // Deleting a range, pasting one, typing a value: one write, one step each.
+ const g = keys();
+ expect(g.commit().key).toBe("g1");
+ expect(g.commit().key).toBe("g2");
+ });
+
+ it("does not join a commit to a gesture that already ended", () => {
+ const g = keys();
+ g.live();
+ g.commit();
+ expect(g.commit().key).toBe("g2");
+ });
+});
diff --git a/packages/studio/src/player/components/automationGestureKeys.ts b/packages/studio/src/player/components/automationGestureKeys.ts
new file mode 100644
index 0000000000..387ff52096
--- /dev/null
+++ b/packages/studio/src/player/components/automationGestureKeys.ts
@@ -0,0 +1,49 @@
+/**
+ * Undo grouping for a lane gesture.
+ *
+ * Dragging a point persists on every move and once more on release. History
+ * coalesces entries that share a key and land within 300ms of each other, and
+ * these writes did neither: the moves and the release used different keys, and a
+ * drag slower than the window split into several entries. Undo then took back
+ * whatever fragment happened to be last, leaving the point near where the drag
+ * had dropped it — which reads as undo not working at all.
+ *
+ * So a gesture mints one key and holds it: every move and the release that ends
+ * it record under that key with no window to expire, and the next gesture gets a
+ * fresh one so two drags never collapse into a single step.
+ */
+
+export interface CoalesceHint {
+ key: string;
+ /** No expiry: a gesture is one step however long the pointer is held. */
+ ms: number;
+}
+
+export interface AutomationGestureKeys {
+ /** A continuous write. Opens a gesture if one is not already open. */
+ live(): CoalesceHint;
+ /** The persisting write that ends a gesture — or a standalone edit. */
+ commit(): CoalesceHint;
+}
+
+export function createAutomationGestureKeys(
+ mint: () => string = () => `automation-gesture:${++counter}`,
+): AutomationGestureKeys {
+ let open: string | null = null;
+ const hint = (key: string): CoalesceHint => ({ key, ms: Number.POSITIVE_INFINITY });
+ return {
+ live: () => {
+ open ??= mint();
+ return hint(open);
+ },
+ commit: () => {
+ // A commit with no drag before it — a Delete, a paste, a typed value — is
+ // its own step, so it mints rather than joining anything.
+ const key = open ?? mint();
+ open = null;
+ return hint(key);
+ },
+ };
+}
+
+let counter = 0;
diff --git a/packages/studio/src/player/components/automationLaneData.test.ts b/packages/studio/src/player/components/automationLaneData.test.ts
index 948c926167..69b486b871 100644
--- a/packages/studio/src/player/components/automationLaneData.test.ts
+++ b/packages/studio/src/player/components/automationLaneData.test.ts
@@ -1,6 +1,11 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
-import { elementAutomation, elementFxChain } from "./automationLaneData";
+import {
+ automationLaneLabel,
+ automationLaneLabelParts,
+ elementAutomation,
+ elementFxChain,
+} from "./automationLaneData";
import type { TimelineElement } from "../store/timelineElement";
const el = (over: Partial = {}): TimelineElement => ({
@@ -13,6 +18,9 @@ const el = (over: Partial = {}): TimelineElement => ({
...over,
});
+/** The chain as the lane code sees it: parsed, not the attribute text. */
+const parseChain = (chain: unknown) => elementFxChain(el({ fxChain: JSON.stringify(chain) }))!;
+
const CHAIN = JSON.stringify({
version: 1,
nodes: [{ type: "lowpass", id: "n1", params: { frequency: 400, q: 0.9, poles: "2" } }],
@@ -63,4 +71,91 @@ describe("automationLaneData", () => {
expect(elementAutomation(el({ automation: "{nope" })).lanes).toEqual([]);
expect(elementFxChain(el({ fxChain: "{nope" }))).toBeNull();
});
+
+ describe("lane order", () => {
+ // A stack of EQ bands reads as a spectrum, so it has to be laid out like one:
+ // the top of the stack is the top of the audible range. Attribute order is
+ // whatever the carve happened to mint, which is the opposite — bands come out
+ // ascending.
+ const bandChain = JSON.stringify({
+ version: 1,
+ nodes: [
+ { type: "peaking", id: "n1", params: { frequency: 400, gain: -6, q: 1.4 } },
+ { type: "peaking", id: "n2", params: { frequency: 1600, gain: -9, q: 1.4 } },
+ { type: "peaking", id: "n3", params: { frequency: 1000, gain: -3, q: 1.4 } },
+ { type: "gain", id: "n4", params: { gain: -6 } },
+ ],
+ });
+ const bandLanes = JSON.stringify({
+ version: 1,
+ lanes: [
+ { target: "volume", points: [{ t: 0, v: 1 }] },
+ { target: "fx.n1.gain", points: [{ t: 0, v: -6 }] },
+ { target: "fx.n2.gain", points: [{ t: 0, v: -9 }] },
+ { target: "fx.n3.gain", points: [{ t: 0, v: -3 }] },
+ { target: "fx.n4.gain", points: [{ t: 0, v: -6 }] },
+ ],
+ });
+
+ it("puts the highest frequency at the top", () => {
+ const lanes = elementAutomation(el({ automation: bandLanes, fxChain: bandChain })).lanes;
+ expect(lanes.map((l) => l.target)).toEqual([
+ "fx.n2.gain", // 1.6 kHz
+ "fx.n3.gain", // 1 kHz
+ "fx.n1.gain", // 400 Hz
+ // Neither of these is a band, so they sit under the spectrum in the order
+ // they were written.
+ "volume",
+ "fx.n4.gain",
+ ]);
+ });
+
+ it("keeps the same object identity, so a drag survives the sort", () => {
+ const a = elementAutomation(el({ automation: bandLanes, fxChain: bandChain }));
+ const b = elementAutomation(el({ automation: bandLanes, fxChain: bandChain }));
+ expect(a).toBe(b);
+ });
+ });
+
+ describe("automationLaneLabel", () => {
+ const chain = parseChain({
+ version: 1,
+ nodes: [
+ { type: "peaking", id: "n1", params: { frequency: 1600, gain: -9, q: 1.4 } },
+ { type: "peaking", id: "n2", params: { frequency: 400, gain: -6, q: 1.4 } },
+ { type: "gain", id: "n3", params: { gain: -6 } },
+ ],
+ });
+
+ it("names the effect and the band it sits at", () => {
+ // Three lanes all reading "Peaking EQ · Gain" say nothing about which band
+ // each one is; a bare frequency does not say whether it is a bell or a
+ // shelf. Both, then the parameter.
+ expect(automationLaneLabel("fx.n1.gain", chain)).toBe("Peaking EQ 1.6 kHz · Gain");
+ expect(automationLaneLabel("fx.n2.gain", chain)).toBe("Peaking EQ 400 Hz · Gain");
+ });
+
+ it("falls back to the effect's name when it has no frequency", () => {
+ expect(automationLaneLabel("fx.n3.gain", chain)).toBe("Gain · Gain");
+ expect(automationLaneLabel("volume", chain)).toBe("Volume");
+ });
+
+ it("has nothing to say about a target that does not resolve", () => {
+ expect(automationLaneLabel("fx.gone.gain", chain)).toBeNull();
+ expect(automationLaneLabelParts("fx.gone.gain", chain)).toBeNull();
+ });
+
+ it("splits the name from the parameter, which the column stacks", () => {
+ expect(automationLaneLabelParts("fx.n1.gain", chain)).toEqual({
+ name: "Peaking EQ 1.6 kHz",
+ param: "Gain",
+ });
+ expect(automationLaneLabelParts("fx.n3.gain", chain)).toEqual({
+ name: "Gain",
+ param: "Gain",
+ });
+ // Volume is one word with no effect behind it, so there is no second line.
+ expect(automationLaneLabelParts("volume", chain)).toEqual({ name: "Volume", param: "" });
+ });
+ });
});
diff --git a/packages/studio/src/player/components/automationLaneData.ts b/packages/studio/src/player/components/automationLaneData.ts
index 375f891dcd..e62b8b7324 100644
--- a/packages/studio/src/player/components/automationLaneData.ts
+++ b/packages/studio/src/player/components/automationLaneData.ts
@@ -15,7 +15,9 @@
import {
parseAutomation,
+ parseAutomationTarget,
resolveAutomation,
+ resolveAutomationRange,
type HfAutomation,
type HfAutomationLane,
} from "@hyperframes/core/audio-automation";
@@ -79,7 +81,8 @@ export function elementAutomation(element: TimelineElement): HfAutomation {
// through a separator no attribute can contain.
return cached(automationCache, `${raw}\u0000${element.fxChain ?? ""}`, () => {
try {
- return resolveAutomation(parseAutomation(raw), chain ?? undefined);
+ const resolved = resolveAutomation(parseAutomation(raw), chain ?? undefined);
+ return { ...resolved, lanes: orderLanes(resolved.lanes, chain) };
} catch {
// Unreadable automation draws no lanes rather than breaking the row.
return EMPTY;
@@ -91,3 +94,83 @@ export function elementAutomation(element: TimelineElement): HfAutomation {
export function elementAutomationLanes(element: TimelineElement): HfAutomationLane[] {
return elementAutomation(element).lanes;
}
+
+/** The frequency the lane's effect sits at, when it has one. */
+function laneFrequency(target: string, chain: HfAudioFxChain | null): number | null {
+ const parsed = parseAutomationTarget(target);
+ if (!parsed || parsed.kind !== "fx") return null;
+ const node = chain?.nodes.find((n) => n.id === parsed.nodeId);
+ const freq = node?.params?.["frequency"];
+ return typeof freq === "number" ? freq : null;
+}
+
+/**
+ * Lane order: the audible spectrum, top down, then everything else.
+ *
+ * A stack of EQ bands is read as a spectrum, so it has to be laid out like one —
+ * high at the top, the way every analyser and every EQ curve is drawn. Attribute
+ * order is whatever minted the nodes, which for a carve is ascending: exactly
+ * upside down. Lanes with no frequency to place them — a level stage, the track's
+ * own volume — keep their written order and sit under the bands, like a fader
+ * below the EQ section of a channel strip.
+ *
+ * Sorted here, in the one function both the canvas lanes and the label column
+ * read, because a label whose row disagrees with the envelope it names is worse
+ * than either order.
+ */
+function orderLanes(lanes: HfAutomationLane[], chain: HfAudioFxChain | null): HfAutomationLane[] {
+ const withFreq: { lane: HfAutomationLane; freq: number }[] = [];
+ const rest: HfAutomationLane[] = [];
+ for (const lane of lanes) {
+ const freq = laneFrequency(lane.target, chain);
+ if (freq === null) rest.push(lane);
+ else withFreq.push({ lane, freq });
+ }
+ withFreq.sort((a, b) => b.freq - a.freq);
+ return [...withFreq.map((e) => e.lane), ...rest];
+}
+
+/** A frequency as an author reads it: 400 Hz, 1.6 kHz, 10 kHz. */
+function formatHz(freq: number): string {
+ if (freq < 1000) return `${Math.round(freq)} Hz`;
+ const k = freq / 1000;
+ return `${k >= 10 ? Math.round(k) : Number(k.toFixed(1))} kHz`;
+}
+
+/**
+ * What a lane is called in the timeline, as its two lines.
+ *
+ * `name` is the effect and, when it has one, the frequency it sits at: "Peaking
+ * EQ 1.6 kHz". The frequency is what tells two bands apart — three lanes all
+ * reading "Peaking EQ" say nothing about which is which — and the effect still
+ * has to be named, since a chain mixes filter types and a bare frequency does not
+ * say whether it is a bell or a shelf.
+ *
+ * `param` is which knob the envelope drives, on its own line: a band can carry a
+ * gain lane and a Q lane, and stacking the two lines is what keeps a name legible
+ * in a column this narrow instead of truncating mid-word.
+ *
+ * Null when the target does not resolve against the chain — the same condition
+ * that stops the lane being drawn at all.
+ */
+export function automationLaneLabelParts(
+ target: string,
+ chain: HfAudioFxChain | null,
+): { name: string; param: string } | null {
+ const range = resolveAutomationRange(target, chain ?? undefined);
+ if (!range) return null;
+ // The registry's label is " · ", or just " " for volume.
+ const parts = range.label.split(" · ");
+ const param = parts.at(-1) ?? range.label;
+ const effect = parts.length > 1 ? parts.slice(0, -1).join(" · ") : null;
+ const freq = laneFrequency(target, chain);
+ const name = [effect, freq === null ? null : formatHz(freq)].filter(Boolean).join(" ");
+ return { name: name || param, param: name ? param : "" };
+}
+
+/** The whole label on one line, for a tooltip or an accessible name. */
+export function automationLaneLabel(target: string, chain: HfAudioFxChain | null): string | null {
+ const parts = automationLaneLabelParts(target, chain);
+ if (!parts) return null;
+ return parts.param ? `${parts.name} · ${parts.param}` : parts.name;
+}
diff --git a/packages/studio/src/player/components/automationLaneDragMath.ts b/packages/studio/src/player/components/automationLaneDragMath.ts
new file mode 100644
index 0000000000..79b029a66d
--- /dev/null
+++ b/packages/studio/src/player/components/automationLaneDragMath.ts
@@ -0,0 +1,194 @@
+/**
+ * Pure position math for the two multi-point drags on an automation lane: a
+ * group move (dragging a whole box-selected set by one delta) and a single
+ * point's move (the modifiers and neighbour clamp one breakpoint honours).
+ *
+ * Split out of useAutomationLaneGestures.ts so the hook's own onPointerDown
+ * and movePoint stay orchestration — read the pointer, call one of these,
+ * apply the result — rather than carrying this branching themselves.
+ */
+
+import type { AutomationRange, HfAutomationLane } from "@hyperframes/core/audio-automation";
+import {
+ applyShiftConstraint,
+ dominantDragAxis,
+ MIN_POINT_GAP_SEC,
+ snapLaneTime,
+} from "./automationLaneGeometry";
+import { pointInSelection } from "./automationLaneSelection";
+
+/** Snap radius in clip seconds, shared with the single-point and group drags. */
+export const SNAP_SEC = 0.04;
+
+export type AutomationSelectionBox = { t0: number; t1: number; v0: number; v1: number };
+
+/** Which points a press on `pressed` should drag together: the whole box-selected
+ * set if it contains more than one member, otherwise none (an ordinary
+ * single-point drag, selection or no). */
+function resolveGroupDragIndices(
+ lane: HfAutomationLane,
+ pressed: HfAutomationLane["points"][number] | undefined,
+ rangeSelection: AutomationSelectionBox | null | undefined,
+): number[] {
+ if (!rangeSelection || !pressed || !pointInSelection(pressed, rangeSelection)) return [];
+ return lane.points.flatMap((p, i) => (pointInSelection(p, rangeSelection) ? [i] : []));
+}
+
+export interface GroupDragSnapshot {
+ points: HfAutomationLane["points"];
+ indices: number[];
+ anchor: { t: number; v: number };
+ selection: AutomationSelectionBox;
+}
+
+/** Snapshot to arm a group drag from a press on `pressed`, or null when the
+ * press does not land on a multi-point selection — an ordinary single-point
+ * drag, selection or no. */
+export function armGroupDrag(
+ lane: HfAutomationLane,
+ pressed: HfAutomationLane["points"][number] | undefined,
+ rangeSelection: AutomationSelectionBox | null | undefined,
+): GroupDragSnapshot | null {
+ const indices = resolveGroupDragIndices(lane, pressed, rangeSelection);
+ if (indices.length <= 1 || !pressed) return null;
+ return {
+ points: lane.points.map((p) => ({ ...p })),
+ indices,
+ anchor: { t: pressed.t, v: pressed.v },
+ selection: rangeSelection ? { ...rangeSelection } : { t0: 0, t1: 0, v0: 0, v1: 0 },
+ };
+}
+
+export interface GroupMoveResult {
+ points: HfAutomationLane["points"];
+ selection: AutomationSelectionBox;
+ hint: string;
+}
+
+/**
+ * Move a selected set by one delta, taken from the point under the pointer.
+ *
+ * The whole group has to stop when its first member reaches a boundary, not
+ * each point on its own: clamping individually squashes the shape flat against
+ * the edge, and the gesture is meant to preserve it. Deltas are in the
+ * parameter's own units, so on a logarithmic axis a group moves by Hz rather
+ * than by octaves — the same as dragging one point does.
+ */
+export function computeGroupMove(input: {
+ group: GroupDragSnapshot;
+ raw: { t: number; v: number };
+ shiftKey: boolean;
+ altKey: boolean;
+ range: AutomationRange;
+ duration: number;
+ snapTimes: readonly number[] | undefined;
+ xOf(t: number): number;
+ yOf(v: number): number;
+}): GroupMoveResult {
+ const { group, raw, shiftKey, altKey, range, duration, snapTimes, xOf, yOf } = input;
+ const moving = group.indices.map((i) => group.points[i]!);
+ let dt = raw.t - group.anchor.t;
+ let dv = raw.v - group.anchor.v;
+ if (shiftKey) {
+ // The axis lock, as a single-point drag has it: keep whichever the pointer
+ // has travelled further along and drop the other.
+ const alongTime = Math.abs(dt * (xOf(1) - xOf(0)));
+ const alongValue = Math.abs((dv * (yOf(range.min) - yOf(range.max))) / (range.max - range.min));
+ if (alongTime >= alongValue) dv = 0;
+ else dt = 0;
+ } else if (!altKey) {
+ // Snap the point under the pointer, and move the set by that same amount.
+ const others = group.points.filter((_, i) => !group.indices.includes(i)).map((p) => p.t);
+ dt =
+ snapLaneTime(group.anchor.t + dt, [...(snapTimes ?? []), ...others], SNAP_SEC) -
+ group.anchor.t;
+ }
+ const times = moving.map((p) => p.t);
+ const values = moving.map((p) => p.v);
+ // No member may cross a point that is staying put. Only stationary
+ // neighbours constrain: two selected points travel together, so the gap
+ // between them never changes. Per member rather than per end of the group,
+ // because a box can select a non-contiguous set — the peaks of an envelope
+ // and not the dip between them.
+ const gapTo = (step: 1 | -1): number[] =>
+ group.indices.flatMap((i) => {
+ const neighbour = group.points[i + step];
+ if (!neighbour || group.indices.includes(i + step)) return [];
+ // Short of the neighbour, not onto it — the lane collapses points that
+ // share a time, and a group drag must not consume what it runs into.
+ return [Math.max(0, Math.abs(neighbour.t - group.points[i]!.t) - MIN_POINT_GAP_SEC)];
+ });
+ dt = Math.min(
+ Math.max(dt, -Math.min(Math.min(...times), ...gapTo(-1))),
+ Math.min(duration - Math.max(...times), ...gapTo(1)),
+ );
+ dv = Math.min(Math.max(dv, range.min - Math.min(...values)), range.max - Math.max(...values));
+
+ // No re-sort: clamped to the neighbours, the lane's order cannot change
+ // under a drag, so the point under the pointer keeps its index.
+ const points = group.points.map((p, i) =>
+ group.indices.includes(i) ? { ...p, t: p.t + dt, v: p.v + dv } : p,
+ );
+ return {
+ points,
+ // The box travels with the points — both axes, or a vertical nudge would
+ // slide its own points out of the box that caught them and a second nudge
+ // would move fewer of them.
+ selection: {
+ t0: group.selection.t0 + dt,
+ t1: group.selection.t1 + dt,
+ v0: group.selection.v0 + dv,
+ v1: group.selection.v1 + dv,
+ },
+ hint: `${group.indices.length} points ${dt >= 0 ? "+" : ""}${dt.toFixed(2)}s`,
+ };
+}
+
+/** Where Shift last locked a single-point drag's axis. */
+export type ShiftAxis = "time" | "value" | null;
+
+/** The clip-local time and value a single dragged point should land on,
+ * honouring the modifiers held with it and the neighbours it cannot cross. */
+export function computeSinglePointMove(input: {
+ raw: { t: number; v: number };
+ origin: { t: number; v: number } | null;
+ shiftKey: boolean;
+ altKey: boolean;
+ shiftAxis: ShiftAxis;
+ range: AutomationRange;
+ duration: number;
+ snapTimes: readonly number[] | undefined;
+ lane: HfAutomationLane;
+ dragIndex: number;
+ xOf(t: number): number;
+ yOf(v: number): number;
+}): { t: number; v: number; shiftAxis: ShiftAxis } {
+ const { raw, origin, shiftKey, altKey, range, duration, snapTimes, lane, dragIndex, xOf, yOf } =
+ input;
+ let shiftAxis = shiftKey ? input.shiftAxis : null;
+ let { t, v } = raw;
+ if (shiftKey && origin) {
+ shiftAxis ??= dominantDragAxis({ origin, raw, xOf, yOf });
+ ({ t, v } = applyShiftConstraint({ range, origin, raw, xOf, yOf, axis: shiftAxis }));
+ }
+ // Shift is a deliberate free-hand move as much as Alt is, so neither snaps.
+ if (!altKey && !shiftKey) {
+ const neighbours = lane.points.filter((_, i) => i !== dragIndex).map((p) => p.t);
+ t = snapLaneTime(t, [...(snapTimes ?? []), ...neighbours], SNAP_SEC);
+ }
+ // A breakpoint cannot cross another in time, and cannot land exactly on one
+ // either: the lane collapses points that share a `t`, so arriving on top of a
+ // neighbour deletes it. It stops a hair short instead, which reads as touching
+ // and keeps both points. Applied after the snap, which can itself put the
+ // point on a beat past a neighbour.
+ const floor = (lane.points[dragIndex - 1]?.t ?? -Infinity) + MIN_POINT_GAP_SEC;
+ const ceiling = (lane.points[dragIndex + 1]?.t ?? Infinity) - MIN_POINT_GAP_SEC;
+ const held = lane.points[dragIndex]?.t ?? t;
+ t =
+ ceiling >= floor
+ ? Math.min(ceiling, Math.max(floor, Math.min(duration, Math.max(0, t))))
+ : // Neighbours closer together than the gap leave nowhere to go, so the
+ // point stays where it is rather than being flung to one side.
+ held;
+ return { t, v, shiftAxis };
+}
diff --git a/packages/studio/src/player/components/automationLaneGeometry.test.ts b/packages/studio/src/player/components/automationLaneGeometry.test.ts
index 2b087bd9b0..105cb3b6f3 100644
--- a/packages/studio/src/player/components/automationLaneGeometry.test.ts
+++ b/packages/studio/src/player/components/automationLaneGeometry.test.ts
@@ -79,29 +79,97 @@ describe("curveForDrag", () => {
const a = { t: 0, v: 1 };
const b = { t: 4, v: 0 };
+ /** The segment the drag describes, as the model would store it. */
+ const bentLane = (bend: { viaX: number; viaY: number } | null) => ({
+ target: "volume",
+ points: [{ ...a, ...(bend ?? {}) }, b],
+ });
+
it("puts the curved segment through the point that was dragged", () => {
- // The whole contract: whatever curve comes back, sampling the segment at the
- // dragged time has to give the dragged value back — otherwise the line runs
- // away from the pointer.
+ // The whole contract: sampling the segment at the dragged time gives the dragged
+ // value back, so the line never runs away from the pointer. Anywhere in the
+ // segment, at any depth.
for (const [t, v] of [
- [1, 0.9],
- [2, 0.8],
- [3, 0.15],
+ [0.4, 0.75],
+ [1.6, 0.65],
+ [2, 0.6],
+ [2, 0.1],
+ [2.8, 0.4],
+ [3.6, 0.5],
] as const) {
- const curve = curveForDrag({ range: VOLUME_RANGE, a, b, t, v });
- expect(curve).not.toBeNull();
- const lane = { target: "volume", points: [{ ...a, curve: curve ?? 0 }, b] };
- expect(sampleAutomationLane(lane, t, "linear")).toBeCloseTo(v, 2);
+ const bend = curveForDrag({ range: VOLUME_RANGE, a, b, t, v });
+ expect(bend).not.toBeNull();
+ expect(sampleAutomationLane(bentLane(bend), t, "linear")).toBeCloseTo(v, 2);
}
});
- it("stays inside the range the model will accept", () => {
- // Anything outside ±1 is clamped on parse, so a drag past the limit has to
- // saturate rather than round-trip to something else.
- const curve = curveForDrag({ range: VOLUME_RANGE, a, b, t: 0.05, v: 0.02 });
- expect(curve).not.toBeNull();
- expect(Math.abs(curve ?? 0)).toBeLessThanOrEqual(1);
- expect(applyCurve(0.5, curve ?? 0)).toBeGreaterThan(0);
+ it("follows the pointer into the corner of a segment, as deep as it is dragged", () => {
+ // The extreme: 10% along, pulled almost to the floor of a falling ramp. The old
+ // exponent saturated a third of the segment away from the pointer, and a later
+ // slope cap stopped following it too. Reached exactly now, and the shape it draws
+ // stays one smooth arc — checked by sampling it, not by trusting it.
+ const bend = curveForDrag({ range: VOLUME_RANGE, a, b, t: 0.4, v: 0.05 });
+ expect(bend).not.toBeNull();
+ const drawn = bentLane(bend);
+ expect(sampleAutomationLane(drawn, 0.4, "linear")).toBeCloseTo(0.05, 2);
+
+ let previous: number | null = null;
+ let worst = 1;
+ for (let i = 1; i < 60; i += 1) {
+ const t = (4 * i) / 60;
+ const h = 0.01;
+ const slope =
+ (sampleAutomationLane(drawn, t + h, "linear") -
+ sampleAutomationLane(drawn, t - h, "linear")) /
+ (2 * h);
+ if (previous !== null && Math.abs(previous) > 1e-6) {
+ const ratio = Math.abs(slope) > Math.abs(previous) ? slope / previous : previous / slope;
+ worst = Math.max(worst, Math.abs(ratio));
+ }
+ previous = slope;
+ }
+ // Gradual: a crease would show up here as a step change in slope.
+ expect(worst).toBeLessThan(3);
+ });
+
+ it("biases the bend toward whichever point the pointer is nearer", () => {
+ // The behaviour a single exponent could not give: grabbing the line near the
+ // right-hand point has to bulge it on the RIGHT. Measured as where the curve
+ // deviates furthest from the straight line it replaced.
+ const apexOf = (bend: { viaX: number; viaY: number } | null): number => {
+ const lane = bentLane(bend);
+ let best = 0;
+ let at = 0;
+ for (let i = 1; i < 40; i++) {
+ const t = (4 * i) / 40;
+ const straight = 1 - t / 4;
+ const gap = Math.abs(sampleAutomationLane(lane, t, "linear") - straight);
+ if (gap > best) {
+ best = gap;
+ at = t;
+ }
+ }
+ return at;
+ };
+ const nearA = curveForDrag({ range: VOLUME_RANGE, a, b, t: 0.6, v: 0.55 });
+ const nearB = curveForDrag({ range: VOLUME_RANGE, a, b, t: 3.4, v: 0.45 });
+ expect(apexOf(nearA)).toBeLessThan(1.6);
+ expect(apexOf(nearB)).toBeGreaterThan(2.4);
+ // And the two sit on opposite sides of centre, which is the whole complaint:
+ // every bend used to land on the same side whatever the pointer did.
+ expect(apexOf(nearA)).toBeLessThan(apexOf(nearB));
+ });
+
+ it("stays inside the normalised segment the model will accept", () => {
+ // Both coordinates are clamped clear of the ends on parse, so a drag right up
+ // against a breakpoint has to describe an interior point, not the breakpoint.
+ const bend = curveForDrag({ range: VOLUME_RANGE, a, b, t: 0.05, v: 0.02 });
+ expect(bend).not.toBeNull();
+ expect(bend?.viaX).toBeGreaterThan(0);
+ expect(bend?.viaX).toBeLessThan(1);
+ expect(bend?.viaY).toBeGreaterThan(0);
+ expect(bend?.viaY).toBeLessThan(1);
+ expect(applyCurve(0.5, 0)).toBe(0.5);
});
it("declines a segment with no room to bend", () => {
diff --git a/packages/studio/src/player/components/automationLaneGeometry.ts b/packages/studio/src/player/components/automationLaneGeometry.ts
index a0da602c82..ef9966f2b9 100644
--- a/packages/studio/src/player/components/automationLaneGeometry.ts
+++ b/packages/studio/src/player/components/automationLaneGeometry.ts
@@ -11,6 +11,7 @@ import {
fxAutomationTarget,
resolveAutomationRange,
sampleAutomationLane,
+ steadyViaPoint,
VOLUME_RANGE,
VOLUME_TARGET,
type AutomationRange,
@@ -21,6 +22,17 @@ import { getAudioFxDef, type HfAudioFxChain } from "@hyperframes/core/audio-fx";
/** Points nearer than this in clip seconds are the same point, not two. */
export const POINT_MERGE_SEC = 0.02;
+
+/**
+ * Closest two breakpoints may sit in clip seconds while still being two points.
+ *
+ * A drag clamps to this short of its neighbour rather than onto it. Landing on the
+ * exact same time is not a step, it is a deletion: the lane's own normalisation
+ * collapses points that share a `t`, keeping the later one — so dragging a point
+ * fully into its neighbour used to consume that neighbour. A millisecond is under a
+ * pixel at any zoom the lane offers, so the two still read as touching.
+ */
+export const MIN_POINT_GAP_SEC = 0.001;
/** Hit radius for grabbing a point, in px. */
export const GRAB_PX = 7;
/** Samples used to draw a segment the eye should see as curved. */
@@ -98,16 +110,21 @@ export function formatValue(range: AutomationRange, value: number): string {
}
/**
- * The `curve` that bends a segment through a dragged point.
+ * The via point that bends a segment through a dragged pointer: the pointer's own
+ * position in the segment's normalised space, which is all the model needs.
*
- * `applyCurve` raises normalised progress to `2^(2*curve)`, so a point the
- * pointer holds at progress `x` and unit height `f` fixes the exponent:
- * `x^e = f`, hence `e = ln f / ln x` and `curve = log2(e) / 2`. Solving rather
- * than accumulating a delta means the segment passes through the pointer
- * instead of drifting away from it over a long drag.
+ * There is nothing to solve any more, and that is the point. This used to fit an
+ * exponent — `x^e = f`, so `curve = log2(ln f / ln x) / 2` — and an exponent is
+ * one knob for two questions. It spent it on the wrong one: every upward bend it
+ * could draw deviated most inside the first fifth of the segment, so grabbing the
+ * line near its right-hand breakpoint still bulged it on the left. And reaching a
+ * pointer near either end needed an exponent the model refuses — `e` runs past 15
+ * at `x = 0.9`, clamped to 4 — so the line stopped following the pointer
+ * altogether, missing it by up to a third of the segment's height. Naming the
+ * point the curve passes through says both things at once, exactly, anywhere.
*
- * Null when the segment cannot express the shape: a flat segment has no room to
- * bend, and progress or height at the very ends divides by zero.
+ * Null when the segment cannot take a bend: a flat segment draws the same line
+ * whatever the shape, and a pointer at the very ends is the ends.
*/
export function curveForDrag(input: {
range: AutomationRange;
@@ -115,7 +132,7 @@ export function curveForDrag(input: {
b: { t: number; v: number };
t: number;
v: number;
-}): number | null {
+}): { viaX: number; viaY: number } | null {
const { range, a, b, t, v } = input;
const span = b.t - a.t;
if (span <= 0) return null;
@@ -126,7 +143,11 @@ export function curveForDrag(input: {
if (Math.abs(ub - ua) < 0.001) return null;
const f = (toUnit(range, v) - ua) / (ub - ua);
if (f <= 0.001 || f >= 0.999) return null;
- return Math.max(-1, Math.min(1, Math.log2(Math.log(f) / Math.log(x)) / 2));
+ // Reported as the model will honour it, not as the pointer asked. A bend is held
+ // to a steady curve, so a pointer dragged past that stops being followed — and
+ // the write, the preview and the readout all have to say the same thing about
+ // where the line actually went.
+ return steadyViaPoint(x, f);
}
/**
@@ -137,6 +158,32 @@ export function curveForDrag(input: {
* Which axis "won" is decided in pixels, not in seconds and dB — those are
* different units and comparing them would make the lock depend on the zoom.
*/
+/** Which way a gesture is going, in pixels — the only comparable unit. */
+export function dominantDragAxis(input: {
+ origin: { t: number; v: number };
+ raw: { t: number; v: number };
+ xOf(t: number): number;
+ yOf(v: number): number;
+}): "time" | "value" {
+ const { origin, raw, xOf, yOf } = input;
+ return Math.abs(xOf(raw.t) - xOf(origin.t)) > Math.abs(yOf(raw.v) - yOf(origin.v))
+ ? "time"
+ : "value";
+}
+
+/**
+ * The pointer, constrained to one axis.
+ *
+ * The axis is handed in rather than worked out here, because it has to be decided
+ * once for the gesture and held. Recomputed per event it followed whichever way
+ * the last move happened to lean, so a hand drifting sideways during a vertical
+ * drag flipped the lock and the point moved in both — which is indistinguishable
+ * from no lock at all.
+ *
+ * Locking to time holds the value exactly. Locking to value holds the time and
+ * moves the value at a quarter speed: the same gesture is the fine adjustment,
+ * because a fader spanning 60px of lane has no other way to be set precisely.
+ */
export function applyShiftConstraint(input: {
range: AutomationRange;
origin: { t: number; v: number };
@@ -144,11 +191,12 @@ export function applyShiftConstraint(input: {
/** Same projections the lane draws with, so the comparison is on screen. */
xOf(t: number): number;
yOf(v: number): number;
+ /** Decided on the gesture's first travel; worked out here when absent. */
+ axis?: "time" | "value";
}): { t: number; v: number } {
- const { range, origin, raw, xOf, yOf } = input;
- if (Math.abs(xOf(raw.t) - xOf(origin.t)) > Math.abs(yOf(raw.v) - yOf(origin.v))) {
- return { t: raw.t, v: origin.v };
- }
+ const { range, origin, raw } = input;
+ const axis = input.axis ?? dominantDragAxis(input);
+ if (axis === "time") return { t: raw.t, v: origin.v };
const from = toUnit(range, origin.v);
return { t: origin.t, v: fromUnit(range, from + (toUnit(range, raw.v) - from) * 0.25) };
}
@@ -202,7 +250,9 @@ export function envelopePath(input: {
const a = lane.points[i];
const b = lane.points[i + 1];
if (!a || !b) continue;
- if (!a.curve && range.scale === "linear") {
+ // A via point bends the segment with no `curve` of its own, so the
+ // straight-line shortcut has to rule out both.
+ if (!a.curve && a.viaX === undefined && range.scale === "linear") {
pts.push(`L ${xOf(b.t)} ${yOf(b.v)}`);
continue;
}
diff --git a/packages/studio/src/player/components/automationLaneHeight.ts b/packages/studio/src/player/components/automationLaneHeight.ts
index 3ad43c4282..b657ee5f76 100644
--- a/packages/studio/src/player/components/automationLaneHeight.ts
+++ b/packages/studio/src/player/components/automationLaneHeight.ts
@@ -6,6 +6,10 @@
* component's constant living somewhere it is not used.
*
* Taller than a keyframe lane because it carries a value axis rather than a row
- * of diamonds: a fader envelope drawn 28px high cannot be aimed.
+ * of diamonds: a fader envelope drawn 28px high cannot be aimed. 48 was still
+ * too tight in use — the drawing area is the height minus 6px of padding either
+ * side, so 48 left 36px for the whole 0..1 fader and a breakpoint's 11px grab
+ * disc covered a third of the axis. 72 leaves 60px, which is what makes a value
+ * aimable and two points at similar values separately grabbable.
*/
-export const AUTOMATION_LANE_H = 48;
+export const AUTOMATION_LANE_H = 72;
diff --git a/packages/studio/src/player/components/automationLaneSelection.ts b/packages/studio/src/player/components/automationLaneSelection.ts
index 66e384e6f1..f5939dd7d8 100644
--- a/packages/studio/src/player/components/automationLaneSelection.ts
+++ b/packages/studio/src/player/components/automationLaneSelection.ts
@@ -2,7 +2,7 @@
* Range operations over one automation lane.
*
* `replaceRange` is the only mutator every range feature (delete, shapes,
- * paste, stretch) composes, and it carries the invariant that makes them safe:
+ * paste) composes, and it carries the invariant that makes them safe:
* the envelope OUTSIDE the selection never moves. It samples the lane at both
* edges first and pins anchor points there, so cutting the middle out of a
* ramp cannot reshape the rest of the clip.
@@ -76,6 +76,24 @@ export function replaceRange(input: {
return [...outside, ...edges, ...cappedInner].sort((a, b) => a.t - b.t);
}
+/**
+ * Whether a breakpoint falls inside the selection box, edges included.
+ *
+ * The one rule three places need: what Delete removes, what the lane rings, and
+ * what a group drag moves. They have to agree — a point drawn as caught but left
+ * behind by the drag is worse than either answer.
+ *
+ * Both axes, which is what makes a selection a box: a point at the right time but
+ * the wrong value is not in it. Values compare in the parameter's own units, and
+ * that is correct on a logarithmic axis too — the mapping to screen is monotonic,
+ * so a box drawn around some pixels holds exactly the values it looks like it does.
+ */
+export function pointInSelection(
+ point: { t: number; v: number },
+ box: { t0: number; t1: number; v0: number; v1: number },
+): boolean {
+ return point.t >= box.t0 && point.t <= box.t1 && point.v >= box.v0 && point.v <= box.v1;
+}
/**
* Retime a selection: interior points scale proportionally into the new span,
* then replaceRange runs over the UNION of old and new spans — growing eats
diff --git a/packages/studio/src/player/components/useAutomationEdgeStretch.ts b/packages/studio/src/player/components/useAutomationEdgeStretch.ts
index faae242e46..ed7b2fd49f 100644
--- a/packages/studio/src/player/components/useAutomationEdgeStretch.ts
+++ b/packages/studio/src/player/components/useAutomationEdgeStretch.ts
@@ -56,8 +56,11 @@ export interface UseAutomationEdgeStretchInput {
duration: number;
readOnly?: boolean | undefined;
/** Active selection on this lane, so its edges have something to grab. */
- rangeSelection?: { t0: number; t1: number } | null | undefined;
- onRangeSelect?: ((t0: number, t1: number) => void) | undefined;
+ rangeSelection?: { t0: number; t1: number; v0: number; v1: number } | null | undefined;
+ /** Whether the pointer is over a point the current selection contains. Such a
+ * press is a group drag, not a stretch — see `arm`. */
+ pointInSelectionAt?: ((clientX: number, clientY: number) => boolean) | undefined;
+ onRangeSelect?: ((t0: number, t1: number, v0: number, v1: number) => void) | undefined;
onRangeClear?: (() => void) | undefined;
/** Value readout owned by the lane's gesture hook. */
onHint(text: string | null): void;
@@ -89,7 +92,7 @@ export interface UseAutomationEdgeStretchResult {
export function clampEdge(
edge: "t0" | "t1",
raw: number,
- origin: { t0: number; t1: number },
+ origin: { t0: number; t1: number; v0: number; v1: number },
duration: number,
): number {
if (edge === "t0") {
@@ -108,6 +111,7 @@ export function useAutomationEdgeStretch({
duration,
readOnly,
rangeSelection,
+ pointInSelectionAt,
onRangeSelect,
onRangeClear,
onHint,
@@ -117,7 +121,7 @@ export function useAutomationEdgeStretch({
* points as they stood at arm time. */
const [drag, setDrag] = useState<{
edge: "t0" | "t1";
- origin: { t0: number; t1: number };
+ origin: { t0: number; t1: number; v0: number; v1: number };
current: number;
points: HfAutomationPoint[];
} | null>(null);
@@ -158,6 +162,18 @@ export function useAutomationEdgeStretch({
if (readOnly || !rangeSelection) return false;
const edge = edgeAt(e.clientX);
if (!edge) return false;
+ // Selected CONTENT outranks the edge it sits on.
+ //
+ // #3207 had the opposite rule, and it was right for a time-only selection:
+ // every range operation leaves a breakpoint exactly on the edge it created,
+ // so a point-first rule made the second stretch of an edge resolve to a
+ // point-drag. A box selection changes the question — a point on the edge is
+ // now visibly INSIDE the selection, with the value axis saying so, and
+ // dragging selected content has to move it or the box means nothing.
+ //
+ // So the edge still stretches everywhere it is not also selected content,
+ // which is most of its length.
+ if (pointInSelectionAt?.(e.clientX, e.clientY)) return false;
e.preventDefault();
capturePointer(e);
setHover(false);
@@ -170,7 +186,7 @@ export function useAutomationEdgeStretch({
});
return true;
},
- [readOnly, rangeSelection, edgeAt, lane],
+ [readOnly, rangeSelection, edgeAt, lane, pointInSelectionAt],
);
/** Preview the stretch: the partner edge stays put as the retime's anchor, and
@@ -190,7 +206,7 @@ export function useAutomationEdgeStretch({
const newT0 = edge === "t0" ? current : origin.t0;
const newT1 = edge === "t1" ? current : origin.t1;
onHint(`${newT0.toFixed(2)}s → ${newT1.toFixed(2)}s`);
- onRangeSelect?.(newT0, newT1);
+ onRangeSelect?.(newT0, newT1, origin.v0, origin.v1);
commitPoints(
retimeRange({
lane: { target: lane.target, points },
@@ -221,7 +237,12 @@ export function useAutomationEdgeStretch({
}
crossed.current = false;
commitPoints(lane.points, true);
- onRangeSelect?.(edge === "t0" ? current : origin.t0, edge === "t1" ? current : origin.t1);
+ onRangeSelect?.(
+ edge === "t0" ? current : origin.t0,
+ edge === "t1" ? current : origin.t1,
+ origin.v0,
+ origin.v1,
+ );
}, [drag, onHint, onRangeClear, commitPoints, lane, onRangeSelect]);
const cancel = useCallback((): void => {
@@ -234,7 +255,7 @@ export function useAutomationEdgeStretch({
// A live write is a preview, so putting the snapshot back through the same
// channel is the whole revert — there is nothing persisted to undo.
commitPoints(points, false);
- onRangeSelect?.(origin.t0, origin.t1);
+ onRangeSelect?.(origin.t0, origin.t1, origin.v0, origin.v1);
}, [drag, onHint, commitPoints, onRangeSelect]);
const updateHover = useCallback(
diff --git a/packages/studio/src/player/components/useAutomationLaneGestures.ts b/packages/studio/src/player/components/useAutomationLaneGestures.ts
index 73f1a23553..de166cf5e9 100644
--- a/packages/studio/src/player/components/useAutomationLaneGestures.ts
+++ b/packages/studio/src/player/components/useAutomationLaneGestures.ts
@@ -4,7 +4,7 @@
* Its own hook because the lane component sits at the studio's file ceiling and
* because these are the parts worth testing on their own: which of a press,
* a drag and a modifier resolves to moving a point, bending a segment,
- * stretching a selection's edge, or nothing at all.
+ * drawing a selection box, or nothing at all.
*
* Modifiers follow Ableton's, since that is the muscle memory an automation lane
* inherits: Shift locks a drag to one axis and fines the value down, Alt over a
@@ -13,20 +13,21 @@
import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import type { AutomationRange, HfAutomationLane } from "@hyperframes/core/audio-automation";
-import {
- applyShiftConstraint,
- curveForDrag,
- formatValue,
- GRAB_PX,
- POINT_MERGE_SEC,
- snapLaneTime,
-} from "./automationLaneGeometry";
+import { curveForDrag, formatValue, GRAB_PX, POINT_MERGE_SEC } from "./automationLaneGeometry";
+import { pointInSelection } from "./automationLaneSelection";
import { capturePointer } from "./automationLanePointer";
import { useAutomationEdgeStretch } from "./useAutomationEdgeStretch";
+import { useAutomationRangeDrag } from "./useAutomationRangeDrag";
+import {
+ armGroupDrag,
+ computeGroupMove,
+ computeSinglePointMove,
+ type GroupDragSnapshot,
+ type ShiftAxis,
+} from "./automationLaneDragMath";
-/** Snap radius in clip seconds. Tight on purpose: a lane is often a few seconds
- * wide, where a generous radius makes a point unplaceable between two beats. */
-const SNAP_SEC = 0.04;
+/** How far a press may travel and still count as a click rather than a drag. */
+const CLICK_SLOP_PX = 3;
/** A point's position, or the origin when the index no longer resolves. */
function originOf(point: HfAutomationLane["points"][number] | undefined): { t: number; v: number } {
@@ -49,12 +50,12 @@ export interface UseAutomationLaneGesturesInput {
snapTimes?: readonly number[] | undefined;
readOnly?: boolean | undefined;
onSelect?: (() => void) | undefined;
- /** Live range-select callbacks; absent = background drags do nothing (read-only lanes). */
- onRangeSelect?: ((t0: number, t1: number) => void) | undefined;
+ /** Live box-select callbacks; absent = background drags do nothing (read-only lanes). */
+ onRangeSelect?: ((t0: number, t1: number, v0: number, v1: number) => void) | undefined;
onRangeClear?: (() => void) | undefined;
- duration: number; // clamp bound for range endpoints
- /** Active selection on this lane, so its edges have something to grab. */
- rangeSelection?: { t0: number; t1: number } | null | undefined;
+ duration: number; // clamp bound for box endpoints
+ /** Active selection box on this lane, so a press inside it can drag its points. */
+ rangeSelection?: { t0: number; t1: number; v0: number; v1: number } | null | undefined;
}
export interface UseAutomationLaneGesturesResult {
@@ -62,21 +63,20 @@ export interface UseAutomationLaneGesturesResult {
dragIndex: number | null;
/** Segment being bent, identified by the point that owns its curve. */
curveIndex: number | null;
- /** Edge being stretched, for the cursor. */
- edgeDrag: "t0" | "t1" | null;
- /** Whether the pointer sits over a stretch handle with no gesture live —
- * the col-resize cursor hint before a press commits to the drag. */
- edgeHover: boolean;
/** Value readout to show while a gesture is live. */
hint: string | null;
hitIndex(clientX: number, clientY: number): number | null;
segmentIndex(clientX: number, clientY: number): number | null;
onPointerDown(e: ReactPointerEvent): void;
onPointerMove(e: ReactPointerEvent): void;
- endDrag(e: ReactPointerEvent): void;
- /** The browser took the gesture away (`pointercancel`): a stretch reverts
- * rather than persisting whatever partial retime it had reached. */
+ /** Edge being stretched, for the cursor. Null when no stretch is live. */
+ edgeDrag: "t0" | "t1" | null;
+ /** Pointer resting over a stretch handle with nothing armed, so the cursor
+ * can advertise the gesture before it starts. */
+ edgeHover: boolean;
+ /** `pointercancel`: a live stretch reverts; anything else ends normally. */
cancelDrag(e: ReactPointerEvent): void;
+ endDrag(e: ReactPointerEvent): void;
/** Adds a point, opens the value field on one, or straightens a segment. */
onDoubleClick(e: ReactPointerEvent): void;
/** The point whose value is being typed, and the text so far. */
@@ -105,16 +105,19 @@ export function useAutomationLaneGestures({
const [dragIndex, setDragIndex] = useState(null);
const [curveIndex, setCurveIndex] = useState(null);
const [hint, setHint] = useState(null);
- /** Where a point drag began, so Shift can lock an axis and fine the value. */
- const dragOrigin = useRef<{ t: number; v: number } | null>(null);
- /** Point whose value is being typed, and the text so far. */
- const [editing, setEditing] = useState<{ index: number; text: string } | null>(null);
- /** A background drag in progress: its start and live end, in clip seconds. */
- const [rangeDrag, setRangeDrag] = useState<{ from: number; to: number } | null>(null);
- /** Whether the live drag has crossed the pixel threshold that turns a press
- * into an actual range, rather than a click that should just clear one. */
- const rangeCrossed = useRef(false);
+ /**
+ * Stretching a selection by one of its time edges (#3207).
+ *
+ * Kept as its own module rather than inlined: it is a fifth mutually-exclusive
+ * gesture on a hook already at the file's complexity budget, and it owns a
+ * snapshot the other gestures have no use for — `retimeRange` scales against
+ * the bounds captured when the drag armed, so running it against the live
+ * draft would compound the scale factor on every pointermove.
+ *
+ * Only the time edges stretch. The value extent rides through untouched, which
+ * is what keeps this the same gesture it was before the selection became a box.
+ */
const stretch = useAutomationEdgeStretch({
getBox,
lane,
@@ -125,10 +128,74 @@ export function useAutomationLaneGestures({
duration,
readOnly,
rangeSelection,
+ // The stretch stands down on a press that lands on selected content — that
+ // gesture belongs to the group drag below.
+ pointInSelectionAt: (clientX, clientY) => {
+ if (!rangeSelection) return false;
+ const i = hitIndex(clientX, clientY);
+ const p = i === null ? null : lane.points[i];
+ return p ? pointInSelection(p, rangeSelection) : false;
+ },
onRangeSelect,
onRangeClear,
onHint: setHint,
});
+ /** Where a point drag began, so Shift can lock an axis and fine the value. */
+ const dragOrigin = useRef<{ t: number; v: number } | null>(null);
+ /**
+ * The set a group drag moves, snapshotted on the press.
+ *
+ * Snapshotted rather than recomputed per move for two reasons: every point is
+ * moving, so "which ones are selected" has to mean what it meant when the
+ * gesture started, and the deltas have to accumulate from the original
+ * positions or a rounded move would drift on every pointermove.
+ */
+ const groupDrag = useRef(null);
+ /** Point whose value is being typed, and the text so far. */
+ const [editing, setEditing] = useState<{ index: number; text: string } | null>(null);
+ const rangeDrag = useAutomationRangeDrag({
+ pointAt,
+ xOf,
+ yOf,
+ duration,
+ snapTimes,
+ onRangeSelect,
+ onRangeClear,
+ });
+ /** Where a press on a point landed, and whether it has travelled since. A press
+ * that goes nowhere is a click, which is a different gesture from a drag even
+ * though both start the same way — see the Shift branch in `endDrag`. */
+ const pressAt = useRef<{ x: number; y: number } | null>(null);
+ const pressTravelled = useRef(false);
+ /**
+ * The axis Shift locked, decided on the gesture's first travel and held until
+ * the drag ends or Shift is let go. Deciding per event followed whichever way
+ * the last move leaned, so a drifting hand unlocked the axis mid-drag.
+ */
+ const shiftAxis = useRef(null);
+
+ // The value field's own handlers, above the gestures that close it: a press on the
+ // lane applies whatever was typed, so onPointerDown lists commitEdit as a
+ // dependency and cannot be declared before it.
+ const setEditingText = useCallback((text: string): void => {
+ setEditing((current) => (current ? { index: current.index, text } : null));
+ }, []);
+
+ const cancelEdit = useCallback((): void => setEditing(null), []);
+
+ /** Apply a typed value, or drop the edit when it is not a number. */
+ const commitEdit = useCallback((): void => {
+ const active = editing;
+ setEditing(null);
+ if (!active) return;
+ const typed = Number(active.text);
+ if (!Number.isFinite(typed)) return;
+ const clamped = Math.min(range.max, Math.max(range.min, typed));
+ commitPoints(
+ lane.points.map((p, i) => (i === active.index ? { ...p, v: clamped } : p)),
+ true,
+ );
+ }, [editing, lane, range, commitPoints]);
/** Index of a point under the pointer, or null. */
const hitIndex = useCallback(
@@ -172,25 +239,6 @@ export function useAutomationLaneGestures({
[hitIndex, segmentIndex],
);
- /**
- * What a press on the lane's empty background arms: a new range selection,
- * and only when a caller wants to hear about one — a read-only lane never
- * reaches here at all.
- */
- const armRangeDrag = useCallback(
- (e: ReactPointerEvent): void => {
- if (!onRangeSelect) return;
- e.preventDefault();
- capturePointer(e);
- const raw = pointAt(e.clientX, e.clientY).t;
- const clamped = Math.min(duration, Math.max(0, raw));
- const t = e.altKey ? clamped : snapLaneTime(clamped, snapTimes ?? [], SNAP_SEC);
- rangeCrossed.current = false;
- setRangeDrag({ from: t, to: t });
- },
- [onRangeSelect, pointAt, duration, snapTimes],
- );
-
const onPointerDown = useCallback(
(e: ReactPointerEvent): void => {
if (e.button !== 0) return;
@@ -198,21 +246,30 @@ export function useAutomationLaneGestures({
// timeline's own gesture (scrub / marquee / clip drag), which then eats the
// rest of the sequence — including the second half of a double-click.
e.stopPropagation();
+ // A press anywhere on the lane closes the value field, applying what was
+ // typed — the same thing Enter and a blur do. It cannot rely on the blur: the
+ // gesture branches below call preventDefault to keep the timeline from
+ // scrubbing, and that suppresses the focus change the blur would come from,
+ // so the field sat open until Enter however far away the next click landed.
+ if (editing) commitEdit();
if (readOnly) {
// The lane sits below the clip bar, so the timeline's selection handler
- // never sees this press; selecting here is the only way in.
+ // never sees this press; selecting here is the only way in. The press then
+ // goes on to arm a range drag rather than being spent on the selection: a
+ // press that only selected made the first drag over any lane do nothing
+ // visible, so a range took two gestures and looked broken on the first.
onSelect?.();
+ rangeDrag.arm(e);
return;
}
// An active selection's edge outranks a point sitting on it. Every range
- // operation — stretch, delete, shape insert — leaves a breakpoint exactly
- // on the edge it just created, so a point-first rule meant the second
- // stretch of the same edge resolved to a point-drag and the feature was
- // not repeatable. Clear the selection to reach that point again.
+ // operation leaves a breakpoint exactly on the edge it just created, so a
+ // point-first rule made the second stretch of the same edge resolve to a
+ // point-drag — which is how the feature silently stopped working.
if (stretch.arm(e)) return;
const gesture = gestureAt(e);
if (!gesture) {
- armRangeDrag(e);
+ rangeDrag.arm(e);
return;
}
e.preventDefault();
@@ -222,9 +279,27 @@ export function useAutomationLaneGestures({
return;
}
dragOrigin.current = originOf(lane.points[gesture.index]);
+ // Pressing one of a selected set drags the whole set. Pressing a point
+ // outside the selection is an ordinary single-point drag, selection or no.
+ groupDrag.current = armGroupDrag(lane, lane.points[gesture.index], rangeSelection);
+ pressAt.current = { x: e.clientX, y: e.clientY };
+ pressTravelled.current = false;
setDragIndex(gesture.index);
},
- [gestureAt, lane, readOnly, onSelect, armRangeDrag, stretch],
+ [
+ gestureAt,
+ lane,
+ readOnly,
+ onSelect,
+ rangeDrag,
+ editing,
+ commitEdit,
+ // The press decides whether it starts a group drag, so it has to see the
+ // selection as it is now — captured stale, a point pressed straight after
+ // selecting a range read the previous selection, or none.
+ rangeSelection,
+ stretch,
+ ],
);
/** Bend the segment under the pointer, which is what Alt-dragging the line does. */
@@ -235,74 +310,103 @@ export function useAutomationLaneGestures({
const b = lane.points[curveIndex + 1];
if (!a || !b) return;
const { t, v } = pointAt(clientX, clientY);
- const curve = curveForDrag({ range, a, b, t, v });
- if (curve === null) return;
- setHint(`curve ${curve.toFixed(2)}`);
+ const bend = curveForDrag({ range, a, b, t, v });
+ if (bend === null) return;
+ // Read out where the bend now sits along the segment, which is what the
+ // pointer is choosing: a percentage means more here than a curve exponent
+ // the author never types.
+ setHint(`bend ${Math.round(bend.viaX * 100)}%`);
commitPoints(
- lane.points.map((p, i) => (i === curveIndex ? { ...p, curve } : p)),
+ // `curve` is dropped rather than carried: the via point supersedes it, and
+ // leaving a stale exponent behind would make the segment's shape depend on
+ // which of the two the reader happened to honour.
+ lane.points.map((p, i) =>
+ i === curveIndex ? { ...p, curve: undefined, viaX: bend.viaX, viaY: bend.viaY } : p,
+ ),
false,
);
},
[curveIndex, lane, pointAt, range, commitPoints],
);
+ /**
+ * Move a selected set by one delta, taken from the point under the pointer.
+ *
+ * The whole group has to stop when its first member reaches a boundary, not
+ * each point on its own: clamping individually squashes the shape flat against
+ * the edge, and the gesture is meant to preserve it. Deltas are in the
+ * parameter's own units, so on a logarithmic axis a group moves by Hz rather
+ * than by octaves — the same as dragging one point does.
+ */
+ const moveGroup = useCallback(
+ (e: ReactPointerEvent, group: GroupDragSnapshot): void => {
+ const { points, selection, hint } = computeGroupMove({
+ group,
+ raw: pointAt(e.clientX, e.clientY),
+ shiftKey: e.shiftKey,
+ altKey: e.altKey,
+ range,
+ duration,
+ snapTimes,
+ xOf,
+ yOf,
+ });
+ onRangeSelect?.(selection.t0, selection.t1, selection.v0, selection.v1);
+ setHint(hint);
+ commitPoints(points, false);
+ },
+ [commitPoints, duration, onRangeSelect, pointAt, range, snapTimes, xOf, yOf],
+ );
+
/** Move the point being dragged, honouring the modifiers held with it. */
const movePoint = useCallback(
(e: ReactPointerEvent): void => {
if (dragIndex === null) return;
- const raw = pointAt(e.clientX, e.clientY);
- const origin = dragOrigin.current;
- let { t, v } =
- e.shiftKey && origin ? applyShiftConstraint({ range, origin, raw, xOf, yOf }) : raw;
- // Shift is a deliberate free-hand move as much as Alt is, so neither snaps.
- if (!e.altKey && !e.shiftKey) {
- const neighbours = lane.points.filter((_, i) => i !== dragIndex).map((p) => p.t);
- t = snapLaneTime(t, [...(snapTimes ?? []), ...neighbours], SNAP_SEC);
+ const group = groupDrag.current;
+ if (group) {
+ moveGroup(e, group);
+ return;
}
+ if (!e.shiftKey) shiftAxis.current = null;
+ const {
+ t,
+ v,
+ shiftAxis: nextAxis,
+ } = computeSinglePointMove({
+ raw: pointAt(e.clientX, e.clientY),
+ origin: dragOrigin.current,
+ shiftKey: e.shiftKey,
+ altKey: e.altKey,
+ shiftAxis: shiftAxis.current,
+ range,
+ duration,
+ snapTimes,
+ lane,
+ dragIndex,
+ xOf,
+ yOf,
+ });
+ shiftAxis.current = nextAxis;
const next = lane.points.map((p, i) => (i === dragIndex ? { ...p, t, v } : p));
- // Re-sort so dragging a point past a neighbour behaves, and keep the
- // dragged one addressable by following where it landed.
- const moved = next[dragIndex];
- next.sort((a, b) => a.t - b.t);
- if (moved) setDragIndex(next.indexOf(moved));
setHint(`${formatValue(range, v)} @ ${t.toFixed(2)}s`);
commitPoints(next, false);
},
- [dragIndex, lane, pointAt, range, commitPoints, snapTimes, xOf, yOf],
- );
-
- /** Update the live range-drag as the pointer moves, firing `onRangeSelect`
- * once it has covered enough pixels to count as an actual range rather
- * than a click that should just clear one. */
- const moveRangeDrag = useCallback(
- (e: ReactPointerEvent): void => {
- if (rangeDrag === null) return;
- const raw = pointAt(e.clientX, e.clientY).t;
- const clamped = Math.min(duration, Math.max(0, raw));
- const t = e.altKey ? clamped : snapLaneTime(clamped, snapTimes ?? [], SNAP_SEC);
- setRangeDrag({ from: rangeDrag.from, to: t });
- if (Math.abs(xOf(t) - xOf(rangeDrag.from)) <= 3) return;
- rangeCrossed.current = true;
- onRangeSelect?.(Math.min(rangeDrag.from, t), Math.max(rangeDrag.from, t));
- },
- [rangeDrag, pointAt, duration, snapTimes, xOf, onRangeSelect],
+ [dragIndex, duration, lane, pointAt, range, commitPoints, snapTimes, xOf, yOf, moveGroup],
);
const onPointerMove = useCallback(
(e: ReactPointerEvent): void => {
if (stretch.edge !== null) {
e.stopPropagation();
- // A capture lost without a `pointercancel` — the child it was taken on
- // unmounted, or the browser handed the gesture elsewhere — leaves no
- // gesture-end event at all, and every later hover would keep retiming.
- // A move with no button held is the only signal left that it is over.
+ // A button-less move means the browser handed the gesture back without a
+ // pointerup — revert rather than leaving a half-applied retime.
if (e.buttons === 0) stretch.cancel();
else stretch.move(e);
return;
}
- if (rangeDrag !== null) {
+ if (rangeDrag.dragging) {
e.stopPropagation();
- moveRangeDrag(e);
+ rangeDrag.move(e);
return;
}
if (curveIndex === null && dragIndex === null) {
@@ -310,20 +414,16 @@ export function useAutomationLaneGestures({
return;
}
e.stopPropagation();
+ const from = pressAt.current;
+ if (from && Math.hypot(e.clientX - from.x, e.clientY - from.y) > CLICK_SLOP_PX) {
+ pressTravelled.current = true;
+ }
if (curveIndex !== null) bendSegment(e.clientX, e.clientY);
else movePoint(e);
},
- [stretch, rangeDrag, moveRangeDrag, curveIndex, dragIndex, bendSegment, movePoint],
+ [rangeDrag, curveIndex, dragIndex, bendSegment, movePoint, stretch],
);
- /** A sub-threshold press clears the selection rather than leaving a
- * zero-width one behind. */
- const finishRangeDrag = useCallback((): void => {
- if (!rangeCrossed.current) onRangeClear?.();
- rangeCrossed.current = false;
- setRangeDrag(null);
- }, [onRangeClear]);
-
const endDrag = useCallback(
(e: ReactPointerEvent): void => {
if (stretch.edge !== null) {
@@ -331,25 +431,42 @@ export function useAutomationLaneGestures({
stretch.finish();
return;
}
- if (rangeDrag !== null) {
+ if (rangeDrag.dragging) {
e.stopPropagation();
- finishRangeDrag();
+ rangeDrag.finish();
return;
}
if (dragIndex === null && curveIndex === null) return;
e.stopPropagation();
+ const index = dragIndex;
+ const shiftClicked = index !== null && e.shiftKey && !pressTravelled.current;
setDragIndex(null);
setCurveIndex(null);
dragOrigin.current = null;
+ groupDrag.current = null;
+ shiftAxis.current = null;
+ pressAt.current = null;
setHint(null);
+ // Shift+click removes the point. Decided on RELEASE, not on the press: Shift
+ // held through a drag is the axis lock, and acting on the press would take
+ // that gesture away. A press that never travelled was a click.
+ if (shiftClicked) {
+ commitPoints(
+ lane.points.filter((_, i) => i !== index),
+ true,
+ );
+ return;
+ }
commitPoints(lane.points, true);
},
- [stretch, rangeDrag, finishRangeDrag, curveIndex, dragIndex, lane, commitPoints],
+ [rangeDrag, curveIndex, dragIndex, lane, commitPoints, stretch],
);
- /** `pointercancel`: the browser abandoned the gesture, so a stretch reverts
- * instead of persisting the partial retime `endDrag` would have committed.
- * Anything else ends the way a release ends it. */
+ /**
+ * `pointercancel`: the browser abandoned the gesture, so a stretch reverts
+ * rather than persisting the partial retime `endDrag` would have committed.
+ * Anything else ends the way a release ends it.
+ */
const cancelDrag = useCallback(
(e: ReactPointerEvent): void => {
if (stretch.edge !== null) {
@@ -399,38 +516,18 @@ export function useAutomationLaneGestures({
[lane, pointAt, commitPoints, readOnly, hitIndex, segmentIndex],
);
- const setEditingText = useCallback((text: string): void => {
- setEditing((current) => (current ? { index: current.index, text } : null));
- }, []);
-
- const cancelEdit = useCallback((): void => setEditing(null), []);
-
- /** Apply a typed value, or drop the edit when it is not a number. */
- const commitEdit = useCallback((): void => {
- const active = editing;
- setEditing(null);
- if (!active) return;
- const typed = Number(active.text);
- if (!Number.isFinite(typed)) return;
- const clamped = Math.min(range.max, Math.max(range.min, typed));
- commitPoints(
- lane.points.map((p, i) => (i === active.index ? { ...p, v: clamped } : p)),
- true,
- );
- }, [editing, lane, range, commitPoints]);
-
return {
dragIndex,
curveIndex,
edgeDrag: stretch.edge,
edgeHover: stretch.hover,
+ cancelDrag,
hint,
hitIndex,
segmentIndex,
onPointerDown,
onPointerMove,
endDrag,
- cancelDrag,
onDoubleClick,
editing,
setEditingText,
diff --git a/packages/studio/src/player/components/useAutomationLanes.test.tsx b/packages/studio/src/player/components/useAutomationLanes.test.tsx
index 2003035ea2..17e5cd1322 100644
--- a/packages/studio/src/player/components/useAutomationLanes.test.tsx
+++ b/packages/studio/src/player/components/useAutomationLanes.test.tsx
@@ -78,6 +78,9 @@ describe("useAutomationLanes", () => {
});
it("gives one lane per automated parameter, in draw order", () => {
+ // Draw order is the spectrum: a lane belonging to an effect that sits at a
+ // frequency is placed by that frequency, high first, and lanes with none —
+ // the track's own volume — follow in written order.
const automation = JSON.stringify({
version: 1,
lanes: [
@@ -87,7 +90,7 @@ describe("useAutomationLanes", () => {
],
});
const bound = bindOnce(el({ automation, fxChain: CHAIN }));
- expect(bound.lanes.map((l) => l.target)).toEqual(["volume", "fx.n2.frequency", "fx.n2.q"]);
+ expect(bound.lanes.map((l) => l.target)).toEqual(["fx.n2.frequency", "fx.n2.q", "volume"]);
});
it("reads an element with neither attribute as an empty volume lane", () => {
diff --git a/packages/studio/src/player/components/useAutomationLanes.ts b/packages/studio/src/player/components/useAutomationLanes.ts
index 1862d4d33c..66242dd747 100644
--- a/packages/studio/src/player/components/useAutomationLanes.ts
+++ b/packages/studio/src/player/components/useAutomationLanes.ts
@@ -10,7 +10,7 @@
* read only, which is also what stops a stray drag from editing the wrong track.
*/
-import { useCallback, useMemo } from "react";
+import { useCallback, useMemo, useRef } from "react";
import {
HF_AUDIO_AUTOMATION_ATTR,
serializeAutomation,
@@ -27,6 +27,7 @@ import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import type { AutomationSelection } from "../store/automationSelectionSlice";
import { elementAutomation, elementFxChain } from "./automationLaneData";
+import { createAutomationGestureKeys } from "./automationGestureKeys";
export interface AutomationLaneBinding {
automation: HfAutomation;
@@ -54,12 +55,13 @@ export interface AutomationLaneBinding {
* element's key; it lags only in the window a non-gesture caller can hit.
*/
commitTargetKey: string | null;
- /** This element's active time selection, or null if none / it belongs to a
+ /** This element's active selection box, or null if none / it belongs to a
* different element. */
selection: AutomationSelection | null;
- /** Live write while dragging a range on the given lane; does not persist —
- * the selection is ephemeral store state, not part of the composition. */
- onRangeSelect(target: string, t0: number, t1: number): void;
+ /** Live write while dragging a selection box on the given lane; does not
+ * persist — the selection is ephemeral store state, not part of the
+ * composition. */
+ onRangeSelect(target: string, t0: number, t1: number, v0: number, v1: number): void;
onRangeClear(): void;
}
@@ -68,6 +70,9 @@ export interface UseAutomationLanesResult {
}
export function useAutomationLanes(): UseAutomationLanesResult {
+ // One per hook instance, held across renders: a gesture spans many commits and
+ // they all have to record under the same key for undo to take the whole drag.
+ const gestureKeys = useRef(createAutomationGestureKeys());
// Optional: the player also runs outside Studio, where there is no edit
// session. There the lanes render read-only, which is the right fallback.
const domEdit = useDomEditActionsContextOptional();
@@ -97,14 +102,32 @@ export function useAutomationLanes(): UseAutomationLanesResult {
const write = (next: HfAutomation, persist: boolean): void => {
if (!domEdit || !isSelected) return;
const value = next.lanes.length > 0 ? serializeAutomation(next) : "";
+ // Every write of one gesture under one key, so undo takes back the whole
+ // drag rather than the last fragment history happened to keep.
+ const coalesce = persist ? gestureKeys.current.commit() : gestureKeys.current.live();
// Quiet, not the refreshing commit: releasing a dragged point used to
// reload the preview, which restarts every playing track — the same chop
// the live write during the drag exists to avoid. Quiet still persists
// and still resyncs the selection, so the next edit sees this one.
- if (persist) void domEdit.handleDomAttributeQuietCommit(HF_AUDIO_AUTOMATION_ATTR, value);
+ if (persist) {
+ void domEdit.handleDomAttributeQuietCommit(HF_AUDIO_AUTOMATION_ATTR, value, coalesce);
+ }
// Dragging a point writes live: no preview refresh, so the composition
// does not reload and restart playback on every pixel.
- else void domEdit.handleDomAttributeLiveCommit(HF_AUDIO_AUTOMATION_ATTR, value || null);
+ else {
+ // Preview only, because a gesture writes on every pointermove: the
+ // preview and the running audio follow the pointer, and the release
+ // below is the one write that reaches the file and the undo stack.
+ void domEdit.handleDomAttributeLiveCommit(
+ HF_AUDIO_AUTOMATION_ATTR,
+ value || null,
+ undefined,
+ {
+ coalesce,
+ previewOnly: true,
+ },
+ );
+ }
};
return {
@@ -121,9 +144,15 @@ export function useAutomationLanes(): UseAutomationLanesResult {
readOnly: !domEdit || !isSelected,
commitTargetKey: domEdit ? commitTargetKey : null,
selection: automationSelection?.elementKey === elementKey ? automationSelection : null,
- onRangeSelect: (target, t0, t1) => {
- if (!domEdit || !isSelected) return;
- setAutomationSelection({ elementKey, target, t0, t1 });
+ // Not gated on `isSelected`, unlike the writes above. A selection is
+ // ephemeral store state, and the drag that draws one on a read-only lane is
+ // the same press that selects the clip — refusing it here meant the first
+ // drag on a lane silently did nothing and the author had to drag again.
+ // Nothing can be written through it while the lane is read-only: every
+ // consumer resolves the binding again and finds `readOnly`.
+ onRangeSelect: (target, t0, t1, v0, v1) => {
+ if (!domEdit) return;
+ setAutomationSelection({ elementKey, target, t0, t1, v0, v1 });
},
onRangeClear: () => clearAutomationSelection(),
};
diff --git a/packages/studio/src/player/components/useAutomationRangeDrag.ts b/packages/studio/src/player/components/useAutomationRangeDrag.ts
new file mode 100644
index 0000000000..4429bc32bd
--- /dev/null
+++ b/packages/studio/src/player/components/useAutomationRangeDrag.ts
@@ -0,0 +1,114 @@
+/**
+ * Drawing a new selection box over an automation lane's empty background.
+ *
+ * Its own module for the same reason useAutomationEdgeStretch is: a sixth
+ * mutually-exclusive gesture on a hook already at the file's complexity
+ * budget, and one with no use for anything the others snapshot.
+ */
+
+import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
+import { snapLaneTime } from "./automationLaneGeometry";
+import { capturePointer } from "./automationLanePointer";
+import { SNAP_SEC } from "./automationLaneDragMath";
+
+export interface UseAutomationRangeDragInput {
+ pointAt(clientX: number, clientY: number): { t: number; v: number };
+ xOf(t: number): number;
+ yOf(v: number): number;
+ duration: number;
+ snapTimes?: readonly number[] | undefined;
+ onRangeSelect?: ((t0: number, t1: number, v0: number, v1: number) => void) | undefined;
+ onRangeClear?: (() => void) | undefined;
+}
+
+export interface UseAutomationRangeDragResult {
+ /** Whether a box is being drawn right now. */
+ dragging: boolean;
+ /** Arms a new box at the press, or declines when no caller wants to hear
+ * about one (a read-only lane). */
+ arm(e: ReactPointerEvent): void;
+ move(e: ReactPointerEvent): void;
+ /** A sub-threshold press clears the selection rather than leaving a
+ * zero-width one behind. */
+ finish(): void;
+}
+
+/**
+ * A corner of the selection box under the pointer.
+ *
+ * Time is clamped to the clip and snaps to the grid, because the box's span is
+ * also what a shape insert or a paste acts over and those want beat-aligned
+ * edges. The value is taken as it lies: there is no grid on a parameter axis,
+ * and rounding a dB bound would move which points the box catches.
+ */
+function boxCornerAt(
+ e: ReactPointerEvent,
+ input: Pick,
+): { t: number; v: number } {
+ const raw = input.pointAt(e.clientX, e.clientY);
+ const clamped = Math.min(input.duration, Math.max(0, raw.t));
+ return {
+ t: e.altKey ? clamped : snapLaneTime(clamped, input.snapTimes ?? [], SNAP_SEC),
+ v: raw.v,
+ };
+}
+
+export function useAutomationRangeDrag({
+ pointAt,
+ xOf,
+ yOf,
+ duration,
+ snapTimes,
+ onRangeSelect,
+ onRangeClear,
+}: UseAutomationRangeDragInput): UseAutomationRangeDragResult {
+ const [box, setBox] = useState<{
+ from: { t: number; v: number };
+ to: { t: number; v: number };
+ } | null>(null);
+ /** Whether the live drag has crossed the pixel threshold that turns a press
+ * into an actual box, rather than a click that should just clear one. */
+ const crossed = useRef(false);
+
+ const arm = useCallback(
+ (e: ReactPointerEvent): void => {
+ if (!onRangeSelect) return;
+ e.preventDefault();
+ capturePointer(e);
+ const corner = boxCornerAt(e, { pointAt, duration, snapTimes });
+ crossed.current = false;
+ setBox({ from: corner, to: corner });
+ },
+ [onRangeSelect, pointAt, duration, snapTimes],
+ );
+
+ const move = useCallback(
+ (e: ReactPointerEvent): void => {
+ if (box === null) return;
+ const to = boxCornerAt(e, { pointAt, duration, snapTimes });
+ const { from } = box;
+ setBox({ from, to });
+ const travelled = Math.max(
+ Math.abs(xOf(to.t) - xOf(from.t)),
+ Math.abs(yOf(to.v) - yOf(from.v)),
+ );
+ if (travelled <= 3) return;
+ crossed.current = true;
+ onRangeSelect?.(
+ Math.min(from.t, to.t),
+ Math.max(from.t, to.t),
+ Math.min(from.v, to.v),
+ Math.max(from.v, to.v),
+ );
+ },
+ [box, pointAt, duration, snapTimes, xOf, yOf, onRangeSelect],
+ );
+
+ const finish = useCallback((): void => {
+ if (!crossed.current) onRangeClear?.();
+ crossed.current = false;
+ setBox(null);
+ }, [onRangeClear]);
+
+ return { dragging: box !== null, arm, move, finish };
+}
diff --git a/packages/studio/src/player/lib/automationStoreSync.test.ts b/packages/studio/src/player/lib/automationStoreSync.test.ts
new file mode 100644
index 0000000000..78148dbb35
--- /dev/null
+++ b/packages/studio/src/player/lib/automationStoreSync.test.ts
@@ -0,0 +1,83 @@
+// @vitest-environment happy-dom
+import { beforeEach, describe, expect, it } from "vitest";
+import { syncStoredAutomationFromPreview } from "./automationStoreSync";
+import { usePlayerStore, type TimelineElement } from "../store/playerStore";
+
+const TWO_POINTS = '{"version":1,"lanes":[{"target":"volume","points":[{"t":0,"v":1}]}]}';
+const RESTORED =
+ '{"version":1,"lanes":[{"target":"volume","points":[{"t":0,"v":1},{"t":4,"v":0}]}]}';
+
+const el = (over: Partial = {}): TimelineElement => ({
+ id: "bgm",
+ key: "bgm",
+ tag: "audio",
+ start: 0,
+ duration: 10,
+ track: 1,
+ domId: "bgm",
+ ...over,
+});
+
+/** A stand-in preview document carrying the attributes an undo would have written. */
+function previewWith(attrs: Record): Document {
+ const doc = document.implementation.createHTMLDocument("preview");
+ const audio = doc.createElement("audio");
+ audio.id = "bgm";
+ for (const [name, value] of Object.entries(attrs)) audio.setAttribute(name, value);
+ doc.body.append(audio);
+ return doc;
+}
+
+beforeEach(() => {
+ usePlayerStore.getState().reset();
+});
+
+describe("syncStoredAutomationFromPreview", () => {
+ it("reads back an envelope an undo restored on the preview", () => {
+ // The bug: a soft undo patches the preview document and re-runs the timeline, but
+ // the store keeps its own copy of the attributes and that copy is what a lane
+ // draws — so an undone delete stayed invisible until the page was reloaded.
+ usePlayerStore.setState({ elements: [el({ automation: TWO_POINTS })] });
+ syncStoredAutomationFromPreview(previewWith({ "data-automation": RESTORED }));
+ expect(usePlayerStore.getState().elements[0]?.automation).toBe(RESTORED);
+ });
+
+ it("reads back an undone FX chain too, since a lane's targets come from it", () => {
+ const chain = '{"version":1,"nodes":[{"type":"lowpass","id":"n1","params":{}}]}';
+ usePlayerStore.setState({ elements: [el()] });
+ syncStoredAutomationFromPreview(previewWith({ "data-fx-chain": chain }));
+ expect(usePlayerStore.getState().elements[0]?.fxChain).toBe(chain);
+ });
+
+ it("clears an attribute the undo removed", () => {
+ usePlayerStore.setState({ elements: [el({ automation: TWO_POINTS })] });
+ syncStoredAutomationFromPreview(previewWith({}));
+ expect(usePlayerStore.getState().elements[0]?.automation).toBeUndefined();
+ });
+
+ it("finds the node by data-hf-id when the dom id does not match", () => {
+ // Studio stamps hf-ids; an element discovered under a suffixed dom id still has
+ // to resolve, or the sync silently skips it.
+ const doc = document.implementation.createHTMLDocument("preview");
+ const audio = doc.createElement("audio");
+ audio.setAttribute("data-hf-id", "hf-snao");
+ audio.setAttribute("data-automation", RESTORED);
+ doc.body.append(audio);
+ usePlayerStore.setState({ elements: [el({ domId: "bgm-2", hfId: "hf-snao" })] });
+ syncStoredAutomationFromPreview(doc);
+ expect(usePlayerStore.getState().elements[0]?.automation).toBe(RESTORED);
+ });
+
+ it("keeps the same array when nothing changed, so nothing re-renders", () => {
+ usePlayerStore.setState({ elements: [el({ automation: RESTORED })] });
+ const before = usePlayerStore.getState().elements;
+ syncStoredAutomationFromPreview(previewWith({ "data-automation": RESTORED }));
+ expect(usePlayerStore.getState().elements).toBe(before);
+ });
+
+ it("does nothing without a preview document", () => {
+ usePlayerStore.setState({ elements: [el({ automation: TWO_POINTS })] });
+ syncStoredAutomationFromPreview(null);
+ expect(usePlayerStore.getState().elements[0]?.automation).toBe(TWO_POINTS);
+ });
+});
diff --git a/packages/studio/src/player/lib/automationStoreSync.ts b/packages/studio/src/player/lib/automationStoreSync.ts
new file mode 100644
index 0000000000..02894699df
--- /dev/null
+++ b/packages/studio/src/player/lib/automationStoreSync.ts
@@ -0,0 +1,56 @@
+/**
+ * Keeping the player store's automation attributes true.
+ *
+ * The store is what a lane draws from, and it is populated by element discovery — a
+ * message from the preview runtime, which only arrives on load. Anything that edits
+ * an envelope afterwards writes to the preview document and the source file, and the
+ * store would go on holding the value it was born with until a reload.
+ *
+ * One reader, called from the two places a change lands: the resync every dom-edit
+ * attribute commit already runs, and the soft restore an undo or redo applies. It
+ * reads the preview rather than being told, because those callers know a file
+ * changed, not which attribute — and because three separate writers shipped without
+ * remembering to sync, which is what a single sink prevents.
+ */
+
+import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
+import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
+import { usePlayerStore, type TimelineElement } from "../store/playerStore";
+
+/** The preview node an element stands for, by dom id and then by `data-hf-id`. */
+function previewNodeFor(doc: Document, element: TimelineElement): Element | null {
+ const domId = element.domId ?? element.id;
+ const byId = domId ? doc.getElementById(domId) : null;
+ if (byId) return byId;
+ return element.hfId ? doc.querySelector(`[data-hf-id="${element.hfId}"]`) : null;
+}
+
+/**
+ * Re-read every element's automation and FX-chain attributes from the preview
+ * document, for a change that reached the DOM without going through this store.
+ *
+ * That is undo and redo. A soft restore patches the reverted attributes onto the
+ * live preview and re-runs the timeline — deliberately, so the frame does not blank
+ * — but the store it does not touch is the one the lanes read, so an undone delete
+ * stayed invisible until a reload. A full restore already clears the store and waits
+ * for discovery, so it needs nothing from here.
+ *
+ * Reads rather than being told: an undo restores whole files, so the attribute it
+ * reverted is only known by looking.
+ */
+export function syncStoredAutomationFromPreview(doc: Document | null | undefined): void {
+ if (!doc) return;
+ usePlayerStore.setState((state) => {
+ let changed = false;
+ const elements = state.elements.map((element) => {
+ const node = previewNodeFor(doc, element);
+ if (!node) return element;
+ const automation = node.getAttribute(HF_AUDIO_AUTOMATION_ATTR) ?? undefined;
+ const fxChain = node.getAttribute(HF_AUDIO_FX_ATTR) ?? undefined;
+ if (automation === element.automation && fxChain === element.fxChain) return element;
+ changed = true;
+ return { ...element, automation, fxChain };
+ });
+ return changed ? { elements } : {};
+ });
+}
diff --git a/packages/studio/src/player/store/automationSelectionSlice.test.ts b/packages/studio/src/player/store/automationSelectionSlice.test.ts
index cb1af6c155..7229fcf3a9 100644
--- a/packages/studio/src/player/store/automationSelectionSlice.test.ts
+++ b/packages/studio/src/player/store/automationSelectionSlice.test.ts
@@ -2,12 +2,28 @@ import { describe, expect, it } from "vitest";
import { usePlayerStore } from "./playerStore";
describe("automationSelectionSlice", () => {
- it("stores one ordered selection and clears it", () => {
+ it("stores one ordered selection box and clears it", () => {
const store = usePlayerStore.getState();
- store.setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 2, t1: 1 });
+ // Dragged up and to the left: both axes arrive backwards.
+ store.setAutomationSelection({
+ elementKey: "bgm",
+ target: "volume",
+ t0: 2,
+ t1: 1,
+ v0: 0.8,
+ v1: 0.3,
+ });
const sel = usePlayerStore.getState().automationSelection;
- // Ordered on write, so every consumer can assume t0 < t1.
- expect(sel).toEqual({ elementKey: "bgm", target: "volume", t0: 1, t1: 2 });
+ // Ordered on write, so every consumer can assume t0 <= t1 and v0 <= v1 —
+ // which is what lets a point test be two range checks rather than four.
+ expect(sel).toEqual({
+ elementKey: "bgm",
+ target: "volume",
+ t0: 1,
+ t1: 2,
+ v0: 0.3,
+ v1: 0.8,
+ });
usePlayerStore.getState().clearAutomationSelection();
expect(usePlayerStore.getState().automationSelection).toBeNull();
});
diff --git a/packages/studio/src/player/store/automationSelectionSlice.ts b/packages/studio/src/player/store/automationSelectionSlice.ts
index 8af1440237..d59ca83d20 100644
--- a/packages/studio/src/player/store/automationSelectionSlice.ts
+++ b/packages/studio/src/player/store/automationSelectionSlice.ts
@@ -1,5 +1,5 @@
/**
- * The active time selection on one automation lane.
+ * The active selection box on one automation lane.
*
* A store slice, not lane-local state, for the same reason keyframe selection
* is one: Delete/copy/paste handlers and the shape menu live outside the lane
@@ -13,9 +13,19 @@ export interface AutomationSelection {
elementKey: string;
/** Lane target: "volume" or "fx.. ". */
target: string;
- /** Clip-local seconds; always t0 < t1 (ordered on write). */
+ /** Clip-local seconds; always t0 <= t1 (ordered on write). */
t0: number;
t1: number;
+ /**
+ * The box's value bounds, in the parameter's own units; always v0 <= v1.
+ *
+ * What makes the selection a box rather than a time span: a point is caught
+ * only if it falls inside both axes. Span operations — copy, paste, shape
+ * insert, simplify — still read t0/t1 alone, because they act on the envelope
+ * over a stretch of time rather than on a set of breakpoints.
+ */
+ v0: number;
+ v1: number;
}
export interface AutomationSelectionSlice {
@@ -31,7 +41,13 @@ export function createAutomationSelectionSlice(
automationSelection: null,
setAutomationSelection: (sel) =>
set({
- automationSelection: sel.t0 <= sel.t1 ? sel : { ...sel, t0: sel.t1, t1: sel.t0 },
+ automationSelection: {
+ ...sel,
+ t0: Math.min(sel.t0, sel.t1),
+ t1: Math.max(sel.t0, sel.t1),
+ v0: Math.min(sel.v0, sel.v1),
+ v1: Math.max(sel.v0, sel.v1),
+ },
}),
clearAutomationSelection: () => set({ automationSelection: null }),
};
From 7d38fed97fd50f70e5a67229250a186baac98d3c Mon Sep 17 00:00:00 2001
From: Vance Ingalls
Date: Thu, 13 Aug 2026 01:31:57 -0700
Subject: [PATCH 4/4] feat(studio): show every automated knob at the playhead
(#3210)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(studio): pure range ops for automation lane selections
Add pointsIn() and replaceRange() functions for managing automation envelope
edits within a time range. The key invariant: envelope values outside the
selection never move. Implemented by anchoring the boundaries at t0 and t1
by sampling the original lane, so cutting middle sections cannot reshape
the rest. Inner points from shape generators can suppress redundant anchors
at merge distance.
* fix(studio): budget replaceRange's inner points before capping, not after
* feat(studio): automation selection slice
* feat(studio): drag-select a time range on an automation lane
Dragging on an automation lane's empty background now arms a range
selection, snapped to the beat grid and clamped to the lane duration; a
sub-3px drag counts as a click and clears instead. Point drags and
Alt-drag segment bends still take priority, since the range arm only
runs where the existing point/segment hit-test already returned null.
useAutomationLanes binds the selection slice per element/lane so the
rect renders from the store, matching the read pattern the writes
already use.
* feat(studio): delete an automation selection from the keyboard
Escape clears the active automation-lane time selection; Delete/Backspace
empties it via replaceRange(..., inner: []), which pins anchor points at
both edges and leaves the envelope outside the selection untouched. Mounted
in TimelineLanes.tsx next to the useAutomationLanes() call that already
lives there. Also adds a stale-selection guard in TimelineAutomationLaneSlot
that clears the selection if its lane's target stops existing on the bound
element's automation (e.g. the automated effect was deleted).
* test(studio): cover the automation selection stale-target guard
* feat(studio): ramp, swell and dip generators for automation selections
* fix(studio): let an automation range keep Delete from the clip
useAppHotkeys listens on window/capture, so it runs before
useAutomationSelectionKeyboard's document/capture handler. With a range
selected, Delete fell straight through to the clip-delete branch and
destroyed the whole audio clip the lane belongs to; Backspace hit the
reset-keyframes branch on the way and wiped the clip's keyframes.
Guard both by returning early when automationSelection is set, mirroring
the selectedKeyframes precedent six lines above. No preventDefault: the
downstream handler still needs the key.
dispatchPlainKey is exported so the arbitration between keyframes, an
automation range and the clip can be pinned without standing up the hook.
Co-Authored-By: Claude Opus 5 (1M context)
* chore: suppress unused export for automationShapes (consumed upstack)
* feat(studio): simplify dense automation runs
Implements Ramer-Douglas-Peucker point-thinning for audio automation
lane breakpoints, working in unit space for correct log-scaled
parameter handling.
Co-Authored-By: Claude Sonnet 5
* chore: add automationSimplify to fallow complexity ignore
The Ramer-Douglas-Peucker algorithm in automationSimplify.ts has
inherent complexity (12 cyclomatic / 20 cognitive) that is by design
and not refactorable. Added to health.ignore list and ignoreExports
list since it's consumed by the UI layer one PR upstack.
Co-Authored-By: Claude Sonnet 5
* feat(studio): internal clipboard for automation ranges
* feat(studio): copy and paste automation ranges across lanes
Extends the automation-selection keyboard hook with Cmd/Ctrl+C (copy the
active range) and Cmd/Ctrl+V (paste onto the selected clip's lane, at the
selection's start or the playhead, chaining the selection to the pasted
span so a second paste lands right after the first). Paste falls through
untouched when no target lane resolves, so clip-level paste keeps working.
Also fixes a latent test-isolation bug: setup() never unmounted the
previous test's Host, so document keydown listeners leaked across tests
and could consume later events before the current test's own listener ran.
* feat(studio): shape and simplify menu on an automation selection
Right-click inside an active time-selection rectangle on an automation
lane now opens a menu offering the four utility shapes (Ramp up, Ramp
down, Swell, Dip) and Simplify, composing generateShape/simplifyPoints
with pointsIn/replaceRange from the prior selection tasks. A point's own
right-click still stops propagation and deletes it, unaffected.
* feat(studio): retime an automation selection
Add retimeRange pure operation that scales interior points proportionally
into a new time span, then uses replaceRange to update the lane while
preserving the envelope outside the union of old and new ranges.
Co-Authored-By: Claude Sonnet 5
* fix(studio): repair the automation paste path and finish the key arbitration
Paste was the least safe path in this feature: it resolved its target from
the player store but committed through a different, asynchronously-lagging
channel. Six review findings against this branch, plus the Cmd+C/Cmd+V half
of the arbitration wa-15 started for Delete.
- Write channel: resolvePasteTarget bails unless the binding's
commitTargetKey equals the element it resolved. useAutomationLanes exposes
that key, resolved through resolveTimelineIdForSelection — the same
resolver applyDomSelection uses — and read in the same render as the
commit handlers, so a handler and the key cannot describe different
moments. Before this, clicking clip B then immediately pasting serialized
B's automation onto A and left B untouched.
- Chaining: the paste anchor comes from sel.t1, not sel.t0, so a second
Cmd+V lands after the first instead of on top of it. The old comment
claimed the new behaviour while the code did the opposite, and no test
pressed Cmd+V twice.
- Empty copy: copyRange returns false rather than arming a clipboard whose
every paste is a destructive flatten, and samples the range's edges so
copying a smooth stretch yields a real segment instead of no points.
- Playhead: the playhead branch requires the playhead to be inside the clip
rather than silently clamping an out-of-clip playhead to the clip's start.
- Keys: one chord helper normalizes with toLowerCase() and gates on
!shiftKey && !altKey, matching useAppHotkeys. CapsLock no longer kills the
shortcut and Ctrl+Alt+V no longer pastes where the app declines.
- Arbitration: useAppHotkeys consults automationOwnsKey before its c/v
branch, so an active range keeps Cmd+C/Cmd+V from the clip clipboard the
same way it keeps Delete. Without it Cmd+V duplicated the clip while the
automation paste wrote the same file, and Cmd+C armed both clipboards.
It returns without preventDefault — the downstream handler needs the key —
and declines when the automation clipboard is empty so clip paste still
works. dispatchModifierKey is exported to pin this, like dispatchPlainKey.
- Double-action: the hook now returns early on e.defaultPrevented.
useAppHotkeys is on window/capture and deliberately lets a keyframe
selection outrank a range on Delete; without this that press deleted the
keyframes there AND emptied the range here.
- Project scoping: the clipboard scopes itself. Every entry point carries the
project it speaks for and a mismatch empties the module, the shape
keyframeSlice already uses to discard a request from a previous session.
Scoping it inside the module rather than clearing it from the session seam
is deliberate: the failure is silent and destructive — a range copied in
project A pasted into B is remapped through A's captured sourceRange for an
FX node B may not have, and the keystroke is consumed so clip paste never
runs — so no future caller should be able to forget the guard. The mark
isLastPasteSpan reads is scoped transitively, through the same check.
- Session reset: createTimelineResetState clears automationSelection. It is
as ephemeral as selectedKeyframes, and a range surviving a project switch
can match a same-keyed clip in the new project and redirect a paste through
sel.elementKey === paste.elementKey to a stale t0.
Five of the six paste fixes above shipped without a test that fails without
them, which is how the branch reached review with a comment describing
chaining that the code did not do. Each now has one: a second Cmd+V landing
after the first, a commit-target mismatch declining, an out-of-clip playhead
declining, an empty-lane copy leaving an earlier clipboard intact, and Cmd+V
with CapsLock on. All five fail against this branch's parent.
* test(studio): probe retimeRange's actual guarantee, not sample-continuity past a moved edge
The failing test probed t=5.1, which sits inside the reshaped transition
segment between the new edge (t=5) and the existing point (t=6). When
growing past an existing breakpoint, the transition TO that point
legitimately reshapes — the edge moved (t=3→t=5) even though the far
point (t=6) did not.
The real guarantee: all BREAKPOINTS strictly outside the union keep
exact (t, v) values. Corrected test to:
1. Verify sample continuity on unaffected side: t=[0,1,1.9]
2. Verify the breakpoint at t=6 keeps exact value: (t:6, v:0)
Co-Authored-By: Claude Sonnet 5
* feat(studio): stretch an automation selection by its edges
Add an edge-handle drag to a selection's rect: grabbing within 8px of
either edge retimes the selection via the already-landed retimeRange,
scaling interior points proportionally and clamping the dragged edge
against its partner and the clip's duration. Priority is point-drag >
curve-drag > edge-stretch > new-range-select, so a point sitting on an
edge still wins the press. Cursor shows col-resize while hovering or
dragging a handle.
Co-Authored-By: Claude Sonnet 5
* fix(studio): retime edge-stretch from a fixed points snapshot
moveEdge fed retimeRange the live draft on every pointermove while
origin.t0/t1 stayed pinned to the drag's start. retimeRange is a
relative transform that scales a lane's own current point positions,
so repeated pointermoves compounded the scale factor (interior points
drift toward the far edge) and could drop points that retimed past the
selection's original bound out of the next move's `inner` set entirely.
Snapshot lane.points at arm time (armBackgroundGesture) alongside the
existing frozen origin, and always retime from that snapshot in
moveEdge instead of the live draft. finishEdgeDrag is unchanged: it
already just persists the last (now-correct) preview.
Adds a regression test asserting a multi-pointermove edge-drag (both
edges) lands on the exact same final points as a single-shot drag to
the same target — the case that exposed the bug, since the existing
suite only ever tested a single move.
* fix(studio): clamp selection-start paste, sharpen clipboard test, cleanup
- useAutomationSelectionKeyboard: clamp the selection-start paste branch
to [0, element.duration - clip.span], same as the playhead branch
already does. An unclamped paste near a clip's end could write points
past element.duration and leave the resulting selection's edge
ungrabbable off the visible lane.
- automationClipboard.test.ts: swap the cross-parameter mapping test's
target from fx.r.wet (numerically identical to VOLUME_RANGE) to the
log-scaled fx.n1.frequency, so the test actually discriminates real
unit-space mapping from a linear guess or a verbatim value copy.
- automationLaneSelection.ts: drop the lone `!` non-null assertion in
decimateEvenly's budget-of-1 branch for a guarded pattern, matching
the loop right below it and the repo's no-`!` convention.
- .fallowrc.jsonc: remove the two ignoreExports entries for
AUTOMATION_SHAPES and simplifyPoints — both are now genuinely
consumed (AutomationSelectionMenu.tsx, TimelineAutomationLane.tsx).
- AutomationSelectionMenu.tsx: port TrackGapContextMenu's viewport-edge
clamping so a right-click near the bottom/right of the timeline
doesn't render the shape/simplify menu partially off-screen.
* fix(studio): give edge-stretch the gesture contract the other four follow
Seven review findings against this branch, five of which were one defect:
edge-stretch was added as a fifth mutually-exclusive gesture on the lane
without joining the threshold / live-preview / revert-on-cancel contract the
point drag, curve bend, range drag and double-click all obey. Patching them
one at a time would have been more code and less coherent, so this makes the
stretch structurally parallel to its sibling range drag instead, and extracts
it to useAutomationEdgeStretch on the way out — the gestures file had ~60
lines of headroom under the 600-line studio cap, and shaving comments to fit
a refactor in is not a plan.
- Threshold. A press within the 8px halo of either edge used to persist a
no-op commit and push an undo entry that changed nothing (commitDataAttribute
has no unchanged-value short-circuit). Worse, it made the pre-existing "click
the background to clear the selection" escape unreachable anywhere near an
edge. Below 3px of travel — the same threshold the range drag uses — the
press now clears the selection and writes nothing at all.
- Live preview. moveEdge never fired onRangeSelect and the hook discarded the
drag's live position, so the highlight rect and both edge lines stayed pinned
at the pre-drag bounds for the whole gesture and snapped into place on
release: the user dragged an invisible handle. It now reports bounds on every
move, exactly as the marquee drag does and for the same reason.
- Revert on cancel. pointercancel means the browser abandoned the gesture; it
was routed to the same handler as pointerup, which persisted whatever partial
retime it had reached. It now restores the arm-time snapshot through the
preview channel — there is nothing persisted to undo — and puts the selection
back. A new cancelDrag handler owns that, so a release and an abandonment are
no longer the same event.
- Lost capture. capturePointer took the capture on e.target, i.e. whichever
child the press landed on. A child that unmounts mid-drag takes the capture
with it, silently, with no pointercancel — after which edgeDrag stayed
non-null and every later button-less pointermove kept retiming and writing.
Capture is now taken on the svg, which outlives every gesture on it, and a
move reporting no buttons held ends the drag as a cancel.
- Hit priority. A breakpoint sitting exactly on the selection's edge used to
win the press. Since replaceRange pins an anchor at the union bound and
finishEdgeDrag leaves the selection edge at that same time, EVERY range
operation — stretch, delete, shape insert — leaves a point exactly on the
edge it just created: the second stretch of the same edge resolved to a
point-drag, at the one height (on the envelope) where a user naturally grabs
it. The feature was not repeatable. An active selection's edge now outranks a
point on it; clearing the selection reaches the point again, which is tested.
- Clamp order. The dragged edge was bounded against its partner AFTER the
0-floor, so a selection thinner than the minimum width yielded a negative t0,
which core's cleanPoint then collapses onto a duplicate t=0 on the serialize
round-trip — silent envelope corruption. The floor is now applied last. The
minimum width is its own MIN_SELECTION_SEC rather than a borrowed
POINT_MERGE_SEC: when two breakpoints are the same breakpoint is a different
question from how thin a time selection may get.
One finding does not survive: edgeAt's `d0 <= d1` tiebreak was reported as
making the t1 edge ungrabbable on a narrow selection, but that comparison IS
nearest-wins, and a press right of the midpoint already resolved to t1. The
midpoint split here is the same rule written so it is legible rather than
inferred, and the test for it is labelled as characterizing behaviour, not
fixing it. What was genuinely unreachable inside a narrow halo — starting a
fresh range, or clearing the old one without Escape — the threshold above fixes.
Also settles what retimeRange does with a breakpoint sitting ON a dragged
edge, which was never decided: pointsIn is endpoint-inclusive, so it is
interior and travels with the stretch. It has to be, because the commonest
stretch of all is grabbing an edge to drag exactly that point outward, and
anchoring it would delete it and flatten the span instead. The price is that
the retimed point lands on the union's own boundary where a preservation anchor
would go, and anchor() stands down within a merge radius — one time cannot hold
two values — so the segment leaving the union reshapes. That is the one place
replaceRange's outside-never-moves invariant bends, and both halves are now
pinned: the exact points and the sampled slope for the on-edge case, and the
full two-sided invariant for a selection whose edges are off any breakpoint.
The earlier right-side probe at t=5.1 that caught this was deleted during
development as inherent; it was reporting the real behaviour.
* fix(core): make audio automation survive being rescheduled mid-playback
Anything landing inside a running value curve is refused unless the parameter is
cancelled first, and two paths were not cancelling: the chain observer wrote each
knob straight onto its AudioParam before rescheduling, and a bent segment read as
straight because only the curve exponent was checked, never the via point the
timeline actually writes. The first threw NotSupportedError into the console and
abandoned the rest of the envelope; the second played a dragged bend as a ramp.
Measured against Chrome, in a live context and in an offline one suspended
mid-curve: any cancel frees the span, and only a missing cancel is refused.
clearParamLane takes the strongest form on purpose, because curve-over-curve
refusals were reported with a cancel at the new schedule time already in place
and have never reproduced; emit keeps a ramp fallback as the backstop for
whatever that mechanism turns out to be.
Dynamic carve is what exercises all of it, so it lands here too:
- a `gain` primitive, so a carve can match levels as well as carve bands
- carve settings collapse to one `strength`, with carveProfile deriving the six
numbers that always moved together anyway
- analyseCarveDynamics / analyseCarveDuck turn the analysis into envelopes, with
a slow release so the bed does not snap back the instant a word ends
- worklets are awaited inside attach, so adding a compressor to a carved bed no
longer kills its envelopes and freezes every later edit
- per-track failure detail in the render's audio stage, which was being discarded
Co-Authored-By: Claude Opus 5 (1M context)
* feat(studio): select automation points with a box, and stop them crossing
Replaces the time-range selection with a rectangle. A lane selection is a set of
breakpoints, not a span, so it now has value bounds as well as time bounds and a
point is caught only if it falls inside both — which is what lets you take the
peaks of an envelope and leave the dips between them. Delete, the group drag and
the rings drawn on caught points all read the one rule, so what looks selected is
exactly what those act on. Copy, paste, shape insert and simplify still work on
the box's time span, because they act on the envelope over a stretch of time.
Dragging is bounded by its neighbours in both the single and group cases. A point
cannot cross another, and cannot land exactly on one either: the lane collapses
points that share a `t`, keeping the later one, so arriving on top of a neighbour
deleted it. It stops a millisecond short, which is under a pixel at any zoom the
lane offers and keeps both points. Only stationary neighbours constrain a group,
per member rather than per end, since a box can select a non-contiguous set.
Edge-stretch is removed rather than fixed. Dragging a selection's edges to retime
the points inside it was the feature this branch opened for, and it is not wanted:
the hook, retimeRange, the edge handles, the col-resize cursor and the pointercancel
revert path all go, along with the ~360 lines of tests that pinned them.
Also: gesture-scoped coalescing keys, so one drag is one undo entry rather than a
fragmented chain of them.
Co-Authored-By: Claude Opus 5 (1M context)
* fix(studio): put #3207's edge-stretch back, folded into the unified hook
The review blocks this PR for deleting a feature two PRs downstack:
"#3209 deletes #3207 edge-stretch instead of folding it into the unified
hook... merging the stack would ship #3207 and then silently remove its
user-facing retime/edge-stretch feature."
Verified, all four claims: `useAutomationEdgeStretch.ts` (248 lines) and its
test were deleted, `retimeRange` was dropped from
`automationLaneSelection.ts`, and the consolidated hook has no edge
hit-test, arm/move/finish path, or resize cursor.
Restored: the module, `retimeRange`, the selection regressions, and the
lane wiring (`col-resize` cursor, `pointercancel` reverting a partial
retime rather than persisting it).
It is not a straight revert, because #3209 changed the selection from a
time range to a box. Edge-stretch now takes `{t0,t1,v0,v1}` and moves only
the time edges — the value extent rides through untouched, which keeps it
the same gesture it was.
**One arbitration call worth a second opinion.** #3207's rule was that a
selection's edge outranks a point sitting on it, because every range
operation leaves a breakpoint exactly on the edge it created — a
point-first rule made the second stretch of an edge resolve to a
point-drag. Under a box that rule now contradicts #3209's own test
("stops the group at a point it did not select"), which presses at t=0
v=1 — simultaneously the t0 edge and a selected point.
I inverted it: selected content wins, the edge stretches everywhere it is
not also selected content. The reasoning is that a box makes the point
visibly part of the selection, and dragging selected content has to move
it. That restores #3209's test and keeps the stretch usable along the rest
of the edge — but it is a product decision between two deliberate designs,
so flag it if #3207's original precedence was load-bearing.
954 player tests pass, including the 17 restored ones.
* feat(studio): show every automated knob at the playhead, and carve as one module
An automated parameter has two values: the number sitting in the chain, which is
only the seed a lane replaced, and the number the envelope is on right now. The
second is the true one, so the panel shows it — on the carve rack's readouts and
on every effect's own fader and number field. A rack that showed the seed stood
still while the carve was audibly working.
Off the clip it keeps sampling rather than falling back to the stored number: a
lane holds its first value backwards and its last forwards, so before the clip
starts it already knows what it will open on, and the stored seed is a value
nothing will ever play. Showing it made the fader jump the moment the clip came
under the playhead.
The playhead comes off the liveTime channel, throttled to 30 Hz — the RAF loop
deliberately keeps frames out of the store, so a panel watching only the store
would sit still for a whole take. PropertyPanel had that subscription inline;
it is now one shared hook with two callers.
Readouts reserve the width their parameter can need rather than what its current
value takes, because an updating value one character narrower shunted everything
after it sideways 30 times a second.
The carve's effects are presented as one module: an author switched on a carve,
and the peaking filters plus the level stage are how it is built, not six things
to remove one at a time. Opening it lists every member's settings as readouts,
since strength is what sets them. No carve control is offered on a track another
track already carves against — that track is the voice, not the bed.
Co-Authored-By: Claude Opus 5 (1M context)
* feat(engine): render audio FX in an OfflineAudioContext
Reads `data-fx-chain` off an audio element and runs the chain over the trimmed
WAV before volume automation is baked in — effects should see the raw signal,
and the envelope belongs on their output.
The processing happens in an OfflineAudioContext inside the headless browser
the engine already drives, running the same graph builders the studio previews
with. That is the point of the approach: one implementation per effect, so the
render agreeing with the preview is a property of the architecture rather than
a tolerance to police. Reimplementing each effect as an FFmpeg filter would
mean two implementations to keep in step, and for the dynamics processors and
modulated delays there is no filter that behaves the same way.
`build:audio-fx-runtime` bundles the graph builders into an injectable IIFE,
following the same pattern as the existing runtime artifacts, so the browser
runs exactly the code the studio does.
The page loads from a file:// URL rather than about:blank because AudioWorklet
is only exposed in a secure context — the compressor, limiter, gate and
bitcrush processors would otherwise fail to register with an opaque error.
file:// qualifies and needs no listening socket.
The chain is serialised into the attribute the way colour grading carries its
config, so there is no side-car file to resolve or lose.
An FX failure is fatal for the whole mix rather than a per-track soft failure.
Every other audio failure mode degrades gracefully — the track drops, siblings
continue — but substituting the dry signal for a processed one ships a render
that sounds plausible and is not what the author set up. Since the per-element
work races under Promise.all, an internal AbortController chained off the
caller's signal aborts in-flight siblings before workDir is removed.
* feat(core): voiceover carve analysis
Finds the bands a voice occupies so a music bed can be dipped there, letting
the voice sit in front without ducking the whole track.
Carve is a relationship between two tracks rather than an effect on one, so it
stays out of the FX chain. What it emits is an ordinary chain of peaking
filters, so a carve composes with whatever else is on the track and needs no
separate rendering path.
Selection is weighted toward intelligibility rather than raw voice energy.
Ranking purely by power lands on the fundamental almost every time, because
that is where a voice is loudest — but the masking that actually hurts a
voiceover happens higher up, and dipping 160 Hz mostly just thins the bed. The
bias is a control, not a constant: at 0 it follows raw energy, at 1 it weights
toward 1-3 kHz.
Ranking happens in dB, which matters more than it looks. Speech spreads 20-30 dB
across these bands — it falls off roughly 6 dB per octave above the fundamental
— so a weighting has to be on that scale to move anything at all. A
multiplicative weight of `1 - bias + bias * shaped` is bounded below by
`1 - bias`, capping its influence at 10*log10(1/(1 - bias)): 5.2 dB at the 0.7
default, 3 dB at 0.5. That is no influence against a real voice — every bias
short of ~0.95 would rank exactly like bias 0 and carve the fundamental, the
outcome the bias exists to prevent, while looking decisive against a fixture
whose bands sit 2 dB apart. So the bias is a dB penalty, zero at 2 kHz and worth
up to 30 dB at full strength, and relative cut depths come from a dB difference
rather than a ratio of weighted linear powers.
The bias reweights ranking without overriding the spectrum — a band the voice
has no energy in is not worth carving, and scores -Infinity rather than
competing — so a strongly low-pitched voice can still select low at full bias.
What the tests hold is that biasing never selects lower than the unbiased
ranking, that the DEFAULT bias reaches the presence region on a voice with a
realistic tilt, and that bias 0 still follows raw power exactly.
Includes a radix-2 FFT rather than a dependency; one Welch-style averaged
spectrum over third-octave bands does not justify pulling in a DSP library.
* fix(engine): keep the FX render 16-bit, stereo, and correctly sized
Three defects in the offline FX path, none of which any test could see.
**Float output silently disabled sample-accurate volume automation.** The writer
emitted 32-bit IEEE float; the very next mixer step bakes the volume envelope
into the samples and accepts only 16-bit PCM, returning null otherwise. So
enabling any effect downgraded that track to the ffmpeg expression path — capped
at 32 straight segments, quantising a curved envelope, and on a dense one falling
back to base volume. It now writes 16-bit PCM, clamped rather than wrapped so a
limiter at 0 dB or a resonant filter cannot turn overshoot into a click. A test
asserts the baker accepts the writer's own output and actually fades it.
**Everything was folded to mono.** `prepareAudioTrack` goes out of its way to
emit stereo — its pan filter exists to dodge ffmpeg's 3 dB mono-to-stereo
rematrix — and this folded it, then wrote one channel. So adding a single peaking
EQ collapsed a bed's width and cost ~3 dB in the render, while preview stayed
stereo. Channels now travel as one plane each, through an OfflineAudioContext of
the same width, and come back interleaved.
**Small results decoded the wrong length.** `new Float32Array(buf.buffer)`
discards byteOffset and byteLength, and Node pools small allocations: a 400-byte
payload sits at offset 8 inside an 8 KiB pool, so a clip under ~1024 samples
decoded as 2048 samples of unrelated memory — and the empty-result guard could
not see it. The reader has the mirror-image fix: a float data chunk on an odd
boundary (ffmpeg's pcm_f32le writes fmt(18) + fact, landing `data` at 58) now
copies instead of throwing RangeError on an unaligned view.
The tail limitation is now stated rather than mis-stated: the context is exactly
as long as the input, so a reverb or delay still ringing is cut there. The old
comment claimed the opposite. How far a tail may run past a clip's end changes
the clip's length in the mix, so it is a product decision, not one to make here.
Co-Authored-By: Claude Opus 5 (1M context)
* fix(producer): report an FX render failure as an audio error
`processCompositionAudio` reports per-track failures in its result, but an FX
failure it cannot degrade past — a browser that will not launch, a chain that
will not build — rejects instead. `runAudioStage` had no try, so that rejection
escaped to the orchestrator as an unclassified pipeline exception, losing the
stage/owner/retryable classification this stage exists to attach, and skipping
its abort check on the way out.
It now lands in `audioError` alongside every other cause, while an abort still
keeps its own shape rather than being reported as an audio problem.
Not done here: committing the generated `audio-fx-runtime-inline.ts` so a fresh
clone typechecks packages/engine without building first. The bundle is built from
the stub, and the stub changes three times across this stack — so the artifact
differs per branch and would conflict on every restack. Its model,
position-edits-render-inline.ts, is committed only because it is stable. Building
before testing is this monorepo's existing contract (studio's tests need core's
dist too), so the gap is not specific to audio FX and is better closed by a build
ordering gate than by committing a per-branch artifact.
Co-Authored-By: Claude Opus 5 (1M context)
* test(engine): skip the browser FX render cases when there is no browser
CI's `Test` job was red on this PR with four failures, all the same cause:
Failed to launch the browser process: spawn
/home/runner/.cache/hyperframes/chrome/chrome-headless-shell
The job installs ffmpeg and no browser, deliberately — every other suite
that needs an external binary already guards on it
(`describe.skipIf(!HAS_FFMPEG)`). These cases were the only ones assuming
a Chrome, so they failed on an absent dependency rather than on anything
about the code.
Guards on `resolveHeadlessShellPath()` — the same resolver
`acquireBrowser` launches through, so the check cannot drift from the
thing it guards the way a hard-coded cache path would. A configured path
that does not exist throws; that is caught and read as "cannot run here".
Checked both directions rather than just the green one: with a browser all
11 cases run and pass, and with `HYPERFRAMES_BROWSER_PATH` pointed at a
missing binary exactly 3 skip and the other 8 still run. A guard that
silently skipped everything would have looked identical in CI.
They keep their value where it exists — every developer machine, and any
job that has run `hyperframes browser ensure`.
Not touched: the CodeQL failure on this PR is a run from 2026-08-07, five
days and several force-pushes stale. None of the 17 open repo alerts are
in files this PR changes; it re-runs on this push.
* chore(engine): suppress the temp-file alert with the reason it is safe
CodeQL flags `writeWav`'s `writeFileSync` as js/insecure-temporary-file
(high) — the one new alert on #3021, and the reason its CodeQL check is
red.
It is a false positive, and the comment says why rather than just silencing
it: `path` is always inside a directory made by `mkdtempSync`, never a
name assembled directly under `tmpdir()`. Both callers are covered — the
browser host page writes into `mkdtempSync(join(tmpdir(), "hf-fx-host-"))`,
and the render output goes to the producer work dir, itself
`mkdtempSync(join(tempRoot, "producer-project-"))`. mkdtemp picks the
random suffix and creates the directory 0700 in one syscall, so the
predictable filename inside it cannot be pre-created or symlinked by
another user, which is the attack the rule is about. The analyzer sees the
dataflow reach `tmpdir()` and not the mkdtemp in between.
Suppressed inline rather than dismissed in the UI, so the justification
lives next to the code and the rule stays live for anything added later in
this file. Matches the repo's existing convention — `planV2.ts:222`
carries an `lgtm[js/insecure-temporary-file]` for a different reason on
the same rule.
Correcting myself: I first reported this alert as not real, having
intersected the PR's files against the default-branch alert list, which
does not contain PR-ref alerts. Querying ?ref=refs/pull/3021/merge returns
it straight away.
* test(engine): probe ffmpeg and Chrome instead of assuming them
Two failures on #3021's Test job, both about the environment rather than
the code under test.
**Bare `ffmpeg` is not on PATH in CI.** The 16-bit fixture shelled out to
`execFileSync("ffmpeg", ...)` and died with ENOENT. The job does provide
ffmpeg, through `prepare-ffmpeg-bin`, which is what `getFfmpegBinary()`
resolves — every other ffmpeg-dependent suite in this package already goes
through it. Now this one does too, and the case is `skipIf(!HAS_FFMPEG)`
so a contributor without ffmpeg skips rather than fails.
**The browser guard trusted the wrong thing.** It asked
`resolveHeadlessShellPath()` and treated a returned path as "a browser is
here". CI's cache holds a chrome-headless-shell that resolves and then
fails to spawn — a partial download is indistinguishable from a working
one by `existsSync`, which is all that resolver checks. So the three
browser cases ran anyway and failed on the launch.
It now runs `--version` and requires exit 0, which is the same probe the
ffmpeg suites use: ask the binary, do not infer from the filesystem.
Checked both directions rather than just the green one. With a working
browser all 11 cases run and pass; with `HYPERFRAMES_BROWSER_PATH` pointed
at a binary that exits non-zero — CI's exact situation — exactly 3 skip
and the other 8 still run. A guard that quietly skipped everything would
have looked identical on the CI summary.
* feat(core): register the audio-fx-rack canary at 0%
Lands the rollout switch dark, per the registry's own procedure: "Start at
percentage: 0 and merge that — a canary at 0 is dead code you can land
safely and ramp without a code review."
Declared at the bottom of the stack so every branch above can read it. The
gate itself goes in at wa-4-fx-panel, where the rack first appears.
Scope is deliberate and stated in the description: it gates the AUTHORING
surface only. A composition that already carries `data-fx-chain` still
plays and renders it. A canary should stage who can REACH a feature, not
make an attribute somebody already wrote silently inert — an agent that
writes a chain through the skill would otherwise produce a file whose audio
processing vanishes with no error.
* feat(studio): audio FX panel generated from the registry
Controls for the whole chain: add, remove, reorder, bypass, and every knob each
effect declares.
Nothing in the panel knows what a compressor is. The registry supplies each
parameter's range, step, unit and scale and the panel renders what it finds, so
adding an effect or a knob upstream needs no change here, and the panel cannot
offer a value the renderer would reject — a typed-in figure is clamped into the
declared range on the way through.
Frequency and time controls span three or four decades, so those declare a log
scale and the slider maps exponentially; a linear slider would spend most of
its travel somewhere useless.
Reorder is a first-class control because chain order changes the sound: a
reverb before a compressor is not the same as after.
Carve gets its own block rather than an entry in the add menu, with a picker
for the voice track to listen to. It processes this track based on another one,
which is how a sidechain control works — it lives on the track that changes,
and names the source.
* feat(studio): show the Audio FX section on audio tracks
Adds `audioFx` to the editing-affordances contract and renders the FX panel in
the inspector when an `` element is selected.
The section is audio-only. A `` carries its sound on a separate
`` element, so an FX chain on the video would have nothing to process.
Chain and carve settings are written straight back onto the element as
serialised attributes, the way colour grading carries its config, so
persistence is an ordinary attribute write and needs no new server route. A
chain that cannot be parsed renders as empty rather than breaking the panel,
and the attribute is left untouched until the user changes something.
The collapsed group summarises what is on the track ("2 effects + carve") so
the state is visible without expanding it.
Wired into PropertyPanelFlat rather than PropertyPanel: STUDIO_FLAT_INSPECTOR_ENABLED
defaults to true, so the flat inspector is what actually renders.
* refactor(studio): lift audioFxSummary out of PropertyPanelFlat
`PropertyPanelFlat.tsx` is 612 lines here against the repo's 600-line cap,
so the required File size check is red — the sole reason this PR is
blocked. The review says as much: "mechanical fix (~5 min), not a design
problem. Code itself is LGTM."
Moves `audioFxSummary` to `audioFxSummary.ts`, the same file a later
branch creates for it. Deliberately the smallest cut that clears the cap
rather than the whole `AudioFxGroup` extraction: every later commit in the
stack edits AudioFxGroup, so moving it here would collide with each of
them, while almost nothing touches this function.
595 lines.
* feat(core,studio): hear the FX chain in preview, and run the carve analysis
Splices an element's FX chain into the playback graph so preview stops being
silent about effects, and wires the carve button that was previously inert.
The chain goes between the decoded source and its gain stage: effects see the
raw signal and volume automation rides on their output, matching the order the
offline render uses. Since preview and render call the same graph builders,
what is heard while scrubbing is what gets written.
The splice lives in the transport rather than on the `` element. The
transport plays each track from a decoded AudioBuffer and mutes the element to
avoid doubling, so capturing the element with createMediaElementSource would
have processed a stream nothing is listening to — it looked like it worked
because the call succeeded, and the audio was unchanged.
A chain that cannot be built plays dry rather than silencing the track, which
is the right failure in preview: the author keeps working and hears the source.
The render still refuses, because shipping the dry signal there would be wrong.
Carve now analyses for real: it decodes the chosen voice track, ranks its bands
and writes the resulting peaking filters onto this track. Generated nodes are
tagged `fromCarve`, so re-running replaces the previous carve instead of
stacking another set on top of hand-added effects.
Known limitation: the graph is built when a source is scheduled, so a knob
turned mid-playback takes effect on the next play or seek rather than
immediately. Live re-parameterisation needs the transport to hold the handle
and forward updates.
* fix(studio,core): stop parameter drags from restarting playback
Dragging a knob wrote the chain through the persisting attribute path on every
input event. That path refreshes the preview, which reloads the composition and
reschedules audio — so a single drag reloaded dozens of times and playback
stuttered the whole way.
Drags now go through `onSetAttributeLive`, the same path colour grading uses for
scrubs: it coalesces undo entries and sets `skipRefresh`, so no reload happens.
The persisting write fires once, when the gesture ends — pointer-up or blur for
a slider, Enter or blur for a typed value. A select commits immediately since
there is no drag to wait for.
While dragging, the control is driven from local state. Waiting for the value to
round-trip through the element attribute made the knob lag behind the pointer.
For the change to be audible without a reload, the graph now follows the
attribute: the chain installed by the transport observes the element and
re-parameterises itself in place, so a value change lands on the next
128-sample quantum. A shape change (effect added, bypassed, pole count) cannot
be patched into a running graph, so it still waits for the next schedule rather
than cutting the audio mid-play.
The regression test drags a slider through several values and asserts the
persisting handler is untouched until release.
* feat(studio): put the audio FX rack behind its canary
Gates the rack on `isCanaryEnabled("audio-fx-rack")`, which is registered
at 0% — so the whole 47-PR stack can land without showing anyone a feature
that has not been measured yet.
The gate sits on the AUTHORING surface and nowhere else. The runtime and
the render still honour a `data-fx-chain` already on an element, so a
composition written through the skill or by `carve.mjs` keeps its
processing rather than going silently dry for anyone outside the cohort. A
canary should stage who can REACH a feature, not make an attribute somebody
already wrote stop working with no error.
Gated at the panel rather than in `resolveEditingSections`: the affordance
resolver is a pure function in core describing what an element CAN support,
and rollout state is not a property of an `` tag.
Pinned the 0% with a test, and checked it fails at 25 — a ramp should have
to break something that says "this ships dark" out loud.
One gap, stated rather than papered over: the gate itself has no unit test.
I wrote one and deleted it, because `PropertyPanel.test.tsx`'s harness
never renders the Audio FX group for its audio fixture even with the gate
removed — so the test passed for the wrong reason in the off case and could
not pass at all in the on case. A test that cannot fail for the right
reason is worse than none. Verifying the gate needs the panel harness to
mount that section first, which is its own change.
* fix(core): register FX worklets before building nodes that need them
An AudioWorkletNode cannot be constructed before its processor is registered —
it throws, and the surrounding chain is lost with it. `attachElementFxChain`
built the chain first and only then called `ensureAudioFxWorklets`, so every
worklet-backed effect (compressor, limiter, gate, bitcrush) threw on
construction and the track fell back to dry. Instrumenting the preview showed
`hf-compressor: InvalidStateError` with addModule never called at all.
When the module has not landed yet the track now plays dry and the graph is
swapped in once registration resolves, so the effect arrives a moment late
instead of never.
Registration is also tracked per context rather than in one module-level
promise. A processor registered on one AudioContext does not exist on another,
so the shared promise made every context after the first believe it was ready
when it was not — the studio's transport owns its own context, which is exactly
that case.
With the worklets actually running, the compressor's per-sample log10 and pow
became real audio-thread work. Samples below the knee have a gain of exactly
unity and need neither, so the envelope is now compared in the linear domain
and the transcendentals only run for samples that are actually being
compressed.
* refactor(studio): split the FX node row out of FxSection
Clears the health findings the FX stack left behind: the chain-node render
callback was a 70-line closure over half of FxSection's state, and the two
reorder arrows were the same button written twice.
Also drops two exports with no consumers, and registers the audio FX runtime
stub as an entry point — it is bundled by file path, so nothing imports it.
Co-Authored-By: Claude Opus 5 (1M context)
* feat(core): automation envelope model for audio tracks
Adds the data model behind Ableton-style automation lanes: breakpoint
envelopes over track volume or one knob of one effect in the track's FX
chain, stored on the element as `data-automation`.
Times are clip-local, so an envelope travels with the clip when it moves —
the clip-envelope model rather than arrangement automation.
`sampleAutomationLane` is the single interpolator. The lane drawing, the
preview scheduler and the render bake all call it, so the picture and the
sound cannot disagree about the curve. Log-scaled parameters interpolate in
log space, matching what their own knob already promises.
FX nodes gain a stable `id`, minted by count rather than randomly so the
document is the same on every machine. Lanes address nodes by id, so
reordering a chain never re-points a lane at a different effect, and a lane
whose effect was deleted is dropped rather than left to reattach.
Also warns when a track carries both a volume lane and a GSAP volume tween,
since only the lane is heard and the tween silently does nothing.
Co-Authored-By: Claude Opus 5 (1M context)
* refactor(studio): lift the audio FX group out of PropertyPanelFlat
`PropertyPanelFlat.tsx` was 672 lines against the repo's 600-line cap, so
the required File size check was red — the sole reason #3014 and #3022 are
blocked. Both reviews say the same thing: "mechanical fix, not a design
problem. Code itself is LGTM."
Moves `AudioFxGroup` and `audioFxSummary` into
`propertyPanelAudioFxGroup.tsx`, which is where a later branch puts them
anyway — done here so the file is under the cap from the point it first
crosses it, rather than ten branches later.
533 lines now. The four audio imports it no longer needs go with it.
Not fixed here: three `FxSection carve` tests fail on this branch with
"Cannot read properties of undefined (reading 'toFixed')". Confirmed
pre-existing by stashing this change and re-running — that is the separate
`Test` failure the review also flags.
* feat(core): expose the AudioParams behind automatable FX knobs
Marks the knobs an automation lane can drive and has each graph builder hand
back the AudioParam behind them, so a scheduler can write to a running effect
without knowing what the effect is.
A knob is not always one AudioParam. A wet/dry mix is two gains moving in
opposition, and a knob in milliseconds drives a delay time in seconds, so
each target carries the mapping out of the knob's own declared unit.
What stays unautomatable is stated where it is decided: a WaveShaper curve, a
convolution impulse and a one-pole filter's coefficients are all rebuilt
wholesale rather than scheduled, and the four worklet effects take values by
postMessage rather than through AudioParams.
The registry flag is written by hand, so a test builds every effect and
checks the exposure both ways — nothing flagged is missing, nothing exposed
is unflagged. A flag that lied would offer a lane that silently did nothing.
Co-Authored-By: Claude Opus 5 (1M context)
* feat(core): play automation envelopes in preview
Schedules each lane onto the AudioParams behind its knob using native ramps
and value curves. Nothing evaluates the envelope per frame: it is handed to
the audio thread once, so it stays sample-accurate however busy the main
thread is, and the offline render will schedule it the same way.
Timing comes from the transport, so an envelope survives seeking into the
middle of a clip, a clip that has not started yet, and a playback rate that
compresses clip seconds into context seconds.
A straight line is only scheduled as a ramp when nothing bends it — no
curvature, a linear parameter scale, and no unit mapping. Log-scaled
parameters and mapped ones are sampled instead, since a delay knob in
milliseconds and a wet/dry pair moving in opposition are not linear in the
parameter they drive.
Lanes with nowhere to write are skipped rather than reported: a one-pole
filter exposes no frequency param, and the worklet effects expose none at
all. Editing an envelope mid-playback re-aims it at the live playhead rather
than restarting the track.
Co-Authored-By: Claude Opus 5 (1M context)
* fix(core): make the volume lane audible in preview
The envelope was scheduled onto the transport's gain AudioParam, but the
runtime rewrites that gain every tick from `data-volume` and the GSAP-seeked
value — so it was erased within a frame. Volume automation was correct in the
render and inaudible while previewing.
The lane now feeds the per-tick path where the probed volume keyframes already
sit, checked ahead of them so the two cannot fight, and the transport no
longer schedules volume at all: one mechanism instead of two racing.
The cost is honest — in preview the level steps per tick rather than per
sample, exactly as the existing keyframe path does. The render still bakes it
into the PCM sample-accurately, and FX parameters are still scheduled on their
own AudioParams, since nothing rewrites those.
Parsed lanes are cached by attribute text: the runtime asks once per tick per
track, and parsing there would run the JSON parser 60 times a second for a
value that only changes on an edit.
Co-Authored-By: Claude Opus 5 (1M context)
* feat(engine): bake automation envelopes into the render
The offline render schedules FX lanes with the same scheduler preview uses,
inside the OfflineAudioContext that already runs the same graph builders. The
input WAV is the clip's own audio from its first sample, so clip-local time
is offline time and the envelope needs no offset.
Volume lanes take the existing PCM bake rather than a second mechanism: the
lane is converted to keyframes, so a straight fade stays two of them and only
a bent segment is sampled — the baker interpolates linearly and would
otherwise quietly straighten the curve. A volume lane supersedes keyframes
probed from the timeline, which `lint` already warns about.
A browser test sweeps a lowpass from below a 2 kHz tone to well above it and
measures both ends. Parsing the envelope is not the same as scheduling it,
and only running the real thing tells the two apart.
Co-Authored-By: Claude Opus 5 (1M context)
* fix(core): apply chain edits to the running graph
A structural edit — an effect added, removed, bypassed, or a filter's pole
count switched — was dropped. `buildFxChain`'s update reports false when the
change is not merely new values, and the attribute observer ignored that, so
the edit only took hold when the persisting write reloaded the composition.
That reload restarted every playing track, which is what was heard as the
audio chopping.
The graph is now swapped in place: the old effects are detached, the new ones
built and connected between the same source and gain, and any lanes
re-scheduled onto the new nodes. The source node is never touched, so playback
does not restart.
A track with no chain is watched too, rather than wired through and forgotten,
so adding its first effect is heard the same way. That means the function
always returns a disposer instead of null for the empty case.
Co-Authored-By: Claude Opus 5 (1M context)
* fix(studio): drop the FX panel's dead __testables export
Fallow audit flagged it — no test imports the module.
* fix(core,studio): clear the remaining Fallow audit findings on the FX panel
- Split FxSection's per-node row into FxNodeRow + FxNodeControls so the
CRAP score (31.6, threshold 30) splits across two smaller units instead
of moving wholesale with one extraction.
- Dedupe the repeated "open the add menu, read its items" block in
propertyPanelFxSection.test.tsx into openAddMenuItems().
- Merge build-audio-fx-runtime.ts and build-position-edits-render.ts into
one build-inline-artifact.ts, config-selected by CLI arg — the two
scripts were a byte-for-byte clone save for names.
- Exempt canary.test.ts's rawFnv (a deliberate independent
reimplementation used to cross-check canaryBucket, per its own
docstring) and the property-panel test files' shared renderInto/mount
scaffolding (pre-existing across 9 files, 2 outside this stack) in
.fallowrc.jsonc, consistent with this file's existing exemptions for
the same class of intentional/pre-existing duplication.
* fix(ci): allowlist the build-script consolidation in the no-main-deletions guard
build-audio-fx-runtime.ts and build-position-edits-render.ts were merged into
build-inline-artifact.ts to kill a fallow duplication finding; the deletion
guard flagged that as an accidental loss since main still has both originals.
* fix(core): dedupe the wet/dry mix math between delayFeedback and chorusLfo
Both effect builders set wet.gain to the mix and dry.gain to its complement
in identical two-line blocks; fallow kept re-flagging it as a 10-line clone
on every unrelated change. Extracted setWetDryMix.
* fix(core): remove the build-audio-fx-runtime.ts stray resurrected by a main merge
An earlier merge with main brought this deleted file back (git's merge/delete
handling on an unchanged-on-one-side file); package.json already points at
build-inline-artifact.ts, so it sat unreachable and duplicating that file's
config, both of which fallow flagged.
* fix(studio): pull TimelineLanes under the 600-line cap
TimelineLanes.tsx hit 620 lines. Extracted the three per-clip pointer
gestures (resize-start, pointer-down move-arm, click/razor-split) into
createClipGestureHandlers — one factory call per rendered clip instead of
~120 lines of inline handler bodies in the render loop. 529 lines now.
* fix(studio): split the extracted pointerdown handler under the CRAP threshold
Moving the ~120-line gesture logic into timelineClipGestureHandlers.ts
concentrated it into two functions fallow flagged (onPointerDown at CRAP
63.6, onResizeStart at 31.6). Split the decision logic (which gesture a
pointerdown implies) into a pure resolvePointerDownAction, then split
its own intent-blocking check into isIntentBlocked. onResizeStart's guard
moved into canStartResize. Every function now scores under 30.
* fix(studio): drop the unused DomEditSelection import in PropertyPanelFlat
CI caught it on PR #3026 (wa-12-panel-params); a later refactor in the
stack removed the last use of the type here without removing the import.
* fix(studio): close the typecheck and fallow gaps wa-18b-reschedule opened
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)
* fix(core): cap the via conic's weight so an edge-clamped via point can't NaN
A via point pulled out past the segment (viaX: 5, viaY: -3) clamps to
(0.999, 0.001) — exactly on the steady region's edge, where edge - viaX is 0.
viaConic divided by that zero to get an infinite weight, and shapeVia turned
Infinity into NaN a few steps later (Infinity - Infinity in the quadratic
coefficient). NaN reaching setValueCurveAtTime silences the automated
parameter for the rest of the render.
Capped the weight at 1e6 instead of leaving it unbounded — past that point
the arc already reads as touching the via point, so nothing visible is lost.
Also hardened shapeVia's existing denominator guard (`<= 0`) to `!(> 0)`,
since NaN fails the original comparison and fell through it.
Review by Miga (PR #3208).
* fix(studio): widen PropertyPanel's resetModules render timeout again
The 20s margin (already once widened for the same reason) is timing out in
CI's full-monorepo Test run — the resetModules()+fresh-import render this
test needs is uncached and competes with every other package's test suite
for the same worker pool, and the same test passes in well under 2s
standalone. Went to 45s rather than re-tuning to whatever number happens to
clear the current CI load, since that number moves every time CI gains a
package.
---------
Co-authored-by: Claude Opus 5 (1M context)
---
.../src/components/StudioRightPanel.tsx | 17 +-
.../src/components/TimelineToolbar.test.tsx | 46 ++
.../studio/src/components/TimelineToolbar.tsx | 17 +
.../src/components/editor/PropertyPanel.tsx | 30 +-
.../components/editor/PropertyPanelFlat.tsx | 4 +-
.../components/editor/audioFxSummary.test.ts | 75 +++
.../src/components/editor/audioFxSummary.ts | 27 +-
.../editor/propertyPanelAudioFxGroup.test.tsx | 629 +++++++++++++++++-
.../editor/propertyPanelAudioFxGroup.tsx | 233 ++++++-
.../editor/propertyPanelFxControls.tsx | 17 +-
.../editor/propertyPanelFxSection.test.tsx | 137 +++-
.../editor/propertyPanelFxSection.tsx | 363 +++++++++-
.../useAutomationSelectionKeyboard.test.tsx | 92 ++-
.../hooks/useAutomationSelectionKeyboard.ts | 49 +-
.../src/hooks/useDomEditAttributeCommits.ts | 14 +-
.../src/hooks/useDomEditCommits.test.tsx | 38 ++
.../studio/src/hooks/useLivePlayheadTime.ts | 49 ++
.../studio/src/hooks/usePreviewPersistence.ts | 7 +
.../player/components/automationLaneData.ts | 2 +-
19 files changed, 1711 insertions(+), 135 deletions(-)
create mode 100644 packages/studio/src/components/editor/audioFxSummary.test.ts
create mode 100644 packages/studio/src/hooks/useLivePlayheadTime.ts
diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx
index 8ee3b9c88d..31ac169dd0 100644
--- a/packages/studio/src/components/StudioRightPanel.tsx
+++ b/packages/studio/src/components/StudioRightPanel.tsx
@@ -327,6 +327,21 @@ export function StudioRightPanel({
},
[projectId, refreshFileTree, showToast],
);
+
+ /**
+ * A dial being dragged writes to the preview and stops there.
+ *
+ * Every one of these panels previews on each pointermove and commits on
+ * release. Persisting the moves too put a fragment of the drag in the undo
+ * stack — and since those writes race, history could not coalesce them
+ * reliably, so undo took back a sliver of the gesture rather than the gesture.
+ * The release's own commit is what reaches the file and the undo stack.
+ */
+ const setAttributeWhileDragging = useCallback(
+ (attr: string, value: string | null) =>
+ handleDomAttributeLiveCommit(attr, value, undefined, { previewOnly: true }),
+ [handleDomAttributeLiveCommit],
+ );
const handleHideAllSelected = () => {
const { elements } = usePlayerStore.getState();
const keys = timelineKeysForSelections(domEditGroupSelections, elements, activeCompPath);
@@ -361,7 +376,7 @@ export function StudioRightPanel({
onSetStyle={handleDomStyleCommit}
onSetAttribute={handleDomAttributeCommit}
onSetAttributes={handleDomAttributesCommit}
- onSetAttributeLive={handleDomAttributeLiveCommit}
+ onSetAttributeLive={setAttributeWhileDragging}
onSetAttributeQuiet={handleDomAttributeQuietCommit}
onApplyColorGradingScope={handleApplyColorGradingScope}
onSetHtmlAttribute={handleDomHtmlAttributeCommit}
diff --git a/packages/studio/src/components/TimelineToolbar.test.tsx b/packages/studio/src/components/TimelineToolbar.test.tsx
index 2e7900e826..ab07e8cd7a 100644
--- a/packages/studio/src/components/TimelineToolbar.test.tsx
+++ b/packages/studio/src/components/TimelineToolbar.test.tsx
@@ -118,3 +118,49 @@ describe("TimelineToolbar — motion path endpoints", () => {
act(() => root.unmount());
});
});
+
+describe("TimelineToolbar — keyframes on audio tracks", () => {
+ const clip = (tag: string) => ({
+ id: "bgm",
+ key: "bgm",
+ tag,
+ start: 0,
+ duration: 10,
+ track: 1,
+ });
+
+ /** A session whose selection would otherwise offer the keyframe toggle. */
+ function sessionFor(tag: string) {
+ usePlayerStore.setState({ elements: [clip(tag)], selectedElementId: "bgm", currentTime: 1 });
+ const element = document.createElement(tag);
+ element.id = "bgm";
+ return {
+ domEditSelection: makeSelection("Element", element),
+ selectedGsapAnimations: [],
+ handleGsapAddAnimation: vi.fn(),
+ handleGsapConvertToKeyframes: vi.fn(),
+ handleGsapRemoveKeyframe: vi.fn(),
+ } satisfies NonNullable["domEditSession"]>;
+ }
+
+ it("offers no keyframe toggle for an audio clip", () => {
+ // An audio clip has no box on the canvas, so there is nothing to move or fade —
+ // and pressing this seeded a tween from the position properties, which put a
+ // position lane on a track that has no position. Audio is automated instead.
+ const { host, root } = renderToolbar(sessionFor("audio"));
+ const button = host.querySelector(
+ 'button[aria-label="Add keyframe at playhead"]',
+ );
+ expect(button?.disabled).toBe(true);
+ act(() => root.unmount());
+ });
+
+ it("still offers it for a visual clip", () => {
+ const { host, root } = renderToolbar(sessionFor("div"));
+ const button = host.querySelector(
+ 'button[aria-label="Add keyframe at playhead"]',
+ );
+ expect(button?.disabled).toBe(false);
+ act(() => root.unmount());
+ });
+});
diff --git a/packages/studio/src/components/TimelineToolbar.tsx b/packages/studio/src/components/TimelineToolbar.tsx
index 1417665071..12cde02a6c 100644
--- a/packages/studio/src/components/TimelineToolbar.tsx
+++ b/packages/studio/src/components/TimelineToolbar.tsx
@@ -86,8 +86,22 @@ function resolveKeyframeToggleState(
};
}
+/**
+ * Can this element be keyframed at all?
+ *
+ * An audio clip cannot. It has no box on the canvas, so there is nothing to move,
+ * scale or fade — and "add a keyframe" on one seeds a tween from the position
+ * properties, which produced a position lane on a track that has no position. Audio
+ * is automated instead: volume and effect parameters, on their own lanes.
+ */
+function isKeyframeable(element: TimelineElement | undefined): boolean {
+ return element?.tag !== "audio";
+}
+
function useKeyframeToggle(session?: DomEditSessionSlice) {
const currentTime = usePlayerStore((s) => s.currentTime);
+ const selectedElementId = usePlayerStore((s) => s.selectedElementId);
+ const elements = usePlayerStore((s) => s.elements);
const sessionRef = useRef(session);
sessionRef.current = session;
@@ -95,6 +109,9 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
sessionRef as React.RefObject,
);
+ const selected = elements.find((element) => (element.key ?? element.id) === selectedElementId);
+ if (!isKeyframeable(selected)) return { ...NO_KEYFRAME_TOGGLE, onToggle: undefined };
+
const toggleState = resolveKeyframeToggleState(session, currentTime);
return {
diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx
index 7308aca237..9b14d83a6d 100644
--- a/packages/studio/src/components/editor/PropertyPanel.tsx
+++ b/packages/studio/src/components/editor/PropertyPanel.tsx
@@ -1,5 +1,5 @@
import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
-import { memo, useEffect, useMemo, useRef, useState } from "react";
+import { memo, useMemo, useRef, useState } from "react";
import { Move } from "../../icons/SystemIcons";
import { InspectorHeaderActions } from "./InspectorHeaderActions";
import { useStudioShellContext } from "../../contexts/StudioContext";
@@ -29,7 +29,8 @@ import { KeyframeNavigation } from "./KeyframeNavigation";
import { STUDIO_FLAT_INSPECTOR_ENABLED } from "./manualEditingAvailability";
import { PropertyPanelFlat } from "./PropertyPanelFlat";
import { createGsapLivePreview } from "./gsapLivePreview";
-import { usePlayerStore, liveTime } from "../../player";
+import { usePlayerStore } from "../../player";
+import { useLivePlayheadTime } from "../../hooks/useLivePlayheadTime";
import { TimingSection } from "./propertyPanelTimingSection";
import { type PropertyPanelProps } from "./propertyPanelHelpers";
import { GestureRecordPanelButton } from "./GestureRecordControl";
@@ -114,31 +115,14 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
const { showToast } = useStudioShellContext();
const [clipboardCopied, setClipboardCopied] = useState(false);
const clipboardTimerRef = useRef>(undefined);
- const storeTime = usePlayerStore((s) => s.currentTime);
- const isPlaying = usePlayerStore((s) => s.isPlaying);
const timelineElements = usePlayerStore((s) => s.elements);
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
const selectedElementHidden = isSelectedElementHidden(timelineElements, selectedElementId);
const visibilityToggleLabel = selectedElementHidden ? "Show element" : "Hide element";
- const liveTimeRef = useRef(storeTime);
- const [, forceRender] = useState(0);
- useEffect(() => {
- if (!isPlaying) return;
- let timerId: ReturnType | 0 = 0;
- const unsub = liveTime.subscribe((t) => {
- liveTimeRef.current = t;
- if (!timerId)
- timerId = setTimeout(() => {
- timerId = 0;
- forceRender((v) => v + 1);
- }, 33);
- });
- return () => {
- unsub();
- if (timerId) clearTimeout(timerId);
- };
- }, [isPlaying]);
- const currentTime = isPlaying ? liveTimeRef.current : storeTime;
+ // Live during playback, the store's when paused — see the hook. Shared with the
+ // audio FX panel, which follows the playhead for the same reason: a value the
+ // timeline drives has to be shown moving, not frozen at what the attribute says.
+ const currentTime = useLivePlayheadTime();
const cacheElementKey = element?.id ?? element?.selector ?? "";
const cacheEntry = usePlayerStore((s) => s.keyframeCache.get(cacheElementKey));
diff --git a/packages/studio/src/components/editor/PropertyPanelFlat.tsx b/packages/studio/src/components/editor/PropertyPanelFlat.tsx
index 59e2a92c8f..32b1aeaabe 100644
--- a/packages/studio/src/components/editor/PropertyPanelFlat.tsx
+++ b/packages/studio/src/components/editor/PropertyPanelFlat.tsx
@@ -6,6 +6,7 @@ import { slugifyDesignInput } from "../../utils/designInputTracking";
import { isTextEditableSelection } from "./domEditing";
import type { PropertyPanelFlatProps } from "./propertyPanelFlatProps";
import { formatPxMetricValue } from "./propertyPanelHelpers";
+import { audioFxSummary } from "./audioFxSummary";
import { PropertyPanelFlatHeader } from "./PropertyPanelFlatHeader";
import { PropertyPanelFlatFooter } from "./PropertyPanelFlatFooter";
import { FlatGroupHeader } from "./propertyPanelFlatPrimitives";
@@ -14,8 +15,7 @@ import { FlatStyleSection } from "./propertyPanelFlatStyleSections";
import { FlatLayoutSection } from "./propertyPanelFlatLayoutSection";
import { FlatMotionSection } from "./propertyPanelFlatMotionSection";
import { isCanaryEnabled } from "../../telemetry/canary";
-import { audioFxSummary } from "./audioFxSummary";
-import { AudioFxGroup } from "./propertyPanelAudioFxGroup";
+import { AudioFxGroup } from "./propertyPanelAudioFxGroup.js";
import { useVolumeAutomation } from "./useVolumeAutomation";
import { FlatMediaSection } from "./propertyPanelFlatMediaSection";
import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
diff --git a/packages/studio/src/components/editor/audioFxSummary.test.ts b/packages/studio/src/components/editor/audioFxSummary.test.ts
new file mode 100644
index 0000000000..5ba7a87a5a
--- /dev/null
+++ b/packages/studio/src/components/editor/audioFxSummary.test.ts
@@ -0,0 +1,75 @@
+import { describe, expect, it } from "vitest";
+import { audioFxSummary } from "./audioFxSummary";
+import type { DomEditSelection } from "./domEditingTypes";
+
+const el = (dataAttributes: Record): DomEditSelection =>
+ ({ dataAttributes }) as unknown as DomEditSelection;
+
+const chain = (nodes: unknown[]) => JSON.stringify({ version: 1, nodes });
+
+describe("audioFxSummary", () => {
+ it("counts a carve as one module, not as the filters behind it", () => {
+ // Six bands and a level stage reading "7 effects" is the misreading the
+ // grouping exists to prevent.
+ const summary = audioFxSummary(
+ el({
+ "fx-chain": chain([
+ { type: "peaking", id: "n1", fromCarve: true, params: { frequency: 400 } },
+ { type: "peaking", id: "n2", fromCarve: true, params: { frequency: 1600 } },
+ { type: "gain", id: "n3", fromCarve: true, params: { gain: -6 } },
+ ]),
+ "fx-carve": JSON.stringify({ source: "vo", strength: 0.25 }),
+ }),
+ );
+ expect(summary).toBe("carve");
+ });
+
+ it("counts hand-built effects alongside the module", () => {
+ expect(
+ audioFxSummary(
+ el({
+ "fx-chain": chain([
+ { type: "peaking", id: "n1", fromCarve: true, params: { frequency: 400 } },
+ { type: "lowpass", id: "n2", params: { frequency: 8000 } },
+ { type: "delay", id: "n3", params: { time: 200 } },
+ ]),
+ }),
+ ),
+ ).toBe("2 effects + carve");
+ });
+
+ it("says how many when there is no carve", () => {
+ expect(audioFxSummary(el({ "fx-chain": chain([{ type: "lowpass", id: "n1" }]) }))).toBe(
+ "1 effect",
+ );
+ });
+
+ it("names a carve that is on but has not compiled to filters yet", () => {
+ // Switching it on with no voice chosen leaves the control in this section with
+ // nothing behind it; the summary should still say the section holds one.
+ expect(audioFxSummary(el({ "fx-carve": JSON.stringify({ source: "", strength: 0.25 }) }))).toBe(
+ "carve",
+ );
+ });
+
+ it("ignores bypassed effects, as it always did", () => {
+ expect(
+ audioFxSummary(
+ el({
+ "fx-chain": chain([
+ { type: "lowpass", id: "n1", enabled: false },
+ { type: "delay", id: "n2" },
+ ]),
+ }),
+ ),
+ ).toBe("1 effect");
+ });
+
+ it("says none for a track with neither", () => {
+ expect(audioFxSummary(el({}))).toBe("none");
+ });
+
+ it("says so when the chain cannot be read", () => {
+ expect(audioFxSummary(el({ "fx-chain": "{not json" }))).toBe("unreadable");
+ });
+});
diff --git a/packages/studio/src/components/editor/audioFxSummary.ts b/packages/studio/src/components/editor/audioFxSummary.ts
index 9accb8cfad..441217a8ab 100644
--- a/packages/studio/src/components/editor/audioFxSummary.ts
+++ b/packages/studio/src/components/editor/audioFxSummary.ts
@@ -1,27 +1,36 @@
/**
* What the collapsed Audio FX group says it holds.
*
- * Its own module because `PropertyPanelFlat.tsx` is at the repo's 600-line
- * budget, and this is the piece with no dependency on the panel around it.
+ * It has to describe the rack the author would see on opening it, which counts a
+ * carve as one module rather than as the filters it compiles to. Six peaking
+ * bands and a level stage reading "7 effects" invited exactly the misreading the
+ * grouping exists to prevent — that they are seven things to manage.
*/
import { parseAudioFxChain } from "@hyperframes/core/audio-fx";
-import type { DomEditSelection } from "./domEditing";
+import type { DomEditSelection } from "./domEditingTypes";
-/** Chain length at a glance, so the collapsed group says whether anything is on. */
export function audioFxSummary(element: DomEditSelection): string {
const raw = element.dataAttributes?.["fx-chain"];
- const carve = element.dataAttributes?.["fx-carve"];
- let count = 0;
+ const carveAttr = element.dataAttributes?.["fx-carve"];
+ let handBuilt = 0;
+ let carveNodes = 0;
if (raw) {
try {
- count = parseAudioFxChain(raw).nodes.filter((n) => n.enabled !== false).length;
+ for (const node of parseAudioFxChain(raw).nodes) {
+ if (node.enabled === false) continue;
+ if (node.fromCarve) carveNodes += 1;
+ else handBuilt += 1;
+ }
} catch {
return "unreadable";
}
}
const parts: string[] = [];
- if (count > 0) parts.push(`${count} effect${count === 1 ? "" : "s"}`);
- if (carve) parts.push("carve");
+ if (handBuilt > 0) parts.push(`${handBuilt} effect${handBuilt === 1 ? "" : "s"}`);
+ // One name for the module however many filters are behind it. Named when the
+ // carve is switched on at all, because the control is in this section whether or
+ // not it has compiled to anything yet.
+ if (carveNodes > 0 || carveAttr) parts.push("carve");
return parts.length > 0 ? parts.join(" + ") : "none";
}
diff --git a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx
index e176af0cf3..f9cad3b146 100644
--- a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx
+++ b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { createRoot } from "react-dom/client";
import { AudioFxGroup } from "./propertyPanelAudioFxGroup.js";
import type { DomEditSelection } from "./domEditingTypes";
+import { liveTime, usePlayerStore } from "../../player";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -174,13 +175,7 @@ describe("AudioFxGroup carve", () => {
],
});
- const carveOn = JSON.stringify({
- source: "vo",
- maxCutDb: 6,
- bands: 3,
- q: 1.4,
- intelligibilityBias: 0.7,
- });
+ const carveOn = JSON.stringify({ source: "vo", strength: 0.5, dynamic: false });
const carveToggle = (host: HTMLElement): HTMLButtonElement => {
const block = host.querySelector(".hf-fx-carve")!;
@@ -232,7 +227,8 @@ describe("AudioFxGroup carve", () => {
act(() => {
// React's value tracker swallows a plain assignment, so go through the
// prototype setter the way the other panel tests do.
- Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(dial, "0.5");
+ // A different value than the carve holds; setting the same one is not a change.
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(dial, "0.8");
dial?.dispatchEvent(new Event("input", { bubbles: true }));
});
expect(onSetAttributeLive.mock.calls.map((c) => c[0])).toEqual(["data-fx-carve"]);
@@ -250,6 +246,372 @@ describe("AudioFxGroup carve", () => {
});
});
+describe("AudioFxGroup dynamic carve", () => {
+ const carvedChain = JSON.stringify({
+ version: 1,
+ nodes: [{ type: "lowpass", id: "n1", params: { frequency: 400, q: 0.9, poles: "2" } }],
+ });
+ // Strength 0 carves frequencies only — no level ducking — so the spectral
+ // cases measure just the spectral half. A case that wants the duck raises it.
+ const settings = (dynamic: boolean, over: Record = {}) =>
+ JSON.stringify({ source: "vo", strength: 0, dynamic, ...over });
+
+ /** The value written for one attribute, whatever order the writes landed in. */
+ const writeFor = (calls: unknown[][], attr: string) =>
+ JSON.parse(String(calls.find((c) => c[0] === attr)![1]));
+
+ /** Choose a voice track the way the select does. */
+ const pickSource = (host: HTMLElement, id: string) => {
+ const select = host.querySelector(".hf-fx-carve select")!;
+ Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set?.call(select, id);
+ select.dispatchEvent(new Event("change", { bubbles: true }));
+ };
+
+ const dynamicBox = (host: HTMLElement) =>
+ host.querySelector(".hf-fx-carve-dynamic")!;
+
+ /** A voice with a pause in it, decoded through a stubbed offline context. */
+ function stubDecode(): void {
+ const sampleRate = 48000;
+ const data = new Float32Array(sampleRate * 4);
+ for (let i = 0; i < data.length; i++) {
+ const t = i / sampleRate;
+ data[i] = t > 1 && t < 3 ? 0.7 * Math.sin(2 * Math.PI * 1000 * t) : 0;
+ }
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => ({ arrayBuffer: async () => new ArrayBuffer(8) })),
+ );
+ vi.stubGlobal(
+ "OfflineAudioContext",
+ class {
+ decodeAudioData = async () => ({ sampleRate, getChannelData: () => data });
+ },
+ );
+ }
+
+ afterEach(() => vi.unstubAllGlobals());
+
+ it("records the choice in the carve settings", () => {
+ const { host, onSetAttributeQuiet } = mount({
+ "fx-chain": carvedChain,
+ "fx-carve": settings(false),
+ });
+ act(() => dynamicBox(host).click());
+ const write = onSetAttributeQuiet.mock.calls.find((c) => c[0] === "data-fx-carve");
+ expect(JSON.parse(String(write![1])).dynamic).toBe(true);
+ });
+
+ it("automates the carve filters' gain from the voice, in the bed's own time", async () => {
+ stubDecode();
+ // Voice starts 10s into the composition, bed at 0: the envelope is measured
+ // against the voice but read from the start of the bed, so it has to shift.
+ const { host, onSetAttributeQuiet } = mount({
+ "fx-chain": carvedChain,
+ // No source yet: picking one is what applies the carve.
+ "fx-carve": settings(true, { source: "" }),
+ start: "0",
+ });
+ const vo = document.getElementById("vo")!;
+ vo.setAttribute("data-start", "10");
+ vo.setAttribute("src", "voice.wav");
+ await act(async () => {
+ pickSource(host, "vo");
+ });
+
+ // Chain first, then automation: a lane naming a node the chain does not
+ // carry yet is dropped when it is read back.
+ const order = onSetAttributeQuiet.mock.calls.map((c) => c[0]);
+ // The settings land first, then the filters they imply, then the envelopes.
+ expect(order.indexOf("data-fx-chain")).toBeLessThan(order.indexOf("data-automation"));
+
+ const carved = writeFor(onSetAttributeQuiet.mock.calls, "data-fx-chain").nodes;
+ const carveNode = carved.find((n: { fromCarve?: boolean }) => n.fromCarve);
+ expect(carveNode.id).toBeTruthy();
+
+ const lanes = writeFor(onSetAttributeQuiet.mock.calls, "data-automation").lanes;
+ const lane = lanes.find((l: { target: string }) => l.target === `fx.${carveNode.id}.gain`) as {
+ points: { t: number; v: number }[];
+ };
+ expect(lane).toBeTruthy();
+ // Flat at the bed's own start, before the voice exists at all.
+ expect(lane.points[0]).toMatchObject({ t: 0, v: 0 });
+ // The voice's pause is at 0-1s of its own clip, so 10-11s of the bed's.
+ expect(lane.points.find((p) => p.t > 10.5 && p.t < 11)?.v ?? 0).toBe(0);
+ // And it cuts once the voice speaks, a second later. Depth is per band and
+ // relative to that band's own peak in the voice, so the invariant is that the
+ // envelope gets most of the way to what the analysis put on the node — not a
+ // fixed number of dB, which changes with the band the analysis chose.
+ const bandGain = Number(carveNode.params?.gain ?? 0);
+ // At least half the depth the analysis put on the node; the exact floor
+ // depends on which band it chose and how the envelope was thinned.
+ expect(Math.min(...lane.points.map((p) => p.v))).toBeLessThanOrEqual(bandGain * 0.5);
+ // Ends back at no cut, so the bed is not left dipped for the rest of the clip.
+ expect(lane.points.at(-1)!.v).toBe(0);
+ });
+
+ it("adds a gain stage that ducks the bed under the voice, automated when dynamic", async () => {
+ // Carving frequencies cannot beat a bed that is simply louder than the
+ // voice. The level half rides a gain node the carve owns, so the track's own
+ // volume lane is left alone.
+ stubDecode();
+ const { host, onSetAttributeQuiet } = mount({
+ "fx-chain": carvedChain,
+ "fx-carve": settings(true, { strength: 1, source: "" }),
+ start: "0",
+ });
+ const vo = document.getElementById("vo")!;
+ vo.setAttribute("data-start", "0");
+ vo.setAttribute("src", "voice.wav");
+ // The bed is measured too — "how far over the voice is it" needs both.
+ document.getElementById("bed")!.setAttribute("src", "bed.m4a");
+ await act(async () => {
+ pickSource(host, "vo");
+ });
+
+ const nodes = writeFor(onSetAttributeQuiet.mock.calls, "data-fx-chain").nodes;
+ const gain = nodes.find((n: { type: string }) => n.type === "gain");
+ expect(gain).toBeTruthy();
+ expect(gain.fromCarve).toBe(true);
+ // Dynamic hands the value to the envelope, so the static one stays at unity.
+ expect(gain.params.gain).toBe(0);
+
+ const lanes = writeFor(onSetAttributeQuiet.mock.calls, "data-automation").lanes;
+ const duckLane = lanes.find((l: { target: string }) => l.target === `fx.${gain.id}.gain`);
+ expect(duckLane).toBeTruthy();
+ expect(Math.min(...duckLane.points.map((p: { v: number }) => p.v))).toBeLessThan(0);
+ // Every carved band gets an envelope reaching that band's own analysed depth.
+ for (const node of nodes.filter((n: { type: string }) => n.type === "peaking")) {
+ const lane = lanes.find((l: { target: string }) => l.target === `fx.${node.id}.gain`) as
+ | { points: { v: number }[] }
+ | undefined;
+ expect(lane, `band ${node.id} has no envelope`).toBeTruthy();
+ const deepest = Math.min(...lane!.points.map((p) => p.v));
+ expect(deepest).toBeLessThanOrEqual(0);
+ expect(deepest).toBeGreaterThanOrEqual(node.params.gain - 0.2);
+ expect(deepest).toBeLessThanOrEqual(node.params.gain * 0.5);
+ }
+ // The author's own volume lane is not something a carve gets to touch.
+ expect(lanes.some((l: { target: string }) => l.target === "volume")).toBe(false);
+ });
+
+ it("holds one measured value when the carve is not dynamic", async () => {
+ stubDecode();
+ const { host, onSetAttributeQuiet } = mount({
+ "fx-chain": carvedChain,
+ "fx-carve": settings(false, { strength: 1, source: "" }),
+ start: "0",
+ });
+ const vo = document.getElementById("vo")!;
+ vo.setAttribute("data-start", "0");
+ vo.setAttribute("src", "voice.wav");
+ // The bed is measured too — "how far over the voice is it" needs both.
+ document.getElementById("bed")!.setAttribute("src", "bed.m4a");
+ await act(async () => {
+ pickSource(host, "vo");
+ });
+ const nodes = writeFor(onSetAttributeQuiet.mock.calls, "data-fx-chain").nodes;
+ const gain = nodes.find((n: { type: string }) => n.type === "gain");
+ expect(gain.params.gain).toBeLessThan(0);
+ // Nothing to schedule: a static carve is a value, not an envelope.
+ expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-automation")).toBe(false);
+ });
+
+ it("carves frequencies only when the duck is off", async () => {
+ stubDecode();
+ const { host, onSetAttributeQuiet } = mount({
+ "fx-chain": carvedChain,
+ "fx-carve": settings(true, { strength: 0, source: "" }),
+ start: "0",
+ });
+ document.getElementById("vo")!.setAttribute("src", "voice.wav");
+ document.getElementById("bed")!.setAttribute("src", "bed.m4a");
+ await act(async () => {
+ pickSource(host, "vo");
+ });
+ const nodes = writeFor(onSetAttributeQuiet.mock.calls, "data-fx-chain").nodes;
+ expect(nodes.some((n: { type: string }) => n.type === "gain")).toBe(false);
+ });
+
+ it("applies as soon as a voice track is picked, with no second step", async () => {
+ // A carve with a source and no filters is a setting nobody applied. Choosing
+ // the voice is the whole gesture.
+ stubDecode();
+ const { host, onSetAttributeQuiet } = mount({
+ "fx-chain": JSON.stringify({ version: 1, nodes: [] }),
+ "fx-carve": JSON.stringify({ source: "", strength: 0.25, dynamic: true }),
+ start: "0",
+ });
+ document.getElementById("vo")!.setAttribute("src", "voice.wav");
+ document.getElementById("bed")!.setAttribute("src", "bed.m4a");
+ await act(async () => {
+ pickSource(host, "vo");
+ });
+ const written = onSetAttributeQuiet.mock.calls.map((c) => c[0]);
+ expect(written).toEqual(["data-fx-carve", "data-fx-chain", "data-automation"]);
+ const nodes = JSON.parse(
+ String(onSetAttributeQuiet.mock.calls.find((c) => c[0] === "data-fx-chain")![1]),
+ ).nodes;
+ expect(nodes.every((n: { fromCarve?: boolean }) => n.fromCarve)).toBe(true);
+ });
+
+ it("re-applies when dynamic is switched on, not just when strength moves", async () => {
+ stubDecode();
+ const carvedAlready = JSON.stringify({
+ version: 1,
+ nodes: [
+ {
+ type: "peaking",
+ id: "n1",
+ fromCarve: true,
+ params: { frequency: 1000, gain: -6, q: 1.4 },
+ },
+ ],
+ });
+ const { host, onSetAttributeQuiet } = mount({
+ "fx-chain": carvedAlready,
+ "fx-carve": settings(false, { strength: 0.25 }),
+ start: "0",
+ });
+ document.getElementById("vo")!.setAttribute("src", "voice.wav");
+ document.getElementById("bed")!.setAttribute("src", "bed.m4a");
+ await act(async () => {
+ host.querySelector(".hf-fx-carve-dynamic")!.click();
+ });
+ // Static and dynamic are different chains, so the switch has to rebuild them.
+ expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-chain")).toBe(true);
+ });
+
+ it("re-applies an existing carve when strength moves", async () => {
+ // Strength is the whole control surface, so it has to act on what is already
+ // applied. Left to the button alone, a carve kept the filters and envelopes
+ // its old strength produced and the knob silently described nothing.
+ stubDecode();
+ const carvedAlready = JSON.stringify({
+ version: 1,
+ nodes: [
+ {
+ type: "peaking",
+ id: "n1",
+ fromCarve: true,
+ params: { frequency: 1000, gain: -6, q: 1.4 },
+ },
+ { type: "gain", id: "n2", fromCarve: true, params: { gain: -6 } },
+ ],
+ });
+ const { host, onSetAttributeQuiet } = mount({
+ "fx-chain": carvedAlready,
+ "fx-carve": settings(true, { strength: 0.25 }),
+ start: "0",
+ });
+ document.getElementById("vo")!.setAttribute("src", "voice.wav");
+ document.getElementById("bed")!.setAttribute("src", "bed.m4a");
+
+ const dial = host.querySelector(".hf-fx-carve input[type=range]")!;
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(dial, "1");
+ dial.dispatchEvent(new Event("input", { bubbles: true }));
+ dial.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
+ });
+
+ const written = onSetAttributeQuiet.mock.calls.map((c) => c[0]);
+ expect(written).toContain("data-fx-carve");
+ // The settings land first, then the filters they imply, then the envelopes.
+ expect(written.indexOf("data-fx-carve")).toBeLessThan(written.indexOf("data-fx-chain"));
+ expect(written.indexOf("data-fx-chain")).toBeLessThan(written.indexOf("data-automation"));
+
+ const chainWrite = onSetAttributeQuiet.mock.calls.find((c) => c[0] === "data-fx-chain");
+ const nodes = JSON.parse(String(chainWrite![1])).nodes;
+ // Full strength: deeper than the 6 dB the quarter-strength carve had.
+ const deepest = Math.min(
+ ...nodes
+ .filter((n: { type: string }) => n.type === "peaking")
+ .map((n: { params: { gain: number } }) => n.params.gain),
+ );
+ expect(deepest).toBeLessThan(-6);
+ });
+
+ it("does nothing but record the setting while no voice track is chosen", async () => {
+ // There is nothing to listen to, so there is nothing to derive. This is the
+ // one case that only writes the setting now that the apply button is gone.
+ stubDecode();
+ const { host, onSetAttributeQuiet } = mount({
+ "fx-chain": JSON.stringify({ version: 1, nodes: [] }),
+ "fx-carve": settings(true, { strength: 0.25, source: "" }),
+ start: "0",
+ });
+ const dial = host.querySelector(".hf-fx-carve input[type=range]")!;
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(dial, "1");
+ dial.dispatchEvent(new Event("input", { bubbles: true }));
+ dial.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
+ });
+ expect(onSetAttributeQuiet.mock.calls.map((c) => c[0])).toEqual(["data-fx-carve"]);
+ });
+
+ it("does not re-analyse on every pixel of a drag", async () => {
+ // Only the release re-applies. Analysing per pointermove would decode both
+ // tracks on each pixel.
+ stubDecode();
+ const carvedAlready = JSON.stringify({
+ version: 1,
+ nodes: [
+ {
+ type: "peaking",
+ id: "n1",
+ fromCarve: true,
+ params: { frequency: 1000, gain: -6, q: 1.4 },
+ },
+ ],
+ });
+ const { host, onSetAttributeQuiet, onSetAttributeLive } = mount({
+ "fx-chain": carvedAlready,
+ "fx-carve": settings(true, { strength: 0.25 }),
+ start: "0",
+ });
+ document.getElementById("vo")!.setAttribute("src", "voice.wav");
+ const dial = host.querySelector(".hf-fx-carve input[type=range]")!;
+ await act(async () => {
+ for (const v of ["0.4", "0.6", "0.8"]) {
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(dial, v);
+ dial.dispatchEvent(new Event("input", { bubbles: true }));
+ }
+ });
+ expect(onSetAttributeLive.mock.calls.every((c) => c[0] === "data-fx-carve")).toBe(true);
+ expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-chain")).toBe(false);
+ });
+
+ it("drops the envelopes when dynamic is switched back off", async () => {
+ // An automated gain ignores the panel's depth, so leaving the lanes behind
+ // would keep the filters following a voice with nothing saying they do.
+ const automation = JSON.stringify({
+ version: 1,
+ lanes: [
+ { target: "fx.n2.gain", points: [{ t: 0, v: 0 }] },
+ { target: "volume", points: [{ t: 0, v: 1 }] },
+ ],
+ });
+ const withCarveNode = JSON.stringify({
+ version: 1,
+ nodes: [
+ { type: "peaking", id: "n2", fromCarve: true, params: { frequency: 1000, gain: -6 } },
+ ],
+ });
+ const { host, onSetAttributeQuiet } = mount({
+ "fx-chain": withCarveNode,
+ "fx-carve": settings(true),
+ automation,
+ });
+ await act(async () => {
+ dynamicBox(host).click();
+ });
+ const write = onSetAttributeQuiet.mock.calls.find((c) => c[0] === "data-automation");
+ expect(write).toBeTruthy();
+ const lanes = JSON.parse(String(write![1])).lanes;
+ expect(lanes.map((l: { target: string }) => l.target)).toEqual(["volume"]);
+ });
+});
+
describe("AudioFxGroup successive edits", () => {
const three = JSON.stringify({
version: 1,
@@ -298,6 +660,72 @@ describe("AudioFxGroup successive edits", () => {
});
describe("AudioFxGroup carve visibility", () => {
+ /**
+ * A carve is a relationship: a bed is carved *against* a voice. The voice is the
+ * other end of it, so offering the same control there is offering to carve a
+ * track against itself by proxy — and switching it on left a setting that could
+ * never do anything.
+ */
+ it("does not offer carve on a track another track is carving against", () => {
+ const bed = document.createElement("audio");
+ bed.id = "bed";
+ bed.setAttribute("src", "bed.m4a");
+ bed.setAttribute("data-fx-carve", JSON.stringify({ source: "vo", strength: 0.25 }));
+ document.body.append(bed);
+ const voice = document.createElement("audio");
+ voice.id = "vo";
+ voice.setAttribute("src", "voice.wav");
+ document.body.append(voice);
+
+ const host = document.createElement("div");
+ document.body.append(host);
+ const selection = {
+ dataAttributes: {},
+ id: "vo",
+ element: voice,
+ } as unknown as DomEditSelection;
+ act(() => {
+ createRoot(host).render(
+ ,
+ );
+ });
+ expect(host.querySelector(".hf-fx-carve")).toBeNull();
+ });
+
+ it("still offers it on the bed doing the carving", () => {
+ const bed = document.createElement("audio");
+ bed.id = "bed";
+ bed.setAttribute("src", "bed.m4a");
+ bed.setAttribute("data-fx-carve", JSON.stringify({ source: "vo", strength: 0.25 }));
+ document.body.append(bed);
+ const voice = document.createElement("audio");
+ voice.id = "vo";
+ voice.setAttribute("src", "voice.wav");
+ document.body.append(voice);
+
+ const host = document.createElement("div");
+ document.body.append(host);
+ const selection = {
+ dataAttributes: { "fx-carve": JSON.stringify({ source: "vo", strength: 0.25 }) },
+ id: "bed",
+ element: bed,
+ } as unknown as DomEditSelection;
+ act(() => {
+ createRoot(host).render(
+ ,
+ );
+ });
+ expect(host.querySelector(".hf-fx-carve")).not.toBeNull();
+ });
+
it("offers carve when the composition holds another audio track", () => {
const { host } = mount({ "fx-chain": CHAIN });
expect(host.querySelector(".hf-fx-carve")).toBeTruthy();
@@ -356,3 +784,188 @@ describe("AudioFxGroup deleting an effect", () => {
expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-automation")).toBe(false);
});
});
+
+describe("AudioFxGroup carve module readouts", () => {
+ /**
+ * A carve on a bed running 0–10 s, with a lane that ramps its 400 Hz band from
+ * no cut to −6 dB across the first five seconds. The stored gain is −1, which is
+ * deliberately not a value the lane ever passes through: it stands in for the
+ * seed a lane leaves behind, so a readout showing it can only mean the playhead
+ * was not consulted.
+ */
+ const carved = {
+ start: "0",
+ duration: "10",
+ "fx-chain": JSON.stringify({
+ version: 1,
+ nodes: [
+ {
+ type: "peaking",
+ id: "n1",
+ fromCarve: true,
+ params: { frequency: 400, gain: -1, q: 1.4 },
+ },
+ ],
+ }),
+ automation: JSON.stringify({
+ version: 1,
+ lanes: [
+ {
+ target: "fx.n1.gain",
+ points: [
+ { t: 0, v: 0 },
+ { t: 5, v: -6 },
+ ],
+ },
+ ],
+ }),
+ "fx-carve": JSON.stringify({ source: "vo", strength: 0.25, dynamic: true }),
+ };
+
+ /** Park the playhead somewhere, paused — a scrub is the same question as playback. */
+ const seek = (time: number) => {
+ act(() => {
+ usePlayerStore.setState({ currentTime: time, isPlaying: false });
+ });
+ };
+
+ const gainReadout = (host: HTMLElement): HTMLElement | null => {
+ for (const span of Array.from(host.querySelectorAll(".hf-fx-carve-member span"))) {
+ if (span.textContent?.startsWith("Gain")) return span;
+ }
+ return null;
+ };
+
+ const openModule = (host: HTMLElement) => {
+ const head = host.querySelector(".hf-fx-carve-module .hf-fx-node-name");
+ act(() => head?.click());
+ };
+
+ afterEach(() => {
+ usePlayerStore.setState({ currentTime: 0, isPlaying: false });
+ });
+
+ it("shows the envelope's value at the playhead, not the stored seed", () => {
+ seek(2.5);
+ const { host } = mount(carved);
+ openModule(host);
+ const gain = gainReadout(host);
+ // Halfway along a 0 → −6 dB ramp.
+ expect(gain?.textContent).toContain("-3 dB");
+ expect(gain?.hasAttribute("data-automation-live")).toBe(true);
+ });
+
+ it("follows the playhead as it moves", () => {
+ seek(0);
+ const { host } = mount(carved);
+ openModule(host);
+ expect(gainReadout(host)?.textContent).toContain("0 dB");
+ seek(5);
+ expect(gainReadout(host)?.textContent).toContain("-6 dB");
+ seek(1);
+ expect(gainReadout(host)?.textContent).toContain("-1.2 dB");
+ });
+
+ it("follows the transport during playback, off the live-time channel", async () => {
+ // The RAF loop deliberately keeps every frame out of the store — it notifies
+ // `liveTime` instead — so a panel that only watched the store would sit still
+ // for the whole take and then jump when playback stopped.
+ seek(0);
+ const { host } = mount(carved);
+ openModule(host);
+ act(() => {
+ usePlayerStore.setState({ isPlaying: true });
+ });
+ act(() => liveTime.notify(4));
+ // Throttled to 30 Hz rather than rendered per frame, so the readout lands on
+ // the next tick and not in this one.
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 60));
+ });
+ expect(gainReadout(host)?.textContent).toContain("-4.8 dB");
+
+ act(() => liveTime.notify(5));
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 60));
+ });
+ expect(gainReadout(host)?.textContent).toContain("-6 dB");
+ });
+
+ it("reserves the same width whatever the value reads", () => {
+ // The readout updates 30 times a second while the transport runs, and a value
+ // one character narrower shunts everything after it sideways. So the width comes
+ // from what the parameter CAN read, not from what it currently does.
+ seek(2.5);
+ const { host } = mount(carved);
+ openModule(host);
+ const widthAt = (): string | undefined =>
+ gainReadout(host)?.querySelector(".tabular-nums")?.style.minWidth;
+ const narrow = widthAt();
+ expect(narrow).toBe("8ch"); // -40..40 dB at one decimal: "-12.5 dB"
+ seek(5); // -6 dB — two characters shorter than -3 dB was
+ expect(gainReadout(host)?.textContent).toContain("-6 dB");
+ expect(widthAt()).toBe(narrow);
+ });
+
+ it("moves a hand-built effect's own slider and number, not just the carve rack", () => {
+ // Every automated value follows its lane, whatever put the effect there. This
+ // one is a delay the author added and automated by hand: its control is locked
+ // (the lane owns the value), so the fader has no drag to fight and can simply
+ // show the truth.
+ const { host } = mount({
+ start: "0",
+ duration: "10",
+ "fx-chain": JSON.stringify({
+ version: 1,
+ nodes: [{ type: "delay", id: "n1", params: { time: 250, feedback: 0.35, mix: 0.4 } }],
+ }),
+ automation: JSON.stringify({
+ version: 1,
+ lanes: [
+ {
+ target: "fx.n1.mix",
+ points: [
+ { t: 0, v: 0 },
+ { t: 4, v: 1 },
+ ],
+ },
+ ],
+ }),
+ });
+ const mixRow = rowFor(host, "Mix");
+ const number = mixRow?.querySelector(".hf-fx-number");
+ const slider = mixRow?.querySelector(".hf-fx-slider");
+ expect(number?.disabled).toBe(true); // the lane owns it
+
+ seek(1); // a quarter along a 0 → 1 ramp
+ expect(Number(number?.value)).toBeCloseTo(0.25, 2);
+ const quarter = Number(slider?.value);
+
+ seek(3);
+ expect(Number(number?.value)).toBeCloseTo(0.75, 2);
+ expect(Number(slider?.value)).toBeGreaterThan(quarter);
+ });
+
+ it("shows the lane's own edge value off the clip, not the stored seed", () => {
+ // Past the bed's end, where a lane holds its last value — which is what would
+ // play if the playhead came back. The stored -1 dB is a seed the lane replaced
+ // and nothing will ever use it, so putting it on screen only made the fader
+ // jump when the clip came under the playhead.
+ seek(20);
+ const { host } = mount(carved);
+ openModule(host);
+ const gain = gainReadout(host);
+ expect(gain?.textContent).toContain("-6 dB"); // the ramp's last point
+ expect(gain?.hasAttribute("data-automation-live")).toBe(true);
+ expect(gain?.hasAttribute("data-automated")).toBe(true);
+ });
+
+ it("holds the lane's first value before the clip starts", () => {
+ // Same rule at the other end: a lane opens on its first point, so that is what
+ // the fader should read while the playhead is still upstream of the clip.
+ seek(-5);
+ const { host } = mount(carved);
+ openModule(host);
+ expect(gainReadout(host)?.textContent).toContain("0 dB");
+ });
+});
diff --git a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx
index 2d56849959..9401d6aae7 100644
--- a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx
+++ b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx
@@ -9,20 +9,30 @@
import { useState } from "react";
import {
+ defaultAudioFxParams,
HF_AUDIO_FX_ATTR,
+ mintAudioFxNodeId,
parseAudioFxChain,
serializeAudioFxChain,
type HfAudioFxChain,
+ type HfAudioFxNode,
} from "@hyperframes/core/audio-fx";
import {
analyseCarveBands,
+ analyseCarveDuck,
+ analyseCarveDynamics,
carveBandsToChain,
carveProfile,
HF_AUDIO_CARVE_ATTR,
normalizeCarveSettings,
type HfCarveSettings,
} from "@hyperframes/core/audio-carve";
-import { fxAutomationTarget, type HfAutomation } from "@hyperframes/core/audio-automation";
+import {
+ fxAutomationTarget,
+ sampleAutomationLane,
+ type HfAutomation,
+ type HfAutomationLane,
+} from "@hyperframes/core/audio-automation";
import {
automatedTargetsOf,
automationAttrValue,
@@ -33,6 +43,7 @@ import {
withSeededLane,
} from "./propertyPanelAutomation";
import type { DomEditSelection } from "./domEditingTypes";
+import { useLivePlayheadTime } from "../../hooks/useLivePlayheadTime";
/**
* Rate the carve source is decoded at. Analysis is self-consistent because it
@@ -41,6 +52,22 @@ import type { DomEditSelection } from "./domEditingTypes";
const DECODE_SAMPLE_RATE = 48000;
import { FxSection, type AudioTrackOption } from "./propertyPanelFxSection.js";
+/** Where a clip starts on the timeline, in seconds. */
+function clipStart(value: string | null | undefined): number {
+ const n = Number(value);
+ return Number.isFinite(n) ? n : 0;
+}
+
+/** Lanes belonging to nodes the carve generated, which a re-run replaces. */
+function withoutCarveLanes(automation: HfAutomation, chain: HfAudioFxChain): HfAutomation {
+ const prefixes = chain.nodes.filter((n) => n.fromCarve && n.id).map((n) => `fx.${n.id}.`);
+ if (prefixes.length === 0) return automation;
+ return {
+ version: automation.version,
+ lanes: automation.lanes.filter((lane) => !prefixes.some((p) => lane.target.startsWith(p))),
+ };
+}
+
/**
* Bridges the FX panel to the element/attribute world. Chain and carve are
* serialised onto the element the way colour grading carries its config, so
@@ -81,6 +108,37 @@ export function AudioFxGroup({
const automation = readPanelAutomation(element.dataAttributes?.["automation"], chain);
const automatedTargets = automatedTargetsOf(automation);
+ /**
+ * What every automated knob is worth at the playhead, so the rack shows the
+ * value the audio is actually using rather than the one the attribute stores.
+ *
+ * An automated parameter has two values: the number sitting in the chain, which
+ * is only a seed once a lane exists, and the number the envelope is on right now.
+ * The second is the true one, and a rack that shows the first reads as broken
+ * during playback — the carve is visibly working and the readouts do not move.
+ *
+ * Sampled while paused as well, because the same argument applies to a scrub:
+ * the playhead is somewhere, and the envelope has a value there.
+ *
+ * Sampled off the clip too, which is not obvious. A lane holds its first value
+ * backwards and its last value forwards, so before the clip starts it already
+ * knows what it will open on — while the stored number is a seed the lane
+ * replaced and nothing will ever play. Showing that seed put a value on screen
+ * that the automation never uses, and made the fader jump the moment the clip
+ * came under the playhead.
+ */
+ const playhead = useLivePlayheadTime();
+ const localTime = playhead - clipStart(element.dataAttributes?.["start"]);
+ const liveAutomationValues = ((): Map => {
+ const values = new Map();
+ for (const lane of automation.lanes) {
+ const range = resolveAutomationRange(lane.target, chain);
+ if (!range) continue;
+ values.set(lane.target, sampleAutomationLane(lane, localTime, range.scale));
+ }
+ return values;
+ })();
+
// Written through the live path on purpose. It persists to the source just
// like the refreshing one, but skips the preview reload — and a reload
// restarts every playing track, which is heard as the audio chopping. The
@@ -127,6 +185,18 @@ export function AudioFxGroup({
* commit, which does not exist yet.
*/
const setCarve = async (next: HfCarveSettings | null): Promise => {
+ // Envelopes the carve wrote outlive it otherwise, and an automated gain
+ // ignores the panel's own depth — so switching dynamic off would leave the
+ // filters still following the voice with nothing saying they do.
+ if (!next || (carve?.dynamic && !next.dynamic)) {
+ const carriedOver = withoutCarveLanes(automation, chain);
+ if (carriedOver.lanes.length !== automation.lanes.length) {
+ await onSetAttributeQuiet(
+ HF_AUDIO_AUTOMATION_ATTR,
+ automationAttrValue(carriedOver) || null,
+ );
+ }
+ }
if (!next) {
const kept = chain.nodes.filter((n) => !n.fromCarve);
if (kept.length !== chain.nodes.length) {
@@ -137,6 +207,20 @@ export function AudioFxGroup({
}
}
await onSetAttributeQuiet(HF_AUDIO_CARVE_ATTR, next ? JSON.stringify(next) : null);
+
+ // Every setting here describes the filters, so changing one rebuilds them.
+ // There is no apply button: a carve naming a voice with no filters behind it
+ // is a setting nobody applied, and the panel already knows everything it needs
+ // to. Picking the voice is what starts it; strength and dynamic re-derive what
+ // is already there. A carve with no source yet has nothing to analyse.
+ const changed =
+ next &&
+ next.source &&
+ (!carve ||
+ next.source !== carve.source ||
+ next.strength !== carve.strength ||
+ next.dynamic !== carve.dynamic);
+ if (next && changed) await analyse(next);
};
/** Every lane belonging to a node that is going away. */
@@ -158,6 +242,32 @@ export function AudioFxGroup({
}
})();
+ /**
+ * Is some other track carving against this one?
+ *
+ * A carve is a relationship — a bed is carved against a voice — and the voice is
+ * the far end of it. Offering the same control there offers to carve a track
+ * against itself by proxy, and switching it on left a setting with no source it
+ * could legally name. Read off the other elements' own carve attributes, because
+ * that is where the relationship is recorded.
+ */
+ const carvedAgainstBy = ((): string | null => {
+ const doc = element.element?.ownerDocument;
+ if (!doc || !element.id) return null;
+ for (const other of Array.from(doc.querySelectorAll(`[${HF_AUDIO_CARVE_ATTR}]`))) {
+ if (other.id === element.id) continue;
+ try {
+ const raw = other.getAttribute(HF_AUDIO_CARVE_ATTR);
+ if (raw && normalizeCarveSettings(JSON.parse(raw)).source === element.id) {
+ return other.id || "another track";
+ }
+ } catch {
+ // An unreadable carve on some other element says nothing about this one.
+ }
+ }
+ return null;
+ })();
+
const sourceOptions: AudioTrackOption[] = (() => {
const doc = element.element?.ownerDocument;
if (!doc) return [];
@@ -173,16 +283,14 @@ export function AudioFxGroup({
* on this one. The bands replace any previous carve output but leave
* hand-added effects alone, so re-analysing does not discard other work.
*/
- const analyse = async (): Promise => {
- if (!carve?.source) return;
+ const analyse = async (active: HfCarveSettings | null = carve): Promise => {
+ if (!active?.source) return;
const doc = element.element?.ownerDocument;
- const voice = doc?.getElementById(carve.source) as HTMLAudioElement | null;
+ const voice = doc?.getElementById(active.source) as HTMLAudioElement | null;
const src = voice?.getAttribute("src");
if (!src) return;
setAnalysing(true);
try {
- const res = await fetch(new URL(src, doc!.baseURI).href);
- const bytes = await res.arrayBuffer();
// Decoded in an OfflineAudioContext, not a live one. Opening a second
// output device mid-playback makes the running track glitch while the
// hardware is reconfigured; an offline context touches no device.
@@ -191,24 +299,116 @@ export function AudioFxGroup({
(window as unknown as { webkitOfflineAudioContext?: typeof OfflineAudioContext })
.webkitOfflineAudioContext;
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,
- carveProfile(carve.strength),
- );
+ const decode = async (relative: string): Promise => {
+ const res = await fetch(new URL(relative, doc!.baseURI).href);
+ return new Ctor(1, 1, DECODE_SAMPLE_RATE).decodeAudioData(await res.arrayBuffer());
+ };
+ const buffer = await decode(src);
+ // Strength is what the author set; these are the numbers it means.
+ const profile = carveProfile(active.strength);
+ // The bed as well as the voice, when the carve is asked to match levels:
+ // "how far over the voice is this bed" cannot be answered by listening to
+ // one of them.
+ const bedSrc = profile.duckDb > 0 ? element.element?.getAttribute("src") : null;
+ const bedBuffer = bedSrc ? await decode(bedSrc).catch(() => null) : null;
+ const bands = analyseCarveBands(buffer.getChannelData(0), buffer.sampleRate, profile);
const carved = carveBandsToChain(bands);
+
+ // The level half of the carve, measured against the voice it has to sit
+ // under. Times come back relative to the voice clip; the gap between the
+ // two clips' starts is what aligns them.
+ const offset =
+ clipStart(voice?.getAttribute("data-start")) - clipStart(element.dataAttributes?.["start"]);
+ const duck = bedBuffer
+ ? analyseCarveDuck(
+ buffer.getChannelData(0),
+ bedBuffer.getChannelData(0),
+ buffer.sampleRate,
+ profile,
+ offset,
+ )
+ : [];
+ // Static carve holds one value, so the level match becomes the duck the
+ // voice needs while it is actually speaking — the median of it, which
+ // ignores both the pauses and any single loudest bar.
+ const speaking = duck.filter((p) => p.v < 0).map((p) => p.v);
+ const staticDuckDb = speaking.length
+ ? (speaking.sort((a, b) => a - b)[Math.floor(speaking.length / 2)] ?? 0)
+ : 0;
+
// Carve output is tagged so a re-run replaces it instead of stacking.
const kept = chain.nodes.filter((n) => !n.fromCarve);
+ // Ids, minted against the nodes already claiming one, because a dynamic
+ // carve automates these filters and a lane addresses its node by id.
+ let claimed: HfAudioFxChain = { version: 1, nodes: kept };
+ const mint = (node: HfAudioFxNode): HfAudioFxNode => {
+ const withId = { ...node, id: mintAudioFxNodeId(claimed), fromCarve: true };
+ claimed = { version: 1, nodes: [...claimed.nodes, withId] };
+ return withId;
+ };
+ const carvedNodes: HfAudioFxNode[] = carved.nodes.map(mint);
+ // The gain stage sits after the filters, and only exists when the carve was
+ // asked to make level room. Dynamic drives it from the envelope; static
+ // holds the one value above.
+ const duckNode =
+ duck.length > 0
+ ? mint({
+ type: "gain",
+ enabled: true,
+ params: { ...defaultAudioFxParams("gain"), gain: active.dynamic ? 0 : staticDuckDb },
+ })
+ : null;
const next = {
version: 1,
- nodes: [...carved.nodes.map((n) => ({ ...n, fromCarve: true })), ...kept],
+ nodes: [...carvedNodes, ...(duckNode ? [duckNode] : []), ...kept],
};
// Live, like every other chain write: the runtime swaps the graph in
// place, so a reload would only interrupt the audio to reach the same
// filters.
- onSetAttributeQuiet(HF_AUDIO_FX_ATTR, serializeAudioFxChain(next));
+ //
+ // Awaited, because the automation write below is a second read-modify-write
+ // against the same file — fired together the later one would drop the
+ // earlier — and because a lane naming a node the chain does not have yet is
+ // pruned when it is read back.
+ await onSetAttributeQuiet(HF_AUDIO_FX_ATTR, serializeAudioFxChain(next));
+
+ /**
+ * One carve envelope as a lane on this bed's clock.
+ *
+ * Everything the analysis returns is timed from the start of the voice, so
+ * it shifts by the gap between the two clips; and a lane holds its first
+ * value backwards to the start of its own clip, so a bed that begins before
+ * the voice needs an explicit "no cut" at zero or it starts out ducked.
+ */
+ const laneFor = (id: string, points: { t: number; v: number }[]): HfAutomationLane[] => {
+ const shifted = points
+ .map((p) => ({ t: Number((p.t + offset).toFixed(3)), v: p.v }))
+ .filter((p) => p.t >= 0);
+ if ((shifted[0]?.t ?? 0) > 0) shifted.unshift({ t: 0, v: 0 });
+ return shifted.length > 1
+ ? [{ target: fxAutomationTarget(id, "gain"), points: shifted }]
+ : [];
+ };
+
+ // Dynamic carve: each filter's depth becomes an envelope of the voice's
+ // level in that band, so pauses leave the bed alone.
+ const lanes: HfAutomationLane[] = active.dynamic
+ ? analyseCarveDynamics(buffer.getChannelData(0), buffer.sampleRate, bands).flatMap(
+ (dyn, i) => {
+ const id = carvedNodes[i]?.id;
+ if (!id) return [];
+ return laneFor(id, dyn.points);
+ },
+ )
+ : [];
+ // The level envelope rides the gain stage, on the same clock as the bands.
+ if (active.dynamic && duckNode?.id && duck.length > 0) {
+ lanes.push(...laneFor(duckNode.id, duck));
+ }
+ const carriedOver = withoutCarveLanes(automation, chain);
+ if (lanes.length > 0 || carriedOver.lanes.length !== automation.lanes.length) {
+ writeAutomation({ version: 1, lanes: [...carriedOver.lanes, ...lanes] });
+ }
} catch {
// Leave the chain as it was; the button simply re-enables.
} finally {
@@ -220,6 +420,7 @@ export function AudioFxGroup({
void setCarve(next)}
onCarvePreview={(next) => onSetAttributeLive(HF_AUDIO_CARVE_ATTR, JSON.stringify(next))}
sourceOptions={sourceOptions}
- onAnalyseCarve={() => void analyse()}
+ carvedAgainstBy={carvedAgainstBy}
analysing={analysing}
/>
);
diff --git a/packages/studio/src/components/editor/propertyPanelFxControls.tsx b/packages/studio/src/components/editor/propertyPanelFxControls.tsx
index ffc7ffd88c..67f7e07ee9 100644
--- a/packages/studio/src/components/editor/propertyPanelFxControls.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFxControls.tsx
@@ -42,6 +42,12 @@ function display(p: HfAudioFxNumberParam, value: number): string {
interface ParamRowProps {
param: HfAudioFxParam;
value: number | string;
+ /**
+ * The envelope's value at the playhead, when a lane drives this parameter and
+ * the playhead is over the clip. Shown in place of `value`, which by then is
+ * only the seed the lane replaced.
+ */
+ liveValue?: number;
/** Fires continuously while dragging — cheap, not persisted. */
onChange(key: string, value: number | string): void;
/** Fires once when the gesture ends — this is the write that persists. */
@@ -100,6 +106,7 @@ export function AutomationToggle({
export function FxParamRow({
param,
value,
+ liveValue,
onChange,
onCommit,
disabled,
@@ -159,7 +166,11 @@ export function FxParamRow({
);
}
- const shown = dragging ? local : value;
+ // An envelope's value at the playhead outranks the one stored in the chain,
+ // because it is the one the audio is using — the stored number is only the seed
+ // the lane replaced. Safe against the pointer: an automated control is locked
+ // (see `locked` below), so there is no drag for this to fight.
+ const shown = dragging ? local : (liveValue ?? value);
const numeric = typeof shown === "number" ? shown : Number(shown);
const current = Number.isFinite(numeric) ? numeric : param.default;
@@ -229,6 +240,8 @@ export function FxParamRow({
interface FxParamsProps {
def: HfAudioFxDef;
params: HfAudioFxParamValues;
+ /** What automated knobs are worth at the playhead, by parameter key. */
+ liveValues?: ReadonlyMap;
onChange(params: HfAudioFxParamValues): void;
onCommit?(params: HfAudioFxParamValues): void;
disabled?: boolean;
@@ -243,6 +256,7 @@ interface FxParamsProps {
export function FxParams({
def,
params,
+ liveValues,
onChange,
onCommit,
disabled,
@@ -270,6 +284,7 @@ export function FxParams({
key={p.key}
param={p}
value={params[p.key] ?? p.default}
+ liveValue={liveValues?.get(p.key)}
onChange={set}
onCommit={commit}
disabled={disabled}
diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx
index 397b6b0793..cd6e5aeea6 100644
--- a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx
@@ -28,8 +28,6 @@ const chainOf = (...types: string[]): HfAudioFxChain => ({
nodes: types.map((t) => ({ type: t, enabled: true, params: defaultAudioFxParams(t) })),
});
-const noop = () => {};
-
function mount(overrides: Partial[0]> = {}) {
const onChainChange = vi.fn();
const onChainPreview = vi.fn();
@@ -42,7 +40,6 @@ function mount(overrides: Partial[0]> = {}) {
carve={overrides.carve ?? null}
onCarveChange={overrides.onCarveChange ?? onCarveChange}
sourceOptions={overrides.sourceOptions ?? [{ id: "vo", label: "Voiceover" }]}
- onAnalyseCarve={overrides.onAnalyseCarve ?? noop}
analysing={overrides.analysing}
disabled={overrides.disabled}
automatedTargets={overrides.automatedTargets}
@@ -200,6 +197,123 @@ describe("FxSection chain", () => {
});
});
+describe("FxSection carve module", () => {
+ /**
+ * A carve is one thing an author turned on, not the six filters it happens to
+ * compile to. Listed individually they read as hand-built effects: removable
+ * one at a time, reorderable, each with knobs that the next strength change
+ * overwrites without warning.
+ */
+ const carved = {
+ version: 1,
+ nodes: [
+ { type: "peaking", id: "n1", fromCarve: true, params: { frequency: 400, gain: -6, q: 1.4 } },
+ { type: "peaking", id: "n2", fromCarve: true, params: { frequency: 1600, gain: -9, q: 1.4 } },
+ { type: "gain", id: "n3", fromCarve: true, params: { gain: -6 } },
+ { type: "lowpass", id: "n4", params: { frequency: 8000, q: 0.7, poles: "2" } },
+ ],
+ } as unknown as HfAudioFxChain;
+
+ it("shows the carve's effects as one module, alongside hand-built ones", () => {
+ const { host } = mount({ chain: carved });
+ const rows = Array.from(host.querySelectorAll(".hf-fx-node"));
+ // One row for the carve, one for the low-pass the author added.
+ expect(rows).toHaveLength(2);
+ expect(host.querySelector(".hf-fx-carve-module")).not.toBeNull();
+ expect(host.querySelector(".hf-fx-carve-module")?.textContent).toContain("Voiceover carve");
+ });
+
+ it("says what the module contains, since its parts are not listed", () => {
+ const { host } = mount({ chain: carved });
+ const text = host.querySelector(".hf-fx-carve-module")?.textContent ?? "";
+ expect(text).toMatch(/2 bands/);
+ expect(text).toMatch(/level/);
+ });
+
+ it("removes every carve effect together, never one of them", () => {
+ const onChainChange = vi.fn();
+ const { host } = mount({ chain: carved, onChainChange });
+ const removes = Array.from(
+ host.querySelectorAll(".hf-fx-carve-module .hf-fx-remove"),
+ );
+ expect(removes).toHaveLength(1);
+ act(() => removes[0]!.click());
+ const nodes = onChainChange.mock.calls[0]![0].nodes as { id: string }[];
+ expect(nodes.map((n) => n.id)).toEqual(["n4"]);
+ });
+
+ it("bypasses the whole module at once", () => {
+ const onChainChange = vi.fn();
+ const { host } = mount({ chain: carved, onChainChange });
+ const bypass = host.querySelector(".hf-fx-carve-module .hf-fx-bypass")!;
+ act(() => bypass.click());
+ const nodes = onChainChange.mock.calls[0]![0].nodes as { id: string; enabled?: boolean }[];
+ expect(nodes.filter((n) => n.id !== "n4").every((n) => n.enabled === false)).toBe(true);
+ // The author's own effect is not touched.
+ expect(nodes.find((n) => n.id === "n4")?.enabled).not.toBe(false);
+ });
+
+ it("lists what each effect inside it is set to", () => {
+ // Grouped is not hidden: the carve compiles to real filters and an author has
+ // to be able to see where they landed. What they cannot do is edit them by
+ // hand — strength owns those numbers — so the settings read out rather than
+ // offering controls that the next adjustment would overwrite.
+ const { host } = mount({ chain: carved });
+ const module = host.querySelector(".hf-fx-carve-module")!;
+ act(() => module.querySelector(".hf-fx-node-name")!.click());
+ const members = Array.from(module.querySelectorAll(".hf-fx-carve-member"));
+ expect(members).toHaveLength(3);
+ // Named by what tells them apart, the way the timeline lanes name them.
+ expect(members.map((m) => m.querySelector(".hf-fx-carve-member-name")?.textContent)).toEqual([
+ "Peaking EQ 400 Hz",
+ "Peaking EQ 1.6 kHz",
+ "Gain",
+ ]);
+ // And every one of its settings is visible.
+ const first = members[0]!.textContent ?? "";
+ expect(first).toContain("Gain");
+ expect(first).toMatch(/-6(\.0)? dB/);
+ expect(first).toMatch(/1\.4/); // Q
+ });
+
+ it("reads its settings out rather than offering controls", () => {
+ const { host } = mount({ chain: carved });
+ const module = host.querySelector(".hf-fx-carve-module")!;
+ act(() => module.querySelector(".hf-fx-node-name")!.click());
+ expect(module.querySelectorAll("input")).toHaveLength(0);
+ });
+
+ it("says which of them the timeline is driving", () => {
+ // A carve in dynamic mode automates every one of these, and that is where the
+ // values come from — so the module has to point at the lane rather than look
+ // like a static setting.
+ const { host } = mount({
+ chain: carved,
+ automatedTargets: new Set(["fx.n1.gain", "fx.n3.gain"]),
+ });
+ const module = host.querySelector(".hf-fx-carve-module")!;
+ act(() => module.querySelector(".hf-fx-node-name")!.click());
+ const automated = Array.from(module.querySelectorAll("[data-automated]"));
+ expect(automated).toHaveLength(2);
+ });
+
+ it("keeps the summary readable while collapsed", () => {
+ const { host } = mount({ chain: carved });
+ const module = host.querySelector(".hf-fx-carve-module")!;
+ expect(module.querySelectorAll(".hf-fx-carve-member")).toHaveLength(0);
+ expect(module.textContent).toContain("2 bands + level");
+ });
+
+ it("offers no per-effect controls inside the module", () => {
+ // Reordering or editing one band is meaningless: the next strength change
+ // rewrites every one of them.
+ const { host } = mount({ chain: carved });
+ const module = host.querySelector(".hf-fx-carve-module")!;
+ expect(module.querySelectorAll(".hf-fx-move")).toHaveLength(0);
+ expect(module.querySelectorAll("input[type=range]")).toHaveLength(0);
+ });
+});
+
describe("FxSection carve", () => {
it("is off by default and is not an entry in the chain", () => {
const { host } = mount();
@@ -229,16 +343,17 @@ describe("FxSection carve", () => {
expect(options).toContain("Narration");
});
- it("will not analyse until a source is chosen", () => {
- const { host } = mount({ carve: { ...DEFAULT_CARVE, source: "" } });
- expect(host.querySelector(".hf-fx-analyse")!.disabled).toBe(true);
+ it("offers no analyse button — picking a voice is the whole gesture", () => {
+ // A carve with a source and no filters is a setting nobody applied; the
+ // button was a second step for something the panel already knew to do.
+ const { host } = mount({ carve: { ...DEFAULT_CARVE, source: "vo" } });
+ expect(host.querySelector(".hf-fx-analyse")).toBeNull();
+ expect(host.textContent).not.toMatch(/Analyse/i);
});
- it("analyses once a source is chosen", () => {
- const onAnalyseCarve = vi.fn();
- const { host } = mount({ carve: { ...DEFAULT_CARVE, source: "vo" }, onAnalyseCarve });
- click(host.querySelector(".hf-fx-analyse"));
- expect(onAnalyseCarve).toHaveBeenCalledTimes(1);
+ it("says when it is working, since there is no button to grey out", () => {
+ const { host } = mount({ carve: { ...DEFAULT_CARVE, source: "vo" }, analysing: true });
+ expect(host.querySelector(".hf-fx-carve-working")?.textContent).toMatch(/Analysing/i);
});
it("disables everything when the panel is read-only", () => {
diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.tsx
index 8e629af84b..3a99c77862 100644
--- a/packages/studio/src/components/editor/propertyPanelFxSection.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFxSection.tsx
@@ -18,11 +18,15 @@ import {
type HfAudioFxDef,
type HfAudioFxGroup,
type HfAudioFxNode,
+ type HfAudioFxParam,
type HfAudioFxParamValues,
} from "@hyperframes/core/audio-fx";
import { DEFAULT_CARVE, type HfCarveSettings } from "@hyperframes/core/audio-carve";
import { fxAutomationTarget } from "@hyperframes/core/audio-automation";
import { FxParams, FxParamRow } from "./propertyPanelFxControls.js";
+// Shared with the timeline's lane labels: a band is named by its frequency in
+// both places, and two formatters would drift.
+import { formatHz } from "../../player/components/automationLaneData";
const GROUP_ORDER: HfAudioFxGroup[] = ["filter", "dynamics", "nonlinear", "time"];
const GROUP_LABEL: Record = {
@@ -41,6 +45,7 @@ interface FxNodeRowProps {
node: HfAudioFxNode;
index: number;
automatedTargets?: ReadonlySet;
+ liveAutomationValues?: ReadonlyMap;
onAutomateParam?(nodeId: string, paramKey: string): void;
onRemoveParamAutomation?(nodeId: string, paramKey: string): void;
open: boolean;
@@ -148,6 +153,205 @@ function FxNodeHeader({
);
}
+/**
+ * The carve's own effects, as one module.
+ *
+ * A carve is one thing the author switched on; the peaking filters and the level
+ * stage are how it is built. Listed individually they read as hand-built effects
+ * — removable one at a time, reorderable, each with knobs the next strength
+ * change silently overwrites. So the rack shows the unit, says what is inside it,
+ * and offers the two actions that mean anything for a whole module: bypass it,
+ * or remove it.
+ */
+/** What one effect inside the module is called: its own name, plus the band. */
+function carveMemberName(node: HfAudioFxNode): string {
+ const def = getAudioFxDef(node.type);
+ const freq = node.params?.["frequency"];
+ const label = def?.label ?? node.type;
+ return typeof freq === "number" ? `${label} ${formatHz(freq)}` : label;
+}
+
+/** A parameter's value as the rack shows it: rounded to the step, with its unit. */
+function formatParamValue(param: HfAudioFxParam, raw: number | string | undefined): string {
+ if (param.kind !== "number" || typeof raw !== "number") return String(raw ?? "");
+ const places = param.step >= 1 ? 0 : param.step >= 0.1 ? 1 : 2;
+ return `${Number(raw.toFixed(places))}${param.unit ? ` ${param.unit}` : ""}`;
+}
+
+/**
+ * Width to reserve for a parameter's value, in characters.
+ *
+ * Derived from what the parameter CAN read rather than what it currently reads, so
+ * the column never moves: an automated value updates 30 times a second, and
+ * `-1 dB` is two characters narrower than `-3.2 dB`, which was enough to shunt
+ * everything after it sideways on every frame. `ch` is exact here because the
+ * readouts are monospace and already `tabular-nums`.
+ */
+function paramValueWidthCh(param: HfAudioFxParam): number {
+ if (param.kind === "enum") {
+ return Math.max(1, ...param.options.map((option) => option.value.length));
+ }
+ const places = param.step >= 1 ? 0 : param.step >= 0.1 ? 1 : 2;
+ const digits = Math.max(
+ String(Math.floor(Math.abs(param.min))).length,
+ String(Math.floor(Math.abs(param.max))).length,
+ );
+ const sign = param.min < 0 ? 1 : 0;
+ const decimals = places > 0 ? places + 1 : 0;
+ const unit = param.unit ? param.unit.length + 1 : 0;
+ return sign + digits + decimals + unit;
+}
+
+/** One member of the module: what it is, and what every knob is set to. */
+function FxCarveMember({
+ node,
+ automatedTargets,
+ liveAutomationValues,
+}: {
+ node: HfAudioFxNode;
+ automatedTargets?: ReadonlySet;
+ liveAutomationValues?: ReadonlyMap;
+}) {
+ const def = getAudioFxDef(node.type);
+ if (!def) return null;
+ const params = node.params ?? defaultAudioFxParams(node.type);
+ return (
+
+
+ {carveMemberName(node)}
+
+
+ {def.params.map((param) => {
+ const target = node.id ? fxAutomationTarget(node.id, param.key) : null;
+ const automated = Boolean(target && automatedTargets?.has(target));
+ // The envelope's value at the playhead when there is one, which is what
+ // the audio is using; the stored number is only the seed behind it.
+ const live = target ? liveAutomationValues?.get(target) : undefined;
+ const driven = automated && live !== undefined;
+ const value = formatParamValue(param, driven ? live : params[param.key]);
+ return (
+
+ {param.label}
+
+ {value}
+
+ {/* The lane is where an automated value comes from, and where it is
+ edited — saying so is the difference between a stale readout and
+ a pointer to the thing that owns it. */}
+ {automated ? A : null}
+
+ );
+ })}
+
+
+ );
+}
+
+/**
+ * The carve's own effects, as one module.
+ *
+ * A carve is one thing the author switched on; the peaking filters and the level
+ * stage are how it is built. Listed individually in the rack they read as
+ * hand-built effects — removable one at a time, reorderable, each with knobs the
+ * next strength change silently overwrites. So the rack shows the unit, and the
+ * unit owns the actions that mean anything for a whole module: bypass, remove.
+ *
+ * Grouped is not hidden. Opening it lists every effect inside with all of its
+ * settings, because an author has to be able to see where the analysis landed —
+ * as readouts rather than controls, since strength is what sets them and a knob
+ * here would be overwritten by the next adjustment. A value the timeline drives
+ * says so, and points at the lane that owns it.
+ */
+function FxCarveModule({
+ nodes,
+ automatedTargets,
+ liveAutomationValues,
+ open,
+ disabled,
+ onToggleOpen,
+ onToggleBypass,
+ onRemove,
+}: {
+ nodes: HfAudioFxNode[];
+ automatedTargets?: ReadonlySet;
+ liveAutomationValues?: ReadonlyMap;
+ open: boolean;
+ disabled?: boolean;
+ onToggleOpen(): void;
+ onToggleBypass(): void;
+ onRemove(): void;
+}) {
+ const bands = nodes.filter((n) => n.type === "peaking").length;
+ const hasLevel = nodes.some((n) => n.type === "gain");
+ const bypassed = nodes.every((n) => n.enabled === false);
+ const summary = [`${bands} band${bands === 1 ? "" : "s"}`, ...(hasLevel ? ["level"] : [])].join(
+ " + ",
+ );
+ return (
+
+
+
+ Voiceover carve
+
+
+ {summary}
+
+
+ {bypassed ? "Off" : "On"}
+
+
+ ×
+
+
+ {open ? (
+ // Divided rows rather than boxes: these are parts of one module, and a
+ // border around each would read as the separate effects this replaced.
+
+ {nodes.map((node, i) => (
+
+ ))}
+
+ ) : null}
+
+ );
+}
+
/**
* Which of an effect's knobs already have a lane.
*
@@ -174,6 +378,7 @@ function FxNodeParams({
index,
disabled,
automatedTargets,
+ liveAutomationValues,
onUpdate,
onPreview,
onAutomateParam,
@@ -184,16 +389,29 @@ function FxNodeParams({
index: number;
disabled: boolean;
automatedTargets?: ReadonlySet;
+ liveAutomationValues?: ReadonlyMap;
onUpdate(index: number, patch: Partial): void;
onPreview(index: number, params: HfAudioFxParamValues): void;
onAutomateParam?(nodeId: string, paramKey: string): void;
onRemoveParamAutomation?(nodeId: string, paramKey: string): void;
}) {
const nodeId = node.id;
+ // Lanes address a node by id; the controls know their own parameter keys. This
+ // is the one place that translation belongs.
+ const liveValues = ((): Map | undefined => {
+ if (!nodeId || !liveAutomationValues?.size) return undefined;
+ const byKey = new Map();
+ for (const param of def.params) {
+ const live = liveAutomationValues.get(fxAutomationTarget(nodeId, param.key));
+ if (live !== undefined) byKey.set(param.key, live);
+ }
+ return byKey;
+ })();
return (
onPreview(index, params)}
onCommit={(params: HfAudioFxParamValues) => onUpdate(index, { params })}
@@ -213,6 +431,7 @@ function FxNodeRow({
node,
index,
automatedTargets,
+ liveAutomationValues,
onAutomateParam,
onRemoveParamAutomation,
open,
@@ -251,6 +470,7 @@ function FxNodeRow({
index={index}
disabled={Boolean(disabled) || bypassed}
automatedTargets={automatedTargets}
+ liveAutomationValues={liveAutomationValues}
onUpdate={onUpdate}
onPreview={onPreview}
onAutomateParam={onAutomateParam}
@@ -265,6 +485,15 @@ export interface FxSectionProps {
chain: HfAudioFxChain;
/** Targets this track already automates, as `fx.. ` strings. */
automatedTargets?: ReadonlySet;
+ /**
+ * What each automated target is worth at the playhead, by the same key.
+ *
+ * An automated parameter's stored number is only the seed the lane replaced, so
+ * a rack that shows it stands still while the carve is audibly working. Absent,
+ * or missing a key, means there is no playhead over this clip and the stored
+ * value is the honest one.
+ */
+ liveAutomationValues?: ReadonlyMap;
/** Add a lane for one effect parameter, seeded at its current value. */
onAutomateParam?(nodeId: string, paramKey: string): void;
/** Delete one effect parameter's lane. */
@@ -281,10 +510,13 @@ export interface FxSectionProps {
/** Continuous updates while a carve slider is dragged. Without this every
* pointermove patched the source file and resynced the selection. */
onCarvePreview?(carve: HfCarveSettings): void;
+ /**
+ * Set when another track's carve listens to this one, naming it. The carve block
+ * is then not offered here at all: this track is the voice, not the bed.
+ */
+ carvedAgainstBy?: string | null;
/** Other audio elements that could act as the carve source. */
sourceOptions: AudioTrackOption[];
- /** Re-run analysis against the current source audio. */
- onAnalyseCarve?(): void;
analysing?: boolean;
disabled?: boolean;
}
@@ -292,16 +524,17 @@ export interface FxSectionProps {
export function FxSection({
chain,
automatedTargets,
+ liveAutomationValues,
onAutomateParam,
onRemoveParamAutomation,
onRemoveNodeAutomation,
onChainChange,
onChainPreview,
carve,
+ carvedAgainstBy,
onCarveChange,
onCarvePreview,
sourceOptions,
- onAnalyseCarve,
analysing,
disabled,
}: FxSectionProps) {
@@ -310,7 +543,10 @@ export function FxSection({
const previewCarve = onCarvePreview ?? onCarveChange;
// Nothing to carve against means nothing to show — see the block below.
- const showCarve = sourceOptions.length > 0 || carve !== null;
+ // Not offered on the voice another track is already carving against — that
+ // track is the far end of someone else's relationship, and a carve of its own
+ // could only name a source it must not.
+ const showCarve = !carvedAgainstBy && (sourceOptions.length > 0 || carve !== null);
const [adding, setAdding] = useState(false);
const [openNode, setOpenNode] = useState(0);
@@ -368,6 +604,24 @@ export function FxSection({
[chain.nodes, mutate, onRemoveNodeAutomation],
);
+ const [carveOpen, setCarveOpen] = useState(false);
+ const carveNodes = useMemo(() => chain.nodes.filter((n) => n.fromCarve), [chain.nodes]);
+
+ /** Remove the carve's effects together, with the envelopes they carried. */
+ const removeCarve = useCallback(() => {
+ for (const node of carveNodes) {
+ if (node.id) onRemoveNodeAutomation?.(node.id);
+ }
+ mutate(chain.nodes.filter((n) => !n.fromCarve));
+ setOpenNode(null);
+ }, [carveNodes, chain.nodes, mutate, onRemoveNodeAutomation]);
+
+ /** Bypass or enable every carve effect at once — the module is the unit. */
+ const toggleCarveBypass = useCallback(() => {
+ const bypassed = carveNodes.every((n) => n.enabled === false);
+ mutate(chain.nodes.map((n) => (n.fromCarve ? { ...n, enabled: bypassed } : n)));
+ }, [carveNodes, chain.nodes, mutate]);
+
const moveNode = useCallback(
(index: number, delta: number) => {
const target = index + delta;
@@ -389,24 +643,46 @@ export function FxSection({
No effects on this track.
) : (
- chain.nodes.map((node, i) => (
- setOpenNode(openNode === i ? null : i)}
- onUpdate={updateNode}
- onMove={moveNode}
- onRemove={removeNode}
- onPreview={previewNode}
- />
- ))
+ chain.nodes.map((node, i) => {
+ if (node.fromCarve) {
+ // The module stands in for the whole run of carve nodes, drawn once
+ // at the first of them.
+ const first = chain.nodes.findIndex((n) => n.fromCarve);
+ if (i !== first) return null;
+ return (
+ setCarveOpen((was) => !was)}
+ onToggleBypass={toggleCarveBypass}
+ onRemove={removeCarve}
+ />
+ );
+ }
+ return (
+ setOpenNode(openNode === i ? null : i)}
+ onUpdate={updateNode}
+ onMove={moveNode}
+ onRemove={removeNode}
+ onPreview={previewNode}
+ />
+ );
+ })
)}
@@ -484,6 +760,12 @@ export function FxSection({
))}
+ {/* One knob for the whole effect. Depth, band count, width, the
+ intelligibility weighting and both level-match numbers move
+ together anyway — a gentle carve is shallow in few bands with
+ little ducking, a hard one is deeper in more with more — so the
+ panel sets the strength and `carveProfile` derives the six
+ numbers the analysis works in. */}
previewCarve({ ...carve, strength: Number(v) })}
onCommit={(_k, v) => onCarveChange({ ...carve, strength: Number(v) })}
/>
- onAnalyseCarve?.()}
- >
- {analysing ? "Analysing…" : "Analyse and apply"}
-
+ {/* A static carve holds its cuts for the whole clip, pauses
+ included. Dynamic hands every value to an envelope of the voice's
+ own level, so the bed is only worked on while there is something
+ to make room for. Written as ordinary automation, which is why the
+ lanes show up in the timeline and can be edited afterwards. */}
+
+
+ Dynamic
+
+ onCarveChange({ ...carve, dynamic: e.target.checked })}
+ />
+
+ follows the voice, flat where it is silent
+
+
+ {analysing ? (
+
+ Analysing…
+
+ ) : null}
>
) : null}
diff --git a/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx b/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx
index 6fb1230447..87fb8523df 100644
--- a/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx
+++ b/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx
@@ -20,8 +20,8 @@ import type { TimelineElement } from "../player/store/timelineElement";
* A selection box spanning the lane's whole value axis.
*
* What almost every test here is about is the time span — which breakpoints a
- * Delete or a copy covers. Giving these an unbounded axis keeps the fixture
- * out of the way of that.
+ * Delete or a copy covers. The box's value bounds have their own tests; giving
+ * these an unbounded axis keeps them testing the one thing they name.
*/
function wholeAxis(sel: T): T & { v0: number; v1: number } {
return { ...sel, v0: Number.NEGATIVE_INFINITY, v1: Number.POSITIVE_INFINITY };
@@ -117,7 +117,11 @@ describe("useAutomationSelectionKeyboard", () => {
return { onCommit };
};
- it("Delete empties the selected range and pins anchors", () => {
+ it("Delete removes every breakpoint the selection covers", () => {
+ // Deleted, not emptied. Pinning anchors at the selection's edges keeps the
+ // envelope either side from moving, which is right for a shape insert or a
+ // paste — but answering "delete these points" with two NEW points at the edges
+ // reads as the delete not having worked.
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
usePlayerStore
.getState()
@@ -126,7 +130,69 @@ describe("useAutomationSelectionKeyboard", () => {
key("Delete");
const written = onCommit.mock.calls.at(-1)?.[0];
const points = written?.lanes?.[0]?.points ?? [];
- expect(points.map((p: { t: number }) => p.t)).toEqual([0, 1, 3, 4]);
+ // The fixture lane is 0, 2, 4: only t=2 was inside.
+ expect(points.map((p: { t: number }) => p.t)).toEqual([0, 4]);
+ });
+
+ it("Delete leaves a point the box's value bounds exclude", () => {
+ // The box spans the whole clip but only its top, so Delete takes the one
+ // breakpoint up there and nothing else. A time range could not express this.
+ usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
+ usePlayerStore.getState().setAutomationSelection({
+ elementKey: "bgm",
+ target: "volume",
+ t0: 0,
+ t1: 4,
+ v0: 0.9,
+ v1: 1,
+ });
+ const { onCommit } = setup({});
+ key("Delete");
+ const written = onCommit.mock.calls.at(-1)?.[0];
+ const points = written?.lanes?.[0]?.points ?? [];
+ // Fixture is (0, v=1), (2, v=0.5), (4, v=0): only the first was in the box.
+ expect(points.map((p: { t: number }) => p.t)).toEqual([2, 4]);
+ });
+
+ it("Delete takes points sitting exactly on the selection's edges", () => {
+ // Endpoint-inclusive, matching the copy path: a point the selection was dragged
+ // over is inside it, edge or not. Every range operation leaves a breakpoint
+ // exactly on an edge, so excluding them would leave those behind every time.
+ usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
+ usePlayerStore
+ .getState()
+ .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
+ const { onCommit } = setup({});
+ key("Delete");
+ const points = onCommit.mock.calls.at(-1)?.[0]?.lanes?.[0]?.points ?? [];
+ expect(points.map((p: { t: number }) => p.t)).toEqual([0]);
+ });
+
+ it("Delete over a stretch with no breakpoints writes nothing at all", () => {
+ // A no-op rather than a write: emptying a span that had nothing in it used to
+ // push an undo entry that changed nothing but the anchors it invented.
+ usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
+ usePlayerStore
+ .getState()
+ .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2.5, t1: 3.5 }));
+ const { onCommit } = setup({});
+ const e = new KeyboardEvent("keydown", { key: "Delete", bubbles: true, cancelable: true });
+ act(() => void document.dispatchEvent(e));
+ expect(onCommit).not.toHaveBeenCalled();
+ expect(e.defaultPrevented).toBe(false);
+ });
+
+ it("Delete clears the lane when the selection covers all of it", () => {
+ usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
+ usePlayerStore
+ .getState()
+ .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 0, t1: 6 }));
+ const { onCommit } = setup({});
+ key("Delete");
+ const written = onCommit.mock.calls.at(-1)?.[0];
+ // withLane drops a lane with no points left, so the attribute goes empty and
+ // the clip is back to its plain data-volume.
+ expect(written?.lanes ?? []).toEqual([]);
});
it("Escape clears the selection", () => {
@@ -194,8 +260,10 @@ describe("useAutomationSelectionKeyboard", () => {
target: "volume",
t0: 5,
t1: 7,
- v0: VOLUME_RANGE.min,
- v1: VOLUME_RANGE.max,
+ // Full height: everything the paste landed is selected, so Delete straight
+ // after undoes it in one press.
+ v0: 0,
+ v1: 1,
});
});
@@ -231,8 +299,10 @@ describe("useAutomationSelectionKeyboard", () => {
target: "volume",
t0: 4,
t1: 6,
- v0: VOLUME_RANGE.min,
- v1: VOLUME_RANGE.max,
+ // Full height: everything the paste landed is selected, so Delete straight
+ // after undoes it in one press.
+ v0: 0,
+ v1: 1,
});
});
@@ -267,8 +337,10 @@ describe("useAutomationSelectionKeyboard", () => {
target: "volume",
t0: 4,
t1: 6,
- v0: VOLUME_RANGE.min,
- v1: VOLUME_RANGE.max,
+ // Full height: everything the paste landed is selected, so Delete straight
+ // after undoes it in one press.
+ v0: 0,
+ v1: 1,
});
});
diff --git a/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts b/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts
index 8dfb9704ac..5e73ab52f8 100644
--- a/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts
+++ b/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts
@@ -1,8 +1,8 @@
/**
* Keyboard surface for the active automation selection: Escape clears,
- * Delete/Backspace empties the range (anchors pinned, envelope outside
- * untouched), Cmd/Ctrl+C copies it, Cmd/Ctrl+V pastes at the selection's
- * start (or the playhead) onto the selected clip's lane. Sibling of
+ * Delete/Backspace deletes every breakpoint inside the selection box, Cmd/Ctrl+C
+ * copies its span, Cmd/Ctrl+V pastes at the selection's start (or the playhead) onto the
+ * selected clip's lane. Sibling of
* useKeyframeKeyboard and copies its contract: capture phase so playback
* shortcuts cannot swallow keys we act on, inert while any text input has
* focus, and a key is only consumed when it does something.
@@ -16,7 +16,7 @@
import { useEffect } from "react";
import { usePlayerStore, type TimelineElement } from "../player/store/playerStore";
import { laneFor, withLane } from "../player/components/automationLaneGeometry";
-import { replaceRange } from "../player/components/automationLaneSelection";
+import { pointInSelection, replaceRange } from "../player/components/automationLaneSelection";
import {
copyRange,
isLastPasteSpan,
@@ -87,11 +87,22 @@ function resolveSelectionContext(
}
/**
- * The write that empties the active selection, or null when there is nothing
- * to do: the clip is gone, its lane is read-only, the target no longer
- * resolves to a range, or the lane already has no points in it. Split out of
- * the keydown handler so each stays under the complexity a single branch of
- * keyboard dispatch should carry.
+ * The write that deletes the breakpoints inside the active selection, or null when
+ * there is nothing to do: the clip is gone, its lane is read-only, the target no
+ * longer resolves to a range, or the selection covers no breakpoints.
+ *
+ * Deletes them outright rather than emptying the span behind anchor points. Anchors
+ * are what `replaceRange` exists for, and they are right for a shape insert or a
+ * paste — the envelope either side of the edit must not move. But Delete over a
+ * selection is the author saying "these points, gone", and answering that with two
+ * NEW points at the selection's edges reads as the delete not having worked. The
+ * envelope between the surviving neighbours re-interpolates, which is what deleting
+ * a breakpoint means everywhere else in the lane (right-clicking one does exactly
+ * this).
+ *
+ * Both axes of the selection box, edges included: what Delete removes is exactly
+ * what the lane drew a ring around. A point at the right time but outside the box's
+ * value bounds stays — which is the whole reason the box has them.
*/
function resolveDeleteWrite(
state: PlayerState,
@@ -99,14 +110,12 @@ function resolveDeleteWrite(
sel: AutomationSelection,
): { onCommit(next: HfAutomation): void; next: HfAutomation } | null {
const ctx = resolveSelectionContext(state, lanes, sel);
- if (!ctx || ctx.lane.points.length === 0) return null;
- const points = replaceRange({
- lane: ctx.lane,
- range: ctx.range,
- t0: sel.t0,
- t1: sel.t1,
- inner: [],
- });
+ if (!ctx) return null;
+ const points = ctx.lane.points.filter((p) => !pointInSelection(p, sel));
+ // Nothing inside is nothing to do — and it must stay a no-op rather than
+ // writing, or Delete over a smooth stretch would push an undo entry that
+ // changed nothing.
+ if (points.length === ctx.lane.points.length) return null;
return {
onCommit: ctx.binding.onCommit,
next: withLane(ctx.binding.automation, { target: sel.target, points }),
@@ -246,9 +255,9 @@ function handlePaste(
paste.binding.onCommit(withLane(paste.binding.automation, { target: paste.target, points }));
// Select the pasted span — the only feedback that it landed — and mark it, so
// an immediate second Cmd+V recognises this selection as the paste's own and
- // chains right after it instead of overwriting it. Full-height box over the
- // pasted span: this path only ever reads t0/t1, so the box's value bounds are
- // cosmetic — but the parameter's own range keeps it a sensible box to draw.
+ // chains right after it instead of overwriting it.
+ // Full-height box over the pasted span: everything that landed is selected, so
+ // Delete straight after a paste undoes it in one press.
const mark = {
elementKey: paste.elementKey,
target: paste.target,
diff --git a/packages/studio/src/hooks/useDomEditAttributeCommits.ts b/packages/studio/src/hooks/useDomEditAttributeCommits.ts
index 7b7a96de93..7d72bd9620 100644
--- a/packages/studio/src/hooks/useDomEditAttributeCommits.ts
+++ b/packages/studio/src/hooks/useDomEditAttributeCommits.ts
@@ -394,7 +394,19 @@ export function useDomEditAttributeCommits({
},
onError: (error) => reportDomEditPersistFailure(domEditSelection, [op], error, showToast),
shouldResync: () => isLatestCommit(),
- resync: () => refreshDomEditSelectionFromPreview(domEditSelection),
+ resync: () => {
+ refreshDomEditSelectionFromPreview(domEditSelection);
+ // The player store keeps its own copy of each element's attributes, and
+ // that copy is what the timeline's automation lanes draw from. Nothing
+ // else refreshes it: a commit patches the preview document and the file,
+ // and resyncs the dom-edit SELECTION for the panel. So every writer that
+ // did not also update the store by hand — the FX panel's automate and
+ // un-automate buttons, the keyboard Delete, a paste — changed the file and
+ // the audio while the lane went on drawing what it had, until a reload.
+ // One sink here rather than a sync in each writer, because three of them
+ // shipped without one.
+ syncStoredAutomationFromPreview(previewIframeRef.current?.contentDocument ?? null);
+ },
});
},
[
diff --git a/packages/studio/src/hooks/useDomEditCommits.test.tsx b/packages/studio/src/hooks/useDomEditCommits.test.tsx
index cf548aaf76..0dac41195f 100644
--- a/packages/studio/src/hooks/useDomEditCommits.test.tsx
+++ b/packages/studio/src/hooks/useDomEditCommits.test.tsx
@@ -1338,6 +1338,44 @@ describe("useDomEditCommits attribute persist handling", () => {
}
});
+ it("applies a preview-only write without persisting it", async () => {
+ // What a drag needs from every pointermove: the preview and the audio graph
+ // follow, the file does not. Persisting each move filled the undo stack with
+ // fragments of one gesture — and because those writes race, a follow-up's
+ // "before" often was not the previous entry's "after", so history refused to
+ // coalesce them and undo took back a few milliseconds of the drag.
+ const fetchSpy = stubPatchFetch({ ok: true, changed: true, matched: true });
+ const { iframe, element } = createPreviewElement();
+ const rendered = renderDomEditCommits(createSelection(element), iframe);
+
+ try {
+ await act(async () => {
+ await rendered.hook.handleDomAttributeLiveCommit("volume", "0.7", undefined, {
+ previewOnly: true,
+ });
+ });
+ expect(element.getAttribute("data-volume")).toBe("0.7");
+ expect(fetchSpy).not.toHaveBeenCalled();
+ } finally {
+ rendered.cleanup();
+ }
+ });
+
+ it("still persists a live write that does not ask to be preview-only", async () => {
+ const fetchSpy = stubPatchFetch({ ok: true, changed: true, matched: true });
+ const { iframe, element } = createPreviewElement();
+ const rendered = renderDomEditCommits(createSelection(element), iframe);
+
+ try {
+ await act(async () => {
+ await rendered.hook.handleDomAttributeLiveCommit("volume", "0.7");
+ });
+ expect(fetchSpy).toHaveBeenCalled();
+ } finally {
+ rendered.cleanup();
+ }
+ });
+
it("keeps a data-attribute commit on success", async () => {
stubPatchFetch({
ok: true,
diff --git a/packages/studio/src/hooks/useLivePlayheadTime.ts b/packages/studio/src/hooks/useLivePlayheadTime.ts
new file mode 100644
index 0000000000..72c0d2972b
--- /dev/null
+++ b/packages/studio/src/hooks/useLivePlayheadTime.ts
@@ -0,0 +1,49 @@
+/**
+ * The playhead in composition seconds, live while the transport runs.
+ *
+ * The RAF loop deliberately does not push every frame through the store — it
+ * notifies `liveTime` instead, so the playhead can move without re-rendering the
+ * app. A panel that wants to follow it therefore has to subscribe itself, and
+ * throttle: 30 Hz reads as continuous and costs an order of magnitude less than a
+ * render per frame.
+ *
+ * Paused, the store is the truth — a seek or a scrub lands there — so this returns
+ * that instead, which is what lets a readout follow the playhead while it is being
+ * dragged as well as while it is playing.
+ */
+import { useEffect, useRef, useState } from "react";
+import { liveTime, usePlayerStore } from "../player";
+
+/** Long enough to be much cheaper than a frame, short enough to read as motion. */
+const THROTTLE_MS = 33;
+
+export function useLivePlayheadTime(): number {
+ const storeTime = usePlayerStore((s) => s.currentTime);
+ const isPlaying = usePlayerStore((s) => s.isPlaying);
+ const liveRef = useRef(storeTime);
+ const [, forceRender] = useState(0);
+
+ // Paused, the ref tracks the store so the first frame of playback is never a
+ // stale value from the last time the transport ran.
+ if (!isPlaying) liveRef.current = storeTime;
+
+ useEffect(() => {
+ if (!isPlaying) return;
+ let timerId: ReturnType | 0 = 0;
+ const unsubscribe = liveTime.subscribe((t) => {
+ liveRef.current = t;
+ if (!timerId) {
+ timerId = setTimeout(() => {
+ timerId = 0;
+ forceRender((v) => v + 1);
+ }, THROTTLE_MS);
+ }
+ });
+ return () => {
+ unsubscribe();
+ if (timerId) clearTimeout(timerId);
+ };
+ }, [isPlaying]);
+
+ return isPlaying ? liveRef.current : storeTime;
+}
diff --git a/packages/studio/src/hooks/usePreviewPersistence.ts b/packages/studio/src/hooks/usePreviewPersistence.ts
index 608220eda2..ee7039231f 100644
--- a/packages/studio/src/hooks/usePreviewPersistence.ts
+++ b/packages/studio/src/hooks/usePreviewPersistence.ts
@@ -14,6 +14,7 @@ import {
import { trackStudioEvent } from "../utils/studioTelemetry";
import { applyUndoRestoreToPreview, type UndoRestoreFile } from "../utils/gsapUndoRestore";
import { usePlayerStore } from "../player";
+import { syncStoredAutomationFromPreview } from "../player/lib/automationStoreSync";
/** The restore payload the undo/redo preview-sync consumes (from the history store). */
interface HistoryPreviewRestore {
@@ -207,7 +208,13 @@ export function usePreviewPersistence({
player.setElements([]);
player.setSelectedElementId(null);
player.setTimelineReady(false);
+ return;
}
+ // A soft restore patched the reverted attributes onto the live preview, but the
+ // player store keeps its own copy and that copy is what the automation lanes
+ // draw — so without this an undone envelope edit stayed invisible until a
+ // reload. The full path above clears the store and waits for discovery instead.
+ syncStoredAutomationFromPreview(previewIframeRef.current?.contentDocument ?? null);
},
[previewIframeRef, activeCompPathRef, reloadPreview],
);
diff --git a/packages/studio/src/player/components/automationLaneData.ts b/packages/studio/src/player/components/automationLaneData.ts
index e62b8b7324..c1c8c850c7 100644
--- a/packages/studio/src/player/components/automationLaneData.ts
+++ b/packages/studio/src/player/components/automationLaneData.ts
@@ -131,7 +131,7 @@ function orderLanes(lanes: HfAutomationLane[], chain: HfAudioFxChain | null): Hf
}
/** A frequency as an author reads it: 400 Hz, 1.6 kHz, 10 kHz. */
-function formatHz(freq: number): string {
+export function formatHz(freq: number): string {
if (freq < 1000) return `${Math.round(freq)} Hz`;
const k = freq / 1000;
return `${k >= 10 ? Math.round(k) : Number(k.toFixed(1))} kHz`;