Skip to content

fix(audio): eleven small correctness fixes across engine, core and the panel - #3173

Open
vanceingalls wants to merge 11 commits into
wa-18i-row-fixesfrom
wa-19a-audio-fixes
Open

fix(audio): eleven small correctness fixes across engine, core and the panel#3173
vanceingalls wants to merge 11 commits into
wa-18i-row-fixesfrom
wa-19a-audio-fixes

Conversation

@vanceingalls

Copy link
Copy Markdown
Collaborator

First of four slices of the old #3156, cut to stay under 1000 lines.

Small, independent fixes with no audible change:

  • an empty audio track is handed back instead of failing the mix
  • the browser lease is taken inside the try that releases it
  • the phaser's trim lanes aim at the trims, not the pinned wet/dry pair — an envelope was modulating a constant while the trim it was meant to move stayed frozen
  • a node's id moves with its slot on an in-place chain update, so a lane cannot end up addressing its neighbour
  • the volume lane is sampled at clip-local time, the way the render does it
  • an FX parameter can be typed, and a bare blur no longer re-persists
  • a clip's FX graph is disposed when it ends, not only when the element goes
  • the rack locks while the carve measures

Each commit body carries its falsification note — the test was broken on purpose and confirmed to fail.

🤖 Generated with Claude Code

…nder

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)`.
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.
…d 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.
…lace

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.
…der 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.
…rsisting 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.
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.
`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.
…s 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.
`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.
… 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.
@vanceingalls
vanceingalls changed the base branch from wa-18-lane-stretch to wa-18i-row-fixes August 11, 2026 08:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant