Commit Graph
3707 Commits
Author SHA1 Message Date
Vance IngallsandClaude Opus 5 85257571c8 fix(core): let a failed worklet registration be retried
`ensureAudioFxWorklets` cached its promise per context and never removed a
rejected one, while `readyContexts` was only written on success. So callers
correctly kept asking and every ask replayed the same rejection: one transient
`addModule` failure left the limiter, compressor, gate and bitcrush silent for
the life of that AudioContext, with no path back short of a page reload. The
entry is now dropped on failure so the next attempt actually retries.

Falsified by removing the `registered.delete(ctx)`: the retry replays the
rejection instead of resolving.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:08:56 -07:00
Vance IngallsandClaude Opus 5 71ca86456c perf(core): reuse the FFT scratch arrays across carve windows
Both analysis loops allocated a fresh `Float64Array(4096)` pair inside the
per-window loop. A 5-minute 48 kHz voiceover is ~7030 windows, so one carve
churned ~460 MB of transient Float64Array through the main thread — the panel's
thread — for a measurement that needs 64 KiB of scratch. Now one pair is reused;
`re` is fully overwritten each window and only `im` has to be cleared.

Wall-clock is unchanged (591 ms vs 596 ms on that file, within noise): the FFT
dominates and a bump allocation is nearly free. This is a GC-pressure fix, not a
latency one, and the band values it produces are bit-identical.

The review's third loop is `analyseCarveDuck`, which runs `windowDb` rather than
an FFT and allocates nothing per window — nothing to fix there.

Striding the Welch hops was tried and REJECTED. It is 27x faster on a 5-minute
voiceover (427 ms -> 15 ms), but it moves the result: at strength 0.9 the chosen
band set changed (630 Hz became 160 Hz), and it did not converge back to the
full read even at 2048 windows. That is the author's carve being silently
redrawn to save time on a measurement that already locks the rack while it runs.
The reason is recorded in the code so it is not re-attempted.

Falsified by deleting `im.fill(0)` from both loops: five tests fail, including
the two added here — a steady tone must measure the same bands whatever its
length, and its dynamics envelope must sit still rather than sliding as the
contamination accumulates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:01:42 -07:00
Vance IngallsandClaude Opus 5 5e166ad47c fix(core): reschedule FX automation when the transport changes rate
`attachElementFxChain` gets a frozen `{scheduledAt, elapsed, rate}` and commits
every lane to absolute context times from it. `setRate` only assigned
`playbackRate.value`, so the audio changed speed and the envelopes did not: a
lowpass sweeping over 10 clip-seconds, switched to 2x mid-play, eats 20 s of
material in 10 s of wall clock while the sweep keeps its original schedule.

The runtime's recovery — `stopAll()` plus a reschedule — only runs when
`hasBoundedActiveSources()` is true, and a project-level music bed with no
`data-duration` is unbounded, so that case never recovered at all.

The timing a chain measures from is now a mutable reference frame that
`setRate` rebases: `elapsed` is advanced to the playhead the OLD rate carried
it to, then the lanes are replayed from there at the new one. That also fixes
the second consequence — `timingNow()` was advancing `elapsed` with the stale
rate, so every later chain edit re-aimed the envelope at the wrong clip
position for as long as the track played.

`attachElementFxChain`'s return type is now the named `ElementFxHandle`, so the
transport's `ScheduledSource.fx` carries `setRate` rather than a structural
`{ dispose(): void }`.

Falsified against four mutations: `setRate` a no-op (envelope stays 8 s long),
rescheduled without adopting the new rate (6 s instead of 3), rate swapped
without rebasing the frame (booked from t=0 instead of t=2), and the guard
dropped (rate 0 books a NaN span).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:55:54 -07:00
Vance IngallsandClaude Opus 5 c9448aec00 fix(engine): move a track's PCM across the wire in bounded chunks
The whole track crossed in one `page.evaluate` pair — ~184 MB of base64 for a
3-minute stereo 48 kHz clip, in a single WebSocket frame each way.
puppeteer-core caps its frames at 256 MB (NodeWebSocketTransport, verified in
the installed copy) and the browser pool does not pass `pipe: true`, so stereo
48 kHz past ~8.7 minutes raised AudioFxRenderError and failed the render.
V8's max string length is a second wall not much beyond it. Both were fatal:
an FX failure takes the whole mix down rather than dropping one track.

Input and output now move 8 MiB at a time, staying separate byte arrays
page-side rather than being concatenated into one string, so neither the frame
cap nor the string limit ever sees the whole track. That also bounds the peak
Node-side allocation: `processCompositionAudio` renders tracks through an
unbounded `Promise.all`, so every track's payload used to be live at once.

The page drops the input chunks before the render allocates its buffers, so it
no longer holds two copies of the track.

Also drops the `Array.from(u8.subarray(...))` in the encoder, which boxed every
byte of each 32 KiB window into a JS array for nothing — `apply` takes
array-likes.

The 8.7-minute ceiling itself has no test: reproducing it needs a 256 MB
payload. What is tested is the assembly, which is where chunking actually goes
wrong — a 45 s stereo ramp spanning two chunks per plane, checked for length,
for values at the seam, and for monotonicity across all 2.16 M frames.
Falsified against three mutations: chunks reassembled in reverse (1.75 off at
the seam), the trailing partial chunk dropped (short by 125,696 frames), and
an unbounded input subarray (long by 125,696).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:50:36 -07:00
Vance IngallsandClaude Opus 5 55f852a723 fix(engine): duck the FX output before quantising it, not after
`writeWav` clamps and quantises the chain's float output to int16, and the
mixer only ran `applyVolumeEnvelopeToWav` over that file afterwards. So a
chain that overshoots full scale was destructively clipped even when the
volume lane immediately pulled the track 12 dB down — audible distortion
baked into the render that preview, working in float throughout, never had.

Measured on 12 s of narration through a +12 dB peaking band into saturation
with the lane holding the track at -12 dB: the chain peaks at 1.346, so the
old order sheared 19,166 samples (1.7%) flat against ±1 before ducking. Level
matched, the two renders differ by an error signal 26.3 dB below the audio,
worst single-sample delta 0.242.

The envelope now travels into `applyAudioFxChain` and lands on the float
planes before `writeWav` sees them; the mixer runs its own pass only for a
track the FX pass did not bake. `writeWav`'s clamp stays — it is the correct
last resort for a signal still hot after the duck, just no longer the first
thing that happens.

The per-frame segment-cursor gain walk is extracted as `createEnvelopeWalker`
so both bakers share one implementation, and the ffmpeg-expression fallback
for non-int16 WAVs is untouched.

`applyAudioFxChain` now returns `{ path, envelopeBaked }` rather than a bare
path: inferring the bake from `path !== inputWav` would silently break on the
two early returns (empty chain, empty track).

The new test is stereo with a step in the envelope, so it also falsifies a
per-plane walk — restarting the cursor for a second channel hands it the tail
gain for its whole length. Falsified against three mutations: clamp-then-duck
(0.5 instead of 0.9), envelope dropped (1.0), plane-outer walk (0.449).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:44:53 -07:00
Vance Ingalls 93de149857 docs(plans): hand off the audio review fixes for a fresh session
Eleven of fifteen findings fixed on wa-19-review-fixes; the four left open are
recorded with the analysis already done on each, including why the clip-before-
volume-lane one was started and deliberately stopped. Also carries the cut list,
the cleanup list, the carve-reconcile-wip undo regression, and the nine traps
that cost time — the published-CLI-is-not-a-symlink one above all.
2026-08-08 11:28:20 -07:00
Vance Ingalls bfa27381e1 fix(engine): give the ffmpeg children the signal that actually aborts them
The internal AbortController exists so a fatal FX error can cancel in-flight
ffmpeg before the finally-block deletes workDir. It reached the entry guard and
applyAudioFxChain, and nothing else: the trim, the video extract and the
download were all still handed the CALLER's signal. So `internalController
.abort()` cancelled nothing, and the `rmSync(workDir, { recursive: true })`
immediately after it ran while those children were still writing into the
directory.

Concretely: track A's FX render fails while track B is mid-trim, with no
external cancellation. B's ffmpeg keeps writing `${id}-trimmed.wav` into an
unlinked directory until it finishes. The comment above the controller
describes a mechanism that was never wired to the processes it names.

The download also had no signal at all, though downloadToTemp accepts one.

No test: the three changes are which variable is passed to a child process
helper, and observing the difference means driving real ffmpeg to the point of
cancellation. The old wiring's inertness is provable by reading — the caller's
signal is never aborted by this function — and the new wiring is the same three
calls with the controller the function already built.
2026-08-08 11:12:58 -07:00
Vance Ingalls 371920695c fix(studio): lock the FX rack while the carve is measuring
`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.
2026-08-08 11:10:55 -07:00
Vance Ingalls 05d081c61d fix(core): dispose a clip's FX graph when it ends, not only when it is stopped
The `ended` listener spliced the source out of `_activeSources` and restored
the element's muted flag, but never disposed the FX handle — and the splice is
what made it unreachable, because `stopAll()` disposes by walking that same
array. So every clip that finished naturally leaked its graph for the rest of
the session.

The leak is not just memory. Each stale handle keeps a MutationObserver bound
to `data-fx-chain`/`data-automation`, so every later panel edit runs rebuild()
against a dead source: a fresh graph per abandoned clip, including a ~576 KB
reverb impulse and chorus/phaser oscillators that are started and never
stopped. Play a five-clip composition three times and fifteen observers answer
every subsequent knob turn.

At this point in the stack `attachElementFxChain` has no chainless early
return — it watches every audio clip, chain or not — so this leaked for all of
them, not only the ones carrying effects.

Disposal is skipped when the entry was already gone: `stopAll()` disposes its
own, and `stop()` is what fires this event.
2026-08-08 11:06:20 -07:00
Vance Ingalls 007c947221 build(core): regenerate the audio-fx runtime before the tests run
`src/generated/audio-fx-runtime-inline.ts` is what the engine injects into the
headless page to do the actual DSP, and nothing kept it in step with its
sources. Its sibling has `check:position-edits-render`, which rebuilds and then
`git diff --exit-code`s the artifact — but that form needs the file tracked, and
this one is gitignored.

Measured tonight while fixing the zero-frame guard: editing the stub and
re-running the engine's render tests injected the PREVIOUS bundle, so the fix
appeared not to work and the old DSP ran with nothing reporting the drift. That
is the exact preview/render divergence this stack exists to prevent, aimed at
whoever is developing it.

Making `test` regenerate it is the version of the gate that works without
touching .gitignore: the artifact cannot be stale when the tests read it.

Not covered: a fresh clone still cannot resolve `@hyperframes/core/audio-fx-runtime`
from `packages/engine` until core has been built at least once. CI survives on
build ordering. Tracking the artifact, or a pretest that builds core from the
engine, are both bigger calls than this one.
2026-08-08 10:52:36 -07:00
Vance Ingalls 59563eae01 fix(lint): stop the double-automation rule blaming the wrong element
The pattern allowed `[^;]{0,200}` between an element's selector and `volume:`,
and `[^;]` crosses `)`, `,` and newlines. A chained GSAP timeline has no
semicolon until the end of the whole chain, so

    gsap.timeline().to("#bgm", { x: 10 }).to("#vo", { volume: 1 });

matched for `#bgm` — an element whose only automation is the lane. The warning
then told the author "the lane wins, the tween is ignored" about a tween that
is on a different track, and its fixHint invited them to delete the lane that
was actually working.

Refusing to cross the closing paren keeps the match inside the call the
selector belongs to. It costs a false negative when another value in the same
object is a call result, which is the safe direction for a rule that only
warns and, by its own comment, already only guesses. The `s` flag went with it:
inert, since the character class already matched newlines.
2026-08-08 10:50:36 -07:00
Vance Ingalls 436db25062 fix(studio): let an FX parameter be typed, and stop a bare blur re-persisting it
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.
2026-08-08 10:48:40 -07:00
Vance Ingalls f6f0e4cf2a fix(core): sample the volume lane at clip-local time, the way the render bakes it
A lane's `t` is "seconds from the start of the clip" — HfAutomationPoint says
so, and the render honours it: prepareAudioTrack cuts the wav with
`-ss mediaStart`, so its t=0 IS the clip's start, and normaliseEnvelope
subtracts trackStart. Preview fed `relTime` instead, which is MEDIA time: it
carries mediaStart, scales by playbackRate and wraps on a loop.

So the same envelope played somewhere else than it rendered. `data-media-start`
past the last point held the final value from the first frame and never faded;
`playbackRate: 2` ran the envelope at double speed in preview only; a looping
clip restarted the envelope every lap in preview while the render baked it once.

The FX lanes shipped in this same stack already use clip-local elapsed. There
is one time base for automation, and this makes the volume lane use it.
2026-08-08 10:44:38 -07:00
Vance Ingalls b754e72d0c fix(core): move a node's id with its slot when the chain updates in place
Reordering two effects of the same type leaves `shapeOf` identical — it carries
type, poles and the one-pole frequency, but no id — so the chain updates in
place instead of rebuilding. That is right for the audio: the params move with
the position. The ids did not move with them. They were captured once at build
time, so after a reorder each handle named whichever effect used to sit in that
slot, and scheduleChainAutomation built its byId map from those stale names:
a lane on `fx.n2.frequency` drove the band that is now n1.

This is the invariant HfAudioFxNode.id documents itself as protecting —
"reordering the chain never re-points a lane at a different effect" — and it is
easiest to hit on the all-peaking chains the voiceover carve produces. Preview
only, since the render rebuilds from scratch, so the symptom is preview
quietly ceasing to predict the render.

Fixed by following the id, not by adding it to the shape: a same-type reorder
genuinely does not need a rebuild, only a correct id map.
2026-08-08 10:42:14 -07:00
Vance Ingalls accac99843 fix(core): aim the phaser's trim lanes at the trims, not at the pinned wet/dry
`in_gain` and `out_gain` trim the signal entering and leaving the phaser, and
apply() drives them through inTrim/outTrim while pinning wet and dry to 1. The
automation map aimed both lanes at wet.gain and dry.gain instead — so an
envelope on either knob modulated a constant, the trim it was supposed to move
stayed wherever apply() last left it, and any values-only edit re-ran apply()
and slammed wet/dry back to 1 over the running ramp.

Heard as: "fade the phaser to silence" ends at full dry signal. Wrong in
preview and in the render, since both share this builder.

The builder's own comment records that wiring these knobs to wet/dry was
already found wrong once and moved to the trims; that refactor updated apply()
and missed the map. The existing exposure test only asserted that SOME param
was present under each key, which a mis-aimed target passes — the new test
asserts by value, which is the only thing a lane actually cares about.
2026-08-08 10:40:19 -07:00
Vance Ingalls 714f662edd fix(engine): take the browser lease inside the try that releases it
The lease was acquired above the try, with `mkdtempSync` between the two — so a
failure there (a full disk, a read-only tmpdir) returned without ever reaching
the `finally` that releases it. A leaked pooled browser is worse than the error
that caused it: a pool with no leases left hangs every later render instead of
failing one.

The temp dir is now taken first and the lease inside the try, with the release
made conditional. The abort check moves above the acquire too, so an
already-cancelled track no longer takes a browser out of the pool just to hand
it straight back.

No test: the fix is the ordering of two resource acquisitions, and the only way
to observe it is to make mkdtempSync throw, which needs an injection seam that
does not exist here and would be more risk than the three lines it guards.
2026-08-08 10:37:16 -07:00
Vance Ingalls 43c88961b2 fix(engine): hand an empty audio track back instead of failing the render
ffmpeg exits 0 and writes a structurally valid but EMPTY wav whenever a clip's
trim starts past the end of its source — `-ss 10 -t 5` on a 2 s file. That
track reached `new OfflineAudioContext(channels, 0, rate)`, which throws
NotSupportedError, and the error travels past the mixer's per-track failure
collector: one mis-set `data-media-start` on one clip with one enabled effect
took the WHOLE render down with a RenderQualityError.

An empty track has nothing to process, so applyAudioFxChain now hands the input
back the way it already does for a chain with nothing enabled — before paying
for a browser to decide it. The runtime stub guards the same case at the point
the constructor would actually blow up, since it is reachable by any other
caller of the injected script.

The test falsifies against the real browser path: removing the guard
reproduces `NotSupportedError: The number of frames provided (0) is less than
the minimum bound (1)`.
2026-08-08 10:35:02 -07:00
Vance Ingalls 4bcc7e1882 fix(studio): expect what deleting a clip already wrote, not what it read
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.
2026-08-08 00:09:16 -07:00
Vance Ingalls c06d7a1a8a feat(studio): re-analyse a carve when a voice it was listening to is 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.
2026-08-07 21:29:12 -07:00
Vance Ingalls 70c5456d23 test(studio): read the caret's aria-controls as the id list it now is
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.
2026-08-07 20:17:05 -07:00
Vance Ingalls 1902fc5866 fix(studio): mount the automation lanes on the row, so selecting a clip 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.
2026-08-07 20:10:51 -07:00
Vance Ingalls dbda70b5b4 fix(studio): stop a shared lane row's clips swallowing each other's envelopes
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.
2026-08-07 20:01:37 -07:00
Vance Ingalls 9121ca3477 fix(studio): cap every clip bar on an expanded row, not just the lane 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.
2026-08-07 19:54:46 -07:00
Vance Ingalls 6370227d6e docs(plans): record that shared automation lane rows have shipped 2026-08-07 17:47:09 -07:00
Vance Ingalls 1e21d763bd feat(studio): give the clips on a track one automation lane row per property
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.
2026-08-07 17:44:25 -07:00
Vance IngallsandClaude Opus 5 24f184e3ec docs(plans): hand off the audio FX / carve work for a fresh session
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>
2026-08-07 17:06:11 -07:00
Vance IngallsandClaude Opus 5 16cb0b65b7 feat(studio): key an automation lane row by the property it drives
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>
2026-08-07 16:59:42 -07:00
Vance IngallsandClaude Opus 5 ce57c5926e docs(plans): record why effects stay on the clip, and how lanes should 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>
2026-08-07 16:56:58 -07:00
Vance IngallsandClaude Opus 5 45eb650d13 docs(skills): carve every voice from the command line too
`--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>
2026-08-07 16:56:58 -07:00
Vance IngallsandClaude Opus 5 56d6e1fc79 feat(studio): the carve is one module in the rack, and it carves by itself
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>
2026-08-07 16:55:44 -07:00
Vance IngallsandClaude Opus 5 2d22c6ba8c feat(core): carve against every voice over a bed, always dynamically
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>
2026-08-07 16:54:26 -07:00
Vance IngallsandClaude Opus 5 6ed67fb512 fix(studio-server): key the waveform cache on the file, not just its 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>
2026-08-07 13:26:26 -07:00
Vance IngallsandClaude Opus 5 f423ea293f docs(skills): add /hyperframes-audio, with a headless carve
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>
2026-08-07 13:26:25 -07:00
Vance IngallsandClaude Opus 5 1fcf9803dc feat(studio): show every automated knob at the playhead, and carve as one module
An automated parameter has two values: the number sitting in the chain, which is
only the seed a lane replaced, and the number the envelope is on right now. The
second is the true one, so the panel shows it — on the carve rack's readouts and
on every effect's own fader and number field. A rack that showed the seed stood
still while the carve was audibly working.

Off the clip it keeps sampling rather than falling back to the stored number: a
lane holds its first value backwards and its last forwards, so before the clip
starts it already knows what it will open on, and the stored seed is a value
nothing will ever play. Showing it made the fader jump the moment the clip came
under the playhead.

The playhead comes off the liveTime channel, throttled to 30 Hz — the RAF loop
deliberately keeps frames out of the store, so a panel watching only the store
would sit still for a whole take. PropertyPanel had that subscription inline;
it is now one shared hook with two callers.

Readouts reserve the width their parameter can need rather than what its current
value takes, because an updating value one character narrower shunted everything
after it sideways 30 times a second.

The carve's effects are presented as one module: an author switched on a carve,
and the peaking filters plus the level stage are how it is built, not six things
to remove one at a time. Opening it lists every member's settings as readouts,
since strength is what sets them. No carve control is offered on a track another
track already carves against — that track is the voice, not the bed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 13:25:01 -07:00
Vance IngallsandClaude Opus 5 3276d86653 feat(studio): select automation points with a box, and stop them crossing
Replaces the time-range selection with a rectangle. A lane selection is a set of
breakpoints, not a span, so it now has value bounds as well as time bounds and a
point is caught only if it falls inside both — which is what lets you take the
peaks of an envelope and leave the dips between them. Delete, the group drag and
the rings drawn on caught points all read the one rule, so what looks selected is
exactly what those act on. Copy, paste, shape insert and simplify still work on
the box's time span, because they act on the envelope over a stretch of time.

Dragging is bounded by its neighbours in both the single and group cases. A point
cannot cross another, and cannot land exactly on one either: the lane collapses
points that share a `t`, keeping the later one, so arriving on top of a neighbour
deleted it. It stops a millisecond short, which is under a pixel at any zoom the
lane offers and keeps both points. Only stationary neighbours constrain a group,
per member rather than per end, since a box can select a non-contiguous set.

Edge-stretch is removed rather than fixed. Dragging a selection's edges to retime
the points inside it was the feature this branch opened for, and it is not wanted:
the hook, retimeRange, the edge handles, the col-resize cursor and the pointercancel
revert path all go, along with the ~360 lines of tests that pinned them.

Also: gesture-scoped coalescing keys, so one drag is one undo entry rather than a
fragmented chain of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 13:23:40 -07:00
Vance IngallsandClaude Opus 5 82036d869c fix(core): make audio automation survive being rescheduled mid-playback
Anything landing inside a running value curve is refused unless the parameter is
cancelled first, and two paths were not cancelling: the chain observer wrote each
knob straight onto its AudioParam before rescheduling, and a bent segment read as
straight because only the curve exponent was checked, never the via point the
timeline actually writes. The first threw NotSupportedError into the console and
abandoned the rest of the envelope; the second played a dragged bend as a ramp.

Measured against Chrome, in a live context and in an offline one suspended
mid-curve: any cancel frees the span, and only a missing cancel is refused.
clearParamLane takes the strongest form on purpose, because curve-over-curve
refusals were reported with a cancel at the new schedule time already in place
and have never reproduced; emit keeps a ramp fallback as the backstop for
whatever that mechanism turns out to be.

Dynamic carve is what exercises all of it, so it lands here too:

- a `gain` primitive, so a carve can match levels as well as carve bands
- carve settings collapse to one `strength`, with carveProfile deriving the six
  numbers that always moved together anyway
- analyseCarveDynamics / analyseCarveDuck turn the analysis into envelopes, with
  a slow release so the bed does not snap back the instant a word ends
- worklets are awaited inside attach, so adding a compressor to a carved bed no
  longer kills its envelopes and freezes every later edit
- per-track failure detail in the render's audio stage, which was being discarded

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 13:22:18 -07:00
Vance Ingalls c1dae3e13a fix(studio): give edge-stretch the gesture contract the other four follow
Seven review findings against this branch, five of which were one defect:
edge-stretch was added as a fifth mutually-exclusive gesture on the lane
without joining the threshold / live-preview / revert-on-cancel contract the
point drag, curve bend, range drag and double-click all obey. Patching them
one at a time would have been more code and less coherent, so this makes the
stretch structurally parallel to its sibling range drag instead, and extracts
it to useAutomationEdgeStretch on the way out — the gestures file had ~60
lines of headroom under the 600-line studio cap, and shaving comments to fit
a refactor in is not a plan.

- Threshold. A press within the 8px halo of either edge used to persist a
  no-op commit and push an undo entry that changed nothing (commitDataAttribute
  has no unchanged-value short-circuit). Worse, it made the pre-existing "click
  the background to clear the selection" escape unreachable anywhere near an
  edge. Below 3px of travel — the same threshold the range drag uses — the
  press now clears the selection and writes nothing at all.
- Live preview. moveEdge never fired onRangeSelect and the hook discarded the
  drag's live position, so the highlight rect and both edge lines stayed pinned
  at the pre-drag bounds for the whole gesture and snapped into place on
  release: the user dragged an invisible handle. It now reports bounds on every
  move, exactly as the marquee drag does and for the same reason.
- Revert on cancel. pointercancel means the browser abandoned the gesture; it
  was routed to the same handler as pointerup, which persisted whatever partial
  retime it had reached. It now restores the arm-time snapshot through the
  preview channel — there is nothing persisted to undo — and puts the selection
  back. A new cancelDrag handler owns that, so a release and an abandonment are
  no longer the same event.
- Lost capture. capturePointer took the capture on e.target, i.e. whichever
  child the press landed on. A child that unmounts mid-drag takes the capture
  with it, silently, with no pointercancel — after which edgeDrag stayed
  non-null and every later button-less pointermove kept retiming and writing.
  Capture is now taken on the svg, which outlives every gesture on it, and a
  move reporting no buttons held ends the drag as a cancel.
- Hit priority. A breakpoint sitting exactly on the selection's edge used to
  win the press. Since replaceRange pins an anchor at the union bound and
  finishEdgeDrag leaves the selection edge at that same time, EVERY range
  operation — stretch, delete, shape insert — leaves a point exactly on the
  edge it just created: the second stretch of the same edge resolved to a
  point-drag, at the one height (on the envelope) where a user naturally grabs
  it. The feature was not repeatable. An active selection's edge now outranks a
  point on it; clearing the selection reaches the point again, which is tested.
- Clamp order. The dragged edge was bounded against its partner AFTER the
  0-floor, so a selection thinner than the minimum width yielded a negative t0,
  which core's cleanPoint then collapses onto a duplicate t=0 on the serialize
  round-trip — silent envelope corruption. The floor is now applied last. The
  minimum width is its own MIN_SELECTION_SEC rather than a borrowed
  POINT_MERGE_SEC: when two breakpoints are the same breakpoint is a different
  question from how thin a time selection may get.

One finding does not survive: edgeAt's `d0 <= d1` tiebreak was reported as
making the t1 edge ungrabbable on a narrow selection, but that comparison IS
nearest-wins, and a press right of the midpoint already resolved to t1. The
midpoint split here is the same rule written so it is legible rather than
inferred, and the test for it is labelled as characterizing behaviour, not
fixing it. What was genuinely unreachable inside a narrow halo — starting a
fresh range, or clearing the old one without Escape — the threshold above fixes.

Also settles what retimeRange does with a breakpoint sitting ON a dragged
edge, which was never decided: pointsIn is endpoint-inclusive, so it is
interior and travels with the stretch. It has to be, because the commonest
stretch of all is grabbing an edge to drag exactly that point outward, and
anchoring it would delete it and flatten the span instead. The price is that
the retimed point lands on the union's own boundary where a preservation anchor
would go, and anchor() stands down within a merge radius — one time cannot hold
two values — so the segment leaving the union reshapes. That is the one place
replaceRange's outside-never-moves invariant bends, and both halves are now
pinned: the exact points and the sampled slope for the on-edge case, and the
full two-sided invariant for a selection whose edges are off any breakpoint.
The earlier right-side probe at t=5.1 that caught this was deleted during
development as inherent; it was reporting the real behaviour.
2026-08-06 10:59:38 -07:00
Vance Ingalls dc0093b045 fix(studio): clamp selection-start paste, sharpen clipboard test, cleanup
- useAutomationSelectionKeyboard: clamp the selection-start paste branch
  to [0, element.duration - clip.span], same as the playhead branch
  already does. An unclamped paste near a clip's end could write points
  past element.duration and leave the resulting selection's edge
  ungrabbable off the visible lane.
- automationClipboard.test.ts: swap the cross-parameter mapping test's
  target from fx.r.wet (numerically identical to VOLUME_RANGE) to the
  log-scaled fx.n1.frequency, so the test actually discriminates real
  unit-space mapping from a linear guess or a verbatim value copy.
- automationLaneSelection.ts: drop the lone `!` non-null assertion in
  decimateEvenly's budget-of-1 branch for a guarded pattern, matching
  the loop right below it and the repo's no-`!` convention.
- .fallowrc.jsonc: remove the two ignoreExports entries for
  AUTOMATION_SHAPES and simplifyPoints — both are now genuinely
  consumed (AutomationSelectionMenu.tsx, TimelineAutomationLane.tsx).
- AutomationSelectionMenu.tsx: port TrackGapContextMenu's viewport-edge
  clamping so a right-click near the bottom/right of the timeline
  doesn't render the shape/simplify menu partially off-screen.
2026-08-06 10:59:37 -07:00
Vance Ingalls 03b27197a5 fix(studio): retime edge-stretch from a fixed points snapshot
moveEdge fed retimeRange the live draft on every pointermove while
origin.t0/t1 stayed pinned to the drag's start. retimeRange is a
relative transform that scales a lane's own current point positions,
so repeated pointermoves compounded the scale factor (interior points
drift toward the far edge) and could drop points that retimed past the
selection's original bound out of the next move's `inner` set entirely.

Snapshot lane.points at arm time (armBackgroundGesture) alongside the
existing frozen origin, and always retime from that snapshot in
moveEdge instead of the live draft. finishEdgeDrag is unchanged: it
already just persists the last (now-correct) preview.

Adds a regression test asserting a multi-pointermove edge-drag (both
edges) lands on the exact same final points as a single-shot drag to
the same target — the case that exposed the bug, since the existing
suite only ever tested a single move.
2026-08-06 10:59:37 -07:00
Vance IngallsandClaude Sonnet 5 94c8b8f1f2 feat(studio): stretch an automation selection by its edges
Add an edge-handle drag to a selection's rect: grabbing within 8px of
either edge retimes the selection via the already-landed retimeRange,
scaling interior points proportionally and clamping the dragged edge
against its partner and the clip's duration. Priority is point-drag >
curve-drag > edge-stretch > new-range-select, so a point sitting on an
edge still wins the press. Cursor shows col-resize while hovering or
dragging a handle.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 10:59:36 -07:00
Vance IngallsandClaude Sonnet 5 ef1a2867e9 test(studio): probe retimeRange's actual guarantee, not sample-continuity past a moved edge
The failing test probed t=5.1, which sits inside the reshaped transition
segment between the new edge (t=5) and the existing point (t=6). When
growing past an existing breakpoint, the transition TO that point
legitimately reshapes — the edge moved (t=3→t=5) even though the far
point (t=6) did not.

The real guarantee: all BREAKPOINTS strictly outside the union keep
exact (t, v) values. Corrected test to:
1. Verify sample continuity on unaffected side: t=[0,1,1.9]
2. Verify the breakpoint at t=6 keeps exact value: (t:6, v:0)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 10:59:35 -07:00
Vance IngallsandClaude Sonnet 5 b0273679e7 feat(studio): retime an automation selection
Add retimeRange pure operation that scales interior points proportionally
into a new time span, then uses replaceRange to update the lane while
preserving the envelope outside the union of old and new ranges.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 10:59:35 -07:00
Vance Ingalls ce90d046b2 fix(studio): repair the automation paste path and finish the key arbitration
Paste was the least safe path in this feature: it resolved its target from
the player store but committed through a different, asynchronously-lagging
channel. Six review findings against this branch, plus the Cmd+C/Cmd+V half
of the arbitration wa-15 started for Delete.

- Write channel: resolvePasteTarget bails unless the binding's
  commitTargetKey equals the element it resolved. useAutomationLanes exposes
  that key, resolved through resolveTimelineIdForSelection — the same
  resolver applyDomSelection uses — and read in the same render as the
  commit handlers, so a handler and the key cannot describe different
  moments. Before this, clicking clip B then immediately pasting serialized
  B's automation onto A and left B untouched.
- Chaining: the paste anchor comes from sel.t1, not sel.t0, so a second
  Cmd+V lands after the first instead of on top of it. The old comment
  claimed the new behaviour while the code did the opposite, and no test
  pressed Cmd+V twice.
- Empty copy: copyRange returns false rather than arming a clipboard whose
  every paste is a destructive flatten, and samples the range's edges so
  copying a smooth stretch yields a real segment instead of no points.
- Playhead: the playhead branch requires the playhead to be inside the clip
  rather than silently clamping an out-of-clip playhead to the clip's start.
- Keys: one chord helper normalizes with toLowerCase() and gates on
  !shiftKey && !altKey, matching useAppHotkeys. CapsLock no longer kills the
  shortcut and Ctrl+Alt+V no longer pastes where the app declines.
- Arbitration: useAppHotkeys consults automationOwnsKey before its c/v
  branch, so an active range keeps Cmd+C/Cmd+V from the clip clipboard the
  same way it keeps Delete. Without it Cmd+V duplicated the clip while the
  automation paste wrote the same file, and Cmd+C armed both clipboards.
  It returns without preventDefault — the downstream handler needs the key —
  and declines when the automation clipboard is empty so clip paste still
  works. dispatchModifierKey is exported to pin this, like dispatchPlainKey.
- Double-action: the hook now returns early on e.defaultPrevented.
  useAppHotkeys is on window/capture and deliberately lets a keyframe
  selection outrank a range on Delete; without this that press deleted the
  keyframes there AND emptied the range here.

- Project scoping: the clipboard scopes itself. Every entry point carries the
  project it speaks for and a mismatch empties the module, the shape
  keyframeSlice already uses to discard a request from a previous session.
  Scoping it inside the module rather than clearing it from the session seam
  is deliberate: the failure is silent and destructive — a range copied in
  project A pasted into B is remapped through A's captured sourceRange for an
  FX node B may not have, and the keystroke is consumed so clip paste never
  runs — so no future caller should be able to forget the guard. The mark
  isLastPasteSpan reads is scoped transitively, through the same check.
- Session reset: createTimelineResetState clears automationSelection. It is
  as ephemeral as selectedKeyframes, and a range surviving a project switch
  can match a same-keyed clip in the new project and redirect a paste through
  sel.elementKey === paste.elementKey to a stale t0.

Five of the six paste fixes above shipped without a test that fails without
them, which is how the branch reached review with a comment describing
chaining that the code did not do. Each now has one: a second Cmd+V landing
after the first, a commit-target mismatch declining, an out-of-clip playhead
declining, an empty-lane copy leaving an earlier clipboard intact, and Cmd+V
with CapsLock on. All five fail against this branch's parent.
2026-08-06 10:59:34 -07:00
Vance Ingalls 2ac86f1c3b feat(studio): copy and paste automation ranges across lanes
Extends the automation-selection keyboard hook with Cmd/Ctrl+C (copy the
active range) and Cmd/Ctrl+V (paste onto the selected clip's lane, at the
selection's start or the playhead, chaining the selection to the pasted
span so a second paste lands right after the first). Paste falls through
untouched when no target lane resolves, so clip-level paste keeps working.

Also fixes a latent test-isolation bug: setup() never unmounted the
previous test's Host, so document keydown listeners leaked across tests
and could consume later events before the current test's own listener ran.
2026-08-06 10:59:33 -07:00
Vance Ingalls 3019225713 feat(studio): internal clipboard for automation ranges 2026-08-06 10:59:33 -07:00
Vance Ingalls 559e96651f feat(studio): shape and simplify menu on an automation selection
Right-click inside an active time-selection rectangle on an automation
lane now opens a menu offering the four utility shapes (Ramp up, Ramp
down, Swell, Dip) and Simplify, composing generateShape/simplifyPoints
with pointsIn/replaceRange from the prior selection tasks. A point's own
right-click still stops propagation and deletes it, unaffected.
2026-08-06 10:59:32 -07:00
Vance IngallsandClaude Sonnet 5 d3d97d7737 chore: add automationSimplify to fallow complexity ignore
The Ramer-Douglas-Peucker algorithm in automationSimplify.ts has
inherent complexity (12 cyclomatic / 20 cognitive) that is by design
and not refactorable. Added to health.ignore list and ignoreExports
list since it's consumed by the UI layer one PR upstack.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 10:59:32 -07:00
Vance IngallsandClaude Sonnet 5 9cae2cea4d feat(studio): simplify dense automation runs
Implements Ramer-Douglas-Peucker point-thinning for audio automation
lane breakpoints, working in unit space for correct log-scaled
parameter handling.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 10:59:31 -07:00
Vance Ingalls 19bd6d4eea chore: suppress unused export for automationShapes (consumed upstack) 2026-08-06 10:59:31 -07:00
Vance Ingalls 9caaf5afea feat(studio): ramp, swell and dip generators for automation selections 2026-08-06 10:59:30 -07:00