Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 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
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
138 changes: 137 additions & 1 deletion crates/compositor/src/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,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 @@ -914,6 +914,84 @@ 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,
};
let gain = 10.0f32.powf(track.gain_db.clamp(-12.0, 12.0) / 20.0);
overlay_track_pcm(&mut programme, &decoded, offset, gain);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
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) {
let programme_len = programme.first().map(Vec::len).unwrap_or(0);
if offset >= programme_len {
return;
}
let room = programme_len - offset;
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;
}
}
}

/// 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 @@ -1044,6 +1122,64 @@ 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);
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);
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);
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
}];
// 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),
}];
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
13 changes: 11 additions & 2 deletions crates/compositor/src/pipeline_linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ use std::ptr;

use crate::audio::{
assemble_concatenated_pcm, build_audio_concat_plan, decode_clip_audio, finish_audio,
stretch_clip_pcm_by_speed, AacEncoder, PlanarPcm,
mix_external_tracks, stretch_clip_pcm_by_speed, AacEncoder, PlanarPcm,
};
use crate::config::Cfg;
use crate::d3d::Gpu;
Expand Down Expand Up @@ -458,6 +458,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 @@ -535,7 +541,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 @@ -31,7 +31,7 @@

use crate::audio::{
assemble_concatenated_pcm, build_audio_concat_plan, decode_clip_audio, finish_audio,
stretch_clip_pcm_by_speed, AacEncoder, PlanarPcm,
mix_external_tracks, stretch_clip_pcm_by_speed, AacEncoder, PlanarPcm,
};
use crate::compositor::Compositor;
use crate::d3d::Gpu;
Expand Down Expand Up @@ -1077,6 +1077,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 @@ -1132,7 +1137,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 @@ -4,7 +4,7 @@

use crate::audio::{
assemble_concatenated_pcm, build_audio_concat_plan, decode_clip_audio, finish_audio,
stretch_clip_pcm_by_speed, AacEncoder, PlanarPcm,
mix_external_tracks, stretch_clip_pcm_by_speed, AacEncoder, PlanarPcm,
};
use crate::compositor::{Compositor, OUT_H, OUT_W};
use crate::config::Cfg;
Expand Down Expand Up @@ -1343,6 +1343,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 @@ -1467,7 +1472,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
27 changes: 27 additions & 0 deletions crates/compositor/src/scene.rs
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,29 @@ pub struct SceneAudio {
pub gain_db: f32,
}

/// One imported audio track (issue #350) mixed over the assembled programme —
/// voiceover / BGM / SFX. Deliberately a SEPARATE `Scene` field rather than a
/// member of `SceneAudio`, so `SceneAudio` stays `Copy` and the pipelines keep
/// copying it out of a borrow unchanged.
///
/// `start_sec` is the track's head on the OUTPUT programme; `trim_start_sec` /
/// `trim_end_sec` window the source file (both source seconds). The renderer
/// resolves `start_sec` from the track's raw timeline position — equal to it when
/// the project has no trims/speed, which is the case this first cut mixes exactly.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SceneAudioTrack {
pub path: String,
#[serde(default)]
pub start_sec: f64,
#[serde(default)]
pub gain_db: f32,
#[serde(default)]
pub trim_start_sec: f64,
#[serde(default)]
pub trim_end_sec: Option<f64>,
}

#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SceneOutput {
Expand Down Expand Up @@ -451,6 +474,10 @@ pub struct Scene {
/// Global audio finishing. Default keeps old scene payloads bit-for-bit compatible.
#[serde(default)]
pub audio: SceneAudio,
/// Imported audio tracks mixed over the programme (issue #350). `#[serde(default)]`:
/// absent from every scene written before this, and from a project with none.
#[serde(default)]
pub audio_tracks: Vec<SceneAudioTrack>,
/// Crop écran par clip, dans le même ordre que `clips` (`cropByClip` côté TS).
#[serde(default)]
pub crop_by_clip: Vec<Option<SceneCrop>>,
Expand Down
86 changes: 86 additions & 0 deletions electron/ai-edition/document-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,48 @@ describe("DocumentService", () => {
expect(after.project.primaryAssetId).toBe(first.project.primaryAssetId);
expect(after.assets).toHaveLength(2);
});

// Issue #350 — external audio import (voiceover / BGM / SFX).
it("appends an audio asset without claiming the primary slot", async () => {
const doc = await service.createProject("P");
const updated = await service.addAsset(doc.project.id, {
path: "/tmp/voiceover.mp3",
kind: "audio",
});
expect(updated.assets).toHaveLength(1);
expect(updated.assets[0]?.kind).toBe("audio");
// An audio-only file must never become the project's primary asset, even
// when it is the first file added to an otherwise-empty project.
expect(updated.project.primaryAssetId).toBeUndefined();
});

it("keeps the existing video primary when an audio track is added", async () => {
const doc = await service.createProject("P");
const withVideo = await service.addAsset(doc.project.id, { path: "/tmp/screen.mp4" });
const primary = withVideo.project.primaryAssetId;
const withAudio = await service.addAsset(doc.project.id, {
path: "/tmp/bgm.wav",
kind: "audio",
});
expect(withAudio.project.primaryAssetId).toBe(primary);
expect(withAudio.assets).toHaveLength(2);
});

it("rejects unsupported audio extensions", async () => {
const doc = await service.createProject("P");
await expect(
service.addAsset(doc.project.id, { path: "/tmp/clip.mp4", kind: "audio" }),
).rejects.toBeInstanceOf(ProjectFileError);
});

it("accepts a video extension under the default kind but not as audio", async () => {
const doc = await service.createProject("P");
// The same extension routing works in reverse: an .mp3 is fine as audio
// but rejected as video (covered above), and an .mp4 is the opposite.
await expect(
service.addAsset(doc.project.id, { path: "/tmp/a.mp3", kind: "audio" }),
).resolves.toBeDefined();
});
});

describe("removeAsset", () => {
Expand Down Expand Up @@ -363,6 +405,50 @@ describe("DocumentService", () => {
expect(after.project.primaryAssetId).toBe(b.assets[1]?.id);
});

// Issue #350 — an audio overlay can never be primary.
it("passes primary to the next VIDEO asset, never to an audio asset", async () => {
const doc = await service.createProject("P");
const video = await service.addAsset(doc.project.id, { path: "/tmp/screen.mp4" });
await service.addAsset(doc.project.id, { path: "/tmp/music.mp3", kind: "audio" });
const primaryId = video.project.primaryAssetId;
expect(primaryId).toBeTruthy();
// Removing the only video leaves just the audio asset; primary must clear,
// not fall to the audio one.
const after = await service.removeAsset(doc.project.id, primaryId ?? "");
expect(after.project.primaryAssetId).toBeUndefined();
expect(after.assets).toHaveLength(1);
expect(after.assets[0]?.kind).toBe("audio");
});

it("drops audioTracks that referenced a removed audio asset", async () => {
const doc = await service.createProject("P");
await service.addAsset(doc.project.id, { path: "/tmp/screen.mp4" });
const withAudio = await service.addAsset(doc.project.id, {
path: "/tmp/music.mp3",
kind: "audio",
});
const audioId = withAudio.assets.find((a) => a.kind === "audio")?.id ?? "";
expect(audioId).toBeTruthy();
const withTrack = await service.saveProject({
...withAudio,
audioTracks: [
{
id: "trk_1",
assetId: audioId,
timelineStartSec: 0,
durationSec: 10,
trimStartSec: 0,
gainDb: 0,
label: "music",
},
],
});
expect(withTrack.audioTracks).toHaveLength(1);
const after = await service.removeAsset(doc.project.id, audioId);
expect(after.audioTracks).toEqual([]);
expect(after.assets.some((a) => a.id === audioId)).toBe(false);
});

it("resequences other assets and rederives their anchored regions", async () => {
const created = await service.createProject("P");
const withA = await service.addAsset(created.project.id, { path: "/tmp/a.mp4" });
Expand Down
Loading