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:
+44
-2
@@ -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![
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user