feat: local call recording (your mic + incoming mix) to WAV

Opt-in recording of the full call as you experienced it. New dep-free
src/audio/recorder.rs: a canonical mono S16LE WavWriter (header patched on
finalize) plus a Recorder that buffers your transmitted mic in a bounded FIFO
and sums it, sample-aligned, with each incoming-mix frame the playout mixer
produces. The two independently-clocked streams stay aligned via the FIFO
(capped at ~200ms so drift lag can't grow without bound); silent stretches
record the incoming mix alone. Dep-free UTC timestamp -> sortable filename.

Wiring: CoreCommand::SetRecording toggles an Arc<Mutex<Option<Recorder>>> gated
by an is_recording flag (so the capture/mixer hot paths only lock while actually
recording); capture pushes post-gate mic, the mixer writes the pre-deafen mix.
Recording finalizes on stop, room leave, and room switch. UI: a Record/Stop
button in the controls and a red "● REC m:ss" pill in the room header;
core-confirmed Recording{Started,Stopped} events drive the UI flag so a failed
start can't lie. Files land in ~/peerspeak-recordings/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 21:48:46 -04:00
co-authored by Claude Opus 4.8
parent 674c9b6950
commit c3cf00f46f
5 changed files with 410 additions and 0 deletions
+54
View File
@@ -63,6 +63,8 @@ pub enum AppMessage {
ToggleEchoCancellation(bool),
CustomSoundPathChanged(Sound, String),
ToggleMicTest(bool),
/// Start/stop recording the call; the core confirms via Recording{Started,Stopped}.
ToggleRecording,
}
fn core_subscription() -> impl iced::futures::Stream<Item = UiEvent> {
@@ -103,6 +105,10 @@ pub struct AppState {
locally_muted: HashSet<EndpointId>,
/// When we joined the current room, for the in-room call-duration timer.
call_started: Option<std::time::Instant>,
/// Whether a local call recording is in progress (confirmed by the core).
recording: bool,
/// When the current recording started, for the header REC timer.
recording_started: Option<std::time::Instant>,
/// Raw mic level (normalized RMS, `0.0..=1.0`) for the settings meter.
mic_level: f32,
/// Whether the standalone (off-call) mic test stream is running.
@@ -174,6 +180,8 @@ impl Default for AppState {
audio_levels: HashMap::new(),
locally_muted: HashSet::new(),
call_started: None,
recording: false,
recording_started: None,
mic_level: 0.0,
mic_test_active: false,
connecting: HashSet::new(),
@@ -310,6 +318,8 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.audio_levels.clear();
state.locally_muted.clear();
state.call_started = None;
state.recording = false;
state.recording_started = None;
state.connecting.clear();
state.ever_connected.clear();
state.status_message = "Ready to connect".to_string();
@@ -362,6 +372,16 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
UiEvent::MicLevel(level) => {
state.mic_level = level;
}
UiEvent::RecordingStarted { path } => {
state.recording = true;
state.recording_started = Some(std::time::Instant::now());
state.status_message = format!("Recording → {path}");
}
UiEvent::RecordingStopped { path } => {
state.recording = false;
state.recording_started = None;
state.status_message = format!("Saved recording → {path}");
}
UiEvent::Error(err) => {
state.status_message = format!("Error: {}", err);
}
@@ -454,6 +474,11 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
Sound::ReconnectFailed => state.config.custom_sound_reconnect_failed = path_opt,
}
}
AppMessage::ToggleRecording => {
// Optimistic intent; the core flips `recording` for real via the
// Recording{Started,Stopped} events (so a failed start won't lie).
let _ = state.controller.send(CoreCommand::SetRecording(!state.recording));
}
AppMessage::ToggleMicTest(enabled) => {
state.mic_test_active = enabled;
if !enabled {
@@ -897,6 +922,18 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
text(format!("{}", format_duration(call_secs)))
.size(14)
.color(color_subtext),
if state.recording {
let rec_secs = state.recording_started.map(|t| t.elapsed().as_secs()).unwrap_or(0);
container(
text(format!("● REC {}", format_duration(rec_secs)))
.size(13)
.color(color_red)
)
.style(c_style(color_crust, color_red, 6.0))
.padding(6)
} else {
container(text("")).padding(0)
},
horizontal_space(),
text(format!("My ID: {}", &state.self_id[..8]))
.size(14)
@@ -1091,6 +1128,23 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
} else {
column![]
},
vertical_space(20.0),
{
let (rec_label, rec_bg, rec_hover, rec_fg) = if state.recording {
("⏹ Stop Recording", color_red, color_maroon, color_crust)
} else {
("⏺ Record Call", color_surface, color_blue, color_text)
};
button(
text(rec_label)
.size(16)
.align_x(iced::alignment::Horizontal::Center)
)
.on_press(AppMessage::ToggleRecording)
.style(b_style(rec_bg, rec_hover, rec_fg, 8.0))
.padding(14)
.width(iced::Length::Fill)
},
vertical_space(30.0),
button(
text("Leave Room")