The rack's rows were keyed `${type}-${index}`. Two effects of the SAME type
keep those keys through a reorder — position 0 is `peaking-0` before and after
— so React reused each row where it stood rather than moving it with its node.
The controls hold real state (a number field mid-edit is held as text, a drag
holds a local value), and that state stayed at the position: moving an effect
handed its half-typed value to whichever effect took its slot.
Different types happened to be safe, because the type is in the key. Same types
were not, and a rack with two EQ bands is the common case.
Now keyed by `node.id`, which the carve module's own list above already does,
falling back to the old form for a node minted without one.
Falsified by restoring the index key: the slot keeps showing 123 after the
effect carrying it moved away.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`analyse` captures the chain and the automation before its fetch and decode,
then rewrites the whole `data-fx-chain` from that snapshot. Anything committed
during those seconds — adding an effect, moving a knob — landed first and was
silently discarded when the analysis returned. Only the Analyse control was
gated on `analysing`; every other control in the rack stayed live throughout.
FxSection already accepts `disabled` and threads it to every row; the carve
panel simply never passed it. Refusing the edit for the duration is the honest
answer — merging it into a measurement that did not account for it is not, and
last-write-wins across an async gap is what this was.
The test had to stub OfflineAudioContext as well as fetch: without a
constructor, `analyse` returns before the decode and the window under test
never opens, which is why the existing carve tests never saw this.
Does not cover a write arriving from somewhere other than this panel — a
timeline lane edit, or an undo — during the same gap. That needs the write to
re-read rather than the reader to lock.
Two failures with one root: the row never tracked whether a gesture actually
edited anything.
The number field could not be typed into. It was bound to the committed value,
while every keystroke was clamped into range and written live — and a live
write does not refresh the prop, so React put the old number straight back.
Typing 5000 into a 20..20000 knob wrote 20 on the first keystroke and got no
further; the knob could only be dragged. Worse, `Number("") === 0` passes
Number.isFinite, so select-all + Delete before retyping instantly live-wrote the
parameter MINIMUM — 20 Hz on a cutoff, -40 dB on a gain. The field now holds
what is being typed as text, and an empty field is a state on the way to a
number rather than a number to clamp and persist.
`commit()` hangs off pointerup, keyup and blur, all reachable with no edit —
clicking a slider thumb without moving it, or tabbing through the field. It
unconditionally wrote `latest`, which was seeded at mount and only ever written
by an edit. So: open an EQ row, run the carve or press undo, then click the
slider and release without moving it, and the row silently re-persisted its
mount-time number over the change. It also fired a full persisting write —
source patch, selection resync, preview reload, audio restart — for a gesture
that moved nothing.
Each fix is falsified by deletion. A fourth guard (resyncing `latest` on every
inbound value) was dropped rather than kept: the edit flag subsumes it, and no
test could be made to fail without it.
Deleting a clip refused its own save. The flow reads the file, POSTs
`remove-element` — which rewrites the file server-side — then saves the
duration shrink on top. That second write carried the content read at the
start as its optimistic-concurrency expectation, but the server had already
moved the file on, so it came back 409 "file conflict". The save queue pauses
on a conflict rather than retry stale work, so the error surfaced and nothing
persisted afterwards; the clip stayed on the timeline until a reload, since the
store update runs after the save and the throw skipped it.
The undo baseline and "what is on disk" were one value in
saveProjectFilesWithHistory, which is right until a server-side mutation has
already written part of the edit. They are now separable: `diskContent` says
what to expect on disk, `readFile` still says what undo restores. Both delete
paths pass what remove-element left behind.
Measured in the studio, before and after: PUT /files/index.html 409 -> 200, no
error toast, the clip leaves the timeline immediately, and undo still brings it
back. Pre-existing — reproduced identically on the pre-session studio source.
The filters and envelopes a carve produces are a MEASUREMENT of specific
tracks. Delete one and they go on describing a voice nobody can hear — the bed
keeps ducking for it. `analyse` already skipped a source it could not find, but
nothing ever asked it to run again.
So the panel prunes sources that have left the composition, and pruning is the
whole trigger: setCarve re-analyses whenever the source list changes, and the
survivors are re-measured together. Losing the last one leaves an empty list,
and what the carve generated goes with it — those filters are making room for
nobody. Staying enabled is deliberate: a voice may come back, and "off" is a
different thing the author chose.
Existence is asked of the TIMELINE, not of element.ownerDocument. Measured in
the studio, a bed selected right after its voice was deleted still found that
voice through the preview DOM, which outlives the edit; the store is what the
delete actually changed. Absence there only counts once the store is known to
describe this composition — the bed being in it is the proof — or an empty
store would read as "every voice was deleted" and strip a healthy carve.
Also fixes what that exposed: auto-apply could pick off the picker's FALLBACK
list. The fallback shows every track when nothing classifies as a voice, so
the author can override a name that reads as music — but with the narration
deleted it left a 200 ms explosion as the only candidate, and the bed carved
itself against it. Offering is for the author; choosing is the panel deciding,
and an explosion is not a voice to make room for.
The caret names two regions since the automation lanes moved to the row, and
the helper resolved the attribute as a single id. Split it, and assert every
region it names actually exists — in both disclosure states.
The lanes hung off the active clip's property lanes, and which clip that is
follows the selection. So pressing a lane to select its clip moved the whole
subtree into a different clip's element: React remounted every lane, each one
lost the hover state that draws its handles, and the breakpoints the pointer
was reaching for vanished at the moment of the press. That is the one gesture
a read-only lane exists to support — press to select, then edit.
They are the row's lanes, so the row mounts them. Their absolute positions
resolve against the same relative row either way, so the geometry is unchanged.
The wrapper is mounted in both disclosure states, empty while collapsed, for
the reason the keyframe lanes are: the caret's aria-controls has to resolve
while collapsed too. The caret now names both regions, since a clip's keyframe
lanes and the track's automation lanes cannot be one element.
TimelinePropertyLanes loses its `footer` prop, which existed only for this.
Every clip on a row mounts its own lane band, and each band spans the full row
width because the separator under it has to. Stacked, that made the band a
pointer trap: whichever clip React rendered last covered all of its siblings,
so hover, drag and right-click reached only that clip's envelope. On a track
four narration slices share, the EQ handles never appeared — while the volume
row, which only one clip automated and so had one band, worked fine.
The band is a positioning device, not a hit target. It takes no pointer events
and the drawn parts opt back in: the envelope's svg, and the value input. The
separator and the hint were already inert, and the selection menu portals out.
An expanded row is mostly automation lanes, but only the clip whose property
lanes were showing got its bar capped to a track height. Its siblings were
left to fill the whole row — so on a track four narration slices share, three
of them painted their waveforms straight down over the envelopes below, and
the lanes were readable only under whichever clip happened to be selected.
The bar's height belongs to the row's disclosure, not to which clip owns the
property lanes. The collapsed case is unchanged: with no lanes under it, a bar
filling the row is exactly right.
Several clips can share a track row. The row was named after whichever of
them was selected, carried its clip count as a badge, and listed only that
clip's envelopes — so a lane belonging to one slice read as governing the
whole row, and clicking a sibling silently swapped which envelopes existed.
A lane is a property over time, not a clip's private strip. Clips on one row
now share a lane row when it is the same property of the same effect, keyed
by `laneGroupKey` (the label, which is what identifies a parameter to a
reader — node ids are minted per chain and collide across clips). Each clip
draws its own envelope over its own span, and a clip that does not automate
the property leaves that stretch empty rather than drawing a flat line that
would claim an envelope exists.
Gestures stay per clip: every clip keeps its own binding, its own
useAutomationLaneGestures and its own selection box. A shared row is a shared
lane track, not a shared envelope, so two clips' curves can never drag as one
thing. That is why the slot loops over components rather than over lanes.
Row height and the label column follow the grouped count, and the header
names the track once the row holds several clips. Disclosure had the same
selection-dependence: it was stored against the active clip, so expanding one
slice and clicking another collapsed the row. The caret now opens and closes
every clip on the row together, and any expanded clip holds it open.
The remove button acts only on the clip the header is showing, since that is
the only one a write can reach; a row that clip is absent from offers none.
Effects stay on the CLIP. There is no track entity to own a chain —
data-track-index is parsed in one place, only to choose a row, and both
runtimes build audio per element.
First step of the shared-lane work in plans/automation-lanes-shared-rows.md. A lane
is a property over time, not a clip's private strip: four narration slices on one
track that each automate a 1 kHz peaking Q belong in one row, each drawing its
envelope over its own span.
`laneGroupKey` is what decides that two lanes on two clips are the same row, and it
deliberately is not the lane target. Targets are `fx.<nodeId>.<param>` and ids are
minted per chain, so the same target means different effects on different clips —
grouping by it would put a low-pass Q and a peaking Q in one row and split matching
bands apart. The key is the label instead, which is what identifies the parameter to
a reader, so a row's identity and its name cannot drift apart.
No behaviour change yet: nothing calls it. The timeline still renders one clip's
lanes at a time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The controls sat in their own block under the effect rack while the filters they
produced appeared inside it, which read as two unrelated features. They are one
card now: the voices it listens to, how hard it works, and what the analysis made
of that, in the order that explains itself. Strength moved in with them, the
separate block is gone, and there is one switch rather than a bypass and a delete —
there is nothing meaningful between carving and not carving.
It is present whenever another track could be the voice, rather than appearing only
once it has already produced something: a control that materialises after the fact
cannot be the thing you reach for to start. And it applies itself. A bed with voices
above it wants carving, and making the author find the control, name the voices and
set a strength before hearing what they already wanted is ceremony. Every candidate,
not one — they are analysed together, so "all of them" is the answer rather than a
guess.
`enabled: false` is what off means, and why the flag exists: with off represented by
an absent attribute, selecting the clip again would read as never-configured and the
default would carve it straight back on.
The picker only offers tracks that could be the voice — music is the thing being
carved, a 200 ms sting has no speech in it, and a track playing somewhere else on
the timeline cannot mask this one. With exactly one candidate there is nothing to
choose, so the voice reads out instead of being a dropdown with one entry; several
become boxes to tick, since they are things to include rather than alternatives.
Tracks whose names say nothing stay in, and if filtering would empty the picker
everything comes back — hiding the track somebody needs is worse than a long list.
Three fixes to what values the panel shows:
- Switching the carve back on re-analyses. Off drops the filters, so On used to
restore the setting and leave the bed uncarved: the switch looked like it worked
and the mix was unchanged.
- A dragged control stays where it was dropped. The value only returns after the
attribute is written and the selection resynced — for a carve, after the analysis
that write starts — and dropping back to the prop in that gap made the control
snap to where the drag began and then jump to where it ended. Held only until
newer information arrives, so a clamp or an undo still wins.
- A running analysis clears the previous one and shows a spinner. Moving strength
re-derives every one of those numbers, so leaving them up presents settings that
are already history as though they were in force.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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.
- 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.
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.
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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.
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.
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.
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 <noreply@anthropic.com>
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) <noreply@anthropic.com>
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).
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.
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.
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.
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.
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 <audio> with a sibling track, and the two cases the
rule exists for are pinned directly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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)
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) <noreply@anthropic.com>
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)
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 `<audio>`, 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) <noreply@anthropic.com>
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)
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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)
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) <noreply@anthropic.com>
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.
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 `<audio>` 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.
Adds `audioFx` to the editing-affordances contract and renders the FX panel in
the inspector when an `<audio>` element is selected.
The section is audio-only. A `<video>` carries its sound on a separate
`<audio>` 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.
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.