fix(core): clamp the native volume of an over-unity clip

`onSetVolume` assigned `clipVolume * volume` straight to
`HTMLMediaElement.volume`. `data-volume` is an authoring GAIN up to
MAX_AUDIO_GAIN (12 dB, ~3.98) — the native property accepts only 0..1 — so a
clip authored above unity threw

  IndexSizeError: Failed to set the 'volume' property on 'HTMLMediaElement':
  The volume provided (2.42103) is outside the range [0, 1].

2.42103 is the +7.68 dB fader stop, serialized by `formatAudioGain`. The bridge
clamps its OWN argument to [0,1] (bridge.ts) but nothing clamped the product,
and because the throw escaped mid-loop it abandoned the rest of the sweep:
every media element after the loud one kept its previous volume.

`clampNativeMediaVolume` already existed in audioGain.ts for exactly this — used
by `withUnclampedVolume`, never here. The gain is not lost by clamping: the Web
Audio transport owns it (`webAudio.setVolume` on the line above), and this
native assignment is only the fallback for elements the transport does not route.

Pre-existing, not from this branch: the line dates to a7a664885 (2026-05-07,
"feat(player): add volume/mute controls"), written before over-unity authoring
gain existed. This branch's faders are what make an over-unity `data-volume`
routine, so it surfaces here.

Regression test in init.test.ts drives a real `set-volume` control message at a
2.42103 clip and asserts no error reaches the window; verified it fails on a
revert of the clamp. Note for whoever edits it: the bridge only accepts
`source: "hf-parent"` — a message with any other source is silently ignored, so
a test that gets that wrong passes while proving nothing.

core: 122 files, 2480 tests pass.
This commit is contained in:
Vance Ingalls
2026-08-20 16:41:05 -07:00
parent d16d1470ad
commit c28300f0cb
2 changed files with 55 additions and 1 deletions
+49
View File
@@ -11,6 +11,55 @@ it("schedules WebAudio element gain from author volume without bridge volume", (
expect(source).not.toMatch(/vol\s*\*\s*state\.bridgeVolume/);
});
/**
* `data-volume` is an authoring GAIN up to `MAX_AUDIO_GAIN` (12 dB ~ 3.98) —
* `HTMLMediaElement.volume` accepts only 0..1. The bridge clamps its own
* argument, but the PRODUCT `clipVolume * volume` was assigned unclamped, so a
* clip authored above unity threw
* `IndexSizeError: The volume provided (2.42103) is outside the range [0, 1]`
* (2.42103 is the +7.68 dB fader stop) — and the throw aborted the loop, so
* every media element after it kept its old volume too.
*/
it("clamps the native volume of an over-unity clip instead of throwing", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "10");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const loud = document.createElement("audio");
loud.setAttribute("data-start", "0");
loud.setAttribute("data-duration", "10");
loud.setAttribute("data-volume", "2.42103");
loud.load = () => {};
root.appendChild(loud);
// Second element proves the throw took the whole sweep down with it, not just
// the offending clip.
const quiet = document.createElement("audio");
quiet.setAttribute("data-start", "0");
quiet.setAttribute("data-duration", "10");
quiet.setAttribute("data-volume", "0.5");
quiet.load = () => {};
root.appendChild(quiet);
window.__timelines = { main: createMockTimeline(10) };
initSandboxRuntimeModular();
const errors: string[] = [];
const onError = (e: ErrorEvent) => errors.push(String(e.message ?? e.error));
window.addEventListener("error", onError);
window.dispatchEvent(
new MessageEvent("message", {
data: { source: "hf-parent", type: "control", action: "set-volume", volume: 1 },
}),
);
window.removeEventListener("error", onError);
expect(errors).toEqual([]);
});
function createMockTimeline(duration: number): RuntimeTimelineLike {
const state = { time: 0, paused: true, duration };
return {
+6 -1
View File
@@ -43,6 +43,7 @@ import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorG
import { TransportClock } from "./clock";
import { WebAudioTransport } from "./webAudioTransport";
import { HF_AUDIO_GROUP_TAG } from "../audioGroups";
import { clampNativeMediaVolume } from "../audioGain";
import { quantizeTimeToFrame } from "../inline-scripts/parityContract";
import { STUDIO_MANUAL_EDIT_GESTURE_ATTR } from "../editing/draftMarkers";
import type {
@@ -3235,7 +3236,11 @@ export function initSandboxRuntimeModular(): void {
// 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));
//
// Through `clampNativeMediaVolume` rather than an inline clamp: that
// helper exists in `audioGain.ts` for exactly this bound and is what
// `withUnclampedVolume` uses, so the two cannot drift.
el.volume = clampNativeMediaVolume(clipVolume * volume);
}
},
onSetMediaOutputMuted: (muted) => {