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
170 changes: 170 additions & 0 deletions crates/compositor/src/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use crate::ffi::*;
use crate::regions::SpeedSegment;
use crate::scene::SceneAudio;
use anyhow::{bail, Result};
use std::f32::consts::PI;
use std::ffi::CString;
Expand All @@ -26,6 +27,114 @@ const PASSTHROUGH_EPSILON: f64 = 1e-3;

pub type PlanarPcm = Vec<Vec<f32>>;

/// Apply the editor's signed sync offset and fast voice-oriented mastering.
///
/// The automatic path is deliberately deterministic and dependency-free: an 80 Hz high-pass
/// removes desk/room rumble, a linked-stereo 3:1 compressor controls speech peaks, and a final
/// RMS/peak pass raises intelligibility without clipping. Linking the envelope preserves the
/// stereo image. The result stays the same length so video and following clips cannot drift.
pub fn finish_audio(mut pcm: PlanarPcm, settings: SceneAudio) -> PlanarPcm {
let samples = pcm.first().map(Vec::len).unwrap_or(0);
if samples == 0 {
return pcm;
}
for channel in pcm.iter_mut() {
channel.resize(samples, 0.0);
}

if settings.auto_master {
let cutoff_hz = 80.0f32;
let dt = 1.0 / AUDIO_OUTPUT_SAMPLE_RATE as f32;
let rc = 1.0 / (2.0 * PI * cutoff_hz);
let alpha = rc / (rc + dt);
for channel in pcm.iter_mut() {
let mut previous_input = 0.0f32;
let mut previous_output = 0.0f32;
for sample in channel.iter_mut() {
let input = *sample;
let output = alpha * (previous_output + input - previous_input);
*sample = output;
previous_input = input;
previous_output = output;
}
}

let threshold_db = -18.0f32;
let ratio = 3.0f32;
let attack = (-1.0 / (0.010 * AUDIO_OUTPUT_SAMPLE_RATE as f32)).exp();
let release = (-1.0 / (0.120 * AUDIO_OUTPUT_SAMPLE_RATE as f32)).exp();
let mut envelope = 0.0f32;
for index in 0..samples {
let peak = pcm
.iter()
.map(|channel| channel[index].abs())
.fold(0.0f32, f32::max);
let coefficient = if peak > envelope { attack } else { release };
envelope = coefficient * envelope + (1.0 - coefficient) * peak;
let level_db = 20.0 * envelope.max(1e-9).log10();
let reduction_db = if level_db > threshold_db {
(level_db - threshold_db) * (1.0 - 1.0 / ratio)
} else {
0.0
};
let reduction = 10.0f32.powf(-reduction_db / 20.0);
for channel in pcm.iter_mut() {
channel[index] *= reduction;
}
}

let mut sum_squares = 0.0f64;
let mut count = 0usize;
let mut peak = 0.0f32;
for &sample in pcm.iter().flatten() {
peak = peak.max(sample.abs());
if sample.abs() > 1e-5 {
sum_squares += (sample as f64) * (sample as f64);
count += 1;
}
}
if count > 0 && peak > 0.0 {
let rms = (sum_squares / count as f64).sqrt() as f32;
let target_rms = 10.0f32.powf(-16.0 / 20.0);
let peak_ceiling = 10.0f32.powf(-1.0 / 20.0);
let makeup = (target_rms / rms.max(1e-9))
.min(peak_ceiling / peak)
.clamp(0.1, 4.0);
for sample in pcm.iter_mut().flatten() {
*sample *= makeup;
}
}
}

let trim = 10.0f32.powf(settings.gain_db.clamp(-24.0, 18.0) / 20.0);
for sample in pcm.iter_mut().flatten() {
*sample = (*sample * trim).clamp(-1.0, 1.0);
}

let shift = ((settings.offset_ms.clamp(-2_000.0, 2_000.0) / 1_000.0)
* AUDIO_OUTPUT_SAMPLE_RATE as f64)
.round() as i64;
if shift == 0 {
return pcm;
}
if shift > 0 {
let destination = (shift as usize).min(samples);
let count = samples - destination;
for channel in pcm.iter_mut() {
channel.copy_within(..count, destination);
channel[..destination].fill(0.0);
}
} else {
let source = ((-shift) as usize).min(samples);
let count = samples - source;
for channel in pcm.iter_mut() {
channel.copy_within(source.., 0);
channel[count..].fill(0.0);
}
}
pcm
}

extern "C" {
fn sn_fmt_stream(s: *mut AVFormatContext, i: i32) -> *mut AVStream;
// bindgen rend `AVFormatContext` opaque (atteinte seulement par pointeur), d'où l'accesseur
Expand Down Expand Up @@ -1063,4 +1172,65 @@ mod tests {
let mixed = mix_aligned_tracks(&[(-0.001, &early)], 0.0, 8);
assert_eq!(mixed[0], vec![0.5; 8]);
}

#[test]
fn signed_audio_offset_is_length_preserving() {
let settings = SceneAudio {
offset_ms: 1000.0 / AUDIO_OUTPUT_SAMPLE_RATE as f64,
gain_db: 0.0,
auto_master: false,
};
let delayed = finish_audio(planar(&[1.0, 2.0, 3.0]), settings);
assert_eq!(delayed[0], vec![0.0, 1.0, 1.0]);

let advanced = finish_audio(
planar(&[1.0, 0.5, 0.25]),
SceneAudio {
offset_ms: -1000.0 / AUDIO_OUTPUT_SAMPLE_RATE as f64,
..settings
},
);
assert_eq!(advanced[0], vec![0.5, 0.25, 0.0]);
}

#[test]
fn manual_gain_is_applied_when_auto_master_is_off() {
let result = finish_audio(
planar(&[0.25, -0.25]),
SceneAudio {
offset_ms: 0.0,
gain_db: 6.0206,
auto_master: false,
},
);
assert!((result[0][0] - 0.5).abs() < 1e-4);
assert!((result[0][1] + 0.5).abs() < 1e-4);
}

#[test]
fn auto_master_removes_dc_and_respects_peak_ceiling() {
let mut input = vec![0.2f32; AUDIO_OUTPUT_SAMPLE_RATE as usize / 2];
for index in (0..input.len()).step_by(400) {
input[index] = 1.0;
}
let result = finish_audio(
planar(&input),
SceneAudio {
offset_ms: 0.0,
gain_db: 0.0,
auto_master: true,
},
);
let peak = result
.iter()
.flatten()
.fold(0.0f32, |value, sample| value.max(sample.abs()));
let tail_mean = result[0][result[0].len() / 2..]
.iter()
.copied()
.sum::<f32>()
/ (result[0].len() / 2) as f32;
assert!(peak <= 10.0f32.powf(-1.0 / 20.0) + 1e-5);
assert!(tail_mean.abs() < 0.01);
}
}
5 changes: 4 additions & 1 deletion crates/compositor/src/compositor_linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1230,9 +1230,12 @@ impl Compositor {
// `cover_crop_uv` est la primitive partagee que macOS et Windows
// utilisent ; elle rend le rect inchange quand il a deja le bon
// ratio, donc aucun placement correct ne bouge.
let (cu0, cv0, cu1, cv1) = crate::frame_geometry::cover_crop_uv(
let [cu0, cv0, cu1, cv1] = crate::frame_geometry::webcam_source_rect(
[wcw, wch],
[wtw as f32, wth as f32],
scene_ref
.as_ref()
.and_then(|scene| scene.layout.webcam_crop),
g.w_px[0] / g.w_px[1].max(0.0001),
);
// MIROIR : on inverse l'intervalle u. Le VS interpole `src`
Expand Down
3 changes: 2 additions & 1 deletion crates/compositor/src/compositor_macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1601,9 +1601,10 @@ impl Compositor {
// --- caméra : ombre PiP puis vidéo ---
let enc = self.begin_pass(cmd_buf, &self.rt, None, &self.pipeline_main)?;
if let (true, Some((wy, wuv))) = (lp.has_webcam, webcam_tex.as_ref()) {
let (cu0, cv0, cu1, cv1) = crate::frame_geometry::cover_crop_uv(
let [cu0, cv0, cu1, cv1] = crate::frame_geometry::webcam_source_rect(
[wcw, wch],
[wtw as f32, wth as f32],
scene_ref.as_ref().and_then(|scene| scene.layout.webcam_crop),
g.w_px[0] / g.w_px[1].max(0.0001),
);
let (u0, u1) = if lp.webcam_mirror { (cu1, cu0) } else { (cu0, cu1) };
Expand Down
5 changes: 4 additions & 1 deletion crates/compositor/src/compositor_windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1517,9 +1517,12 @@ impl Compositor {
//
// Le center-crop carré de square/circle en est un cas particulier (boîte 1:1) — il n'a
// plus besoin d'être traité à part.
let (su0, sv0, su1, sv1) = cover_crop_uv(
let [su0, sv0, su1, sv1] = crate::frame_geometry::webcam_source_rect(
[wcw, wch],
[wtw as f32, wth as f32],
scene_ref
.as_ref()
.and_then(|scene| scene.layout.webcam_crop),
w_px[0] / w_px[1].max(0.0001),
);
// miroir = échanger les bornes u du rect source (flip horizontal).
Expand Down
35 changes: 35 additions & 0 deletions crates/compositor/src/frame_geometry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,23 @@ pub(crate) fn cover_crop_uv(visible: [f32; 2], tex: [f32; 2], box_ar: f32) -> (f
let [u0, v0, u1, v1] = cover_uv_rect(full, tex, box_ar);
(u0, v0, u1, v1)
}

/// Camera equivalent of the screen crop pipeline: apply the user crop first, then a centred
/// cover-crop inside that authored window so arbitrary layout slots never stretch the image.
pub(crate) fn webcam_source_rect(
visible: [f32; 2],
tex: [f32; 2],
crop: Option<SceneCrop>,
box_ar: f32,
) -> [f32; 4] {
let u_max = visible[0].max(1.0) / tex[0].max(1.0);
let v_max = visible[1].max(1.0) / tex[1].max(1.0);
cover_uv_rect(
screen_source_rect(u_max, v_max, crop, 1.0, [0.5, 0.5]),
tex,
box_ar,
)
}
/// Rétrécit un rect SOURCE déjà exprimé en UV (`[u0, v0, u1, v1]`) autour de son
/// centre pour qu'il porte le ratio `box_ar` une fois rapporté aux pixels de la
/// texture. C'est la forme générale de `object-fit: cover`, et LA primitive qui
Expand Down Expand Up @@ -1750,4 +1767,22 @@ mod tests {
assert!((su0 - (960.0 - 720.0) * 0.5 / tex[0]).abs() < 1e-6);
assert!((su1 - (960.0 + 720.0) * 0.5 / tex[0]).abs() < 1e-6);
}

#[test]
fn webcam_crop_identity_keeps_the_full_visible_frame() {
let uv = webcam_source_rect([1280.0, 720.0], [2048.0, 1024.0], None, 16.0 / 9.0);
assert_rect(uv, [0.0, 0.0, 1280.0 / 2048.0, 720.0 / 1024.0]);
}

#[test]
fn webcam_crop_applies_authored_zoom_and_pan_before_layout_cover() {
let crop = SceneCrop {
x: 0.25,
y: 0.20,
width: 0.50,
height: 0.60,
};
let uv = webcam_source_rect([100.0, 100.0], [100.0, 100.0], Some(crop), 0.50 / 0.60);
assert_rect(uv, [0.25, 0.20, 0.75, 0.80]);
}
}
8 changes: 6 additions & 2 deletions crates/compositor/src/pipeline_linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ use std::ffi::CString;
use std::ptr;

use crate::audio::{
assemble_concatenated_pcm, build_audio_concat_plan, decode_clip_audio,
assemble_concatenated_pcm, build_audio_concat_plan, decode_clip_audio, finish_audio,
stretch_clip_pcm_by_speed, AacEncoder, PlanarPcm,
};
use crate::config::Cfg;
Expand Down Expand Up @@ -457,6 +457,7 @@ pub fn run_composited_multi(
let mut clip_frame_counts: Vec<u64> = vec![0; clips.len()];

let scene = comp.scene_snapshot();
let audio_settings = scene.as_ref().map(|scene| scene.audio).unwrap_or_default();
// Ring de staging a 2 : l'export ne veut que du debit, une frame de latence
// ne se voit pas dans un fichier. Voir `Compositor::set_readback_depth` pour
// la raison pour laquelle la preview, elle, reste a 1.
Expand Down Expand Up @@ -533,7 +534,10 @@ pub fn run_composited_multi(
// raccourci voit son audio raccourci d'autant), puis un seul encode AAC.
let declared_audio: Vec<bool> = clips.iter().map(|c| c.has_audio).collect();
let plan = build_audio_concat_plan(&clip_frame_counts, &declared_audio, out_fps as f64);
audio_encoder.encode(&assemble_concatenated_pcm(&clip_pcm, &plan), octx)?;
audio_encoder.encode(
&finish_audio(assemble_concatenated_pcm(&clip_pcm, &plan), audio_settings),
octx,
)?;
crate::ffi::averr(crate::ffi::av_write_trailer(octx), "write_trailer")?;
crate::ffi::avio_closep(&mut pb);
crate::ffi::avformat_free_context(octx);
Expand Down
10 changes: 7 additions & 3 deletions crates/compositor/src/pipeline_macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
//! décodeurs, symétrique.

use crate::audio::{
assemble_concatenated_pcm, build_audio_concat_plan, decode_clip_audio,
assemble_concatenated_pcm, build_audio_concat_plan, decode_clip_audio, finish_audio,
stretch_clip_pcm_by_speed, AacEncoder, PlanarPcm,
};
use crate::compositor::Compositor;
Expand Down Expand Up @@ -1076,6 +1076,7 @@ pub fn run_composited_multi(
// exactement le bug de troncature en slow-motion que la doc de `walk_composited_timeline`
// raconte avoir déjà coûté une fois.
let scene = comp.scene_snapshot();
let audio_settings = scene.as_ref().map(|scene| scene.audio).unwrap_or_default();
frames = unsafe {
crate::timeline_walk::walk_composited_timeline(
clips,
Expand Down Expand Up @@ -1130,7 +1131,10 @@ pub fn run_composited_multi(
// d'autant, sinon la piste dérive pour tous les suivants.
let declared_audio: Vec<bool> = clips.iter().map(|clip| clip.has_audio).collect();
let plan = build_audio_concat_plan(&clip_frame_counts, &declared_audio, out_fps as f64);
audio_encoder.encode(&assemble_concatenated_pcm(&clip_pcm, &plan), octx)?;
audio_encoder.encode(
&finish_audio(assemble_concatenated_pcm(&clip_pcm, &plan), audio_settings),
octx,
)?;

crate::ffi::averr(
crate::ffi::av_write_trailer(octx),
Expand Down Expand Up @@ -1188,4 +1192,4 @@ pub fn probe_frame_count(_path: &str) -> Result<u64> {
// cfg-re-export `crate::compositor::Compositor`, et cette fonction helper garantit
// que le type reste référencé.
#[allow(dead_code)]
fn _typecheck_compositor(_c: &Compositor, _g: &Gpu) {}
fn _typecheck_compositor(_c: &Compositor, _g: &Gpu) {}
8 changes: 6 additions & 2 deletions crates/compositor/src/pipeline_windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
//! tout le run, deux lectures seulement. Rien dans la boucle ne peut fausser le fps.

use crate::audio::{
assemble_concatenated_pcm, build_audio_concat_plan, decode_clip_audio,
assemble_concatenated_pcm, build_audio_concat_plan, decode_clip_audio, finish_audio,
stretch_clip_pcm_by_speed, AacEncoder, PlanarPcm,
};
use crate::compositor::{Compositor, OUT_H, OUT_W};
Expand Down Expand Up @@ -1342,6 +1342,7 @@ unsafe fn run_multi_inner(
// La scène (déjà posée par l'appelant via comp.set_scene) pilote le curseur et le
// fenêtrage par clip ; `walk_composited_timeline` s'en charge.
let scene = comp.scene_snapshot();
let audio_settings = scene.as_ref().map(|scene| scene.audio).unwrap_or_default();

// ---- encodeur (choisi à l'exécution, cf. ExportCodec::candidates) + mux ----
// Backend CPU : pas de pool D3D11 du tout. `av_hwdevice_ctx_init(D3D11VA)` échoue sur
Expand Down Expand Up @@ -1465,7 +1466,10 @@ unsafe fn run_multi_inner(
&declared_audio,
out_fps as f64,
);
let assembled_audio = assemble_concatenated_pcm(&clip_pcm, &audio_plan);
let assembled_audio = finish_audio(
assemble_concatenated_pcm(&clip_pcm, &audio_plan),
audio_settings,
);
audio_encoder.encode(&assembled_audio, octx)?;

averr(av_write_trailer(octx), "write_trailer")?;
Expand Down
Loading