Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
9f9d669
feat(editor): add audio-track data model for external audio import
Beetix Aug 24, 2026
95e80d6
feat(editor): import external audio files as audio-kind assets
Beetix Aug 24, 2026
255b9f2
feat(editor): add timeline state ops for imported audio tracks
Beetix Aug 24, 2026
336f5fc
feat(editor): import audio onto the timeline with a lane and inspector
Beetix Aug 24, 2026
1affb14
feat(editor): drag and trim audio tracks on the timeline lane
Beetix Aug 24, 2026
db66bbc
feat(editor): play imported audio tracks in the preview
Beetix Aug 24, 2026
79efff1
feat(export): mix imported audio tracks into the exported MP4
Beetix Aug 25, 2026
5310b28
feat(editor): fold audio import into the single "Import media" button
Beetix Aug 25, 2026
a6ca70b
fix(editor): smooth audio-track preview and stop audio becoming a clip
Beetix Aug 25, 2026
7b167ae
feat(editor): add audio from the timeline toolbar, not the media tab
Beetix Aug 25, 2026
5dd510d
refine(editor): tidy the audio toolbar button and inspector pane
Beetix Aug 25, 2026
84a439d
refine(editor): reposition Add audio and make Delete track span
Beetix Aug 25, 2026
86a78b9
refine(editor): match the audio-track pane to the global Audio pane
Beetix Aug 25, 2026
0ef1af3
refactor(editor): drop the superseded audio-track move/resize ops
Beetix Aug 25, 2026
f7c4062
refactor(editor): remove the mute feature from imported audio tracks
Beetix Aug 25, 2026
1cc8f0e
docs: list audioTracks[] in the document-model top-level shape
Beetix Aug 25, 2026
73c1e4d
fix(editor): address CodeRabbit review on the audio-import PR (#502)
Beetix Aug 25, 2026
f5339a2
feat(editor): honor imported-track boost in the preview (CodeRabbit #5)
Beetix Aug 25, 2026
93ae2ad
feat(editor): always show the audio lane + an "Add audio" shortcut (M)
Beetix Aug 25, 2026
ee68186
fix(editor): sync imported audio tracks to trims in the export (issue…
Beetix Aug 25, 2026
2914c22
fix(editor): address CodeRabbit follow-up on the audio-track PR (#502)
Beetix Aug 26, 2026
1bb1913
docs(editor): correct the timelineStartSec comments — the head is raw…
EtienneLescot Aug 27, 2026
ef33206
fix(editor): play imported audio contiguously in preview to match the…
Beetix Aug 28, 2026
93c06e0
fix(ipc): accept audio paths in the generic media reads so imported w…
Beetix Aug 28, 2026
b47e791
fix(editor): three more preview↔export audio seam bugs
Beetix Aug 28, 2026
efd3cbb
fix(editor): four imported-audio state leaks
Beetix Aug 28, 2026
8da3230
refactor(editor): three imported-audio cleanups
Beetix Aug 28, 2026
eb6c4ab
feat(audio): clip-anchor timeline audio tracks
olamide226 Aug 31, 2026
518f125
feat(audio): fades, loop and mute on timeline audio tracks
olamide226 Aug 31, 2026
2c62010
feat(audio): record voiceovers against the timeline
olamide226 Aug 31, 2026
876277c
fix(audio): unblock voiceover recording and restore the left-edge trim
olamide226 Sep 1, 2026
f545556
fix(audio): make the loop toggle reachable, and give voiceover a button
olamide226 Sep 1, 2026
19c3807
test(timeline): pin the voiceover toolbar button
olamide226 Sep 1, 2026
d397d08
feat(audio): make loop fill by itself, and mark where it repeats
olamide226 Sep 1, 2026
2753103
feat(timeline): one audio button with a named menu, and real tooltips
olamide226 Sep 1, 2026
09f2dcd
fix(ui): forward refs through the Popover and Tooltip wrappers
olamide226 Sep 1, 2026
8ed15ff
feat(timeline): stack overlapping audio tracks on their own rows
olamide226 Sep 1, 2026
18854ad
fix(audio): silence the timeline's own tracks while recording a take
olamide226 Sep 1, 2026
2819518
feat(audio): dock the voiceover recorder instead of covering the video
olamide226 Sep 1, 2026
88595d5
fix(audio): stop tracks inside a trimmed stretch from playing
olamide226 Sep 1, 2026
9a2ab93
fix(audio): keep tracks at 1x and in place under speed regions
olamide226 Sep 1, 2026
9b6420e
Merge origin/main into the audio branch
olamide226 Sep 1, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,4 @@ workbench/fixtures/
/aur_ci
/aur_ci.pub
/aur_known_hosts
tmp_handoff.md
267 changes: 266 additions & 1 deletion crates/compositor/src/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
use crate::ffi::*;

use crate::regions::SpeedSegment;
use crate::scene::SceneAudio;
use crate::scene::{SceneAudio, SceneAudioTrack};
use anyhow::{bail, Result};
use std::f32::consts::PI;
use std::ffi::CString;
Expand Down Expand Up @@ -1381,6 +1381,142 @@ pub fn assemble_concatenated_pcm(
output
}

/// Mix imported audio tracks (issue #350) over the assembled programme.
///
/// Each track is decoded across its trim window — already resampled to 48 kHz
/// stereo by `decode_clip_audio`, the same path a clip's own audio takes — scaled
/// by its per-track gain (the same `10^(dB/20)` law as `finish_audio`), and summed
/// into the programme at `start_sec`. The programme length is NOT extended: a
/// track that runs past the video is truncated to it, so the audio and video
/// streams stay the same length for the muxer.
///
/// The decode window is capped up front at the room left in the programme after
/// `start_sec`, and a track starting at/after the end is skipped without decoding.
/// `decode_clip_audio` preallocates from the window, so this keeps a long track
/// pinned near a short programme's end from buffering (and clamping away) hours of
/// PCM. `trim_end_sec` must therefore be concrete — the renderer sends
/// `trimEnd ?? durationSec`.
///
/// A track whose file has no decodable audio is skipped — the same degradation a
/// stream-less clip gets.
pub fn mix_external_tracks(mut programme: PlanarPcm, tracks: &[SceneAudioTrack]) -> PlanarPcm {
let programme_len = programme.first().map(Vec::len).unwrap_or(0);
if programme_len == 0 {
return programme;
}
for track in tracks {
let offset = (track.start_sec.max(0.0) * AUDIO_OUTPUT_SAMPLE_RATE as f64).round() as usize;
// A track that starts at or past the programme end contributes nothing —
// skip it before decoding anything.
if offset >= programme_len {
continue;
}
let trim_start = track.trim_start_sec.max(0.0);
let Some(trim_end_full) = track.trim_end_sec else {
// Without a concrete end there is no safe window to decode (see the doc
// comment); the renderer always resolves one, so this only guards a
// hand-written scene.
continue;
};
// Cap the decode window at the room left in the programme. Everything past
// `offset` that overflows is discarded by `overlay_track_pcm` anyway, so
// decoding it only wastes time and memory — a three-hour track placed at
// second 9 of a ten-second export must not buffer three hours of PCM.
let remaining_sec = (programme_len - offset) as f64 / AUDIO_OUTPUT_SAMPLE_RATE as f64;
let trim_end = trim_end_full.min(trim_start + remaining_sec);
if trim_end <= trim_start {
continue;
}
let decoded = match decode_clip_audio(&track.path, trim_start, trim_end) {
Ok(Some(pcm)) => pcm,
_ => continue,
};
// The app's own range is -60..+12 dB (the inspector slider); clamping at
// -12 here floored every quiet bed at a tenth of the attenuation asked for.
let gain = 10.0f32.powf(track.gain_db.clamp(-60.0, 12.0) / 20.0);
overlay_track_pcm(
&mut programme,
&decoded,
offset,
gain,
track.fade_in_sec.max(0.0),
track.fade_out_sec.max(0.0),
);
}
programme
}

/// Sum one decoded track into the programme at `offset` samples, scaled by `gain`,
/// truncated at the programme's end. Split out of `mix_external_tracks` so the
/// placement/gain/clamp math is testable without ffmpeg, exactly like
/// `mix_aligned_tracks` is split from the decode above.
fn overlay_track_pcm(
programme: &mut PlanarPcm,
decoded: &PlanarPcm,
offset: usize,
gain: f32,
fade_in_sec: f64,
fade_out_sec: f64,
) {
let programme_len = programme.first().map(Vec::len).unwrap_or(0);
if offset >= programme_len {
return;
}
let room = programme_len - offset;
// The ramps are measured against the DECODED length, not the room left in the
// programme: a track running past the end is cut off there, and a fade-out
// timed to the cut would ramp down over audio the export never reaches.
let decoded_len = decoded.iter().map(Vec::len).max().unwrap_or(0);
let (fade_in, fade_out) = resolve_fade_samples(decoded_len, fade_in_sec, fade_out_sec);
for channel in 0..AUDIO_OUTPUT_CHANNELS {
let Some(source) = decoded.get(channel) else {
continue;
};
let count = source.len().min(room);
let dst = &mut programme[channel];
for k in 0..count {
dst[offset + k] += source[k] * gain * fade_envelope(k, decoded_len, fade_in, fade_out);
}
}
}

/// Fade lengths in samples, reduced to fit inside `len`.
///
/// Fades that do not fit share the window in proportion rather than being clamped
/// independently: clamping each to the length first would turn an asymmetric pair
/// into a symmetric one, losing the shape asked for. Kept identical to the app's
/// `resolveFadeSecs` so the preview and the render agree.
fn resolve_fade_samples(len: usize, fade_in_sec: f64, fade_out_sec: f64) -> (usize, usize) {
if len == 0 {
return (0, 0);
}
let rate = AUDIO_OUTPUT_SAMPLE_RATE as f64;
let mut fade_in = fade_in_sec.max(0.0) * rate;
let mut fade_out = fade_out_sec.max(0.0) * rate;
let total = fade_in + fade_out;
if total > len as f64 && total > 0.0 {
let scale = len as f64 / total;
fade_in *= scale;
fade_out *= scale;
}
(fade_in.round() as usize, fade_out.round() as usize)
}

/// Linear ramp factor at sample `k` of a `len`-sample track.
fn fade_envelope(k: usize, len: usize, fade_in: usize, fade_out: usize) -> f32 {
let mut v = 1.0f32;
if fade_in > 0 && k < fade_in {
v = v.min(k as f32 / fade_in as f32);
}
if fade_out > 0 && len > k {
let remaining = len - k;
if remaining <= fade_out {
v = v.min(remaining as f32 / fade_out as f32);
}
}
v
}

/// Encodeur AAC attaché au muxer avant son header. Les paquets utilisent le même interleaver
/// que la vidéo ; les pts restent en unités échantillon jusqu'au rescale vers l'AVStream.
pub(crate) struct AacEncoder {
Expand Down Expand Up @@ -1692,6 +1828,68 @@ mod tests {
assert_eq!(mixed[1], vec![0.25, -0.5, 0.75]);
}

// Imported audio track overlay (issue #350).
#[test]
fn overlay_sums_at_offset_with_gain() {
let mut programme = planar(&[0.1, 0.1, 0.1, 0.1]);
// ×2 gain, placed at sample offset 1.
overlay_track_pcm(&mut programme, &planar(&[0.2, 0.2]), 1, 2.0, 0.0, 0.0);
assert_eq!(programme[0], vec![0.1, 0.5, 0.5, 0.1]);
assert_eq!(programme[1], vec![0.1, 0.5, 0.5, 0.1]);
}

#[test]
fn overlay_truncates_a_track_that_runs_past_the_programme() {
let mut programme = planar(&[0.0, 0.0, 0.0]);
// A 4-sample track placed at offset 2 has room for only 1 sample.
overlay_track_pcm(&mut programme, &planar(&[1.0, 1.0, 1.0, 1.0]), 2, 1.0, 0.0, 0.0);
assert_eq!(programme[0], vec![0.0, 0.0, 1.0]);
}

#[test]
fn overlay_past_the_end_is_a_no_op() {
let mut programme = planar(&[0.3, 0.3]);
overlay_track_pcm(&mut programme, &planar(&[1.0]), 5, 1.0, 0.0, 0.0);
assert_eq!(programme[0], vec![0.3, 0.3]);
}

#[test]
fn mix_external_tracks_skips_empty_windows() {
let programme = planar(&[0.4, 0.4]);
let tracks = vec![SceneAudioTrack {
path: "/nope.mp3".into(),
start_sec: 0.0,
gain_db: 0.0,
trim_start_sec: 2.0,
trim_end_sec: Some(1.0), // end <= start: empty window, never decoded
fade_in_sec: 0.0,
fade_out_sec: 0.0,
}];
// The empty window is skipped before any decode, so the programme is
// untouched even though the path does not exist.
let out = mix_external_tracks(programme, &tracks);
assert_eq!(out[0], vec![0.4, 0.4]);
}

#[test]
fn mix_external_tracks_skips_a_track_that_starts_past_the_programme() {
// 2 samples = ~0.00004 s of programme at 48 kHz; the track starts at 1 s, so
// its offset is past the end. It must be skipped before any decode is
// attempted (the path does not exist), never buffering its window.
let programme = planar(&[0.4, 0.4]);
let tracks = vec![SceneAudioTrack {
path: "/nope.mp3".into(),
start_sec: 1.0,
gain_db: 0.0,
trim_start_sec: 0.0,
trim_end_sec: Some(3600.0),
fade_in_sec: 0.0,
fade_out_sec: 0.0,
}];
let out = mix_external_tracks(programme, &tracks);
assert_eq!(out[0], vec![0.4, 0.4]);
}

#[test]
fn single_track_is_not_clamped() {
// Promesse de non-régression : une source mono-piste ressort telle quelle, y compris
Expand Down Expand Up @@ -1784,6 +1982,73 @@ mod tests {
assert!((loud[0][0] - ceiling).abs() < 1e-6);
}

#[test]
fn fades_that_fit_are_left_alone() {
let rate = AUDIO_OUTPUT_SAMPLE_RATE as f64;
let (fin, fout) = resolve_fade_samples(rate as usize, 0.1, 0.2);
assert_eq!(fin, (0.1 * rate).round() as usize);
assert_eq!(fout, (0.2 * rate).round() as usize);
}

#[test]
fn fades_too_long_for_the_track_share_it_in_proportion() {
// 6 s + 4 s of fade on a 2 s track → 1.2 s / 0.8 s, not a clamped 1 s / 1 s.
// Mirrors `resolveFadeSecs` on the app side; the two must agree or the
// preview and the render shape the same track differently.
let rate = AUDIO_OUTPUT_SAMPLE_RATE as f64;
let len = (2.0 * rate) as usize;
let (fin, fout) = resolve_fade_samples(len, 6.0, 4.0);
assert_eq!(fin, (1.2 * rate).round() as usize);
assert_eq!(fout, (0.8 * rate).round() as usize);
assert!(fin + fout <= len + 1);
}

#[test]
fn a_fade_in_longer_than_the_track_still_reaches_full_volume() {
// Left unreduced this holds the gain near zero for the whole track — the
// layer exports silent.
let (fin, fout) = resolve_fade_samples(100, 10.0, 0.0);
assert_eq!((fin, fout), (100, 0));
assert!((fade_envelope(99, 100, fin, fout) - 0.99).abs() < 1e-3);
}

#[test]
fn the_envelope_ramps_at_both_edges_and_holds_between() {
assert_eq!(fade_envelope(0, 100, 10, 10), 0.0);
assert!((fade_envelope(5, 100, 10, 10) - 0.5).abs() < 1e-6);
assert_eq!(fade_envelope(50, 100, 10, 10), 1.0);
assert!((fade_envelope(95, 100, 10, 10) - 0.5).abs() < 1e-6);
}

#[test]
fn overlay_applies_the_fade_over_the_decoded_length() {
// The ramps are measured against the DECODED length, not the room left in
// the programme: a fade-out timed to the programme's end would ramp down
// over audio the export never reaches.
let mut programme = planar(&[0.0, 0.0, 0.0, 0.0]);
let decoded = planar(&[1.0, 1.0, 1.0, 1.0]);
// A 4-sample fade-in at 48 kHz is far below one sample of real time, so
// ask for the whole decoded length in seconds.
let four = 4.0 / AUDIO_OUTPUT_SAMPLE_RATE as f64;
overlay_track_pcm(&mut programme, &decoded, 0, 1.0, four, 0.0);
assert_eq!(programme[0][0], 0.0);
assert!(programme[0][1] > 0.0 && programme[0][1] < 1.0);
assert!(programme[0][3] > programme[0][1]);
}

#[test]
fn a_track_gain_below_the_output_bound_is_honoured() {
// The per-track gain range is the inspector's -60..+12, NOT the project
// output trim's ±12: clamping here at -12 floored every quiet bed at a
// tenth of the attenuation asked for.
let mut programme = planar(&[0.0]);
let decoded = planar(&[1.0]);
let gain = 10.0f32.powf(-40.0 / 20.0);
overlay_track_pcm(&mut programme, &decoded, 0, gain, 0.0, 0.0);
assert!((programme[0][0] - gain).abs() < 1e-9);
assert!(programme[0][0] < 10.0f32.powf(-12.0 / 20.0));
}

#[test]
fn output_is_clipped_to_full_scale_and_keeps_its_length() {
// The trim can push a hot signal past full scale; the timeline must come back the
Expand Down
13 changes: 11 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, finish_audio,
assemble_concatenated_pcm, build_audio_concat_plan, finish_audio, mix_external_tracks,
AacEncoder, PlanarPcm,
};
use crate::audio_jobs::{decode_and_stretch_clip_audio, ClipAudioJobs};
Expand Down Expand Up @@ -459,6 +459,12 @@ pub fn run_composited_multi(

let scene = comp.scene_snapshot();
let audio_settings = scene.as_ref().map(|scene| scene.audio).unwrap_or_default();
// Imported audio tracks (issue #350), cloned out of the borrowed scene so the
// mix step below owns them. Empty for a project with no imported audio.
let audio_tracks = scene
.as_ref()
.map(|scene| scene.audio_tracks.clone())
.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 @@ -552,7 +558,10 @@ pub fn run_composited_multi(
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(
&finish_audio(assemble_concatenated_pcm(&clip_pcm, &plan), audio_settings),
&finish_audio(
mix_external_tracks(assemble_concatenated_pcm(&clip_pcm, &plan), &audio_tracks),
audio_settings,
),
octx,
)?;
crate::ffi::averr(crate::ffi::av_write_trailer(octx), "write_trailer")?;
Expand Down
12 changes: 10 additions & 2 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, finish_audio,
assemble_concatenated_pcm, build_audio_concat_plan, finish_audio, mix_external_tracks,
AacEncoder, PlanarPcm,
};
use crate::audio_jobs::{decode_and_stretch_clip_audio, ClipAudioJobs};
Expand Down Expand Up @@ -1078,6 +1078,11 @@ pub fn run_composited_multi(
// 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();
// Imported audio tracks (issue #350), cloned out of the borrowed scene.
let audio_tracks = scene
.as_ref()
.map(|scene| scene.audio_tracks.clone())
.unwrap_or_default();
frames = unsafe {
crate::timeline_walk::walk_composited_timeline(
clips,
Expand Down Expand Up @@ -1146,7 +1151,10 @@ pub fn run_composited_multi(
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(
&finish_audio(assemble_concatenated_pcm(&clip_pcm, &plan), audio_settings),
&finish_audio(
mix_external_tracks(assemble_concatenated_pcm(&clip_pcm, &plan), &audio_tracks),
audio_settings,
),
octx,
)?;

Expand Down
9 changes: 7 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, finish_audio,
assemble_concatenated_pcm, build_audio_concat_plan, finish_audio, mix_external_tracks,
AacEncoder, PlanarPcm,
};
use crate::audio_jobs::{decode_and_stretch_clip_audio, ClipAudioJobs};
Expand Down Expand Up @@ -1344,6 +1344,11 @@ unsafe fn run_multi_inner(
// 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();
// Imported audio tracks (issue #350), cloned out of the borrowed scene.
let audio_tracks = scene
.as_ref()
.map(|scene| scene.audio_tracks.clone())
.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 @@ -1477,7 +1482,7 @@ unsafe fn run_multi_inner(
out_fps as f64,
);
let assembled_audio = finish_audio(
assemble_concatenated_pcm(&clip_pcm, &audio_plan),
mix_external_tracks(assemble_concatenated_pcm(&clip_pcm, &audio_plan), &audio_tracks),
audio_settings,
);
audio_encoder.encode(&assembled_audio, octx)?;
Expand Down
Loading