Skip to content

feat(studio): box-select automation, dynamic voiceover carve, live automated readouts (replaces edge-stretch) - #3058

Draft
vanceingalls wants to merge 11 commits into
wa-17-lane-clipboardfrom
wa-18-lane-stretch
Draft

feat(studio): box-select automation, dynamic voiceover carve, live automated readouts (replaces edge-stretch)#3058
vanceingalls wants to merge 11 commits into
wa-17-lane-clipboardfrom
wa-18-lane-stretch

Conversation

@vanceingalls

@vanceingalls vanceingalls commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Replaces this branch's original subject. It opened to add edge-stretch — dragging a selection's edges to retime the points inside it — and that feature is removed here rather than finished: it is not wanted. The hook, retimeRange, the edge handles, the col-resize cursor and the pointercancel revert path all go, with the ~360 lines of tests that pinned them. The commits that added it are still in the history below; the net diff has no stretch in it.

What lands instead:

Automation selection is a box, not a time span. A lane selection is a set of breakpoints, so it carries 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 on caught points read one shared rule. Copy, paste, shape insert and simplify still act on the box's time span, because they operate on the envelope over a stretch of time.

Dragging is bounded by neighbours. A point cannot cross another, and cannot land exactly on one either: the lane collapses points sharing a t, so arriving on top of a neighbour used to delete it. It now stops a millisecond short — under a pixel at any zoom — and both points survive. Only stationary neighbours constrain a group drag, per member rather than per end, since a box can select a non-contiguous set.

Automation survives 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, and a bent segment read as straight because only the curve exponent was checked and never the via point the timeline writes. Measured against Chrome, in a live context and an offline one suspended mid-curve: any cancel frees the span, only a missing cancel is refused.

Voiceover carve gains level matching and a dynamic mode, collapses to one strength knob (carveProfile derives the six numbers that always moved together), and is presented as one module in the rack rather than six removable effects.

Every automated knob shows its value at the playhead — the carve rack's readouts and each effect's own fader and number field. The stored number is only the seed a lane replaced, so a panel showing it stood still while the carve was audibly working. Off the clip it keeps sampling, because a lane holds its first value backwards and its last forwards.

New /hyperframes-audio skill, with scripts/carve.mjs so an agent with no Studio panel can still carve: same core functions, same decode rate, finds the voice/bed pair itself and refuses rather than guessing when it cannot tell them apart.


Note for reviewers: the whole wa-* stack was rebased locally some time ago and never resubmitted, so all 16 diverged branches were force-pushed together — otherwise this PR's diff was computed against a stale base and showed 13 unrelated files. Every branch now matches its remote and each PR in the stack compares against the right base.

🤖 Generated with Claude Code

vanceingalls and others added 11 commits August 6, 2026 10:59
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 vanceingalls changed the title wa 18 lane stretch feat(studio): box-select automation, dynamic voiceover carve, live automated readouts (replaces edge-stretch) Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant