fix(studio): three defects a browser found that no amount of reading did

First time any of this stack has been looked at rather than reasoned
about. Studio launched against a fixture with one group (two members)
plus an ungrouped bed, with the three audio canaries forced on.

The group element drew a phantom CLIP row. `<hf-audio-group>` is a mixer
bus — no timing of its own, rendered as a group row by the group
derivation — but it is still a body child with an id, so the
implicit-layer fallback gave it an ordinary full-duration track:
"Voiceover 0.0s-12.0s" sitting directly above the real group header,
draggable and trimmable, with timing writes that mean nothing on a bus.
Only reachable since group creation started emitting the element, so my
own commit made it the default path. Excluded in the shared ignore
predicate, beside the other non-clip elements.

Group writes never reached the store. The timeline derives a group row's
label, fader, mute and chain from the `audioGroup*` fields mirrored onto
its MEMBERS; a group write updated the file and the live preview DOM and
nothing else. Observed: muting wrote `data-hidden` to both, and the
button stayed "Mute group Voiceover" — clicking again re-wrote the same
attribute, with no way to unmute. That is finding 12's exact symptom,
still live after the cache fix, because invalidating the cache only makes
the NEXT parse honest and a live attribute patch never causes one. Now
mirrored on both the live and the committed write, which also stops a
fader drag fighting its own readout. Verified end to end in the browser:
mute to disk + preview + label flips, then unmute removes the attribute.

The bus fader offered 0..2 while every consumer clamps to [0,1]. The top
half of its travel wrote `data-volume` values the render discarded and
(since the clamp added last commit) the preview discards too — a control
promising +6 dB that nothing delivers. Ceiling lowered to unity. Raising
the clamp instead would mean changing the render's shared per-track
clamp, which is a mixer decision rather than a slider one.

Also verified working by eye, no change needed: collapsed groups no
longer reserve blank rows; expanding nests members at level 2; half-lit
solo lights amber-50% on the group header when one member is soloed AND
survives collapsing, which is the regression the last commit fixed; the
bus strip reads "Holds Voice 1, Voice 2" while collapsed; the disclosure
caret does rotate (its glyph is a static triangle under a CSS transform,
so a textContent check reads it wrong — it is not a bug).

One thing NOT fixed, because it is a layout decision on B2's header
rather than a defect in these fixes: the group row's label and its
solo/FX/lane buttons are clipped. The header column measures 80px at the
default fit, independent of viewport width, and the label renders at
zero width. A normal track survives this because its clips carry the name
on the bar; a group row has no clip bar, so the gutter is the only place
its name exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-20 16:39:39 -07:00
co-authored by Claude Opus 5
parent 7393095be8
commit ff4965037c
8 changed files with 252 additions and 28 deletions
@@ -77,28 +77,41 @@ describe("TimelineGroupBusStrip", () => {
const { onVolumeChange, onVolumeCommit } = renderStrip({ volume: 1 });
const input = slider();
act(() => setSliderValue(input, "1.2"));
act(() => setSliderValue(input, "1.5"));
act(() => setSliderValue(input, "0.4"));
act(() => setSliderValue(input, "0.6"));
expect(onVolumeChange).toHaveBeenCalledTimes(2);
expect(onVolumeChange).toHaveBeenLastCalledWith(1.5);
expect(onVolumeChange).toHaveBeenLastCalledWith(0.6);
expect(onVolumeCommit).not.toHaveBeenCalled();
act(() => {
input.value = "1.5";
input.value = "0.6";
input.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
});
expect(onVolumeCommit).toHaveBeenCalledTimes(1);
expect(onVolumeCommit).toHaveBeenCalledWith(1.5);
expect(onVolumeCommit).toHaveBeenCalledWith(0.6);
});
it("clamps the volume slider to the 0..2 range", () => {
const { onVolumeChange } = renderStrip();
// Unity is the ceiling because unity is what the pipeline honours: the render
// clamps every track volume to [0,1] and the preview bus clamps to match, so
// the fader's old travel to 2.0 spent its top half writing `data-volume`
// values that both ends discarded — a control promising +6 dB that nothing
// delivered.
it("clamps the volume slider to the 0..1 range the pipeline honours", () => {
// Starts below unity so each write below is a real change — setting a range
// input to the value it already holds fires no change event at all.
const { onVolumeChange } = renderStrip({ volume: 0.5 });
const input = slider();
expect(input.min).toBe("0");
expect(input.max).toBe("2");
expect(input.max).toBe("1");
act(() => setSliderValue(input, "1"));
expect(onVolumeChange).toHaveBeenLastCalledWith(1);
// Above unity is pulled back to unity rather than written through — the
// element's own max does the first half, `clampVolume` the rest.
act(() => setSliderValue(input, "0.5"));
act(() => setSliderValue(input, "2"));
expect(onVolumeChange).toHaveBeenLastCalledWith(2);
expect(onVolumeChange).toHaveBeenLastCalledWith(1);
});
it("the level bar tracks a live reading and shows nothing extra when it isn't clipping", () => {
@@ -12,8 +12,18 @@ import type { TimelineTheme } from "./timelineTheme";
/** How long "Too loud" stays lit after the last clipped block. */
const CLIP_HOLD_MS = 2000;
/**
* Unity is the ceiling because unity is what the pipeline honours: the render
* puts every track volume through its own `clampVolume` ([0,1]) before building
* the filter, and the preview bus clamps to match. A fader travelling to 2.0
* therefore spent its top half writing `data-volume` values that BOTH ends
* discard — the control promised +6 dB and nothing delivered it.
*
* Raising the ceiling instead would mean changing the render's shared clamp for
* every track, not just group buses; that is a mixer decision, not a slider one.
*/
function clampVolume(value: number): number {
return Math.min(2, Math.max(0, value));
return Math.min(1, Math.max(0, value));
}
interface TimelineGroupBusStripProps {
@@ -66,7 +76,7 @@ export function TimelineGroupBusStrip({
type="range"
aria-label="Group volume"
min={0}
max={2}
max={1}
step={0.01}
value={shownVolume}
className="h-1 w-20 shrink-0 accent-[#3CE6AC]"