fix(runtime): preserve authored muted attr; clean up WebAudio on end (#1319)

onSetMuted/onSetMediaOutputMuted set el.muted = effective on every
<video> and <audio> element. When the bridge sent onSetMuted(false),
it unmuted avatar <video muted> elements whose baked-in lip-sync audio
should never play — causing double audio alongside the separate TTS.
Fix: el.muted = effective || el.defaultMuted.

AudioBufferSourceNode fires 'ended' when playback completes naturally,
but _activeSources was never cleaned up. This kept isActive() true
permanently, which force-muted all HTML audio elements via the
outputMuted flag in syncRuntimeMedia — causing audio to disappear
after the WebAudio buffer finished (~5s for short TTS clips).

Add onended listener that removes the source from _activeSources and
restores el.muted to its pre-WebAudio value. All side-effects are
guarded by idx !== -1 so a stale ended event after stopAll() is a
no-op and cannot clobber bridge state set between stop and the async
event delivery.
This commit is contained in:
Miguel Ángel
2026-06-10 19:09:13 -04:00
committed by GitHub
parent e845793ce1
commit 036b991cbb
4 changed files with 166 additions and 2 deletions
@@ -3,6 +3,7 @@ import { WebAudioTransport } from "./webAudioTransport";
function createMockAudioContext(currentTime = 100) {
const startFn = vi.fn();
const endedListeners: (() => void)[] = [];
const sourceNode = {
buffer: null as AudioBuffer | null,
playbackRate: { value: 1 },
@@ -10,6 +11,10 @@ function createMockAudioContext(currentTime = 100) {
stop: vi.fn(),
disconnect: vi.fn(),
connect: vi.fn(),
addEventListener: vi.fn((event: string, cb: () => void) => {
if (event === "ended") endedListeners.push(cb);
}),
_fireEnded: () => endedListeners.forEach((cb) => cb()),
};
const gainNode = {
gain: { value: 1 },
@@ -281,4 +286,60 @@ describe("WebAudioTransport", () => {
expect(transport.getTime()).toBeCloseTo(9.5, 10);
});
});
describe("onended cleanup (audio dropout fix)", () => {
it("cleans up _activeSources when AudioBufferSourceNode ends naturally", async () => {
const { transport, mock, gen } = setupTransport(100);
const el = { muted: false } as HTMLMediaElement;
await transport.schedulePlayback(el, mockBuffer, 0, 0, 0, 1, gen);
expect(transport.isActive()).toBe(true);
expect(el.muted).toBe(true);
mock.sourceNode._fireEnded();
expect(transport.isActive()).toBe(false);
expect(el.muted).toBe(false);
});
it("restores priorMuted=true when element was already muted", async () => {
const { transport, mock, gen } = setupTransport(100);
const el = { muted: true } as HTMLMediaElement;
await transport.schedulePlayback(el, mockBuffer, 0, 0, 0, 1, gen);
expect(el.muted).toBe(true);
mock.sourceNode._fireEnded();
expect(el.muted).toBe(true);
expect(transport.isActive()).toBe(false);
});
it("registers onended listener on the sourceNode", async () => {
const { transport, mock, gen } = setupTransport(100);
await transport.schedulePlayback(mockEl, mockBuffer, 0, 0, 0, 1, gen);
expect(mock.sourceNode.addEventListener).toHaveBeenCalledWith("ended", expect.any(Function));
});
it("onended after stopAll is a no-op — does not clobber restored state", async () => {
const { transport, mock, gen } = setupTransport(100);
const el = { muted: false } as HTMLMediaElement;
await transport.schedulePlayback(el, mockBuffer, 0, 0, 0, 1, gen);
expect(el.muted).toBe(true);
transport.stopAll();
expect(el.muted).toBe(false);
expect(transport.isActive()).toBe(false);
el.muted = true;
mock.sourceNode._fireEnded();
expect(el.muted).toBe(true);
expect(transport.isActive()).toBe(false);
});
});
});