From 20643a24de03bf387f2261ab3641a50594149ad7 Mon Sep 17 00:00:00 2001 From: Mollusk Date: Tue, 16 Jun 2026 17:23:38 -0400 Subject: [PATCH] Add audio controls and focused hotkeys --- src/app/mod.rs | 429 ++++++++++++++++++++++++++++++++++--- src/audio/eq.rs | 316 +++++++++++++++++++++++++++ src/audio/mod.rs | 15 +- src/audio/pan.rs | 77 +++++++ src/audio/pipewire_impl.rs | 41 ++-- src/bin/audio_probe.rs | 6 +- src/config.rs | 28 ++- src/core/messages.rs | 4 + src/core/mod.rs | 137 +++++++++++- src/hotkeys.rs | 284 ++++++++++++++++++++++++ src/lib.rs | 2 +- task-report.md | 61 ++++++ 12 files changed, 1341 insertions(+), 59 deletions(-) create mode 100644 src/audio/eq.rs create mode 100644 src/audio/pan.rs create mode 100644 src/hotkeys.rs create mode 100644 task-report.md diff --git a/src/app/mod.rs b/src/app/mod.rs index 6c6ae09..bb4fd28 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,8 +1,10 @@ use crate::core::{CoreController, messages::{CoreCommand, UiEvent}}; use crate::network::PeerState; use crate::notify::{self, Sound}; +use crate::audio::eq::{EqSettings, EQ_GAIN_DB_MAX, EQ_GAIN_DB_MIN}; use crate::audio::pw_cli::{AudioDevice, enumerate_audio_devices}; use crate::config::{AppConfig, NetworkMode, RecordingMode, RoomLayout}; +use crate::hotkeys::{format_binding, HotkeyAction, HotkeyContext, KeyBinding}; use crate::presence::PresenceMode; use crate::theme::{AppTheme, Palette}; @@ -62,6 +64,13 @@ pub enum DividerKind { ChatDrawer, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EqBand { + Low, + Mid, + High, +} + /// Minimum width of the Participants panel (px). const PARTICIPANTS_MIN_W: f32 = 200.0; /// Minimum width reserved for the Controls panel when resizing Participants (px). @@ -121,8 +130,11 @@ pub enum AppMessage { /// Copy an arbitrary string to the clipboard (e.g. the full node ID). CopyText(String), TogglePtt(bool), - StartSettingHotkey, + StartHotkeyCapture(HotkeyAction), + ClearHotkey(HotkeyAction), PeerVolumeChanged(EndpointId, f32), + PeerPanChanged(EndpointId, f32), + PeerEqChanged(EndpointId, EqBand, f32), /// Toggle local mute of a peer (silence them just for us). TogglePeerMute(EndpointId), InputDeviceSelected(AudioDevice), @@ -186,6 +198,9 @@ pub enum AppMessage { /// Open / close the room-layout picker popup. OpenLayoutPicker, CloseLayoutPicker, + /// Open / close the live hotkey reference popup. + OpenHotkeyInfo, + CloseHotkeyInfo, /// Open / close the "screen sharing needs pixelpass" explainer popup (A11). OpenPixelpassHelp, ClosePixelpassHelp, @@ -235,8 +250,7 @@ pub struct AppState { is_deafened: bool, ptt_enabled: bool, ptt_active: bool, - ptt_hotkey: keyboard::Key, - is_setting_hotkey: bool, + hotkey_capture: Option, input_devices: Vec, output_devices: Vec, selected_input: Option, @@ -261,6 +275,8 @@ pub struct AppState { window_size: Size, /// Whether the room-layout picker popup is open (launch + in-call screens). layout_picker_open: bool, + /// Whether the hotkey reference popup is open. + hotkey_info_open: bool, /// Whether the pixelpass screen-share explainer popup is open (A11). pixelpass_help_open: bool, /// Whether the Chat drawer is open (drawer layout only). @@ -353,6 +369,16 @@ impl Default for AppState { 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)); + for (peer, settings) in &config.peer_eq { + if let Ok(id) = peer.parse::() { + let _ = controller.send(CoreCommand::SetPeerEq(id, *settings)); + } + } + for (peer, pan) in &config.peer_pan { + if let Ok(id) = peer.parse::() { + let _ = controller.send(CoreCommand::SetPeerPan(id, *pan)); + } + } let pixelpass_available = crate::screenshare::is_available(config.pixelpass_path.as_deref()); let all_devices = enumerate_audio_devices(); @@ -375,8 +401,7 @@ impl Default for AppState { is_deafened: false, ptt_enabled: false, ptt_active: false, - ptt_hotkey: keyboard::Key::Named(keyboard::key::Named::Space), - is_setting_hotkey: false, + hotkey_capture: None, input_devices, output_devices, selected_input, @@ -393,6 +418,7 @@ impl Default for AppState { chat_input: String::new(), window_size: Size::new(ww, wh), layout_picker_open: false, + hotkey_info_open: false, pixelpass_help_open: false, drawer_chat_open: false, mic_level: 0.0, @@ -526,6 +552,105 @@ fn reconnected_chime( was_reconnect.then_some(Sound::Reconnected) } +fn in_call(state: &AppState) -> bool { + !state.ticket.is_empty() +} + +fn toggle_mute(state: &mut AppState) { + if !in_call(state) { + return; + } + let _ = state.controller.send(CoreCommand::ToggleMute); + state.is_muted = !state.is_muted; + notify::play(Sound::MicToggle, state.config.custom_sound_mic_toggle.as_deref()); +} + +fn toggle_deafen(state: &mut AppState) { + if !in_call(state) { + return; + } + let _ = state.controller.send(CoreCommand::ToggleDeafen); + state.is_deafened = !state.is_deafened; + notify::play(Sound::MicToggle, state.config.custom_sound_mic_toggle.as_deref()); +} + +fn handle_hotkey_pressed(state: &mut AppState, action: HotkeyAction) { + match action { + HotkeyAction::ToggleMute => toggle_mute(state), + HotkeyAction::ToggleDeafen => toggle_deafen(state), + HotkeyAction::OpenSettings => { + state.current_screen = Screen::Settings; + state.hotkey_info_open = false; + state.layout_picker_open = false; + } + HotkeyAction::PushToTalk => { + if in_call(state) && state.ptt_enabled && !state.ptt_active { + state.ptt_active = true; + let _ = state.controller.send(CoreCommand::SetPttActive(true)); + } + } + HotkeyAction::LeaveRoom => { + if in_call(state) { + let _ = state.controller.send(CoreCommand::Leave); + } + } + } +} + +fn set_peer_pan_config(config: &mut AppConfig, id: EndpointId, pan: f32) -> f32 { + let pan = pan.clamp(-1.0, 1.0); + let key = id.to_string(); + if pan.abs() <= 0.001 { + config.peer_pan.remove(&key); + } else { + config.peer_pan.insert(key, pan); + } + pan +} + +fn peer_eq_settings(config: &AppConfig, id: &EndpointId) -> EqSettings { + config + .peer_eq + .get(&id.to_string()) + .copied() + .unwrap_or_default() + .clamped() +} + +fn set_peer_eq_config( + config: &mut AppConfig, + id: EndpointId, + band: EqBand, + gain_db: f32, +) -> EqSettings { + let key = id.to_string(); + let mut settings = config.peer_eq.get(&key).copied().unwrap_or_default(); + let gain_db = gain_db.clamp(EQ_GAIN_DB_MIN, EQ_GAIN_DB_MAX); + match band { + EqBand::Low => settings.low_gain_db = gain_db, + EqBand::Mid => settings.mid_gain_db = gain_db, + EqBand::High => settings.high_gain_db = gain_db, + } + settings = settings.clamped(); + if settings.is_flat() { + config.peer_eq.remove(&key); + } else { + config.peer_eq.insert(key, settings); + } + settings +} + +fn pan_label(pan: f32) -> String { + let pan = pan.clamp(-1.0, 1.0); + if pan.abs() <= 0.01 { + "Center".to_string() + } else if pan < 0.0 { + format!("L {:.0}%", pan.abs() * 100.0) + } else { + format!("R {:.0}%", pan * 100.0) + } +} + fn update(state: &mut AppState, message: AppMessage) -> Task { match message { AppMessage::NicknameChanged(val) => { @@ -593,14 +718,10 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.status_message = "Opening screen share…".to_string(); } AppMessage::ToggleMutePressed => { - let _ = state.controller.send(CoreCommand::ToggleMute); - state.is_muted = !state.is_muted; - notify::play(Sound::MicToggle, state.config.custom_sound_mic_toggle.as_deref()); + toggle_mute(state); } AppMessage::ToggleDeafenPressed => { - let _ = state.controller.send(CoreCommand::ToggleDeafen); - state.is_deafened = !state.is_deafened; - notify::play(Sound::MicToggle, state.config.custom_sound_mic_toggle.as_deref()); + toggle_deafen(state); } AppMessage::UiEventReceived(event) => { match event { @@ -761,13 +882,26 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.ptt_enabled = enabled; let _ = state.controller.send(CoreCommand::SetPttMode(enabled)); } - AppMessage::StartSettingHotkey => { - state.is_setting_hotkey = true; + AppMessage::StartHotkeyCapture(action) => { + state.hotkey_capture = Some(action); + state.hotkey_info_open = false; + } + AppMessage::ClearHotkey(action) => { + state.config.hotkeys.set_binding(action, None); + state.config.save(); } AppMessage::PeerVolumeChanged(id, vol) => { state.peer_volumes.insert(id, vol); let _ = state.controller.send(CoreCommand::SetPeerVolume(id, vol)); } + AppMessage::PeerPanChanged(id, pan) => { + let pan = set_peer_pan_config(&mut state.config, id, pan); + let _ = state.controller.send(CoreCommand::SetPeerPan(id, pan)); + } + AppMessage::PeerEqChanged(id, band, gain_db) => { + let settings = set_peer_eq_config(&mut state.config, id, band, gain_db); + let _ = state.controller.send(CoreCommand::SetPeerEq(id, settings)); + } AppMessage::TogglePeerMute(id) => { let now_muted = if state.locally_muted.contains(&id) { state.locally_muted.remove(&id); @@ -1021,10 +1155,18 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { } AppMessage::OpenLayoutPicker => { state.layout_picker_open = true; + state.hotkey_info_open = false; } AppMessage::CloseLayoutPicker => { state.layout_picker_open = false; } + AppMessage::OpenHotkeyInfo => { + state.hotkey_info_open = true; + state.layout_picker_open = false; + } + AppMessage::CloseHotkeyInfo => { + state.hotkey_info_open = false; + } AppMessage::OpenPixelpassHelp => { state.pixelpass_help_open = true; } @@ -1119,16 +1261,30 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { .send(CoreCommand::SetMicMonitor { enabled, input_device }); } AppMessage::EventOccurred(Event::Keyboard(keyboard::Event::KeyPressed { key, .. })) => { - if state.is_setting_hotkey { - state.ptt_hotkey = key.clone(); - state.is_setting_hotkey = false; - } else if state.ptt_enabled && key == state.ptt_hotkey && !state.ptt_active { - state.ptt_active = true; - let _ = state.controller.send(CoreCommand::SetPttActive(true)); + if let Some(action) = state.hotkey_capture.take() { + if let Some(binding) = KeyBinding::from_key(&key) { + state.config.hotkeys.set_binding(action, Some(binding)); + state.config.save(); + } else { + state.hotkey_capture = Some(action); + } + } else if let Some(action) = state + .config + .hotkeys + .lookup_key(&key, HotkeyContext { in_call: in_call(state) }) + { + handle_hotkey_pressed(state, action); } } AppMessage::EventOccurred(Event::Keyboard(keyboard::Event::KeyReleased { key, .. })) => { - if state.ptt_enabled && key == state.ptt_hotkey && state.ptt_active { + if state + .config + .hotkeys + .lookup_key(&key, HotkeyContext { in_call: in_call(state) }) + == Some(HotkeyAction::PushToTalk) + && state.ptt_enabled + && state.ptt_active + { state.ptt_active = false; let _ = state.controller.send(CoreCommand::SetPttActive(false)); } @@ -1171,9 +1327,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { AppMessage::EventOccurred(_) => {} AppMessage::NavigateToSettings => { state.current_screen = Screen::Settings; + state.layout_picker_open = false; + state.hotkey_info_open = false; } AppMessage::NavigateBack => { state.config.save(); + state.hotkey_capture = None; // Release the mic when leaving Settings if the test was running. if state.mic_test_active { state.mic_test_active = false; @@ -1752,6 +1911,17 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { let top_bar = row![ horizontal_space(), + tooltip( + button(icon(IconKind::Info, 18.0, color_text)) + .on_press(AppMessage::OpenHotkeyInfo) + .style(b_style(color_surface, color_blue, color_text, 6.0)) + .padding(8), + container(text("Hotkeys").size(11).color(color_text)) + .padding(8) + .style(c_style(color_crust, color_surface, 6.0)), + iced::widget::tooltip::Position::Bottom, + ) + .gap(8), tooltip( button( Canvas::new(LayoutIcon { fg: color_text }) @@ -2039,6 +2209,72 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .into() }; + let mut hotkey_rows = Column::new().spacing(8).width(iced::Length::Fill); + for action in HotkeyAction::ALL { + let capturing = state.hotkey_capture == Some(action); + let binding = if capturing { + "Press a key...".to_string() + } else { + format_binding(state.config.hotkeys.binding(action)) + }; + hotkey_rows = hotkey_rows.push( + row![ + column![ + text(action.label()).size(13).color(color_text), + text(match action.tier() { + crate::hotkeys::HotkeyTier::AppWide => "App-wide", + crate::hotkeys::HotkeyTier::RoomOnly => "Room-only", + }) + .size(10) + .color(color_subtext), + ] + .spacing(2) + .width(iced::Length::Fill), + container(text(binding).size(12).color(if capturing { color_yellow } else { color_subtext })) + .width(iced::Length::Fixed(110.0)) + .align_x(iced::alignment::Horizontal::Right), + button(text("Set").size(12)) + .on_press(AppMessage::StartHotkeyCapture(action)) + .style(b_style(color_surface, color_blue, color_text, 6.0)) + .padding(6), + button(text("Clear").size(12)) + .on_press(AppMessage::ClearHotkey(action)) + .style(b_style(color_surface, color_red, color_text, 6.0)) + .padding(6), + ] + .spacing(10) + .align_y(iced::alignment::Vertical::Center), + ); + } + let hotkey_conflicts = state.config.hotkeys.conflicts(); + let conflict_block: Element<'_, AppMessage> = if hotkey_conflicts.is_empty() { + vertical_space(0.0).into() + } else { + let mut lines = Column::new().spacing(4); + for conflict in hotkey_conflicts { + lines = lines.push( + text(format!( + "Conflict: {} is assigned to {} and {}.", + conflict.binding.label(), + conflict.first.label(), + conflict.second.label() + )) + .size(11) + .color(color_red), + ); + } + lines.into() + }; + let hotkey_section = column![ + hotkey_rows, + conflict_block, + text("Shortcuts work only while the PeerSpeak window has focus. Unset actions are ignored.") + .size(11) + .color(color_subtext), + ] + .spacing(8) + .width(iced::Length::Fill); + // --- Identity (W7) --- // Your persistent node id + a Regenerate control. When the key isn't // persisted (disk/permission failure → ephemeral fallback) we show a @@ -2163,6 +2399,11 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { ].spacing(8).width(iced::Length::Fill), vertical_space(section_gap), + // --- Hotkeys --- + section_header("Hotkeys"), + hotkey_section, + vertical_space(section_gap), + // --- Recording --- section_header("Recording"), column![ @@ -2345,7 +2586,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .height(iced::Length::Fill) .style(c_style(color_crust, Color::TRANSPARENT, 0.0)); - with_layout_picker(home.into(), state) + with_hotkey_info(with_layout_picker(home.into(), state), state) } else { // --- ROOM SCREEN --- let participant_count = state.peers.len() + 1; // peers + you @@ -2643,6 +2884,46 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { ].spacing(8).align_y(iced::alignment::Vertical::Center) ); + let peer_key = peer_id.to_string(); + let current_pan = state.config.peer_pan.get(&peer_key).copied().unwrap_or(0.0); + card_content = card_content.push( + row![ + text("Pan:").size(12).color(color_subtext), + container(text(pan_label(current_pan)).size(11).color(color_subtext)) + .width(iced::Length::Fixed(58.0)), + slider(-1.0..=1.0, current_pan, move |v| AppMessage::PeerPanChanged(peer_id_clone, v)) + .step(0.05) + .on_release(AppMessage::PersistConfig), + ] + .spacing(8) + .align_y(iced::alignment::Vertical::Center), + ); + + let eq = peer_eq_settings(&state.config, peer_id); + let eq_row = |label: &'static str, band: EqBand, value: f32| -> Element<'_, AppMessage> { + row![ + container(text(format!("{label} {value:+.1} dB")).size(11).color(color_subtext)) + .width(iced::Length::Fixed(86.0)), + slider(EQ_GAIN_DB_MIN..=EQ_GAIN_DB_MAX, value, move |v| { + AppMessage::PeerEqChanged(peer_id_clone, band, v) + }) + .step(0.5) + .on_release(AppMessage::PersistConfig), + ] + .spacing(8) + .align_y(iced::alignment::Vertical::Center) + .into() + }; + card_content = card_content.push( + column![ + text("EQ").size(11).color(color_subtext), + eq_row("Low", EqBand::Low, eq.low_gain_db), + eq_row("Mid", EqBand::Mid, eq.mid_gain_db), + eq_row("High", EqBand::High, eq.high_gain_db), + ] + .spacing(4), + ); + let card = container(card_content) .style(c_style( if is_speaking { color_base } else { color_mantle }, @@ -2696,10 +2977,15 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { checkbox(state.ptt_enabled).label("Push-to-Talk").on_toggle(AppMessage::TogglePtt), vertical_space(10.0), if state.ptt_enabled { + let ptt_binding = if state.hotkey_capture == Some(HotkeyAction::PushToTalk) { + "Press a key...".to_string() + } else { + format_binding(state.config.hotkeys.binding(HotkeyAction::PushToTalk)) + }; column![ - text(format!("Hotkey: {}", if state.is_setting_hotkey { "Press any key...".to_string() } else { format!("{:?}", state.ptt_hotkey) })).size(14).color(color_subtext), - button(text("Set Hotkey").size(12).align_x(iced::alignment::Horizontal::Center)) - .on_press(AppMessage::StartSettingHotkey) + text(format!("PTT key: {ptt_binding}")).size(14).color(color_subtext), + button(text("Set PTT Key").size(12).align_x(iced::alignment::Horizontal::Center)) + .on_press(AppMessage::StartHotkeyCapture(HotkeyAction::PushToTalk)) .style(b_style(color_surface, color_blue, color_text, 6.0)) .padding(8) .width(iced::Length::Fill) @@ -2987,7 +3273,10 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .height(iced::Length::Fill) .style(c_style(color_crust, Color::TRANSPARENT, 0.0)); - with_pixelpass_help(with_layout_picker(room.into(), state), state) + with_hotkey_info( + with_pixelpass_help(with_layout_picker(room.into(), state), state), + state, + ) } } @@ -3357,6 +3646,90 @@ fn with_layout_picker<'a>( .into() } +/// Overlay the live hotkey reference from the top-right info button. It reads +/// directly from config, so Settings edits are reflected immediately. +fn with_hotkey_info<'a>( + base: Element<'a, AppMessage>, + state: &'a AppState, +) -> Element<'a, AppMessage> { + if !state.hotkey_info_open { + return base; + } + let pal = state.config.theme.palette(); + let crust = pal.crust; + let mantle = pal.mantle; + let surface = pal.surface; + let text_c = pal.text; + let subtext = pal.subtext; + let blue = pal.blue; + + let backdrop = mouse_area( + container(horizontal_space()) + .width(iced::Length::Fill) + .height(iced::Length::Fill) + .style(move |_t: &Theme| container::Style { + background: Some(Background::Color(Color { a: 0.25, ..crust })), + ..Default::default() + }), + ) + .on_press(AppMessage::CloseHotkeyInfo); + + let mut rows = Column::new().spacing(8).width(iced::Length::Fill); + for action in HotkeyAction::ALL { + rows = rows.push( + row![ + text(action.label()).size(12).color(text_c), + horizontal_space(), + text(format_binding(state.config.hotkeys.binding(action))) + .size(12) + .color(subtext), + ] + .spacing(12) + .align_y(iced::alignment::Vertical::Center), + ); + } + + let dialog = container( + column![ + row![ + text("Hotkeys").size(16).color(blue), + horizontal_space(), + button(text("✕").size(16).color(subtext)) + .on_press(AppMessage::CloseHotkeyInfo) + .style(|_t: &Theme, _s: button::Status| button::Style { + background: None, + ..Default::default() + }) + .padding(2), + ] + .align_y(iced::alignment::Vertical::Center), + rows, + ] + .spacing(14), + ) + .style(move |_t: &Theme| container::Style { + text_color: Some(text_c), + background: Some(Background::Color(mantle)), + border: Border { color: surface, width: 1.0, radius: 8.0.into() }, + ..Default::default() + }) + .padding(16) + .width(iced::Length::Fixed(320.0)); + + stack![ + base, + backdrop, + container(column![ + vertical_space(48.0), + row![horizontal_space(), dialog].width(iced::Length::Fill), + ]) + .width(iced::Length::Fill) + .height(iced::Length::Fill) + .padding(12), + ] + .into() +} + /// Overlays the "screen sharing needs pixelpass" explainer popup over `base` /// when open (A11). Triggered by the Share Screen / Watch controls when the /// optional `pixelpass` companion isn't installed, so those controls open a @@ -3725,6 +4098,7 @@ enum IconKind { Chat, People, Clock, + Info, Settings, Copy, Leave, @@ -3994,6 +4368,11 @@ impl Program for Icon { f.stroke(&poly(&[(12.0, 7.0), (12.0, 12.0)], false), stk()); f.stroke(&poly(&[(12.0, 12.0), (15.5, 14.0)], false), stk()); } + IconKind::Info => { + f.stroke(&Path::circle(p(12.0, 12.0), 8.5 * s), stk()); + f.stroke(&poly(&[(12.0, 10.5), (12.0, 17.0)], false), stk()); + f.fill(&Path::circle(p(12.0, 7.0), 1.1 * s), col); + } IconKind::Settings => { f.stroke(&poly(&[(4.0, 7.0), (20.0, 7.0)], false), stk()); f.stroke(&poly(&[(4.0, 12.0), (20.0, 12.0)], false), stk()); diff --git a/src/audio/eq.rs b/src/audio/eq.rs new file mode 100644 index 0000000..ad357b0 --- /dev/null +++ b/src/audio/eq.rs @@ -0,0 +1,316 @@ +//! Per-peer listener-side voice EQ. +//! +//! The EQ is deliberately small and local: three RBJ cookbook biquads at fixed +//! voice-oriented frequencies, with only gain exposed to the UI. State lives per +//! peer in the playout mixer so filter delay registers are continuous across 20ms +//! Opus frames; flat settings are treated as bypass so the default path is cheap +//! and sample-exact. + +use serde::{Deserialize, Serialize}; + +const DEFAULT_SAMPLE_RATE: f32 = 48_000.0; +const LOW_SHELF_HZ: f32 = 160.0; +const MID_PEAK_HZ: f32 = 2_400.0; +const HIGH_SHELF_HZ: f32 = 6_500.0; +const MID_Q: f32 = 1.0; +const SHELF_Q: f32 = std::f32::consts::FRAC_1_SQRT_2; +const FLAT_EPSILON_DB: f32 = 0.001; + +/// UI and config clamp for each band. Wide enough to be useful for voice, narrow +/// enough that a peer cannot accidentally make the listener-side limiter do all +/// the work. +pub const EQ_GAIN_DB_MIN: f32 = -12.0; +pub const EQ_GAIN_DB_MAX: f32 = 12.0; + +/// Persisted per-peer EQ gains, in decibels. `Default` is flat/bypassed. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +pub struct EqSettings { + #[serde(default)] + pub low_gain_db: f32, + #[serde(default)] + pub mid_gain_db: f32, + #[serde(default)] + pub high_gain_db: f32, +} + +impl Default for EqSettings { + fn default() -> Self { + Self { + low_gain_db: 0.0, + mid_gain_db: 0.0, + high_gain_db: 0.0, + } + } +} + +impl EqSettings { + pub fn flat() -> Self { + Self::default() + } + + /// Clamp all public gains to the supported UI/DSP range. + pub fn clamped(self) -> Self { + Self { + low_gain_db: self.low_gain_db.clamp(EQ_GAIN_DB_MIN, EQ_GAIN_DB_MAX), + mid_gain_db: self.mid_gain_db.clamp(EQ_GAIN_DB_MIN, EQ_GAIN_DB_MAX), + high_gain_db: self.high_gain_db.clamp(EQ_GAIN_DB_MIN, EQ_GAIN_DB_MAX), + } + } + + /// True when the EQ should be bypassed entirely. + pub fn is_flat(self) -> bool { + self.low_gain_db.abs() <= FLAT_EPSILON_DB + && self.mid_gain_db.abs() <= FLAT_EPSILON_DB + && self.high_gain_db.abs() <= FLAT_EPSILON_DB + } +} + +/// A stateful three-band EQ. One instance belongs to one decoded peer stream. +pub struct Eq { + settings: EqSettings, + low: Biquad, + mid: Biquad, + high: Biquad, +} + +impl Eq { + /// Build an EQ at the application's audio rate (48 kHz). + pub fn new(settings: EqSettings) -> Self { + Self::with_sample_rate(settings, DEFAULT_SAMPLE_RATE) + } + + fn with_sample_rate(settings: EqSettings, sample_rate: f32) -> Self { + let settings = settings.clamped(); + Self { + settings, + low: Biquad::low_shelf(sample_rate, LOW_SHELF_HZ, settings.low_gain_db, SHELF_Q), + mid: Biquad::peaking(sample_rate, MID_PEAK_HZ, settings.mid_gain_db, MID_Q), + high: Biquad::high_shelf(sample_rate, HIGH_SHELF_HZ, settings.high_gain_db, SHELF_Q), + } + } + + pub fn settings(&self) -> EqSettings { + self.settings + } + + /// Process one mono PCM frame in place. Flat settings are sample-exact bypass. + pub fn process_frame(&mut self, frame: &mut [i16]) { + if self.settings.is_flat() { + return; + } + for sample in frame { + let x = *sample as f32; + let y = self.high.process(self.mid.process(self.low.process(x))); + *sample = y.round().clamp(i16::MIN as f32, i16::MAX as f32) as i16; + } + } +} + +#[derive(Debug, Clone, Copy)] +struct Coeffs { + b0: f32, + b1: f32, + b2: f32, + a1: f32, + a2: f32, +} + +impl Coeffs { + fn normalized(b0: f32, b1: f32, b2: f32, a0: f32, a1: f32, a2: f32) -> Self { + let inv_a0 = 1.0 / a0; + Self { + b0: b0 * inv_a0, + b1: b1 * inv_a0, + b2: b2 * inv_a0, + a1: a1 * inv_a0, + a2: a2 * inv_a0, + } + } + + fn all_finite(self) -> bool { + self.b0.is_finite() + && self.b1.is_finite() + && self.b2.is_finite() + && self.a1.is_finite() + && self.a2.is_finite() + } +} + +/// Direct Form II transposed biquad. The two delay registers are the state that +/// must survive across frames. +struct Biquad { + coeffs: Coeffs, + z1: f32, + z2: f32, +} + +impl Biquad { + fn new(coeffs: Coeffs) -> Self { + debug_assert!(coeffs.all_finite()); + Self { + coeffs, + z1: 0.0, + z2: 0.0, + } + } + + fn low_shelf(sample_rate: f32, freq: f32, gain_db: f32, q: f32) -> Self { + let (a, cos_w0, alpha) = rbj_terms(sample_rate, freq, gain_db, q); + let sqrt_a = a.sqrt(); + let b0 = a * ((a + 1.0) - (a - 1.0) * cos_w0 + 2.0 * sqrt_a * alpha); + let b1 = 2.0 * a * ((a - 1.0) - (a + 1.0) * cos_w0); + let b2 = a * ((a + 1.0) - (a - 1.0) * cos_w0 - 2.0 * sqrt_a * alpha); + let a0 = (a + 1.0) + (a - 1.0) * cos_w0 + 2.0 * sqrt_a * alpha; + let a1 = -2.0 * ((a - 1.0) + (a + 1.0) * cos_w0); + let a2 = (a + 1.0) + (a - 1.0) * cos_w0 - 2.0 * sqrt_a * alpha; + Self::new(Coeffs::normalized(b0, b1, b2, a0, a1, a2)) + } + + fn peaking(sample_rate: f32, freq: f32, gain_db: f32, q: f32) -> Self { + let (a, cos_w0, alpha) = rbj_terms(sample_rate, freq, gain_db, q); + let b0 = 1.0 + alpha * a; + let b1 = -2.0 * cos_w0; + let b2 = 1.0 - alpha * a; + let a0 = 1.0 + alpha / a; + let a1 = -2.0 * cos_w0; + let a2 = 1.0 - alpha / a; + Self::new(Coeffs::normalized(b0, b1, b2, a0, a1, a2)) + } + + fn high_shelf(sample_rate: f32, freq: f32, gain_db: f32, q: f32) -> Self { + let (a, cos_w0, alpha) = rbj_terms(sample_rate, freq, gain_db, q); + let sqrt_a = a.sqrt(); + let b0 = a * ((a + 1.0) + (a - 1.0) * cos_w0 + 2.0 * sqrt_a * alpha); + let b1 = -2.0 * a * ((a - 1.0) + (a + 1.0) * cos_w0); + let b2 = a * ((a + 1.0) + (a - 1.0) * cos_w0 - 2.0 * sqrt_a * alpha); + let a0 = (a + 1.0) - (a - 1.0) * cos_w0 + 2.0 * sqrt_a * alpha; + let a1 = 2.0 * ((a - 1.0) - (a + 1.0) * cos_w0); + let a2 = (a + 1.0) - (a - 1.0) * cos_w0 - 2.0 * sqrt_a * alpha; + Self::new(Coeffs::normalized(b0, b1, b2, a0, a1, a2)) + } + + fn process(&mut self, x: f32) -> f32 { + let y = self.coeffs.b0 * x + self.z1; + self.z1 = self.coeffs.b1 * x - self.coeffs.a1 * y + self.z2; + self.z2 = self.coeffs.b2 * x - self.coeffs.a2 * y; + + // Avoid carrying denormal-sized state forever on long quiet tails. + if self.z1.abs() < 1.0e-20 { + self.z1 = 0.0; + } + if self.z2.abs() < 1.0e-20 { + self.z2 = 0.0; + } + y + } +} + +fn rbj_terms(sample_rate: f32, freq: f32, gain_db: f32, q: f32) -> (f32, f32, f32) { + let sr = sample_rate.max(1.0); + let f = freq.clamp(1.0, sr * 0.49); + let w0 = 2.0 * std::f32::consts::PI * f / sr; + let a = 10.0f32.powf(gain_db / 40.0); + let alpha = w0.sin() / (2.0 * q.max(0.001)); + (a, w0.cos(), alpha) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sine(freq: f32, len: usize, amp: f32) -> Vec { + (0..len) + .map(|n| { + let t = n as f32 / DEFAULT_SAMPLE_RATE; + (amp * (2.0 * std::f32::consts::PI * freq * t).sin()).round() as i16 + }) + .collect() + } + + fn rms(frame: &[i16]) -> f32 { + let sum: f32 = frame.iter().map(|&s| (s as f32).powi(2)).sum(); + (sum / frame.len().max(1) as f32).sqrt() + } + + #[test] + fn flat_eq_is_sample_exact_identity() { + let mut eq = Eq::new(EqSettings::flat()); + let mut frame: Vec = (-480..480).map(|n| (n * 31) as i16).collect(); + let original = frame.clone(); + eq.process_frame(&mut frame); + assert_eq!(frame, original); + } + + #[test] + fn low_shelf_boost_raises_low_frequency_energy() { + let mut eq = Eq::new(EqSettings { + low_gain_db: 9.0, + ..EqSettings::flat() + }); + let mut low = sine(100.0, 48_000, 3_000.0); + let before = rms(&low); + eq.process_frame(&mut low); + let after = rms(&low); + assert!(after > before * 1.6, "low shelf should boost low RMS: {before} -> {after}"); + } + + #[test] + fn high_shelf_boost_raises_high_frequency_energy() { + let mut eq = Eq::new(EqSettings { + high_gain_db: 9.0, + ..EqSettings::flat() + }); + let mut high = sine(8_000.0, 48_000, 3_000.0); + let before = rms(&high); + eq.process_frame(&mut high); + let after = rms(&high); + assert!(after > before * 1.6, "high shelf should boost high RMS: {before} -> {after}"); + } + + #[test] + fn coefficients_are_finite_across_supported_gain_range() { + for gain in [EQ_GAIN_DB_MIN, -6.0, 0.0, 6.0, EQ_GAIN_DB_MAX] { + for b in [ + Biquad::low_shelf(DEFAULT_SAMPLE_RATE, LOW_SHELF_HZ, gain, SHELF_Q), + Biquad::peaking(DEFAULT_SAMPLE_RATE, MID_PEAK_HZ, gain, MID_Q), + Biquad::high_shelf(DEFAULT_SAMPLE_RATE, HIGH_SHELF_HZ, gain, SHELF_Q), + ] { + assert!(b.coeffs.all_finite(), "coefficients must be finite at {gain} dB"); + } + } + } + + #[test] + fn hot_signal_does_not_nan_or_wrap() { + let mut eq = Eq::new(EqSettings { + low_gain_db: 12.0, + mid_gain_db: 12.0, + high_gain_db: 12.0, + }); + let mut frame = sine(1_000.0, 48_000, 30_000.0); + eq.process_frame(&mut frame); + let peak = frame + .iter() + .map(|&s| i32::from(s).abs()) + .max() + .unwrap_or(0); + assert!(peak > 1_000, "processed signal should retain audible energy"); + assert!( + frame.iter().any(|&s| s > 0) && frame.iter().any(|&s| s < 0), + "a boosted sine should retain both polarities" + ); + } + + #[test] + fn settings_are_clamped() { + let s = EqSettings { + low_gain_db: -99.0, + mid_gain_db: 2.0, + high_gain_db: 99.0, + } + .clamped(); + assert_eq!(s.low_gain_db, EQ_GAIN_DB_MIN); + assert_eq!(s.mid_gain_db, 2.0); + assert_eq!(s.high_gain_db, EQ_GAIN_DB_MAX); + } +} diff --git a/src/audio/mod.rs b/src/audio/mod.rs index 9e513cf..4a83742 100644 --- a/src/audio/mod.rs +++ b/src/audio/mod.rs @@ -1,17 +1,22 @@ -use std::sync::mpsc::{Sender, Receiver}; +use std::sync::mpsc::{Receiver, Sender}; use std::sync::Arc; use std::sync::atomic::AtomicUsize; use thiserror::Error; -/// Target depth of the playback ring buffer, in samples (48kHz mono). +/// Playback output channel count. Capture/encode/network remain mono; only the +/// listener-side playout bus is stereo. +pub const PLAYBACK_CHANNELS: usize = 2; + +/// Target depth of the playback ring buffer, in interleaved samples (48kHz +/// stereo). /// /// The playout chain is paced to keep the ring near this level: production is /// driven by how fast PipeWire actually drains the ring (the hardware clock), /// not by a fixed software timer — which is what eliminates the producer/ /// consumer beat that otherwise churns ~20% of audio into drops + silence. -/// 2880 = 60ms = 3×20ms frames, comfortably above the 2048-sample max quantum +/// 5760 = 60ms = 3×20ms stereo frames, comfortably above the 2048-frame max quantum /// so a single hardware pull can never empty the ring before the mixer refills. -pub const PLAYBACK_TARGET_SAMPLES: usize = 2880; +pub const PLAYBACK_TARGET_SAMPLES: usize = 2880 * PLAYBACK_CHANNELS; #[derive(Error, Debug)] pub enum AudioError { @@ -52,9 +57,11 @@ pub trait AudioBackend: Send + Sync { } pub mod echo_cancel; +pub mod eq; pub mod gate; pub mod limiter; pub mod multitrack; +pub mod pan; pub mod pipewire_impl; pub mod pw_cli; pub mod recorder; diff --git a/src/audio/pan.rs b/src/audio/pan.rs new file mode 100644 index 0000000..be6bc07 --- /dev/null +++ b/src/audio/pan.rs @@ -0,0 +1,77 @@ +//! Listener-side stereo pan law. +//! +//! Capture, Opus, and the network stay mono. These helpers are used only after a +//! peer has been decoded locally, just before the playout mix is written to the +//! stereo playback bus. + +/// Clamp and compute constant-power pan gains for `pan` in `[-1.0, 1.0]`. +/// +/// - `-1.0` is hard left `(1, 0)` +/// - `0.0` is center `(sqrt(1/2), sqrt(1/2))` +/// - `1.0` is hard right `(0, 1)` +pub fn pan_gains(pan: f32) -> (f32, f32) { + let pan = pan.clamp(-1.0, 1.0); + let theta = (pan + 1.0) * std::f32::consts::FRAC_PI_4; + (theta.cos(), theta.sin()) +} + +/// Gains used by the legacy-compatible playback mixer. +/// +/// The pure law above is constant-power. The existing application, however, was +/// mono and users heard the full old mono signal in both ears. Scaling by sqrt(2) +/// makes `pan = 0` exactly dual-mono `(1, 1)`, preserving the default sound while +/// still following the same equal-power curve as a peer is moved away from center. +pub fn playback_pan_gains(pan: f32) -> (f32, f32) { + let (left, right) = pan_gains(pan); + (left * std::f32::consts::SQRT_2, right * std::f32::consts::SQRT_2) +} + +#[cfg(test)] +mod tests { + use super::*; + + const EPS: f32 = 1.0e-6; + + #[test] + fn hard_left_and_right_are_endpoints() { + assert_eq!(pan_gains(-1.0), (1.0, 0.0)); + let (l, r) = pan_gains(1.0); + assert!(l.abs() < EPS, "left at hard-right should be zero-ish, got {l}"); + assert!((r - 1.0).abs() < EPS, "right at hard-right should be one, got {r}"); + } + + #[test] + fn center_is_equal_and_power_preserving() { + let (l, r) = pan_gains(0.0); + assert!((l - r).abs() < EPS); + assert!((l - std::f32::consts::FRAC_1_SQRT_2).abs() < EPS); + assert!(((l * l + r * r) - 1.0).abs() < EPS); + } + + #[test] + fn gains_move_monotonically() { + let pans = [-1.0, -0.5, 0.0, 0.5, 1.0]; + let mut prev_l = f32::INFINITY; + let mut prev_r = f32::NEG_INFINITY; + for pan in pans { + let (l, r) = pan_gains(pan); + assert!(l <= prev_l + EPS, "left gain must not rise as pan moves right"); + assert!(r >= prev_r - EPS, "right gain must not fall as pan moves right"); + prev_l = l; + prev_r = r; + } + } + + #[test] + fn playback_center_preserves_legacy_dual_mono() { + let (l, r) = playback_pan_gains(0.0); + assert!((l - 1.0).abs() < EPS); + assert!((r - 1.0).abs() < EPS); + } + + #[test] + fn input_is_clamped() { + assert_eq!(pan_gains(-9.0), pan_gains(-1.0)); + assert_eq!(pan_gains(9.0), pan_gains(1.0)); + } +} diff --git a/src/audio/pipewire_impl.rs b/src/audio/pipewire_impl.rs index 5c4a2fc..b782ee8 100644 --- a/src/audio/pipewire_impl.rs +++ b/src/audio/pipewire_impl.rs @@ -283,8 +283,9 @@ fn run_playback( let core = context.connect_rc(None) .map_err(|e| AudioError::Init(e.to_string()))?; - // Ring buffer setup: 9600 samples (200ms capacity for mono 48kHz). - const RING_CAPACITY: usize = 9600; + // Ring buffer setup: 19200 interleaved samples (200ms capacity for stereo + // 48kHz). + const RING_CAPACITY: usize = 9600 * crate::audio::PLAYBACK_CHANNELS; let rb = HeapRb::::new(RING_CAPACITY); let (mut producer, consumer) = rb.split(); @@ -371,7 +372,7 @@ fn run_playback( let data = &mut datas[0]; let mut total_size = 0; if let Some(slice) = data.data() { - let stride = 2; // S16LE Mono = 2 bytes per frame + let stride = 2 * crate::audio::PLAYBACK_CHANNELS; // S16LE stereo // Fill exactly what the graph asked for this cycle (with // a safe fallback), never the whole mapped slice — that // over-pull past the ring depth was the original crackle. @@ -383,17 +384,20 @@ fn run_playback( user_data.callback_count.fetch_add(1, Ordering::Relaxed); let mut starved = 0u64; for i in 0..n_frames { - let val = match user_data.consumer.try_pop() { - Some(v) => v, - None => { - starved += 1; - 0 - } - }; - let bytes = val.to_le_bytes(); let start = i * stride; - slice[start] = bytes[0]; - slice[start + 1] = bytes[1]; + for ch in 0..crate::audio::PLAYBACK_CHANNELS { + let val = match user_data.consumer.try_pop() { + Some(v) => v, + None => { + starved += 1; + 0 + } + }; + let bytes = val.to_le_bytes(); + let offset = start + ch * 2; + slice[offset] = bytes[0]; + slice[offset + 1] = bytes[1]; + } } if starved > 0 { // One wait-free atomic add per quantum — RT-safe. @@ -403,7 +407,8 @@ fn run_playback( // actually pulled (excluding underruns, which removed // nothing) so the mixer paces against true ring depth. // Wait-free fetch_sub, RT-safe. - let popped = n_frames - starved as usize; + let requested_samples = n_frames * crate::audio::PLAYBACK_CHANNELS; + let popped = requested_samples - starved as usize; if popped > 0 { user_data.fill_gauge.fetch_sub(popped, Ordering::Relaxed); } @@ -411,7 +416,7 @@ fn run_playback( } let chunk = data.chunk_mut(); *chunk.offset_mut() = 0; - *chunk.stride_mut() = 2; + *chunk.stride_mut() = (2 * crate::audio::PLAYBACK_CHANNELS) as _; *chunk.size_mut() = total_size as _; } } @@ -422,7 +427,7 @@ fn run_playback( let mut audio_info = spa::param::audio::AudioInfoRaw::new(); audio_info.set_format(spa::param::audio::AudioFormat::S16LE); audio_info.set_rate(48000); - audio_info.set_channels(1); // Mono + audio_info.set_channels(crate::audio::PLAYBACK_CHANNELS as u32); // Stereo playback let obj = pw::spa::pod::Object { type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(), @@ -450,7 +455,7 @@ fn run_playback( // `frames_to_produce`). `requested()`, not the buffer size, now governs // per-cycle output, so this is a generous max rather than a hard pin. const MAX_QUANTUM_FRAMES: i32 = 8192; - const STRIDE: i32 = 2; // S16LE mono = 2 bytes/frame + const STRIDE: i32 = 2 * crate::audio::PLAYBACK_CHANNELS as i32; // S16LE stereo let buffers_obj = pw::spa::pod::Object { type_: pw::spa::utils::SpaTypes::ObjectParamBuffers.as_raw(), id: pw::spa::param::ParamType::Buffers.as_raw(), @@ -555,7 +560,7 @@ fn run_playback( if verbose || du > 0 || dd > 0 { crate::log_msg(&format!( "playout-health: fill={fill} samples (~{}ms) | underrun +{du} samples/s (total {u}) | dropped +{dd} frames/s (total {d}) | quantum={q} frames, {dc} callbacks/s", - fill / 48, + fill / (48 * crate::audio::PLAYBACK_CHANNELS), )); } } diff --git a/src/bin/audio_probe.rs b/src/bin/audio_probe.rs index 240179d..e655622 100644 --- a/src/bin/audio_probe.rs +++ b/src/bin/audio_probe.rs @@ -26,7 +26,7 @@ use std::time::Duration; use peerspeak::audio::AudioBackend; use peerspeak::audio::pipewire_impl::PipeWireBackend; -use peerspeak::core::jitter::FRAME_SAMPLES; // 960 samples = 20ms @ 48kHz mono +use peerspeak::core::jitter::FRAME_SAMPLES; // 960 mono frames = 20ms @ 48kHz const SAMPLE_RATE: f32 = 48_000.0; @@ -69,11 +69,13 @@ async fn main() { tokio::time::sleep(Duration::from_millis(2)).await; continue; } - let mut frame = Vec::with_capacity(FRAME_SAMPLES); + let mut frame = Vec::with_capacity(FRAME_SAMPLES * peerspeak::audio::PLAYBACK_CHANNELS); for _ in 0..FRAME_SAMPLES { let t = n as f32 / SAMPLE_RATE; // 0.25 amplitude: clearly audible but not harsh. let sample = (0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16; + // Stereo playback bus: duplicate the probe tone to L/R. + frame.push(sample); frame.push(sample); n += 1; } diff --git a/src/config.rs b/src/config.rs index 4e9d00d..e7c8e87 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,7 @@ use crate::notify::Sound; use crate::theme::AppTheme; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::fs; use std::path::PathBuf; @@ -232,6 +233,17 @@ pub struct AppConfig { /// capped (see `recents`). Defaulted empty so older configs upgrade cleanly. #[serde(default)] pub recents: Vec, + /// Per-peer listener-side EQ settings, keyed by peer node id string. Local + /// preference only; never sent to peers. + #[serde(default)] + pub peer_eq: HashMap, + /// Per-peer listener-side pan (`-1.0` left, `0.0` center, `1.0` right), + /// keyed by peer node id string. Local preference only. + #[serde(default)] + pub peer_pan: HashMap, + /// Focused app-local keyboard shortcuts. + #[serde(default)] + pub hotkeys: crate::hotkeys::HotkeyMap, /// Last window size (px), restored as the initial size on next launch. /// Saved on close. #[serde(default = "default_window_width")] @@ -287,6 +299,9 @@ impl Default for AppConfig { sound_reconnect_failed_enabled: true, pixelpass_path: None, recents: Vec::new(), + peer_eq: HashMap::new(), + peer_pan: HashMap::new(), + hotkeys: crate::hotkeys::HotkeyMap::default(), window_width: default_window_width(), window_height: default_window_height(), window_x: None, @@ -411,6 +426,18 @@ mod tests { assert_eq!(deserialized.window_height, 760.0); // Configs predating the recents list load an empty list. assert!(deserialized.recents.is_empty()); + // Configs predating per-peer listener shaping load flat/center/default + // shortcut settings. + assert!(deserialized.peer_eq.is_empty()); + assert!(deserialized.peer_pan.is_empty()); + assert_eq!( + crate::hotkeys::format_binding( + deserialized + .hotkeys + .binding(crate::hotkeys::HotkeyAction::PushToTalk) + ), + "Space" + ); } #[test] @@ -596,4 +623,3 @@ mod tests { assert_eq!(config.noise_gate_threshold, 0.01); } } - diff --git a/src/core/messages.rs b/src/core/messages.rs index b57b612..b738b50 100644 --- a/src/core/messages.rs +++ b/src/core/messages.rs @@ -18,6 +18,10 @@ pub enum CoreCommand { SetPttMode(bool), SetPttActive(bool), SetPeerVolume(EndpointId, f32), + /// Listener-side per-peer EQ. Local only; never leaves this app instance. + SetPeerEq(EndpointId, crate::audio::eq::EqSettings), + /// Listener-side per-peer pan. Local only; never leaves this app instance. + SetPeerPan(EndpointId, f32), /// Locally mute/unmute a peer: when muted, their audio is decoded (so levels /// still show) but not mixed into our output. SetPeerMuted(EndpointId, bool), diff --git a/src/core/mod.rs b/src/core/mod.rs index 04e2de5..671267b 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -2,6 +2,7 @@ pub mod messages; pub mod jitter; use crate::audio::{AudioBackend, pipewire_impl::PipeWireBackend}; +use crate::audio::eq::{Eq, EqSettings}; use crate::codec::{AudioEncoder, opus_impl::OpusEncoder}; use crate::core::jitter::{JitterBuffer, FRAME_SAMPLES}; use crate::network::{ @@ -214,6 +215,7 @@ fn stop_mic_monitor(backend: &PipeWireBackend, monitor: Option) { /// limiter (see [`crate::audio::limiter`]) can ride it down to the ceiling instead /// of the old hard clip shattering loud moments. Peers shorter than `frame_len` /// contribute 0 past their end; an empty peer set yields a silent bus. +#[cfg(test)] fn mix_frames(peer_frames: &[Vec], frame_len: usize) -> Vec { let mut mixed = vec![0i32; frame_len]; for frame in peer_frames { @@ -224,6 +226,44 @@ fn mix_frames(peer_frames: &[Vec], frame_len: usize) -> Vec { mixed } +/// Sum per-peer mono frames into one interleaved stereo `i32` bus. Center pan is +/// a special exact dual-mono path so the default listener mix is bit-for-bit the +/// old mono sum duplicated to both ears. +fn mix_stereo_frames(peer_frames: &[(Vec, f32)], frame_len: usize) -> Vec { + let mut mixed = vec![0i32; frame_len * crate::audio::PLAYBACK_CHANNELS]; + for (frame, pan) in peer_frames { + if pan.abs() <= f32::EPSILON { + for (i, &sample) in frame.iter().take(frame_len).enumerate() { + let idx = i * crate::audio::PLAYBACK_CHANNELS; + let s = sample as i32; + mixed[idx] += s; + mixed[idx + 1] += s; + } + continue; + } + + let (left_gain, right_gain) = crate::audio::pan::playback_pan_gains(*pan); + for (i, &sample) in frame.iter().take(frame_len).enumerate() { + let idx = i * crate::audio::PLAYBACK_CHANNELS; + let x = sample as f32; + mixed[idx] += (x * left_gain).round() as i32; + mixed[idx + 1] += (x * right_gain).round() as i32; + } + } + mixed +} + +/// Fold an interleaved stereo frame to mono for the existing mixed WAV writers. +/// Center/default pan folds back to the exact old mono mix. +fn stereo_to_mono(stereo: &[i16]) -> Vec { + let mut mono = Vec::with_capacity(stereo.len() / crate::audio::PLAYBACK_CHANNELS); + for pair in stereo.chunks_exact(crate::audio::PLAYBACK_CHANNELS) { + let sum = pair[0] as i32 + pair[1] as i32; + mono.push((sum / 2).clamp(i16::MIN as i32, i16::MAX as i32) as i16); + } + mono +} + /// Handles the transport's per-peer link-state stream (`ConnEvent`): arms/cancels /// reconnect grace timers, tracks which peers we've linked with, and forwards /// link state to the UI. Pulled out of the conn-event task as a unit so the @@ -665,6 +705,8 @@ async fn run_core_loop( let is_multitrack = Arc::new(AtomicBool::new(false)); let mut recording_mode = RecordingMode::default(); let peer_volumes = Arc::new(Mutex::new(HashMap::::new())); + let peer_eq = Arc::new(Mutex::new(HashMap::::new())); + let peer_pan = Arc::new(Mutex::new(HashMap::::new())); // Peers locally muted by us: decoded for level metering but not mixed. let locally_muted = Arc::new(Mutex::new(HashSet::::new())); let mut current_name = "Anonymous".to_string(); @@ -1104,6 +1146,8 @@ async fn run_core_loop( let jitter_mixer = jitter.clone(); let is_deafened_clone = is_deafened.clone(); let peer_volumes_mixer = peer_volumes.clone(); + let peer_eq_mixer = peer_eq.clone(); + let peer_pan_mixer = peer_pan.clone(); let locally_muted_mixer = locally_muted.clone(); let output_gain_mixer = output_gain.clone(); let ui_tx_mixer = ui_tx.clone(); @@ -1117,6 +1161,9 @@ async fn run_core_loop( // the ceiling instead of hard-clipping. State carries across // frames (see audio::limiter). let mut limiter = crate::audio::limiter::SoftLimiter::new(48_000); + // Per-peer EQ filter state. Settings are live-cloned each + // cycle; state is rebuilt only when a peer's EQ changes. + let mut peer_eqs: HashMap = HashMap::new(); // When the ring is at/above target we have nothing to do; nap // briefly and re-check. Short enough (relative to the ~60ms // target and ~21ms device quantum) that we always refill well @@ -1140,8 +1187,11 @@ async fn run_core_loop( } let current_volumes = peer_volumes_mixer.lock().await.clone(); + let current_eq = peer_eq_mixer.lock().await.clone(); + let current_pans = peer_pan_mixer.lock().await.clone(); let muted_peers = locally_muted_mixer.lock().await.clone(); - let mut peer_frames = Vec::new(); + let mut peer_frames: Vec<(Vec, f32)> = Vec::new(); + let mut peers_seen = HashSet::new(); // Multitrack stem capture: tap each peer's RAW decoded frame // (pre-volume, pre-mute, pre-limiter) so the stems are clean @@ -1167,10 +1217,31 @@ async fn run_core_loop( let vol = current_volumes.get(&peer_id).copied().unwrap_or(1.0); apply_volume(&mut frame, vol); + let eq_settings = current_eq + .get(&peer_id) + .copied() + .unwrap_or_default() + .clamped(); + if eq_settings.is_flat() { + peer_eqs.remove(&peer_id); + } else { + let needs_rebuild = peer_eqs + .get(&peer_id) + .map(|eq| eq.settings() != eq_settings) + .unwrap_or(true); + if needs_rebuild { + peer_eqs.insert(peer_id, Eq::new(eq_settings)); + } + if let Some(eq) = peer_eqs.get_mut(&peer_id) { + eq.process_frame(&mut frame); + } + } + // Level is recorded even for locally-muted peers so // the UI still shows that they're speaking. let peak = level_peaks.entry(peer_id).or_insert(0.0); *peak = peak.max(frame_level(&frame)); + peers_seen.insert(peer_id); // Locally muted: decoded above (jitter buffer advances, // level shown) but not mixed into our output. @@ -1178,16 +1249,23 @@ async fn run_core_loop( continue; } - peer_frames.push(frame); + let pan = current_pans + .get(&peer_id) + .copied() + .unwrap_or(0.0) + .clamp(-1.0, 1.0); + peer_frames.push((frame, pan)); } } + peer_eqs.retain(|id, _| peers_seen.contains(id) || current_eq.contains_key(id)); // Lossless i32 sum, then the limiter applies the master // output gain (in f32, so a boost past the ceiling is // limited too) and rides peaks down to the ceiling. - let mixed_sum = mix_frames(&peer_frames, FRAME_SAMPLES); + let mixed_sum = mix_stereo_frames(&peer_frames, FRAME_SAMPLES); let out_gain = f32::from_bits(output_gain_mixer.load(Ordering::Relaxed)); let mixed = limiter.process(&mixed_sum, out_gain); + let record_mix = stereo_to_mono(&mixed); // Record the true call audio, independent of local deafen — // deafen only silences our own monitor, not what the call @@ -1200,7 +1278,7 @@ async fn run_core_loop( for (id, f) in &stems { mt.write_peer(*id, f)?; } - mt.write_mix(&mixed)?; + mt.write_mix(&record_mix)?; mt.end_cycle() })(); if let Err(e) = res { @@ -1209,13 +1287,13 @@ async fn run_core_loop( } } else if is_recording_mixer.load(Ordering::Relaxed) && let Some(rec) = recorder_mixer.lock().unwrap().as_mut() - && let Err(e) = rec.write_frame(&mixed) + && let Err(e) = rec.write_frame(&record_mix) { crate::log_msg(&format!("Recording write failed: {e}")); } let frame_to_send = if is_deafened_clone.load(Ordering::Relaxed) { - vec![0i16; FRAME_SAMPLES] + vec![0i16; mixed.len()] } else { mixed }; @@ -1522,6 +1600,26 @@ async fn run_core_loop( guard.insert(peer_id, vol); } + CoreCommand::SetPeerEq(peer_id, settings) => { + let settings = settings.clamped(); + let mut guard = peer_eq.lock().await; + if settings.is_flat() { + guard.remove(&peer_id); + } else { + guard.insert(peer_id, settings); + } + } + + CoreCommand::SetPeerPan(peer_id, pan) => { + let pan = pan.clamp(-1.0, 1.0); + let mut guard = peer_pan.lock().await; + if pan.abs() <= 0.001 { + guard.remove(&peer_id); + } else { + guard.insert(peer_id, pan); + } + } + CoreCommand::SetPeerMuted(peer_id, muted) => { let mut guard = locally_muted.lock().await; if muted { @@ -1865,7 +1963,10 @@ async fn run_core_loop( #[cfg(test)] mod tests { - use super::{apply_volume, frame_level, mix_frames, MicLevelMeter, MIC_LEVEL_REPORT_SAMPLES}; + use super::{ + apply_volume, frame_level, mix_frames, mix_stereo_frames, stereo_to_mono, MicLevelMeter, + MIC_LEVEL_REPORT_SAMPLES, + }; /// A frame of constant amplitude with the given sample count. fn frame(amp: i16, len: usize) -> Vec { @@ -1924,6 +2025,27 @@ mod tests { assert_eq!(mixed, vec![100i32, -200, 300, -400]); } + #[test] + fn centered_stereo_mix_is_exact_dual_mono() { + let a = vec![100, -200, 300, -400]; + let b = vec![50, 200, -100, 400]; + let mixed = mix_stereo_frames(&[(a, 0.0), (b, 0.0)], 4); + assert_eq!(mixed, vec![150, 150, 0, 0, 200, 200, 0, 0]); + } + + #[test] + fn hard_left_pan_only_contributes_left_channel() { + let frame = vec![100, 200]; + let mixed = mix_stereo_frames(&[(frame, -1.0)], 2); + assert_eq!(mixed, vec![141, 0, 283, 0]); + } + + #[test] + fn stereo_fold_down_averages_pairs() { + let mono = stereo_to_mono(&[100, 100, 200, 0, i16::MAX, i16::MAX]); + assert_eq!(mono, vec![100, 100, i16::MAX]); + } + #[test] fn two_peers_sum_sample_by_sample() { let a = vec![100, -200, 300, -400]; @@ -2038,4 +2160,3 @@ mod tests { assert!((level - 0.5).abs() < 1e-3, "mid-range level was {level}"); } } - diff --git a/src/hotkeys.rs b/src/hotkeys.rs new file mode 100644 index 0000000..2ea2709 --- /dev/null +++ b/src/hotkeys.rs @@ -0,0 +1,284 @@ +//! Focused, app-local keyboard shortcuts. +//! +//! These helpers are intentionally pure: key serialization, formatting, lookup, +//! and conflict detection live here, while iced event handling stays at the app +//! edge. There are no OS-global shortcuts. + +use iced::keyboard; +use serde::{Deserialize, Serialize}; + +/// A serializable key identity. Modifiers are deliberately out of scope for this +/// first pass; iced delivers the focused app key and we compare that exact key. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum KeyBinding { + Named(String), + Character(String), +} + +impl KeyBinding { + pub fn from_key(key: &keyboard::Key) -> Option { + match key { + keyboard::Key::Named(named) => Some(Self::Named(format!("{named:?}"))), + keyboard::Key::Character(ch) => { + let s = ch.to_string(); + if s.is_empty() { + None + } else { + Some(Self::Character(s.to_lowercase())) + } + } + keyboard::Key::Unidentified => None, + } + } + + pub fn label(&self) -> String { + match self { + KeyBinding::Named(name) => name.clone(), + KeyBinding::Character(ch) => ch.to_uppercase(), + } + } +} + +/// Parse a hand-editable binding string from config/docs/tests. Empty and +/// `"unset"` are unbound. +pub fn parse_binding(input: &str) -> Option { + let trimmed = input.trim(); + if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("unset") { + return None; + } + if trimmed.chars().count() == 1 { + Some(KeyBinding::Character(trimmed.to_lowercase())) + } else { + Some(KeyBinding::Named(trimmed.to_string())) + } +} + +pub fn format_binding(binding: Option<&KeyBinding>) -> String { + binding + .map(KeyBinding::label) + .unwrap_or_else(|| "unset".to_string()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum HotkeyAction { + ToggleMute, + ToggleDeafen, + OpenSettings, + PushToTalk, + LeaveRoom, +} + +impl HotkeyAction { + pub const ALL: [HotkeyAction; 5] = [ + HotkeyAction::ToggleMute, + HotkeyAction::ToggleDeafen, + HotkeyAction::OpenSettings, + HotkeyAction::PushToTalk, + HotkeyAction::LeaveRoom, + ]; + + pub fn label(self) -> &'static str { + match self { + HotkeyAction::ToggleMute => "Toggle mute", + HotkeyAction::ToggleDeafen => "Toggle deafen", + HotkeyAction::OpenSettings => "Open Settings", + HotkeyAction::PushToTalk => "Push-to-talk", + HotkeyAction::LeaveRoom => "Leave room", + } + } + + pub fn tier(self) -> HotkeyTier { + match self { + HotkeyAction::ToggleMute + | HotkeyAction::ToggleDeafen + | HotkeyAction::OpenSettings => HotkeyTier::AppWide, + HotkeyAction::PushToTalk | HotkeyAction::LeaveRoom => HotkeyTier::RoomOnly, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HotkeyTier { + AppWide, + RoomOnly, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HotkeyContext { + pub in_call: bool, +} + +impl HotkeyContext { + fn allows(self, action: HotkeyAction) -> bool { + matches!(action.tier(), HotkeyTier::AppWide) || self.in_call + } +} + +/// Persisted shortcut map. Defaults preserve the old Space push-to-talk binding +/// and add a few function-key app shortcuts that do not collide with typing. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HotkeyMap { + #[serde(default = "default_mute")] + pub toggle_mute: Option, + #[serde(default = "default_deafen")] + pub toggle_deafen: Option, + #[serde(default = "default_settings")] + pub open_settings: Option, + #[serde(default = "default_ptt")] + pub push_to_talk: Option, + #[serde(default)] + pub leave_room: Option, +} + +impl Default for HotkeyMap { + fn default() -> Self { + Self { + toggle_mute: default_mute(), + toggle_deafen: default_deafen(), + open_settings: default_settings(), + push_to_talk: default_ptt(), + leave_room: None, + } + } +} + +fn named(name: &str) -> Option { + Some(KeyBinding::Named(name.to_string())) +} + +fn default_mute() -> Option { + named("F9") +} + +fn default_deafen() -> Option { + named("F10") +} + +fn default_settings() -> Option { + named("F2") +} + +fn default_ptt() -> Option { + named("Space") +} + +impl HotkeyMap { + pub fn binding(&self, action: HotkeyAction) -> Option<&KeyBinding> { + match action { + HotkeyAction::ToggleMute => self.toggle_mute.as_ref(), + HotkeyAction::ToggleDeafen => self.toggle_deafen.as_ref(), + HotkeyAction::OpenSettings => self.open_settings.as_ref(), + HotkeyAction::PushToTalk => self.push_to_talk.as_ref(), + HotkeyAction::LeaveRoom => self.leave_room.as_ref(), + } + } + + pub fn set_binding(&mut self, action: HotkeyAction, binding: Option) { + match action { + HotkeyAction::ToggleMute => self.toggle_mute = binding, + HotkeyAction::ToggleDeafen => self.toggle_deafen = binding, + HotkeyAction::OpenSettings => self.open_settings = binding, + HotkeyAction::PushToTalk => self.push_to_talk = binding, + HotkeyAction::LeaveRoom => self.leave_room = binding, + } + } + + pub fn lookup_key(&self, key: &keyboard::Key, context: HotkeyContext) -> Option { + let pressed = KeyBinding::from_key(key)?; + HotkeyAction::ALL + .into_iter() + .find(|&action| context.allows(action) && self.binding(action) == Some(&pressed)) + } + + pub fn lookup_binding( + &self, + binding: &KeyBinding, + context: HotkeyContext, + ) -> Option { + HotkeyAction::ALL + .into_iter() + .find(|&action| context.allows(action) && self.binding(action) == Some(binding)) + } + + pub fn conflicts(&self) -> Vec { + let mut conflicts = Vec::new(); + let actions = HotkeyAction::ALL; + for i in 0..actions.len() { + for j in (i + 1)..actions.len() { + let a = actions[i]; + let b = actions[j]; + if let (Some(ab), Some(bb)) = (self.binding(a), self.binding(b)) + && ab == bb + { + conflicts.push(HotkeyConflict { + binding: ab.clone(), + first: a, + second: b, + }); + } + } + } + conflicts + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HotkeyConflict { + pub binding: KeyBinding, + pub first: HotkeyAction, + pub second: HotkeyAction, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unset_actions_format_as_unset() { + assert_eq!(format_binding(None), "unset"); + assert_eq!(parse_binding("unset"), None); + assert_eq!(parse_binding(""), None); + } + + #[test] + fn duplicate_binding_is_detected() { + let mut map = HotkeyMap::default(); + map.set_binding(HotkeyAction::ToggleMute, parse_binding("M")); + map.set_binding(HotkeyAction::ToggleDeafen, parse_binding("m")); + let conflicts = map.conflicts(); + assert_eq!(conflicts.len(), 1); + assert_eq!(conflicts[0].first, HotkeyAction::ToggleMute); + assert_eq!(conflicts[0].second, HotkeyAction::ToggleDeafen); + } + + #[test] + fn lookup_respects_room_tier() { + let mut map = HotkeyMap::default(); + map.set_binding(HotkeyAction::LeaveRoom, parse_binding("Escape")); + let binding = parse_binding("Escape").unwrap(); + assert_eq!( + map.lookup_binding(&binding, HotkeyContext { in_call: false }), + None, + "room-only shortcuts should not fire outside a call" + ); + assert_eq!( + map.lookup_binding(&binding, HotkeyContext { in_call: true }), + Some(HotkeyAction::LeaveRoom) + ); + } + + #[test] + fn default_ptt_is_space() { + let map = HotkeyMap::default(); + assert_eq!( + format_binding(map.binding(HotkeyAction::PushToTalk)), + "Space" + ); + } + + #[test] + fn parse_single_character_case_folds() { + assert_eq!(parse_binding("M"), Some(KeyBinding::Character("m".to_string()))); + assert_eq!(format_binding(parse_binding("m").as_ref()), "M"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 6f5e277..877fdbd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,7 @@ pub mod sanitize; pub mod avatar; pub mod recents; pub mod discovery; +pub mod hotkeys; use std::path::PathBuf; use std::sync::OnceLock; @@ -60,4 +61,3 @@ pub fn log_msg(msg: &str) { let _ = file.write_all(line.as_bytes()); } } - diff --git a/task-report.md b/task-report.md new file mode 100644 index 0000000..a12c48f --- /dev/null +++ b/task-report.md @@ -0,0 +1,61 @@ +# Codex task report - 2026-06-16 + +## W2 - Per-peer EQ + +- Added `src/audio/eq.rs`: a 3-band listener-side RBJ biquad EQ (low shelf, mid peaking, high shelf) with per-peer state and flat bypass. +- Added local config persistence in `AppConfig.peer_eq`, keyed by peer node id string. +- Added local `CoreCommand::SetPeerEq` and mixer-side per-peer `Eq` state. EQ is applied after local volume and before pan/mix; raw multitrack stems remain pre-volume/pre-EQ. +- Added participant-card controls for Low/Mid/High gain sliders (-12 dB to +12 dB). Changes apply live and persist on slider release. +- Tests added for flat identity, low/high boost energy, coefficient finiteness, clamping, and hot-signal processing. + +Unverified: subjective voice quality and zipper/noise behavior on real devices. + +## W1 - Per-listener pan / stereo playback + +- Added `src/audio/pan.rs`: constant-power `pan_gains()` with tests, plus playback gains that preserve the legacy default dual-mono center. +- Converted playback mix to interleaved stereo in `src/core/mod.rs`. +- Switched PipeWire playback output to 2-channel S16LE and adjusted ring target/capacity/stride accounting in `src/audio/pipewire_impl.rs`. +- Kept capture, Opus encode/decode, jitter buffers, and network audio mono. +- Limiter now receives the interleaved stereo bus; shared limiter gain ducks both channels consistently. +- Mixed WAV and multitrack convenience mix fold the listener stereo mix back to mono before writing. Per-peer stems remain raw mono. +- Updated `audio_probe` to send dual-mono stereo frames. +- Added tests for exact center dual-mono behavior, hard-left pan contribution, and stereo fold-down. + +Decision for senior sanity-check: pure pan law is constant-power, but playback scales it by sqrt(2) so pan=0 is exactly the old mono signal in both ears. This satisfies the "default behavior unchanged" guardrail at the cost of louder hard-panned extremes, which the existing limiter catches. + +Unverified: real PipeWire stereo playback, underrun behavior on actual hardware, and recorded WAV listening checks. + +## W5 - Focused hotkeys + info popup + +- Added `src/hotkeys.rs`: serializable `KeyBinding`, `HotkeyAction`, `HotkeyMap`, parse/format/lookup, tier checks, and duplicate conflict detection. +- Added `AppConfig.hotkeys` with defaults: F9 mute, F10 deafen, F2 Settings, Space push-to-talk, Leave unset. +- Replaced the hard-coded PTT key capture with config-backed binding capture. +- Added Settings hotkey editor with Set/Clear per action and live conflict warnings. +- Added top-right hotkey info popup that lists every action and current binding, showing `unset` for unbound actions. +- Routed focused iced key events through the map. App-wide actions can fire from any screen while focused; room-only actions require an active call. PTT press/release still uses `SetPttActive`. +- Tests added for unset formatting, duplicate detection, room-tier lookup, defaults, and character parse/format. + +Unverified: manual keyboard interaction in the GUI. No OS-global hooks were added. + +## W3 - PipeWire pro-routing plan (not implemented) + +I stopped at design for W3. The current backend already supports simple target-node routing through PipeWire stream property `node.target`, but true "pro routing" (explicit ports / manual graph links / no-autoconnect patching) would require backend changes that are not safely verifiable offline. + +Proposed future scope: + +- Expose two advanced route targets: capture source node and playback sink node, with optional future per-port routing. +- Enumerate available nodes with the existing `pw-cli list-objects Node` parser. For port-level routing, add a separate parser for `pw-cli list-objects Port` collecting `object.id`, `node.id`, `port.name`, direction, and channel position. +- For node-level routing, continue using PipeWire stream property `node.target` on stream creation. This is the low-risk path and matches current backend behavior. +- For explicit port routing, do not use `AUTOCONNECT`; instead capture the created PeerSpeak stream node/port ids from the PipeWire registry, then link with PipeWire-native APIs or `pw-link `. Degrade by falling back to `node.target` autoconnect if any selected node/port is missing. +- Offline tests should cover pure routing-plan decisions: selected node exists/missing, selected port exists/missing, capture/playback direction mismatch, and fallback choice. Real-device tests still need a PipeWire graph. + +Reason for not implementing: the current `run_playback` / `run_capture` code does not retain stream node or port ids, and changing `AUTOCONNECT` behavior plus adding manual `pw-link` calls could destabilize the working audio path. That matches the assignment's "bail if risky" instruction. + +## Verification + +- `cargo test --lib` passed: 285 passed, 0 failed, 2 ignored. +- `cargo clippy --all-targets` passed. +- `cargo build --release` passed. +- `cargo fmt --check` reports broad repo-wide formatting diffs, including untouched files; I did not run `cargo fmt` to avoid unrelated churn. + +No new dependencies were added. I did not run git commands.