feat(studio): box-select automation, dynamic voiceover carve, live automated readouts (replaces edge-stretch) - #3058
Open
vanceingalls wants to merge 25 commits into
Open
feat(studio): box-select automation, dynamic voiceover carve, live automated readouts (replaces edge-stretch)#3058vanceingalls wants to merge 25 commits into
vanceingalls wants to merge 25 commits into
Conversation
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>
…uity 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 <noreply@anthropic.com>
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>
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.
…anup - 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.
…llow 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.
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) <noreply@anthropic.com>
…sing 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>
… 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) <noreply@anthropic.com>
Mixing was the one audio surface no skill covered: the effect chain, automation lanes and the voiceover carve existed with no guidance, so an agent had the attributes and none of the judgement for using them. The skill teaches the carve as what it is — a relationship between two tracks, wired like a sidechain, with the settings on the bed naming the voice — and routes the effect families by the problem each solves rather than listing parameters. It ships scripts/carve.mjs because a skill teaching a feature agents cannot operate is not much of a skill: the analysis needs decoded PCM, and the only other way in is clicking a Studio panel. Same core functions and same decode rate, so headless output and panel output are the same three attributes. It finds the voice and the bed itself — names first, then by ear, measuring how much of each track is quiet, since a voice stops between phrases and a bed does not — and refuses rather than guessing when two tracks are too close to call. References carry the full registry with which parameters can actually be automated (the four worklet effects expose none, so a lane on one is silently inert) and the exact JSON of the three attributes. Counts, catalogues and the core-skill manifest updated in lockstep; the pinning test in skillsManifest is what caught the two surfaces the maintenance checklist in CLAUDE.md does not name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…path Two takes written to the same path returned the first one's waveform, so a re-recorded track drew the shape of the audio it replaced. The key now carries size and mtime, which is enough to notice the bytes changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vanceingalls
force-pushed
the
wa-18-lane-stretch
branch
from
August 7, 2026 20:32
7cde748 to
6ed67fb
Compare
A bed usually runs under a whole sequence — a narrator, an interview answer, a second presenter — and carving against one of them left the others fighting it. `source` becomes `sources`, and `mixCarveSources` sums every voice onto the BED's clock before anything is measured. That is what keeps one analysis sufficient: the chain is fixed, so there is no per-voice filter to switch between, and bands drawn from all the speech there is with envelopes that rise wherever any of it happens answer the actual question — where and when is speech masking this bed. Summed rather than averaged: two people talking at once mask more than either alone. Audio before the bed starts is dropped rather than folded in at zero, since it plays over nothing and shifting it would put a cut where there is no voice. `dynamic` is gone. A fixed depth thins the bed through every pause, and once both have been heard there is no reason to want it, so every carve follows the speech. Two helpers the panel and the headless script now share instead of each carrying a copy — two definitions of "what does this name suggest" drift, and then the two disagree about which track is the voice: - `classifyAudioName` reads a track's kind from its id and filename together. `unknown` is deliberately common: treating an unrecognised name as "not a voice" would hide the one track somebody needs to pick. - `clipsOverlap` keeps out a voice that never plays while the bed does. An unwritten duration counts as unbounded, not zero — refusing a clip whose length the composition leaves to the media would drop the commonest case there is. Files written before this still load: a single `source` reads as a one-voice list, a stored `dynamic` is ignored, and an absent attribute means the defaults whole. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tself 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>
`--voice` repeats, and with none given the script takes the bed by name and every non-SFX track playing over it. Audio elements are preferred: video counts only when no audio track is left to be the voice, or every B-roll clip in a composition reads as somebody talking — which is exactly what happened on the first run against a real project. The by-ear tie-break is gone with the single-voice model that needed it: it existed to choose one voice, and there is nothing to choose now. `--static` is gone too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d group Four clips on one row made a per-clip lane read as governing the whole track. Track- level FX was considered and rejected — `data-track-index` picks a row and nothing else, and both runtimes build audio per element, so there is no track to own a chain — which leaves the fix in the timeline's presentation: lane rows keyed by the property they drive, shared by every clip on the row that automates it. Written down rather than started, with the parts that are not obvious until you look: the grouping key cannot be the lane target, because node ids are minted per chain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
Where the branch stands, what the feature does now, the architectural facts worth not re-deriving, the traps that cost time (the studio reads core from dist; Number(null) is 0; the fallow gate already failed before this work), the premises this session falsified so nobody re-measures them, the stack force-push and what PR #3058 now is, and the four remaining steps for shared lane rows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…roperty 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.
… owner 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.
…nvelopes 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.
…ip keeps them 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.
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.
…deleted 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.
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.
vanceingalls
marked this pull request as ready for review
August 8, 2026 09:24
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replaces edge-stretch with the automation work that grew out of it: box selection, a dynamic voiceover carve, live automated readouts — and, since the last review, shared automation lane rows plus the fixes that shipping them surfaced.
Shared automation lane rows
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. The key cannot be the lane target: node ids are minted per chain, sofx.n1.qon one clip andfx.n1.qon another may be different effects entirely.Each clip draws its own envelope over its own span. A clip that does not automate the property leaves that stretch empty, rather than a flat line that would claim an envelope exists.
Gestures stay per clip — every clip keeps its own binding, its own
useAutomationLaneGestures, 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 (
Track 12) 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 — so the caret now opens and closes every clip on the row together.Effects stay on the clip. There is no track entity to own a chain:
data-track-indexis parsed in one place, only to choose a row, and both runtimes build audio per element. Track FX would mean inventing a storage location and a bus node in two runtimes.Fixes this surfaced
aria-controlsnow names both regions, since a clip's keyframe lanes and the track's automation lanes cannot be one element.element.ownerDocument— the preview DOM outlives a delete. Also stops auto-apply choosing off the picker's fallback list: with the narration deleted, a 200 ms explosion was the only candidate and the bed carved itself against it.remove-element— which rewrites it server-side — then saves the duration shrink expecting the content it read at the start. The server had moved on, so it came back409 file conflict; the save queue pauses on a conflict, so the error surfaced and the clip stayed on the timeline until a reload. The undo baseline and "what is on disk" are now separable.Verification
packages/studio: 3656 passing, 18 todo, 1 file skipped.tsc,oxlint,oxfmtclean.Known limit
The carve reconciles a deleted voice when the bed's Audio FX section is opened, not at delete time — that panel is only mounted while the section is open. A version that watches the timeline instead is built and working on
carve-reconcile-wip, but it disables undo, so it is parked rather than included here.