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:
+44
-16
@@ -12,7 +12,7 @@
|
||||
//! This module is pure plumbing over [`WavWriter`]: no audio decode, no
|
||||
//! networking, no realtime work. The mixer (a non-RT task) drives it.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -25,6 +25,11 @@ use crate::core::jitter::FRAME_SAMPLES;
|
||||
/// long-running call can't trigger a single multi-hundred-MB allocation.
|
||||
const SILENCE_CHUNK: usize = FRAME_SAMPLES * 256;
|
||||
|
||||
/// Cap on buffered mic samples (~200ms @ 48kHz). Bounds how far the mic track
|
||||
/// can drift if the capture clock runs ahead of the mixer cycle; past it the
|
||||
/// oldest mic audio is dropped. Mirrors `recorder::MAX_MIC_FIFO`.
|
||||
const MAX_MIC_FIFO: usize = 48_000 / 5;
|
||||
|
||||
/// One output track: its WAV writer plus whether it has been written *this*
|
||||
/// cycle (so `end_cycle` knows which tracks to pad with silence).
|
||||
struct Track {
|
||||
@@ -97,7 +102,11 @@ pub struct MultitrackRecorder {
|
||||
/// Cycles recorded so far = the shared length (in frames) of every track.
|
||||
cycles: u64,
|
||||
peers: HashMap<EndpointId, Track>,
|
||||
mic: Track,
|
||||
/// Your mic track. Fed asynchronously from the capture thread via
|
||||
/// [`push_mic`](MultitrackRecorder::push_mic) into `mic_fifo`, then drained
|
||||
/// one frame per `end_cycle` so it aligns with the cycle clock.
|
||||
mic: WavWriter,
|
||||
mic_fifo: VecDeque<i16>,
|
||||
/// Present in "Both" mode (stems + mixed), absent in "stems only".
|
||||
mix: Option<Track>,
|
||||
}
|
||||
@@ -106,7 +115,7 @@ impl MultitrackRecorder {
|
||||
/// Create a recording in `dir` (which must already exist). `with_mix` adds
|
||||
/// the convenience mixed track (`mix.wav`). Your mic is always `me.wav`.
|
||||
pub fn create(dir: &Path, frame_samples: usize, with_mix: bool) -> io::Result<Self> {
|
||||
let mic = Track::create(&dir.join("me.wav"))?;
|
||||
let mic = WavWriter::new(&dir.join("me.wav"))?;
|
||||
let mix = if with_mix {
|
||||
Some(Track::create(&dir.join("mix.wav"))?)
|
||||
} else {
|
||||
@@ -118,10 +127,16 @@ impl MultitrackRecorder {
|
||||
cycles: 0,
|
||||
peers: HashMap::new(),
|
||||
mic,
|
||||
mic_fifo: VecDeque::new(),
|
||||
mix,
|
||||
})
|
||||
}
|
||||
|
||||
/// The session directory holding all the track files.
|
||||
pub fn dir(&self) -> &Path {
|
||||
&self.dir
|
||||
}
|
||||
|
||||
/// Register a peer's stem track, pre-padding it with silence back to cycle 0
|
||||
/// so it aligns with the others. Idempotent: a peer already tracked is left
|
||||
/// as-is (re-announce / name change doesn't restart their file).
|
||||
@@ -146,10 +161,23 @@ impl MultitrackRecorder {
|
||||
self.peers.get_mut(&id).unwrap().write_frame(frame, fs)
|
||||
}
|
||||
|
||||
/// Record your mic frame for the current cycle.
|
||||
pub fn write_mic(&mut self, frame: &[i16]) -> io::Result<()> {
|
||||
let fs = self.frame_samples;
|
||||
self.mic.write_frame(frame, fs)
|
||||
/// Buffer a frame of your transmitted mic audio (called from the capture
|
||||
/// thread, asynchronously to the mixer cycle). Bounded: oldest samples drop
|
||||
/// past [`MAX_MIC_FIFO`] so the mic track's offset can't grow without limit.
|
||||
pub fn push_mic(&mut self, frame: &[i16]) {
|
||||
self.mic_fifo.extend(frame.iter().copied());
|
||||
let overflow = self.mic_fifo.len().saturating_sub(MAX_MIC_FIFO);
|
||||
if overflow > 0 {
|
||||
self.mic_fifo.drain(..overflow);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull exactly `n` mic samples from the FIFO, silence-padded on underrun.
|
||||
fn drain_mic(&mut self, n: usize) -> Vec<i16> {
|
||||
let take = n.min(self.mic_fifo.len());
|
||||
let mut v: Vec<i16> = self.mic_fifo.drain(..take).collect();
|
||||
v.resize(n, 0);
|
||||
v
|
||||
}
|
||||
|
||||
/// Record the finished mixed-bus frame for the current cycle (no-op in
|
||||
@@ -167,12 +195,12 @@ impl MultitrackRecorder {
|
||||
/// per mixer cycle, after the per-track writes.
|
||||
pub fn end_cycle(&mut self) -> io::Result<()> {
|
||||
let fs = self.frame_samples;
|
||||
for track in self
|
||||
.peers
|
||||
.values_mut()
|
||||
.chain(std::iter::once(&mut self.mic))
|
||||
.chain(self.mix.as_mut())
|
||||
{
|
||||
// Mic: always one frame per cycle, drained from the FIFO (silence on
|
||||
// underrun), so it tracks the cycle clock like the peer stems.
|
||||
let mic_frame = self.drain_mic(fs);
|
||||
self.mic.write_samples(&mic_frame)?;
|
||||
// Peers + the optional mix track: pad any not written this cycle.
|
||||
for track in self.peers.values_mut().chain(self.mix.as_mut()) {
|
||||
if !track.written_this_cycle {
|
||||
track.write_silence(fs)?;
|
||||
}
|
||||
@@ -184,7 +212,7 @@ impl MultitrackRecorder {
|
||||
|
||||
/// Finalize every track's WAV header. Consumes the recorder.
|
||||
pub fn finalize(self) -> io::Result<()> {
|
||||
self.mic.writer.finalize()?;
|
||||
self.mic.finalize()?;
|
||||
if let Some(mix) = self.mix {
|
||||
mix.writer.finalize()?;
|
||||
}
|
||||
@@ -245,14 +273,14 @@ mod tests {
|
||||
rec.add_peer(p1, "p1").unwrap();
|
||||
rec.add_peer(p2, "p2").unwrap();
|
||||
|
||||
// 3 cycles; p1 talks every cycle, p2 only on cycle 2, mic talks twice.
|
||||
// 3 cycles; p1 talks every cycle, p2 only on cycle 2, mic pushed twice.
|
||||
for c in 0..3 {
|
||||
rec.write_peer(p1, &[1, 1, 1, 1]).unwrap();
|
||||
if c == 2 {
|
||||
rec.write_peer(p2, &[2, 2, 2, 2]).unwrap();
|
||||
}
|
||||
if c < 2 {
|
||||
rec.write_mic(&[9, 9, 9, 9]).unwrap();
|
||||
rec.push_mic(&[9, 9, 9, 9]);
|
||||
}
|
||||
rec.write_mix(&[5, 5, 5, 5]).unwrap();
|
||||
rec.end_cycle().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user