mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
0126db77121fdc1ab8dff9b29a3bbe1831b0d5a2
1006
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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. |
||
|
|
59a69a145b | chore: release v0.8.10 (#3426) | ||
|
|
f6e8e8ddfd | chore: release v0.8.9 (#3422) | ||
|
|
6f82acf50c | chore: release v0.8.8 (#3411) | ||
|
|
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. |
||
|
|
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. |
||
|
|
41af866bcb | chore: release v0.8.7 (#3402) | ||
|
|
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
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
36c7dffe5c | chore: release v0.8.6 (#3386) | ||
|
|
477e09642b | fix(core): replay ended audio after backward seeks (#3383) | ||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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
|
||
|
|
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.**
|
||
|
|
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). |
||
|
|
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).
|
||
|
|
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> |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |