diff --git a/docs/multitrack-recording-plan.md b/docs/multitrack-recording-plan.md index dce35b6..dc6c34c 100644 --- a/docs/multitrack-recording-plan.md +++ b/docs/multitrack-recording-plan.md @@ -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//` (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//` (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. diff --git a/src/app/mod.rs b/src/app/mod.rs index 58dec01..0b2b51d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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 { // 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![ diff --git a/src/config.rs b/src/config.rs index 3105e19..8976e75 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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).