mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 19:06:04 +00:00
fix(core): mute preview audio per-element so a slow-decoding track isn't silenced (#1602)
The runtime plays audio two ways — a Web Audio transport (sample-accurate) and the HTMLMediaElement as a fallback — and mutes the elements when Web Audio takes over so they don't double-play. That mute gate was global: it muted every element the moment ANY Web Audio source was active (webAudio.isActive()). A track Web Audio had not claimed yet (its larger buffer decodes slower) was muted on the fallback AND not playing on Web Audio = silent, while the other tracks played. With TTS narration + BGM + SFX, the narration (largest buffer) lost the decode race and dropped out intermittently, with every file fully loaded. Make the mute per-element: an element is muted only when its own Web Audio source is live, or the user / parent-proxy force-mute is set. A track Web Audio has not claimed stays audible on the HTMLMedia fallback until the transport takes it over — which also lets narration start immediately on cold play instead of waiting for its buffer to decode. Also in this change: - Don't permanently blacklist a transient fetch failure in the Web Audio decoder (_failedSrcs was never cleared); only blacklist genuinely undecodable bytes, so a late-arriving asset (404 then available) self-heals on the next play. - Stop re-issuing play() every tick on an errored / no-source element.
This commit is contained in:
@@ -129,6 +129,42 @@ describe("WebAudioTransport", () => {
|
||||
expect(transport.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
describe("ownsElement (per-element mute gate)", () => {
|
||||
function withSource(el: HTMLMediaElement) {
|
||||
const transport = new WebAudioTransport();
|
||||
const source = {
|
||||
el,
|
||||
sourceNode: { stop: vi.fn(), disconnect: vi.fn() } as unknown as AudioBufferSourceNode,
|
||||
gainNode: { disconnect: vi.fn() } as unknown as GainNode,
|
||||
compositionStart: 0,
|
||||
mediaStart: 0,
|
||||
scheduledAt: 0,
|
||||
priorMuted: false,
|
||||
};
|
||||
(transport as unknown as { _activeSources: (typeof source)[] })._activeSources = [source];
|
||||
(transport as unknown as { _paused: boolean })._paused = false;
|
||||
return transport;
|
||||
}
|
||||
|
||||
it("returns true for an element the transport plays", () => {
|
||||
const el = { muted: false } as HTMLMediaElement;
|
||||
expect(withSource(el).ownsElement(el)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for an element the transport does not play", () => {
|
||||
const el = { muted: false } as HTMLMediaElement;
|
||||
const other = { muted: false } as HTMLMediaElement;
|
||||
expect(withSource(el).ownsElement(other)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false after stopAll releases the element", () => {
|
||||
const el = { muted: false } as HTMLMediaElement;
|
||||
const transport = withSource(el);
|
||||
transport.stopAll();
|
||||
expect(transport.ownsElement(el)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("schedulePlayback timing", () => {
|
||||
it("starts in-progress clips immediately with correct buffer offset", async () => {
|
||||
const { transport, mock, gen } = setupTransport(100);
|
||||
@@ -379,4 +415,54 @@ describe("WebAudioTransport", () => {
|
||||
expect(transport.isActive()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeAudioElement retry policy (late-asset self-heal)", () => {
|
||||
function transportWithDecode(decodeImpl: () => Promise<AudioBuffer>) {
|
||||
const transport = new WebAudioTransport();
|
||||
const ctx = { state: "running", decodeAudioData: vi.fn(decodeImpl) };
|
||||
(transport as unknown as { _ctx: unknown })._ctx = ctx;
|
||||
return transport;
|
||||
}
|
||||
const el = (src: string) =>
|
||||
({ getAttribute: () => src, currentSrc: "" }) as unknown as HTMLMediaElement;
|
||||
const failedSrcs = (t: WebAudioTransport) =>
|
||||
(t as unknown as { _failedSrcs: Set<string> })._failedSrcs;
|
||||
|
||||
it("does NOT blacklist a transient fetch failure — a later play retries and succeeds", async () => {
|
||||
const transport = transportWithDecode(async () => ({}) as AudioBuffer);
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: false, status: 404 }) // asset not uploaded yet
|
||||
.mockResolvedValueOnce({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const first = await transport.decodeAudioElement(el("tts.wav"));
|
||||
expect(first).toBeNull();
|
||||
expect(failedSrcs(transport).has("tts.wav")).toBe(false); // not permanently silenced
|
||||
|
||||
const second = await transport.decodeAudioElement(el("tts.wav"));
|
||||
expect(second).not.toBeNull(); // self-heals once the asset is available
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("DOES blacklist genuinely undecodable bytes — not retried", async () => {
|
||||
const transport = transportWithDecode(async () => {
|
||||
throw new Error("unsupported codec");
|
||||
});
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const first = await transport.decodeAudioElement(el("corrupt.wav"));
|
||||
expect(first).toBeNull();
|
||||
expect(failedSrcs(transport).has("corrupt.wav")).toBe(true); // bad data is permanent
|
||||
|
||||
const second = await transport.decodeAudioElement(el("corrupt.wav"));
|
||||
expect(second).toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // short-circuited, no re-fetch
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user