mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user