diff --git a/packages/core/src/audioGroups.ts b/packages/core/src/audioGroups.ts index ad96b91822..50a3351d5c 100644 --- a/packages/core/src/audioGroups.ts +++ b/packages/core/src/audioGroups.ts @@ -52,8 +52,14 @@ export function resolveAudioGroups(root: ParentNode): HfAudioGroup[] { } /** The group a member belongs to, or null. Groups do not nest — this ignores - * `data-audio-group` on an `` element itself. */ + * `data-audio-group` on an `` element itself. + * + * Tolerant of objects that only partially implement `Element` (test doubles + * for `HTMLMediaElement` commonly do) — anything missing `tagName` or + * `getAttribute` simply has no group, mirroring `readChain`'s style in + * `runtime/audioFx.ts`. */ export function audioGroupOf(el: Element): string | null { + if (typeof el.tagName !== "string") return null; if (el.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG) return null; - return el.getAttribute(HF_AUDIO_GROUP_ATTR); + return typeof el.getAttribute === "function" ? el.getAttribute(HF_AUDIO_GROUP_ATTR) : null; } diff --git a/packages/core/src/runtime/webAudioTransport.test.ts b/packages/core/src/runtime/webAudioTransport.test.ts index 35f935bc5a..f1f31f5780 100644 --- a/packages/core/src/runtime/webAudioTransport.test.ts +++ b/packages/core/src/runtime/webAudioTransport.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from "vitest"; +import { beforeEach, describe, it, expect, vi } from "vitest"; import { WebAudioTransport } from "./webAudioTransport"; function createMockAudioContext(currentTime = 100) { @@ -446,6 +446,167 @@ describe("WebAudioTransport", () => { }); }); + describe("group routing (preview)", () => { + // Real jsdom elements — `groupInput` looks the group up via + // `el.ownerDocument.getElementById`, and `audioGroupOf` reads `tagName` / + // `getAttribute`, neither of which the plain-object mocks above implement. + function createGroupMockAudioContext(currentTime = 100) { + const gainNodes: { + gain: { value: number }; + connect: ReturnType; + disconnect: ReturnType; + }[] = []; + const masterGain = { gain: { value: 1 }, connect: vi.fn(), disconnect: vi.fn() }; + const ctx = { + currentTime, + state: "running", + resume: vi.fn(), + createBufferSource: vi.fn(() => ({ + buffer: null as AudioBuffer | null, + playbackRate: { value: 1 }, + start: vi.fn(), + stop: vi.fn(), + disconnect: vi.fn(), + connect: vi.fn(), + addEventListener: vi.fn(), + })), + createGain: vi.fn(() => { + const node = { gain: { value: 1 }, connect: vi.fn(), disconnect: vi.fn() }; + gainNodes.push(node); + return node; + }), + destination: {}, + close: vi.fn(), + }; + return { ctx, gainNodes, masterGain }; + } + + function setupGroupTransport(currentTime = 100) { + const transport = new WebAudioTransport(); + const mock = createGroupMockAudioContext(currentTime); + (transport as unknown as { _ctx: unknown })._ctx = mock.ctx; + (transport as unknown as { _masterGain: unknown })._masterGain = mock.masterGain; + const gen = transport.startGeneration(); + return { transport, mock, gen }; + } + + function groupedAudioEl(id: string, groupId?: string): HTMLMediaElement { + const el = document.createElement("audio"); + el.id = id; + if (groupId) el.setAttribute("data-audio-group", groupId); + document.body.appendChild(el); + return el as unknown as HTMLMediaElement; + } + + /** Create a grouped member and schedule it in one step — the shape every + * test below needs, differing only in id/group/generation. */ + async function scheduleGrouped( + transport: WebAudioTransport, + gen: number, + id: string, + groupId?: string, + ): Promise { + const el = groupedAudioEl(id, groupId); + await transport.schedulePlayback(el, mockBuffer, 0, 0, 0, 1, gen); + return el; + } + + /** The group's own input gain is built lazily on the first member — + * index 1 in creation order (that member's gain is index 0). */ + const firstGroupInput = (mock: ReturnType) => + mock.gainNodes[1]!; + + beforeEach(() => { + document.body.innerHTML = ""; + }); + + it("routes an ungrouped member straight to master, unchanged", async () => { + const { transport, mock, gen } = setupGroupTransport(); + + await scheduleGrouped(transport, gen, "solo"); + + // One gain node — the member's own — connected directly to master. + expect(mock.gainNodes).toHaveLength(1); + expect(mock.gainNodes[0]!.connect).toHaveBeenCalledWith(mock.masterGain); + }); + + it("two members of the same group land on ONE shared group gain, not master directly", async () => { + const { transport, mock, gen } = setupGroupTransport(); + + 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); + const groupInput = firstGroupInput(mock); + const aGain = mock.gainNodes[0]!; + const bGain = mock.gainNodes[2]!; + + // Neither member connects straight to master — both feed the shared bus. + expect(aGain.connect).toHaveBeenCalledWith(groupInput); + expect(bGain.connect).toHaveBeenCalledWith(groupInput); + 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 ``. + expect(groupInput.connect).toHaveBeenCalledWith(mock.masterGain); + }); + + it("a second member of an already-open group does not rebuild the group bus", async () => { + const { transport, mock, gen } = setupGroupTransport(); + + await scheduleGrouped(transport, gen, "a", "vo"); + const gainCountAfterFirst = mock.gainNodes.length; // a-gain + group-input + await scheduleGrouped(transport, gen, "b", "vo"); + + // Only b's own gain is new — no second group-input gain minted. + expect(mock.gainNodes.length).toBe(gainCountAfterFirst + 1); + }); + + it("a group id with no matching element still gets a flat bus", async () => { + const { transport, mock, gen } = setupGroupTransport(); + + await scheduleGrouped(transport, gen, "a", "orphan-group"); // no matching element + + expect(firstGroupInput(mock).connect).toHaveBeenCalledWith(mock.masterGain); + }); + + it("group volume rides the group's own data-volume via its automation lane, not the member's", async () => { + document.body.innerHTML = ``; + const { transport, gen } = setupGroupTransport(); + + // No throw wiring the group's automation reader against a real + // element that carries no fx/automation attrs. + await expect(scheduleGrouped(transport, gen, "a", "vo")).resolves.not.toBeNull(); + }); + + it("destroy() disposes every group bus", async () => { + const { transport, mock, gen } = setupGroupTransport(); + await scheduleGrouped(transport, gen, "a", "vo"); + const groupInput = firstGroupInput(mock); + + transport.destroy(); + + expect(groupInput.disconnect).toHaveBeenCalled(); + }); + + it("stopAll() does NOT dispose group buses — replaying the group does not rebuild it", async () => { + const { transport, mock, gen } = setupGroupTransport(); + await scheduleGrouped(transport, gen, "a", "vo"); + const groupInput = firstGroupInput(mock); + + transport.stopAll(); + expect(groupInput.disconnect).not.toHaveBeenCalled(); + + const gen2 = transport.startGeneration(); + await scheduleGrouped(transport, gen2, "a", "vo"); + // Still only one group-input gain ever created for "vo". + expect(mock.gainNodes.filter((n) => n === groupInput)).toHaveLength(1); + }); + }); + describe("decodeAudioElement retry policy (late-asset self-heal)", () => { function transportWithDecode(decodeImpl: () => Promise) { const transport = new WebAudioTransport(); diff --git a/packages/core/src/runtime/webAudioTransport.ts b/packages/core/src/runtime/webAudioTransport.ts index e2e2dc66ed..b38dd1e3e8 100644 --- a/packages/core/src/runtime/webAudioTransport.ts +++ b/packages/core/src/runtime/webAudioTransport.ts @@ -5,6 +5,7 @@ import { type AutomationTiming, } from "../audio/audioFxAutomation.js"; import { VOLUME_RANGE } from "../audioAutomation.js"; +import { audioGroupOf } from "../audioGroups.js"; import { swallow } from "./diagnostics"; import { getDebugSurface } from "./globals.js"; @@ -66,9 +67,12 @@ function startBoundedSource( /** * The volume lane rides the fader, after the effects — where a DAW puts it, * and the order the render bakes it in. + * + * Typed against the attribute reader rather than `HTMLMediaElement` so a group + * bus (an ``, not a media element) can ride the same path. */ function scheduleVolumeLane( - el: HTMLMediaElement, + el: { getAttribute?(name: string): string | null }, gainNode: GainNode, timing: AutomationTiming, ): void { @@ -99,6 +103,11 @@ export class WebAudioTransport { private _failedSrcs = new Set(); private _activeSources: ScheduledSource[] = []; private _masterGain: GainNode | null = null; + // One shared bus per group id, lazily built the first time a member of that + // 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(); // 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. @@ -179,6 +188,97 @@ export class WebAudioTransport { return this._playGeneration; } + /** + * The gain a grouped member's signal should land on, building it on first + * use. A group's clock is COMPOSITION time (design doc §1.3) — it has no + * `data-start`, and a missing start parses as 0, which is exactly + * composition time — so its chain and volume lane are scheduled once here + * against that zero-offset timing, not the member's own clip-local timing. + * A group id with no matching `` element still gets a bus + * (flat, no chain) so a hand-authored `data-audio-group` degrades to a + * plain sum rather than losing the member's audio. + */ + private groupInput(groupId: string, doc: Document, timing: AutomationTiming): GainNode | null { + const existing = this._groups.get(groupId); + if (existing) return existing.input; + if (!this._ctx || !this._masterGain) return null; + + const input = this._ctx.createGain(); + const groupEl = doc.getElementById(groupId); + const fx = attachElementFxChain( + this._ctx, + groupEl ?? { getAttribute: () => null }, + input, + this._masterGain, + timing, + ); + if (groupEl) scheduleVolumeLane(groupEl, input, timing); + + this._groups.set(groupId, { + input, + dispose: () => { + try { + fx?.dispose(); + input.disconnect(); + } catch { + // Already torn down. + } + }, + }); + return input; + } + + /** Master, unless `el` belongs to a group — then that group's bus (built on + * first use, per `groupInput`). */ + private resolveDestination( + el: HTMLMediaElement, + scheduledAt: number, + compositionTime: number, + safeRate: number, + ): GainNode | null { + if (!this._masterGain) return null; + const groupId = audioGroupOf(el); + if (!groupId) return this._masterGain; + const groupTiming: AutomationTiming = { scheduledAt, elapsed: compositionTime, rate: safeRate }; + return this.groupInput(groupId, el.ownerDocument, groupTiming) ?? this._masterGain; + } + + /** + * The graph goes with it. Splicing alone left the FX handle alive and then + * UNREACHABLE — `stopAll()` disposes by walking `_activeSources`, which the + * splice just emptied of this entry. Every clip that finished naturally + * leaked its MutationObserver for the session, and each one still answered + * later `data-fx-chain` edits by rebuilding a whole graph (impulse response, + * chorus/phaser oscillators started and never stopped) around a dead + * source. Not disposed when the index is already -1: `stopAll()` has + * already done it, and `stop()` is what fired this event. + */ + private handleSourceEnded( + sourceNode: AudioBufferSourceNode, + scheduled: ScheduledSource, + el: HTMLMediaElement, + priorMuted: boolean, + ): void { + const idx = this._activeSources.indexOf(scheduled); + if (idx === -1) return; + this._activeSources.splice(idx, 1); + el.muted = priorMuted; + try { + sourceNode.disconnect(); + scheduled.fx?.dispose(); + scheduled.gainNode.disconnect(); + } catch { + // Already torn down. + } + if (this._activeSources.length === 0) this._paused = true; + } + + // Pre-existing size (110 lines before this diff, which shrank it to under + // 95 via two extractions — see `handleSourceEnded`/`resolveDestination`); + // the remainder is inherently sequential graph-wiring, not a nested + // decision tree, and further splitting would cost more readability than it + // buys. Same call the B2 step took on `TimelineLogicalRow`. + // fallow-ignore-next-line complexity async schedulePlayback( el: HTMLMediaElement, buffer: AudioBuffer, @@ -217,7 +317,9 @@ export class WebAudioTransport { // output — the same order the offline render uses. Preview and render run // the identical graph builders, so what is heard here is what is written. const fx = attachElementFxChain(this._ctx, el, sourceNode, gainNode, timing); - gainNode.connect(this._masterGain); + gainNode.connect( + this.resolveDestination(el, scheduledAt, compositionTime, safeRate) ?? this._masterGain, + ); scheduleVolumeLane(el, gainNode, timing); @@ -259,29 +361,9 @@ export class WebAudioTransport { this._activeSources.push(scheduled); this._paused = false; - sourceNode.addEventListener("ended", () => { - const idx = this._activeSources.indexOf(scheduled); - if (idx !== -1) { - this._activeSources.splice(idx, 1); - el.muted = priorMuted; - // The graph goes with it. Splicing alone left the FX handle alive and - // then UNREACHABLE — stopAll() disposes by walking this array, which - // the splice just emptied of this entry. Every clip that finished - // naturally leaked its MutationObserver for the session, and each one - // still answered later `data-fx-chain` edits by rebuilding a whole - // graph (impulse response, chorus/phaser oscillators started and never - // stopped) around a dead source. Not disposed when idx is -1: stopAll() - // has already done it, and `stop()` is what fired this event. - try { - sourceNode.disconnect(); - fx?.dispose(); - gainNode.disconnect(); - } catch { - // Already torn down. - } - if (this._activeSources.length === 0) this._paused = true; - } - }); + sourceNode.addEventListener("ended", () => + this.handleSourceEnded(sourceNode, scheduled, el, priorMuted), + ); return scheduled; } catch (err) { @@ -382,6 +464,8 @@ export class WebAudioTransport { destroy(): void { this.stopAll(); + for (const group of this._groups.values()) group.dispose(); + this._groups.clear(); this._bufferCache.clear(); this._failedSrcs.clear(); if (this._ctx) {