From 7d38fed97fd50f70e5a67229250a186baac98d3c Mon Sep 17 00:00:00 2001
From: Vance Ingalls
Date: Thu, 13 Aug 2026 01:31:57 -0700
Subject: [PATCH] feat(studio): show every automated knob at the playhead
(#3210)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(studio): pure range ops for automation lane selections
Add pointsIn() and replaceRange() functions for managing automation envelope
edits within a time range. The key invariant: envelope values outside the
selection never move. Implemented by anchoring the boundaries at t0 and t1
by sampling the original lane, so cutting middle sections cannot reshape
the rest. Inner points from shape generators can suppress redundant anchors
at merge distance.
* fix(studio): budget replaceRange's inner points before capping, not after
* feat(studio): automation selection slice
* feat(studio): drag-select a time range on an automation lane
Dragging on an automation lane's empty background now arms a range
selection, snapped to the beat grid and clamped to the lane duration; a
sub-3px drag counts as a click and clears instead. Point drags and
Alt-drag segment bends still take priority, since the range arm only
runs where the existing point/segment hit-test already returned null.
useAutomationLanes binds the selection slice per element/lane so the
rect renders from the store, matching the read pattern the writes
already use.
* feat(studio): delete an automation selection from the keyboard
Escape clears the active automation-lane time selection; Delete/Backspace
empties it via replaceRange(..., inner: []), which pins anchor points at
both edges and leaves the envelope outside the selection untouched. Mounted
in TimelineLanes.tsx next to the useAutomationLanes() call that already
lives there. Also adds a stale-selection guard in TimelineAutomationLaneSlot
that clears the selection if its lane's target stops existing on the bound
element's automation (e.g. the automated effect was deleted).
* test(studio): cover the automation selection stale-target guard
* feat(studio): ramp, swell and dip generators for automation selections
* fix(studio): let an automation range keep Delete from the clip
useAppHotkeys listens on window/capture, so it runs before
useAutomationSelectionKeyboard's document/capture handler. With a range
selected, Delete fell straight through to the clip-delete branch and
destroyed the whole audio clip the lane belongs to; Backspace hit the
reset-keyframes branch on the way and wiped the clip's keyframes.
Guard both by returning early when automationSelection is set, mirroring
the selectedKeyframes precedent six lines above. No preventDefault: the
downstream handler still needs the key.
dispatchPlainKey is exported so the arbitration between keyframes, an
automation range and the clip can be pinned without standing up the hook.
Co-Authored-By: Claude Opus 5 (1M context)
* chore: suppress unused export for automationShapes (consumed upstack)
* feat(studio): simplify dense automation runs
Implements Ramer-Douglas-Peucker point-thinning for audio automation
lane breakpoints, working in unit space for correct log-scaled
parameter handling.
Co-Authored-By: Claude Sonnet 5
* chore: add automationSimplify to fallow complexity ignore
The Ramer-Douglas-Peucker algorithm in automationSimplify.ts has
inherent complexity (12 cyclomatic / 20 cognitive) that is by design
and not refactorable. Added to health.ignore list and ignoreExports
list since it's consumed by the UI layer one PR upstack.
Co-Authored-By: Claude Sonnet 5
* feat(studio): internal clipboard for automation ranges
* feat(studio): copy and paste automation ranges across lanes
Extends the automation-selection keyboard hook with Cmd/Ctrl+C (copy the
active range) and Cmd/Ctrl+V (paste onto the selected clip's lane, at the
selection's start or the playhead, chaining the selection to the pasted
span so a second paste lands right after the first). Paste falls through
untouched when no target lane resolves, so clip-level paste keeps working.
Also fixes a latent test-isolation bug: setup() never unmounted the
previous test's Host, so document keydown listeners leaked across tests
and could consume later events before the current test's own listener ran.
* feat(studio): shape and simplify menu on an automation selection
Right-click inside an active time-selection rectangle on an automation
lane now opens a menu offering the four utility shapes (Ramp up, Ramp
down, Swell, Dip) and Simplify, composing generateShape/simplifyPoints
with pointsIn/replaceRange from the prior selection tasks. A point's own
right-click still stops propagation and deletes it, unaffected.
* feat(studio): retime an automation selection
Add retimeRange pure operation that scales interior points proportionally
into a new time span, then uses replaceRange to update the lane while
preserving the envelope outside the union of old and new ranges.
Co-Authored-By: Claude Sonnet 5
* fix(studio): repair the automation paste path and finish the key arbitration
Paste was the least safe path in this feature: it resolved its target from
the player store but committed through a different, asynchronously-lagging
channel. Six review findings against this branch, plus the Cmd+C/Cmd+V half
of the arbitration wa-15 started for Delete.
- Write channel: resolvePasteTarget bails unless the binding's
commitTargetKey equals the element it resolved. useAutomationLanes exposes
that key, resolved through resolveTimelineIdForSelection — the same
resolver applyDomSelection uses — and read in the same render as the
commit handlers, so a handler and the key cannot describe different
moments. Before this, clicking clip B then immediately pasting serialized
B's automation onto A and left B untouched.
- Chaining: the paste anchor comes from sel.t1, not sel.t0, so a second
Cmd+V lands after the first instead of on top of it. The old comment
claimed the new behaviour while the code did the opposite, and no test
pressed Cmd+V twice.
- Empty copy: copyRange returns false rather than arming a clipboard whose
every paste is a destructive flatten, and samples the range's edges so
copying a smooth stretch yields a real segment instead of no points.
- Playhead: the playhead branch requires the playhead to be inside the clip
rather than silently clamping an out-of-clip playhead to the clip's start.
- Keys: one chord helper normalizes with toLowerCase() and gates on
!shiftKey && !altKey, matching useAppHotkeys. CapsLock no longer kills the
shortcut and Ctrl+Alt+V no longer pastes where the app declines.
- Arbitration: useAppHotkeys consults automationOwnsKey before its c/v
branch, so an active range keeps Cmd+C/Cmd+V from the clip clipboard the
same way it keeps Delete. Without it Cmd+V duplicated the clip while the
automation paste wrote the same file, and Cmd+C armed both clipboards.
It returns without preventDefault — the downstream handler needs the key —
and declines when the automation clipboard is empty so clip paste still
works. dispatchModifierKey is exported to pin this, like dispatchPlainKey.
- Double-action: the hook now returns early on e.defaultPrevented.
useAppHotkeys is on window/capture and deliberately lets a keyframe
selection outrank a range on Delete; without this that press deleted the
keyframes there AND emptied the range here.
- Project scoping: the clipboard scopes itself. Every entry point carries the
project it speaks for and a mismatch empties the module, the shape
keyframeSlice already uses to discard a request from a previous session.
Scoping it inside the module rather than clearing it from the session seam
is deliberate: the failure is silent and destructive — a range copied in
project A pasted into B is remapped through A's captured sourceRange for an
FX node B may not have, and the keystroke is consumed so clip paste never
runs — so no future caller should be able to forget the guard. The mark
isLastPasteSpan reads is scoped transitively, through the same check.
- Session reset: createTimelineResetState clears automationSelection. It is
as ephemeral as selectedKeyframes, and a range surviving a project switch
can match a same-keyed clip in the new project and redirect a paste through
sel.elementKey === paste.elementKey to a stale t0.
Five of the six paste fixes above shipped without a test that fails without
them, which is how the branch reached review with a comment describing
chaining that the code did not do. Each now has one: a second Cmd+V landing
after the first, a commit-target mismatch declining, an out-of-clip playhead
declining, an empty-lane copy leaving an earlier clipboard intact, and Cmd+V
with CapsLock on. All five fail against this branch's parent.
* test(studio): probe retimeRange's actual guarantee, not sample-continuity past a moved edge
The failing test probed t=5.1, which sits inside the reshaped transition
segment between the new edge (t=5) and the existing point (t=6). When
growing past an existing breakpoint, the transition TO that point
legitimately reshapes — the edge moved (t=3→t=5) even though the far
point (t=6) did not.
The real guarantee: all BREAKPOINTS strictly outside the union keep
exact (t, v) values. Corrected test to:
1. Verify sample continuity on unaffected side: t=[0,1,1.9]
2. Verify the breakpoint at t=6 keeps exact value: (t:6, v:0)
Co-Authored-By: Claude Sonnet 5
* feat(studio): stretch an automation selection by its edges
Add an edge-handle drag to a selection's rect: grabbing within 8px of
either edge retimes the selection via the already-landed retimeRange,
scaling interior points proportionally and clamping the dragged edge
against its partner and the clip's duration. Priority is point-drag >
curve-drag > edge-stretch > new-range-select, so a point sitting on an
edge still wins the press. Cursor shows col-resize while hovering or
dragging a handle.
Co-Authored-By: Claude Sonnet 5
* fix(studio): retime edge-stretch from a fixed points snapshot
moveEdge fed retimeRange the live draft on every pointermove while
origin.t0/t1 stayed pinned to the drag's start. retimeRange is a
relative transform that scales a lane's own current point positions,
so repeated pointermoves compounded the scale factor (interior points
drift toward the far edge) and could drop points that retimed past the
selection's original bound out of the next move's `inner` set entirely.
Snapshot lane.points at arm time (armBackgroundGesture) alongside the
existing frozen origin, and always retime from that snapshot in
moveEdge instead of the live draft. finishEdgeDrag is unchanged: it
already just persists the last (now-correct) preview.
Adds a regression test asserting a multi-pointermove edge-drag (both
edges) lands on the exact same final points as a single-shot drag to
the same target — the case that exposed the bug, since the existing
suite only ever tested a single move.
* fix(studio): clamp selection-start paste, sharpen clipboard test, cleanup
- useAutomationSelectionKeyboard: clamp the selection-start paste branch
to [0, element.duration - clip.span], same as the playhead branch
already does. An unclamped paste near a clip's end could write points
past element.duration and leave the resulting selection's edge
ungrabbable off the visible lane.
- automationClipboard.test.ts: swap the cross-parameter mapping test's
target from fx.r.wet (numerically identical to VOLUME_RANGE) to the
log-scaled fx.n1.frequency, so the test actually discriminates real
unit-space mapping from a linear guess or a verbatim value copy.
- automationLaneSelection.ts: drop the lone `!` non-null assertion in
decimateEvenly's budget-of-1 branch for a guarded pattern, matching
the loop right below it and the repo's no-`!` convention.
- .fallowrc.jsonc: remove the two ignoreExports entries for
AUTOMATION_SHAPES and simplifyPoints — both are now genuinely
consumed (AutomationSelectionMenu.tsx, TimelineAutomationLane.tsx).
- AutomationSelectionMenu.tsx: port TrackGapContextMenu's viewport-edge
clamping so a right-click near the bottom/right of the timeline
doesn't render the shape/simplify menu partially off-screen.
* fix(studio): give edge-stretch the gesture contract the other four follow
Seven review findings against this branch, five of which were one defect:
edge-stretch was added as a fifth mutually-exclusive gesture on the lane
without joining the threshold / live-preview / revert-on-cancel contract the
point drag, curve bend, range drag and double-click all obey. Patching them
one at a time would have been more code and less coherent, so this makes the
stretch structurally parallel to its sibling range drag instead, and extracts
it to useAutomationEdgeStretch on the way out — the gestures file had ~60
lines of headroom under the 600-line studio cap, and shaving comments to fit
a refactor in is not a plan.
- Threshold. A press within the 8px halo of either edge used to persist a
no-op commit and push an undo entry that changed nothing (commitDataAttribute
has no unchanged-value short-circuit). Worse, it made the pre-existing "click
the background to clear the selection" escape unreachable anywhere near an
edge. Below 3px of travel — the same threshold the range drag uses — the
press now clears the selection and writes nothing at all.
- Live preview. moveEdge never fired onRangeSelect and the hook discarded the
drag's live position, so the highlight rect and both edge lines stayed pinned
at the pre-drag bounds for the whole gesture and snapped into place on
release: the user dragged an invisible handle. It now reports bounds on every
move, exactly as the marquee drag does and for the same reason.
- Revert on cancel. pointercancel means the browser abandoned the gesture; it
was routed to the same handler as pointerup, which persisted whatever partial
retime it had reached. It now restores the arm-time snapshot through the
preview channel — there is nothing persisted to undo — and puts the selection
back. A new cancelDrag handler owns that, so a release and an abandonment are
no longer the same event.
- Lost capture. capturePointer took the capture on e.target, i.e. whichever
child the press landed on. A child that unmounts mid-drag takes the capture
with it, silently, with no pointercancel — after which edgeDrag stayed
non-null and every later button-less pointermove kept retiming and writing.
Capture is now taken on the svg, which outlives every gesture on it, and a
move reporting no buttons held ends the drag as a cancel.
- Hit priority. A breakpoint sitting exactly on the selection's edge used to
win the press. Since replaceRange pins an anchor at the union bound and
finishEdgeDrag leaves the selection edge at that same time, EVERY range
operation — stretch, delete, shape insert — leaves a point exactly on the
edge it just created: the second stretch of the same edge resolved to a
point-drag, at the one height (on the envelope) where a user naturally grabs
it. The feature was not repeatable. An active selection's edge now outranks a
point on it; clearing the selection reaches the point again, which is tested.
- Clamp order. The dragged edge was bounded against its partner AFTER the
0-floor, so a selection thinner than the minimum width yielded a negative t0,
which core's cleanPoint then collapses onto a duplicate t=0 on the serialize
round-trip — silent envelope corruption. The floor is now applied last. The
minimum width is its own MIN_SELECTION_SEC rather than a borrowed
POINT_MERGE_SEC: when two breakpoints are the same breakpoint is a different
question from how thin a time selection may get.
One finding does not survive: edgeAt's `d0 <= d1` tiebreak was reported as
making the t1 edge ungrabbable on a narrow selection, but that comparison IS
nearest-wins, and a press right of the midpoint already resolved to t1. The
midpoint split here is the same rule written so it is legible rather than
inferred, and the test for it is labelled as characterizing behaviour, not
fixing it. What was genuinely unreachable inside a narrow halo — starting a
fresh range, or clearing the old one without Escape — the threshold above fixes.
Also settles what retimeRange does with a breakpoint sitting ON a dragged
edge, which was never decided: pointsIn is endpoint-inclusive, so it is
interior and travels with the stretch. It has to be, because the commonest
stretch of all is grabbing an edge to drag exactly that point outward, and
anchoring it would delete it and flatten the span instead. The price is that
the retimed point lands on the union's own boundary where a preservation anchor
would go, and anchor() stands down within a merge radius — one time cannot hold
two values — so the segment leaving the union reshapes. That is the one place
replaceRange's outside-never-moves invariant bends, and both halves are now
pinned: the exact points and the sampled slope for the on-edge case, and the
full two-sided invariant for a selection whose edges are off any breakpoint.
The earlier right-side probe at t=5.1 that caught this was deleted during
development as inherent; it was reporting the real behaviour.
* fix(core): make audio automation survive being rescheduled mid-playback
Anything landing inside a running value curve is refused unless the parameter is
cancelled first, and two paths were not cancelling: the chain observer wrote each
knob straight onto its AudioParam before rescheduling, and a bent segment read as
straight because only the curve exponent was checked, never the via point the
timeline actually writes. The first threw NotSupportedError into the console and
abandoned the rest of the envelope; the second played a dragged bend as a ramp.
Measured against Chrome, in a live context and in an offline one suspended
mid-curve: any cancel frees the span, and only a missing cancel is refused.
clearParamLane takes the strongest form on purpose, because curve-over-curve
refusals were reported with a cancel at the new schedule time already in place
and have never reproduced; emit keeps a ramp fallback as the backstop for
whatever that mechanism turns out to be.
Dynamic carve is what exercises all of it, so it lands here too:
- a `gain` primitive, so a carve can match levels as well as carve bands
- carve settings collapse to one `strength`, with carveProfile deriving the six
numbers that always moved together anyway
- analyseCarveDynamics / analyseCarveDuck turn the analysis into envelopes, with
a slow release so the bed does not snap back the instant a word ends
- worklets are awaited inside attach, so adding a compressor to a carved bed no
longer kills its envelopes and freezes every later edit
- per-track failure detail in the render's audio stage, which was being discarded
Co-Authored-By: Claude Opus 5 (1M context)
* feat(studio): select automation points with a box, and stop them crossing
Replaces the time-range selection with a rectangle. A lane selection is a set of
breakpoints, not a span, so it now has value bounds as well as time bounds and a
point is caught only if it falls inside both — which is what lets you take the
peaks of an envelope and leave the dips between them. Delete, the group drag and
the rings drawn on caught points all read the one rule, so what looks selected is
exactly what those act on. Copy, paste, shape insert and simplify still work on
the box's time span, because they act on the envelope over a stretch of time.
Dragging is bounded by its neighbours in both the single and group cases. A point
cannot cross another, and cannot land exactly on one either: the lane collapses
points that share a `t`, keeping the later one, so arriving on top of a neighbour
deleted it. It stops a millisecond short, which is under a pixel at any zoom the
lane offers and keeps both points. Only stationary neighbours constrain a group,
per member rather than per end, since a box can select a non-contiguous set.
Edge-stretch is removed rather than fixed. Dragging a selection's edges to retime
the points inside it was the feature this branch opened for, and it is not wanted:
the hook, retimeRange, the edge handles, the col-resize cursor and the pointercancel
revert path all go, along with the ~360 lines of tests that pinned them.
Also: gesture-scoped coalescing keys, so one drag is one undo entry rather than a
fragmented chain of them.
Co-Authored-By: Claude Opus 5 (1M context)
* fix(studio): put #3207's edge-stretch back, folded into the unified hook
The review blocks this PR for deleting a feature two PRs downstack:
"#3209 deletes #3207 edge-stretch instead of folding it into the unified
hook... merging the stack would ship #3207 and then silently remove its
user-facing retime/edge-stretch feature."
Verified, all four claims: `useAutomationEdgeStretch.ts` (248 lines) and its
test were deleted, `retimeRange` was dropped from
`automationLaneSelection.ts`, and the consolidated hook has no edge
hit-test, arm/move/finish path, or resize cursor.
Restored: the module, `retimeRange`, the selection regressions, and the
lane wiring (`col-resize` cursor, `pointercancel` reverting a partial
retime rather than persisting it).
It is not a straight revert, because #3209 changed the selection from a
time range to a box. Edge-stretch now takes `{t0,t1,v0,v1}` and moves only
the time edges — the value extent rides through untouched, which keeps it
the same gesture it was.
**One arbitration call worth a second opinion.** #3207's rule was that a
selection's edge outranks a point sitting on it, because every range
operation leaves a breakpoint exactly on the edge it created — a
point-first rule made the second stretch of an edge resolve to a
point-drag. Under a box that rule now contradicts #3209's own test
("stops the group at a point it did not select"), which presses at t=0
v=1 — simultaneously the t0 edge and a selected point.
I inverted it: selected content wins, the edge stretches everywhere it is
not also selected content. The reasoning is that a box makes the point
visibly part of the selection, and dragging selected content has to move
it. That restores #3209's test and keeps the stretch usable along the rest
of the edge — but it is a product decision between two deliberate designs,
so flag it if #3207's original precedence was load-bearing.
954 player tests pass, including the 17 restored ones.
* feat(studio): show every automated knob at the playhead, and carve as one module
An automated parameter has two values: the number sitting in the chain, which is
only the seed a lane replaced, and the number the envelope is on right now. The
second is the true one, so the panel shows it — on the carve rack's readouts and
on every effect's own fader and number field. A rack that showed the seed stood
still while the carve was audibly working.
Off the clip it keeps sampling rather than falling back to the stored number: a
lane holds its first value backwards and its last forwards, so before the clip
starts it already knows what it will open on, and the stored seed is a value
nothing will ever play. Showing it made the fader jump the moment the clip came
under the playhead.
The playhead comes off the liveTime channel, throttled to 30 Hz — the RAF loop
deliberately keeps frames out of the store, so a panel watching only the store
would sit still for a whole take. PropertyPanel had that subscription inline;
it is now one shared hook with two callers.
Readouts reserve the width their parameter can need rather than what its current
value takes, because an updating value one character narrower shunted everything
after it sideways 30 times a second.
The carve's effects are presented as one module: an author switched on a carve,
and the peaking filters plus the level stage are how it is built, not six things
to remove one at a time. Opening it lists every member's settings as readouts,
since strength is what sets them. No carve control is offered on a track another
track already carves against — that track is the voice, not the bed.
Co-Authored-By: Claude Opus 5 (1M context)
* feat(engine): render audio FX in an OfflineAudioContext
Reads `data-fx-chain` off an audio element and runs the chain over the trimmed
WAV before volume automation is baked in — effects should see the raw signal,
and the envelope belongs on their output.
The processing happens in an OfflineAudioContext inside the headless browser
the engine already drives, running the same graph builders the studio previews
with. That is the point of the approach: one implementation per effect, so the
render agreeing with the preview is a property of the architecture rather than
a tolerance to police. Reimplementing each effect as an FFmpeg filter would
mean two implementations to keep in step, and for the dynamics processors and
modulated delays there is no filter that behaves the same way.
`build:audio-fx-runtime` bundles the graph builders into an injectable IIFE,
following the same pattern as the existing runtime artifacts, so the browser
runs exactly the code the studio does.
The page loads from a file:// URL rather than about:blank because AudioWorklet
is only exposed in a secure context — the compressor, limiter, gate and
bitcrush processors would otherwise fail to register with an opaque error.
file:// qualifies and needs no listening socket.
The chain is serialised into the attribute the way colour grading carries its
config, so there is no side-car file to resolve or lose.
An FX failure is fatal for the whole mix rather than a per-track soft failure.
Every other audio failure mode degrades gracefully — the track drops, siblings
continue — but substituting the dry signal for a processed one ships a render
that sounds plausible and is not what the author set up. Since the per-element
work races under Promise.all, an internal AbortController chained off the
caller's signal aborts in-flight siblings before workDir is removed.
* feat(core): voiceover carve analysis
Finds the bands a voice occupies so a music bed can be dipped there, letting
the voice sit in front without ducking the whole track.
Carve is a relationship between two tracks rather than an effect on one, so it
stays out of the FX chain. What it emits is an ordinary chain of peaking
filters, so a carve composes with whatever else is on the track and needs no
separate rendering path.
Selection is weighted toward intelligibility rather than raw voice energy.
Ranking purely by power lands on the fundamental almost every time, because
that is where a voice is loudest — but the masking that actually hurts a
voiceover happens higher up, and dipping 160 Hz mostly just thins the bed. The
bias is a control, not a constant: at 0 it follows raw energy, at 1 it weights
toward 1-3 kHz.
Ranking happens in dB, which matters more than it looks. Speech spreads 20-30 dB
across these bands — it falls off roughly 6 dB per octave above the fundamental
— so a weighting has to be on that scale to move anything at all. A
multiplicative weight of `1 - bias + bias * shaped` is bounded below by
`1 - bias`, capping its influence at 10*log10(1/(1 - bias)): 5.2 dB at the 0.7
default, 3 dB at 0.5. That is no influence against a real voice — every bias
short of ~0.95 would rank exactly like bias 0 and carve the fundamental, the
outcome the bias exists to prevent, while looking decisive against a fixture
whose bands sit 2 dB apart. So the bias is a dB penalty, zero at 2 kHz and worth
up to 30 dB at full strength, and relative cut depths come from a dB difference
rather than a ratio of weighted linear powers.
The bias reweights ranking without overriding the spectrum — a band the voice
has no energy in is not worth carving, and scores -Infinity rather than
competing — so a strongly low-pitched voice can still select low at full bias.
What the tests hold is that biasing never selects lower than the unbiased
ranking, that the DEFAULT bias reaches the presence region on a voice with a
realistic tilt, and that bias 0 still follows raw power exactly.
Includes a radix-2 FFT rather than a dependency; one Welch-style averaged
spectrum over third-octave bands does not justify pulling in a DSP library.
* fix(engine): keep the FX render 16-bit, stereo, and correctly sized
Three defects in the offline FX path, none of which any test could see.
**Float output silently disabled sample-accurate volume automation.** The writer
emitted 32-bit IEEE float; the very next mixer step bakes the volume envelope
into the samples and accepts only 16-bit PCM, returning null otherwise. So
enabling any effect downgraded that track to the ffmpeg expression path — capped
at 32 straight segments, quantising a curved envelope, and on a dense one falling
back to base volume. It now writes 16-bit PCM, clamped rather than wrapped so a
limiter at 0 dB or a resonant filter cannot turn overshoot into a click. A test
asserts the baker accepts the writer's own output and actually fades it.
**Everything was folded to mono.** `prepareAudioTrack` goes out of its way to
emit stereo — its pan filter exists to dodge ffmpeg's 3 dB mono-to-stereo
rematrix — and this folded it, then wrote one channel. So adding a single peaking
EQ collapsed a bed's width and cost ~3 dB in the render, while preview stayed
stereo. Channels now travel as one plane each, through an OfflineAudioContext of
the same width, and come back interleaved.
**Small results decoded the wrong length.** `new Float32Array(buf.buffer)`
discards byteOffset and byteLength, and Node pools small allocations: a 400-byte
payload sits at offset 8 inside an 8 KiB pool, so a clip under ~1024 samples
decoded as 2048 samples of unrelated memory — and the empty-result guard could
not see it. The reader has the mirror-image fix: a float data chunk on an odd
boundary (ffmpeg's pcm_f32le writes fmt(18) + fact, landing `data` at 58) now
copies instead of throwing RangeError on an unaligned view.
The tail limitation is now stated rather than mis-stated: the context is exactly
as long as the input, so a reverb or delay still ringing is cut there. The old
comment claimed the opposite. How far a tail may run past a clip's end changes
the clip's length in the mix, so it is a product decision, not one to make here.
Co-Authored-By: Claude Opus 5 (1M context)
* fix(producer): report an FX render failure as an audio error
`processCompositionAudio` reports per-track failures in its result, but an FX
failure it cannot degrade past — a browser that will not launch, a chain that
will not build — rejects instead. `runAudioStage` had no try, so that rejection
escaped to the orchestrator as an unclassified pipeline exception, losing the
stage/owner/retryable classification this stage exists to attach, and skipping
its abort check on the way out.
It now lands in `audioError` alongside every other cause, while an abort still
keeps its own shape rather than being reported as an audio problem.
Not done here: committing the generated `audio-fx-runtime-inline.ts` so a fresh
clone typechecks packages/engine without building first. The bundle is built from
the stub, and the stub changes three times across this stack — so the artifact
differs per branch and would conflict on every restack. Its model,
position-edits-render-inline.ts, is committed only because it is stable. Building
before testing is this monorepo's existing contract (studio's tests need core's
dist too), so the gap is not specific to audio FX and is better closed by a build
ordering gate than by committing a per-branch artifact.
Co-Authored-By: Claude Opus 5 (1M context)
* test(engine): skip the browser FX render cases when there is no browser
CI's `Test` job was red on this PR with four failures, all the same cause:
Failed to launch the browser process: spawn
/home/runner/.cache/hyperframes/chrome/chrome-headless-shell
The job installs ffmpeg and no browser, deliberately — every other suite
that needs an external binary already guards on it
(`describe.skipIf(!HAS_FFMPEG)`). These cases were the only ones assuming
a Chrome, so they failed on an absent dependency rather than on anything
about the code.
Guards on `resolveHeadlessShellPath()` — the same resolver
`acquireBrowser` launches through, so the check cannot drift from the
thing it guards the way a hard-coded cache path would. A configured path
that does not exist throws; that is caught and read as "cannot run here".
Checked both directions rather than just the green one: with a browser all
11 cases run and pass, and with `HYPERFRAMES_BROWSER_PATH` pointed at a
missing binary exactly 3 skip and the other 8 still run. A guard that
silently skipped everything would have looked identical in CI.
They keep their value where it exists — every developer machine, and any
job that has run `hyperframes browser ensure`.
Not touched: the CodeQL failure on this PR is a run from 2026-08-07, five
days and several force-pushes stale. None of the 17 open repo alerts are
in files this PR changes; it re-runs on this push.
* chore(engine): suppress the temp-file alert with the reason it is safe
CodeQL flags `writeWav`'s `writeFileSync` as js/insecure-temporary-file
(high) — the one new alert on #3021, and the reason its CodeQL check is
red.
It is a false positive, and the comment says why rather than just silencing
it: `path` is always inside a directory made by `mkdtempSync`, never a
name assembled directly under `tmpdir()`. Both callers are covered — the
browser host page writes into `mkdtempSync(join(tmpdir(), "hf-fx-host-"))`,
and the render output goes to the producer work dir, itself
`mkdtempSync(join(tempRoot, "producer-project-"))`. mkdtemp picks the
random suffix and creates the directory 0700 in one syscall, so the
predictable filename inside it cannot be pre-created or symlinked by
another user, which is the attack the rule is about. The analyzer sees the
dataflow reach `tmpdir()` and not the mkdtemp in between.
Suppressed inline rather than dismissed in the UI, so the justification
lives next to the code and the rule stays live for anything added later in
this file. Matches the repo's existing convention — `planV2.ts:222`
carries an `lgtm[js/insecure-temporary-file]` for a different reason on
the same rule.
Correcting myself: I first reported this alert as not real, having
intersected the PR's files against the default-branch alert list, which
does not contain PR-ref alerts. Querying ?ref=refs/pull/3021/merge returns
it straight away.
* test(engine): probe ffmpeg and Chrome instead of assuming them
Two failures on #3021's Test job, both about the environment rather than
the code under test.
**Bare `ffmpeg` is not on PATH in CI.** The 16-bit fixture shelled out to
`execFileSync("ffmpeg", ...)` and died with ENOENT. The job does provide
ffmpeg, through `prepare-ffmpeg-bin`, which is what `getFfmpegBinary()`
resolves — every other ffmpeg-dependent suite in this package already goes
through it. Now this one does too, and the case is `skipIf(!HAS_FFMPEG)`
so a contributor without ffmpeg skips rather than fails.
**The browser guard trusted the wrong thing.** It asked
`resolveHeadlessShellPath()` and treated a returned path as "a browser is
here". CI's cache holds a chrome-headless-shell that resolves and then
fails to spawn — a partial download is indistinguishable from a working
one by `existsSync`, which is all that resolver checks. So the three
browser cases ran anyway and failed on the launch.
It now runs `--version` and requires exit 0, which is the same probe the
ffmpeg suites use: ask the binary, do not infer from the filesystem.
Checked both directions rather than just the green one. With a working
browser all 11 cases run and pass; with `HYPERFRAMES_BROWSER_PATH` pointed
at a binary that exits non-zero — CI's exact situation — exactly 3 skip
and the other 8 still run. A guard that quietly skipped everything would
have looked identical on the CI summary.
* feat(core): register the audio-fx-rack canary at 0%
Lands the rollout switch dark, per the registry's own procedure: "Start at
percentage: 0 and merge that — a canary at 0 is dead code you can land
safely and ramp without a code review."
Declared at the bottom of the stack so every branch above can read it. The
gate itself goes in at wa-4-fx-panel, where the rack first appears.
Scope is deliberate and stated in the description: it gates the AUTHORING
surface only. A composition that already carries `data-fx-chain` still
plays and renders it. A canary should stage who can REACH a feature, not
make an attribute somebody already wrote silently inert — an agent that
writes a chain through the skill would otherwise produce a file whose audio
processing vanishes with no error.
* feat(studio): audio FX panel generated from the registry
Controls for the whole chain: add, remove, reorder, bypass, and every knob each
effect declares.
Nothing in the panel knows what a compressor is. The registry supplies each
parameter's range, step, unit and scale and the panel renders what it finds, so
adding an effect or a knob upstream needs no change here, and the panel cannot
offer a value the renderer would reject — a typed-in figure is clamped into the
declared range on the way through.
Frequency and time controls span three or four decades, so those declare a log
scale and the slider maps exponentially; a linear slider would spend most of
its travel somewhere useless.
Reorder is a first-class control because chain order changes the sound: a
reverb before a compressor is not the same as after.
Carve gets its own block rather than an entry in the add menu, with a picker
for the voice track to listen to. It processes this track based on another one,
which is how a sidechain control works — it lives on the track that changes,
and names the source.
* feat(studio): show the Audio FX section on audio tracks
Adds `audioFx` to the editing-affordances contract and renders the FX panel in
the inspector when an `
) : (
- chain.nodes.map((node, i) => (
- setOpenNode(openNode === i ? null : i)}
- onUpdate={updateNode}
- onMove={moveNode}
- onRemove={removeNode}
- onPreview={previewNode}
- />
- ))
+ chain.nodes.map((node, i) => {
+ if (node.fromCarve) {
+ // The module stands in for the whole run of carve nodes, drawn once
+ // at the first of them.
+ const first = chain.nodes.findIndex((n) => n.fromCarve);
+ if (i !== first) return null;
+ return (
+ setCarveOpen((was) => !was)}
+ onToggleBypass={toggleCarveBypass}
+ onRemove={removeCarve}
+ />
+ );
+ }
+ return (
+ setOpenNode(openNode === i ? null : i)}
+ onUpdate={updateNode}
+ onMove={moveNode}
+ onRemove={removeNode}
+ onPreview={previewNode}
+ />
+ );
+ })
)}
@@ -484,6 +760,12 @@ export function FxSection({
))}
+ {/* One knob for the whole effect. Depth, band count, width, the
+ intelligibility weighting and both level-match numbers move
+ together anyway — a gentle carve is shallow in few bands with
+ little ducking, a hard one is deeper in more with more — so the
+ panel sets the strength and `carveProfile` derives the six
+ numbers the analysis works in. */}
previewCarve({ ...carve, strength: Number(v) })}
onCommit={(_k, v) => onCarveChange({ ...carve, strength: Number(v) })}
/>
-
+ {/* A static carve holds its cuts for the whole clip, pauses
+ included. Dynamic hands every value to an envelope of the voice's
+ own level, so the bed is only worked on while there is something
+ to make room for. Written as ordinary automation, which is why the
+ lanes show up in the timeline and can be edited afterwards. */}
+
+ {analysing ? (
+
+ Analysing…
+
+ ) : null}
>
) : null}
diff --git a/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx b/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx
index 6fb123044..87fb8523d 100644
--- a/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx
+++ b/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx
@@ -20,8 +20,8 @@ import type { TimelineElement } from "../player/store/timelineElement";
* A selection box spanning the lane's whole value axis.
*
* What almost every test here is about is the time span — which breakpoints a
- * Delete or a copy covers. Giving these an unbounded axis keeps the fixture
- * out of the way of that.
+ * Delete or a copy covers. The box's value bounds have their own tests; giving
+ * these an unbounded axis keeps them testing the one thing they name.
*/
function wholeAxis(sel: T): T & { v0: number; v1: number } {
return { ...sel, v0: Number.NEGATIVE_INFINITY, v1: Number.POSITIVE_INFINITY };
@@ -117,7 +117,11 @@ describe("useAutomationSelectionKeyboard", () => {
return { onCommit };
};
- it("Delete empties the selected range and pins anchors", () => {
+ it("Delete removes every breakpoint the selection covers", () => {
+ // Deleted, not emptied. Pinning anchors at the selection's edges keeps the
+ // envelope either side from moving, which is right for a shape insert or a
+ // paste — but answering "delete these points" with two NEW points at the edges
+ // reads as the delete not having worked.
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
usePlayerStore
.getState()
@@ -126,7 +130,69 @@ describe("useAutomationSelectionKeyboard", () => {
key("Delete");
const written = onCommit.mock.calls.at(-1)?.[0];
const points = written?.lanes?.[0]?.points ?? [];
- expect(points.map((p: { t: number }) => p.t)).toEqual([0, 1, 3, 4]);
+ // The fixture lane is 0, 2, 4: only t=2 was inside.
+ expect(points.map((p: { t: number }) => p.t)).toEqual([0, 4]);
+ });
+
+ it("Delete leaves a point the box's value bounds exclude", () => {
+ // The box spans the whole clip but only its top, so Delete takes the one
+ // breakpoint up there and nothing else. A time range could not express this.
+ usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
+ usePlayerStore.getState().setAutomationSelection({
+ elementKey: "bgm",
+ target: "volume",
+ t0: 0,
+ t1: 4,
+ v0: 0.9,
+ v1: 1,
+ });
+ const { onCommit } = setup({});
+ key("Delete");
+ const written = onCommit.mock.calls.at(-1)?.[0];
+ const points = written?.lanes?.[0]?.points ?? [];
+ // Fixture is (0, v=1), (2, v=0.5), (4, v=0): only the first was in the box.
+ expect(points.map((p: { t: number }) => p.t)).toEqual([2, 4]);
+ });
+
+ it("Delete takes points sitting exactly on the selection's edges", () => {
+ // Endpoint-inclusive, matching the copy path: a point the selection was dragged
+ // over is inside it, edge or not. Every range operation leaves a breakpoint
+ // exactly on an edge, so excluding them would leave those behind every time.
+ usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
+ usePlayerStore
+ .getState()
+ .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
+ const { onCommit } = setup({});
+ key("Delete");
+ const points = onCommit.mock.calls.at(-1)?.[0]?.lanes?.[0]?.points ?? [];
+ expect(points.map((p: { t: number }) => p.t)).toEqual([0]);
+ });
+
+ it("Delete over a stretch with no breakpoints writes nothing at all", () => {
+ // A no-op rather than a write: emptying a span that had nothing in it used to
+ // push an undo entry that changed nothing but the anchors it invented.
+ usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
+ usePlayerStore
+ .getState()
+ .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2.5, t1: 3.5 }));
+ const { onCommit } = setup({});
+ const e = new KeyboardEvent("keydown", { key: "Delete", bubbles: true, cancelable: true });
+ act(() => void document.dispatchEvent(e));
+ expect(onCommit).not.toHaveBeenCalled();
+ expect(e.defaultPrevented).toBe(false);
+ });
+
+ it("Delete clears the lane when the selection covers all of it", () => {
+ usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
+ usePlayerStore
+ .getState()
+ .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 0, t1: 6 }));
+ const { onCommit } = setup({});
+ key("Delete");
+ const written = onCommit.mock.calls.at(-1)?.[0];
+ // withLane drops a lane with no points left, so the attribute goes empty and
+ // the clip is back to its plain data-volume.
+ expect(written?.lanes ?? []).toEqual([]);
});
it("Escape clears the selection", () => {
@@ -194,8 +260,10 @@ describe("useAutomationSelectionKeyboard", () => {
target: "volume",
t0: 5,
t1: 7,
- v0: VOLUME_RANGE.min,
- v1: VOLUME_RANGE.max,
+ // Full height: everything the paste landed is selected, so Delete straight
+ // after undoes it in one press.
+ v0: 0,
+ v1: 1,
});
});
@@ -231,8 +299,10 @@ describe("useAutomationSelectionKeyboard", () => {
target: "volume",
t0: 4,
t1: 6,
- v0: VOLUME_RANGE.min,
- v1: VOLUME_RANGE.max,
+ // Full height: everything the paste landed is selected, so Delete straight
+ // after undoes it in one press.
+ v0: 0,
+ v1: 1,
});
});
@@ -267,8 +337,10 @@ describe("useAutomationSelectionKeyboard", () => {
target: "volume",
t0: 4,
t1: 6,
- v0: VOLUME_RANGE.min,
- v1: VOLUME_RANGE.max,
+ // Full height: everything the paste landed is selected, so Delete straight
+ // after undoes it in one press.
+ v0: 0,
+ v1: 1,
});
});
diff --git a/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts b/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts
index 8dfb9704a..5e73ab52f 100644
--- a/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts
+++ b/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts
@@ -1,8 +1,8 @@
/**
* Keyboard surface for the active automation selection: Escape clears,
- * Delete/Backspace empties the range (anchors pinned, envelope outside
- * untouched), Cmd/Ctrl+C copies it, Cmd/Ctrl+V pastes at the selection's
- * start (or the playhead) onto the selected clip's lane. Sibling of
+ * Delete/Backspace deletes every breakpoint inside the selection box, Cmd/Ctrl+C
+ * copies its span, Cmd/Ctrl+V pastes at the selection's start (or the playhead) onto the
+ * selected clip's lane. Sibling of
* useKeyframeKeyboard and copies its contract: capture phase so playback
* shortcuts cannot swallow keys we act on, inert while any text input has
* focus, and a key is only consumed when it does something.
@@ -16,7 +16,7 @@
import { useEffect } from "react";
import { usePlayerStore, type TimelineElement } from "../player/store/playerStore";
import { laneFor, withLane } from "../player/components/automationLaneGeometry";
-import { replaceRange } from "../player/components/automationLaneSelection";
+import { pointInSelection, replaceRange } from "../player/components/automationLaneSelection";
import {
copyRange,
isLastPasteSpan,
@@ -87,11 +87,22 @@ function resolveSelectionContext(
}
/**
- * The write that empties the active selection, or null when there is nothing
- * to do: the clip is gone, its lane is read-only, the target no longer
- * resolves to a range, or the lane already has no points in it. Split out of
- * the keydown handler so each stays under the complexity a single branch of
- * keyboard dispatch should carry.
+ * The write that deletes the breakpoints inside the active selection, or null when
+ * there is nothing to do: the clip is gone, its lane is read-only, the target no
+ * longer resolves to a range, or the selection covers no breakpoints.
+ *
+ * Deletes them outright rather than emptying the span behind anchor points. Anchors
+ * are what `replaceRange` exists for, and they are right for a shape insert or a
+ * paste — the envelope either side of the edit must not move. But Delete over a
+ * selection is the author saying "these points, gone", and answering that with two
+ * NEW points at the selection's edges reads as the delete not having worked. The
+ * envelope between the surviving neighbours re-interpolates, which is what deleting
+ * a breakpoint means everywhere else in the lane (right-clicking one does exactly
+ * this).
+ *
+ * Both axes of the selection box, edges included: what Delete removes is exactly
+ * what the lane drew a ring around. A point at the right time but outside the box's
+ * value bounds stays — which is the whole reason the box has them.
*/
function resolveDeleteWrite(
state: PlayerState,
@@ -99,14 +110,12 @@ function resolveDeleteWrite(
sel: AutomationSelection,
): { onCommit(next: HfAutomation): void; next: HfAutomation } | null {
const ctx = resolveSelectionContext(state, lanes, sel);
- if (!ctx || ctx.lane.points.length === 0) return null;
- const points = replaceRange({
- lane: ctx.lane,
- range: ctx.range,
- t0: sel.t0,
- t1: sel.t1,
- inner: [],
- });
+ if (!ctx) return null;
+ const points = ctx.lane.points.filter((p) => !pointInSelection(p, sel));
+ // Nothing inside is nothing to do — and it must stay a no-op rather than
+ // writing, or Delete over a smooth stretch would push an undo entry that
+ // changed nothing.
+ if (points.length === ctx.lane.points.length) return null;
return {
onCommit: ctx.binding.onCommit,
next: withLane(ctx.binding.automation, { target: sel.target, points }),
@@ -246,9 +255,9 @@ function handlePaste(
paste.binding.onCommit(withLane(paste.binding.automation, { target: paste.target, points }));
// Select the pasted span — the only feedback that it landed — and mark it, so
// an immediate second Cmd+V recognises this selection as the paste's own and
- // chains right after it instead of overwriting it. Full-height box over the
- // pasted span: this path only ever reads t0/t1, so the box's value bounds are
- // cosmetic — but the parameter's own range keeps it a sensible box to draw.
+ // chains right after it instead of overwriting it.
+ // Full-height box over the pasted span: everything that landed is selected, so
+ // Delete straight after a paste undoes it in one press.
const mark = {
elementKey: paste.elementKey,
target: paste.target,
diff --git a/packages/studio/src/hooks/useDomEditAttributeCommits.ts b/packages/studio/src/hooks/useDomEditAttributeCommits.ts
index 7b7a96de9..7d72bd962 100644
--- a/packages/studio/src/hooks/useDomEditAttributeCommits.ts
+++ b/packages/studio/src/hooks/useDomEditAttributeCommits.ts
@@ -394,7 +394,19 @@ export function useDomEditAttributeCommits({
},
onError: (error) => reportDomEditPersistFailure(domEditSelection, [op], error, showToast),
shouldResync: () => isLatestCommit(),
- resync: () => refreshDomEditSelectionFromPreview(domEditSelection),
+ resync: () => {
+ refreshDomEditSelectionFromPreview(domEditSelection);
+ // The player store keeps its own copy of each element's attributes, and
+ // that copy is what the timeline's automation lanes draw from. Nothing
+ // else refreshes it: a commit patches the preview document and the file,
+ // and resyncs the dom-edit SELECTION for the panel. So every writer that
+ // did not also update the store by hand — the FX panel's automate and
+ // un-automate buttons, the keyboard Delete, a paste — changed the file and
+ // the audio while the lane went on drawing what it had, until a reload.
+ // One sink here rather than a sync in each writer, because three of them
+ // shipped without one.
+ syncStoredAutomationFromPreview(previewIframeRef.current?.contentDocument ?? null);
+ },
});
},
[
diff --git a/packages/studio/src/hooks/useDomEditCommits.test.tsx b/packages/studio/src/hooks/useDomEditCommits.test.tsx
index cf548aaf7..0dac41195 100644
--- a/packages/studio/src/hooks/useDomEditCommits.test.tsx
+++ b/packages/studio/src/hooks/useDomEditCommits.test.tsx
@@ -1338,6 +1338,44 @@ describe("useDomEditCommits attribute persist handling", () => {
}
});
+ it("applies a preview-only write without persisting it", async () => {
+ // What a drag needs from every pointermove: the preview and the audio graph
+ // follow, the file does not. Persisting each move filled the undo stack with
+ // fragments of one gesture — and because those writes race, a follow-up's
+ // "before" often was not the previous entry's "after", so history refused to
+ // coalesce them and undo took back a few milliseconds of the drag.
+ const fetchSpy = stubPatchFetch({ ok: true, changed: true, matched: true });
+ const { iframe, element } = createPreviewElement();
+ const rendered = renderDomEditCommits(createSelection(element), iframe);
+
+ try {
+ await act(async () => {
+ await rendered.hook.handleDomAttributeLiveCommit("volume", "0.7", undefined, {
+ previewOnly: true,
+ });
+ });
+ expect(element.getAttribute("data-volume")).toBe("0.7");
+ expect(fetchSpy).not.toHaveBeenCalled();
+ } finally {
+ rendered.cleanup();
+ }
+ });
+
+ it("still persists a live write that does not ask to be preview-only", async () => {
+ const fetchSpy = stubPatchFetch({ ok: true, changed: true, matched: true });
+ const { iframe, element } = createPreviewElement();
+ const rendered = renderDomEditCommits(createSelection(element), iframe);
+
+ try {
+ await act(async () => {
+ await rendered.hook.handleDomAttributeLiveCommit("volume", "0.7");
+ });
+ expect(fetchSpy).toHaveBeenCalled();
+ } finally {
+ rendered.cleanup();
+ }
+ });
+
it("keeps a data-attribute commit on success", async () => {
stubPatchFetch({
ok: true,
diff --git a/packages/studio/src/hooks/useLivePlayheadTime.ts b/packages/studio/src/hooks/useLivePlayheadTime.ts
new file mode 100644
index 000000000..72c0d2972
--- /dev/null
+++ b/packages/studio/src/hooks/useLivePlayheadTime.ts
@@ -0,0 +1,49 @@
+/**
+ * The playhead in composition seconds, live while the transport runs.
+ *
+ * The RAF loop deliberately does not push every frame through the store — it
+ * notifies `liveTime` instead, so the playhead can move without re-rendering the
+ * app. A panel that wants to follow it therefore has to subscribe itself, and
+ * throttle: 30 Hz reads as continuous and costs an order of magnitude less than a
+ * render per frame.
+ *
+ * Paused, the store is the truth — a seek or a scrub lands there — so this returns
+ * that instead, which is what lets a readout follow the playhead while it is being
+ * dragged as well as while it is playing.
+ */
+import { useEffect, useRef, useState } from "react";
+import { liveTime, usePlayerStore } from "../player";
+
+/** Long enough to be much cheaper than a frame, short enough to read as motion. */
+const THROTTLE_MS = 33;
+
+export function useLivePlayheadTime(): number {
+ const storeTime = usePlayerStore((s) => s.currentTime);
+ const isPlaying = usePlayerStore((s) => s.isPlaying);
+ const liveRef = useRef(storeTime);
+ const [, forceRender] = useState(0);
+
+ // Paused, the ref tracks the store so the first frame of playback is never a
+ // stale value from the last time the transport ran.
+ if (!isPlaying) liveRef.current = storeTime;
+
+ useEffect(() => {
+ if (!isPlaying) return;
+ let timerId: ReturnType | 0 = 0;
+ const unsubscribe = liveTime.subscribe((t) => {
+ liveRef.current = t;
+ if (!timerId) {
+ timerId = setTimeout(() => {
+ timerId = 0;
+ forceRender((v) => v + 1);
+ }, THROTTLE_MS);
+ }
+ });
+ return () => {
+ unsubscribe();
+ if (timerId) clearTimeout(timerId);
+ };
+ }, [isPlaying]);
+
+ return isPlaying ? liveRef.current : storeTime;
+}
diff --git a/packages/studio/src/hooks/usePreviewPersistence.ts b/packages/studio/src/hooks/usePreviewPersistence.ts
index 608220eda..ee7039231 100644
--- a/packages/studio/src/hooks/usePreviewPersistence.ts
+++ b/packages/studio/src/hooks/usePreviewPersistence.ts
@@ -14,6 +14,7 @@ import {
import { trackStudioEvent } from "../utils/studioTelemetry";
import { applyUndoRestoreToPreview, type UndoRestoreFile } from "../utils/gsapUndoRestore";
import { usePlayerStore } from "../player";
+import { syncStoredAutomationFromPreview } from "../player/lib/automationStoreSync";
/** The restore payload the undo/redo preview-sync consumes (from the history store). */
interface HistoryPreviewRestore {
@@ -207,7 +208,13 @@ export function usePreviewPersistence({
player.setElements([]);
player.setSelectedElementId(null);
player.setTimelineReady(false);
+ return;
}
+ // A soft restore patched the reverted attributes onto the live preview, but the
+ // player store keeps its own copy and that copy is what the automation lanes
+ // draw — so without this an undone envelope edit stayed invisible until a
+ // reload. The full path above clears the store and waits for discovery instead.
+ syncStoredAutomationFromPreview(previewIframeRef.current?.contentDocument ?? null);
},
[previewIframeRef, activeCompPathRef, reloadPreview],
);
diff --git a/packages/studio/src/player/components/automationLaneData.ts b/packages/studio/src/player/components/automationLaneData.ts
index e62b8b732..c1c8c850c 100644
--- a/packages/studio/src/player/components/automationLaneData.ts
+++ b/packages/studio/src/player/components/automationLaneData.ts
@@ -131,7 +131,7 @@ function orderLanes(lanes: HfAutomationLane[], chain: HfAudioFxChain | null): Hf
}
/** A frequency as an author reads it: 400 Hz, 1.6 kHz, 10 kHz. */
-function formatHz(freq: number): string {
+export function formatHz(freq: number): string {
if (freq < 1000) return `${Math.round(freq)} Hz`;
const k = freq / 1000;
return `${k >= 10 ? Math.round(k) : Number(k.toFixed(1))} kHz`;