Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/core/package-subpaths.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
61 changes: 61 additions & 0 deletions packages/core/src/audioGain.test.ts
Original file line number Diff line number Diff line change
@@ -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));
});
});
});
110 changes: 110 additions & 0 deletions packages/core/src/audioGain.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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";
}
15 changes: 10 additions & 5 deletions packages/core/src/audioLeveller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,16 @@
*
* ## 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, and the old reason not to use it
* — "volume is 0..1, so a lane can only ever attenuate" — is being retired in
* stages: `normaliseEnvelope` now clamps to 0..+12 dB, while `VOLUME_RANGE`,
* which bounds the lane itself, still stops at unity until the dB fader lands.
*
* The reason that survives either way 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 {
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/runtime/media.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,27 @@ describe("syncRuntimeMedia", () => {
});
});

it("hands the transport an above-unity author gain, uncapped", () => {
// The preview terminus. `el.volume` is spec-bound to [0,1] and always will
// be, so the boost can only reach the ear through the Web Audio gain node —
// which means the author gain handed to the transport must NOT be capped on
// the way out, even though the native write beside it is.
const clip = createMockClip({ start: 0, end: 10, volume: 1.949845 });
const onElementVolume = vi.fn();

syncRuntimeMedia({
clips: [clip],
timeSeconds: 1,
playing: false,
playbackRate: 1,
onElementVolume,
});

const [, , authorVolume] = onElementVolume.mock.calls.at(-1) as [unknown, number, number];
expect(authorVolume).toBeCloseTo(1.949845, 6);
expect(clip.el.volume).toBe(1);
});

it("plays active clip when playing and buffered", () => {
const clip = createMockClip({ start: 0, end: 10 });
Object.defineProperty(clip.el, "readyState", { value: 4, writable: true });
Expand Down
22 changes: 18 additions & 4 deletions packages/core/src/runtime/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { swallow } from "./diagnostics";
import { interpolateVolumeGain, type VolumeKeyframe } from "./mediaVolumeEnvelope.js";
import { elementVolumeLaneGain } from "./audioAutomationVolume.js";
import { readElementPlaybackRate, readMediaStart } from "./playbackRate.js";
import { clampAudioGain } from "../audioGain.js";
export { readElementPlaybackRate, resolveNaturalMediaTimelineDuration } from "./playbackRate.js";

export function readElementPlaybackStart(el: Element): number {
Expand Down Expand Up @@ -255,7 +256,7 @@ export function syncRuntimeMedia(params: {
}
}
const userVol = clampVolume(params.userVolume ?? 1);
const fallbackAuthorVolume = clampVolume(clip.volume ?? 1);
const fallbackAuthorVolume = clampAudioGain(clip.volume ?? 1);
const previousRuntimeVolume = lastRuntimeAppliedVolume.get(el);
const currentElementVolume = clampVolume(el.volume);

Expand All @@ -273,7 +274,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.
Expand All @@ -283,17 +284,30 @@ 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 (params.isWebAudioRouted?.(el)) {
authorVolume = fallbackAuthorVolume;
} 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;
//
// Except above unity. `el.volume` is spec-bound to [0,1], so it cannot
// represent an authored boost, and reading it back can only lose the
// gain. Without this, a boosted clip opened at 0 dB for one tick and
// then jumped once the unchanged-since-last-tick branch below took over
// — audible, and invisible to any test that ticks more than once.
authorVolume = fallbackAuthorVolume > 1 ? fallbackAuthorVolume : currentElementVolume;
} else if (Math.abs(currentElementVolume - previousRuntimeVolume) > 0.0001) {
// GSAP (or user code) changed el.volume between ticks — track it.
//
// Unity-capped on purpose, and it is not a hole in the ceiling: this
// reads back through `el.volume`, which the spec pins to [0,1], so it
// cannot observe an above-unity value however wide the clamp gets. A
// clip whose volume is actually animated takes the probed-keyframes
// branch above, which carries the authored gain unclamped; this branch
// is the fallback for elements no probe ran on.
authorVolume = currentElementVolume;
} else {
// Volume unchanged since last tick — use data-volume as the baseline.
Expand Down
56 changes: 56 additions & 0 deletions packages/core/src/runtime/mediaVolumeEnvelope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,4 +201,60 @@ describe("probeAndCacheElementVolume", () => {
expect(interpolateVolumeGain(envelope, 0.5)).toBeCloseTo(1, 5);
expect(interpolateVolumeGain(envelope, 1)).toBeCloseTo(1, 5);
});
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();
});
});
Loading
Loading