diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts
index ecccece1b0..d6c37f2be0 100644
--- a/packages/core/src/runtime/init.test.ts
+++ b/packages/core/src/runtime/init.test.ts
@@ -140,6 +140,37 @@ describe("initSandboxRuntimeModular", () => {
},
);
+ it("keeps a boosted clip legal on the element when the bridge sets volume", () => {
+ // `data-volume` may hold up to 12 dB of authored gain. `el.volume` is
+ // spec-pinned to [0,1] and THROWS outside it, so assigning the product raw
+ // aborted the loop — every element after the boosted one kept its old
+ // volume, and the bridge's own state said otherwise.
+ document.body.innerHTML =
+ `
`;
+ window.__timelines = {};
+ initSandboxRuntimeModular();
+
+ const [boosted, quiet] = Array.from(document.querySelectorAll("audio"));
+ if (!boosted || !quiet) throw new Error("expected both clips");
+ // Sentinels, so the assertions cannot be satisfied by what the runtime
+ // already applied while starting up.
+ boosted.volume = 0.2;
+ quiet.volume = 0.1;
+
+ window.dispatchEvent(
+ new MessageEvent("message", {
+ data: { source: "hf-parent", type: "control", action: "set-volume", volume: 1 },
+ }),
+ );
+
+ expect(boosted.volume).toBe(1);
+ // The clip after the boosted one is what a throw mid-loop strands.
+ expect(quiet.volume).toBeCloseTo(0.5, 5);
+ });
+
afterEach(() => {
window.__hfRuntimeTeardown?.();
document.body.innerHTML = "";
diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts
index f9260e0c92..c5c291e9fb 100644
--- a/packages/core/src/runtime/init.ts
+++ b/packages/core/src/runtime/init.ts
@@ -3159,7 +3159,12 @@ export function initSandboxRuntimeModular(): void {
if (!(el instanceof HTMLMediaElement)) continue;
const parsed = parseFloat(el.dataset.volume ?? "");
const clipVolume = Number.isFinite(parsed) ? parsed : 1;
- el.volume = clipVolume * volume;
+ // `data-volume` carries authored gain, which goes above unity now that
+ // the ceiling is 12 dB — and `el.volume` is spec-pinned to [0,1], so
+ // assigning the product raw THROWS IndexSizeError and takes the rest of
+ // the loop with it. The element carries the legal part; the boost above
+ // unity belongs to Web Audio, which already has it from `setVolume`.
+ el.volume = Math.max(0, Math.min(1, clipVolume * volume));
}
},
onSetMediaOutputMuted: (muted) => {
diff --git a/packages/core/src/runtime/media.test.ts b/packages/core/src/runtime/media.test.ts
index 3d10ffe31b..bc348f0eae 100644
--- a/packages/core/src/runtime/media.test.ts
+++ b/packages/core/src/runtime/media.test.ts
@@ -436,6 +436,27 @@ describe("syncRuntimeMedia", () => {
expect(only).toBeCloseTo(0.55, 5);
});
+ it("sends boosted author gain to Web Audio while keeping the native element legal", () => {
+ const clip = createMockClip({ start: 0, end: 10, volume: 3.98 });
+ Object.defineProperty(clip.el, "readyState", { value: 4, writable: true });
+ let transportGain = -1;
+
+ syncRuntimeMedia({
+ clips: [clip],
+ timeSeconds: 1,
+ playing: true,
+ playbackRate: 1,
+ // Third arg is the authored gain, which is the one the transport wants;
+ // the second is the element's, which the spec pins to [0,1].
+ onElementVolume: (_el, _effectiveVolume, authorVolume) => {
+ transportGain = authorVolume;
+ },
+ });
+
+ expect(transportGain).toBeCloseTo(3.98, 5);
+ expect(clip.el.volume).toBe(1);
+ });
+
/**
* The render bakes the lane at CLIP-LOCAL time: prepareAudioTrack already
* cut the wav with `-ss mediaStart`, so its t=0 is the clip's start, and
diff --git a/packages/core/src/runtime/webAudioTransport.test.ts b/packages/core/src/runtime/webAudioTransport.test.ts
index 80f3d411bf..92058e1ef3 100644
--- a/packages/core/src/runtime/webAudioTransport.test.ts
+++ b/packages/core/src/runtime/webAudioTransport.test.ts
@@ -306,6 +306,15 @@ describe("WebAudioTransport", () => {
});
describe("schedulePlayback timing", () => {
+ it("keeps author boost above unity on the per-element gain node", async () => {
+ const { transport, mock, gen } = setupTransport(100);
+
+ await transport.schedulePlayback(mockEl, mockBuffer, 0, 0, 0, 1, gen);
+ transport.setElementVolume(mockEl, 3.98);
+
+ expect(mock.gainNode.gain.value).toBeCloseTo(3.98, 5);
+ });
+
it("starts in-progress clips immediately with correct buffer offset", async () => {
const { transport, mock, gen } = setupTransport(100);