/** * 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"; }