Skip to content
Open
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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing calls this at the current head. The volume write path is still formatNumericValue -> roundToCenti (packages/studio/src/components/editor/propertyPanelHelpers.ts:232-237), i.e. the two-decimal formatter this doc comment names as the thing that collapses the bottom of the fader onto 0.

So the six-decimal serializer ships correct and unreferenced, while the write it exists to fix still rounds to two. Presumably u2-studio-gain-surface wires it — worth confirming, because the test suite here reads as though data-volume already round-trips through it.

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";
}
12 changes: 7 additions & 5 deletions packages/core/src/audioLeveller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
32 changes: 24 additions & 8 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
},
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -3061,7 +3070,7 @@ export function initSandboxRuntimeModular(): void {
compStart,
mediaStart,
clock.now(),
vol * state.bridgeVolume,
vol,
gen,
state.playbackRate,
clipDuration,
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading