Files
hyperframes/packages/core/src/audioLeveller.ts
T
Miguel Ángel e282ff15cc fix(audio): raise the authoring gain ceiling and carry it through the probes (#3333)
* fix(audio): raise the authoring gain ceiling and carry it through the probes

Builds on #3328, which made the preview graph apply author gain and user volume
exactly once each. That ownership is now correct but everything is still clamped
to 1.0, so a clip authored above unity cannot be heard or rendered.

`HTMLMediaElement.volume` is spec-clamped to [0,1], so both timeline probes lost
a clip's authored gain the moment it also carried a fade: the probe seeded the
element at the clamped value and every sample read back at or below 0 dB, and
the mixer prefers probed keyframes over the static volume. Both probes now
shadow the accessor for their own duration and forward the clamped value to the
native setter, so the authored gain survives while nothing outside the probe
ever sees an illegal volume.

Measured on one 6 s composition, first 4 s: unity -32.8 LUFS, boosted-with-fade
-32.8 before and -27.0 after — +5.8 dB, exactly the gain the clip was authored
at.

One ceiling, defined once in `audioGain.ts` and reachable from both sides: the
render mixer imports it, and the page-serialized probe takes it as a parameter
rather than re-literalling it. User volume stays spec-clamped — it is a fader,
not a gain.

Also holds the percent volume slider above unity in both property panels. That
control tops out at 100%, so one touch would cap a boosted clip and drop up to
12 dB that now genuinely renders; the dB fader that can represent these levels
replaces it in the next PR.

* fix(audio): carry a static above-unity gain onto the preview gain node

Review follow-up.

`setElementVolume` receives the clip's author gain and clamped it to [0,1],
so a static `data-volume` above unity was capped on the WebAudio preview path
while the render honoured it — the exact preview/render divergence this
ceiling exists to close. Automation lanes hid it: they schedule ramps onto the
param directly and never pass through here. The master volume beside it stays
spec-clamped, because a user fader is not a gain.

Verified by mutation: restoring the [0,1] clamp reds the new case.

Also scope the leveller's rationale to this rung — `VOLUME_RANGE` still stops
at unity until the dB fader lands, so "both now span the same range" was
premature — and say why the GSAP-tracking fallback is unity-capped: it 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.

* fix(audio): restore the live test files this branch overwrote, and uncap preview

Review blocker: three files were wholesale copies from the abandoned #3304
branch laid over a two-day-newer base, so they silently reverted work that had
landed in between. CI could not see it — deleted tests do not fail.

- `audioMixer.test.ts` was byte-identical to #3304's head: 1186 lines against a
  base of 1353. Gone with it were the `data-playback-start` fallthrough cases
  from #3322 — merged 54 minutes before this branch's own merge base — and all
  retiming coverage (`playbackRate` 7 to 0, `atempo` 5 to 0), the strict
  literal-timing table, and the zero-window cases.
- `mediaVolumeEnvelope.test.ts` dropped the trailing-garbage duration case and
  the plateau-retention case.
- `packages/core/package.json` rolled the package version back 0.8.3 to 0.7.109.

All three are restored from `main` with only this PR's additions re-applied on
top, and the subpath export is regenerated by the repo's own script rather than
hand-edited.

Also closes the preview/render split the same review raised. Two clamps had to
go, not one: `setElementVolume` capped the author gain at the transport, and
the first-tick branch in `syncRuntimeMedia` trusted `el.volume` — which is
spec-bound to [0,1] and so cannot represent a boost, opening a boosted clip at
0 dB for one tick before the steady-state branch took over. Both pinned by
tests, both verified by mutation.
2026-08-18 20:07:42 -04:00

253 lines
9.5 KiB
TypeScript

/**
* "Even Out Levels" — the first of the adaptive scripts.
*
* A preset cannot fix inconsistent loudness, because the right correction
* depends on the recording. So this measures the track and writes an automation
* lane that lifts the quiet passages toward the loud ones, exactly as the carve
* measures a voice and writes its bands.
*
* It is NOT loudness normalisation. Platform targets (-14 LUFS and friends) are
* ITU-R BS.1770 — K-weighted and gated — and this is plain windowed RMS, so
* calling it LUFS would be a claim the measurement does not support. It is
* named for what it does: evening out.
*
* ## Why the lane rides a `gain` node
*
* 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 {
fxAutomationTarget,
type HfAutomation,
type HfAutomationPoint,
} from "./audioAutomation.js";
import {
HF_AUDIO_FX_CHAIN_VERSION,
mintAudioFxNodeId,
normalizeAudioFxParams,
type HfAudioFxChain,
type HfAudioFxNode,
} from "./audioFx.js";
/** One window per emitted point; the hop keeps the lane inside its budget. */
const FRAME = 4096;
const POINT_BUDGET = 400;
/** Below this, a window is silence rather than a quiet passage worth lifting. */
const FLOOR_BELOW_PEAK_DB = 42;
/** How far a correction may go. Beyond this, lifting a whisper only lifts the room. */
const MAX_LIFT_DB = 12;
const MAX_CUT_DB = 12;
/** Ignore differences smaller than this — they are not audible and they add points. */
const SNAP_DB = 0.4;
/** Attack/release in seconds, so a correction rides the phrase and not the syllable. */
const ATTACK_S = 0.35;
const RELEASE_S = 0.9;
export interface HfLevellerSettings {
/** 0 = leave it alone, 1 = as even as this can make it. */
strength: number;
}
export const DEFAULT_LEVELLER: HfLevellerSettings = { strength: 0.5 };
export interface HfLevellerProfile {
/** How much of the measured difference to correct. */
correction: number;
}
/**
* One knob to a profile, the shape `carveProfile` established.
*
* At full strength this still corrects only most of the difference: driving a
* track to a flat line removes the performance along with the inconsistency.
*/
export function levellerProfile(strength: number): HfLevellerProfile {
const s = Number.isFinite(strength) ? Math.min(1, Math.max(0, strength)) : 0.5;
return { correction: Number((0.25 + s * 0.6).toFixed(3)) };
}
/** Level in dB of one window, or -Infinity for silence. */
function windowDb(samples: Float32Array, from: number, count: number): number {
let sum = 0;
let n = 0;
for (let i = from; i < from + count; i += 1) {
const s = samples[i];
if (s === undefined) break;
sum += s * s;
n += 1;
}
if (n === 0) return Number.NEGATIVE_INFINITY;
const rms = Math.sqrt(sum / n);
return rms > 0 ? 20 * Math.log10(rms) : Number.NEGATIVE_INFINITY;
}
/**
* Measure a track and return the gain moves that even it out, in dB against
* clip-local seconds. Empty when there is nothing worth correcting.
*/
export function analyseLevelling(
samples: Float32Array,
sampleRate: number,
strength = DEFAULT_LEVELLER.strength,
): HfAutomationPoint[] {
if (samples.length === 0 || sampleRate <= 0) return [];
const profile = levellerProfile(strength);
const hop = Math.max(FRAME, Math.ceil(samples.length / POINT_BUDGET));
const levels: number[] = [];
const times: number[] = [];
for (let start = 0; start < samples.length; start += hop) {
levels.push(windowDb(samples, start, FRAME));
times.push((start + FRAME / 2) / sampleRate);
}
const speaking = levels.filter((d) => Number.isFinite(d));
if (speaking.length === 0) return [];
const peak = Math.max(...speaking);
const floor = peak - FLOOR_BELOW_PEAK_DB;
/**
* The target is a level the track ALREADY REACHES — the 80th percentile of
* its speaking windows — not an absolute one.
*
* Anchoring it to an absolute figure means an already-even track gets pulled
* bodily up or down to meet it, which is a volume change wearing a
* levelling label. Against a level the track reaches, its loud passages
* correct to roughly nothing and only the quiet ones move, which is what
* evening out means. It is a percentile rather than the peak so one loud
* word cannot set the target for the whole track.
*/
const sorted = [...speaking].sort((a, b) => a - b);
const target = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.8))] ?? peak;
const attack = 1 - Math.exp(-(hop / sampleRate) / ATTACK_S);
const release = 1 - Math.exp(-(hop / sampleRate) / RELEASE_S);
let applied = 0;
const raw: HfAutomationPoint[] = [];
levels.forEach((db, i) => {
// Silence is left alone. Lifting a pause only lifts the room with it.
const wanted =
Number.isFinite(db) && db > floor
? Math.max(-MAX_CUT_DB, Math.min(MAX_LIFT_DB, (target - db) * profile.correction))
: 0;
applied += (wanted > applied ? attack : release) * (wanted - applied);
const v = Math.abs(applied) < SNAP_DB ? 0 : Number(applied.toFixed(1));
raw.push({ t: Number((times[i] ?? 0).toFixed(3)), v });
});
// Keep only the moves. A run of equal values is held by keeping the last of
// the run, or interpolation would slide across a passage that is steady.
const points: HfAutomationPoint[] = [];
let lastKept = 0;
let keptIndex = -1;
raw.forEach((pt, i) => {
if (i !== raw.length - 1 && Math.abs(pt.v - lastKept) < SNAP_DB) return;
if (i > 0 && keptIndex !== i - 1) {
const prev = raw[i - 1];
if (prev) points.push(prev);
}
points.push(pt);
lastKept = pt.v;
keptIndex = i;
});
// A lane of zeroes is not a correction, it is a lane that says nothing. An
// author who runs this on an even track should be told there was nothing to
// do, not handed an inert envelope to wonder about.
if (points.length === 0 || points.every((p) => p.v === 0)) return [];
// A lane's first point has to sit at the clip's start, or everything before
// it is drawn from wherever the first move happens to be.
if ((points[0]?.t ?? 0) > 0) points.unshift({ t: 0, v: points[0]?.v ?? 0 });
return points;
}
/**
* The chain and lane for "Even Out Levels".
*
* Returns nothing when the track needs no correcting — a script that always
* writes something teaches an author that it is doing nothing.
*/
export function levellingResult(
chain: HfAudioFxChain,
samples: Float32Array,
sampleRate: number,
strength = DEFAULT_LEVELLER.strength,
): { chain: HfAudioFxChain; automation: HfAutomation } | null {
const points = analyseLevelling(samples, sampleRate, strength);
if (points.length === 0) return null;
const existing = chain.nodes.find((n) => n.fromLeveller);
const id = existing?.id ?? mintAudioFxNodeId(chain);
const node: HfAudioFxNode = {
type: "gain",
id,
fromLeveller: true,
label: "Even Out Levels",
enabled: true,
// Seeded at 0 dB: the lane is what moves it, and a non-zero seed would be
// heard for the instant before the first ramp is scheduled.
params: normalizeAudioFxParams("gain", { gain: 0 }),
};
/**
* In FRONT of a trailing limiter, not after it.
*
* The likely sequence is "apply Clean Voice, then even out the levels", and
* Clean Voice ends in a Peak Ceiling. Appending would put up to 12 dB of lift
* AFTER the ceiling that exists to bound the chain — so every quiet-to-loud
* transition leaves residual lift on loud material sitting at -1 dBFS, and
* the render shears it flat. A ceiling that something is added after is not
* a ceiling.
*/
const insertAt =
!existing && chain.nodes[chain.nodes.length - 1]?.type === "limiter"
? chain.nodes.length - 1
: chain.nodes.length;
return {
chain: {
version: HF_AUDIO_FX_CHAIN_VERSION,
nodes: existing
? chain.nodes.map((n) => (n.fromLeveller ? node : n))
: [...chain.nodes.slice(0, insertAt), node, ...chain.nodes.slice(insertAt)],
},
automation: {
version: 1,
lanes: [{ target: fxAutomationTarget(id, "gain"), points }],
},
};
}
/** Drop the leveller and say which lane went with it. */
export function removeLevelling(chain: HfAudioFxChain): {
chain: HfAudioFxChain;
removedTarget: string | null;
} {
const node = chain.nodes.find((n) => n.fromLeveller);
return {
chain: { ...chain, nodes: chain.nodes.filter((n) => !n.fromLeveller) },
removedTarget: node?.id ? fxAutomationTarget(node.id, "gain") : null,
};
}
/** What the module says when it is closed. */
export function levellingSummary(points: readonly HfAutomationPoint[]): string {
const moves = points.filter((p) => p.v !== 0);
if (moves.length === 0) return "Already even — nothing to do";
const lift = Math.max(...moves.map((p) => p.v));
const cut = Math.min(...moves.map((p) => p.v));
const parts: string[] = [];
if (lift > 0) parts.push(`lifting quiet parts up to ${lift.toFixed(1)} dB`);
if (cut < 0) parts.push(`holding loud parts down ${Math.abs(cut).toFixed(1)} dB`);
return parts.join(", ");
}