feat(audio): multitrack stem recording — Stage 2 (wire into the mixer)

Wires MultitrackRecorder into the live audio path, behind a recording_mode.

- config: RecordingMode { Mixed, Multitrack, Both } + AppConfig.recording_mode
  (serde-default Mixed, back-compat); CoreCommand::SetRecordingMode, sent at
  app startup from config.
- multitrack.rs: mic now arrives async via push_mic into an internal FIFO,
  drained one frame per end_cycle (mirrors recorder.rs) so the mic track tracks
  the cycle clock; added dir() accessor. mic is a plain WavWriter now.
- core: parallel `multitrack` slot + `is_multitrack` fast-path gate (exactly one
  of the mixed/multitrack recorders is active). SetRecording start branches on
  mode: Mixed → single-file Recorder (unchanged); Multitrack/Both → a per-session
  dir, MultitrackRecorder, and registers everyone already in the room (named,
  silence-aligned from t=0). The mixer taps each peer's RAW frame (pre-volume/
  mute/limiter) into stems and writes peer stems + mix (Both) + end_cycle per
  cycle; the capture thread pushes mic to whichever recorder; PeerJoined adds a
  late joiner's stem track. stop_recording finalizes both.

No UI yet to pick the mode (Stage 3) — defaults to Mixed, so behaviour is
unchanged until then; set recording_mode in config.json to exercise stems.
168 lib tests, clippy --all-targets clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 00:04:49 -04:00
co-authored by Claude Opus 4.8
parent fde6f8a680
commit f6520b79f7
5 changed files with 218 additions and 40 deletions
+39
View File
@@ -44,6 +44,41 @@ impl RoomLayout {
[RoomLayout::ThreeColumn, RoomLayout::BottomDock, RoomLayout::Drawer];
}
/// What a call recording captures. `Mixed` is the original single-file behaviour;
/// `Multitrack`/`Both` write per-peer stems for post-production (see
/// `docs/multitrack-recording-plan.md`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum RecordingMode {
/// One mixed WAV (your mic + the incoming mix). Smallest; the default.
#[default]
Mixed,
/// One WAV per peer + your mic, all sample-aligned. Rebuild the mix yourself.
Multitrack,
/// Per-peer stems + your mic + a convenience mixed track.
Both,
}
impl RecordingMode {
/// All variants, in picker display order.
pub const ALL: [RecordingMode; 3] =
[RecordingMode::Mixed, RecordingMode::Multitrack, RecordingMode::Both];
/// True when this mode writes per-peer stem tracks (Multitrack or Both).
pub fn is_multitrack(self) -> bool {
matches!(self, RecordingMode::Multitrack | RecordingMode::Both)
}
}
impl std::fmt::Display for RecordingMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
RecordingMode::Mixed => "Mixed (single file)",
RecordingMode::Multitrack => "Multitrack (per-peer stems)",
RecordingMode::Both => "Both (stems + mixed)",
})
}
}
impl std::fmt::Display for RoomLayout {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
@@ -141,6 +176,9 @@ pub struct AppConfig {
/// Chosen UI colour theme.
#[serde(default)]
pub theme: AppTheme,
/// What a call recording captures (mixed / per-peer stems / both).
#[serde(default)]
pub recording_mode: RecordingMode,
#[serde(default)]
pub custom_sound_self_join: Option<String>,
#[serde(default)]
@@ -195,6 +233,7 @@ impl Default for AppConfig {
chat_drawer_width: default_chat_drawer_width(),
room_layout: RoomLayout::default(),
theme: AppTheme::default(),
recording_mode: RecordingMode::default(),
custom_sound_self_join: None,
custom_sound_peer_join: None,
custom_sound_peer_leave: None,