feat(audio): multitrack stem recording — Stage 3 (Recording settings UI)

Adds the "Recording" category to the Settings page so the recording mode is
selectable from the UI (config + core wiring already existed from Stage 2).

- AppMessage::RecordingModeSelected → persists config + sends SetRecordingMode
  (takes effect on the next recording start). recording_mode_hint copy.
- iced 0.14 pick_list can't host per-option tooltips, so the three modes are
  RADIO BUTTONS each wrapped in a `tooltip` (hover explains that mode) — chosen
  over a dropdown so each option is self-documenting. Placed after Microphone,
  using the shared section_header; output-dir note below.
- +1 config test (recording_mode round-trip + is_multitrack classification).

User-verified live (Both mode writes the expected WAVs; radios + tooltips
approved). Senior-written — Gemini's unsanctioned Stage 3 attempt was discarded.
179 tests (169 lib +1 ign, 6 reconnect, 4 transport), clippy --all-targets clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 02:04:07 -04:00
co-authored by Claude Opus 4.8
parent 729f5ed6a5
commit d3e831d5e7
3 changed files with 65 additions and 5 deletions
+44 -2
View File
@@ -2,12 +2,12 @@ use crate::core::{CoreController, messages::{CoreCommand, UiEvent}};
use crate::network::PeerState;
use crate::notify::{self, Sound};
use crate::audio::pw_cli::{AudioDevice, enumerate_audio_devices};
use crate::config::{AppConfig, NetworkMode, RoomLayout};
use crate::config::{AppConfig, NetworkMode, RecordingMode, RoomLayout};
use crate::theme::{AppTheme, Palette};
use iced::widget::{
container, column, row, text, button, text_input, scrollable, slider, checkbox, pick_list,
progress_bar, canvas, Canvas, Column, stack, mouse_area,
radio, tooltip, progress_bar, canvas, Canvas, Column, stack, mouse_area,
};
use iced::widget::canvas::{Action, Frame, Geometry, Path, Program};
use iced::{
@@ -130,6 +130,7 @@ pub enum AppMessage {
/// immediately but does not persist (saved once on release via NoiseGateChanged).
NoiseGateDragging(f32),
NetworkModeSelected(NetworkMode),
RecordingModeSelected(RecordingMode),
EventOccurred(Event),
NavigateToSettings,
NavigateBack,
@@ -668,6 +669,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
// Applied on the next join, since the endpoint is rebuilt then.
let _ = state.controller.send(CoreCommand::SetNetworkMode(mode));
}
AppMessage::RecordingModeSelected(mode) => {
state.config.recording_mode = mode;
state.config.save();
// Takes effect on the next recording start.
let _ = state.controller.send(CoreCommand::SetRecordingMode(mode));
}
AppMessage::ToggleNotifications(enabled) => {
state.config.notifications_enabled = enabled;
state.config.save();
@@ -860,6 +867,15 @@ fn network_mode_hint(mode: NetworkMode) -> &'static str {
}
}
/// One-line explanation of a recording mode for the settings picker.
fn recording_mode_hint(mode: RecordingMode) -> &'static str {
match mode {
RecordingMode::Mixed => "One WAV: your mic blended with everyone you hear.",
RecordingMode::Multitrack => "One WAV per person + your mic, sample-aligned — mix it yourself.",
RecordingMode::Both => "Per-person stems + your mic AND a ready-made mixed WAV.",
}
}
/// Formats a call duration as `m:ss` (or `h:mm:ss` past an hour).
fn format_duration(total_secs: u64) -> String {
let h = total_secs / 3600;
@@ -1175,6 +1191,21 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
// Spacing between one category and the next.
let section_gap = 18.0;
// One recording-mode radio with a hover tooltip explaining it. (iced's
// pick_list can't host per-option tooltips, so the modes are radios.)
let mode_radio = |mode: RecordingMode, label: &'static str| -> Element<'_, AppMessage> {
tooltip(
radio(label, mode, Some(state.config.recording_mode), AppMessage::RecordingModeSelected),
container(text(recording_mode_hint(mode)).size(11).color(color_text))
.padding(8)
.max_width(300.0)
.style(c_style(color_crust, color_surface, 6.0)),
iced::widget::tooltip::Position::Right,
)
.gap(8)
.into()
};
let settings_content = scrollable(
column![
// --- Audio Devices ---
@@ -1220,6 +1251,17 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
].spacing(8).width(iced::Length::Fill),
vertical_space(section_gap),
// --- Recording ---
section_header("Recording"),
column![
mode_radio(RecordingMode::Mixed, "Mixed (single file)"),
mode_radio(RecordingMode::Multitrack, "Multitrack (per-peer stems)"),
mode_radio(RecordingMode::Both, "Both (stems + mixed)"),
vertical_space(2.0),
text("Hover an option for what it does. Saved to ~/peerspeak-recordings/ — Multitrack/Both as a timestamped folder of tracks, Mixed as a single file. Applies to your next recording.").size(11).color(color_subtext),
].spacing(8).width(iced::Length::Fill),
vertical_space(section_gap),
// --- Network & Privacy ---
section_header("Network & Privacy"),
column![
+18
View File
@@ -362,6 +362,24 @@ mod tests {
assert_eq!(back.window_y, Some(-50));
}
#[test]
fn test_recording_mode_field() {
// Default is Mixed (back-compat with pre-feature configs).
assert_eq!(AppConfig::default().recording_mode, RecordingMode::Mixed);
// A chosen mode round-trips through JSON.
let cfg = AppConfig {
recording_mode: RecordingMode::Both,
..AppConfig::default()
};
let back: AppConfig =
serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
assert_eq!(back.recording_mode, RecordingMode::Both);
// is_multitrack() classifies correctly.
assert!(!RecordingMode::Mixed.is_multitrack());
assert!(RecordingMode::Multitrack.is_multitrack());
assert!(RecordingMode::Both.is_multitrack());
}
#[test]
fn test_theme_field() {
// Default theme is Mocha (the original look).