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
+93
View File
@@ -744,4 +744,97 @@ describe("initSandboxRuntimeModular", () => {
expect(seekTimes.length).toBeGreaterThanOrEqual(2);
expect(seekTimes[seekTimes.length - 1]).toBe(0);
});
it("onSetMuted preserves authored muted attribute on video elements", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "root");
root.setAttribute("data-root", "true");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const video = document.createElement("video");
video.setAttribute("muted", "");
video.muted = true; // browsers auto-sync from attribute; jsdom doesn't
video.setAttribute("src", "avatar.mp4");
root.appendChild(video);
const audio = document.createElement("audio");
audio.setAttribute("data-start", "0");
audio.setAttribute("data-duration", "10");
audio.setAttribute("src", "voiceover.mp3");
root.appendChild(audio);
window.__timelines = { root: createMockTimeline(10) };
initSandboxRuntimeModular();
expect(video.defaultMuted).toBe(true);
expect(video.muted).toBe(true);
expect(audio.muted).toBe(false);
window.dispatchEvent(
new MessageEvent("message", {
data: { source: "hf-parent", type: "control", action: "set-muted", muted: false },
}),
);
expect(video.muted).toBe(true);
expect(audio.muted).toBe(false);
window.dispatchEvent(
new MessageEvent("message", {
data: { source: "hf-parent", type: "control", action: "set-muted", muted: true },
}),
);
expect(video.muted).toBe(true);
expect(audio.muted).toBe(true);
window.dispatchEvent(
new MessageEvent("message", {
data: { source: "hf-parent", type: "control", action: "set-muted", muted: false },
}),
);
expect(video.muted).toBe(true);
expect(audio.muted).toBe(false);
});
it("onSetMediaOutputMuted preserves authored muted attribute on video elements", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "root");
root.setAttribute("data-root", "true");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const video = document.createElement("video");
video.setAttribute("muted", "");
video.muted = true;
video.setAttribute("src", "avatar.mp4");
root.appendChild(video);
const audio = document.createElement("audio");
audio.setAttribute("data-start", "0");
audio.setAttribute("data-duration", "10");
audio.setAttribute("src", "voiceover.mp3");
root.appendChild(audio);
window.__timelines = { root: createMockTimeline(10) };
initSandboxRuntimeModular();
window.dispatchEvent(
new MessageEvent("message", {
data: {
source: "hf-parent",
type: "control",
action: "set-media-output-muted",
muted: false,
},
}),
);
expect(video.muted).toBe(true);
expect(audio.muted).toBe(false);
});
});
+2 -2
View File
@@ -1680,7 +1680,7 @@ export function initSandboxRuntimeModular(): void {
const mediaEls = document.querySelectorAll("video, audio");
for (const el of mediaEls) {
if (!(el instanceof HTMLMediaElement)) continue;
el.muted = effective;
el.muted = effective || el.defaultMuted;
}
},
onSetVolume: (volume) => {
@@ -1701,7 +1701,7 @@ export function initSandboxRuntimeModular(): void {
const mediaEls = document.querySelectorAll("video, audio");
for (const el of mediaEls) {
if (!(el instanceof HTMLMediaElement)) continue;
el.muted = effective;
el.muted = effective || el.defaultMuted;
}
},
onSetPlaybackRate: (rate) => {
@@ -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);
});
});
});
@@ -140,6 +140,16 @@ 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;
if (this._activeSources.length === 0) this._paused = true;
}
});
return scheduled;
} catch (err) {
swallow("webAudioTransport.schedule", err);