Commit Graph
3726 Commits
Author SHA1 Message Date
Vance IngallsandClaude Opus 5 a533d16775 feat(core): add the audio FX preset catalogue
Twenty named starting points across four shelves — voice, repair, character,
space — so an author who wants "Telephone" does not have to know it is two
stacked filter pairs, a honk band, a de-mud band and a soft clip.

A preset is DATA. Applying one writes ordinary nodes into `data-fx-chain`, the
same nodes hand-building would produce, so there is no second code path beside
the rack, nothing new in the render, and no failure mode the chain does not
already have. Only the parameters a preset means are listed; the rest come from
`normalizeAudioFxParams`, which keeps each entry readable as an intent.

`fromPreset` joins `fromCarve` on the node — the id rather than a flag, because
a chain can carry more than one preset and each has to find its own nodes.
Applying appends (stacking Telephone onto a cleaned voice is a real thing to
want), re-applying replaces that preset's own nodes IN PLACE so it keeps its
position in the signal order, and hand-added effects are never touched.

The repair shelf is named for what it does. None of it is noise reduction —
that needs spectral work this effect set does not have — so "Quiet Between
Phrases" says it closes the pauses and leaves room tone under speech, rather
than implying a denoiser the author only discovers is absent after trusting it.

The tests check the DATA, not just the machinery: every effect real, every
parameter declared, every value inside its own range, every enum a real option,
every chain round-tripping through the attribute, and every preset that lifts
level bounded by a limiter that is actually last. Falsified against four
mutations — a gain past its maximum, an enum the effect does not offer, an
undeclared parameter, and a limiter moved off the end — each caught with the
preset and parameter named.

Core 1670 -> 1688.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 12:05:17 -07:00
Vance IngallsandClaude Opus 5 19e0ed7ad7 docs(design): automated parameters read live instead of drawing their lane
An earlier pass drew each automation lane's envelope shape inside its module.
Dropped: the lane already has a home in the timeline, and a second small
drawing of it in the rack is decoration. What the rack is actually missing is
the CURRENT value.

The panel already receives that — FxSectionProps.liveAutomationValues exists
because "an automated parameter's stored number is only the seed the lane
replaced, so a rack that shows it stands still while the carve is audibly
working". So an automated row is now an ordinary parameter row whose number is
live at the playhead, marked `~` to say it is driven rather than set, with the
marker moving with it. Stopped, the values go grey and hold.

Better on its own terms and cheaper to build: no new drawing, and it consumes
a prop that already exists for exactly this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:50:01 -07:00
Vance IngallsandClaude Opus 5 03ace6e3cb docs(design): pick the schematic direction for the FX rack and record why
Four directions were mocked at the real 292px panel width — hardware
silkscreen, vintage test equipment, risograph, schematic — and schematic was
chosen.

It won because it is the only one that adds information rather than decoration.
Chain order is load-bearing in audio (a limiter first and a limiter last are
different sounds) and nothing in the panel said so. Drawing the rack as its
signal path also lets bypass be a route rather than an opacity, gives a preset
a visible brace over the nodes it wrote, lets an automated parameter draw its
lane instead of showing the stale number the lane replaced, and marks a
measuring module with a second ring — which is the static-versus-script
distinction from section 4 delivered as drawing rather than prose.

Drops the 3px coloured rail from the first pass. It is the single most overused
device in dashboard UI, and forcing each direction to find another answer is
what separated them.

Adds .impeccable.md with the design context the direction was chosen against —
users, personality, principles, palette, anti-references — so the next session
does not re-derive it or re-ask.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:46:55 -07:00
Vance IngallsandClaude Opus 5 695a38bdb2 docs(plans): add rack module identity and the preset menu to the presets doc
The rack is a 292px column of stacked modules, which is near enough a Eurorack
case — so each effect gets a faceplate: a 3px family-hue rail, lettering per
family, and a tint step per module within the family.

Costs almost no plumbing. `group` is already on every effect in the registry so
hue and lettering are derived rather than hand-assigned, and every module
already renders `data-fx-node="<type>"`, so the identity layer is CSS on an
attribute that exists today. Only the Smart family is new, and carve already
lives there in spirit.

Records the type budget as the real decision (self-hosted only, two faces
recommended — one is too subtle at 11px, five reads as a collage), and the
preset menu design: grouped by the same families, module counts per row so a
preset visibly IS a chain, and "measures" instead of a count on the adaptive
ones, which delivers the static-vs-script distinction in one word.

Mockup published as an artifact at the real panel width.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 03:38:57 -07:00
Vance IngallsandClaude Opus 5 f0d12e7820 docs(plans): research audio treatment presets over the shipped effect set
Voice carve made a pile of effects into one understandable feature. This asks
what else can, using only the 15 effects already in the registry.

Surveys what consumer tools ship and splits it into three tiers: ML enhancement
(Adobe/Descript — NOT reachable, and it is the most-requested thing in our own
internal feedback, so the doc says so plainly rather than dodging it), intent
panels over conventional DSP (Premiere's Essential Sound — the model worth
copying), and character effects (CapCut — fully reachable, except the pitch and
formant half, which needs DSP we do not have).

Proposes ~20 static presets with concrete values, all inside the registry's
declared ranges and in the node order the audio skill already fixes, plus five
adaptive scripts. The load-bearing finding: none of the scripts needs new DSP —
de-ess, leveller and tone-match are different questions asked of
analyseCarveDynamics, windowDb and powerSpectrum, which already run in the
panel. Carve is not one feature, it is the script engine.

Framed against the VST cancellation, which is the governing precedent: that was
killed for a sidecar process, an external repo, an unbundled dependency and
render-path liability. Preset values are data and the scripts reuse shipped
analysis, so this is the inverse of all four.

Four traps recorded so they are not hit later: a de-esser cannot be a static EQ
cut; boost presets must end in a limiter (yesterday's overshoot sweep is the
rationale); the leveller's lane must ride a `gain` node because VOLUME_RANGE is
0..1 and normaliseEnvelope clamps to it, so a volume lane can only attenuate;
and analyseCarveDynamics' 85-150 ms hop is too coarse for 50-150 ms sibilants,
so de-ess needs it re-parameterised rather than called as-is.

Nothing built. Research and design only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 03:29:26 -07:00
Vance IngallsandClaude Opus 5 06b22bdd7e docs(plans): close the listening step on the clip-before-duck fix
Verified by ear 2026-08-09: barely detectable at 1.35x overshoot, heard very
clearly once the chain was pushed. The mechanism is confirmed and the fix does
what it claims; the severity really is conditional on how far past full scale
the chain runs, rather than the original pair having been a bad test.

§4a-1 is fully closed — implemented, tested, mutation-falsified, and now
confirmed audibly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 03:11:57 -07:00
Vance IngallsandClaude Opus 5 87dc466f17 docs(plans): calibrate how much the clip-before-duck fix actually changes
The before/after pair was listened to and the difference was barely
detectable. That is a real result, not a bad test: sweeping the chain's
overshoot shows the damage scales with it, and the pair happened to sit at the
bottom of the range.

  chain peak 0.48x -> nothing clamped, -70 dB (16-bit rounding only)
  chain peak 1.35x ->  2.1% of samples, -25.1 dB  <- what was listened to
  chain peak 2.69x -> 19.3% of samples,  -9.1 dB
  chain peak 4.00x -> 48.8% of samples,  -3.3 dB

So a chain that stays under full scale is completely unaffected — the fix
changes nothing for it — and 55f852a72's "audible distortion" framing is
overstated for mild chains. The fix is still right: it destroys data the lane
was about to scale down anyway. But it belongs with correctness work rather
than with "renders sound wrong today", and the handoff now says so plainly so
nobody re-derives the severity from the commit message alone.

The commit itself is left alone rather than rebasing 15 commits over an
adjective; the measurement lives here instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 03:06:24 -07:00
Vance IngallsandClaude Opus 5 9983bdaaaf docs(plans): spell out the producer bun-test command instead of globbing it
The command as written would have wasted the time it was added to save. The
`stages/*.test.ts` glob sweeps in the vitest files sitting in the same
directory — bun cannot load them, and the run reports 37 failures and 5 errors
that are purely the wrong runner meeting the wrong file. It also omitted
planV2.test.ts, so it could not have produced the 203 it claimed.

The 49 bun files and the vitest files are interleaved through the same
directories with no path that separates them, so the list is spelled out and
both ways of getting it wrong are named.

Re-run at this HEAD: 203 passing across the 12 files. That also makes the
figure current — it was last measured three commits ago, before the 4c
refactors touched core.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 13:06:15 -07:00
Vance IngallsandClaude Opus 5 7968daa97a docs(plans): record the 4c cleanups taken and why the rest were left
Four of the nine cleanup items are done in three commits. The
enabledAudioFxNodes consolidation is worth reading as more than tidying: it
exposed that buildFxChain's update() filter had no test, and the failure it
prevents is silent.

The five left each carry a reason rather than a line item. The two WAV chunk
walkers are genuinely duplicated but differ in four behaviours, in the parser
every render's audio passes through. The FlatSlider reuse is a merge of two
behaviours rather than a swap, and is entangled with the held-back throttle
fix in PR #2190. The wet/dry shells want doing after the LFO rework, not
twice. The 30/s curve sampling is an audio-changing decision, not a cleanup.

Verified at this HEAD: studio 3662, core 1672, engine 736, producer 534 under
vitest plus 203 under bun test; tsc and oxlint clean across all five packages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 13:02:46 -07:00
Vance IngallsandClaude Opus 5 5a22474357 refactor: derive the dataset keys for the FX and automation attributes
The studio writes these attributes through `HF_AUDIO_FX_ATTR` and
`HF_AUDIO_AUTOMATION_ATTR` but reads them out of `dataAttributes`, which is
keyed without the `data-` prefix — so the read side carried its own
`"fx-chain"` and `"automation"` literals in four places. The two spellings had
no link, so a rename could only half-land: the writes would move and the panel
would go on reading an attribute nothing writes any more.

Both keys are now derived from their attribute (`HF_AUDIO_FX_DATA_KEY`,
`HF_AUDIO_AUTOMATION_DATA_KEY`) and the four read sites use them.

Falsified two ways. Repointing the derived key breaks 24 existing panel and
summary tests, which is what proves the reads actually go through it. And the
new one-line pairing assertion in each module fails the moment the derivation
is replaced by a literal again — which is the regression it exists for, since
a hardcoded key that happens to be right today passes every other test in the
suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:54 -07:00
Vance IngallsandClaude Opus 5 ce7c9e4867 refactor(core): route the graph's enabled-node filter through one helper
`enabledAudioFxNodes()` existed and was used only by the engine, while
audioFxGraph restated the same predicate three times in three shapes —
`.filter((n) => n.enabled !== false)` in `shapeOf`, `if (node.enabled === false)
continue` in `buildFxChain`, and the filter again in `update`. All three now
call the helper.

Also drops the `shape = shapeOf(next)` at the end of `update`. The early return
above it already established that `shapeOf(next)` equals `shape`, so this was a
full normalise-and-join of the chain per observer tick to write back the string
that was already there. `shape` is `const` now, which is the compiler enforcing
the same thing.

Consolidating found a genuine gap: `update`'s filter had NO test. A bypassed
node is not in the graph, so the update must walk the ENABLED nodes to stay
aligned with what was built — walking every node shifts each one's parameters
into its neighbour, and the shape is identical either way so nothing forces a
rebuild that would expose it. Both other call sites failed under mutation; this
one passed. Now covered, and falsified: the lowpass keeps its old cutoff while
the bypassed peaking's values land on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:53:00 -07:00
Vance IngallsandClaude Opus 5 ac0e372474 refactor: drop the non-null assertion and two casts the guards already cover
Three violations of the repo's "avoid `any`/`as T`" rule and the standing
no-`!` rule, all in the carve's analysis path.

`resolveAutomationRange` cast to `HfAudioFxNumberParam` immediately after
`if (!param || param.kind !== "number") return null` — the discriminated union
was already narrowed, so the cast asserted what the compiler knew. Removing it
leaves tsc clean, which is the proof it was dead.

`analyse` read `doc!.baseURI` and `el.getAttribute("src")!`, and laundered
`getElementById`'s `HTMLElement | null` through `as HTMLAudioElement | null`
into a predicate that only checked for a `src` attribute — so the "is this an
audio element" claim was never actually tested by anything. The document is now
guarded once at the top, and the voices are read out to `{ src, start }` values
as they are found, which makes both fields non-null by construction rather than
by assertion.

The element check is by `tagName`, not `instanceof HTMLAudioElement`: these
elements belong to the composition's iframe document, so this realm's
constructor never matches them. It is not a narrowing either — `sourceOptions`
is built from `doc.querySelectorAll("audio[id]")`, so a carve source is an
`<audio>` element by construction.

No behaviour change, so no new test. The path is covered: forcing the voice
collection to yield nothing fails 8 existing tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:48:43 -07:00
Vance IngallsandClaude Opus 5 c67f254cd3 docs(plans): correct the producer baseline and keep the A/B generator
Producer's suite is split across two runners, not half broken: 49 files import
`bun:test` and vitest reports them as collect failures. Both halves are now
recorded with the commands to run them, including the `./` prefix `bun test`
needs to treat arguments as paths rather than name filters — without it the run
exits 0 having run nothing.

That half was the real blind spot for b1faacb3a: `plan.test.ts` and the
`plan-parity-*` suite cover `applyDistributedAudioWarningPolicy`, the consumer
whose input that commit changed. Run and green — 203 passing.

Also copies the A/B generator next to the wavs it produced, so the pair can be
rebuilt with a different chain or the user's own audio instead of dying with
the session's scratchpad.

All four suites re-run at this HEAD, so the baseline block is true at the
commit it names: studio 3662, core 1669, engine 736, producer 534 + 203.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:43:36 -07:00
Vance IngallsandClaude Opus 5 0518a10033 docs(plans): record the second session's ten fixes and what is left
All four of section 4a and six of 4b are done. Adds section 3b with the
commits and the three judgement calls behind them: 4a-1 changes rendered
audio and its before/after pair is written but unheard; 4a-4's striding was
measured, found to move the carve's band selection, and rejected; 4a-2's
size ceiling has no test because reproducing it needs a 256 MB payload.

Rewrites 4b around what remains — the chorus/phaser LFO phase, which needs a
buffer-source rework and its own listen, and the per-element browser setup —
and records why the over-broad media.ts health ignore was left alone.

Refreshes the baselines (studio 3662, core 1669, engine 736) and adds
producer, which was never in the set: 534 passing plus 50 pre-existing
collect failures from files importing bun:test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:25:42 -07:00
Vance IngallsandClaude Opus 5 fddd596e8f fix(studio): key FX rows by node id so a mid-edit stays with its effect
The rack's rows were keyed `${type}-${index}`. Two effects of the SAME type
keep those keys through a reorder — position 0 is `peaking-0` before and after
— so React reused each row where it stood rather than moving it with its node.
The controls hold real state (a number field mid-edit is held as text, a drag
holds a local value), and that state stayed at the position: moving an effect
handed its half-typed value to whichever effect took its slot.

Different types happened to be safe, because the type is in the key. Same types
were not, and a rack with two EQ bands is the common case.

Now keyed by `node.id`, which the carve module's own list above already does,
falling back to the old form for a node minted without one.

Falsified by restoring the index key: the slot keeps showing 123 after the
effect carrying it moved away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:23:28 -07:00
Vance IngallsandClaude Opus 5 7ecc996057 chore: drop a dead audio-FX ignoreExports entry
Both symbols the entry named are gone: `AUDIO_FX_WORKLET_SOURCE` is a
module-private const rather than an export, and `__resetAudioFxWorkletsForTests`
does not exist at all. The entry has been inert since c3291f291 added it, and an
ignore that names nothing only makes the next reader look for something that
is not there.

Left alone deliberately: the file-scoped `health.ignore` on
`packages/core/src/runtime/media.ts`. It is over-broad — its comment says it is
for `refreshRuntimeMediaCache`, but it also exempts `syncRuntimeMedia`, which
IS what this stack changed. `health.ignore` takes file paths only, so there is
no way to narrow it in config; removing it wants a fallow run to confirm what
it then reports, and fallow already fails on this stack.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:18:31 -07:00
Vance IngallsandClaude Opus 5 b1faacb3ad fix(producer): classify the audio failure that takes the whole mix down
`runAudioStage` catches the rejection `processCompositionAudio` throws when an
FX failure cannot be degraded past — and returned `audioFailures: undefined`
with it. `applyDistributedAudioWarningPolicy` reads owner, retryability, reason
and stage off that list, so the FATAL failure was reported with an empty
reasons array, an empty stages array and no owner: strictly less classification
than a single dropped track gets, which is the opposite of what the stage's own
comment says it exists to do.

A failure is now synthesised from the error. "internal" is the honest bucket —
the stage names enumerate ffmpeg steps and this is the FX render, which is none
of them — and system/non-retryable is right for a browser or chain that will
not build. The detail is bounded at 2000 characters like the mixer's own.

Falsified by restoring `audioFailures: undefined`: both assertions fail.

Producer's suite has 50 pre-existing collect failures under vitest (files that
import `bun:test`); passing goes 533 -> 534, unchanged otherwise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:16:07 -07:00
Vance IngallsandClaude Opus 5 eb0080dec8 fix(core): stop reading a blank FX parameter as zero
`normalizeAudioFxParams` coerced with a bare `Number(raw)`, and `Number(null)`,
`Number("")`, `Number(false)` and `Number([])` are all 0 — every one of them
finite. So a parameter that arrived missing or blanked clamped to 0 instead of
falling back to the effect's declared default, and because 0 is a legal setting
for most of these knobs nothing downstream could tell the difference: a
compressor whose threshold came through as null sat at 0 dB and never engaged.

Now only a number, or a string that actually spells one, counts — the same rule
`numberOrNull` in audioAutomation.ts already applies. Panel inputs arrive as
strings, so those still work.

Falsified by restoring the bare `Number(raw)`: null reads as 0 dB instead of
-24.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:13:25 -07:00
Vance IngallsandClaude Opus 5 eb60133e33 fix(core): retire a worklet processor on dispose instead of only disconnecting it
An AudioWorkletProcessor lives until its `process()` returns false; all four
returned true unconditionally, and `dispose` only called `node.disconnect()`.
So every chain rebuild that dropped a limiter, compressor, gate or bitcrush
left it running on the audio thread for the rest of the session — and a
structural edit rebuilds the whole graph, so a few passes over a carved bed
stacked up abandoned processors that nothing could reach to stop.

`dispose` now posts `__hfDispose`, which each processor treats as its cue to
return false from then on. A later parameter update cannot revive it.

Falsified two ways: dropping the `if (this.dead) return false;` guards (each
processor keeps running after dispose) and dropping the postMessage (dispose
sends nothing).

The processor test evaluates the module source pulled back out of the data:
URL that registration hands to `addModule`, so it exercises what actually
ships rather than a copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:11:11 -07:00
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