diff --git a/src/app/mod.rs b/src/app/mod.rs index 3c704f4..6e0fb8c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -4,7 +4,7 @@ use crate::audio::clip_player::{ }; use crate::audio::eq::{EQ_GAIN_DB_MAX, EQ_GAIN_DB_MIN, EqSettings}; use crate::audio::{AudioDevice, enumerate_audio_devices}; -use crate::config::{AppConfig, NetworkMode, RecordingMode, RoomLayout}; +use crate::config::{AppConfig, AudioProfile, NetworkMode, RecordingMode, RoomLayout}; use crate::core::{ CoreController, messages::{CoreCommand, UiEvent}, @@ -377,6 +377,7 @@ pub enum AppMessage { /// immediately but does not persist (saved once on release via NoiseGateChanged). NoiseGateDragging(f32), NetworkModeSelected(NetworkMode), + AudioProfileSelected(AudioProfile), RecordingModeSelected(RecordingMode), /// Choose the friends presence posture (W7): invisible / normal / discoverable. PresenceModeSelected(PresenceMode), @@ -919,6 +920,7 @@ impl Default for AppState { let _ = controller.send(CoreCommand::SetInputVolume(config.input_volume)); let _ = controller.send(CoreCommand::SetOutputVolume(config.output_volume)); let _ = controller.send(CoreCommand::SetNetworkMode(config.network_mode)); + let _ = controller.send(CoreCommand::SetAudioProfile(config.audio_profile)); let _ = controller.send(CoreCommand::SetRecordingMode(config.recording_mode)); let _ = controller.send(CoreCommand::SetPixelpassPath(config.pixelpass_path.clone())); let _ = controller.send(CoreCommand::SetPresenceMode(config.presence_mode)); @@ -2050,6 +2052,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::AudioProfileSelected(profile) => { + state.config.audio_profile = profile; + state.config.save(); + // Applies live to the running encoder, and to the next call. + let _ = state.controller.send(CoreCommand::SetAudioProfile(profile)); + } AppMessage::RecordingModeSelected(mode) => { state.config.recording_mode = mode; state.config.save(); @@ -3123,6 +3131,19 @@ fn network_mode_hint(mode: NetworkMode) -> &'static str { } } +/// One-line explanation of an audio/network profile for the settings picker (W12). +fn audio_profile_hint(profile: AudioProfile) -> &'static str { + match profile { + AudioProfile::LowLatency => { + "Lowest delay, no loss recovery. Best on a clean LAN or wired link." + } + AudioProfile::Balanced => "Default: voice quality with light loss recovery.", + AudioProfile::BadNetwork => { + "Most resilient on a lossy/congested link: extra loss recovery, lower bitrate." + } + } +} + /// One-line explanation of a recording mode for the settings picker. fn recording_mode_hint(mode: RecordingMode) -> &'static str { match mode { @@ -4981,6 +5002,17 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { control }, ].spacing(8).width(iced::Length::Fill), + vertical_space(section_gap), + section_header("Connection quality"), + column![ + pick_list( + &AudioProfile::ALL[..], + Some(state.config.audio_profile), + AppMessage::AudioProfileSelected, + ).width(iced::Length::Fill), + text(audio_profile_hint(state.config.audio_profile)).size(11).color(color_subtext), + text("Applies immediately, even mid-call.").size(11).color(color_subtext), + ].spacing(4).width(iced::Length::Fill), ] .spacing(10) .width(iced::Length::Fill) diff --git a/src/codec/opus_impl.rs b/src/codec/opus_impl.rs index 714914e..fcd46fc 100644 --- a/src/codec/opus_impl.rs +++ b/src/codec/opus_impl.rs @@ -1,5 +1,49 @@ use crate::codec::{AudioDecoder, AudioEncoder, CodecError}; -use opus::{Application, Channels, Decoder, Encoder}; +use crate::config::AudioProfile; +use opus::{Application, Bitrate, Channels, Decoder, Encoder}; + +/// Concrete libopus encoder settings derived from an [`AudioProfile`]. Plain +/// data, so the profile→params mapping ([`opus_params`]) stays a pure, +/// unit-testable function (W12). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OpusParams { + /// Target bitrate in bits/sec. + pub bitrate: i32, + /// Enable in-band forward error correction (loss redundancy in the bitstream). + pub inband_fec: bool, + /// Expected packet-loss percentage (0..=100); tunes how much FEC libopus adds. + pub packet_loss_perc: i32, + /// Discontinuous transmission: stop sending during silence to save bandwidth. + pub dtx: bool, +} + +/// Map a named profile to concrete Opus parameters. Pure — the W12 testable seam. +/// +/// `BadNetwork` deliberately runs a *lower* bitrate than `Balanced`: in-band FEC +/// redundancy is carried inside the same bitstream, so trimming the base bitrate +/// leaves headroom for the redundancy on a congested link. +pub fn opus_params(profile: AudioProfile) -> OpusParams { + match profile { + AudioProfile::LowLatency => OpusParams { + bitrate: 24_000, + inband_fec: false, + packet_loss_perc: 0, + dtx: false, + }, + AudioProfile::Balanced => OpusParams { + bitrate: 32_000, + inband_fec: true, + packet_loss_perc: 10, + dtx: false, + }, + AudioProfile::BadNetwork => OpusParams { + bitrate: 20_000, + inband_fec: true, + packet_loss_perc: 25, + dtx: true, + }, + } +} pub struct OpusEncoder { encoder: Encoder, @@ -17,6 +61,29 @@ impl OpusEncoder { .map_err(|e| CodecError::Init(format!("Failed to create Opus encoder: {}", e)))?; Ok(Self { encoder }) } + + /// Apply concrete codec parameters to the live encoder. Safe to call between + /// frames, so the user can switch profile mid-call. + pub fn apply_params(&mut self, params: &OpusParams) -> Result<(), CodecError> { + self.encoder + .set_bitrate(Bitrate::Bits(params.bitrate)) + .map_err(|e| CodecError::Init(format!("set_bitrate: {}", e)))?; + self.encoder + .set_inband_fec(params.inband_fec) + .map_err(|e| CodecError::Init(format!("set_inband_fec: {}", e)))?; + self.encoder + .set_packet_loss_perc(params.packet_loss_perc) + .map_err(|e| CodecError::Init(format!("set_packet_loss_perc: {}", e)))?; + self.encoder + .set_dtx(params.dtx) + .map_err(|e| CodecError::Init(format!("set_dtx: {}", e)))?; + Ok(()) + } + + /// Apply a named [`AudioProfile`] (shorthand for `apply_params(&opus_params(p))`). + pub fn apply_profile(&mut self, profile: AudioProfile) -> Result<(), CodecError> { + self.apply_params(&opus_params(profile)) + } } impl AudioEncoder for OpusEncoder { @@ -101,6 +168,47 @@ impl AudioDecoder for OpusDecoder { mod tests { use super::*; + #[test] + fn test_opus_params_mapping() { + let low = opus_params(AudioProfile::LowLatency); + let bal = opus_params(AudioProfile::Balanced); + let bad = opus_params(AudioProfile::BadNetwork); + + // LowLatency has no loss redundancy; the other two do. + assert!(!low.inband_fec); + assert_eq!(low.packet_loss_perc, 0); + assert!(bal.inband_fec); + assert!(bad.inband_fec); + + // BadNetwork is the only profile that enables DTX, and it expects the + // heaviest loss. + assert!(bad.dtx); + assert!(!low.dtx && !bal.dtx); + assert!(bad.packet_loss_perc > bal.packet_loss_perc); + + // BadNetwork trims base bitrate to make room for FEC redundancy. + assert!(bad.bitrate < bal.bitrate); + + // All bitrates are sane positive voice rates. + for p in [low, bal, bad] { + assert!(p.bitrate > 0 && p.bitrate <= 64_000); + assert!((0..=100).contains(&p.packet_loss_perc)); + } + } + + #[test] + fn test_apply_profile_sets_bitrate() { + let mut encoder = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap(); + // Every profile applies cleanly to a real encoder... + for profile in AudioProfile::ALL { + encoder.apply_profile(profile).unwrap(); + } + // ...and the last-applied bitrate is reflected by the encoder. + encoder.apply_profile(AudioProfile::Balanced).unwrap(); + let want = opus_params(AudioProfile::Balanced).bitrate; + assert_eq!(encoder.encoder.get_bitrate().unwrap(), Bitrate::Bits(want)); + } + #[test] fn test_round_trip() { let mut encoder = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap(); diff --git a/src/config.rs b/src/config.rs index 8627ab7..b78f2bf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -93,6 +93,60 @@ impl std::fmt::Display for RecordingMode { } } +/// Named Opus encoder / network-resilience policy (W12). The user picks a +/// profile instead of raw codec knobs; the concrete libopus parameters live in +/// `codec::opus_impl::opus_params`. Applies live to the running encoder. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum AudioProfile { + /// Lowest mouth-to-ear delay: modest bitrate, no FEC redundancy. Best on a + /// clean LAN / low-loss link where added latency matters more than loss. + LowLatency, + /// Sensible default: voice bitrate with in-band FEC for light packet loss. + #[default] + Balanced, + /// Maximum resilience on a lossy/congested link: in-band FEC tuned for heavy + /// loss plus DTX, at a lower bitrate to leave headroom for the redundancy. + BadNetwork, +} + +impl AudioProfile { + /// All variants, in picker display order. + pub const ALL: [AudioProfile; 3] = [ + AudioProfile::LowLatency, + AudioProfile::Balanced, + AudioProfile::BadNetwork, + ]; + + /// Compact discriminant for handing the profile to the capture thread via an + /// atomic. Pairs with [`AudioProfile::from_u8`]. + pub fn as_u8(self) -> u8 { + match self { + AudioProfile::LowLatency => 0, + AudioProfile::Balanced => 1, + AudioProfile::BadNetwork => 2, + } + } + + /// Inverse of [`AudioProfile::as_u8`]; unknown values fall back to the default. + pub fn from_u8(v: u8) -> AudioProfile { + match v { + 0 => AudioProfile::LowLatency, + 2 => AudioProfile::BadNetwork, + _ => AudioProfile::Balanced, + } + } +} + +impl std::fmt::Display for AudioProfile { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + AudioProfile::LowLatency => "Low latency", + AudioProfile::Balanced => "Balanced", + AudioProfile::BadNetwork => "Bad network", + }) + } +} + impl std::fmt::Display for RoomLayout { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { @@ -199,6 +253,10 @@ pub struct AppConfig { pub clip_volume_universal: bool, #[serde(default)] pub network_mode: NetworkMode, + /// Opus encoder / network-resilience profile (W12). Applies live to the + /// running encoder; default `Balanced`. + #[serde(default)] + pub audio_profile: AudioProfile, /// Presence posture for the friends idle listener (W7): invisible / normal / /// discoverable. Default `Normal` = answer friends only, no DNS beacon. #[serde(default)] @@ -380,6 +438,7 @@ impl Default for AppConfig { show_player_bar: true, clip_volume_universal: true, network_mode: NetworkMode::default(), + audio_profile: AudioProfile::default(), presence_mode: crate::presence::PresenceMode::default(), echo_cancellation_enabled: false, notifications_enabled: true, @@ -1019,6 +1078,38 @@ mod tests { assert_ne!(display_0, display_2); } + #[test] + fn test_audio_profile() { + // Default is Balanced. + assert_eq!(AudioProfile::default(), AudioProfile::Balanced); + + // ALL holds the three variants. + assert_eq!(AudioProfile::ALL.len(), 3); + assert!(AudioProfile::ALL.contains(&AudioProfile::LowLatency)); + assert!(AudioProfile::ALL.contains(&AudioProfile::Balanced)); + assert!(AudioProfile::ALL.contains(&AudioProfile::BadNetwork)); + + // as_u8 / from_u8 round-trip every variant, and unknown bytes fall back + // to the default rather than panicking. + for p in AudioProfile::ALL { + assert_eq!(AudioProfile::from_u8(p.as_u8()), p); + } + assert_eq!(AudioProfile::from_u8(99), AudioProfile::Balanced); + + // serde round-trips, and Display strings are non-empty + distinct. + let mut labels = Vec::new(); + for p in AudioProfile::ALL { + let s = serde_json::to_string(&p).unwrap(); + assert_eq!(serde_json::from_str::(&s).unwrap(), p); + let label = p.to_string(); + assert!(!label.is_empty()); + labels.push(label); + } + labels.sort(); + labels.dedup(); + assert_eq!(labels.len(), 3); + } + #[test] fn test_unknown_field_tolerance() { // Unknown/extra field tolerance: a config JSON containing an extra unrecognized key should still deserialize. diff --git a/src/core/messages.rs b/src/core/messages.rs index e953a43..d29e8a6 100644 --- a/src/core/messages.rs +++ b/src/core/messages.rs @@ -1,4 +1,4 @@ -use crate::config::{NetworkMode, RecordingMode}; +use crate::config::{AudioProfile, NetworkMode, RecordingMode}; use crate::friends::Friend; use crate::network::PeerState; use crate::presence::{FriendPresence, PresenceMode}; @@ -56,6 +56,10 @@ pub enum CoreCommand { /// Set the relay/discovery posture. Takes effect on the next room join, /// since the endpoint is (re)built then. SetNetworkMode(NetworkMode), + /// Set the Opus encoder / network-resilience profile (W12). Applies live to + /// the running capture encoder, and to the next call's encoder. Sent at + /// startup from config and whenever the user changes it. + SetAudioProfile(AudioProfile), /// Start/stop recording the call to a local WAV (your mic + the incoming /// mix). No-op start if already recording / not in a call. SetRecording(bool), @@ -212,6 +216,7 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass { input_device: _, } | CoreCommand::SetNetworkMode(_) + | CoreCommand::SetAudioProfile(_) | CoreCommand::SetRecording(_) | CoreCommand::SetRecordingMode(_) | CoreCommand::SendChat(_) @@ -292,6 +297,7 @@ pub fn coalesce_key(cmd: &CoreCommand) -> Option { input_device: _, } | CoreCommand::SetNetworkMode(_) + | CoreCommand::SetAudioProfile(_) | CoreCommand::SetRecording(_) | CoreCommand::SetRecordingMode(_) | CoreCommand::SendChat(_) @@ -574,6 +580,7 @@ mod tests { }, CoreCommand::SetPeerMuted(peer, true), CoreCommand::SetPresenceMode(PresenceMode::Normal), + CoreCommand::SetAudioProfile(crate::config::AudioProfile::BadNetwork), CoreCommand::SendChat("hello".to_string()), ]; diff --git a/src/core/mod.rs b/src/core/mod.rs index 593da21..0ca70ce 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -17,7 +17,7 @@ use crate::network::{ }; use crate::audio::multitrack::MultitrackRecorder; -use crate::config::{NetworkMode, RecordingMode}; +use crate::config::{AudioProfile, NetworkMode, RecordingMode}; use crate::presence::PresenceMode; use iroh::{ Endpoint, EndpointAddr, EndpointId, RelayMode, SecretKey, endpoint::presets, protocol::Router, @@ -1162,6 +1162,12 @@ async fn run_core_loop( // App-internal capture/playback gains (f32 bits), live-read by the audio loops. let input_gain = Arc::new(std::sync::atomic::AtomicU32::new(1.0f32.to_bits())); let output_gain = Arc::new(std::sync::atomic::AtomicU32::new(1.0f32.to_bits())); + // Opus encoder profile (W12) as a discriminant, live-read by the capture + // thread so a mid-call profile switch re-tunes the running encoder. Set from + // config via the GUI's startup `SetAudioProfile`; defaults to Balanced. + let audio_profile = Arc::new(std::sync::atomic::AtomicU8::new( + AudioProfile::default().as_u8(), + )); // Call recording: an optional live recorder (mic FIFO + WAV writer), shared // by the capture thread (pushes mic) and the mixer task (writes mix frames). // `is_recording` is a fast-path gate so the audio loops only take the lock @@ -1740,6 +1746,7 @@ async fn run_core_loop( let is_recording_capture = is_recording.clone(); let multitrack_capture = multitrack.clone(); let is_multitrack_capture = is_multitrack.clone(); + let audio_profile_capture = audio_profile.clone(); let capture_thread = std::thread::spawn(move || { use opus::{Application, Channels}; @@ -1751,6 +1758,13 @@ async fn run_core_loop( return; } }; + // Tune the encoder to the configured profile (W12), then track + // the live discriminant so a mid-call switch re-applies it. + let mut current_profile = + AudioProfile::from_u8(audio_profile_capture.load(Ordering::Relaxed)); + if let Err(e) = encoder.apply_profile(current_profile) { + crate::log_msg(&format!("Opus profile apply failed: {:?}", e)); + } // Per-sender packet sequence number, prepended to every frame so // receivers can reorder and conceal loss. Wraps after ~years. let mut seq: u32 = 0; @@ -1763,6 +1777,14 @@ async fn run_core_loop( let mut mic_meter = MicLevelMeter::new(); while let Ok(mut pcm) = capture_rx.recv() { + // Re-tune the encoder if the user switched profile mid-call. + // Cheap atomic load per frame; only reconfigures on change. + let want = + AudioProfile::from_u8(audio_profile_capture.load(Ordering::Relaxed)); + if want != current_profile && encoder.apply_profile(want).is_ok() { + current_profile = want; + } + // Apply the input gain first so the meter, gate, and what we // transmit all reflect the same (gained) signal. apply_volume( @@ -2682,6 +2704,13 @@ async fn run_core_loop( } } + CoreCommand::SetAudioProfile(profile) => { + // Publish the new profile to the capture thread (W12). It picks up + // the change on its next frame and re-tunes the live encoder; a + // call that starts later reads the same atomic at encoder creation. + audio_profile.store(profile.as_u8(), Ordering::Relaxed); + } + CoreCommand::RegenerateIdentity => { // Mint + persist a fresh identity, discarding the old one. The // persistent endpoint is rebuilt with the new key (now if idle, else