mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
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:
co-authored by
Claude Sonnet 5
parent
8052f3e68f
commit
e1c9b50948
@@ -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
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user