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
+3 -3
View File
@@ -1,6 +1,6 @@
# Multitrack (stem) recording — plan / scope contract
**Status:** Stages 1 + 2 DONE (2026-06-13). Stage 3 (config UI) next, then Stage 4 (field-test). This doc is the scope contract; update it as stages land.
**Status:** Stages 1, 2 + 3 DONE (Stage 3 on 2026-06-14, all by the senior). Stage 4 (2-machine field-test on dopedart) is the only remaining step. This doc is the scope contract; update it as stages land.
## Goal & differentiation
@@ -29,8 +29,8 @@ Pure/testable: tests assert "after N cycles every track is exactly N×FRAME_SAMP
### Stage 2 — Wire into the mixer + mic tasks — DONE (`f6520b7`)
Done: mixer taps each peer's raw frame into `stems`, writes peer stems + mix (Both) + `end_cycle` per cycle; mic pushed to the recorder's FIFO from the capture thread; `add_peer` on `PeerJoined` (named) + for everyone present at recording start; `Mixed` mode keeps the single-file `Recorder`; `RecordingMode` config + `SetRecordingMode` command (sent at startup) select the path; `is_multitrack` is the fast-path gate; `stop_recording` finalizes both. Note: mic uses an internal FIFO drained per cycle (not a per-cycle `write_mic`), matching `recorder.rs`. No UI yet → defaults to `Mixed`; set `recording_mode` in config.json to exercise stems until Stage 3.
### Stage 3 — Config + UI
`AppConfig.recording_mode: Mixed | Multitrack | Both` (serde-default `Mixed`, back-compat). New **"Recording"** category in Settings (fits the category-header layout) with the mode picker + an output-dir note. Output to a per-session dir `~/peerspeak-recordings/<timestamp>/` (Multitrack/Both); `Mixed` keeps today's single-file behaviour.
### Stage 3 — Config + UI — DONE (2026-06-14, senior-written)
`AppConfig.recording_mode` was added in Stage 2; Stage 3 added the **"Recording"** Settings category (after Microphone): `AppMessage::RecordingModeSelected` (persists + sends `SetRecordingMode`), `recording_mode_hint`, and the picker. **Note:** iced 0.14 `pick_list` can't host per-option tooltips, so the modes are rendered as **radio buttons each wrapped in a `tooltip`** (hover shows the per-mode explanation) — user-chosen over a dropdown. Output dir note included. Output to a per-session dir `~/peerspeak-recordings/<timestamp>/` (Multitrack/Both); `Mixed` keeps the single-file behaviour. +1 config test (`test_recording_mode_field`). User-verified live (2 WAVs created as expected in Both mode; radios + tooltips approved). (Gemini's unsanctioned Stage 3 attempt was discarded; this is the senior's implementation.)
### Stage 4 — Field-test (dopedart)
Solo first (mic + silence stems, inspect WAVs), then 2-machine desktop↔dopedart: each voice isolated on its own track, tracks sample-aligned + in sync, late-joiner leading-silence correct, peer-leave handled.
+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).