From 730f7d47098c677109b8e2085d737f865fd7e8d6 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 20 Aug 2026 02:36:34 -0700 Subject: [PATCH] fix(core): route the media-element transport through the group bus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- packages/core/src/runtime/init.test.ts | 26 +++++++++++-------- .../core/src/runtime/webAudioTransport.ts | 9 ++++++- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index bde05de3d..7ce83106b 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -1354,16 +1354,20 @@ describe("initSandboxRuntimeModular", () => { // playing in preview for anyone not enrolled. window.__hf?.setCanaries?.({ "audio-track-mute": true }); - const decodeSpy = vi - .spyOn(WebAudioTransport.prototype, "decodeAudioElement") + // `scheduleMediaElementPlayback`, not `decodeAudioElement`: the media-element + // transport is the path the runtime tries FIRST for audio, and the decoded + // buffer is only its fallback. What is under test either way is which + // ELEMENTS get scheduled at all. + const scheduleSpy = vi + .spyOn(WebAudioTransport.prototype, "scheduleMediaElementPlayback") .mockResolvedValue(null); const player = window.__player; player?.play(); player?.seek(0); - expect(decodeSpy).toHaveBeenCalledTimes(1); - expect(decodeSpy.mock.calls[0]?.[0]).toBe(audibleAudio); + expect(scheduleSpy).toHaveBeenCalledTimes(1); + expect(scheduleSpy.mock.calls[0]?.[0]).toBe(audibleAudio); }); it("still schedules a data-hidden audio clip when the host has not opted in", () => { @@ -1387,16 +1391,16 @@ describe("initSandboxRuntimeModular", () => { window.__timelines = { main: createMockTimeline(10) }; initSandboxRuntimeModular(); - const decodeSpy = vi - .spyOn(WebAudioTransport.prototype, "decodeAudioElement") + const scheduleSpy = vi + .spyOn(WebAudioTransport.prototype, "scheduleMediaElementPlayback") .mockResolvedValue(null); const player = window.__player; player?.play(); player?.seek(0); - expect(decodeSpy).toHaveBeenCalledTimes(1); - expect(decodeSpy.mock.calls[0]?.[0]).toBe(hiddenAudio); + expect(scheduleSpy).toHaveBeenCalledTimes(1); + expect(scheduleSpy.mock.calls[0]?.[0]).toBe(hiddenAudio); }); it("batches a mid-playback data-hidden toggle into exactly one Web Audio reschedule", () => { @@ -1437,8 +1441,8 @@ describe("initSandboxRuntimeModular", () => { // toggles away from. player?.play(); - const decodeSpy = vi - .spyOn(WebAudioTransport.prototype, "decodeAudioElement") + const scheduleSpy = vi + .spyOn(WebAudioTransport.prototype, "scheduleMediaElementPlayback") .mockResolvedValue(null); const generationSpy = vi.spyOn(WebAudioTransport.prototype, "startGeneration"); @@ -1450,7 +1454,7 @@ describe("initSandboxRuntimeModular", () => { player?.seek(1, { keepPlaying: true }); expect(generationSpy).toHaveBeenCalledTimes(1); - expect(decodeSpy).toHaveBeenCalledTimes(2); + expect(scheduleSpy).toHaveBeenCalledTimes(2); }); // Scheduling does NOT replace the active set: it bumps a generation, which diff --git a/packages/core/src/runtime/webAudioTransport.ts b/packages/core/src/runtime/webAudioTransport.ts index 0b3e41dd7..024160590 100644 --- a/packages/core/src/runtime/webAudioTransport.ts +++ b/packages/core/src/runtime/webAudioTransport.ts @@ -272,7 +272,14 @@ export class WebAudioTransport { const elapsed = compositionTime - compositionStart; const timing: AutomationTiming = { scheduledAt, elapsed, rate: safeRate }; const fx = attachElementFxChain(this._ctx, el, sourceNode, gainNode, timing); - gainNode.connect(this._masterGain); + // The group bus, not master, for a member — same as the decoded-buffer + // path. This transport is the PRIMARY one for audio (the decode path is + // its fallback), so routing it at master would have left every grouped + // track bypassing the bus whose whole premise is that a group is one + // signal. + gainNode.connect( + this.resolveDestination(el, scheduledAt, compositionTime, safeRate) ?? this._masterGain, + ); scheduleVolumeLane(el, gainNode, timing); this._rate = safeRate;