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
29 changes: 29 additions & 0 deletions crates/compositor-view-napi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -824,3 +824,32 @@ pub fn remux_seekable(input_path: String, output_path: String) -> AsyncTask<Remu
output_path,
})
}

pub struct LoudnessGainTask {
path: String,
}

impl Task for LoudnessGainTask {
type Output = f64;
type JsValue = f64;

fn compute(&mut self) -> Result<Self::Output> {
Ok(f64::from(openscreen_compositor::audio::loudness_gain_db(&self.path)))
}

fn resolve(&mut self, _env: Env, out: Self::Output) -> Result<Self::JsValue> {
Ok(out)
}
}

/// Le gain de normalisation de loudness (dB) que l'export applique à ce fichier voix — le
/// même nombre, par la même fonction, pour que la preview de l'éditeur joue la voix au
/// niveau où l'export l'écrira. Voir `openscreen_compositor::audio::loudness_gain_db`.
///
/// `AsyncTask` : la mesure décode tout l'audio du fichier, ce qui se compte en secondes sur
/// un long enregistrement. Le résultat est mis en cache dans le processus, donc l'export qui
/// suit ne le refait pas.
#[napi]
pub fn loudness_gain_db(path: String) -> AsyncTask<LoudnessGainTask> {
AsyncTask::new(LoudnessGainTask { path })
}
561 changes: 510 additions & 51 deletions crates/compositor/src/audio.rs

Large diffs are not rendered by default.

14 changes: 11 additions & 3 deletions crates/compositor/src/audio_jobs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@
//! n'a pas d'état partagé entre contextes, et le décodeur vidéo du parcours en a un autre
//! sur le même chemin, en lecture seule lui aussi.

use crate::audio::{decode_clip_audio, stretch_clip_pcm_by_speed, PlanarPcm};
use crate::audio::{
apply_gain_db, decode_clip_audio, loudness_gain_db, stretch_clip_pcm_by_speed, PlanarPcm,
};
use crate::regions::SpeedSegment;
use std::collections::VecDeque;
use std::thread::JoinHandle;
Expand All @@ -34,7 +36,9 @@ use std::thread::JoinHandle;
/// est tout ce qu'on cherche ici.
const MAX_INFLIGHT_AUDIO_JOBS: usize = 4;

/// Le corps d'un job : décode la fenêtre gardée du clip et l'étire sur ses spans de vitesse.
/// Le corps d'un job : décode la fenêtre gardée du clip, l'étire sur ses spans de vitesse et
/// l'amène au niveau de loudness cible avec le gain mesuré sur le fichier entier
/// (`loudness_gain_db`, celui que la preview applique aussi).
///
/// Rend `None` quand le clip se déclare audio mais n'a pas de flux décodable, ou quand le
/// décodage échoue — dans les deux cas l'export continue et le clip sort muet, comme avant
Expand All @@ -49,7 +53,11 @@ pub fn decode_and_stretch_clip_audio(
out_fps: f64,
) -> Option<PlanarPcm> {
match decode_clip_audio(screen_path, source_start_sec, source_end_sec) {
Ok(Some(pcm)) => Some(stretch_clip_pcm_by_speed(&pcm, speed_segments, out_fps)),
Ok(Some(pcm)) => {
let mut pcm = stretch_clip_pcm_by_speed(&pcm, speed_segments, out_fps);
apply_gain_db(&mut pcm, loudness_gain_db(screen_path));
Some(pcm)
}
Ok(None) => {
eprintln!(
"[pipeline] warning: clip #{clip_index} déclaré audio mais sans flux décodable; silence conservé"
Expand Down
14 changes: 14 additions & 0 deletions crates/compositor/src/scene.rs
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,20 @@ pub struct SceneAudioTrack {
pub fade_in_sec: f64,
#[serde(default)]
pub fade_out_sec: f64,
/// A voiceover is voice: it is loudness-normalised like the recording's own audio.
/// A music bed is not. `#[serde(default)]` reads an older payload as music, which is
/// what every imported file was before voiceovers were recorded in the app.
#[serde(default)]
pub kind: SceneAudioTrackKind,
}

/// `AxcutAudioTrack["kind"]` on the app side.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SceneAudioTrackKind {
#[default]
Music,
Voiceover,
}

#[derive(Debug, Clone, Copy, Deserialize)]
Expand Down
5 changes: 5 additions & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,11 @@ interface Window {
message?: string;
error?: string;
}>;
getLoudnessGain: (filePath: string) => Promise<{
success: boolean;
gainDb: number;
message?: string;
}>;
clearCurrentVideoPath: () => Promise<{ success: boolean }>;
saveProjectFile: (
projectData: unknown,
Expand Down
28 changes: 28 additions & 0 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ import {
macSystemPickerEnabled,
markMacSystemPickerUnavailable,
} from "../native-bridge/screen/macPickerSession";
import { CompositorViewService } from "../native-bridge/services/compositorViewService";
import { getMacPermissions, showPermissionsWindow } from "../permissions";
import { scoreDeviceNameMatch } from "../recording/deviceNameMatching";
import {
Expand Down Expand Up @@ -4240,6 +4241,33 @@ export function registerIpcHandlers(
},
);

// The loudness-normalisation gain the export applies to a voice file, measured by the
// compositor over the whole file and cached there, so the preview plays the voice at the
// level the export writes it. `gainDb: 0` whenever there is nothing to apply — no addon, a
// file with no audio, a failed read — which is the preview as it played before.
const loudnessService = new CompositorViewService();
ipcMain.handle(
"get-loudness-gain",
async (
_,
filePath: string,
): Promise<{ success: boolean; gainDb: number; message?: string }> => {
try {
// Same approval gate as every other read of a renderer-supplied path.
const normalizedPath = readableApprovedPath(filePath);
if (!normalizedPath) {
return { success: false, gainDb: 0, message: "File path is not approved" };
}
return {
success: true,
gainDb: (await loudnessService.loudnessGainDb(normalizedPath)) ?? 0,
};
} catch (error) {
return { success: false, gainDb: 0, message: String(error) };
}
},
);

// Cap renderer-requested chunk sizes so a buggy or compromised renderer
// cannot make the main process allocate an arbitrarily large buffer.
const MAX_IPC_CHUNK_BYTES = 64 * 1024 * 1024;
Expand Down
11 changes: 11 additions & 0 deletions electron/native-bridge/services/compositorViewService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -737,4 +737,15 @@ export class CompositorViewService {
}
return addon.remuxSeekable(inputPath, outputPath);
}

/** The loudness-normalisation gain (dB) the export applies to this voice file, so the
* preview can play it at the same level. Null when the addon is absent or predates it:
* the preview then plays the file as recorded, the export still normalises. */
async loudnessGainDb(filePath: string): Promise<number | null> {
const addon = this.ensureAddon();
if (!addon?.loudnessGainDb) {
return null;
}
return addon.loudnessGainDb(filePath);
}
}
8 changes: 8 additions & 0 deletions electron/native/compositor-view/addon.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,14 @@ export interface CompositorViewAddon {
* (dev trees keep a stale binary until the next `build-linux-compositor-addon.mjs`),
* and the caller degrades to "leave the file alone" rather than failing the save. */
remuxSeekable?(inputPath: string, outputPath: string): Promise<RemuxStats>;

/** Loudness-normalisation gain in dB that the export applies to this voice file (the
* recording's own audio, or a voiceover take), measured over the whole file. The
* preview applies the same number so it plays the voice at the exported level.
* 0 for a file with no audio, only silence, or that cannot be read.
*
* Optional for the same reason as `remuxSeekable`: a stale `.node` predates it. */
loudnessGainDb?(path: string): Promise<number>;
}

/**
Expand Down
4 changes: 4 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,10 @@ contextBridge.exposeInMainWorld("electronAPI", {
preparePreviewAudioTrack: (filePath: string) => {
return ipcRenderer.invoke("prepare-preview-audio-track", filePath);
},
/** Loudness-normalisation gain the export applies to a voice file. See the handler. */
getLoudnessGain: (filePath: string) => {
return ipcRenderer.invoke("get-loudness-gain", filePath);
},
clearCurrentVideoPath: () => {
return ipcRenderer.invoke("clear-current-video-path");
},
Expand Down
24 changes: 23 additions & 1 deletion src/components/ai-edition/VirtualPreview.audio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ import {
timelineAudioFadeAt,
} from "./VirtualPreview";

/** Minimal stand-in: the function only ever touches `gain.gain.value`. */
/** Minimal stand-in: the function only ever touches the two nodes' `gain.value`. */
function fakeGraph(): PreviewAudioGraph {
return {
context: {} as AudioContext,
gain: { gain: { value: Number.NaN } } as GainNode,
voice: { gain: { value: Number.NaN } } as GainNode,
};
}

Expand Down Expand Up @@ -83,6 +84,27 @@ describe("applyPreviewAudioSettings", () => {
expect(element.volume).toBe(0.25);
expect(graph.gain.gain.value).toBeCloseTo(0.5, 4);
});

it("levels the recording with the export's loudness gain, under the output trim", () => {
// The export multiplies each clip by `loudness_gain_db` of its file, then the whole
// mix by the trim. The preview has to play the same product, or the voice is heard
// at one level while editing and another in the file.
const graph = fakeGraph();
applyPreviewAudioSettings(graph, [], -6.0206, 9.5424);
expect(graph.voice.gain.value).toBeCloseTo(3, 3);
expect(graph.gain.gain.value).toBeCloseTo(0.5, 4);
// No measurement yet (or nothing to correct): unity, the file as recorded.
applyPreviewAudioSettings(graph, [], 0);
expect(graph.voice.gain.value).toBe(1);
});

it("folds the loudness gain into the element-volume fallback, still capped at unity", () => {
const element = { volume: Number.NaN } as HTMLAudioElement;
applyPreviewAudioSettings(null, [element], -12.0412, 6.0206);
expect(element.volume).toBeCloseTo(0.5, 4);
applyPreviewAudioSettings(null, [element], 0, 6.0206);
expect(element.volume).toBe(1);
});
});

describe("resolveTimelineAudioPlayback", () => {
Expand Down
28 changes: 28 additions & 0 deletions src/components/ai-edition/VirtualPreview.mediaError.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -465,3 +465,31 @@ describe("VirtualPreview media-error recovery (issue #395)", () => {
expect(video.loadCalls).toBe(0);
});
});

describe("VirtualPreview loudness measurement across a Retry", () => {
it("measures the recording again when Retry reloads it", async () => {
// The first request came back failed while the file was missing; without a fresh
// one after Retry the voice would stay unlevelled (0 dB) for the whole session.
const getLoudnessGain = vi
.fn<(path: string) => Promise<{ success: boolean; gainDb: number }>>()
.mockResolvedValueOnce({ success: false, gainDb: 0 })
.mockResolvedValueOnce({ success: true, gainDb: 6 });
vi.stubGlobal("electronAPI", { getLoudnessGain });
const sources: VideoSource[] = [
{ id: "a1", src: "file:///tmp/a1.mp4", filePath: "/tmp/a1.mp4", label: "a1" },
];
const { bumpRetryToken } = mount([clip("clip_1", "a1", 0)], sources);
await act(async () => {});
expect(getLoudnessGain).toHaveBeenCalledTimes(1);

// A re-render with the same token must not ask again.
bumpRetryToken(0);
await act(async () => {});
expect(getLoudnessGain).toHaveBeenCalledTimes(1);

bumpRetryToken(1);
await act(async () => {});
expect(getLoudnessGain).toHaveBeenCalledTimes(2);
expect(getLoudnessGain).toHaveBeenLastCalledWith("/tmp/a1.mp4");
});
});
Loading
Loading