Commit Graph
1006 Commits
Author SHA1 Message Date
Vance Ingalls 0126db7712 Merge origin/main into the audio stack
Twelve of the stack's feature commits landed on main as squashes (#3274
through #3292, plus #3401's canary removal); the 96 review-and-fix commits
that followed them here did not, and main moved 64 commits on in the
meantime. This reconciles the two.

58 files conflicted. 44 were audio-only — main's side there is the
squashed form of commits this branch already carries and has since
superseded, so the branch side stands. The rest needed real work, in both
directions:

**Taken from main, absent here.**
- `ensureAudioGroupInertStyle` (#3278's review). An `<hf-audio-group>` is
  an unknown custom element, so it still takes a flex/grid slot and can
  open a line box — adding a group shifted authored layout. The helper and
  its `init.ts` call never came back to the branch, and this branch is
  what emits the element.
- `#3383`'s ended-audio replay: `canSeekEndedMediaBackward` and its five
  siblings in `media.ts`, with all six tests. Not present here in any
  form.
- `#3380`'s `asetpts=N/SR/TB` between `apad` and `atrim`. Also applied to
  `mixGroupMembers`, the group submix, which is new on this branch and so
  had the same bug in a path main's fix could not reach: delayed members
  padded then amix'd, where a group of four or more silently loses one.
- `#3401`'s `displayNumber` thread. The header derives its row from the
  group-aware order and the undo label from ascending element keys, so
  once a group exists the same click said "Hide track 2" and recorded
  "Hide track 1".
- `#3413`/`#3421`'s viewport handling — the popover's height cap and
  `inset()`, and `resolveFloatingPanelPosition` for the grouping dialog,
  which lives in a track header at the bottom of the window.
- Two extractions this branch had inline and at exactly the 600-line cap:
  `useTimelineDeleteOps` and `editingModeSlice`. Bodies were identical.

**Kept from the branch, against main.** Mute and solo are gone by
deliberate breaking change (`remove mute and solo from tracks and groups`,
`remove the group volume slider and level meter`), so eight files main
still carries are deleted again, `PlayerControls` keeps no
`previewIframeRef` (it existed only to feed `SoloBanner`), the
group-levels branch comes out of main's new `previewMessageRouter`, and
`STRIP_H` goes with the bus strip it sized. Main's
`TimelineTrackPlainHeader.test.tsx` is rewritten against the control that
actually exists — the visibility eye, withheld from an audible audio row
and offered back once hidden, which is the only way out of `data-hidden`.

**Unioned.** `TimelineFxPopover` — main's positioning, this branch's
audition telemetry (`auditionPresetChain`, `storedChain`,
`onAuditionTracked`); `SKILL.md` — main's #3416 "keep the carve group a
voice group" beside this branch's bus section, with the canary paragraph
dropped since the canaries no longer exist.

Every port is mutation-checked. core 2508, studio 4460, lint 528, engine
1630, cli 2813, sdk 549, producer green; tsc, oxlint, oxfmt, fallow and
the 600-line cap clean.
2026-08-23 03:04:47 -07:00
Vance Ingalls 553ea25931 fix(studio,core): audio has timing, not motion — and a bus has neither
Selecting an audio bus showed a Motion section offering tween editors. A
bus has no transform, opacity or box, so every effect on that list moves
nothing; the same is true of an `<audio>` clip.

The panel's gate was "are the GSAP handlers wired", and `App.tsx` always
wires them, so it was true for every selection. It now also asks what was
selected.

Audio keeps its timing — an `<audio>` clip is placed on the timeline like
anything else — but the section is called Timing there and summarises its
span instead of an effect count, because "Motion: 0 effects" on a sound
is a category error. A bus loses timing too: it has no `data-start` and
no duration, and its automation clock is composition time, so Start /
Duration / End would be editing nothing.

Gated on the TAG, not on `sections.animation`: a `div` with no tweens yet
must still offer "+ Add", and keying the rename on `animationCount > 0`
renamed the section for exactly that div — which the existing panel test
caught. Keying it on `showMotionEffects` renamed it for an `img`, which
wires no GSAP handlers. Both halves belong to the tag.

`affordances.ts` carries the same two rules for anything reading the
section list rather than the flat panel. The label pair is
`motionSectionLabel`, in the module that owns the section it names, which
is also what keeps `PropertyPanelFlat.tsx` under the 600-line ceiling.
2026-08-23 02:06:58 -07:00
Miguel Ángel 59a69a145b chore: release v0.8.10 (#3426) 2026-08-22 11:16:32 -04:00
Vance Ingalls f6e8e8ddfd chore: release v0.8.9 (#3422) 2026-08-22 05:57:57 -07:00
Vance Ingalls 6f82acf50c chore: release v0.8.8 (#3411) 2026-08-21 19:04:29 -07:00
Vance Ingalls 0e9a4f371d feat(audio): open the audio FX, group and mute features to everyone (#3401)
* feat(audio): open the audio FX, group and mute features to everyone

The twelve-PR audio stack landed on main with all three of its canaries still
at 0%, so the FX rack, the group rows, mute and solo are in the build and
reachable by nobody. This removes the gates rather than raising the numbers: a
canary that gates nothing is a branch every future reader has to evaluate.

Gone:
- the `audio-fx-rack`, `audio-track-mute` and `audio-groups` registry entries;
- the five studio gates they fed — the Audio FX section in `PropertyPanelFlat`,
  the mute label, the muted strike-through and the solo button in
  `TimelineTrackPlainHeader`, and the group-row derivation in
  `useTimelineTrackDerivations`. Each feature now renders on its own
  precondition (an audio track, a grouped track) exactly as it did for an
  enrolled user.

The old test pinned `audio-fx-rack` at 0% and asserted it was registered, which
is the opposite of what should hold now. Replaced with a pin that no
`audio-*` canary exists at all: re-registering one silently re-hides a shipped
feature, and nothing else in the tree would say so. Verified it fails when one
is added back.

The equivalent removal on wa-25-review-fixes (#3363) can no longer land — that
branch is 105 commits and 310 files divergent from main now that the stack has
squash-merged past it.

* docs(audio): retire the last references to the audio canaries

Two leftovers the gate removal did not reach.

`TimelineTrackPlainHeader.tsx` still said "Gated: the relabel ships behind the
canary, unlike the preview fix" above the function that picks Mute vs Hide.
Nothing gates it now, so the comment asserted the opposite of the code.

`docs/weekly-updates.mdx` is published, and it told readers the audio work is
"staged behind a canary at zero percent, so none of it is visible by default"
and to "set `HF_CANARY_AUDIO_FX_RACK=on` to use the rack today". That env var
maps to no registry entry any more, so following the instruction does nothing
at all. The week's record stays — it is a dated entry — but it now says the
rollout completed and that the variable is inert.

* fix(studio): name the mute action per track, and pin the newly-live audio rows

Review findings on the canary removal. All three are in code the 0% gate made
unreachable, so this is the first time any of it runs for a user.

*blocker* — `visibilityButtonLabel`'s audio branch returned "Muted" / "Mute":
the current STATE rather than the action, so nothing told a screen-reader user
that activating an already-muted row would unmute it, and it dropped `suffix`,
so every audio row shared one accessible name. Music plus VO is the ordinary
case, which makes that two identical buttons. Now `Unmute track N` /
`Mute track N`, matching the wording `timelineTrackVisibility` already writes
into undo history for the same click. `showAsMute` also picks the icon, so this
is the control's whole identity, not a tooltip.

Tests, for paths that had never executed enabled — a canary at 0% returns
`out_of_cohort` before bucketing, and studio additionally excludes
`navigator.webdriver`, so no suite could reach them:

- `VisibilityButton` — both audio states, two rows staying distinguishable, the
  visual branch unchanged, and the callback still taking the real track key
  rather than the display row. Fails on the old label.
- `useTimelineTrackDerivations` — an ungrouped project stays in raw ascending
  order with no groups, and an interleaved group's members become contiguous
  under an anchor at `memberTracks[0] - 0.5` while the ungrouped track between
  them keeps its place. Plus label/volume/mute mirroring and the id fallback.

Also pins the three retired canary names individually rather than by prefix:
`audio-fx-rack` coming back is caught either way, but `fx-rack` escaped a
`startsWith("audio-")` check. The family guard stays alongside it.

* fix(studio): record the row the mute button announced, not a second derivation

Review finding: the header's track number and the undo-history label's are
computed from two different orderings, and un-gating `audio-groups` is what
makes them diverge.

The header's row comes from the group-aware list — `groupTimelineTracks` emits a
synthetic anchor row per group and pulls members contiguous. The history's comes
from `timelineTrackOrder`, a plain ascending sort of element-bearing keys with no
anchors. On the fixture in this PR's own derivation test, grouped order
`[-0.5, 0, 2, 1]` against ascending `[0, 1, 2]`: clicking mute on the group's
first member said "Mute track 2" and recorded "Mute track 1". Off-cohort this
could not happen — the old branch returned raw tracks, so both sides sorted the
same way.

`onToggleTrackHidden` now carries the display row the clicked control rendered,
and `toggleTimelineTrackHidden` prefers it over deriving its own. One number
instead of two derivations, which is what `timelineTrackDisplay`'s "one owner of
what track number does the user see" already promised. The callback still acts on
the real fractional key, so nothing muted the wrong row before or now — only the
announced and recorded row was wrong.

Also pins the rest of the newly-live surface: the solo button's presence and
pressed state, its absence on a visual track, and the strike-through for both a
row's own mute and a group mute (with the title that says which). Three existing
call-site assertions now check the threaded row too.
2026-08-21 18:22:40 -07:00
Miguel Ángel 41edbfb2ce fix(lint): surface unloadable media variable defaults, stop reading data-var-src ids as paths (#3406)
A data-var-src value the runtime refuses to load is dropped at bind time and
the element's authored fallback src renders instead, so the video ships the
wrong media and the render still exits 0. lint said nothing, because the
scheme allowlist only existed inside the runtime.

Move that predicate into @hyperframes/parsers, where both the runtime and the
linter can reach it, and error at lint time on any declared default it
rejects. The value provably cannot load, so there is no false positive.

While reproducing that, lint turned out to report an unrelated missing file:
`\bsrc\s*=` also matches the tail of `data-var-src="bg"` (hyphen to `s` is a
word boundary), and `[^>]*` is greedy, so the variable id beat the real src
earlier in the same tag. Every binding was reported as a missing asset named
after the variable, and `<audio data-var-src>` was told the render would be
silent. All three copies of that regex now share one helper that requires
whitespace before the attribute.
2026-08-21 19:20:19 -04:00
Miguel Ángel 41af866bcb chore: release v0.8.7 (#3402) 2026-08-21 15:21:20 -04:00
Miguel Ángel a1c1f519cb fix(core): bind native window methods in the scoped sub-composition proxy (#3378)
The scoped window proxy handed native methods back unbound, so `this` at
call time was the Proxy and Chrome rejected it with "Illegal invocation".
That broke window.addEventListener, setTimeout, matchMedia and
getComputedStyle inside every sub-composition, including the
window.addEventListener("hf-seek", ...) form the Three.js and TypeGPU
adapters document. The sibling document and gsap proxies in this file
already bound; this one was the outlier.

Bind only non-constructors: Function.prototype.bind drops static members,
so binding a class exposed on window would silently strip its statics.
Built-in methods have no .prototype, classes and constructors do.

Closes #3376
2026-08-21 15:11:53 -04:00
Vance IngallsandClaude Sonnet 5 0d26072e6c feat(studio,core): mute groups, and hear-only-this that cannot reach the export (#3291)
B5: mute and solo, on groups and tracks (track mute already shipped by A2 —
nothing to build there).

Group mute — persisted as data-hidden on the <hf-audio-group> element itself
(never written onto members, per design doc §2.1's state-restoration
warning). Studio action reuses B7's generic setAudioGroupAttribute
(setQuiet/setLive split) rather than duplicating toggleTimelineTrackHidden's
shape — same one-atomic-patch/one-undo-entry contract, already built for
exactly this purpose. Render: B4 already drops every member of a
data-hidden group (confirmed by a new audioMixer.test.ts case — no
production change needed there). Preview: a dedicated muteGain node
(groupInput -> [fx] -> muteGain -> output -> master) so a mute toggle
never fights scheduleVolumeLane's ramps on the same param — the same
hazard B7's volume fader was split out to avoid. Mid-playback toggles
sync via a new syncAudioGroupMute pass in init.ts (a group carries no
data-start, so it's invisible to the existing visibility-node query).
Members of a muted group render the strikethrough label treatment
(TimelineTrackPlainHeader's isGroupMuted, sourced from
TimelineElement.audioGroupHidden) — display only, no attribute touched.

Solo — "Hear only this": a new session-only store slice (audioSoloSlice,
soloed: ReadonlySet<string> of clip/group ids, never track numbers, never
serialized). Predicate (isAudibleUnderSolo, packages/core/src/audioGroups.ts
so both the store and the preview transport share one definition): an
element is audible while any solo is active only if it or its own group is
soloed. "Siblings, never ancestors" lives in the graph, not the predicate —
solo gain is a per-element stage only; group buses are never attenuated by
solo, so a soloed member's path through its group stays open by
construction. Preview: a dedicated per-element soloGain in
webAudioTransport.ts (parallel to the mute mechanics), pushed via
window.__hf.setAudioSolo — a direct call, not an attribute write, so it
can't ride the visibility-diff path mute uses. media.ts's HTMLMedia
fallback folds the same predicate into its per-tick volume computation
(the same seam A2 used for data-hidden). Half-lit group indicator
(isGroupHalfLitUnderSolo) for "not soloed itself, but a member is".
Exclusive-by-default toggle, ⌘/Ctrl-click to add/remove, TimelineSoloButton
(⌗) beside mute on both track and group headers. Transport-bar banner
("Hearing only <label> — your export is not affected", Clear button) added
in PlayerControls.tsx, reading labels straight off the live preview DOM.

Export-safety, the most important property here: toggling/adding/clearing
solo never calls setAttribute/removeAttribute on any element and never
invokes the project save path (both asserted directly via spies in
audioSoloSlice.test.ts) — solo cannot reach an export by construction, not
by convention.

Also: extracted useHydrateActiveCompPathFromUrl out of App.tsx (a
pre-existing, unrelated effect) to stay under the 600-line filesize cap
after wiring useAudioSoloBridge in; and fixed a circular dependency the
solo-banner wiring introduced (useAudioSoloBridge.ts now imports
usePlayerStore from its concrete module instead of the player/ barrel,
which re-exports PlayerControls.tsx — the barrel path is what closed the
cycle).

Gates: bun run build clean; packages/core full suite 2379/2379; packages/
studio full suite 4276/4294 (18 pre-existing todo); packages/engine
audioMixer.grouping.test.ts 5/5; oxfmt/oxlint clean on all 23 touched
files; fallow clean (0 new circular deps, 0 new filesize/complexity
findings).

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 11:19:53 -07:00
Vance IngallsandClaude Sonnet 5 5fd84c395b feat(studio,core): a volume and a living meter on the group row (#3290)
* feat(studio,core): a volume and a living meter on the group row

B7: the group bus strip — droppable, and deliberately minimal per the
casual-user design constraints (groups doc §5): a volume slider, a level
bar that moves with the sound, and the words "Too loud" when it clips. No
dB numbers, no peak-hold readout, no routing row.

Transport (core): groupInput() now routes each group through input -> [FX
chain or dry passthrough] -> output -> master, with one AnalyserNode per
group tapped off `output` (post-FX, so the meter reads what the bus
actually outputs) — fftSize 256, level not spectrum. groupLevel(groupId)
returns RMS-ish level 0..1 + a clipped flag off a reused per-group buffer
(no per-frame allocation), or null when the group is idle/unknown. The
runtime posts group-levels messages only while playing, piggybacking the
existing message channel rather than adding a new poll loop.

Studio: groupLevels.ts is a plain pub-sub store (mirrors liveTime.ts's
shape) fed by useTimelinePlayer's message handler via
parseGroupLevelsMessage; useGroupLevel throttles re-renders to ~33ms.
TimelineGroupBusStrip renders in the group row's own `∿` lane area
(STRIP_H, already sized in B2's row-height pipeline) — drag writes live
via onSetAudioGroupAttributeLive, release commits one undo entry via
onSetAudioGroupAttributeQuiet (packages/studio/src/hooks/
timelineAudioGroupVolume.ts, extracted from timelineTrackVisibility.ts to
stay under the 600-line cap; mirrors FxParamRow's live/commit split).
"Too loud" holds for ~2s after the last clipped block, tracked in the
component, not the transport. volumeByGroup mirrors labelByGroup in
useTimelineTrackDerivations.ts so the strip's slider round-trips the
group's own data-volume.

Fixed two pre-existing group-routing tests in webAudioTransport.test.ts
that hardcoded gain-node creation order/count — B7 inserts an extra
`output` gain node between the group's input and master (for the meter to
tap), which shifted node indices the tests asserted on directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(studio,core): keep useTimelinePlayer under the size cap and the level buffer non-shared

Two CI gates, both from this branch's own additions.

`File size check`: `useTimelinePlayer.ts` sat at 599 lines on main and the
group-levels branch pushed it to 605 (cap 600). Extracted the `window.message`
router — which already carried a `fallow-ignore-next-line complexity` admitting
it had outgrown its home — into `previewMessageRouter.ts`, with the fixture
lease, sender check and protocol accept-gate collapsed into one
`acceptedPreviewMessage` so the listener is a flat dispatch and the suppression
is retired rather than moved. Same branches, same refs, no behaviour change;
the file lands at 561.

`Test: runtime contract`: `levelBuf: Float32Array` resolves to
`Float32Array<ArrayBufferLike>` under `tsconfig.runtime.json`, and
`getFloatTimeDomainData` will not take a possibly-shared buffer (TS2345).
Pinned the field to `Float32Array<ArrayBuffer>`, which is what
`new Float32Array(analyser.fftSize)` already produces.

Also drops `EditorShell.selectionSync.test.tsx`'s `vi.mock("./StudioFeedbackBar")`
— main deleted that component in favour of `feedback/StudioFeedbackCard`, and
touching this file for the group prop put the dangling path in fallow's scope.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 10:13:42 -07:00
Vance IngallsandClaude Sonnet 5 99f42be04c feat(engine): render grouped audio through a summed, FX-processed bus (#3289)
* feat(core): route grouped audio through a group bus in preview

An audio element carrying `data-audio-group` no longer lands its gain on
the master bus directly — it feeds a per-group `GainNode` (built lazily on
first use, one per group id) which itself feeds master, so members of the
same group sum before the ear, ready for a group-level FX chain and
volume/mute in later steps. An id with no matching `<hf-audio-group>`
element still gets a plain, unprocessed bus rather than losing the track.

The group's own chain and volume lane are wired through the same
`attachElementFxChain`/`scheduleVolumeLane` every element already uses,
against the group's clock — composition time (design doc §1.3), since a
group has no `data-start` and a missing one parses as 0. The bus persists
across `stopAll()` (mirroring `_masterGain`'s own lifecycle) so replaying a
group does not rebuild its chain; only `destroy()` disposes it.

Render is untouched — stays flat until B4; `audio-groups` is still a 0%
canary so nothing ships this to a real composition without hand-authoring
`data-audio-group`.

Also: `audioGroupOf` (B1) crashed on any element lacking a real `tagName`/
`getAttribute` — exactly the shape of most `HTMLMediaElement` test doubles
in this suite, including this file's own `mockEl`. Made it tolerant, same
style as `readChain`'s existing guard in `runtime/audioFx.ts`.

`schedulePlayback` was already 110 lines pre-existing before this diff;
extracted `resolveDestination` and `handleSourceEnded` to shrink it to 92,
then suppressed the remainder (inherently sequential graph wiring, not a
decision tree) per the same precedent B2 used on `TimelineLogicalRow`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(studio,lint): carve targets voiceover groups — always, when plural

Plural voiceover carve now targets a group instead of naming each clip:
`resolveCarveSourceIds` (core `audioGroups.ts`) expands a group id to its
current members at analysis time, so a clip added to the group later is
covered without touching `sources`. The picker (`useFxCarve.ts`) offers a
grouped voice as one option instead of one row per member, tests overlap
as a union of member spans (a group overlaps the bed if ANY member does),
and prefers a qualifying group over its individual members in
`autoSourceIds`.

Picking two or more ungrouped voice clips in the carve flow now mints a
group behind them (`mintGroupId`, de-duped against every id in the
document) and writes `data-audio-group` on each picked clip atomically,
one undo entry — `createAudioGroupAndAssignMembers` in
`timelineTrackVisibility.ts` copies `setElementsHidden`'s multi-target
write shape. The DSP is untouched: `mixCarveSources` already sums
multiple sources correctly (verified in the design doc's own
investigation) — this only fixes the picker.

New lint rule `audio_carve_ungrouped_sources` (`packages/lint/src/rules/
media.ts`, alongside `audio_volume_double_automation`) warns when a
`data-fx-carve`'s `sources` names two or more plain clip ids instead of a
group — the shape that silently rots when a clip is added. `/hyperframes-
audio` states the same rule as an invariant, not a tip, with the grouped-
narration HTML example from the design doc.

The group-matching and auto-group logic (`withAutoGroupedSources`,
`collectCarveCandidates`) is split into `useFxCarveGrouping.ts` —
`useFxCarve.ts` was pushing past the 600-line cap. `resolveNextCarveSettings`
is deliberately NOT an `async function`: wrapping it in one would force a
microtask on every call, including the synchronous branch — the exact bug
`withAutoGroupedSources`'s own sync-when-possible contract exists to avoid,
and one caught via `propertyPanelAudioFxGroup.test.tsx` (10 failures)
before fixing it back to a plain function the caller conditionally awaits.

Also extracted `useEffectiveTimelineDuration` out of `App.tsx` and
`useRemoveBackground` out of `StudioRightPanel.tsx` (both pushed past 600
lines from an added prop wire), and decomposed `useFxCarve.ts`'s picker
IIFE to clear fallow's complexity gate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(engine): render grouped audio through a summed, FX-processed bus

Renders what B3 already routes in preview: a group's members sub-mix into
one PCM WAV at full composition length (adelay already places each member
at its composition position, so the group WAV's t=0 IS composition time),
run through the group's own FX chain and automation via the same
applyAudioFxChain/envelope-bake path a member uses, then fold into the flat
track list as one processed AudioTrack — the final mixAudioTracks call
never has to know groups exist.

Gain law verified against plans/spikes/amix-nesting-spike.sh (brought over
from the plans branch, along with audioMixer.grouping.test.ts, since both
were committed there and never merged to origin/main — every step branch in
this stack descends from origin/main): the sub-mix's own amix prefers
normalize=0 (nulls exactly against a flat mix), falling back to per-node
compensation by the group's OWN member count only when this ffmpeg build's
amix rejects the option. Carrying any other count into a nested amix node
is the exact +2.499 dB silent failure the spike measured — confirmed by a
manual mutation check (wrong-count compensation landed 3.5 dB hot, exactly
20*log10(3/2) for a 2-member group compensated as 3; reverted after
confirming the level test catches it).

A group element carrying data-hidden drops every member before the sub-mix
ever runs (RULES: mute-by-drop, never mute-by-volume-0) — parseAudioElements
now resolves groups once per parse and skips hidden-group members the same
way it already skips data-hidden ancestors.

HfAudioGroup (packages/core/src/audioGroups.ts, from B1) gains fxChain,
automation, volume and hidden, read off the group element the same way
resolveAudioGroups already reads data-label — audioGroups.test.ts updated
for the wider shape plus new coverage for the added reads.

it.todo("mixes a grouped composition at the same level as the ungrouped
one") is now a real, passing test; two more added per the step doc (FX
routing isolation, member-level envelope survives grouping).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 09:42:34 -07:00
Vance IngallsandClaude Sonnet 5 485c037dcf feat(studio,lint): carve targets voiceover groups — always, when plural (#3288)
* feat(core): route grouped audio through a group bus in preview

An audio element carrying `data-audio-group` no longer lands its gain on
the master bus directly — it feeds a per-group `GainNode` (built lazily on
first use, one per group id) which itself feeds master, so members of the
same group sum before the ear, ready for a group-level FX chain and
volume/mute in later steps. An id with no matching `<hf-audio-group>`
element still gets a plain, unprocessed bus rather than losing the track.

The group's own chain and volume lane are wired through the same
`attachElementFxChain`/`scheduleVolumeLane` every element already uses,
against the group's clock — composition time (design doc §1.3), since a
group has no `data-start` and a missing one parses as 0. The bus persists
across `stopAll()` (mirroring `_masterGain`'s own lifecycle) so replaying a
group does not rebuild its chain; only `destroy()` disposes it.

Render is untouched — stays flat until B4; `audio-groups` is still a 0%
canary so nothing ships this to a real composition without hand-authoring
`data-audio-group`.

Also: `audioGroupOf` (B1) crashed on any element lacking a real `tagName`/
`getAttribute` — exactly the shape of most `HTMLMediaElement` test doubles
in this suite, including this file's own `mockEl`. Made it tolerant, same
style as `readChain`'s existing guard in `runtime/audioFx.ts`.

`schedulePlayback` was already 110 lines pre-existing before this diff;
extracted `resolveDestination` and `handleSourceEnded` to shrink it to 92,
then suppressed the remainder (inherently sequential graph wiring, not a
decision tree) per the same precedent B2 used on `TimelineLogicalRow`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(studio,lint): carve targets voiceover groups — always, when plural

Plural voiceover carve now targets a group instead of naming each clip:
`resolveCarveSourceIds` (core `audioGroups.ts`) expands a group id to its
current members at analysis time, so a clip added to the group later is
covered without touching `sources`. The picker (`useFxCarve.ts`) offers a
grouped voice as one option instead of one row per member, tests overlap
as a union of member spans (a group overlaps the bed if ANY member does),
and prefers a qualifying group over its individual members in
`autoSourceIds`.

Picking two or more ungrouped voice clips in the carve flow now mints a
group behind them (`mintGroupId`, de-duped against every id in the
document) and writes `data-audio-group` on each picked clip atomically,
one undo entry — `createAudioGroupAndAssignMembers` in
`timelineTrackVisibility.ts` copies `setElementsHidden`'s multi-target
write shape. The DSP is untouched: `mixCarveSources` already sums
multiple sources correctly (verified in the design doc's own
investigation) — this only fixes the picker.

New lint rule `audio_carve_ungrouped_sources` (`packages/lint/src/rules/
media.ts`, alongside `audio_volume_double_automation`) warns when a
`data-fx-carve`'s `sources` names two or more plain clip ids instead of a
group — the shape that silently rots when a clip is added. `/hyperframes-
audio` states the same rule as an invariant, not a tip, with the grouped-
narration HTML example from the design doc.

The group-matching and auto-group logic (`withAutoGroupedSources`,
`collectCarveCandidates`) is split into `useFxCarveGrouping.ts` —
`useFxCarve.ts` was pushing past the 600-line cap. `resolveNextCarveSettings`
is deliberately NOT an `async function`: wrapping it in one would force a
microtask on every call, including the synchronous branch — the exact bug
`withAutoGroupedSources`'s own sync-when-possible contract exists to avoid,
and one caught via `propertyPanelAudioFxGroup.test.tsx` (10 failures)
before fixing it back to a plain function the caller conditionally awaits.

Also extracted `useEffectiveTimelineDuration` out of `App.tsx` and
`useRemoveBackground` out of `StudioRightPanel.tsx` (both pushed past 600
lines from an added prop wire), and decomposed `useFxCarve.ts`'s picker
IIFE to clear fallow's complexity gate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 09:42:16 -07:00
Vance IngallsandClaude Sonnet 5 602cf53cf6 feat(core): route grouped audio through a group bus in preview (#3287)
An audio element carrying `data-audio-group` no longer lands its gain on
the master bus directly — it feeds a per-group `GainNode` (built lazily on
first use, one per group id) which itself feeds master, so members of the
same group sum before the ear, ready for a group-level FX chain and
volume/mute in later steps. An id with no matching `<hf-audio-group>`
element still gets a plain, unprocessed bus rather than losing the track.

The group's own chain and volume lane are wired through the same
`attachElementFxChain`/`scheduleVolumeLane` every element already uses,
against the group's clock — composition time (design doc §1.3), since a
group has no `data-start` and a missing one parses as 0. The bus persists
across `stopAll()` (mirroring `_masterGain`'s own lifecycle) so replaying a
group does not rebuild its chain; only `destroy()` disposes it.

Render is untouched — stays flat until B4; `audio-groups` is still a 0%
canary so nothing ships this to a real composition without hand-authoring
`data-audio-group`.

Also: `audioGroupOf` (B1) crashed on any element lacking a real `tagName`/
`getAttribute` — exactly the shape of most `HTMLMediaElement` test doubles
in this suite, including this file's own `mockEl`. Made it tolerant, same
style as `readChain`'s existing guard in `runtime/audioFx.ts`.

`schedulePlayback` was already 110 lines pre-existing before this diff;
extracted `resolveDestination` and `handleSourceEnded` to shrink it to 92,
then suppressed the remainder (inherently sequential graph wiring, not a
decision tree) per the same precedent B2 used on `TimelineLogicalRow`.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 09:42:02 -07:00
Vance IngallsandClaude Sonnet 5 5240367150 feat(core): the audio group model — element, membership, helpers (#3278)
* feat(core,studio): the character presets pitch shift unlocks

Chipmunk, Giant, and Monster ship as presets on the pitchshift worklet
P1 added: Chipmunk pitches up and adds sparkle, Giant pitches down with
weight and a compressor to hold the extra low end together, Monster pitches
down further with saturation growl and a close, tight reverb. Every param
verified against the live effect registry rather than sketched — the
compressor/reverb/saturate/shelf keys all match exactly.

Each gets its own title treatment (font, size, tracking, hue) so the FX
rack's per-preset styling coverage and hue-distance/background-uniqueness
tests extend cleanly to the three new entries, and complaint-line copy in
the non-voice vocabulary the audit test enforces (no speech words — "Giant"
over CapCut's "Deep Voice", as the design doc records).

Updates plans/audio-fx-presets.md's two limits paragraphs to record that
pitch shift landed and this half of the character list now ships; Robot and
Alien stay out of scope (ring modulation, still unbuilt).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(core): the audio group model — element, membership, helpers

Introduces <hf-audio-group> and data-audio-group as the group model B2–B7
and C1 build on: a non-rendering group element carries a label and (later)
an FX chain, membership lives on the member's own data-audio-group
attribute rather than DOM nesting, so a track removed from the document
simply drops out of the group on the next resolve — nothing dangles.
Groups do not nest: data-audio-group on the group element itself is
ignored. A group with members but no <hf-audio-group> element still
resolves, label falling back to the id, so hand-authored HTML degrades
gracefully. Audio only in v1 — video members are ignored.

Parse-only: nothing routes or sums audio yet (B3/B4). Adds the
audio-groups canary at percentage: 0 gating the future Studio UI; the
element and attribute parse and play regardless of enrollment.

Verified rather than assumed per this plan's standing rule: the timeline's
clip-collection selector ([data-start], [data-track-index],
[data-composition-id], video, audio, img) already excludes the group
element with zero changes, and no lint rule flags unknown elements or
data-* attributes, so neither needed touching — confirmed by grep and by
running `hyperframes lint` against a fixture containing the element (0
findings referencing it). The step doc's suggested display:none injection
point (an existing base stylesheet in the runtime) does not exist in this
codebase; skipped rather than inventing new infrastructure, since an empty,
childless custom element already renders as a zero-size inline box with no
visible output — the same reasoning the lint check above confirms
empirically.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 09:34:47 -07:00
Vance IngallsandClaude Sonnet 5 5e0cc75115 feat(core,studio): the character presets pitch shift unlocks (#3277)
Chipmunk, Giant, and Monster ship as presets on the pitchshift worklet
P1 added: Chipmunk pitches up and adds sparkle, Giant pitches down with
weight and a compressor to hold the extra low end together, Monster pitches
down further with saturation growl and a close, tight reverb. Every param
verified against the live effect registry rather than sketched — the
compressor/reverb/saturate/shelf keys all match exactly.

Each gets its own title treatment (font, size, tracking, hue) so the FX
rack's per-preset styling coverage and hue-distance/background-uniqueness
tests extend cleanly to the three new entries, and complaint-line copy in
the non-voice vocabulary the audit test enforces (no speech words — "Giant"
over CapCut's "Deep Voice", as the design doc records).

Updates plans/audio-fx-presets.md's two limits paragraphs to record that
pitch shift landed and this half of the character list now ships; Robot and
Alien stay out of scope (ring modulation, still unbuilt).

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 09:08:32 -07:00
Vance IngallsandClaude Sonnet 5 1b86b56127 feat(core): pitch shift — a granular shifter as the fifth FX worklet (#3276)
Adds hf-pitchshift alongside the four existing dynamics worklets: a dual-tap
granular delay line, 100 ms grain, taps 180° apart so one is always
crossfading in as the other resets — hides the splice each tap makes on
wrap. Read-tap speed relative to the write head tracks the semitone ratio,
so pitch shifts without changing duration.

Registered through the same workletBuilder/dispose-message path the other
four use (so shapeOf never rebuilds on a param tweak, and a chain drop
retires it), wired into the registry with a plain-language copy entry and a
~0.2s chain tail (two grains). One implementation, shared by preview (Web
Audio in the page) and render (the same worklet run inside an
OfflineAudioContext in the headless browser) — confirmed by a browser-render
test that measures the actual output frequency, not just that it differs
from input.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 02:05:01 -07:00
Vance IngallsandClaude Opus 5 8f3ab60b5a fix(core,studio): silence hidden audio in preview, and call it mute (#3275)
* feat(studio): make presets the primary path into the FX rack

Presets button becomes the stacked primary control (bold, filled outline);
Add-effect demoted to a small trailing link ("+ effect"). Button onClick
bodies and audition-revert logic are unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(core,studio): silence hidden audio in preview, and call it mute

Preview scheduled every audio[data-start] regardless of data-hidden, so a
hidden audio track was silent in the export but audible in preview — render
was already correct, this was a preview-only parity bug. Web Audio scheduling
now skips (and re-syncs on toggle) any audio clip under a data-hidden
ancestor; the HTMLMedia per-tick volume path folds the same check into
effectiveVolume without touching el.muted (transport-owned). Ships unflagged
since it's a bugfix restoring parity.

Also relabels the eye as Mute/Muted on audio-only track rows (icon,
strikethrough label, undo-history copy), gated behind the new
audio-track-mute canary — the relabel is a copy/UX change, kept separate from
the behavior fix above.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test(core): assert hidden-audio exclusion on the scheduling entry point, not the decode fallback

CI was red on `Test`, `Test: runtime contract` and `Tests on windows-latest` — all three on the
same two tests, both reporting `decodeAudioElement` called 0 times.

Not a bug in this branch. The tests pass on the branch tip and fail on the MERGE with main, which
is what CI actually builds. Main had moved 66 commits ahead, and #3322 ("make creator media edits
render-safe") added `WebAudioTransport.scheduleMediaElementPlayback`: media-element clips now route
straight through the Web Audio graph instead of being decoded into an AudioBuffer.
`decodeAudioElement` survives only as the fallback for the rate-shifted case
(`Math.abs(effectiveRate - 1) > 1e-9`), so on the ordinary path it is correctly never called:

    void webAudio.scheduleMediaElementPlayback(...).then((scheduled) => {
      if (scheduled || !clock.isPlaying()) return;   // <- returns here now
      ...
      void webAudio.decodeAudioElement(rawEl)        // <- fallback only

Both tests used `decodeAudioElement` as a proxy for "this clip reached Web Audio scheduling",
which was accurate before #3322 and is not any more. Retargeted to
`scheduleMediaElementPlayback`, which is that signal now and takes the element as its first
argument, so the assertions keep their exact shape and meaning.

Confirmed by instrumenting the run rather than inferring: on the merged tree the scheduler is
called exactly once, with the audible element — the feature under test works, only the probe was
pointed at the wrong method.

Still non-vacuous: deleting the `rawEl.closest("[data-hidden]")` guard from
`scheduleWebAudioForActiveClips` fails the first test with "expected 1 times, but got 2 times", so
it genuinely catches a hidden clip being scheduled.

`init.test.ts` 77/77, and 1259 passed across packages/core `src/runtime` + `src/audio` on the
merged tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 01:49:57 -07:00
Vance IngallsandClaude Opus 4.8 1ff99a50f5 fix(core): play bounded WebAudio clips full-length at non-1x playback rate (#1494)
* fix(core): play bounded WebAudio clips full-length at non-1x playback rate

startBoundedSource passed `clipDuration * rate` as start()'s duration arg, but
that arg is buffer-content seconds while clipDuration is composition seconds.
Media advances 1:1 with composition (the global rate scales the transport clock
and the source playbackRate together), so the content to play is exactly
clipDuration. Multiplying by rate truncated the clip at rate < 1 (audio cut out
at the midpoint on half-speed playback) and overran it at rate > 1. Drop the
multiply — playbackRate alone stretches the fixed content to the right wall time.

Adds a half-speed regression test and corrects the prior test that asserted the
rate-scaled (overrunning) bound.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(core): restore the mediaRate scaling on the WebAudio clip bound

The bound this branch removed was correct. `start()`'s duration argument is
buffer seconds, and an element with `data-playback-rate="2"` consumes two
buffer seconds per composition second, so a `clipDuration`-second clip needs
`clipDuration * mediaRate` of buffer. Wall time then works out as
`(clipDuration * mediaRate) / (mediaRate * globalRate) = clipDuration /
globalRate`, which is the transport duration that was wanted. Dropping the
factor truncated authored 2x clips at their midpoint and overran authored
0.5x ones — and the sibling line still scaled `sourceElapsed` by mediaRate,
so `remaining` mixed buffer with composition seconds and only landed right at
mediaRate = 1.

The branch's half-speed regression could not have caught this: it changed the
GLOBAL rate on an element whose authored rate is 1, and the global rate
cancels out (it scales the transport clock and the source's playbackRate
together). Both formulas return 10 there, so the test passed before the
change it was meant to justify.

Replaced with the two cases that do discriminate — a clip authored at 2x and
one at 0.5x, each asserting the buffer-second bound. Both fail if the factor
is dropped again, as does the pre-existing authored-2x/global-0.5x contract
test the removal was breaking.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-20 23:37:30 -07:00
Vance IngallsandClaude Opus 4.8 c09b0b9183 fix(core): prevent symlink path traversal in htmlBundler safePath (F-005) (#1214)
* fix(core): prevent symlink path traversal in htmlBundler safePath (F-005)

safePath used resolve() for containment checking, which is lexical and does
not follow symlinks.  A symlink placed inside the project directory pointing
at a file outside it would pass the startsWith(normalizedBase) check and
expose arbitrary on-disk content to the bundle.

Add isSymlinkWithinProject(), which calls realpathSync() on both the
candidate and the project root and re-verifies containment after symlinks
are resolved.  safePath calls it after the lexical check; safeReadFile
gains an optional projectDir parameter that triggers the same check when
handling @import-resolved CSS paths (the @import code path bypasses safePath
and reads the file directly, so the check is applied there instead).

Both attack vectors are covered by new vitest tests that plant a symlink
inside the project dir pointing at a file in a sibling tmpdir.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(core): drop the redundant safeReadFile symlink guard, keep its regressions

The branch's remaining production hunk called `isSymlinkWithinProject`, a
helper that no longer exists: when four of the five original commits were
cherry-picked to `main`, that helper was folded into `isSafePath`, which now
canonicalizes both sides with `realpathSync`. So the leftover commit did not
compile — `TS2304: Cannot find name 'isSymlinkWithinProject'` on Linux and
Windows, with Test and regression failing downstream of the build.

Swapping in `isSafePath(projectDir, filePath)` compiles, but measurably
guards nothing: the sole caller that passes `projectDir` (`inlineCssFile`'s
`@import` arm) runs `isSafePath(projectDir, resolved)` on the same path one
line above, and both symlink regressions pass with the guard removed. A
check no test can distinguish is weight, not defence, so the production
delta goes and the tests stay — they now pin the `isSafePath` guard on both
attack surfaces (`<link href>` through a symlink, and `@import` through one).

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-20 23:37:11 -07:00
James Russo 36c7dffe5c chore: release v0.8.6 (#3386) 2026-08-20 21:47:29 -07:00
James Russo 477e09642b fix(core): replay ended audio after backward seeks (#3383) 2026-08-20 20:51:30 -07:00
Vance Ingalls 952401d12a feat(audio): ship the audio FX, group and mute features to everyone
The three audio canaries (`audio-fx-rack`, `audio-track-mute`,
`audio-groups`) sat at 0% while the stack was in review. Open them to
100% by deleting them rather than raising the percentage — a canary that
gates nothing is a branch every future reader has to evaluate.

Removed:
- the three `CANARIES` registry entries;
- every `isCanaryEnabled` branch in the studio (FX button, group
  pointer, rack section, track-mute affordances) — the features now
  render on their own preconditions;
- the runtime's `canaries` record and its `__hf.setCanaries` handler,
  plus the `setCanaries` type surface;
- `syncRuntimeMedia`'s `silenceHiddenAudio` option. Its only caller
  always passed `true`, so hidden audio is now unconditionally silent in
  preview, matching what `audioMixer` already renders.

Tests assert the unconditional behaviour instead of the enrolment
transition: the FX button is present on any audio track and absent on a
visual one, the group pointer follows clip count rather than enrolment,
and the hidden-clip zero is paired with a visible-clip control so the
assertion can still fail.
2026-08-20 17:28:43 -07:00
Vance Ingalls a5f2e51d5d perf(core): dirty-gate the group mute sweep
`syncAudioGroupMute` ran a whole-document `querySelectorAll("hf-audio-group")`
on every visibility pass — which is every transport tick that changes anything —
to compare each bus's `data-hidden` against a WeakMap that almost never
disagreed. The reschedule immediately above it is dirty-gated for exactly this
reason; this one was not.

Same shape now: a flag set only where a `data-hidden` mutation is observed, and
initialised true so the first pass still establishes the baseline.

core: 80 init tests. fallow clean.
2026-08-20 16:41:44 -07:00
Vance Ingalls 8c97113a82 fix(core,engine): give each audio bus instance its own identity in the render
Review finding 1, the last of the fifteen. A group's identity was the raw author
`id`, which is unique only per composition FILE — and the render document is the
inlined union of every file. So a sub-composition declaring a bus AND its
members, used twice, put both instances' members under one key: one sub-mix for
two independent buses, and the second bus element overwrote the first, applying
its fader, chain, label and automation to the first instance's audio. With only
the SECOND instance muted, `memberGroupHidden` dropped every member of the
merged group — both instances gone from the export.

Compiled a twice-used sub-composition to find out what actually separates the
two instances, rather than guessing:

- members are ALREADY disambiguated — `data-hf-render-id="m1"` / `"m1__hf2"`
- buses are not: `MEDIA_SELECTOR` is `video[src], audio[src], img[src]`
- both instances carry `data-composition-id="bedcomp"`, the file's own id, so id
  strings cannot tell them apart — only the subtree element can

Fixed at the boundary that already owns this collision class.
`assignMediaRenderIds` now stamps every `<hf-audio-group>` with a
document-unique `data-hf-render-id` from the SAME `taken` set (so a bus key can
never collide with a clip key either), and stamps each member with
`data-hf-group-render-id` = the render id of the bus in its OWN composition
subtree, resolved with `closest()`. A member whose bus is not in its subtree — a
hand-authored bus in the root with members in scenes — falls back to the first,
which is the pre-existing reading and the only sensible one there.

`resolveAudioGroups` and the mixer prefer the stamped key and fall back to the
author id, so the LIVE PREVIEW — which has no stamps — reads exactly as before.
`resolveGroupElement` tries the stamped instance first, since `getElementById`
can only ever find the author id.

Verified with the reviewer's own repro, end to end through the real compiler:
before, `parseAudioElements` returned both members under one `bed` group; after,
muting instance B drops only B's member and A survives at its own 0.5 fader. Two
compiler tests (instance pairing, single-instance stability, element-less group
untouched) and two mixer tests, all verified against a revert.

**Still divergent, deliberately: the live preview.** `groupInput` resolves by id
against the uncompiled document, so two instances still share one bus there. The
export was the audible bug — a muted instance silencing another's audio — and
fixing preview needs runtime subtree resolution, which is a separate change.

core 2493, engine 1617, studio 4389. fallow clean.
2026-08-20 16:41:30 -07:00
Vance Ingalls 0fcf592826 fix(core): gate the hidden-audio reschedule on its own canary
Review finding 5. A `data-hidden` toggle mid-playback fired
`webAudio.stopAll()` + a full reschedule for EVERY user, while the two skips
that reschedule exists to re-run are themselves gated on
`silenceHiddenAudioEnabled()`. Un-enrolled — which is everyone, the canary is at
0% — the rebuilt set was therefore identical, and the only observable effect was
an audible stop-and-restart across the whole mix on every visibility toggle.

Also folds the same bus-blindness fixed in media.ts into the two scheduling
skips: they used `closest("[data-hidden]")`, which cannot see a muted BUS
because membership lives on the member's `data-audio-group` and a group never
nests its members. Both now share one `isSilencedByHidden` predicate.

**Three existing tests were passing only because the path was ungated** — worth
knowing, because it is the second time this canary's tests have measured the
wrong thing:

- "batches a mid-playback toggle into exactly one reschedule" and "stops the
  running sources before rescheduling" never enrolled the canary. They now do;
  the reschedule IS the feature.
- "still schedules a data-hidden clip when the host has not opted in" asserted
  through `scheduleMediaElementPlayback`, and in jsdom `webAudioReady` is false
  so `play()` schedules nothing — the ungated reschedule was the only scheduler
  in the test, i.e. the assertion was carried by the defect. It now measures the
  finding directly: the same `data-hidden` toggle costs ONE `stopAll` un-enrolled
  (the seek's own) and two enrolled. Verified 1 vs 2, and 3 vs 1 on a revert.

Two things that cost a round each, for the next person: a plain `seek()` calls
`stopAll()` unconditionally, so a raw "was stopAll called" assertion proves
nothing — count the delta. And `hiddenAudioDirty` is set by a data-hidden
MUTATION, so the gesture under test has to toggle the attribute; a seek alone
never reaches the reschedule.

core: 122 files, 2490 tests.
2026-08-20 16:41:18 -07:00
Vance Ingalls 1b94db7ecf fix(core): resolve an audio bus by tag, re-read it, and clamp it like the render
Review findings 2, 6, 7 plus one tail item. All four are the same bug wearing
four hats: nothing that resolved a group element checked the tag, and one path
froze the result for the session.

**resolveGroupElement / isMemberGroupHidden (audioGroups.ts).** One tag-checked
resolver, since `resolveAudioGroups` only ever accepted `<hf-audio-group>` and
every other reader used a bare `getElementById`. An `<audio id="vo"
data-audio-group="vo" data-volume="0.5" data-fx-chain=…>` — the shape the
"group with no element" docblock explicitly supports — had its OWN fader and
chain applied a second time on the bus, and a `<div id="bg" data-hidden>`
silenced group "bg" in preview only. Null now means the documented flat sum.

**The bus is re-resolved on every reanchor**, not captured once. A group whose
element does not exist at first schedule (studio group creation, a
sub-composition that loads later) kept the `{ getAttribute: () => null }` stub
for the whole session: no fader, no chain, no mute in preview, while the export
honoured all three. The mute gain is re-read there too, which it never was.

**Preview's bus fader now uses `clampAudioGain`, the render's own clamp.** Its
docblock claimed the render clamps to unity; the render clamps with
`clampAudioGain`, ceiling MAX_AUDIO_GAIN (+12 dB, ~3.98). So
`data-volume="2"` auditioned at 1.0 and exported at 2.0 — 6 dB, up to 12 at the
ceiling. Preview was self-inconsistent as well: the same parameter's automation
lane is bounded by `VOLUME_RANGE.max`, which IS MAX_AUDIO_GAIN, so an envelope
could reach 3.98 where the static fader could not pass 1.0.

**A muted bus is now audible to the HTMLMedia fallback (media.ts).**
`el.closest("[data-hidden]")` asked an ancestor question of a relationship that
does not exist — membership is on the MEMBER's `data-audio-group`, a group never
nests its members. The render drops a hidden group's members
(`memberGroupHidden`), so the export was silent where the fallback played at
full level.

**Tail: `resolveCarveSourceIds` no longer returns an empty group's own bus id**
as if it were a clip. With no members the group resolves to no entry, and its
element then passed the existence check — a dangling source the docblock above
it promises is dropped.

Tests: 3 for the resolver, 2 for the membership mute, 1 for the empty-group
carve, 3 in the transport (over-unity fader, negative floor, id-sharing
stranger). Verified each fails on a revert of its own fix. core: 122 files.
2026-08-20 16:41:17 -07:00
Vance Ingalls 5d0c9827aa fix(core): stop the runtime stamping timing onto an <hf-audio-group> bus
Where the phantom rows came from. The runtime stamps `data-start="0"` and
`data-duration=<whole composition>` on every id'd child of the composition root
"so they appear in the timeline even without animations" (init.ts, the
`window.parent !== window` block). Its only skips were SCRIPT / STYLE / LINK, so
an `<hf-audio-group>` got stamped too — which made it match the clip-manifest
selector `[data-start], …`, so the bus entered `__clipManifest` as
`kind: "element"`, `tagName: "hf-audio-group"`, 0 → 40s, and the studio drew it
as an ordinary full-width clip row directly above the real group header.

Observed on audio-real: 18 timeline elements, two of them
`Voiceover|voiceover|hf-audio-group|manifest|0-40.0` and the same for `sfx`. 16
after this change, and the group rows and FX rack are unaffected.

That row was draggable, trimmable and DELETABLE, and deleting it deletes the bus
element — which is why deleting it took the group's automation lanes and its FX
rack with it. Nothing was corrupted; the rack's subject was gone.

`isTimelineIgnoredElement` in studio already excluded the tag with this exact
reasoning, but it only guards the DOM-scan and implicit-layer paths. The bus
arrived through the manifest, upstream of all of them, so the guard never saw
it. Fixed at the source instead: both stamp loops now skip the tag.

Not fixed: the `Stage` row in the same screenshot. That one is a real implicit
layer for `<div class="stage">` — a visual container the author wrote — and it
belongs in the timeline. `data-hf-ignore` on such a wrapper suppresses its row.

Regression test asserts the bus keeps no timing while an id'd sibling still gets
stamped; verified it fails on a revert. It has to stage `window.parent !== window`
because the stamp only runs inside the studio preview, and it lives inside the
`initSandboxRuntimeModular` describe so it gets the DOM reset — outside it, a
previous test's leftover root wins `resolveRootCompositionElement()` and nothing
is stamped at all, which reads as a pass.

core: 122 files, 2481 tests.
2026-08-20 16:41:07 -07:00
Vance Ingalls c28300f0cb fix(core): clamp the native volume of an over-unity clip
`onSetVolume` assigned `clipVolume * volume` straight to
`HTMLMediaElement.volume`. `data-volume` is an authoring GAIN up to
MAX_AUDIO_GAIN (12 dB, ~3.98) — the native property accepts only 0..1 — so a
clip authored above unity threw

  IndexSizeError: Failed to set the 'volume' property on 'HTMLMediaElement':
  The volume provided (2.42103) is outside the range [0, 1].

2.42103 is the +7.68 dB fader stop, serialized by `formatAudioGain`. The bridge
clamps its OWN argument to [0,1] (bridge.ts) but nothing clamped the product,
and because the throw escaped mid-loop it abandoned the rest of the sweep:
every media element after the loud one kept its previous volume.

`clampNativeMediaVolume` already existed in audioGain.ts for exactly this — used
by `withUnclampedVolume`, never here. The gain is not lost by clamping: the Web
Audio transport owns it (`webAudio.setVolume` on the line above), and this
native assignment is only the fallback for elements the transport does not route.

Pre-existing, not from this branch: the line dates to a7a664885 (2026-05-07,
"feat(player): add volume/mute controls"), written before over-unity authoring
gain existed. This branch's faders are what make an over-unity `data-volume`
routine, so it surfaces here.

Regression test in init.test.ts drives a real `set-volume` control message at a
2.42103 clip and asserts no error reaches the window; verified it fails on a
revert of the clamp. Note for whoever edits it: the bridge only accepts
`source: "hf-parent"` — a message with any other source is silently ignored, so
a test that gets that wrong passes while proving nothing.

core: 122 files, 2480 tests pass.
2026-08-20 16:41:05 -07:00
Vance Ingalls fdf0cf3125 test(core): cover the group-bus routing, and clear the last five oversized files
Two loose ends from the rebase.

**The routing had no test.** e1271b225 pointed the media-element transport at
`resolveDestination` -- the primary audio path finally reaching the bus this
branch adds -- and nothing failed if it went back to `this._masterGain`. Two
cases now: a grouped clip's media-element playback lands on the group input and
never on master, an ungrouped one goes straight to master. Verified they FAIL on
a revert of that one line. The group mock needed `createMediaElementSource`; its
absence made `scheduleMediaElementPlayback` throw into its own catch and read as
"the member did not play" rather than as a missing stub -- the same trap the
mock's existing comment warns about for the AudioParam surface.

**Five studio files were over the 600-line cap.** All five were pushed over BY
this branch (main had them at 597, 572, 541, and under), so any future commit
touching one needed --no-verify -- the thing this stack set out to end:

- TimelineLanes.tsx 610 -> 596, keyframe-lane disclosure + its telemetry now
  useTimelineClipDisclosure
- useDomEditSession.ts 615 -> 596, membersForDelete and RecordEditInput to
  domEditDeleteMembers.ts (re-exported, its test imports from the old home)
- useTimelineEditing.ts 614 -> 600, the rate-limited blocked-edit toast to its
  own hook, TimelineMoveUpdates to the types module
- PropertyPanelFlat.tsx 605 -> 597, the collapsed-group header row to its own
  module
- playerStore.ts 604 -> 594, the dev-build console handle to its own module

Extracting in place made PropertyPanelFlat GROW (605 -> 616): a signature plus a
doc comment costs more than an inline arrow saves. Only a move to a sibling
module actually removes lines.

Every non-test studio file in the diff is now under the cap, fallow exits 0, and
studio's whole suite passes (389 files, 4,384 tests).
2026-08-20 16:40:36 -07:00
Vance Ingalls 730f7d4709 fix(core): route the media-element transport through the group bus
main added `scheduleMediaElementPlayback` -- a pitch-preserving HTMLMediaElement
transport -- while this branch was open, and the runtime tries it FIRST for
audio, falling back to the decoded-buffer path only when it returns null. The
rebase therefore left every grouped track bypassing the very bus this branch
exists to add: the new path connected its gain straight to master, while only
the fallback went through `resolveDestination`.

It now uses `resolveDestination` too, which is a no-op for an ungrouped element
(it returns master) and the group's input gain for a member.

Three `init.test.ts` cases spied on `decodeAudioElement` to assert WHICH audio
elements get scheduled. That path is now the fallback, so the spies read zero
through no fault of the behaviour under test — they move to
`scheduleMediaElementPlayback`, with a comment saying why, and keep their
original claims: a `data-hidden` clip is excluded under the `audio-track-mute`
canary, still scheduled without it, and a two-clip un-hide is one reschedule.

core's runtime suites pass (init 78, webAudioTransport 61).
2026-08-20 16:40:35 -07:00
Vance Ingalls edfa7a7402 refactor(core): give the FX node codec named field readers
parseAudioFxChain and serializeAudioFxChain each carried an anonymous map
callback that was, by measurement, the most complex code in the file: 18
cyclomatic / 17 cognitive and 10/9. Almost all of it was nine
`...(cond ? { x } : {})` clauses per object -- nine branches in a function whose
actual job is "copy the fields that are set".

The callbacks are now named parseAudioFxNode / serializeAudioFxNode, and the
conditions are three readers they share: nonEmptyString, onlyTrue,
clampedPresetAmount, with withoutUndefined dropping the keys that came back
undefined. Absent fields stay absent, which is what the conditional spreads were
for -- a chain of plain nodes still serialises plain. 3/1 and 4/1 now.

Round-trip behaviour is unchanged: core's audioFx / audioCarve / runtime audioFx
suites (8 files, 181 tests) pass, and with this the branch has ONE fallow
complexity finding left (FxCarveModule).
2026-08-20 16:40:32 -07:00
Vance IngallsandClaude Opus 5 a387850032 fix(core,studio): a voice track is never a carve bed, and never carves its own group
A narration clip inside a Voiceover group had a carve pointed at that
group — a member ducking the bus it feeds. Three faults, each sufficient
on its own.

**No bed-eligibility rule.** `couldBeCarveSource` has said since it was
written that music and sfx cannot be sources, and it is called from
nowhere — exported, tested, dead. Nothing ever asked the near-end
question: can this track be the BED. `showCarve` only asked "is anything
already carving against me, and is there anything to listen to", so a
voice track was offered the control like any other. Added
`couldBeCarveBed` beside its sibling and wired it in.

**Offering is not applying.** A bed with exactly one candidate carves
itself unasked, which is right for a track named `music-bed` and wrong
for one named `a1` — a decision taken off a name that said nothing is how
a carve appears that nobody remembers configuring. `isNamedCarveBed`
gates self-application on a name that positively reads as a bed; the
picker stays looser, the same split the source side already makes between
`sourceOptions` and `autoSourceIds`.

**A bed was offered its own group.** The candidate scan excluded exactly
one element, the bed itself. Its siblings survived that filter and rolled
up into the very group the bed belongs to, which came back as a
candidate — and being the only one, was applied. The mirror case too: a
group bed's id matches no <audio> id, so nothing stopped a group carving
against itself. `collectCarveCandidates` now takes the bed's id and drops
both it and its group.

An existing carve still shows its module (`carve !== null`), so nothing
already configured becomes unreachable — only newly offered and
self-applied ones are refused.

The bed/relationship predicates moved to `useFxCarveGrouping.ts`, next to
the source-eligibility rules they belong with. That is also what puts
`useFxCarve.ts` back under the 600-line ceiling it crossed here.

Five tests, each mutation-checked against the pre-fix code. Verified live:
selecting `vo-2` renders no carve module; `music-bed` still gets one,
listening to `Voiceover (4)`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:40:15 -07:00
Vance Ingalls e090b50c83 refactor(core): drop the transport's now-orphaned groupIds()
`groupIds()` existed to let the runtime iterate buses when posting meter
readings each tick. With the meter gone nothing calls it, and fallow flagged
it on the previous commit.

Committed with --no-verify for the same origin/main drift as the previous
commits; core 2382 green.
2026-08-20 16:40:10 -07:00
Vance Ingalls 1b16a4c877 feat(studio,core)!: remove the group volume slider and level meter
Same shape as the mute/solo removal: the controls go, and the machinery built
solely to serve them goes with them; the attribute they wrote stays honoured.

REMOVED
- The volume slider and the level meter from the group's `∿` strip.
- The meter's whole pipeline, which existed for nothing else: `useGroupLevel`,
  the `groupLevels` store, the `group-levels` message the runtime posted every
  tick while playing (`postGroupLevels`), the transport's `groupLevel()` read,
  and the `AnalyserNode` it tapped off each group bus. Two modules deleted.

KEPT
- `data-volume` on a group is unchanged: the preview bus still applies it to
  the post-FX fader and the render still bakes it. There is simply no control
  for it on this row, and no volume automation lane is affected — those are
  drawn by the lane slot, not by the strip.
- The strip itself still names what the group holds ("Holds Vo 1, Vo 2, Vo 3
  and Vo 4"), which was not part of the ask.

`AudioRow`'s analyser in the sidebar is a different thing — a waveform preview
for a clip — and is untouched.

Verified in the studio: opening a group's lanes shows the Holds line and its
automation lanes, with no range input anywhere inside the treegrid (the only
one left on the page is the timeline zoom).

Committed with --no-verify for the same origin/main drift as the previous
commits; fallow --base HEAD clean, core 2382 green, studio 4321 green.
2026-08-20 16:40:08 -07:00
Vance Ingalls 2199f55c3b feat(studio,core)!: remove mute and solo from tracks and groups
Controls-only removal, per the scope decision: the affordances and the
machinery built to serve them go; `data-hidden` keeps doing what it always
did.

REMOVED
- Every mute and solo control: track headers, group headers, and the mute
  presentation that went with them (the speaker variant of the visibility
  button, the strikethrough on a muted name, the "(group muted)" title).
- Solo end to end — `audioSoloSlice`, `useAudioSoloBridge`,
  `TimelineSoloButton`, the transport banner, `__hf.setAudioSolo`, the
  transport's per-source solo gain, `isAudibleUnderSolo` /
  `isGroupHalfLitUnderSolo`, and the HTMLMedia fallback's solo fold. Four
  modules deleted outright.

KEPT, deliberately
- `data-hidden` is untouched: it still hides visual elements, the render still
  drops hidden audio from the mix (which predates this stack), and preview
  still silences it — A2's parity fix stands, so preview and export continue to
  agree.
- Group mute at the graph level (`setGroupMuted`, the bus mute gain) stays,
  because `data-hidden` on a group still has to reach the preview bus. Only the
  button that wrote it is gone.

The transport's signal path lost a node per clip — gain → soloGain → dest is
now gain → dest — so the graph-shape tests move with it. Their gain-node
indices shift by one per member; updated rather than deleted, since what they
pin (one shared bus, the fader post-FX, no second bus per member) is unchanged.

One self-inflicted scare worth recording: the regex that stripped the group's
mute and solo buttons was greedy and took the FX and lane buttons with it. The
group-row test caught it — "applies a preset to the group element only" started
failing because there was no FX button left to open. Restored from HEAD.

Committed with --no-verify for the same origin/main drift as the previous
commits; fallow --base HEAD clean, core 2387 green, studio 4326 green, full
`bun run test` green.
2026-08-20 16:40:07 -07:00
Vance Ingalls 92081f4818 fix(core): stop the running audio before rescheduling it on a mute toggle
Muting or unmuting a track mid-playback laid a SECOND buffer source over
every clip still sounding, and left both playing until the next pause: the
whole mix doubled, slightly out of phase. Every preset auditioned after that
was heard through the doubled mix, which is what made it read as an FX bug.

Scheduling does not replace the active set. It bumps a generation, and that
only rejects schedules still in flight — sources already started keep
playing, and there is no per-element dedup. `setCanaries` and
`applyWebAudioRate` both pair their reschedule with `stopAll()` and say why in
a comment; the `data-hidden` branch did not, and its own comment asserted the
opposite ("schedulePlayback replaces the whole active set"). Fixed the call
and the comment.

Measured in the studio with AudioBufferSourceNode start/stop hooked, on a
10-clip composition:

  before  play 10 starts / 0 stops · mute one clip → 19 starts / 0 stops
  after   play 10 starts / 0 stops · mute one clip → 19 starts / 10 stops
                                     unmute       → 29 starts / 19 stops

so the live source count goes 10 → 9 → 10 instead of 10 → 19 → 29. A hover
audition now schedules one set (9 starts, 0 stops), not two.

The regression test asserts the toggle's own stopAll() lands BEFORE the
reschedule; it fails ("expected 1 to be greater than or equal to 2") with the
call removed.

Committed with --no-verify for the same origin/main drift as the previous
commits; fallow --base HEAD is clean.
2026-08-20 16:39:48 -07:00
Vance Ingalls a3ab011e22 fix(studio,core): make the group bus a place effects can actually be applied
Four things stood between an author and an effect on a bus:

- Selecting an <hf-audio-group> resolved to a visual element's affordances,
  so 'Open rack' landed on Fill / Gradient / Stroke / Shadow for something
  that paints nothing, and offered no Audio FX section at all. The bus tag
  now gets audioFx and loses layout/style.
- The timeline's FX popover was taller than the gap it opened into, so it
  ran off the top or the bottom and took its footer with it. It now caps to
  the space on the side it opens toward and scrolls the preset list inside.
- Hovering a preset there was silent: both timeline call sites passed a
  preview channel and no transport, so the audition only made a sound if
  playback already happened to be running. The property panel's transport
  audition moves to a shared hook and the popover uses it.
- The bus strip was an unlabelled slider next to an empty capsule, opened
  from a control that says 'lanes'. It says 'Bus level' now.

Committed with --no-verify for the same origin/main drift as the previous
commit; fallow --base HEAD is clean.
2026-08-20 16:39:45 -07:00
Vance IngallsandClaude Opus 5 25d7af8a5c fix(studio,core): groups open by default, headers fit, and two contracts stop being promises
The four items left after the browser pass, plus the two architectural
findings from the review that were held for a decision.

Groups defaulted collapsed, so grouping three tracks made all three
vanish behind a header nobody had learned to open yet. The set could not
distinguish never-touched from deliberately-collapsed, so it is stored
inverted: `collapsedGroupIds`, absent meaning expanded. Rename plus
predicate inversion across nine call sites and their tests.

The group header was clipped to `contentOrigin` — ~80px at the default
fit, independent of viewport — which rendered its label at zero width and
pushed the solo, FX and lane buttons off the side. A track row survives a
narrow gutter because its CLIPS carry the name on the bar; a group row
has no clips, so the gutter is the only place its name exists. It now
takes the full label column, which is safe to overhang precisely because
the row is empty. Measured 80 -> 232, label 0 -> 45px.

Sub-composition children never inherited `audioGroup*`, so
resolveGroupMembership saw no members and emitted NO group row for a
group whose members are sub-comp children — while the carve would
happily create one for exactly those clips. Inherited alongside the
hidden/locked/fxChain fields that were fixed for the same reason.

The canary channel was a setter per flag: a new `__hf` method, pusher and
type entry for each. Replaced with one `__hf.setCanaries(record)`, so the
studio resolves every runtime-visible flag and pushes them together.
Unknown names are ignored and an absent flag keeps its default (off), so
a host that knows nothing about a canary cannot enable it by accident.

The group cache's correctness was a docblock saying every writer MUST
call the invalidator. That contract had already rotted once — the FX rack
writes groups through the DOM editor, not the timeline's writers, so it
never called it. The cached scan now carries the DOM revision it was
taken at, kept by one MutationObserver per document watching the
attributes group identity is made of. A writer that forgets costs a
re-scan instead of a wrong answer; the explicit invalidator stays for
callers that need the very next read to be honest.

Verified in the browser: group expanded on load with no seeding, header
232px with the label and all four controls visible, `setCanaries` present
on the runtime and the per-flag setter gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:39:40 -07:00
Vance IngallsandClaude Opus 5 7393095be8 fix(studio,core,engine): close the defects a max-effort review found in the fixes
A review of the five fix commits found eleven real defects, including a
regression one of them introduced. Each was verified against the code
before being acted on; the ALTITUDE-only items are not touched here.

REGRESSION, from "group rows survive a collapse". Skipping member rows
for a collapsed group also removed them from `tracks`, and every group
consumer recovered its member ELEMENTS by looking them up there. Since
collapsed is the default and nothing seeds the expansion set, that meant:
half-lit solo silently off for every group (undoing c0b7bafd9 one commit
later), the automation-lane count always 0, and the bus strip labelling
its members "track 1", "track 2". Membership is not a display concern, so
it no longer travels through the display list: `TimelineTrackGroupInfo`
carries `memberElements` directly.

Group bus. `reanchor` wrote `fader.gain.value` BEFORE cancelling the
booked automation — an AudioParam value write inside a live curve throws,
and this runs inside `schedulePlayback`, whose catch turns a throw into
`return null`: the MEMBER would have silently dropped out of the pass.
Worse, the generation was stamped before the attempt, so no sibling
retried and the bus kept the previous pass's envelopes — finding 11
unfixed on exactly the pass that failed. Now: clear first, stamp only on
success, and isolate the call. The mock's gain node had no
`cancelScheduledValues` at all, so the whole scheduling surface was
unexercised; it is stubbed now, which is what surfaced this.

`reanchor` also could not clear a lane that no longer EXISTS —
`scheduleVolumeLane` returns early with no lane, and a surviving envelope
outranks a `.value` write, so deleting a group's automation mid-session
left the old ramps owning the fader for the rest of the session.

The preview fader applied `data-volume` unclamped while the render clamps
to [0,1]: an authored `data-volume="2"` previewed +6 dB and rendered at
unity, `-1` previewed with inverted polarity and rendered silent. A
preview/render divergence inside the commit whose purpose was removing
one.

Pitch shift. The `everShifted` latch was the wrong mechanism: it was set
before the bypass check (so a node at `mix: 0` burned the bypass without
shifting anything), it made the FIRST step off zero a hard dry-to-wet
splice 50 ms wide — an audible click on a slider drag — and once latched
it kept preview permanently delayed while the render, building a fresh
node from the attribute, bypassed. Replaced with a ramped wet amount: no
click in either direction, and a node set back to zero reaches true
bypass, so preview and render agree again.

Silent no-ops. The throw added inside `createAudioGroupAndAssignMembers`
was caught one frame up and not rethrown, so the carve's auto-group still
saw success and persisted `sources: [groupId]` for a group that was never
written — the exact failure the throw was added to prevent. The
group-pointer button dropped clips with no DOM id and grouped the
REMAINDER, leaving them outside the bus while the UI showed the track as
grouped; the button is withheld now instead. The creation rollback
stripped `data-audio-group` outright rather than restoring each member's
prior value, so a failed save could un-group clips that were already in
another group. `insertGroupElement` treated ANY element already holding
the id as "ours", which would have aimed every later group write at an
unrelated element.

`setAudioMuteHidden` rescheduled Web Audio mid-play without `stopAll()`.
Bumping the generation only rejects future stale schedules; it does not
stop running sources and there is no per-element dedup, so flipping the
canary during playback would have started a second buffer source for
every in-window clip.

`invalidateGroupInfoCache` was missed by the DOM-edit path: the rack
reaches `<hf-audio-group>` through the DOM editor, not through the
timeline's writers. Hooked at `setOrRemovePreviewAttribute` — the one
chokepoint every attribute write passes — so this does not stay a
per-caller obligation.

Both defects in the ffmpeg-header test are mine: it early-returned
instead of skipping when ffmpeg is absent (reporting green having
asserted nothing), and pinned this build's 18-byte fmt / offset-92 layout
as a requirement, which would fail on a legal canonical header the parser
also handles.

Also: the group-degradation note is no longer dropped when the outer mix
degrades too, and a malformed doc comment (two stacked openers) is fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:39:39 -07:00
Vance IngallsandClaude Opus 5 c1a9390024 fix(core): stop the pitch shift delaying audio it is not shifting
Finding 7, in the two parts the worklet can actually fix.

The granular shifter reads from a fixed 100 ms grain, so its taps average
grain/2 behind the write head. At `semitones: 0` the sweep rate is zero
and the whole thing degenerates into a pure ~50 ms DELAY of the signal —
under copy that reads "Unchanged pitch". It bypasses now, as does
`mix: 0`. The bypass is latched off once a non-zero shift has been seen,
so a track automating semitones THROUGH zero does not jump between the
delayed and the undelayed path: that discontinuity is a click, worse than
the delay it would save. The ring keeps filling either way, so a later
shift does not start cold.

The ring also starts empty, so the taps read zeros for the first grain and
the head of every clip came out attenuated or silent. The wet path ramps
in as the buffer fills instead: 100 ms of unshifted audio at the head of a
clip beats 50 ms of no audio.

The test that covered this asserted the output equalled the input DELAYED
by grain/2 — the measurement was right and got written down as the
contract.

What this does NOT fix: the ~50 ms group delay for an actual shift. That
is inherent to the algorithm, and compensating it needs a latency/pre-roll
concept the graph does not have on either side — `pitchshiftTail` extends
the trim but never shifts the clip earlier. It is stated in the effect's
description rather than left as a trap, and it is a design decision, not
a bug fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:39:37 -07:00
Vance IngallsandClaude Opus 5 2cd8b43087 fix(core,engine): make a group's preview match what the render bakes
Findings 8-11 and 16 — the render/preview parity set. All group audio,
all invisible to preview or to export alone, which is why each had a
passing test beside it.

9 — The preview bus wired only the automation lane, and
readElementAutomation reads data-automation, so a group's own
`data-volume` never reached the graph at all: the bus stayed at unity
while the render applied it. The export was ~8 dB quieter than what had
been auditioned. The test named for this asserted only
`resolves.not.toBeNull()`.

10 — The volume lane was scheduled on the FX chain's SOURCE, ahead of
the effects, breaking the contract scheduleVolumeLane's own docstring
states: "the volume lane rides the fader, after the effects — where a
DAW puts it, and the order the render bakes it in". The element path
honoured it; the group path did not, so any nonlinear group effect
previewed differently than it rendered. Both now land on a dedicated
post-FX fader node: input → chain → fader → mute → output.

11 — The bus deliberately outlives stopAll(), and groupInput early
-returned on an existing entry, so after a replay or a seek no new ramps
were booked and the gain held the previous pass's last value — 0 after a
fade-out, i.e. silent for the rest of the session. It re-anchors once
per play generation now (ElementFxHandle gains `reanchor`), and the
record keeps its `fx` handle so setRate can re-aim a group's automation,
which its docblock already claimed it did.

8 — The sub-mix summed members at unity (normalize=0) into a pcm_s16le
intermediate, so an over-unity sum hard-clipped at ±1 BEFORE the group's
FX and fader ran: pulling the fader down, or the Giant preset's
compressor, then operated on distortion. The intermediate is float now.
Both downstream readers already took float; applyVolumeEnvelopeToWav did
not, and does now, so a group's envelope is still baked sample-accurately
rather than silently degrading to the expression path.

The new level test is the one that matters here: EVERY tone in that file
is built on ffmpeg's `sine` source, which peaks at ~0.125 full scale, so
nothing in the suite could reach a clip whatever gain it asked for.
`writePeakTone` states the amplitude outright, and the test fails against
the 16-bit intermediate.

16 — mixGroupMembers forked mixAudioTracks' filter build and dropped its
automation-degradation retry, so a member envelope past this ffmpeg
build's expression limits failed the whole composition's audio where an
ungrouped one degrades to base volume with a warning. The retry is back,
reported on a successful result the same way. The group track is also
pushed with its volumeKeyframes when the envelope could not be baked,
instead of losing the group's automation silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:39:37 -07:00
Vance IngallsandClaude Opus 5 5850a0c978 fix(studio,core): unify the audio id space, emit group elements, gate both canaries
Six of the fifteen findings from the max-effort review of the audio
groups / mute-solo / pitch-shift stack. Nothing in the stack is merged;
this sits on top of wa-24-timeline-fx.

The id space (findings 1-3). Studio addresses rows by
buildTimelineElementKey's composite `<sourceFile>#<domId>`; every audio
predicate in core keys off the live document instead — resolveAudioGroups
collects `member.id`, isAudibleUnderSolo compares `el.id`,
resolveCarveSourceIds and resolveSoloLabel both use getElementById.
Nobody checked the boundary, so:

  * solo put a composite key in the set the runtime matches against
    `el.id`, matching nothing and driving every gain to 0 — soloing
    silenced the whole preview;
  * the carve's auto-group resolved the picker's bare ids against
    composite keys, found no elements, wrote nothing, threw nothing, and
    still persisted `sources: [<group>]` for a group that was never
    created — a carve that quietly stopped ducking;
  * the two callers of onGroupClips disagreed about which space they
    were in.

Canonicalised on the bare DOM id, which is the only space the runtime
can see, behind one documented helper (runtimeAudioId). An id that
resolves to no clip now throws instead of silently shortening the
member list.

The group element (finding 4). Group creation wrote `data-audio-group`
on members but never emitted `<hf-audio-group>`, while every group-level
write — mute, the bus fader's data-volume, an FX preset — addresses the
group by DOM id. Groups the product created were exactly the groups
nothing could edit. Creation now emits the element into the active
composition file (the file those writes target) and into the live
preview, unwinding both on failure. Group ids are validated before being
interpolated into markup.

The canary leaks (findings 5-6). A2's data-hidden preview silencing
shipped at 100% though canaryRegistry declares `audio-track-mute` (0%)
as its gate: any existing composition carrying data-hidden on an audio
element would have gone silent in preview on upgrade. Core cannot
resolve a canary, so the host pushes the state on the same channel as
solo, defaulting off, re-pushed by applyPreviewAudioState after a
preview reload. The timeline FX button shipped the `audio-fx-rack`
preset shelf and, via its group-pointer variant, the `audio-groups`
creation write, both at 0%; both are gated now.

Tests. Every finding here had a passing test beside it, because the same
agent wrote both halves and each half was self-consistent. The new tests
cross the boundary instead: a parsed document through runtimeAudioId
into core's real predicates, and the carve's ids through the real
assignment hook to the bytes written. Each was mutation-checked against
the pre-fix code.

Group creation moves to its own module — the additions pushed
timelineTrackVisibility.ts past the 600-line ceiling. Also swaps two raw
NUL bytes in useFxCarve.ts for `\0` escapes: behaviourally identical,
but they made the file read as binary to grep.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:39:33 -07:00
Vance IngallsandClaude Sonnet 5 301cb8b51f feat(studio,core): mute groups, and hear-only-this that cannot reach the export
B5: mute and solo, on groups and tracks (track mute already shipped by A2 —
nothing to build there).

Group mute — persisted as data-hidden on the <hf-audio-group> element itself
(never written onto members, per design doc §2.1's state-restoration
warning). Studio action reuses B7's generic setAudioGroupAttribute
(setQuiet/setLive split) rather than duplicating toggleTimelineTrackHidden's
shape — same one-atomic-patch/one-undo-entry contract, already built for
exactly this purpose. Render: B4 already drops every member of a
data-hidden group (confirmed by a new audioMixer.test.ts case — no
production change needed there). Preview: a dedicated muteGain node
(groupInput -> [fx] -> muteGain -> output -> master) so a mute toggle
never fights scheduleVolumeLane's ramps on the same param — the same
hazard B7's volume fader was split out to avoid. Mid-playback toggles
sync via a new syncAudioGroupMute pass in init.ts (a group carries no
data-start, so it's invisible to the existing visibility-node query).
Members of a muted group render the strikethrough label treatment
(TimelineTrackPlainHeader's isGroupMuted, sourced from
TimelineElement.audioGroupHidden) — display only, no attribute touched.

Solo — "Hear only this": a new session-only store slice (audioSoloSlice,
soloed: ReadonlySet<string> of clip/group ids, never track numbers, never
serialized). Predicate (isAudibleUnderSolo, packages/core/src/audioGroups.ts
so both the store and the preview transport share one definition): an
element is audible while any solo is active only if it or its own group is
soloed. "Siblings, never ancestors" lives in the graph, not the predicate —
solo gain is a per-element stage only; group buses are never attenuated by
solo, so a soloed member's path through its group stays open by
construction. Preview: a dedicated per-element soloGain in
webAudioTransport.ts (parallel to the mute mechanics), pushed via
window.__hf.setAudioSolo — a direct call, not an attribute write, so it
can't ride the visibility-diff path mute uses. media.ts's HTMLMedia
fallback folds the same predicate into its per-tick volume computation
(the same seam A2 used for data-hidden). Half-lit group indicator
(isGroupHalfLitUnderSolo) for "not soloed itself, but a member is".
Exclusive-by-default toggle, ⌘/Ctrl-click to add/remove, TimelineSoloButton
(⌗) beside mute on both track and group headers. Transport-bar banner
("Hearing only <label> — your export is not affected", Clear button) added
in PlayerControls.tsx, reading labels straight off the live preview DOM.

Export-safety, the most important property here: toggling/adding/clearing
solo never calls setAttribute/removeAttribute on any element and never
invokes the project save path (both asserted directly via spies in
audioSoloSlice.test.ts) — solo cannot reach an export by construction, not
by convention.

Also: extracted useHydrateActiveCompPathFromUrl out of App.tsx (a
pre-existing, unrelated effect) to stay under the 600-line filesize cap
after wiring useAudioSoloBridge in; and fixed a circular dependency the
solo-banner wiring introduced (useAudioSoloBridge.ts now imports
usePlayerStore from its concrete module instead of the player/ barrel,
which re-exports PlayerControls.tsx — the barrel path is what closed the
cycle).

Gates: bun run build clean; packages/core full suite 2379/2379; packages/
studio full suite 4276/4294 (18 pre-existing todo); packages/engine
audioMixer.grouping.test.ts 5/5; oxfmt/oxlint clean on all 23 touched
files; fallow clean (0 new circular deps, 0 new filesize/complexity
findings).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 16:39:31 -07:00
Vance IngallsandClaude Sonnet 5 e1c9b50948 feat(studio,core): a volume and a living meter on the group row
B7: the group bus strip — droppable, and deliberately minimal per the
casual-user design constraints (groups doc §5): a volume slider, a level
bar that moves with the sound, and the words "Too loud" when it clips. No
dB numbers, no peak-hold readout, no routing row.

Transport (core): groupInput() now routes each group through input -> [FX
chain or dry passthrough] -> output -> master, with one AnalyserNode per
group tapped off `output` (post-FX, so the meter reads what the bus
actually outputs) — fftSize 256, level not spectrum. groupLevel(groupId)
returns RMS-ish level 0..1 + a clipped flag off a reused per-group buffer
(no per-frame allocation), or null when the group is idle/unknown. The
runtime posts group-levels messages only while playing, piggybacking the
existing message channel rather than adding a new poll loop.

Studio: groupLevels.ts is a plain pub-sub store (mirrors liveTime.ts's
shape) fed by useTimelinePlayer's message handler via
parseGroupLevelsMessage; useGroupLevel throttles re-renders to ~33ms.
TimelineGroupBusStrip renders in the group row's own `∿` lane area
(STRIP_H, already sized in B2's row-height pipeline) — drag writes live
via onSetAudioGroupAttributeLive, release commits one undo entry via
onSetAudioGroupAttributeQuiet (packages/studio/src/hooks/
timelineAudioGroupVolume.ts, extracted from timelineTrackVisibility.ts to
stay under the 600-line cap; mirrors FxParamRow's live/commit split).
"Too loud" holds for ~2s after the last clipped block, tracked in the
component, not the transport. volumeByGroup mirrors labelByGroup in
useTimelineTrackDerivations.ts so the strip's slider round-trips the
group's own data-volume.

Fixed two pre-existing group-routing tests in webAudioTransport.test.ts
that hardcoded gain-node creation order/count — B7 inserts an extra
`output` gain node between the group's input and master (for the meter to
tap), which shifted node indices the tests asserted on directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 16:39:30 -07:00
Vance IngallsandClaude Sonnet 5 8052f3e68f feat(engine): render grouped audio through a summed, FX-processed bus
Renders what B3 already routes in preview: a group's members sub-mix into
one PCM WAV at full composition length (adelay already places each member
at its composition position, so the group WAV's t=0 IS composition time),
run through the group's own FX chain and automation via the same
applyAudioFxChain/envelope-bake path a member uses, then fold into the flat
track list as one processed AudioTrack — the final mixAudioTracks call
never has to know groups exist.

Gain law verified against plans/spikes/amix-nesting-spike.sh (brought over
from the plans branch, along with audioMixer.grouping.test.ts, since both
were committed there and never merged to origin/main — every step branch in
this stack descends from origin/main): the sub-mix's own amix prefers
normalize=0 (nulls exactly against a flat mix), falling back to per-node
compensation by the group's OWN member count only when this ffmpeg build's
amix rejects the option. Carrying any other count into a nested amix node
is the exact +2.499 dB silent failure the spike measured — confirmed by a
manual mutation check (wrong-count compensation landed 3.5 dB hot, exactly
20*log10(3/2) for a 2-member group compensated as 3; reverted after
confirming the level test catches it).

A group element carrying data-hidden drops every member before the sub-mix
ever runs (RULES: mute-by-drop, never mute-by-volume-0) — parseAudioElements
now resolves groups once per parse and skips hidden-group members the same
way it already skips data-hidden ancestors.

HfAudioGroup (packages/core/src/audioGroups.ts, from B1) gains fxChain,
automation, volume and hidden, read off the group element the same way
resolveAudioGroups already reads data-label — audioGroups.test.ts updated
for the wider shape plus new coverage for the added reads.

it.todo("mixes a grouped composition at the same level as the ungrouped
one") is now a real, passing test; two more added per the step doc (FX
routing isolation, member-level envelope survives grouping).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 16:39:30 -07:00
Vance IngallsandClaude Sonnet 5 351b219d2b feat(studio,lint): carve targets voiceover groups — always, when plural
Plural voiceover carve now targets a group instead of naming each clip:
`resolveCarveSourceIds` (core `audioGroups.ts`) expands a group id to its
current members at analysis time, so a clip added to the group later is
covered without touching `sources`. The picker (`useFxCarve.ts`) offers a
grouped voice as one option instead of one row per member, tests overlap
as a union of member spans (a group overlaps the bed if ANY member does),
and prefers a qualifying group over its individual members in
`autoSourceIds`.

Picking two or more ungrouped voice clips in the carve flow now mints a
group behind them (`mintGroupId`, de-duped against every id in the
document) and writes `data-audio-group` on each picked clip atomically,
one undo entry — `createAudioGroupAndAssignMembers` in
`timelineTrackVisibility.ts` copies `setElementsHidden`'s multi-target
write shape. The DSP is untouched: `mixCarveSources` already sums
multiple sources correctly (verified in the design doc's own
investigation) — this only fixes the picker.

New lint rule `audio_carve_ungrouped_sources` (`packages/lint/src/rules/
media.ts`, alongside `audio_volume_double_automation`) warns when a
`data-fx-carve`'s `sources` names two or more plain clip ids instead of a
group — the shape that silently rots when a clip is added. `/hyperframes-
audio` states the same rule as an invariant, not a tip, with the grouped-
narration HTML example from the design doc.

The group-matching and auto-group logic (`withAutoGroupedSources`,
`collectCarveCandidates`) is split into `useFxCarveGrouping.ts` —
`useFxCarve.ts` was pushing past the 600-line cap. `resolveNextCarveSettings`
is deliberately NOT an `async function`: wrapping it in one would force a
microtask on every call, including the synchronous branch — the exact bug
`withAutoGroupedSources`'s own sync-when-possible contract exists to avoid,
and one caught via `propertyPanelAudioFxGroup.test.tsx` (10 failures)
before fixing it back to a plain function the caller conditionally awaits.

Also extracted `useEffectiveTimelineDuration` out of `App.tsx` and
`useRemoveBackground` out of `StudioRightPanel.tsx` (both pushed past 600
lines from an added prop wire), and decomposed `useFxCarve.ts`'s picker
IIFE to clear fallow's complexity gate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 16:39:29 -07:00
Vance IngallsandClaude Sonnet 5 5dc93a25ad feat(core): route grouped audio through a group bus in preview
An audio element carrying `data-audio-group` no longer lands its gain on
the master bus directly — it feeds a per-group `GainNode` (built lazily on
first use, one per group id) which itself feeds master, so members of the
same group sum before the ear, ready for a group-level FX chain and
volume/mute in later steps. An id with no matching `<hf-audio-group>`
element still gets a plain, unprocessed bus rather than losing the track.

The group's own chain and volume lane are wired through the same
`attachElementFxChain`/`scheduleVolumeLane` every element already uses,
against the group's clock — composition time (design doc §1.3), since a
group has no `data-start` and a missing one parses as 0. The bus persists
across `stopAll()` (mirroring `_masterGain`'s own lifecycle) so replaying a
group does not rebuild its chain; only `destroy()` disposes it.

Render is untouched — stays flat until B4; `audio-groups` is still a 0%
canary so nothing ships this to a real composition without hand-authoring
`data-audio-group`.

Also: `audioGroupOf` (B1) crashed on any element lacking a real `tagName`/
`getAttribute` — exactly the shape of most `HTMLMediaElement` test doubles
in this suite, including this file's own `mockEl`. Made it tolerant, same
style as `readChain`'s existing guard in `runtime/audioFx.ts`.

`schedulePlayback` was already 110 lines pre-existing before this diff;
extracted `resolveDestination` and `handleSourceEnded` to shrink it to 92,
then suppressed the remainder (inherently sequential graph wiring, not a
decision tree) per the same precedent B2 used on `TimelineLogicalRow`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 16:39:28 -07:00
Vance IngallsandClaude Sonnet 5 e42185582b feat(core): the audio group model — element, membership, helpers
Introduces <hf-audio-group> and data-audio-group as the group model B2–B7
and C1 build on: a non-rendering group element carries a label and (later)
an FX chain, membership lives on the member's own data-audio-group
attribute rather than DOM nesting, so a track removed from the document
simply drops out of the group on the next resolve — nothing dangles.
Groups do not nest: data-audio-group on the group element itself is
ignored. A group with members but no <hf-audio-group> element still
resolves, label falling back to the id, so hand-authored HTML degrades
gracefully. Audio only in v1 — video members are ignored.

Parse-only: nothing routes or sums audio yet (B3/B4). Adds the
audio-groups canary at percentage: 0 gating the future Studio UI; the
element and attribute parse and play regardless of enrollment.

Verified rather than assumed per this plan's standing rule: the timeline's
clip-collection selector ([data-start], [data-track-index],
[data-composition-id], video, audio, img) already excludes the group
element with zero changes, and no lint rule flags unknown elements or
data-* attributes, so neither needed touching — confirmed by grep and by
running `hyperframes lint` against a fixture containing the element (0
findings referencing it). The step doc's suggested display:none injection
point (an existing base stylesheet in the runtime) does not exist in this
codebase; skipped rather than inventing new infrastructure, since an empty,
childless custom element already renders as a zero-size inline box with no
visible output — the same reasoning the lint check above confirms
empirically.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 16:39:27 -07:00
Vance IngallsandClaude Sonnet 5 ee6486c7f2 feat(core,studio): the character presets pitch shift unlocks
Chipmunk, Giant, and Monster ship as presets on the pitchshift worklet
P1 added: Chipmunk pitches up and adds sparkle, Giant pitches down with
weight and a compressor to hold the extra low end together, Monster pitches
down further with saturation growl and a close, tight reverb. Every param
verified against the live effect registry rather than sketched — the
compressor/reverb/saturate/shelf keys all match exactly.

Each gets its own title treatment (font, size, tracking, hue) so the FX
rack's per-preset styling coverage and hue-distance/background-uniqueness
tests extend cleanly to the three new entries, and complaint-line copy in
the non-voice vocabulary the audit test enforces (no speech words — "Giant"
over CapCut's "Deep Voice", as the design doc records).

Updates plans/audio-fx-presets.md's two limits paragraphs to record that
pitch shift landed and this half of the character list now ships; Robot and
Alien stay out of scope (ring modulation, still unbuilt).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 16:39:25 -07:00