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
290 changes: 232 additions & 58 deletions crates/compositor/src/audio.rs

Large diffs are not rendered by default.

86 changes: 66 additions & 20 deletions src/components/ai-edition/RightPanes.audioTrack.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ describe("AudioTrackPane reset button", () => {
expect(setAudioTrackGain).toHaveBeenCalledWith("audio_track_1", -24);
});

it("resets all track parameters (gain, fades, mute, loop) on reset click", () => {
it("resets a music bed to the level a new bed starts at, under the voice", () => {
const updateAudioTrack = vi.fn();
const mockTrack: AxcutAudioTrack = {
id: "audio_track_1",
Expand Down Expand Up @@ -122,11 +122,13 @@ describe("AudioTrackPane reset button", () => {
const resetBtn = screen.getByRole("button", { name: /reset/i });
fireEvent.click(resetBtn);

// Not 0 dB: a bed reset to unity buries the narration, which is the state the
// defaults exist to avoid.
expect(updateAudioTrack).toHaveBeenCalledTimes(1);
expect(updateAudioTrack).toHaveBeenCalledWith("audio_track_1", {
gainDb: 0,
fadeInMs: 0,
fadeOutMs: 0,
gainDb: -18,
fadeInMs: 1000,
fadeOutMs: 1000,
muted: false,
loop: false,
});
Expand All @@ -136,12 +138,12 @@ describe("AudioTrackPane reset button", () => {
expect(fadeInSlider).not.toHaveValue("1500");
expect(fadeOutSlider).not.toHaveValue("2000");

// When re-rendered with the reset track state, sliders show zeroed defaults
// When re-rendered with the reset track state, sliders show the bed defaults
const resetTrack: AxcutAudioTrack = {
...mockTrack,
gainDb: 0,
fadeInMs: 0,
fadeOutMs: 0,
gainDb: -18,
fadeInMs: 1000,
fadeOutMs: 1000,
muted: false,
loop: false,
};
Expand All @@ -151,9 +153,53 @@ describe("AudioTrackPane reset button", () => {
</I18nProvider>,
);

expect(gainSlider).toHaveValue("0");
expect(fadeInSlider).toHaveValue("0");
expect(fadeOutSlider).toHaveValue("0");
expect(gainSlider).toHaveValue("-18");
expect(fadeInSlider).toHaveValue("1000");
expect(fadeOutSlider).toHaveValue("1000");
});

it("resets a voiceover flat: it is voice, levelled by the export", () => {
const updateAudioTrack = vi.fn();
const voiceover: AxcutAudioTrack = {
id: "audio_track_1",
clipId: "clip_1",
assetId: "asset_audio_1",
trackId: "audio_track_1",
startMs: 1000,
endMs: 5000,
durationSec: 10,
offsetMs: 0,
gainDb: -9,
fadeInMs: 200,
fadeOutMs: 300,
muted: true,
loop: false,
kind: "voiceover",
label: "take-1.webm",
origin: "user",
};
render(
<I18nProvider>
<AudioTrackPane
tl={
{
selectedAudioTrackId: "audio_track_1",
audioTracks: [voiceover],
assets: [],
updateAudioTrack,
} as unknown as TimelineApi
}
/>
</I18nProvider>,
);
fireEvent.click(screen.getByRole("button", { name: /reset/i }));
expect(updateAudioTrack).toHaveBeenCalledWith("audio_track_1", {
gainDb: 0,
fadeInMs: 0,
fadeOutMs: 0,
muted: false,
loop: false,
});
});

it("resets all track parameters under French locale", () => {
Expand Down Expand Up @@ -217,9 +263,9 @@ describe("AudioTrackPane reset button", () => {

expect(updateAudioTrack).toHaveBeenCalledTimes(1);
expect(updateAudioTrack).toHaveBeenCalledWith("audio_track_1", {
gainDb: 0,
fadeInMs: 0,
fadeOutMs: 0,
gainDb: -18,
fadeInMs: 1000,
fadeOutMs: 1000,
muted: false,
loop: false,
});
Expand All @@ -230,9 +276,9 @@ describe("AudioTrackPane reset button", () => {

const resetTrack: AxcutAudioTrack = {
...mockTrack,
gainDb: 0,
fadeInMs: 0,
fadeOutMs: 0,
gainDb: -18,
fadeInMs: 1000,
fadeOutMs: 1000,
muted: false,
loop: false,
};
Expand All @@ -242,9 +288,9 @@ describe("AudioTrackPane reset button", () => {
</I18nProvider>,
);

expect(gainSlider).toHaveValue("0");
expect(fadeInSlider).toHaveValue("0");
expect(fadeOutSlider).toHaveValue("0");
expect(gainSlider).toHaveValue("-18");
expect(fadeInSlider).toHaveValue("1000");
expect(fadeOutSlider).toHaveValue("1000");
});

it("retains slider value on release while async commit is in flight without jumping back", async () => {
Expand Down
21 changes: 11 additions & 10 deletions src/components/ai-edition/RightPanes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,14 @@ import {
pickOutputDims,
} from "@/lib/ai-edition/document/outputFormat";
import type { InsertSide } from "@/lib/ai-edition/document/transcript";
import type {
AxcutAsset,
AxcutAudioTrack,
AxcutClip,
AxcutTranscript,
AxcutTrimRange,
AxcutWord,
import {
type AxcutAsset,
type AxcutAudioTrack,
type AxcutClip,
type AxcutTranscript,
type AxcutTrimRange,
type AxcutWord,
audioTrackDefaults,
} from "@/lib/ai-edition/schema";
import {
AUDIO_GAIN_DB_LIMIT,
Expand Down Expand Up @@ -3663,10 +3664,10 @@ export function AudioTrackPane({ tl, onClose }: { tl: TimelineApi; onClose?: ()
setLiveGain(null);
setLiveFadeIn(null);
setLiveFadeOut(null);
// Back to what a new track of this kind starts at: a bed returns UNDER the
// voice, not to a 0 dB level that buries it.
void tl.updateAudioTrack(track.id, {
gainDb: 0,
fadeInMs: 0,
fadeOutMs: 0,
...audioTrackDefaults(track.kind),
muted: false,
loop: false,
});
Expand Down
32 changes: 32 additions & 0 deletions src/components/ai-edition/VirtualPreview.audio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { projectRawTimelineSecToPlayback } from "@/lib/ai-edition/document/timel
import type { AxcutAudioTrack, AxcutClip, AxcutTrimRange } from "@/lib/ai-edition/schema";
import {
applyPreviewAudioSettings,
MUSIC_DUCK,
nextMusicDuckDb,
type PreviewAudioGraph,
resolveAudioTrackPlayback,
resolveTimelineAudioPlayback,
Expand All @@ -15,9 +17,39 @@ function fakeGraph(): PreviewAudioGraph {
context: {} as AudioContext,
gain: { gain: { value: Number.NaN } } as GainNode,
voice: { gain: { value: Number.NaN } } as GainNode,
analyser: {} as AnalyserNode,
};
}

describe("nextMusicDuckDb", () => {
// The preview's side of the export's ducker (`duck_curve`): same depth, hold and
// release, stepped one frame at a time.
const frame = 1 / 60;
function run(fromDb: number, sinceVoiceAt: (t: number) => number, seconds: number) {
let db = fromDb;
for (let t = 0; t < seconds; t += frame) db = nextMusicDuckDb(db, sinceVoiceAt(t), frame);
return db;
}

it("dips to the export's depth over one attack once the voice is heard", () => {
expect(run(0, () => 0, MUSIC_DUCK.attackSec / 2)).toBeCloseTo(MUSIC_DUCK.depthDb / 2, 0);
expect(run(0, () => 0, MUSIC_DUCK.attackSec + frame)).toBe(MUSIC_DUCK.depthDb);
// And never deeper, however long the voice goes on.
expect(run(0, () => 0, 10)).toBe(MUSIC_DUCK.depthDb);
});

it("stays down through a pause shorter than the hold", () => {
expect(run(MUSIC_DUCK.depthDb, (t) => t, MUSIC_DUCK.holdSec - 0.05)).toBe(MUSIC_DUCK.depthDb);
});

it("comes back up over the release once the pause outlasts the hold", () => {
const pause = MUSIC_DUCK.holdSec + MUSIC_DUCK.releaseSec + 2 * frame;
expect(run(MUSIC_DUCK.depthDb, (t) => t, pause)).toBe(0);
// And never above the level the user set.
expect(run(0, () => 10, 1)).toBe(0);
});
});

describe("resolveAudioTrackPlayback", () => {
it("mirrors the video's time", () => {
expect(resolveAudioTrackPlayback(1, 10)).toEqual({ targetTimeSec: 1, shouldPlay: true });
Expand Down
4 changes: 4 additions & 0 deletions src/components/ai-edition/VirtualPreview.playback.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,10 @@ class FakeAudioContext {
createdGains.push(node);
return node;
});
// The music ducker listens to the voice; silence here, so nothing ducks.
createAnalyser = vi.fn(() =>
Object.assign(new FakeAudioNode(), { fftSize: 2048, getFloatTimeDomainData: vi.fn() }),
);
}

describe("VirtualPreview imported audio track boost", () => {
Expand Down
83 changes: 74 additions & 9 deletions src/components/ai-edition/VirtualPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,12 +156,42 @@ export function timelineAudioFadeAt(
return v;
}

/**
* The export's music ducking (`duck_curve` in audio.rs), mirrored for the preview: the same
* depth, speech threshold, hold and release. One difference is structural: the export holds
* the whole voice and looks ahead, so its dip is already complete when a word starts, while
* the preview only hears the voice once it plays, so here the same ramp ends `attackSec`
* into the word.
*/
export const MUSIC_DUCK = {
depthDb: -10,
thresholdDbfs: -35,
holdSec: 0.5,
attackSec: 0.25,
releaseSec: 0.6,
} as const;

/** A music bed's ducking gain in dB, one preview frame of `frameSec` later, when the voice
* was last heard `sinceVoiceSec` ago. Straight ramps in dB, like the export's. */
export function nextMusicDuckDb(
currentDb: number,
sinceVoiceSec: number,
frameSec: number,
): number {
const { depthDb, holdSec, attackSec, releaseSec } = MUSIC_DUCK;
return sinceVoiceSec <= holdSec
? Math.max(depthDb, currentDb + (depthDb / attackSec) * frameSec)
: Math.min(0, currentDb - (depthDb / releaseSec) * frameSec);
}

export interface PreviewAudioGraph {
context: AudioContext;
/** Output trim: everything the preview plays goes through it. */
gain: GainNode;
/** The recording's own audio (primary + supplemental elements), on its way to `gain`. */
voice: GainNode;
/** Listens to the voice — the recording and every voiceover — to duck the music. */
analyser: AnalyserNode;
}

/**
Expand Down Expand Up @@ -475,7 +505,11 @@ export function VirtualPreview({
gain.connect(context.destination);
const voice = context.createGain();
voice.connect(gain);
return { context, gain, voice };
// 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 };
} catch {
return null;
}
Expand Down Expand Up @@ -522,6 +556,10 @@ export function VirtualPreview({
const trackGain = graph.context.createGain();
source.connect(trackGain);
trackGain.connect(graph.gain);
// A voiceover is voice: the music ducks under it as under the recording.
if (audioTracksRef.current.find((track) => track.id === trackId)?.kind === "voiceover") {
trackGain.connect(graph.analyser);
}
audioTrackGainNodesRef.current.set(trackId, trackGain);
connectedSources.push(source);
trackGainNodes.push(trackGain);
Expand All @@ -539,6 +577,7 @@ export function VirtualPreview({
for (const trackGain of trackGainNodes) trackGain.disconnect();
audioTrackGainNodesRef.current = new Map();
graph.voice.disconnect();
graph.analyser.disconnect();
graph.gain.disconnect();
};
}, [
Expand Down Expand Up @@ -683,6 +722,14 @@ export function VirtualPreview({
audioTracksRef.current = audioTracks;
const audioSourcesRef = useRef(audioSources);
audioSourcesRef.current = audioSources;
// Live state of the music ducking (see MUSIC_DUCK): the gain, when the voice was last
// heard, and the analyser's reading buffer, reused every frame.
const musicDuckRef = useRef({
db: 0,
voiceAt: Number.NEGATIVE_INFINITY,
tickAt: 0,
reading: new Float32Array(1024),
});
const audioTrackElsRef = useRef<Map<string, HTMLAudioElement>>(new Map());
const registerAudioTrackEl = useCallback((trackId: string, element: HTMLAudioElement | null) => {
if (element) audioTrackElsRef.current.set(trackId, element);
Expand Down Expand Up @@ -754,6 +801,21 @@ export function VirtualPreview({
virtualTimeSecRef.current,
speedRegionsRef.current,
);
// Music ducking, heard live: one reading of the voice per frame (see MUSIC_DUCK).
const duck = musicDuckRef.current;
const now = performance.now();
const frameSec = Math.min(0.1, Math.max(0, (now - duck.tickAt) / 1000));
duck.tickAt = now;
const analyser = audioGraphRef.current?.analyser;
if (analyser && !v.paused) {
analyser.getFloatTimeDomainData(duck.reading);
let energy = 0;
for (const sample of duck.reading) energy += sample * sample;
if (10 * Math.log10(energy / duck.reading.length) > MUSIC_DUCK.thresholdDbfs) {
duck.voiceAt = now;
}
}
duck.db = nextMusicDuckDb(duck.db, (now - duck.voiceAt) / 1000, frameSec);
for (const track of audioTracksRef.current) {
const el = audioTrackElsRef.current.get(track.id);
if (!el) continue;
Expand Down Expand Up @@ -819,14 +881,17 @@ export function VirtualPreview({
// voiceover up under a 2× region and finish it early, diverging from export.
if (el.playbackRate !== 1) el.playbackRate = 1;
// A voiceover is voice: levelled like the recording, its own gain trimming from
// there — the sum `mix_external_tracks` applies. A music bed is not levelled.
const voiceoverPath =
track.kind === "voiceover"
? audioSourcesRef.current.find((source) => source.id === track.assetId)?.filePath
: undefined;
const trackGainDb =
track.gainDb +
(voiceoverPath ? (loudnessGainDbByPathRef.current.get(voiceoverPath) ?? 0) : 0);
// there — the sum `mix_external_tracks` applies. A music bed is not levelled; it
// ducks under the voice instead.
let trackGainDb = track.gainDb;
if (track.kind === "voiceover") {
const path = audioSourcesRef.current.find(
(source) => source.id === track.assetId,
)?.filePath;
trackGainDb += path ? (loudnessGainDbByPathRef.current.get(path) ?? 0) : 0;
} else {
trackGainDb += duck.db;
}
const trackGainNode = audioTrackGainNodesRef.current.get(track.id);
if (trackGainNode) {
trackGainNode.gain.value = audioGainScalar(trackGainDb) * fade;
Expand Down
2 changes: 1 addition & 1 deletion src/i18n/locales/ar/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@
"remove": "حذف المسار",
"loop": "تكرار",
"slipHint": "اضغط Alt واسحب لتحريك الصوت بالداخل",
"help": "مستوى الصوت والتلاشي والتكرار لهذا المسار الصوتي. يُمزج فوق التسجيل في المعاينة وعند التصدير. اسحب المسار على الخط الزمني لنقله أو تغيير حجمه، أو اضغط Alt واسحب لتحريك الصوت بداخله.",
"help": "مستوى الصوت والتلاشي والتكرار لهذا المسار الصوتي. يُمزج فوق التسجيل في المعاينة وعند التصدير. ينخفض مسار الموسيقى تلقائيًا أثناء الكلام. اسحب المسار على الخط الزمني لنقله أو تغيير حجمه، أو اضغط Alt واسحب لتحريك الصوت بداخله.",
"add": "إضافة مسار صوتي",
"mute": "كتم"
},
Expand Down
2 changes: 1 addition & 1 deletion src/i18n/locales/cs/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@
"remove": "Smazat stopu",
"loop": "Opakovat",
"slipHint": "Alt-přetažením posuňte zvuk uvnitř",
"help": "Hlasitost, přechody a opakování pro tuto zvukovou stopu. Mixuje se s nahrávkou v náhledu i při exportu. Přetáhněte stopu na časové ose pro přesunutí nebo změnu velikosti, nebo podržte Alt a přetáhněte pro posun zvuku uvnitř.",
"help": "Hlasitost, přechody a opakování pro tuto zvukovou stopu. Mixuje se s nahrávkou v náhledu i při exportu. Hudební stopa se při mluvení sama ztiší. Přetáhněte stopu na časové ose pro přesunutí nebo změnu velikosti, nebo podržte Alt a přetáhněte pro posun zvuku uvnitř.",
"add": "Přidat zvukovou stopu",
"mute": "Ztlumit"
},
Expand Down
2 changes: 1 addition & 1 deletion src/i18n/locales/de/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@
"remove": "Spur löschen",
"loop": "Wiederholen",
"slipHint": "Mit Alt ziehen, um das Audio darin zu verschieben",
"help": "Lautstärke, Blenden und Wiederholung für diese Audiospur. Sie wird in der Vorschau und im Export über die Aufnahme gemischt. Zieh die Spur auf der Timeline, um sie zu verschieben oder ihre Länge zu ändern, oder halte Alt und zieh, um das Audio darin zu verschieben.",
"help": "Lautstärke, Blenden und Wiederholung für diese Audiospur. Sie wird in der Vorschau und im Export über die Aufnahme gemischt. Eine Musikspur wird automatisch leiser, während jemand spricht. Zieh die Spur auf der Timeline, um sie zu verschieben oder ihre Länge zu ändern, oder halte Alt und zieh, um das Audio darin zu verschieben.",
"add": "Audiospur hinzufügen",
"mute": "Stumm"
},
Expand Down
Loading
Loading