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>
This commit is contained in:
Vance Ingalls
2026-08-20 16:39:30 -07:00
co-authored by Claude Sonnet 5
parent 8052f3e68f
commit e1c9b50948
25 changed files with 838 additions and 44 deletions
+22
View File
@@ -2140,6 +2140,25 @@ export function initSandboxRuntimeModular(): void {
scheduleRootStageLayoutDiagnostics();
};
/** One meter reading per group with an active member — polled from the
* transport's analyser, not the DOM, so an idle group (never played, no
* matching `<hf-audio-group>`) is simply absent rather than reported as
* zero. Cheap when nothing is grouped: `groupIds()` is empty. */
const postGroupLevels = () => {
const groupIds = webAudio.groupIds();
if (groupIds.length === 0) return;
const levels = groupIds
.map((groupId) => {
const reading = webAudio.groupLevel(groupId);
return reading ? { groupId, ...reading } : null;
})
.filter(
(entry): entry is { groupId: string; level: number; clipped: boolean } => entry !== null,
);
if (levels.length === 0) return;
postRuntimeMessage({ source: "hf-preview", type: "group-levels", levels });
};
const finitePositiveDuration = (value: number | null | undefined): number =>
typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
@@ -2897,6 +2916,9 @@ export function initSandboxRuntimeModular(): void {
if (transportTickCount % 30 === 0) {
bindMediaMetadataListeners();
}
if (clock.isPlaying()) {
postGroupLevels();
}
// Sync clock duration with the resolved timeline each tick (catches async
// rebinds, live data-duration edits). Never shrink while playing — transient
+11 -1
View File
@@ -197,6 +197,15 @@ export type RuntimePerformanceMessage = {
tags: Record<string, string | number | boolean | null>;
};
/** One audio group's live meter reading, polled from the transport each tick
* while playing. A group id absent from `levels` is idle/unknown (no active
* member) the studio side treats that as "no reading", not zero. */
export type RuntimeGroupLevelsMessage = {
source: "hf-preview";
type: "group-levels";
levels: Array<{ groupId: string; level: number; clipped: boolean }>;
};
export type RuntimeOutboundMessage =
| RuntimeStateMessage
| RuntimeTimelineMessage
@@ -210,7 +219,8 @@ export type RuntimeOutboundMessage =
| RuntimeMediaAutoplayBlockedMessage
| RuntimeReadyMessage
| RuntimeAnalyticsMessage
| RuntimePerformanceMessage;
| RuntimePerformanceMessage
| RuntimeGroupLevelsMessage;
export type RuntimePlayer = {
_timeline: RuntimeTimelineLike | null;
@@ -635,6 +635,12 @@ describe("WebAudioTransport", () => {
connect: ReturnType<typeof vi.fn>;
disconnect: ReturnType<typeof vi.fn>;
}[] = [];
const analysers: {
fftSize: number;
connect: ReturnType<typeof vi.fn>;
disconnect: ReturnType<typeof vi.fn>;
getFloatTimeDomainData: ReturnType<typeof vi.fn>;
}[] = [];
const masterGain = { gain: { value: 1 }, connect: vi.fn(), disconnect: vi.fn() };
const ctx = {
currentTime,
@@ -654,10 +660,20 @@ describe("WebAudioTransport", () => {
gainNodes.push(node);
return node;
}),
createAnalyser: vi.fn(() => {
const node = {
fftSize: 2048,
connect: vi.fn(),
disconnect: vi.fn(),
getFloatTimeDomainData: vi.fn(),
};
analysers.push(node);
return node;
}),
destination: {},
close: vi.fn(),
};
return { ctx, gainNodes, masterGain };
return { ctx, gainNodes, analysers, masterGain };
}
function setupGroupTransport(currentTime = 100) {
@@ -715,12 +731,14 @@ describe("WebAudioTransport", () => {
await scheduleGrouped(transport, gen, "a", "vo");
await scheduleGrouped(transport, gen, "b", "vo");
// Member gain nodes: index 0 (a) and index 2 (b) — index 1 is the
// group's own input gain, built inside a's schedule call.
expect(mock.gainNodes.length).toBeGreaterThanOrEqual(3);
// Member gain nodes: index 0 (a) and index 3 (b) — index 1/2 are the
// group's own input/output gain pair (B7's meter taps `output`),
// built inside a's schedule call.
expect(mock.gainNodes.length).toBeGreaterThanOrEqual(4);
const groupInput = firstGroupInput(mock);
const groupOutput = mock.gainNodes[2]!;
const aGain = mock.gainNodes[0]!;
const bGain = mock.gainNodes[2]!;
const bGain = mock.gainNodes[3]!;
// Neither member connects straight to master — both feed the shared bus.
expect(aGain.connect).toHaveBeenCalledWith(groupInput);
@@ -728,9 +746,12 @@ describe("WebAudioTransport", () => {
expect(aGain.connect).not.toHaveBeenCalledWith(mock.masterGain);
expect(bGain.connect).not.toHaveBeenCalledWith(mock.masterGain);
// The bus itself is what reaches master — a plain sum, no processing,
// since neither member's group has a chain-bearing `<hf-audio-group>`.
expect(groupInput.connect).toHaveBeenCalledWith(mock.masterGain);
// The bus's input never reaches master directly — it lands on the
// output gain (the dry passthrough, since neither member's group has a
// chain-bearing `<hf-audio-group>`), and THAT reaches master.
expect(groupInput.connect).not.toHaveBeenCalledWith(mock.masterGain);
expect(groupInput.connect).toHaveBeenCalledWith(groupOutput);
expect(groupOutput.connect).toHaveBeenCalledWith(mock.masterGain);
});
it("a second member of an already-open group does not rebuild the group bus", async () => {
@@ -749,7 +770,9 @@ describe("WebAudioTransport", () => {
await scheduleGrouped(transport, gen, "a", "orphan-group"); // no matching element
expect(firstGroupInput(mock).connect).toHaveBeenCalledWith(mock.masterGain);
const groupOutput = mock.gainNodes[2]!;
expect(firstGroupInput(mock).connect).toHaveBeenCalledWith(groupOutput);
expect(groupOutput.connect).toHaveBeenCalledWith(mock.masterGain);
});
it("group volume rides the group's own data-volume via its automation lane, not the member's", async () => {
@@ -784,6 +807,66 @@ describe("WebAudioTransport", () => {
// Still only one group-input gain ever created for "vo".
expect(mock.gainNodes.filter((n) => n === groupInput)).toHaveLength(1);
});
describe("groupLevel meter (B7)", () => {
it("groupLevel returns null for an unknown/idle group id", () => {
const { transport } = setupGroupTransport();
expect(transport.groupLevel("never-played")).toBeNull();
});
it("creates exactly one analyser per group, lazily, on first member", async () => {
const { transport, mock, gen } = setupGroupTransport();
expect(mock.analysers).toHaveLength(0);
await scheduleGrouped(transport, gen, "a", "vo");
expect(mock.analysers).toHaveLength(1);
expect(mock.analysers[0]!.fftSize).toBe(256); // level, not spectrum
await scheduleGrouped(transport, gen, "b", "vo");
expect(mock.analysers).toHaveLength(1); // second member reuses the bus
expect(transport.groupIds()).toEqual(["vo"]);
});
it("groupLevel reads RMS off the group's own analyser once a member is scheduled", async () => {
const { transport, mock, gen } = setupGroupTransport();
await scheduleGrouped(transport, gen, "a", "vo");
const analyser = mock.analysers[0]!;
analyser.getFloatTimeDomainData.mockImplementation((buf: Float32Array) => {
buf.fill(0.5);
});
const reading = transport.groupLevel("vo");
expect(reading).not.toBeNull();
expect(reading!.level).toBeCloseTo(0.5, 5);
expect(reading!.clipped).toBe(false);
});
it("flags clipped when any sample hits the ceiling", async () => {
const { transport, mock, gen } = setupGroupTransport();
await scheduleGrouped(transport, gen, "a", "vo");
const analyser = mock.analysers[0]!;
analyser.getFloatTimeDomainData.mockImplementation((buf: Float32Array) => {
buf.fill(0.1);
buf[0] = 0.995;
});
expect(transport.groupLevel("vo")!.clipped).toBe(true);
});
it("disposes the analyser along with the rest of the group bus", async () => {
const { transport, mock, gen } = setupGroupTransport();
await scheduleGrouped(transport, gen, "a", "vo");
const analyser = mock.analysers[0]!;
transport.destroy();
expect(analyser.disconnect).toHaveBeenCalled();
expect(transport.groupLevel("vo")).toBeNull();
});
});
});
describe("decodeAudioElement retry policy (late-asset self-heal)", () => {
+46 -2
View File
@@ -127,7 +127,10 @@ export class WebAudioTransport {
// group is scheduled. Lives for the session (mirrors `_masterGain`'s own
// lifecycle) rather than being torn down on every `stopAll()`, so replaying
// a group does not rebuild its chain; only `destroy()` disposes these.
private _groups = new Map<string, { input: GainNode; dispose(): void }>();
private _groups = new Map<
string,
{ input: GainNode; analyser: AnalyserNode; levelBuf: Float32Array; dispose(): void }
>();
// Composition-time reference frame: at AudioContext time `_rateAnchorCtx`,
// composition time was `_rateAnchorComp`, and time has been advancing at
// `_rate` composition-seconds per wallclock-second since.
@@ -294,22 +297,39 @@ export class WebAudioTransport {
if (!this._ctx || !this._masterGain) return null;
const input = this._ctx.createGain();
// Stable point the FX chain (or, when there's none, the dry passthrough —
// see `attachElementFxChain`'s `detach()`) always lands on before master,
// regardless of whether a chain is attached/detached/rebuilt later. B7's
// meter taps here. B5's group mute gain MUST splice in before `output`
// (between the FX chain and here), never after — the meter is defined to
// read the group's true, honestly-muted level (design doc §5), and this
// node is that contract's anchor.
const output = this._ctx.createGain();
output.connect(this._masterGain);
const analyser = this._ctx.createAnalyser();
analyser.fftSize = 256; // level, not spectrum
output.connect(analyser);
const groupEl = doc.getElementById(groupId);
const fx = attachElementFxChain(
this._ctx,
groupEl ?? { getAttribute: () => null },
input,
this._masterGain,
output,
timing,
);
if (groupEl) scheduleVolumeLane(groupEl, input, timing);
this._groups.set(groupId, {
input,
analyser,
levelBuf: new Float32Array(analyser.fftSize),
dispose: () => {
try {
fx?.dispose();
input.disconnect();
output.disconnect();
analyser.disconnect();
} catch {
// Already torn down.
}
@@ -318,6 +338,30 @@ export class WebAudioTransport {
return input;
}
/** Every group id currently routing audio (built lazily by `groupInput`
* a group with no active member yet has no entry here). */
groupIds(): string[] {
return [...this._groups.keys()];
}
/**
* RMS-ish level 0..1 and whether the last block clipped, for the group's
* meter or null when the group has no active member (idle/unknown).
* Reuses a per-group buffer; no per-frame allocation.
*/
groupLevel(groupId: string): { level: number; clipped: boolean } | null {
const g = this._groups.get(groupId);
if (!g) return null;
g.analyser.getFloatTimeDomainData(g.levelBuf);
let sumSquares = 0;
let clipped = false;
for (const sample of g.levelBuf) {
sumSquares += sample * sample;
if (Math.abs(sample) >= 0.99) clipped = true;
}
return { level: Math.sqrt(sumSquares / g.levelBuf.length), clipped };
}
/** Master, unless `el` belongs to a group then that group's bus (built on
* first use, per `groupInput`). */
private resolveDestination(
+1
View File
@@ -548,6 +548,7 @@ export function StudioApp() {
handleTimelineElementResize={timelineEditing.handleTimelineElementResize}
handleTimelineGroupResize={timelineEditing.handleTimelineGroupResize}
handleToggleTrackHidden={timelineEditing.handleToggleTrackHidden}
setAudioGroupAttribute={timelineEditing.setAudioGroupAttribute}
handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit}
handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit}
handleRazorSplit={timelineEditing.handleRazorSplit}
@@ -82,6 +82,7 @@ describe("EditorShell timeline selection sync", () => {
handleTimelineElementResize={vi.fn()}
handleTimelineGroupResize={vi.fn()}
handleToggleTrackHidden={vi.fn()}
setAudioGroupAttribute={{ setLive: vi.fn(), setQuiet: vi.fn() }}
handleBlockedTimelineEdit={vi.fn()}
handleTimelineElementSplit={vi.fn()}
handleRazorSplit={vi.fn()}
@@ -85,6 +85,7 @@ export function EditorShell({
handleTimelineElementResize,
handleTimelineGroupResize,
handleToggleTrackHidden,
setAudioGroupAttribute,
handleBlockedTimelineEdit,
handleTimelineElementSplit,
handleRazorSplit,
@@ -135,6 +136,7 @@ export function EditorShell({
handleTimelineElementResize,
handleTimelineGroupResize,
handleToggleTrackHidden,
setAudioGroupAttribute,
handleBlockedTimelineEdit,
handleTimelineElementSplit,
handleRazorSplit,
@@ -104,6 +104,7 @@ function renderCallbacks(): { callbacks: TimelineEditCallbacks; unmount: () => v
handleTimelineElementResize: vi.fn(),
handleTimelineGroupResize: vi.fn(),
handleToggleTrackHidden: vi.fn(),
setAudioGroupAttribute: { setLive: vi.fn(), setQuiet: vi.fn() },
handleBlockedTimelineEdit: vi.fn(),
handleTimelineElementSplit: vi.fn(),
handleRazorSplit: vi.fn(),
@@ -38,6 +38,10 @@ export interface TimelineEditCallbackDeps {
) => Promise<void> | void;
handleTimelineGroupResize: NonNullable<TimelineEditCallbacks["onResizeElements"]>;
handleToggleTrackHidden: (track: number, hidden: boolean) => Promise<void> | void;
setAudioGroupAttribute: {
setLive: (groupId: string, attr: string, value: string | null) => void;
setQuiet: (groupId: string, attr: string, value: string | null, label: string) => Promise<void>;
};
handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
@@ -99,6 +103,7 @@ export function useTimelineEditCallbacks({
handleTimelineElementResize,
handleTimelineGroupResize,
handleToggleTrackHidden,
setAudioGroupAttribute,
handleBlockedTimelineEdit,
handleTimelineElementSplit,
handleRazorSplit,
@@ -185,6 +190,8 @@ export function useTimelineEditCallbacks({
onResizeElement: handleTimelineElementResize,
onResizeElements: handleTimelineGroupResize,
onToggleTrackHidden: handleToggleTrackHidden,
onSetAudioGroupAttributeLive: setAudioGroupAttribute.setLive,
onSetAudioGroupAttributeQuiet: setAudioGroupAttribute.setQuiet,
onBlockedEditAttempt: handleBlockedTimelineEdit,
onSplitElement: handleTimelineElementSplit,
onRazorSplit: handleRazorSplit,
@@ -378,6 +385,7 @@ export function useTimelineEditCallbacks({
handleTimelineElementResize,
handleTimelineGroupResize,
handleToggleTrackHidden,
setAudioGroupAttribute,
handleBlockedTimelineEdit,
handleTimelineElementSplit,
handleRazorSplit,
@@ -34,6 +34,8 @@ export function TimelineEditProvider({
value.onMoveElements,
value.onResizeElement,
value.onToggleTrackHidden,
value.onSetAudioGroupAttributeLive,
value.onSetAudioGroupAttributeQuiet,
value.onBlockedEditAttempt,
value.onSplitElement,
value.onRazorSplit,
@@ -0,0 +1,167 @@
import { useCallback } from "react";
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
import { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher";
import {
applyPatchByTarget,
buildPatchTarget,
readFileContent,
type RecordEditInput,
} from "./timelineEditingHelpers";
import type {
MutableRef,
UseTimelineElementVisibilityEditingInput,
} from "./timelineTrackVisibility";
/** Direct DOM write on the group element for the gesture in progress no
* file write, no history entry (mirrors FxParamRow's live/commit split). */
function patchLiveGroupAttribute(
iframe: HTMLIFrameElement | null,
groupId: string,
attr: string,
value: string | null,
): void {
const target = iframe?.contentDocument?.getElementById(groupId);
if (!target) return;
if (value === null) target.removeAttribute(attr);
else target.setAttribute(attr, value);
}
interface SetAudioGroupAttributeInput {
projectId: string;
activeCompPath: string | null;
groupId: string;
attr: string;
value: string | null;
label: string;
previewIframe: HTMLIFrameElement | null;
writeProjectFile: (path: string, content: string) => Promise<void>;
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: MutableRef<number>;
pendingTimelineEditPathRef: MutableRef<Set<string>>;
}
/**
* Persist one attribute on the group element itself e.g. the bus strip's
* volume slider writing `data-volume` on release. One undo entry; mirrors
* `createAudioGroupAndAssignMembers`'s save shape but for a single element
* and attribute rather than a member-assignment sweep.
*/
async function setAudioGroupAttribute({
projectId,
activeCompPath,
groupId,
attr,
value,
label,
previewIframe,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
pendingTimelineEditPathRef,
}: SetAudioGroupAttributeInput): Promise<string[]> {
const targetPath = activeCompPath || "index.html";
const patchTarget = buildPatchTarget({ domId: groupId });
if (!patchTarget) return [];
const previousValue =
previewIframe?.contentDocument?.getElementById(groupId)?.getAttribute(attr) ?? null;
patchLiveGroupAttribute(previewIframe, groupId, attr, value);
const before = await readFileContent(projectId, targetPath);
if (readTagSnippetByTarget(before, patchTarget) === undefined) {
throw new Error(`Unable to patch audio group ${groupId} in ${targetPath}`);
}
const operation: PatchOperation = { type: "attribute", property: attr, value };
const patched = applyPatchByTarget(before, patchTarget, operation);
pendingTimelineEditPathRef.current.add(targetPath);
domEditSaveTimestampRef.current = Date.now();
try {
const changedPaths = await saveProjectFilesWithHistory({
projectId,
label,
kind: "timeline",
files: { [targetPath]: patched },
readFile: async (path) => (path === targetPath ? before : readFileContent(projectId, path)),
writeFile: writeProjectFile,
recordEdit,
});
domEditSaveTimestampRef.current = Date.now();
return changedPaths;
} catch (error) {
// The optimistic live write already ran; unwind it on a save failure so
// the preview doesn't show a value that never reached disk.
patchLiveGroupAttribute(previewIframe, groupId, attr, previousValue);
throw error;
}
}
/**
* B7's bus strip: live-write the group's own attribute (`data-volume`, so
* far B5's mute will reuse this too) while dragging, persist one undo entry
* on release. Unlike `useAudioGroupCarveAssignment`, this never touches
* member elements the group id doubles as its own DOM id, so no selection
* or expanded-rows resolution is needed to find it.
*/
export function useSetAudioGroupAttribute({
projectIdRef,
activeCompPath,
showToast,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
previewIframeRef,
pendingTimelineEditPathRef,
isRecordingRef,
}: UseTimelineElementVisibilityEditingInput): {
setLive: (groupId: string, attr: string, value: string | null) => void;
setQuiet: (groupId: string, attr: string, value: string | null, label: string) => Promise<void>;
} {
const setLive = useCallback(
(groupId: string, attr: string, value: string | null) => {
patchLiveGroupAttribute(previewIframeRef.current, groupId, attr, value);
},
[previewIframeRef],
);
const setQuiet = useCallback(
async (groupId: string, attr: string, value: string | null, label: string) => {
if (isRecordingRef?.current) {
showToast("Cannot edit timeline while recording", "error");
return;
}
const pid = projectIdRef.current;
if (!pid) return;
try {
await setAudioGroupAttribute({
projectId: pid,
activeCompPath,
groupId,
attr,
value,
label,
previewIframe: previewIframeRef.current,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
pendingTimelineEditPathRef,
});
} catch (error) {
console.error("[Timeline] Failed to set group attribute", error);
const message = error instanceof Error ? error.message : "Failed to update group";
showToast(message);
}
},
[
activeCompPath,
previewIframeRef,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
pendingTimelineEditPathRef,
isRecordingRef,
showToast,
projectIdRef,
],
);
return { setLive, setQuiet };
}
@@ -18,7 +18,7 @@ import {
type RecordEditInput,
} from "./timelineEditingHelpers";
interface MutableRef<T> {
export interface MutableRef<T> {
current: T;
}
@@ -68,7 +68,7 @@ interface UseTimelineTrackVisibilityEditingInput extends Omit<
forceReloadSdkSession?: () => void;
}
interface UseTimelineElementVisibilityEditingInput extends Omit<
export interface UseTimelineElementVisibilityEditingInput extends Omit<
ToggleTimelineElementHiddenInput,
"projectId" | "elementKey" | "hidden" | "previewIframe" | "timelineElements"
> {
@@ -0,0 +1,36 @@
/**
* A group's live meter reading (0..1 RMS-ish level, whether it just clipped),
* or null when the group is idle/unknown never zero for "not playing yet".
*
* Mirrors useLivePlayheadTime's shape: the runtime posts readings at its own
* cadence (only while playing see `postGroupLevels` in init.ts), this just
* throttles the re-render, it does not add its own polling loop.
*/
import { useEffect, useRef, useState } from "react";
import { groupLevels, type GroupLevelReading } from "../player/store/groupLevels";
const THROTTLE_MS = 33;
export function useGroupLevel(groupId: string): GroupLevelReading | null {
const latestRef = useRef<GroupLevelReading | null>(groupLevels.get().get(groupId) ?? null);
const [, forceRender] = useState(0);
useEffect(() => {
let timerId: ReturnType<typeof setTimeout> | 0 = 0;
const unsubscribe = groupLevels.subscribe((levels) => {
latestRef.current = levels.get(groupId) ?? null;
if (!timerId) {
timerId = setTimeout(() => {
timerId = 0;
forceRender((v) => v + 1);
}, THROTTLE_MS);
}
});
return () => {
unsubscribe();
if (timerId) clearTimeout(timerId);
};
}, [groupId]);
return latestRef.current;
}
@@ -27,6 +27,7 @@ import {
} from "./timelineTimingSync";
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing";
import { useSetAudioGroupAttribute } from "./timelineAudioGroupVolume";
import {
useAudioGroupCarveAssignment,
useTimelineElementVisibilityEditing,
@@ -401,6 +402,18 @@ export function useTimelineEditing({
isRecordingRef,
});
const setAudioGroupAttribute = useSetAudioGroupAttribute({
projectIdRef,
activeCompPath,
showToast,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
previewIframeRef,
pendingTimelineEditPathRef,
isRecordingRef,
});
// fallow-ignore-next-line complexity
const handleTimelineElementsDelete = useCallback(
// fallow-ignore-next-line complexity
@@ -572,6 +585,7 @@ export function useTimelineEditing({
handleToggleTrackHidden,
handleToggleElementHidden,
handleAutoGroupCarveSources,
setAudioGroupAttribute,
handleTimelineElementDelete,
handleTimelineElementsDelete,
handleTimelineElementSplit: handleRazorSplit,
@@ -0,0 +1,146 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TimelineGroupBusStrip } from "./TimelineGroupBusStrip";
import { defaultTimelineTheme } from "./timelineTheme";
import { groupLevels } from "../store/groupLevels";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
groupLevels.notify(new Map());
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.useRealTimers();
});
function renderStrip(overrides: Partial<React.ComponentProps<typeof TimelineGroupBusStrip>> = {}) {
const onVolumeChange = vi.fn();
const onVolumeCommit = vi.fn();
act(() => {
root.render(
<TimelineGroupBusStrip
groupId="vo"
volume={1}
memberLabels={["vo-1", "vo-2"]}
onVolumeChange={onVolumeChange}
onVolumeCommit={onVolumeCommit}
theme={defaultTimelineTheme}
{...overrides}
/>,
);
});
return { onVolumeChange, onVolumeCommit };
}
function slider(): HTMLInputElement {
return container.querySelector('input[type="range"]') as HTMLInputElement;
}
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
/** React tracks the DOM's own value setter to detect real changes a plain
* `input.value = ...` assignment is invisible to it, so onChange never
* fires. Go through the native setter, same as this codebase's other
* range/text input tests (e.g. propertyPanelFlatStyleSections.test.tsx). */
function setSliderValue(input: HTMLInputElement, value: string) {
nativeInputValueSetter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
describe("TimelineGroupBusStrip", () => {
it('renders "Holds …" from the member labels, comma-joined', () => {
renderStrip({ memberLabels: ["vo-1", "vo-2"] });
expect(container.textContent).toContain("Holds vo-1, vo-2");
});
it("falls back to a neutral line when a group has no members yet", () => {
renderStrip({ memberLabels: [] });
expect(container.textContent).toContain("Holds nothing yet");
});
it("live-writes on every drag tick, but only commits once on release", () => {
const { onVolumeChange, onVolumeCommit } = renderStrip({ volume: 1 });
const input = slider();
act(() => setSliderValue(input, "1.2"));
act(() => setSliderValue(input, "1.5"));
expect(onVolumeChange).toHaveBeenCalledTimes(2);
expect(onVolumeChange).toHaveBeenLastCalledWith(1.5);
expect(onVolumeCommit).not.toHaveBeenCalled();
act(() => {
input.value = "1.5";
input.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
});
expect(onVolumeCommit).toHaveBeenCalledTimes(1);
expect(onVolumeCommit).toHaveBeenCalledWith(1.5);
});
it("clamps the volume slider to the 0..2 range", () => {
const { onVolumeChange } = renderStrip();
const input = slider();
expect(input.min).toBe("0");
expect(input.max).toBe("2");
act(() => setSliderValue(input, "2"));
expect(onVolumeChange).toHaveBeenLastCalledWith(2);
});
it("the level bar tracks a live reading and shows nothing extra when it isn't clipping", () => {
vi.useFakeTimers();
renderStrip();
act(() => {
groupLevels.notify(new Map([["vo", { level: 0.4, clipped: false }]]));
vi.advanceTimersByTime(40); // past useGroupLevel's 33ms throttle
});
expect(container.textContent).not.toContain("Too loud");
});
it('shows "Too loud" while clipped and holds it for ~2s after clipping stops', () => {
vi.useFakeTimers();
renderStrip();
act(() => {
groupLevels.notify(new Map([["vo", { level: 0.9, clipped: true }]]));
vi.advanceTimersByTime(40);
});
expect(container.textContent).toContain("Too loud");
// Clipping stops — the warning must still hold for a couple seconds.
act(() => {
groupLevels.notify(new Map([["vo", { level: 0.2, clipped: false }]]));
vi.advanceTimersByTime(40);
});
expect(container.textContent).toContain("Too loud");
act(() => {
vi.advanceTimersByTime(2001);
});
expect(container.textContent).not.toContain("Too loud");
});
it("never renders a dB number anywhere in the strip (design constraint: no dB, no peak-hold readout)", () => {
vi.useFakeTimers();
renderStrip({ volume: 1.5 });
act(() => {
groupLevels.notify(new Map([["vo", { level: 0.9, clipped: true }]]));
vi.advanceTimersByTime(40);
});
expect(container.textContent ?? "").not.toMatch(/dB/i);
});
});
@@ -0,0 +1,103 @@
/**
* B7: the group's own volume slider + a living level bar + "Holds …" the
* bus, not the mechanism. No dB numbers, no peak-hold readout, no routing
* row (groups doc §5, casual-user section) a slider, a bar that moves with
* the sound, and the words "Too loud" when it clips.
*/
import { useEffect, useRef, useState } from "react";
import { useGroupLevel } from "../../hooks/useGroupLevel";
import { STRIP_H, TRACK_H } from "./timelineLayout";
import type { TimelineTheme } from "./timelineTheme";
/** How long "Too loud" stays lit after the last clipped block. */
const CLIP_HOLD_MS = 2000;
function clampVolume(value: number): number {
return Math.min(2, Math.max(0, value));
}
interface TimelineGroupBusStripProps {
groupId: string;
volume: number;
memberLabels: readonly string[];
onVolumeChange: (value: number) => void;
onVolumeCommit: (value: number) => void;
theme: TimelineTheme;
}
export function TimelineGroupBusStrip({
groupId,
volume,
memberLabels,
onVolumeChange,
onVolumeCommit,
theme,
}: TimelineGroupBusStripProps) {
const [dragValue, setDragValue] = useState<number | null>(null);
const reading = useGroupLevel(groupId);
const [clipped, setClipped] = useState(false);
const clipTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!reading?.clipped) return;
setClipped(true);
if (clipTimerRef.current) clearTimeout(clipTimerRef.current);
clipTimerRef.current = setTimeout(() => setClipped(false), CLIP_HOLD_MS);
}, [reading?.clipped]);
useEffect(
() => () => {
if (clipTimerRef.current) clearTimeout(clipTimerRef.current);
},
[],
);
const shownVolume = dragValue ?? volume;
const level = Math.min(1, reading?.level ?? 0);
const holdsText =
memberLabels.length > 0 ? `Holds ${memberLabels.join(", ")}` : "Holds nothing yet";
return (
<div
className="absolute left-0 right-0 flex items-center gap-2 px-2 text-[10px] text-white/70"
style={{ top: TRACK_H, height: STRIP_H }}
>
<input
type="range"
aria-label="Group volume"
min={0}
max={2}
step={0.01}
value={shownVolume}
className="h-1 w-20 shrink-0 accent-[#3CE6AC]"
onChange={(event) => {
const next = clampVolume(Number(event.currentTarget.value));
setDragValue(next);
onVolumeChange(next);
}}
onPointerUp={(event) => {
const next = clampVolume(Number(event.currentTarget.value));
setDragValue(null);
onVolumeCommit(next);
}}
/>
<div
className="relative h-1.5 w-16 shrink-0 overflow-hidden rounded-full"
style={{ background: theme.gutterBorder }}
aria-hidden="true"
>
<div
className="absolute inset-y-0 left-0 rounded-full"
style={{
width: `${level * 100}%`,
background: clipped ? "#ff5c5c" : "#3CE6AC",
}}
/>
</div>
{clipped && <span className="shrink-0 font-medium text-[#ff5c5c]">Too loud</span>}
<span className="min-w-0 flex-1 truncate" title={holdsText}>
{holdsText}
</span>
</div>
);
}
@@ -4,8 +4,10 @@ import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
import { TimelineTrackRow } from "./TimelineTrackRow";
import { TimelineGroupHeader } from "./TimelineGroupHeader";
import { TimelineGroupBusStrip } from "./TimelineGroupBusStrip";
import { groupAutomationLanes } from "./automationLaneData";
import { LABEL_COL_W } from "./timelineLayout";
import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
interface TimelineGroupRowProps {
index: number;
@@ -46,6 +48,12 @@ export function TimelineGroupRow({
const memberElements = group.memberTracks.flatMap(
(track) => tracks.find(([t]) => t === track)?.[1] ?? [],
);
const memberLabels = group.memberTracks.map((track, i) => {
const owner = tracks.find(([t]) => t === track)?.[1]?.find((el) => el.audioGroup);
return owner?.label ?? owner?.id ?? `track ${i + 1}`;
});
const isLaneOpen = expandedLaneOwnerIds.has(group.id);
const { onSetAudioGroupAttributeLive, onSetAudioGroupAttributeQuiet } = useTimelineEditContext();
return (
<TimelineTrackRow
index={index}
@@ -67,11 +75,30 @@ export function TimelineGroupRow({
isExpanded={expandedGroupIds.has(group.id)}
onToggleExpanded={() => toggleGroupExpanded(group.id)}
laneCount={groupAutomationLanes(memberElements).length}
isLaneOpen={expandedLaneOwnerIds.has(group.id)}
isLaneOpen={isLaneOpen}
onToggleLanes={() => toggleLaneOwnerExpanded(group.id)}
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
theme={theme}
/>
{isLaneOpen && (
<TimelineGroupBusStrip
groupId={group.id}
volume={group.volume}
memberLabels={memberLabels}
onVolumeChange={(value) =>
onSetAudioGroupAttributeLive?.(group.id, "data-volume", String(value))
}
onVolumeCommit={(value) =>
onSetAudioGroupAttributeQuiet?.(
group.id,
"data-volume",
String(value),
"Set group volume",
)
}
theme={theme}
/>
)}
</TimelineTrackRow>
);
}
@@ -68,6 +68,15 @@ export interface TimelineEditCallbacks {
options?: { coalesceKey?: string },
) => Promise<void> | void;
onToggleTrackHidden?: (track: number, hidden: boolean) => Promise<void> | void;
/** B7's bus strip: live-write the group's own attribute while dragging. */
onSetAudioGroupAttributeLive?: (groupId: string, attr: string, value: string | null) => void;
/** ...and persist one undo entry on release. */
onSetAudioGroupAttributeQuiet?: (
groupId: string,
attr: string,
value: string | null,
label: string,
) => Promise<void>;
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
@@ -7,10 +7,10 @@ export const GUTTER = 32;
export const LABEL_COL_W = 232;
export const TRACK_H = 48;
export const LANE_H = 28;
export const STRIP_H = 40; // group bus strip (B7) — one fixed block, not per-property like LANE_H
export const RULER_H = 24;
export const CLIP_Y = 3;
export const CLIP_HANDLE_W = 18;
export interface TimelineBeatEntry {
readonly index: number;
readonly time: number;
@@ -16,31 +16,68 @@ export interface TimelineTrackGroupInfo {
anchorKey: number;
/** Member track numbers, ascending. */
memberTracks: number[];
/** The group element's `data-volume`, mirrored from a member's parse (B7's slider). */
volume: number;
}
interface GroupMembership {
trackToGroupId: Map<number, string>;
memberTracksByGroup: Map<string, number[]>;
labelByGroup: Map<string, string>;
volumeByGroup: Map<string, number>;
}
/** Which track belongs to which group, and each group's label — one pass over raw tracks. */
/** Which track belongs to which group, and each group's label/volume — one pass over raw tracks. */
function resolveGroupMembership(rawTracks: [number, TimelineElement[]][]): GroupMembership {
const trackToGroupId = new Map<number, string>();
const memberTracksByGroup = new Map<string, number[]>();
const labelByGroup = new Map<string, string>();
const volumeByGroup = new Map<string, number>();
for (const [trackNum, elements] of rawTracks) {
const owner = elements.find((el) => el.audioGroup);
if (!owner?.audioGroup) continue;
trackToGroupId.set(trackNum, owner.audioGroup);
if (!labelByGroup.has(owner.audioGroup)) {
labelByGroup.set(owner.audioGroup, owner.audioGroupLabel ?? owner.audioGroup);
volumeByGroup.set(owner.audioGroup, owner.audioGroupVolume ?? 1);
}
const members = memberTracksByGroup.get(owner.audioGroup) ?? [];
members.push(trackNum);
memberTracksByGroup.set(owner.audioGroup, members);
}
return { trackToGroupId, memberTracksByGroup, labelByGroup };
return { trackToGroupId, memberTracksByGroup, labelByGroup, volumeByGroup };
}
/** One group's resolved row info, built once the first time its id is seen. */
function buildGroupInfo(
groupId: string,
fallbackTrackNum: number,
membership: GroupMembership,
): TimelineTrackGroupInfo {
const memberTracks = [...(membership.memberTracksByGroup.get(groupId) ?? [])].sort(
(a, b) => a - b,
);
return {
id: groupId,
label: membership.labelByGroup.get(groupId) ?? groupId,
anchorKey: (memberTracks[0] ?? fallbackTrackNum) - 0.5,
memberTracks,
volume: membership.volumeByGroup.get(groupId) ?? 1,
};
}
/** Push a group's synthetic anchor row plus its members' rows, contiguously. */
function emitGroupRows(
info: TimelineTrackGroupInfo,
rawByTrack: ReadonlyMap<number, TimelineElement[]>,
trackGroupOf: Map<number, TimelineTrackGroupInfo>,
tracks: [number, TimelineElement[]][],
): void {
tracks.push([info.anchorKey, []]);
for (const member of info.memberTracks) {
trackGroupOf.set(member, info);
tracks.push([member, rawByTrack.get(member) ?? []]);
}
}
/**
@@ -54,7 +91,7 @@ function groupTimelineTracks(rawTracks: [number, TimelineElement[]][]): {
groups: TimelineTrackGroupInfo[];
trackGroupOf: Map<number, TimelineTrackGroupInfo>;
} {
const { trackToGroupId, memberTracksByGroup, labelByGroup } = resolveGroupMembership(rawTracks);
const membership = resolveGroupMembership(rawTracks);
const rawByTrack = new Map(rawTracks);
const groups: TimelineTrackGroupInfo[] = [];
const trackGroupOf = new Map<number, TimelineTrackGroupInfo>();
@@ -62,26 +99,16 @@ function groupTimelineTracks(rawTracks: [number, TimelineElement[]][]): {
const tracks: [number, TimelineElement[]][] = [];
for (const [trackNum, elements] of rawTracks) {
const groupId = trackToGroupId.get(trackNum);
const groupId = membership.trackToGroupId.get(trackNum);
if (!groupId) {
tracks.push([trackNum, elements]);
continue;
}
if (emitted.has(groupId)) continue;
emitted.add(groupId);
const memberTracks = [...(memberTracksByGroup.get(groupId) ?? [])].sort((a, b) => a - b);
const info: TimelineTrackGroupInfo = {
id: groupId,
label: labelByGroup.get(groupId) ?? groupId,
anchorKey: (memberTracks[0] ?? trackNum) - 0.5,
memberTracks,
};
const info = buildGroupInfo(groupId, trackNum, membership);
groups.push(info);
tracks.push([info.anchorKey, []]);
for (const member of memberTracks) {
trackGroupOf.set(member, info);
tracks.push([member, rawByTrack.get(member) ?? []]);
}
emitGroupRows(info, rawByTrack, trackGroupOf, tracks);
}
return { tracks, groups, trackGroupOf };
}
@@ -7,12 +7,14 @@ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import type { DraggedClipState } from "./timelineClipDragTypes";
import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
import {
STRIP_H,
TRACK_H,
createTimelineRowGeometry,
type TimelineRowGeometry,
trackHeights,
type TimelineTrackHeightClip,
} from "./timelineLayout";
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
export { getTrackStyle } from "./timelineIcons";
@@ -127,13 +129,35 @@ function computeLaneCounts(
return laneCounts;
}
/** Group anchor rows have no elements of their own (`groupTimelineTracks`
* pushes them as `[anchorKey, []]`), so `trackHeights` which only ever
* looks at a row's clips always gives them TRACK_H. Override those
* specific rows post-hoc: TRACK_H while collapsed, +STRIP_H once the
* group's own `` (bus strip) is open. */
function applyGroupStripHeights(
tracks: readonly (readonly [number, readonly TimelineElement[]])[],
rowHeights: number[],
groups: readonly TimelineTrackGroupInfo[],
expandedLaneOwnerIds: ReadonlySet<string>,
): number[] {
if (groups.length === 0) return rowHeights;
const groupByAnchor = new Map(groups.map((group) => [group.anchorKey, group]));
return tracks.map(([track], index) => {
const group = groupByAnchor.get(track);
if (!group || !expandedLaneOwnerIds.has(group.id)) return rowHeights[index] ?? TRACK_H;
return TRACK_H + STRIP_H;
});
}
function useTimelineRowHeights(
tracks: [number, TimelineElement[]][],
gsapAnimations: Map<string, GsapAnimation[]>,
selectedElementId: string | null,
selectedElementIds: ReadonlySet<string>,
groups: readonly TimelineTrackGroupInfo[],
) {
const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
const expandedLaneOwnerIds = usePlayerStore((s) => s.expandedLaneOwnerIds);
const { laneCounts, rowGeometry } = useMemo(() => {
const laneCounts = computeLaneCounts(tracks, gsapAnimations);
// Keyframe lanes follow only the active clip, so a track with several
@@ -163,7 +187,12 @@ function useTimelineRowHeights(
},
];
});
const rowHeights = trackHeights(heightTracks, expandedClipIds);
const rowHeights = applyGroupStripHeights(
tracks,
trackHeights(heightTracks, expandedClipIds),
groups,
expandedLaneOwnerIds,
);
return {
laneCounts,
rowGeometry: createTimelineRowGeometry(
@@ -171,7 +200,15 @@ function useTimelineRowHeights(
rowHeights,
),
};
}, [expandedClipIds, gsapAnimations, tracks, selectedElementId, selectedElementIds]);
}, [
expandedClipIds,
expandedLaneOwnerIds,
gsapAnimations,
groups,
tracks,
selectedElementId,
selectedElementIds,
]);
const rowGeometryRef = useRef<TimelineRowGeometry>(rowGeometry);
rowGeometryRef.current = rowGeometry;
return {
@@ -197,6 +234,7 @@ export function useTimelineTrackLayout(
gsapAnimations,
selectedElementId,
selectedElementIds,
groups,
);
return {
@@ -49,6 +49,7 @@ import { applyCachedSourceDurations, probeMissingSourceDurations } from "../lib/
import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../lib/playbackSeek";
import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
import { acceptStudioRuntimeMessage } from "../lib/runtimeProtocol";
import { groupLevels, parseGroupLevelsMessage } from "../store/groupLevels";
import { timelineElementsChanged } from "./timelinePlayerSync";
export function useTimelinePlayer() {
@@ -500,6 +501,11 @@ export function useTimelinePlayer() {
if (data?.source === "hf-preview") {
if (!acceptStudioRuntimeMessage(data)) return;
}
if (data?.source === "hf-preview" && data?.type === "group-levels") {
const levels = parseGroupLevelsMessage(data);
if (levels) groupLevels.notify(levels);
return;
}
if (data?.source === "hf-preview" && data?.type === "state") {
try {
if (usePlayerStore.getState().elements.length === 0) {
+22 -10
View File
@@ -70,16 +70,24 @@ function resolveClipTag(clip: ClipManifestClip): string {
// One `<hf-audio-group>` scan per document, not per clip — resolveAudioGroups
// walks the whole tree, and a parse touches every clip in it.
const groupLabelCache = new WeakMap<Document, Map<string, string>>();
const groupInfoCache = new WeakMap<Document, Map<string, { label: string; volume: number }>>();
function groupLabelFor(doc: Document | null | undefined, groupId: string): string {
if (!doc) return groupId;
let labels = groupLabelCache.get(doc);
if (!labels) {
labels = new Map(resolveAudioGroups(doc).map((group) => [group.id, group.label]));
groupLabelCache.set(doc, labels);
function groupInfoFor(
doc: Document | null | undefined,
groupId: string,
): { label: string; volume: number } {
if (!doc) return { label: groupId, volume: 1 };
let info = groupInfoCache.get(doc);
if (!info) {
info = new Map(
resolveAudioGroups(doc).map((group) => [
group.id,
{ label: group.label, volume: group.volume },
]),
);
groupInfoCache.set(doc, info);
}
return labels.get(groupId) ?? groupId;
return info.get(groupId) ?? { label: groupId, volume: 1 };
}
// fallow-ignore-next-line complexity
@@ -156,7 +164,9 @@ export function createTimelineElementFromManifestClip(params: {
const audioGroup = hostEl.getAttribute("data-audio-group");
if (audioGroup) {
entry.audioGroup = audioGroup;
entry.audioGroupLabel = groupLabelFor(doc ?? hostEl.ownerDocument, audioGroup);
const info = groupInfoFor(doc ?? hostEl.ownerDocument, audioGroup);
entry.audioGroupLabel = info.label;
entry.audioGroupVolume = info.volume;
}
const fxChain = hostEl.getAttribute("data-fx-chain");
if (fxChain) entry.fxChain = fxChain;
@@ -379,7 +389,9 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
const domAudioGroup = el.getAttribute("data-audio-group");
if (domAudioGroup) {
entry.audioGroup = domAudioGroup;
entry.audioGroupLabel = groupLabelFor(doc, domAudioGroup);
const domGroupInfo = groupInfoFor(doc, domAudioGroup);
entry.audioGroupLabel = domGroupInfo.label;
entry.audioGroupVolume = domGroupInfo.volume;
}
// Sub-compositions
@@ -0,0 +1,33 @@
// ponytail: mirrors liveTime.ts's plain pub-sub — same throttle-at-the-edge shape.
/** A group's live meter reading, or absent when idle/unknown (never zero). */
export type GroupLevelReading = { level: number; clipped: boolean };
type Listener = (levels: ReadonlyMap<string, GroupLevelReading>) => void;
const listeners = new Set<Listener>();
let latest: ReadonlyMap<string, GroupLevelReading> = new Map();
export const groupLevels = {
notify: (levels: ReadonlyMap<string, GroupLevelReading>) => {
latest = levels;
listeners.forEach((listener) => listener(levels));
},
subscribe: (listener: Listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
get: () => latest,
};
/** Turns the runtime's `group-levels` postMessage payload into the map `groupLevels.notify` wants. */
export function parseGroupLevelsMessage(
data: unknown,
): ReadonlyMap<string, GroupLevelReading> | null {
const levels = (data as { levels?: unknown } | null)?.levels;
if (!Array.isArray(levels)) return null;
return new Map(
(levels as Array<{ groupId: string; level: number; clipped: boolean }>).map((entry) => [
entry.groupId,
{ level: entry.level, clipped: entry.clipped },
]),
);
}
@@ -71,6 +71,8 @@ export interface TimelineElement {
audioGroup?: string;
/** The owning group's `data-label` (falls back to its id) — resolved once per parse. */
audioGroupLabel?: string;
/** The owning group's `data-volume` (defaults to 1) — resolved once per parse. */
audioGroupVolume?: number;
/**
* Set by useExpandedTimelineElements on an inline-expanded sub-composition
* child: the absolute master-timeline start of the sub-comp host the child