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
47 changes: 47 additions & 0 deletions src/components/ai-edition/VirtualPreview.audio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import {
applyPreviewAudioSettings,
MUSIC_DUCK,
nextMusicDuckDb,
PREVIEW_AUDIO_CEILING,
PREVIEW_AUDIO_HEADROOM,
type PreviewAudioGraph,
previewCeilingCurve,
resolveAudioTrackPlayback,
resolveTimelineAudioPlayback,
timelineAudioFadeAt,
Expand All @@ -18,6 +21,8 @@ function fakeGraph(): PreviewAudioGraph {
gain: { gain: { value: Number.NaN } } as GainNode,
voice: { gain: { value: Number.NaN } } as GainNode,
analyser: {} as AnalyserNode,
headroom: {} as GainNode,
ceiling: {} as WaveShaperNode,
};
}

Expand Down Expand Up @@ -139,6 +144,48 @@ describe("applyPreviewAudioSettings", () => {
});
});

describe("previewCeilingCurve", () => {
const curve = previewCeilingCurve();
/** What the preview plays for a mix at `level`: the headroom gain, then a WaveShaperNode as
* the Web Audio spec defines it (clamped to [-1, 1], linear between curve points). */
const played = (level: number) => {
const input = Math.min(1, Math.max(-1, level / PREVIEW_AUDIO_HEADROOM));
const position = ((input + 1) / 2) * (curve.length - 1);
const below = Math.floor(position);
const above = Math.min(curve.length - 1, below + 1);
return curve[below] + (curve[above] - curve[below]) * (position - below);
};

it("plays everything under the export's ceiling exactly as the export writes it", () => {
for (let level = -PREVIEW_AUDIO_CEILING; level <= PREVIEW_AUDIO_CEILING; level += 0.001) {
expect(Math.abs(played(level) - level)).toBeLessThan(1e-5);
}
});

it("keeps a boosted peak within full scale instead of letting the device clip it", () => {
// 1.226 is the measured case: a −7.1 dBFS peak under an 8.9 dB loudness boost. Far up
// the knee float32 rounds the curve onto 1 itself, which is full scale, not past it.
let previous = PREVIEW_AUDIO_CEILING;
for (const level of [0.9, 1, 1.226, 2, 4, 16, 64]) {
const out = played(level);
expect(out).toBeGreaterThanOrEqual(previous);
expect(out).toBeLessThanOrEqual(1);
expect(played(-level)).toBeCloseTo(-out, 9);
previous = out;
}
expect(played(1.226)).toBeLessThan(1);
});

it("leaves the identity without a corner", () => {
// Same slope on both sides of the knee, so the shaping starts as a bend, not a click.
const step = 1e-3;
const below = (played(PREVIEW_AUDIO_CEILING) - played(PREVIEW_AUDIO_CEILING - step)) / step;
const above = (played(PREVIEW_AUDIO_CEILING + step) - played(PREVIEW_AUDIO_CEILING)) / step;
expect(below).toBeCloseTo(1, 2);
expect(above).toBeCloseTo(1, 1);
});
});

describe("resolveTimelineAudioPlayback", () => {
// A 6s track placed 10s into the RAW timeline, playing the source from 2s in.
const track: AxcutAudioTrack = {
Expand Down
32 changes: 32 additions & 0 deletions src/components/ai-edition/VirtualPreview.playback.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ class FakeGainNode extends FakeAudioNode {
gain = { value: 1 };
}
let createdGains: FakeGainNode[] = [];
let createdShapers: Array<FakeAudioNode & { curve: Float32Array | null }> = [];
class FakeAudioContext {
state = "running";
destination = new FakeAudioNode();
Expand All @@ -329,11 +330,17 @@ class FakeAudioContext {
createAnalyser = vi.fn(() =>
Object.assign(new FakeAudioNode(), { fftSize: 2048, getFloatTimeDomainData: vi.fn() }),
);
createWaveShaper = vi.fn(() => {
const node = Object.assign(new FakeAudioNode(), { curve: null });
createdShapers.push(node);
return node;
});
}

describe("VirtualPreview imported audio track boost", () => {
beforeEach(() => {
createdGains = [];
createdShapers = [];
vi.stubGlobal("AudioContext", FakeAudioContext);
});

Expand Down Expand Up @@ -384,6 +391,31 @@ describe("VirtualPreview imported audio track boost", () => {
expect(trackGain?.gain.value).toBeCloseTo(2, 3); // boosted, NOT clamped to 1
expect(audioEl.volume).toBe(1); // volume left at unity so it doesn't double-attenuate
});

// The loudness boost and the trim can push a loud take past full scale; only the ceiling
// keeps the audio device from clipping it (#911). Nothing may bypass it on the way out.
it("reaches the speakers only through the ceiling", () => {
const sources: VideoSource[] = [{ id: "a1", src: "file:///tmp/a1.mp4", label: "a1" }];
const { container } = render(
<VirtualPreview
videoSources={sources}
clips={[clip("c1", "a1", 0, 10, 0)]}
onTimeChange={vi.fn()}
/>,
);
const videoEl = container.querySelector("video");
if (!videoEl) throw new Error("no <video>");
driveVideo(videoEl as HTMLVideoElement);
act(() => fireEvent.loadedMetadata(videoEl));

const ceiling = createdShapers.at(-1);
expect(ceiling?.curve).toBeInstanceOf(Float32Array);
const speakers = ceiling?.connect.mock.calls[0]?.[0];
expect(speakers).toBeDefined();
for (const gain of createdGains) {
expect(gain.connect).not.toHaveBeenCalledWith(speakers);
}
});
});

// The recording's sound is a second element over the same file: a muted <video> is the clock
Expand Down
63 changes: 57 additions & 6 deletions src/components/ai-edition/VirtualPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -309,12 +309,54 @@ export interface PreviewAudioGraph {
voice: GainNode;
/** Listens to the voice — the recording and every voiceover — to duck the music. */
analyser: AnalyserNode;
/** Between `gain` and the speakers: `headroom` scales the mix into `ceiling`'s input range,
* and `ceiling` keeps it under full scale (see `previewCeilingCurve`). */
headroom: GainNode;
ceiling: WaveShaperNode;
}

/** The export limiter's ceiling, `LIMITER_CEILING` in audio.rs: −1.5 dBFS. */
export const PREVIEW_AUDIO_CEILING = 0.841_395_1;
/** Loudest mix the ceiling stage reads, as a multiple of full scale: +24 dB, a full-scale file
* under the largest loudness boost and the largest output trim. Louder lands on the curve's
* flat end, which the curve has reached long before. */
export const PREVIEW_AUDIO_HEADROOM = 16;

/**
* The curve of the WaveShaperNode in front of the preview's speakers. Without it, the loudness
* boost (up to +12 dB) and the output trim push a loud take's peaks past full scale, and the
* audio device clips them: the preview saturates where the export, whose limiter holds them
* at −1.5 dBFS, does not (measured on a real take: a −7.1 dBFS peak raised 8.9 dB, 92 samples
* over).
*
* It is the identity up to the export's ceiling, then a tanh knee that leaves the identity
* with the same slope and rises towards full scale without reaching it. A curve, not a
* limiter, because a curve has no state (see `applyPreviewAudioSettings`): everything under
* the ceiling plays exactly as the export writes it, and only the peaks the export limits
* are shaped, here by their own level rather than by a gain riding over 5 ms. A WaveShaper
* reads its input on [−1, 1], so `headroom` divides the mix by `PREVIEW_AUDIO_HEADROOM` on
* the way in and the curve's values are real levels. The point count is odd so that silence
* is a point of its own.
*/
export function previewCeilingCurve(points = 8193): Float32Array<ArrayBuffer> {
const knee = PREVIEW_AUDIO_CEILING;
const curve = new Float32Array(points);
for (let index = 0; index < points; index += 1) {
const level = ((index / (points - 1)) * 2 - 1) * PREVIEW_AUDIO_HEADROOM;
const magnitude = Math.abs(level);
curve[index] =
Math.sign(level) *
(magnitude <= knee
? magnitude
: knee + (1 - knee) * Math.tanh((magnitude - knee) / (1 - knee)));
}
return curve;
}

/**
* The preview's audio processing is static gains only, and that is deliberate: each is the
* same `10 ** (dB / 20)` scalar the export applies natively, so what the editor plays is
* what the export writes.
* The preview's gains are static, and that is deliberate: each is the same
* `10 ** (dB / 20)` scalar the export applies natively, so what the editor plays is what
* the export writes.
*
* - `gainDb` is the output trim, `finish_audio`'s gain.
* - `voiceGainDb` is the loudness normalisation of the recording being played. The
Expand All @@ -324,7 +366,8 @@ export interface PreviewAudioGraph {
* Nothing with state belongs here. The export runs on the assembled timeline (trimmed,
* speed-adjusted, concatenated); the preview runs on the untouched source file, seeked. A
* filter or a compressor would see a different signal on each side and drift. That is why
* the export's peak limiter, which only acts above −1.5 dBFS, has no counterpart here.
* the export's peak limiter has a stateless stand-in here, `previewCeilingCurve`, rather
* than a compressor.
*/
export function applyPreviewAudioSettings(
graph: PreviewAudioGraph | null,
Expand Down Expand Up @@ -621,15 +664,21 @@ export function VirtualPreview({
audioContextRef.current = context;
audioSourceNodesRef.current = new WeakMap();
}
const ceiling = context.createWaveShaper();
ceiling.curve = previewCeilingCurve();
ceiling.connect(context.destination);
const headroom = context.createGain();
headroom.gain.value = 1 / PREVIEW_AUDIO_HEADROOM;
headroom.connect(ceiling);
const gain = context.createGain();
gain.connect(context.destination);
gain.connect(headroom);
const voice = context.createGain();
voice.connect(gain);
// 1024 samples: about 21 ms of voice per reading, one reading per frame.
const analyser = context.createAnalyser();
analyser.fftSize = 1024;
voice.connect(analyser);
return { context, gain, voice, analyser };
return { context, gain, voice, analyser, headroom, ceiling };
} catch {
return null;
}
Expand Down Expand Up @@ -699,6 +748,8 @@ export function VirtualPreview({
graph.voice.disconnect();
graph.analyser.disconnect();
graph.gain.disconnect();
graph.headroom.disconnect();
graph.ceiling.disconnect();
};
}, [
primaryAudioEl,
Expand Down
Loading