diff --git a/packages/core/package-subpaths.json b/packages/core/package-subpaths.json index 1485e4f022..ef217b46f8 100644 --- a/packages/core/package-subpaths.json +++ b/packages/core/package-subpaths.json @@ -158,6 +158,12 @@ "types": "./dist/audioAutomation.d.ts", "environments": ["browser", "bun", "node"] }, + "./audio-gain": { + "source": "./src/audioGain.ts", + "runtime": "./dist/audioGain.js", + "types": "./dist/audioGain.d.ts", + "environments": ["browser", "bun", "node"] + }, "./color-grading": { "source": "./src/colorGrading.ts", "runtime": "./dist/colorGrading.js", diff --git a/packages/core/package.json b/packages/core/package.json index 41077e31d1..c830528b9c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -172,6 +172,12 @@ "import": "./src/audioAutomation.ts", "types": "./src/audioAutomation.ts" }, + "./audio-gain": { + "bun": "./src/audioGain.ts", + "node": "./dist/audioGain.js", + "import": "./src/audioGain.ts", + "types": "./src/audioGain.ts" + }, "./color-grading": { "bun": "./src/colorGrading.ts", "node": "./dist/colorGrading.js", @@ -478,6 +484,10 @@ "import": "./dist/audioAutomation.js", "types": "./dist/audioAutomation.d.ts" }, + "./audio-gain": { + "import": "./dist/audioGain.js", + "types": "./dist/audioGain.d.ts" + }, "./color-grading": { "import": "./dist/colorGrading.js", "types": "./dist/colorGrading.d.ts" diff --git a/packages/core/src/audioGain.test.ts b/packages/core/src/audioGain.test.ts new file mode 100644 index 0000000000..029c7b6c66 --- /dev/null +++ b/packages/core/src/audioGain.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { + AUDIO_GAIN_FADER_MAX, + formatAudioGain, + AUDIO_GAIN_FADER_MIN, + MAX_AUDIO_GAIN, + audioGainToFaderPosition, + audioGainToText, + audioFaderPositionToGain, +} from "./audioGain"; + +describe("audio gain fader", () => { + it("puts unity gain at the physical midpoint", () => { + expect(audioGainToFaderPosition(1)).toBe(0); + expect(audioFaderPositionToGain(0)).toBe(1); + }); + + it("provides +12 dB of boost above unity", () => { + expect(audioFaderPositionToGain(AUDIO_GAIN_FADER_MAX)).toBeCloseTo(MAX_AUDIO_GAIN, 6); + expect(audioGainToText(MAX_AUDIO_GAIN)).toBe("+12.0 dB"); + }); + + it("preserves a true silence endpoint below unity", () => { + expect(audioFaderPositionToGain(AUDIO_GAIN_FADER_MIN)).toBe(0); + expect(audioGainToText(0)).toBe("-∞ dB"); + }); + + it("pins sub-floor gain to the fader's silence endpoint", () => { + expect(audioGainToFaderPosition(0.00001)).toBe(AUDIO_GAIN_FADER_MIN); + }); + + it("round-trips representative attenuation and boost values", () => { + for (const gain of [0.1, 0.5, 1, 2, MAX_AUDIO_GAIN]) { + expect(audioFaderPositionToGain(audioGainToFaderPosition(gain))).toBeCloseTo(gain, 6); + } + }); + + describe("formatAudioGain", () => { + it("never collapses an audible fader stop onto silence", () => { + for (let position = AUDIO_GAIN_FADER_MIN + 1; position <= AUDIO_GAIN_FADER_MAX; position++) { + const serialized = formatAudioGain(audioFaderPositionToGain(position)); + expect(Number(serialized)).toBeGreaterThan(0); + } + // Only the very bottom of the travel is a real mute. + expect(formatAudioGain(audioFaderPositionToGain(AUDIO_GAIN_FADER_MIN))).toBe("0"); + }); + + it("puts the knob back where the user let go of it", () => { + for (let position = AUDIO_GAIN_FADER_MIN; position <= AUDIO_GAIN_FADER_MAX; position++) { + const written = Number(formatAudioGain(audioFaderPositionToGain(position))); + expect(Math.round(audioGainToFaderPosition(written))).toBe(position); + } + }); + + it("keeps a serialized gain short and inside the ceiling", () => { + expect(formatAudioGain(1)).toBe("1"); + expect(formatAudioGain(0.5)).toBe("0.5"); + expect(formatAudioGain(99)).toBe(formatAudioGain(MAX_AUDIO_GAIN)); + }); + }); +}); diff --git a/packages/core/src/audioGain.ts b/packages/core/src/audioGain.ts new file mode 100644 index 0000000000..542890ca54 --- /dev/null +++ b/packages/core/src/audioGain.ts @@ -0,0 +1,110 @@ +/** + * Authoring gain for a media clip. + * + * HTMLMediaElement.volume is limited to 0..1, but HyperFrames' Web Audio + * preview and FFmpeg render paths both support gain above unity. Keep the + * shared ceiling here so Studio, preview, and render cannot drift. + */ +export const MAX_AUDIO_GAIN_DB = 12; +export const MAX_AUDIO_GAIN = 10 ** (MAX_AUDIO_GAIN_DB / 20); + +/** Studio fader coordinates. Unity is deliberately the physical midpoint. */ +export const AUDIO_GAIN_FADER_MIN = -100; +export const AUDIO_GAIN_FADER_MAX = 100; + +const MIN_AUDIO_GAIN_DB = -60; + +export function clampAudioGain(value: number): number { + if (!Number.isFinite(value)) return 1; + return Math.max(0, Math.min(MAX_AUDIO_GAIN, value)); +} + +export function clampNativeMediaVolume(value: number): number { + if (!Number.isFinite(value)) return 1; + return Math.max(0, Math.min(1, value)); +} + +/** + * Serialize an authored gain for `data-volume`. + * + * The fader travels in dB, so its stops are irrational (position -70 is + * 10 ** (-42/20)). Rounding to two decimals — what the generic numeric + * attribute formatter does — collapses the whole bottom of the fader onto + * `"0"` (a hard mute) and makes the knob jump on release everywhere below + * unity. Six decimals round-trip every integer fader stop back to itself. + */ +export function formatAudioGain(gain: number): string { + return clampAudioGain(gain) + .toFixed(6) + .replace(/\.?0+$/, ""); +} + +/** + * Run `probe` with `el.volume` shadowed by an accessor that keeps the authored + * value instead of the spec's [0,1] clamp. + * + * `HTMLMediaElement.volume` cannot hold gain above unity, so a clip authored + * at `data-volume="1.95"` reads back as 1 the moment the probe seeds it — and + * a GSAP tween started from that seed fades from 0 dB rather than from the + * authored boost. Both the FFmpeg mixer and the Web Audio transport carry gain + * up to MAX_AUDIO_GAIN, so the clamp is a probe artefact, not a real ceiling. + * The native setter still receives the clamped value, so nothing outside the + * probe observes an out-of-range volume, and the shadow is removed afterwards. + */ +export function withUnclampedVolume(el: HTMLMediaElement, probe: () => T): T { + // Guarded for non-DOM runtimes: the probe that calls this is also reachable + // from tests and tools that run outside a browser, where the clamped path is + // the right (and only) answer. + const descriptor = + typeof HTMLMediaElement === "undefined" + ? undefined + : Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, "volume"); + const nativeGet = descriptor?.get; + const nativeSet = descriptor?.set; + if (!nativeGet || !nativeSet) return probe(); + + let authored = Number(nativeGet.call(el)); + Object.defineProperty(el, "volume", { + configurable: true, + get: () => authored, + set: (value: number) => { + authored = Number(value); + nativeSet.call(el, clampNativeMediaVolume(authored)); + }, + }); + try { + return probe(); + } finally { + delete (el as unknown as Record<"volume", unknown>).volume; + nativeSet.call(el, clampNativeMediaVolume(authored)); + } +} + +export function audioFaderPositionToGain(position: number): number { + const safe = Math.max(AUDIO_GAIN_FADER_MIN, Math.min(AUDIO_GAIN_FADER_MAX, position)); + if (safe === AUDIO_GAIN_FADER_MIN) return 0; + const db = + safe < 0 + ? (safe / Math.abs(AUDIO_GAIN_FADER_MIN)) * Math.abs(MIN_AUDIO_GAIN_DB) + : (safe / AUDIO_GAIN_FADER_MAX) * MAX_AUDIO_GAIN_DB; + return 10 ** (db / 20); +} + +export function audioGainToFaderPosition(gain: number): number { + const safe = clampAudioGain(gain); + if (safe === 0) return AUDIO_GAIN_FADER_MIN; + const db = 20 * Math.log10(safe); + const position = + db < 0 + ? (db / Math.abs(MIN_AUDIO_GAIN_DB)) * Math.abs(AUDIO_GAIN_FADER_MIN) + : (db / MAX_AUDIO_GAIN_DB) * AUDIO_GAIN_FADER_MAX; + return Math.max(AUDIO_GAIN_FADER_MIN, Math.min(AUDIO_GAIN_FADER_MAX, position)); +} + +export function audioGainToText(gain: number): string { + const safe = clampAudioGain(gain); + if (safe === 0) return "-∞ dB"; + const db = 20 * Math.log10(safe); + const rounded = Math.abs(db) < 0.05 ? 0 : db; + return (rounded > 0 ? "+" : "") + rounded.toFixed(1) + " dB"; +} diff --git a/packages/core/src/audioLeveller.ts b/packages/core/src/audioLeveller.ts index 8133e03ff9..2a889f84b6 100644 --- a/packages/core/src/audioLeveller.ts +++ b/packages/core/src/audioLeveller.ts @@ -13,11 +13,13 @@ * * ## Why the lane rides a `gain` node * - * The obvious home is the track's volume lane, and that cannot work: volume is - * 0..1 and `normaliseEnvelope` clamps every keyframe into it, so a volume lane - * can only ever attenuate. Lifting a quiet passage needs a `gain` node, which - * spans -60..+12 dB — which is what the audio skill means when it calls `gain` - * "what an automation lane rides when a track has to move". + * The obvious home is the track's volume lane. Both now span the same range — + * `normaliseEnvelope` clamps keyframes to 0..+12 dB, not 0..1 — so the reason + * is no longer that a volume lane can only attenuate. It is ownership: the + * volume lane is the fader the author draws, and a leveller that wrote into it + * would silently redraw their envelope. A `gain` node is a separate stage the + * leveller owns outright, which is what the audio skill means when it calls + * `gain` "what an automation lane rides when a track has to move". */ import { diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index cd8bd8b2ea..abca294397 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -28,6 +28,7 @@ import { } from "./media"; import { handleErrorForProxy, handleMetadataForProxy, maybeProxyProactively } from "./mediaProxy"; import { probeAndCacheElementVolume, type VolumeKeyframe } from "./mediaVolumeEnvelope.js"; +import { clampAudioGain, clampNativeMediaVolume } from "../audioGain.js"; import { createPickerModule } from "./picker"; import { createRuntimePlayer, type RuntimePlayerTransport } from "./player"; import { createRuntimeState } from "./state"; @@ -1978,11 +1979,17 @@ export function initSandboxRuntimeModular(): void { } }; + // Which media elements `syncRuntimeMedia` drives. It owns their volume every + // tick, so any other writer (the bridge's master-volume handler) must skip + // them or the two fight and the transport reads the loser back as the clip's + // author gain. + const isTransportOwnedMedia = (element: Element): boolean => + element.hasAttribute("data-start") || + Boolean(resolveMediaCompositionContext(element).compositionRoot); + const syncMediaForCurrentState = () => { const cache = refreshRuntimeMediaCache({ - shouldIncludeElement: (element) => - element.hasAttribute("data-start") || - Boolean(resolveMediaCompositionContext(element).compositionRoot), + shouldIncludeElement: isTransportOwnedMedia, resolveStartSeconds: (element) => { return resolveAbsoluteMediaStartSeconds(element); }, @@ -2039,7 +2046,7 @@ export function initSandboxRuntimeModular(): void { userMuted: state.bridgeMuted, userVolume: state.bridgeVolume, forceSync, - onElementVolume: (el, volume) => webAudio.setElementVolume(el, volume), + applyElementGain: (el, authorGain) => webAudio.applyElementGain(el, authorGain), isWebAudioOwned: (el) => webAudio.ownsElement(el), onAutoplayBlocked: () => { if (state.mediaAutoplayBlockedPosted) return; @@ -3036,7 +3043,9 @@ export function initSandboxRuntimeModular(): void { const mediaStart = Number.parseFloat(rawEl.dataset.playbackStart ?? rawEl.dataset.mediaStart ?? "0") || 0; const volumeAttr = Number.parseFloat(rawEl.dataset.volume ?? ""); - const vol = Number.isFinite(volumeAttr) ? volumeAttr : 1; + // Author gain only. The user's master volume rides the transport's master + // gain; folding it in here too applied it twice. + const vol = clampAudioGain(Number.isFinite(volumeAttr) ? volumeAttr : 1); const durationAttr = Number.parseFloat(rawEl.dataset.duration ?? ""); let clipDuration = Number.isFinite(durationAttr) && durationAttr > 0 ? durationAttr : Number.POSITIVE_INFINITY; @@ -3061,7 +3070,7 @@ export function initSandboxRuntimeModular(): void { compStart, mediaStart, clock.now(), - vol * state.bridgeVolume, + vol, gen, state.playbackRate, clipDuration, @@ -3141,13 +3150,20 @@ export function initSandboxRuntimeModular(): void { onSetVolume: (volume) => { state.bridgeVolume = volume; webAudio.setVolume(volume); + // Only untimed media is set directly. `syncRuntimeMedia` already folds + // `state.bridgeVolume` into every clip it owns; writing those here too + // made the next tick see a changed `el.volume`, latch the clamped product + // as the clip's author gain, and drop a boosted clip by up to 12 dB on + // every master-fader move. const mediaEls = document.querySelectorAll("video, audio"); for (const el of mediaEls) { - if (!(el instanceof HTMLMediaElement)) continue; + if (!(el instanceof HTMLMediaElement) || isTransportOwnedMedia(el)) continue; const parsed = parseFloat(el.dataset.volume ?? ""); const clipVolume = Number.isFinite(parsed) ? parsed : 1; - el.volume = clipVolume * volume; + el.volume = clampNativeMediaVolume(clampAudioGain(clipVolume) * volume); } + state.mediaForceSyncNextTick = true; + syncMediaForCurrentState(); }, onSetMediaOutputMuted: (muted) => { state.mediaOutputMuted = muted; diff --git a/packages/core/src/runtime/media.test.ts b/packages/core/src/runtime/media.test.ts index bf697161b8..90e7952b96 100644 --- a/packages/core/src/runtime/media.test.ts +++ b/packages/core/src/runtime/media.test.ts @@ -340,7 +340,10 @@ describe("syncRuntimeMedia", () => { timeSeconds: t, playing: true, playbackRate: 1, - onElementVolume: (_el, v) => seen.push(v), + applyElementGain: (_el, v) => { + seen.push(v); + return false; + }, }); } return seen; @@ -365,6 +368,28 @@ 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, + applyElementGain: (_el, gain) => { + transportGain = gain; + // The transport reports that it now carries the gain, which is what + // keeps the native element at unity instead of the clamped product. + return true; + }, + }); + + 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 @@ -383,8 +408,9 @@ describe("syncRuntimeMedia", () => { timeSeconds: t, playing: true, playbackRate: 1, - onElementVolume: (_el, v) => { + applyElementGain: (_el, v) => { seen = v; + return false; }, }); return seen; @@ -411,8 +437,9 @@ describe("syncRuntimeMedia", () => { timeSeconds: 5, playing: true, playbackRate: 1, - onElementVolume: (_el, v) => { + applyElementGain: (_el, v) => { seen = v; + return false; }, }); expect(seen).toBeCloseTo(0.1, 5); @@ -694,15 +721,15 @@ describe("syncRuntimeMedia", () => { expect(clip.el.volume).toBe(0.5); }); - it("reports the effective element volume to external audio transports", () => { + it("reports the clip's author gain to external audio transports", () => { const clip = createMockClip({ start: 0, end: 10, volume: 0 }); - const onElementVolume = vi.fn(); + const applyElementGain = vi.fn().mockReturnValue(false); syncRuntimeMedia({ clips: [clip], timeSeconds: 0, playing: false, playbackRate: 1, - onElementVolume, + applyElementGain, }); clip.el.volume = 0.75; syncRuntimeMedia({ @@ -711,11 +738,13 @@ describe("syncRuntimeMedia", () => { playing: false, playbackRate: 1, userVolume: 0.5, - onElementVolume, + applyElementGain, }); + // The transport applies the user's volume once, on its master gain, so it + // is told the author gain alone. The native element still needs the product. expect(clip.el.volume).toBeCloseTo(0.375); - expect(onElementVolume).toHaveBeenLastCalledWith(clip.el, 0.375); + expect(applyElementGain).toHaveBeenLastCalledWith(clip.el, 0.75); }); describe("per-element mute (Web Audio ownership)", () => { diff --git a/packages/core/src/runtime/media.ts b/packages/core/src/runtime/media.ts index 40ae5ec176..68aa99d29b 100644 --- a/packages/core/src/runtime/media.ts +++ b/packages/core/src/runtime/media.ts @@ -2,6 +2,7 @@ import { swallow } from "./diagnostics"; import { interpolateVolumeGain, type VolumeKeyframe } from "./mediaVolumeEnvelope.js"; import { elementVolumeLaneGain } from "./audioAutomationVolume.js"; import { normalizePlaybackRate } from "./playbackRate.js"; +import { clampAudioGain, clampNativeMediaVolume } from "../audioGain.js"; export function readElementPlaybackRate(el: Element): number { const authored = Number.parseFloat(el.getAttribute("data-playback-rate") ?? ""); @@ -162,11 +163,6 @@ function isUnplayable(el: HTMLMediaElement): boolean { const lastRuntimeAppliedVolume = new WeakMap(); -function clampVolume(volume: number): number { - if (!Number.isFinite(volume)) return 1; - return Math.max(0, Math.min(1, volume)); -} - /** * Drop every per-source sync baseline tracked for `el` — offset drift * samples, the seek-past-buffered-range retry latch, and the last @@ -213,6 +209,8 @@ export function syncRuntimeMedia(params: { /** * User's volume preference (0–1, set via `onSetVolume`). Multiplied with the * per-clip author volume so `data-volume="0.5"` at user volume 0.8 yields 0.4. + * Only for the native HTMLMedia path — the Web Audio transport applies it + * once on its own master gain (see `applyElementGain`). */ userVolume?: number; /** @@ -221,7 +219,18 @@ export function syncRuntimeMedia(params: { * outbound message; further invocations are suppressed by the caller. */ onAutoplayBlocked?: () => void; - onElementVolume?: (el: HTMLMediaElement, volume: number) => void; + /** + * Hand the clip's AUTHOR gain to the Web Audio transport. The user's master + * volume is NOT folded in here — the transport applies that once, on its + * master gain, so a boosted clip is not attenuated twice. + * + * Returns true when the transport now carries the full gain in its graph, in + * which case `el.volume` must stay at unity or the gain is applied twice + * (the element feeds the graph). Returns false when the element is still + * played by the native HTMLMedia path, which needs the product written to + * `el.volume` — spec-clamped, so above-unity gain is unreachable there. + */ + applyElementGain?: (el: HTMLMediaElement, authorGain: number) => boolean; /** Is THIS element owned by the Web Audio transport? Owned → mute it (transport * plays it); not owned → leave audible (HTMLMedia fallback). Per-element, not a * global flag, so a not-yet-claimed track isn't muted by other tracks. */ @@ -265,10 +274,10 @@ export function syncRuntimeMedia(params: { relTime = clip.mediaStart + ((relTime - clip.mediaStart) % loopLength); } } - const userVol = clampVolume(params.userVolume ?? 1); - const fallbackAuthorVolume = clampVolume(clip.volume ?? 1); + const userVol = clampNativeMediaVolume(params.userVolume ?? 1); + const fallbackAuthorVolume = clampAudioGain(clip.volume ?? 1); const previousRuntimeVolume = lastRuntimeAppliedVolume.get(el); - const currentElementVolume = clampVolume(el.volume); + const currentElementVolume = clampNativeMediaVolume(el.volume); let authorVolume: number; // An explicit volume lane owns the fader. It is checked before the probed @@ -284,7 +293,7 @@ export function syncRuntimeMedia(params: { // there is one time base, and this is it. const laneGain = elementVolumeLaneGain(el, params.timeSeconds - clip.start); if (laneGain !== null) { - authorVolume = clampVolume(laneGain); + authorVolume = clampAudioGain(laneGain); } else if (clip.volumeKeyframes && clip.volumeKeyframes.length > 0) { // Keyframes probed from the GSAP timeline — same source as the renderer. // Use the interpolated envelope value directly; no need to track GSAP changes. @@ -294,13 +303,13 @@ export function syncRuntimeMedia(params: { // and the playback rate — so it only coincides with the envelope's time base // for an untrimmed clip playing at 1x from t=0. const elapsedInClip = params.timeSeconds - clip.start; - authorVolume = clampVolume(interpolateVolumeGain(clip.volumeKeyframes, elapsedInClip)); + authorVolume = clampAudioGain(interpolateVolumeGain(clip.volumeKeyframes, elapsedInClip)); } else if (previousRuntimeVolume === undefined) { // First tick this clip is active. The transport has already seeked GSAP // to the current time (seekTimelineAndAdapters runs before syncRuntimeMedia), // so el.volume reflects the animated value — trust it rather than falling // back to data-volume, which would clobber the GSAP-seeked position. - authorVolume = currentElementVolume; + authorVolume = fallbackAuthorVolume > 1 ? fallbackAuthorVolume : currentElementVolume; } else if (Math.abs(currentElementVolume - previousRuntimeVolume) > 0.0001) { // GSAP (or user code) changed el.volume between ticks — track it. authorVolume = currentElementVolume; @@ -309,10 +318,13 @@ export function syncRuntimeMedia(params: { authorVolume = fallbackAuthorVolume; } - const effectiveVolume = clampVolume(authorVolume * userVol); - el.volume = effectiveVolume; - lastRuntimeAppliedVolume.set(el, effectiveVolume); - params.onElementVolume?.(el, effectiveVolume); + const authorGain = clampAudioGain(authorVolume); + const graphCarriesGain = params.applyElementGain?.(el, authorGain) === true; + const nativeVolume = graphCarriesGain + ? 1 + : clampNativeMediaVolume(clampAudioGain(authorGain * userVol)); + el.volume = nativeVolume; + lastRuntimeAppliedVolume.set(el, nativeVolume); // Mute only when force-muted or the transport owns this element; an unclaimed // track stays audible via the HTMLMedia fallback. if (forceMuteAll || params.isWebAudioOwned?.(el)) el.muted = true; diff --git a/packages/core/src/runtime/mediaVolumeEnvelope.test.ts b/packages/core/src/runtime/mediaVolumeEnvelope.test.ts index 0132ca8eb3..a1a56d5729 100644 --- a/packages/core/src/runtime/mediaVolumeEnvelope.test.ts +++ b/packages/core/src/runtime/mediaVolumeEnvelope.test.ts @@ -26,6 +26,63 @@ describe("probeElementVolumeKeyframes", () => { expect(keyframes).toContainEqual({ time: 1.1, volume: 0.2 }); }); + it("keeps a fade that starts from an above-unity authored gain", () => { + const audio = document.createElement("audio"); + audio.dataset.start = "0"; + audio.dataset.duration = "2"; + audio.dataset.volume = "1.949845"; // +5.8 dB + + // A GSAP tween reads the seeded value as its FROM. Through the spec's + // [0,1] clamp on `HTMLMediaElement.volume` that read back as 1, so the + // whole authored boost was thrown away by the mere presence of a fade. + const keyframes = probeElementVolumeKeyframes( + audio, + (time) => { + audio.volume = 1.949845 * Math.max(0, 1 - time / 2); + }, + 2, + 10, + ); + + expect(keyframes?.[0]?.volume).toBeCloseTo(1.949845, 5); + expect(audio.volume).toBeLessThanOrEqual(1); + }); + + it("carries an above-unity tween target through to the envelope", () => { + const audio = document.createElement("audio"); + audio.dataset.start = "0"; + audio.dataset.duration = "1"; + audio.dataset.volume = "1"; + + const keyframes = probeElementVolumeKeyframes( + audio, + (time) => { + audio.volume = 1 + time; + }, + 1, + 10, + ); + + expect(keyframes?.at(-1)?.volume).toBeCloseTo(2, 5); + }); + + it("restores the native accessor once the probe is done", () => { + const audio = document.createElement("audio"); + audio.dataset.start = "0"; + audio.dataset.duration = "1"; + audio.dataset.volume = "2"; + + probeElementVolumeKeyframes(audio, () => {}, 1, 10); + + // The own accessor is gone and the spec setter is back in charge: it + // rejects an out-of-range volume rather than silently taking it. + expect(Object.getOwnPropertyDescriptor(audio, "volume")).toBeUndefined(); + expect(audio.volume).toBe(1); + expect(() => { + audio.volume = 5; + }).toThrow(); + }); + it("samples a short transition at a clip end between frame intervals", () => { const audio = document.createElement("audio"); audio.dataset.start = "0"; diff --git a/packages/core/src/runtime/mediaVolumeEnvelope.ts b/packages/core/src/runtime/mediaVolumeEnvelope.ts index b12349ed4e..e3405b3692 100644 --- a/packages/core/src/runtime/mediaVolumeEnvelope.ts +++ b/packages/core/src/runtime/mediaVolumeEnvelope.ts @@ -1,4 +1,5 @@ import type { RuntimeTimelineLike } from "./types"; +import { clampAudioGain, withUnclampedVolume } from "../audioGain.js"; /** * Shared volume-automation utilities used by both the renderer (offline PCM @@ -16,8 +17,9 @@ export interface VolumeKeyframe { /** * Normalise raw keyframes to track-relative seconds: subtract `trackStart`, - * clamp to [0,1], sort, de-duplicate, and prepend a `baseVolume` anchor at - * t=0 when the first keyframe starts after the clip's begin. + * clamp to the authoring gain range, sort, de-duplicate, and prepend a + * `baseVolume` anchor at t=0 when the first keyframe starts after the clip's + * begin. * * Returns an empty array when all keyframes are invalid — the caller should * treat an empty envelope as "no automation, use static volume." @@ -31,7 +33,7 @@ export function normaliseEnvelope( .filter((k) => Number.isFinite(k.time) && Number.isFinite(k.volume)) .map((k) => ({ time: Math.max(0, k.time - trackStart), - volume: Math.max(0, Math.min(1, k.volume)), + volume: clampAudioGain(k.volume), })) .sort((a, b) => a.time - b.time); @@ -47,7 +49,7 @@ export function normaliseEnvelope( if (deduped.length === 0) return deduped; if (deduped[0]!.time > 0) { - deduped.unshift({ time: 0, volume: Math.max(0, Math.min(1, baseVolume)) }); + deduped.unshift({ time: 0, volume: clampAudioGain(baseVolume) }); } return deduped; } @@ -114,8 +116,7 @@ function resolveVolumeProbeWindow( end = endAttr; } const staticAttr = parseFiniteDatasetNumber(el.dataset.volume) ?? 1; - const staticVolume = Math.max(0, Math.min(1, staticAttr)); - return { start, end, staticVolume }; + return { start, end, staticVolume: clampAudioGain(staticAttr) }; } /** @@ -136,29 +137,32 @@ export function probeElementVolumeKeyframes( ): VolumeKeyframe[] | null { const { start, end, staticVolume } = resolveVolumeProbeWindow(el, compositionDuration); - // Reset to data-volume so GSAP captures the correct FROM value. - el.volume = staticVolume; - const step = 1 / Math.min(60, Math.max(1, sampleFps)); const sampleStart = Math.max(0, start); const sampleEnd = Math.min(compositionDuration, end); - const keyframes: VolumeKeyframe[] = []; - let previousSample: VolumeKeyframe | undefined; - for (let t = sampleStart; t <= sampleEnd + 1e-6; t = Math.min(sampleEnd, t + step)) { - seekTimeline(t); - const raw = Number(el.volume); - if (Number.isFinite(raw)) { - const volume = Math.max(0, Math.min(1, raw)); - const sample = { - time: Number(t.toFixed(6)), - volume: Number(volume.toFixed(6)), - }; - recordVolumeSample(keyframes, previousSample, sample, t === sampleEnd); - previousSample = sample; + const keyframes: VolumeKeyframe[] = withUnclampedVolume(el, () => { + // Reset to data-volume so GSAP captures the correct FROM value. Above + // unity that only survives because the shadow accessor is installed. + el.volume = staticVolume; + + const samples: VolumeKeyframe[] = []; + let previousSample: VolumeKeyframe | undefined; + for (let t = sampleStart; t <= sampleEnd + 1e-6; t = Math.min(sampleEnd, t + step)) { + seekTimeline(t); + const raw = Number(el.volume); + if (Number.isFinite(raw)) { + const sample = { + time: Number(t.toFixed(6)), + volume: Number(clampAudioGain(raw).toFixed(6)), + }; + recordVolumeSample(samples, previousSample, sample, t === sampleEnd); + previousSample = sample; + } + if (t === sampleEnd) break; } - if (t === sampleEnd) break; - } + return samples; + }); const hasAutomation = keyframes.some((kf) => Math.abs(kf.volume - staticVolume) > 0.0001); return hasAutomation ? keyframes : null; diff --git a/packages/core/src/runtime/webAudioTransport.test.ts b/packages/core/src/runtime/webAudioTransport.test.ts index 35f935bc5a..44d052ea09 100644 --- a/packages/core/src/runtime/webAudioTransport.test.ts +++ b/packages/core/src/runtime/webAudioTransport.test.ts @@ -49,6 +49,70 @@ function setupTransport(currentTime = 100) { const mockBuffer = {} as AudioBuffer; const mockEl = { muted: false } as HTMLMediaElement; +describe("WebAudioTransport gain routing", () => { + const elementSourceCtx = (state: AudioContextState) => { + const gainNode = { gain: { value: 1 }, connect: vi.fn(), disconnect: vi.fn() }; + const sourceNode = { connect: vi.fn() }; + const ctx = { + state, + createMediaElementSource: vi.fn(() => sourceNode), + createGain: vi.fn(() => gainNode), + destination: {}, + }; + const transport = new WebAudioTransport(); + (transport as unknown as { _ctx: unknown })._ctx = ctx; + (transport as unknown as { _masterGain: unknown })._masterGain = { + gain: { value: 1 }, + connect: vi.fn(), + }; + return { transport, ctx, gainNode }; + }; + + it("routes an above-unity element through the graph the native volume cannot reach", () => { + // `