Compare commits

..
Author SHA1 Message Date
mollusk 33e3998e7c Add orderly shutdown on window close 2026-06-16 17:37:00 -04:00
mollusk 44bad7b70b Fix jitter restart and WAV size overflow 2026-06-16 17:28:41 -04:00
mollusk 20643a24de Add audio controls and focused hotkeys 2026-06-16 17:23:38 -04:00
15 changed files with 1566 additions and 96 deletions
+457 -28
View File
@@ -1,8 +1,10 @@
use crate::core::{CoreController, messages::{CoreCommand, UiEvent}}; use crate::core::{CoreController, messages::{CoreCommand, UiEvent}};
use crate::network::PeerState; use crate::network::PeerState;
use crate::notify::{self, Sound}; 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::audio::pw_cli::{AudioDevice, enumerate_audio_devices};
use crate::config::{AppConfig, NetworkMode, RecordingMode, RoomLayout}; use crate::config::{AppConfig, NetworkMode, RecordingMode, RoomLayout};
use crate::hotkeys::{format_binding, HotkeyAction, HotkeyContext, KeyBinding};
use crate::presence::PresenceMode; use crate::presence::PresenceMode;
use crate::theme::{AppTheme, Palette}; use crate::theme::{AppTheme, Palette};
@@ -62,6 +64,13 @@ pub enum DividerKind {
ChatDrawer, ChatDrawer,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EqBand {
Low,
Mid,
High,
}
/// Minimum width of the Participants panel (px). /// Minimum width of the Participants panel (px).
const PARTICIPANTS_MIN_W: f32 = 200.0; const PARTICIPANTS_MIN_W: f32 = 200.0;
/// Minimum width reserved for the Controls panel when resizing Participants (px). /// Minimum width reserved for the Controls panel when resizing Participants (px).
@@ -73,6 +82,8 @@ const CHAT_MIN_H: f32 = 110.0;
const ABOVE_CHAT_MIN_H: f32 = 300.0; const ABOVE_CHAT_MIN_H: f32 = 300.0;
/// Thickness of a draggable divider (px). /// Thickness of a draggable divider (px).
const DIVIDER_THICKNESS: f32 = 8.0; const DIVIDER_THICKNESS: f32 = 8.0;
/// Upper bound for waiting on orderly core shutdown before letting the window exit.
const SHUTDOWN_TIMEOUT_SECS: u64 = 5;
/// Clamp the Participants panel width so neither it nor the Controls panel drops /// Clamp the Participants panel width so neither it nor the Controls panel drops
/// below its minimum, given the current window width. /// below its minimum, given the current window width.
@@ -121,8 +132,11 @@ pub enum AppMessage {
/// Copy an arbitrary string to the clipboard (e.g. the full node ID). /// Copy an arbitrary string to the clipboard (e.g. the full node ID).
CopyText(String), CopyText(String),
TogglePtt(bool), TogglePtt(bool),
StartSettingHotkey, StartHotkeyCapture(HotkeyAction),
ClearHotkey(HotkeyAction),
PeerVolumeChanged(EndpointId, f32), PeerVolumeChanged(EndpointId, f32),
PeerPanChanged(EndpointId, f32),
PeerEqChanged(EndpointId, EqBand, f32),
/// Toggle local mute of a peer (silence them just for us). /// Toggle local mute of a peer (silence them just for us).
TogglePeerMute(EndpointId), TogglePeerMute(EndpointId),
InputDeviceSelected(AudioDevice), InputDeviceSelected(AudioDevice),
@@ -186,6 +200,9 @@ pub enum AppMessage {
/// Open / close the room-layout picker popup. /// Open / close the room-layout picker popup.
OpenLayoutPicker, OpenLayoutPicker,
CloseLayoutPicker, CloseLayoutPicker,
/// Open / close the live hotkey reference popup.
OpenHotkeyInfo,
CloseHotkeyInfo,
/// Open / close the "screen sharing needs pixelpass" explainer popup (A11). /// Open / close the "screen sharing needs pixelpass" explainer popup (A11).
OpenPixelpassHelp, OpenPixelpassHelp,
ClosePixelpassHelp, ClosePixelpassHelp,
@@ -206,6 +223,10 @@ pub enum AppMessage {
ToggleScreenShare, ToggleScreenShare,
/// Watch a peer's screen share, identified by their pixelpass ticket. /// Watch a peer's screen share, identified by their pixelpass ticket.
WatchShare(String), WatchShare(String),
/// Result of asynchronously enqueueing the core shutdown command.
ShutdownCommandSent(bool),
/// Fallback close if the core does not acknowledge shutdown promptly.
ShutdownTimeout,
} }
fn core_subscription() -> impl iced::futures::Stream<Item = UiEvent> { fn core_subscription() -> impl iced::futures::Stream<Item = UiEvent> {
@@ -235,8 +256,7 @@ pub struct AppState {
is_deafened: bool, is_deafened: bool,
ptt_enabled: bool, ptt_enabled: bool,
ptt_active: bool, ptt_active: bool,
ptt_hotkey: keyboard::Key, hotkey_capture: Option<HotkeyAction>,
is_setting_hotkey: bool,
input_devices: Vec<AudioDevice>, input_devices: Vec<AudioDevice>,
output_devices: Vec<AudioDevice>, output_devices: Vec<AudioDevice>,
selected_input: Option<AudioDevice>, selected_input: Option<AudioDevice>,
@@ -261,6 +281,8 @@ pub struct AppState {
window_size: Size, window_size: Size,
/// Whether the room-layout picker popup is open (launch + in-call screens). /// Whether the room-layout picker popup is open (launch + in-call screens).
layout_picker_open: bool, 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). /// Whether the pixelpass screen-share explainer popup is open (A11).
pixelpass_help_open: bool, pixelpass_help_open: bool,
/// Whether the Chat drawer is open (drawer layout only). /// Whether the Chat drawer is open (drawer layout only).
@@ -308,6 +330,8 @@ pub struct AppState {
friend_add_name: String, friend_add_name: String,
/// Inline feedback for the add-friend form (e.g. a bad id), cleared on edit. /// Inline feedback for the add-friend form (e.g. a bad id), cleared on edit.
friend_add_error: Option<String>, friend_add_error: Option<String>,
/// Window close has been requested and the GUI is waiting for core teardown.
closing: bool,
} }
impl AppState { impl AppState {
@@ -353,6 +377,16 @@ impl Default for AppState {
let _ = controller.send(CoreCommand::SetRecordingMode(config.recording_mode)); let _ = controller.send(CoreCommand::SetRecordingMode(config.recording_mode));
let _ = controller.send(CoreCommand::SetPixelpassPath(config.pixelpass_path.clone())); let _ = controller.send(CoreCommand::SetPixelpassPath(config.pixelpass_path.clone()));
let _ = controller.send(CoreCommand::SetPresenceMode(config.presence_mode)); let _ = controller.send(CoreCommand::SetPresenceMode(config.presence_mode));
for (peer, settings) in &config.peer_eq {
if let Ok(id) = peer.parse::<EndpointId>() {
let _ = controller.send(CoreCommand::SetPeerEq(id, *settings));
}
}
for (peer, pan) in &config.peer_pan {
if let Ok(id) = peer.parse::<EndpointId>() {
let _ = controller.send(CoreCommand::SetPeerPan(id, *pan));
}
}
let pixelpass_available = let pixelpass_available =
crate::screenshare::is_available(config.pixelpass_path.as_deref()); crate::screenshare::is_available(config.pixelpass_path.as_deref());
let all_devices = enumerate_audio_devices(); let all_devices = enumerate_audio_devices();
@@ -375,8 +409,7 @@ impl Default for AppState {
is_deafened: false, is_deafened: false,
ptt_enabled: false, ptt_enabled: false,
ptt_active: false, ptt_active: false,
ptt_hotkey: keyboard::Key::Named(keyboard::key::Named::Space), hotkey_capture: None,
is_setting_hotkey: false,
input_devices, input_devices,
output_devices, output_devices,
selected_input, selected_input,
@@ -393,6 +426,7 @@ impl Default for AppState {
chat_input: String::new(), chat_input: String::new(),
window_size: Size::new(ww, wh), window_size: Size::new(ww, wh),
layout_picker_open: false, layout_picker_open: false,
hotkey_info_open: false,
pixelpass_help_open: false, pixelpass_help_open: false,
drawer_chat_open: false, drawer_chat_open: false,
mic_level: 0.0, mic_level: 0.0,
@@ -415,6 +449,7 @@ impl Default for AppState {
friend_add_id: String::new(), friend_add_id: String::new(),
friend_add_name: String::new(), friend_add_name: String::new(),
friend_add_error: None, friend_add_error: None,
closing: false,
} }
} }
} }
@@ -494,6 +529,20 @@ fn subscription(_state: &AppState) -> Subscription<AppMessage> {
Subscription::batch(vec![core_sub, event_sub]) Subscription::batch(vec![core_sub, event_sub])
} }
fn shutdown_timeout_task() -> Task<AppMessage> {
Task::perform(
async {
let (tx, rx) = iced::futures::channel::oneshot::channel();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(SHUTDOWN_TIMEOUT_SECS));
let _ = tx.send(());
});
let _ = rx.await;
},
|_| AppMessage::ShutdownTimeout,
)
}
/// Reconnect-chime edge trigger for `UiEvent::PeerConnecting`. Marks the peer as /// Reconnect-chime edge trigger for `UiEvent::PeerConnecting`. Marks the peer as
/// connecting and returns `Some(Sound::ReconnectAttempt)` exactly once per outage: /// connecting and returns `Some(Sound::ReconnectAttempt)` exactly once per outage:
/// only when the peer had a live link before (a genuine reconnect, not a first /// only when the peer had a live link before (a genuine reconnect, not a first
@@ -526,6 +575,105 @@ fn reconnected_chime(
was_reconnect.then_some(Sound::Reconnected) 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<AppMessage> { fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
match message { match message {
AppMessage::NicknameChanged(val) => { AppMessage::NicknameChanged(val) => {
@@ -593,14 +741,10 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.status_message = "Opening screen share…".to_string(); state.status_message = "Opening screen share…".to_string();
} }
AppMessage::ToggleMutePressed => { AppMessage::ToggleMutePressed => {
let _ = state.controller.send(CoreCommand::ToggleMute); toggle_mute(state);
state.is_muted = !state.is_muted;
notify::play(Sound::MicToggle, state.config.custom_sound_mic_toggle.as_deref());
} }
AppMessage::ToggleDeafenPressed => { AppMessage::ToggleDeafenPressed => {
let _ = state.controller.send(CoreCommand::ToggleDeafen); toggle_deafen(state);
state.is_deafened = !state.is_deafened;
notify::play(Sound::MicToggle, state.config.custom_sound_mic_toggle.as_deref());
} }
AppMessage::UiEventReceived(event) => { AppMessage::UiEventReceived(event) => {
match event { match event {
@@ -744,6 +888,11 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.status_message = state.status_message =
"Discoverable timed out — back to Normal".to_string(); "Discoverable timed out — back to Normal".to_string();
} }
UiEvent::ShutdownComplete => {
if state.closing {
return iced::exit();
}
}
UiEvent::Error(err) => { UiEvent::Error(err) => {
state.status_message = format!("Error: {}", err); state.status_message = format!("Error: {}", err);
} }
@@ -761,13 +910,26 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.ptt_enabled = enabled; state.ptt_enabled = enabled;
let _ = state.controller.send(CoreCommand::SetPttMode(enabled)); let _ = state.controller.send(CoreCommand::SetPttMode(enabled));
} }
AppMessage::StartSettingHotkey => { AppMessage::StartHotkeyCapture(action) => {
state.is_setting_hotkey = true; 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) => { AppMessage::PeerVolumeChanged(id, vol) => {
state.peer_volumes.insert(id, vol); state.peer_volumes.insert(id, vol);
let _ = state.controller.send(CoreCommand::SetPeerVolume(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) => { AppMessage::TogglePeerMute(id) => {
let now_muted = if state.locally_muted.contains(&id) { let now_muted = if state.locally_muted.contains(&id) {
state.locally_muted.remove(&id); state.locally_muted.remove(&id);
@@ -1021,10 +1183,18 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
} }
AppMessage::OpenLayoutPicker => { AppMessage::OpenLayoutPicker => {
state.layout_picker_open = true; state.layout_picker_open = true;
state.hotkey_info_open = false;
} }
AppMessage::CloseLayoutPicker => { AppMessage::CloseLayoutPicker => {
state.layout_picker_open = false; 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 => { AppMessage::OpenPixelpassHelp => {
state.pixelpass_help_open = true; state.pixelpass_help_open = true;
} }
@@ -1119,16 +1289,30 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
.send(CoreCommand::SetMicMonitor { enabled, input_device }); .send(CoreCommand::SetMicMonitor { enabled, input_device });
} }
AppMessage::EventOccurred(Event::Keyboard(keyboard::Event::KeyPressed { key, .. })) => { AppMessage::EventOccurred(Event::Keyboard(keyboard::Event::KeyPressed { key, .. })) => {
if state.is_setting_hotkey { if let Some(action) = state.hotkey_capture.take() {
state.ptt_hotkey = key.clone(); if let Some(binding) = KeyBinding::from_key(&key) {
state.is_setting_hotkey = false; state.config.hotkeys.set_binding(action, Some(binding));
} else if state.ptt_enabled && key == state.ptt_hotkey && !state.ptt_active { state.config.save();
state.ptt_active = true; } else {
let _ = state.controller.send(CoreCommand::SetPttActive(true)); 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, .. })) => { 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; state.ptt_active = false;
let _ = state.controller.send(CoreCommand::SetPttActive(false)); let _ = state.controller.send(CoreCommand::SetPttActive(false));
} }
@@ -1162,18 +1346,43 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.config.window_y = Some(position.y as i32); state.config.window_y = Some(position.y as i32);
} }
AppMessage::EventOccurred(Event::Window(iced::window::Event::CloseRequested)) => { AppMessage::EventOccurred(Event::Window(iced::window::Event::CloseRequested)) => {
if state.closing {
return Task::none();
}
// We took over the close path (exit_on_close_request:false) so we can // We took over the close path (exit_on_close_request:false) so we can
// persist the final window size + position before quitting. Both are // persist the final window size + position and give core a chance to
// already mirrored into config by the Resized/Moved handlers above. // leave the room/finalize recordings before quitting.
state.config.save(); state.config.save();
return iced::exit(); state.closing = true;
state.status_message = "Shutting down...".to_string();
let tx = state.controller.command_sender();
return Task::batch(vec![
Task::perform(
async move { tx.send(CoreCommand::Shutdown).await.is_ok() },
AppMessage::ShutdownCommandSent,
),
shutdown_timeout_task(),
]);
} }
AppMessage::EventOccurred(_) => {} AppMessage::EventOccurred(_) => {}
AppMessage::ShutdownCommandSent(sent) => {
if !sent {
return iced::exit();
}
}
AppMessage::ShutdownTimeout => {
if state.closing {
return iced::exit();
}
}
AppMessage::NavigateToSettings => { AppMessage::NavigateToSettings => {
state.current_screen = Screen::Settings; state.current_screen = Screen::Settings;
state.layout_picker_open = false;
state.hotkey_info_open = false;
} }
AppMessage::NavigateBack => { AppMessage::NavigateBack => {
state.config.save(); state.config.save();
state.hotkey_capture = None;
// Release the mic when leaving Settings if the test was running. // Release the mic when leaving Settings if the test was running.
if state.mic_test_active { if state.mic_test_active {
state.mic_test_active = false; state.mic_test_active = false;
@@ -1752,6 +1961,17 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
let top_bar = row![ let top_bar = row![
horizontal_space(), 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( tooltip(
button( button(
Canvas::new(LayoutIcon { fg: color_text }) Canvas::new(LayoutIcon { fg: color_text })
@@ -2039,6 +2259,72 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.into() .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) --- // --- Identity (W7) ---
// Your persistent node id + a Regenerate control. When the key isn't // Your persistent node id + a Regenerate control. When the key isn't
// persisted (disk/permission failure → ephemeral fallback) we show a // persisted (disk/permission failure → ephemeral fallback) we show a
@@ -2163,6 +2449,11 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
].spacing(8).width(iced::Length::Fill), ].spacing(8).width(iced::Length::Fill),
vertical_space(section_gap), vertical_space(section_gap),
// --- Hotkeys ---
section_header("Hotkeys"),
hotkey_section,
vertical_space(section_gap),
// --- Recording --- // --- Recording ---
section_header("Recording"), section_header("Recording"),
column![ column![
@@ -2345,7 +2636,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.height(iced::Length::Fill) .height(iced::Length::Fill)
.style(c_style(color_crust, Color::TRANSPARENT, 0.0)); .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 { } else {
// --- ROOM SCREEN --- // --- ROOM SCREEN ---
let participant_count = state.peers.len() + 1; // peers + you let participant_count = state.peers.len() + 1; // peers + you
@@ -2643,6 +2934,46 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
].spacing(8).align_y(iced::alignment::Vertical::Center) ].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) let card = container(card_content)
.style(c_style( .style(c_style(
if is_speaking { color_base } else { color_mantle }, if is_speaking { color_base } else { color_mantle },
@@ -2696,10 +3027,15 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
checkbox(state.ptt_enabled).label("Push-to-Talk").on_toggle(AppMessage::TogglePtt), checkbox(state.ptt_enabled).label("Push-to-Talk").on_toggle(AppMessage::TogglePtt),
vertical_space(10.0), vertical_space(10.0),
if state.ptt_enabled { 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![ column![
text(format!("Hotkey: {}", if state.is_setting_hotkey { "Press any key...".to_string() } else { format!("{:?}", state.ptt_hotkey) })).size(14).color(color_subtext), text(format!("PTT key: {ptt_binding}")).size(14).color(color_subtext),
button(text("Set Hotkey").size(12).align_x(iced::alignment::Horizontal::Center)) button(text("Set PTT Key").size(12).align_x(iced::alignment::Horizontal::Center))
.on_press(AppMessage::StartSettingHotkey) .on_press(AppMessage::StartHotkeyCapture(HotkeyAction::PushToTalk))
.style(b_style(color_surface, color_blue, color_text, 6.0)) .style(b_style(color_surface, color_blue, color_text, 6.0))
.padding(8) .padding(8)
.width(iced::Length::Fill) .width(iced::Length::Fill)
@@ -2987,7 +3323,10 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.height(iced::Length::Fill) .height(iced::Length::Fill)
.style(c_style(color_crust, Color::TRANSPARENT, 0.0)); .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 +3696,90 @@ fn with_layout_picker<'a>(
.into() .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` /// Overlays the "screen sharing needs pixelpass" explainer popup over `base`
/// when open (A11). Triggered by the Share Screen / Watch controls when the /// when open (A11). Triggered by the Share Screen / Watch controls when the
/// optional `pixelpass` companion isn't installed, so those controls open a /// optional `pixelpass` companion isn't installed, so those controls open a
@@ -3725,6 +4148,7 @@ enum IconKind {
Chat, Chat,
People, People,
Clock, Clock,
Info,
Settings, Settings,
Copy, Copy,
Leave, Leave,
@@ -3994,6 +4418,11 @@ impl Program<AppMessage> for Icon {
f.stroke(&poly(&[(12.0, 7.0), (12.0, 12.0)], false), stk()); 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()); 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 => { IconKind::Settings => {
f.stroke(&poly(&[(4.0, 7.0), (20.0, 7.0)], false), stk()); 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()); f.stroke(&poly(&[(4.0, 12.0), (20.0, 12.0)], false), stk());
+316
View File
@@ -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<i16> {
(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<i16> = (-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);
}
}
+11 -4
View File
@@ -1,17 +1,22 @@
use std::sync::mpsc::{Sender, Receiver}; use std::sync::mpsc::{Receiver, Sender};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::AtomicUsize; use std::sync::atomic::AtomicUsize;
use thiserror::Error; 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 /// 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), /// driven by how fast PipeWire actually drains the ring (the hardware clock),
/// not by a fixed software timer — which is what eliminates the producer/ /// not by a fixed software timer — which is what eliminates the producer/
/// consumer beat that otherwise churns ~20% of audio into drops + silence. /// 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. /// 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)] #[derive(Error, Debug)]
pub enum AudioError { pub enum AudioError {
@@ -52,9 +57,11 @@ pub trait AudioBackend: Send + Sync {
} }
pub mod echo_cancel; pub mod echo_cancel;
pub mod eq;
pub mod gate; pub mod gate;
pub mod limiter; pub mod limiter;
pub mod multitrack; pub mod multitrack;
pub mod pan;
pub mod pipewire_impl; pub mod pipewire_impl;
pub mod pw_cli; pub mod pw_cli;
pub mod recorder; pub mod recorder;
+77
View File
@@ -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));
}
}
+23 -18
View File
@@ -283,8 +283,9 @@ fn run_playback(
let core = context.connect_rc(None) let core = context.connect_rc(None)
.map_err(|e| AudioError::Init(e.to_string()))?; .map_err(|e| AudioError::Init(e.to_string()))?;
// Ring buffer setup: 9600 samples (200ms capacity for mono 48kHz). // Ring buffer setup: 19200 interleaved samples (200ms capacity for stereo
const RING_CAPACITY: usize = 9600; // 48kHz).
const RING_CAPACITY: usize = 9600 * crate::audio::PLAYBACK_CHANNELS;
let rb = HeapRb::<i16>::new(RING_CAPACITY); let rb = HeapRb::<i16>::new(RING_CAPACITY);
let (mut producer, consumer) = rb.split(); let (mut producer, consumer) = rb.split();
@@ -371,7 +372,7 @@ fn run_playback(
let data = &mut datas[0]; let data = &mut datas[0];
let mut total_size = 0; let mut total_size = 0;
if let Some(slice) = data.data() { 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 // Fill exactly what the graph asked for this cycle (with
// a safe fallback), never the whole mapped slice — that // a safe fallback), never the whole mapped slice — that
// over-pull past the ring depth was the original crackle. // 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); user_data.callback_count.fetch_add(1, Ordering::Relaxed);
let mut starved = 0u64; let mut starved = 0u64;
for i in 0..n_frames { 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; let start = i * stride;
slice[start] = bytes[0]; for ch in 0..crate::audio::PLAYBACK_CHANNELS {
slice[start + 1] = bytes[1]; 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 { if starved > 0 {
// One wait-free atomic add per quantum — RT-safe. // One wait-free atomic add per quantum — RT-safe.
@@ -403,7 +407,8 @@ fn run_playback(
// actually pulled (excluding underruns, which removed // actually pulled (excluding underruns, which removed
// nothing) so the mixer paces against true ring depth. // nothing) so the mixer paces against true ring depth.
// Wait-free fetch_sub, RT-safe. // 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 { if popped > 0 {
user_data.fill_gauge.fetch_sub(popped, Ordering::Relaxed); user_data.fill_gauge.fetch_sub(popped, Ordering::Relaxed);
} }
@@ -411,7 +416,7 @@ fn run_playback(
} }
let chunk = data.chunk_mut(); let chunk = data.chunk_mut();
*chunk.offset_mut() = 0; *chunk.offset_mut() = 0;
*chunk.stride_mut() = 2; *chunk.stride_mut() = (2 * crate::audio::PLAYBACK_CHANNELS) as _;
*chunk.size_mut() = total_size as _; *chunk.size_mut() = total_size as _;
} }
} }
@@ -422,7 +427,7 @@ fn run_playback(
let mut audio_info = spa::param::audio::AudioInfoRaw::new(); let mut audio_info = spa::param::audio::AudioInfoRaw::new();
audio_info.set_format(spa::param::audio::AudioFormat::S16LE); audio_info.set_format(spa::param::audio::AudioFormat::S16LE);
audio_info.set_rate(48000); 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 { let obj = pw::spa::pod::Object {
type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(), 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 // `frames_to_produce`). `requested()`, not the buffer size, now governs
// per-cycle output, so this is a generous max rather than a hard pin. // per-cycle output, so this is a generous max rather than a hard pin.
const MAX_QUANTUM_FRAMES: i32 = 8192; 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 { let buffers_obj = pw::spa::pod::Object {
type_: pw::spa::utils::SpaTypes::ObjectParamBuffers.as_raw(), type_: pw::spa::utils::SpaTypes::ObjectParamBuffers.as_raw(),
id: pw::spa::param::ParamType::Buffers.as_raw(), id: pw::spa::param::ParamType::Buffers.as_raw(),
@@ -555,7 +560,7 @@ fn run_playback(
if verbose || du > 0 || dd > 0 { if verbose || du > 0 || dd > 0 {
crate::log_msg(&format!( 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", "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),
)); ));
} }
} }
+55 -8
View File
@@ -22,6 +22,8 @@ use std::path::{Path, PathBuf};
const SAMPLE_RATE: u32 = 48_000; const SAMPLE_RATE: u32 = 48_000;
const BITS_PER_SAMPLE: u16 = 16; const BITS_PER_SAMPLE: u16 = 16;
const CHANNELS: u16 = 1; const CHANNELS: u16 = 1;
const RIFF_DATA_OVERHEAD: u64 = 36;
const MAX_RIFF_DATA_BYTES: u64 = u32::MAX as u64 - RIFF_DATA_OVERHEAD;
/// Cap on buffered mic samples (~200ms). Bounds how far recording lag can drift /// Cap on buffered mic samples (~200ms). Bounds how far recording lag can drift
/// if the capture clock runs persistently faster than playout — past this we drop /// if the capture clock runs persistently faster than playout — past this we drop
@@ -34,7 +36,7 @@ const MAX_MIC_FIFO: usize = SAMPLE_RATE as usize / 5;
pub struct WavWriter { pub struct WavWriter {
file: File, file: File,
/// Bytes of PCM data written so far (for the size fields). /// Bytes of PCM data written so far (for the size fields).
data_bytes: u32, data_bytes: u64,
} }
impl WavWriter { impl WavWriter {
@@ -42,7 +44,10 @@ impl WavWriter {
pub fn new(path: &Path) -> io::Result<Self> { pub fn new(path: &Path) -> io::Result<Self> {
let mut file = File::create(path)?; let mut file = File::create(path)?;
file.write_all(&Self::header(0))?; file.write_all(&Self::header(0))?;
Ok(Self { file, data_bytes: 0 }) Ok(Self {
file,
data_bytes: 0,
})
} }
/// The 44-byte canonical WAV/PCM header for the given data length in bytes. /// The 44-byte canonical WAV/PCM header for the given data length in bytes.
@@ -68,21 +73,40 @@ impl WavWriter {
/// Append PCM samples to the data chunk. /// Append PCM samples to the data chunk.
pub fn write_samples(&mut self, samples: &[i16]) -> io::Result<()> { pub fn write_samples(&mut self, samples: &[i16]) -> io::Result<()> {
let added_bytes = u64::try_from(samples.len())
.ok()
.and_then(|len| len.checked_mul(2))
.ok_or_else(|| io::Error::other("WAV sample buffer too large"))?;
let new_data_bytes = self
.data_bytes
.checked_add(added_bytes)
.ok_or_else(|| io::Error::other("WAV data size overflow"))?;
if new_data_bytes > MAX_RIFF_DATA_BYTES {
return Err(io::Error::other("WAV too large for RIFF"));
}
let mut buf = Vec::with_capacity(samples.len() * 2); let mut buf = Vec::with_capacity(samples.len() * 2);
for &s in samples { for &s in samples {
buf.extend_from_slice(&s.to_le_bytes()); buf.extend_from_slice(&s.to_le_bytes());
} }
self.file.write_all(&buf)?; self.file.write_all(&buf)?;
self.data_bytes += (samples.len() * 2) as u32; self.data_bytes = new_data_bytes;
Ok(()) Ok(())
} }
/// Patch the RIFF + data size fields and flush. Consumes the writer. /// Patch the RIFF + data size fields and flush. Consumes the writer.
pub fn finalize(mut self) -> io::Result<()> { pub fn finalize(mut self) -> io::Result<()> {
let data_bytes = u32::try_from(self.data_bytes)
.map_err(|_| io::Error::other("WAV too large for RIFF"))?;
let riff_size = self
.data_bytes
.checked_add(RIFF_DATA_OVERHEAD)
.and_then(|size| u32::try_from(size).ok())
.ok_or_else(|| io::Error::other("WAV too large for RIFF"))?;
self.file.seek(SeekFrom::Start(4))?; self.file.seek(SeekFrom::Start(4))?;
self.file.write_all(&(36 + self.data_bytes).to_le_bytes())?; self.file.write_all(&riff_size.to_le_bytes())?;
self.file.seek(SeekFrom::Start(40))?; self.file.seek(SeekFrom::Start(40))?;
self.file.write_all(&self.data_bytes.to_le_bytes())?; self.file.write_all(&data_bytes.to_le_bytes())?;
self.file.flush()?; self.file.flush()?;
Ok(()) Ok(())
} }
@@ -209,11 +233,29 @@ mod tests {
let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(&path);
} }
#[test]
fn wav_writer_rejects_data_that_would_overflow_riff_header() {
let dir = std::env::temp_dir();
let path = dir.join(format!("peerspeak-overflow-{}.wav", std::process::id()));
let mut w = WavWriter::new(&path).unwrap();
w.data_bytes = MAX_RIFF_DATA_BYTES - 1;
let before_len = std::fs::metadata(&path).unwrap().len();
let err = w.write_samples(&[0]).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::Other);
assert_eq!(w.data_bytes, MAX_RIFF_DATA_BYTES - 1);
assert_eq!(std::fs::metadata(&path).unwrap().len(), before_len);
drop(w);
let _ = std::fs::remove_file(&path);
}
#[test] #[test]
fn mic_is_summed_with_mix_when_present() { fn mic_is_summed_with_mix_when_present() {
let dir = std::env::temp_dir(); let dir = std::env::temp_dir();
let mut r = Recorder { let mut r = Recorder {
writer: WavWriter::new(&dir.join(format!("ps-sum-{}.wav", std::process::id()))).unwrap(), writer: WavWriter::new(&dir.join(format!("ps-sum-{}.wav", std::process::id())))
.unwrap(),
mic_fifo: VecDeque::new(), mic_fifo: VecDeque::new(),
path: PathBuf::new(), path: PathBuf::new(),
}; };
@@ -223,7 +265,11 @@ mod tests {
r.write_frame(&[10, 20]).unwrap(); r.write_frame(&[10, 20]).unwrap();
assert_eq!(r.mic_fifo.len(), 1, "two samples consumed, one mic left"); assert_eq!(r.mic_fifo.len(), 1, "two samples consumed, one mic left");
r.write_frame(&[0, 0]).unwrap(); r.write_frame(&[0, 0]).unwrap();
assert_eq!(r.mic_fifo.len(), 0, "remaining mic sample consumed; rest is silence"); assert_eq!(
r.mic_fifo.len(),
0,
"remaining mic sample consumed; rest is silence"
);
let _ = r.finalize(); let _ = r.finalize();
} }
@@ -231,7 +277,8 @@ mod tests {
fn mic_fifo_is_capped() { fn mic_fifo_is_capped() {
let dir = std::env::temp_dir(); let dir = std::env::temp_dir();
let mut r = Recorder { let mut r = Recorder {
writer: WavWriter::new(&dir.join(format!("ps-cap-{}.wav", std::process::id()))).unwrap(), writer: WavWriter::new(&dir.join(format!("ps-cap-{}.wav", std::process::id())))
.unwrap(),
mic_fifo: VecDeque::new(), mic_fifo: VecDeque::new(),
path: PathBuf::new(), path: PathBuf::new(),
}; };
+4 -2
View File
@@ -26,7 +26,7 @@ use std::time::Duration;
use peerspeak::audio::AudioBackend; use peerspeak::audio::AudioBackend;
use peerspeak::audio::pipewire_impl::PipeWireBackend; 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; const SAMPLE_RATE: f32 = 48_000.0;
@@ -69,11 +69,13 @@ async fn main() {
tokio::time::sleep(Duration::from_millis(2)).await; tokio::time::sleep(Duration::from_millis(2)).await;
continue; 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 { for _ in 0..FRAME_SAMPLES {
let t = n as f32 / SAMPLE_RATE; let t = n as f32 / SAMPLE_RATE;
// 0.25 amplitude: clearly audible but not harsh. // 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; 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); frame.push(sample);
n += 1; n += 1;
} }
+27 -1
View File
@@ -1,6 +1,7 @@
use crate::notify::Sound; use crate::notify::Sound;
use crate::theme::AppTheme; use crate::theme::AppTheme;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
@@ -232,6 +233,17 @@ pub struct AppConfig {
/// capped (see `recents`). Defaulted empty so older configs upgrade cleanly. /// capped (see `recents`). Defaulted empty so older configs upgrade cleanly.
#[serde(default)] #[serde(default)]
pub recents: Vec<crate::recents::Recent>, pub recents: Vec<crate::recents::Recent>,
/// 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<String, crate::audio::eq::EqSettings>,
/// 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<String, f32>,
/// Focused app-local keyboard shortcuts.
#[serde(default)]
pub hotkeys: crate::hotkeys::HotkeyMap,
/// Last window size (px), restored as the initial size on next launch. /// Last window size (px), restored as the initial size on next launch.
/// Saved on close. /// Saved on close.
#[serde(default = "default_window_width")] #[serde(default = "default_window_width")]
@@ -287,6 +299,9 @@ impl Default for AppConfig {
sound_reconnect_failed_enabled: true, sound_reconnect_failed_enabled: true,
pixelpass_path: None, pixelpass_path: None,
recents: Vec::new(), recents: Vec::new(),
peer_eq: HashMap::new(),
peer_pan: HashMap::new(),
hotkeys: crate::hotkeys::HotkeyMap::default(),
window_width: default_window_width(), window_width: default_window_width(),
window_height: default_window_height(), window_height: default_window_height(),
window_x: None, window_x: None,
@@ -411,6 +426,18 @@ mod tests {
assert_eq!(deserialized.window_height, 760.0); assert_eq!(deserialized.window_height, 760.0);
// Configs predating the recents list load an empty list. // Configs predating the recents list load an empty list.
assert!(deserialized.recents.is_empty()); 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] #[test]
@@ -596,4 +623,3 @@ mod tests {
assert_eq!(config.noise_gate_threshold, 0.01); assert_eq!(config.noise_gate_threshold, 0.01);
} }
} }
+60 -1
View File
@@ -59,6 +59,10 @@ const PRIME_TIMEOUT_TICKS: usize = 25;
/// badly behind, so we drop the oldest and resync rather than grow unbounded. /// badly behind, so we drop the oldest and resync rather than grow unbounded.
const MAX_BUFFERED_FRAMES: usize = 32; const MAX_BUFFERED_FRAMES: usize = 32;
/// Sequence discontinuities larger than this (~10s at 20ms/frame) are treated
/// as a restarted/new stream, not ordinary packet loss or reordering.
const MAX_REASONABLE_SEQ_GAP: u32 = 500;
pub struct JitterBuffer { pub struct JitterBuffer {
decoder: OpusDecoder, decoder: OpusDecoder,
/// Reorder window: sequence number -> encoded Opus payload. /// Reorder window: sequence number -> encoded Opus payload.
@@ -116,6 +120,14 @@ impl JitterBuffer {
} }
} }
fn reset_to_stream(&mut self, seq: u32, payload: Vec<u8>) {
self.packets.clear();
self.packets.insert(seq, payload);
self.next_seq = None;
self.clean_run = 0;
self.buffering_ticks = 0;
}
/// Store a received packet, dropping ones we've already played past and /// Store a received packet, dropping ones we've already played past and
/// bounding total depth. /// bounding total depth.
pub fn insert(&mut self, seq: u32, payload: Vec<u8>) { pub fn insert(&mut self, seq: u32, payload: Vec<u8>) {
@@ -124,9 +136,19 @@ impl JitterBuffer {
if let Some(next) = self.next_seq if let Some(next) = self.next_seq
&& seq_before(seq, next) && seq_before(seq, next)
{ {
if next.wrapping_sub(seq) > MAX_REASONABLE_SEQ_GAP {
self.reset_to_stream(seq, payload);
return;
}
self.note_disruption(); self.note_disruption();
return; return;
} }
if let Some(next) = self.next_seq
&& seq.wrapping_sub(next) > MAX_REASONABLE_SEQ_GAP
{
self.reset_to_stream(seq, payload);
return;
}
self.packets.insert(seq, payload); self.packets.insert(seq, payload);
while self.packets.len() > MAX_BUFFERED_FRAMES { while self.packets.len() > MAX_BUFFERED_FRAMES {
@@ -283,6 +305,44 @@ mod tests {
assert_eq!(jb.packets.len(), 1); // only seq 7 remains buffered assert_eq!(jb.packets.len(), 1); // only seq 7 remains buffered
} }
#[test]
fn far_behind_sequence_resets_as_restarted_stream() {
let mut jb = JitterBuffer::new().unwrap();
jb.next_seq = Some(5_000);
jb.packets.insert(5_000, vec![9]);
jb.clean_run = 12;
jb.buffering_ticks = 4;
jb.insert(0, vec![1]);
assert_eq!(jb.next_seq, None);
assert_eq!(jb.packets.len(), 1);
assert_eq!(jb.packets.get(&0).map(Vec::as_slice), Some(&[1][..]));
assert_eq!(jb.clean_run, 0);
assert_eq!(jb.buffering_ticks, 0);
}
#[test]
fn far_ahead_sequence_resets_to_bound_plc_run() {
let mut jb = JitterBuffer::new().unwrap();
jb.next_seq = Some(10);
jb.packets.insert(10, vec![9]);
jb.clean_run = 12;
jb.buffering_ticks = 4;
let jumped_seq = 10 + MAX_REASONABLE_SEQ_GAP + 1;
jb.insert(jumped_seq, vec![2]);
assert_eq!(jb.next_seq, None);
assert_eq!(jb.packets.len(), 1);
assert_eq!(
jb.packets.get(&jumped_seq).map(Vec::as_slice),
Some(&[2][..])
);
assert_eq!(jb.clean_run, 0);
assert_eq!(jb.buffering_ticks, 0);
}
#[test] #[test]
fn test_seq_before_ordering() { fn test_seq_before_ordering() {
// Basic ordering // Basic ordering
@@ -635,4 +695,3 @@ mod tests {
assert_eq!(jb.clean_run, 0); assert_eq!(jb.clean_run, 0);
} }
} }
+10
View File
@@ -11,6 +11,10 @@ pub enum CoreCommand {
/// for a NEW room; it's ignored when joining (the label rides in the ticket). /// for a NEW room; it's ignored when joining (the label rides in the ticket).
Join { name: String, ticket: String, room_name: String, input_device: Option<String>, output_device: Option<String>, echo_cancellation: bool, avatar: crate::avatar::Avatar }, Join { name: String, ticket: String, room_name: String, input_device: Option<String>, output_device: Option<String>, echo_cancellation: bool, avatar: crate::avatar::Avatar },
Leave, Leave,
/// Orderly app shutdown: finalize recordings, leave any active room, stop local
/// audio/screen-share work, close the persistent network stack, then ack with
/// [`UiEvent::ShutdownComplete`].
Shutdown,
ToggleMute, ToggleMute,
/// Change our avatar (W4) and re-announce it to the room over presence. /// Change our avatar (W4) and re-announce it to the room over presence.
SetAvatar(crate::avatar::Avatar), SetAvatar(crate::avatar::Avatar),
@@ -18,6 +22,10 @@ pub enum CoreCommand {
SetPttMode(bool), SetPttMode(bool),
SetPttActive(bool), SetPttActive(bool),
SetPeerVolume(EndpointId, f32), 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 /// Locally mute/unmute a peer: when muted, their audio is decoded (so levels
/// still show) but not mixed into our output. /// still show) but not mixed into our output.
SetPeerMuted(EndpointId, bool), SetPeerMuted(EndpointId, bool),
@@ -122,5 +130,7 @@ pub enum UiEvent {
/// Discoverable. Distinct from a user-driven change so the GUI knows to update /// Discoverable. Distinct from a user-driven change so the GUI knows to update
/// without having issued the command itself. /// without having issued the command itself.
PresenceModeReverted { mode: PresenceMode }, PresenceModeReverted { mode: PresenceMode },
/// Core finished orderly app shutdown and the GUI can exit.
ShutdownComplete,
Error(String), Error(String),
} }
+152 -8
View File
@@ -2,6 +2,7 @@ pub mod messages;
pub mod jitter; pub mod jitter;
use crate::audio::{AudioBackend, pipewire_impl::PipeWireBackend}; use crate::audio::{AudioBackend, pipewire_impl::PipeWireBackend};
use crate::audio::eq::{Eq, EqSettings};
use crate::codec::{AudioEncoder, opus_impl::OpusEncoder}; use crate::codec::{AudioEncoder, opus_impl::OpusEncoder};
use crate::core::jitter::{JitterBuffer, FRAME_SAMPLES}; use crate::core::jitter::{JitterBuffer, FRAME_SAMPLES};
use crate::network::{ use crate::network::{
@@ -49,6 +50,12 @@ impl CoreController {
pub fn send(&self, cmd: CoreCommand) -> bool { pub fn send(&self, cmd: CoreCommand) -> bool {
self.cmd_tx.try_send(cmd).is_ok() self.cmd_tx.try_send(cmd).is_ok()
} }
/// Clone the command sender for asynchronous one-shot sends that should wait
/// for channel capacity instead of failing immediately on a full queue.
pub fn command_sender(&self) -> mpsc::Sender<CoreCommand> {
self.cmd_tx.clone()
}
} }
/// How long a peer may stay "reconnecting" after a transient drop before we give /// How long a peer may stay "reconnecting" after a transient drop before we give
@@ -214,6 +221,7 @@ fn stop_mic_monitor(backend: &PipeWireBackend, monitor: Option<MicMonitor>) {
/// limiter (see [`crate::audio::limiter`]) can ride it down to the ceiling instead /// 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` /// 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. /// contribute 0 past their end; an empty peer set yields a silent bus.
#[cfg(test)]
fn mix_frames(peer_frames: &[Vec<i16>], frame_len: usize) -> Vec<i32> { fn mix_frames(peer_frames: &[Vec<i16>], frame_len: usize) -> Vec<i32> {
let mut mixed = vec![0i32; frame_len]; let mut mixed = vec![0i32; frame_len];
for frame in peer_frames { for frame in peer_frames {
@@ -224,6 +232,44 @@ fn mix_frames(peer_frames: &[Vec<i16>], frame_len: usize) -> Vec<i32> {
mixed 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<i16>, f32)], frame_len: usize) -> Vec<i32> {
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<i16> {
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 /// Handles the transport's per-peer link-state stream (`ConnEvent`): arms/cancels
/// reconnect grace timers, tracks which peers we've linked with, and forwards /// 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 /// link state to the UI. Pulled out of the conn-event task as a unit so the
@@ -665,6 +711,8 @@ async fn run_core_loop(
let is_multitrack = Arc::new(AtomicBool::new(false)); let is_multitrack = Arc::new(AtomicBool::new(false));
let mut recording_mode = RecordingMode::default(); let mut recording_mode = RecordingMode::default();
let peer_volumes = Arc::new(Mutex::new(HashMap::<EndpointId, f32>::new())); let peer_volumes = Arc::new(Mutex::new(HashMap::<EndpointId, f32>::new()));
let peer_eq = Arc::new(Mutex::new(HashMap::<EndpointId, EqSettings>::new()));
let peer_pan = Arc::new(Mutex::new(HashMap::<EndpointId, f32>::new()));
// Peers locally muted by us: decoded for level metering but not mixed. // Peers locally muted by us: decoded for level metering but not mixed.
let locally_muted = Arc::new(Mutex::new(HashSet::<EndpointId>::new())); let locally_muted = Arc::new(Mutex::new(HashSet::<EndpointId>::new()));
let mut current_name = "Anonymous".to_string(); let mut current_name = "Anonymous".to_string();
@@ -814,6 +862,23 @@ async fn run_core_loop(
} }
}; };
match cmd { match cmd {
CoreCommand::Shutdown => {
crate::log_msg("Core shutdown requested");
// Finalize recordings while capture/mixer feeders are still alive.
stop_recording(&recorder, &is_recording, &multitrack, &is_multitrack, &ui_tx).await;
stop_mic_monitor(&audio_backend, mic_monitor.take());
if let Some(session) = active_session.take() {
session.shutdown(audio_backend.clone()).await;
net.audio_router.clear();
}
*current_room.lock().unwrap() = None;
net.shutdown().await;
let _ = ui_tx.send(UiEvent::ShutdownComplete).await;
break;
}
CoreCommand::Join { name, ticket, room_name, input_device, output_device, echo_cancellation, avatar } => { CoreCommand::Join { name, ticket, room_name, input_device, output_device, echo_cancellation, avatar } => {
current_name = name.clone(); current_name = name.clone();
current_avatar = avatar; current_avatar = avatar;
@@ -1104,6 +1169,8 @@ async fn run_core_loop(
let jitter_mixer = jitter.clone(); let jitter_mixer = jitter.clone();
let is_deafened_clone = is_deafened.clone(); let is_deafened_clone = is_deafened.clone();
let peer_volumes_mixer = peer_volumes.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 locally_muted_mixer = locally_muted.clone();
let output_gain_mixer = output_gain.clone(); let output_gain_mixer = output_gain.clone();
let ui_tx_mixer = ui_tx.clone(); let ui_tx_mixer = ui_tx.clone();
@@ -1117,6 +1184,9 @@ async fn run_core_loop(
// the ceiling instead of hard-clipping. State carries across // the ceiling instead of hard-clipping. State carries across
// frames (see audio::limiter). // frames (see audio::limiter).
let mut limiter = crate::audio::limiter::SoftLimiter::new(48_000); 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<EndpointId, Eq> = HashMap::new();
// When the ring is at/above target we have nothing to do; nap // When the ring is at/above target we have nothing to do; nap
// briefly and re-check. Short enough (relative to the ~60ms // briefly and re-check. Short enough (relative to the ~60ms
// target and ~21ms device quantum) that we always refill well // target and ~21ms device quantum) that we always refill well
@@ -1140,8 +1210,11 @@ async fn run_core_loop(
} }
let current_volumes = peer_volumes_mixer.lock().await.clone(); 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 muted_peers = locally_muted_mixer.lock().await.clone();
let mut peer_frames = Vec::new(); let mut peer_frames: Vec<(Vec<i16>, f32)> = Vec::new();
let mut peers_seen = HashSet::new();
// Multitrack stem capture: tap each peer's RAW decoded frame // Multitrack stem capture: tap each peer's RAW decoded frame
// (pre-volume, pre-mute, pre-limiter) so the stems are clean // (pre-volume, pre-mute, pre-limiter) so the stems are clean
@@ -1167,10 +1240,31 @@ async fn run_core_loop(
let vol = current_volumes.get(&peer_id).copied().unwrap_or(1.0); let vol = current_volumes.get(&peer_id).copied().unwrap_or(1.0);
apply_volume(&mut frame, vol); 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 // Level is recorded even for locally-muted peers so
// the UI still shows that they're speaking. // the UI still shows that they're speaking.
let peak = level_peaks.entry(peer_id).or_insert(0.0); let peak = level_peaks.entry(peer_id).or_insert(0.0);
*peak = peak.max(frame_level(&frame)); *peak = peak.max(frame_level(&frame));
peers_seen.insert(peer_id);
// Locally muted: decoded above (jitter buffer advances, // Locally muted: decoded above (jitter buffer advances,
// level shown) but not mixed into our output. // level shown) but not mixed into our output.
@@ -1178,16 +1272,23 @@ async fn run_core_loop(
continue; 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 // Lossless i32 sum, then the limiter applies the master
// output gain (in f32, so a boost past the ceiling is // output gain (in f32, so a boost past the ceiling is
// limited too) and rides peaks down to the ceiling. // 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 out_gain = f32::from_bits(output_gain_mixer.load(Ordering::Relaxed));
let mixed = limiter.process(&mixed_sum, out_gain); let mixed = limiter.process(&mixed_sum, out_gain);
let record_mix = stereo_to_mono(&mixed);
// Record the true call audio, independent of local deafen — // Record the true call audio, independent of local deafen —
// deafen only silences our own monitor, not what the call // deafen only silences our own monitor, not what the call
@@ -1200,7 +1301,7 @@ async fn run_core_loop(
for (id, f) in &stems { for (id, f) in &stems {
mt.write_peer(*id, f)?; mt.write_peer(*id, f)?;
} }
mt.write_mix(&mixed)?; mt.write_mix(&record_mix)?;
mt.end_cycle() mt.end_cycle()
})(); })();
if let Err(e) = res { if let Err(e) = res {
@@ -1209,13 +1310,13 @@ async fn run_core_loop(
} }
} else if is_recording_mixer.load(Ordering::Relaxed) } else if is_recording_mixer.load(Ordering::Relaxed)
&& let Some(rec) = recorder_mixer.lock().unwrap().as_mut() && 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}")); crate::log_msg(&format!("Recording write failed: {e}"));
} }
let frame_to_send = if is_deafened_clone.load(Ordering::Relaxed) { let frame_to_send = if is_deafened_clone.load(Ordering::Relaxed) {
vec![0i16; FRAME_SAMPLES] vec![0i16; mixed.len()]
} else { } else {
mixed mixed
}; };
@@ -1522,6 +1623,26 @@ async fn run_core_loop(
guard.insert(peer_id, vol); 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) => { CoreCommand::SetPeerMuted(peer_id, muted) => {
let mut guard = locally_muted.lock().await; let mut guard = locally_muted.lock().await;
if muted { if muted {
@@ -1865,7 +1986,10 @@ async fn run_core_loop(
#[cfg(test)] #[cfg(test)]
mod tests { 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. /// A frame of constant amplitude with the given sample count.
fn frame(amp: i16, len: usize) -> Vec<i16> { fn frame(amp: i16, len: usize) -> Vec<i16> {
@@ -1924,6 +2048,27 @@ mod tests {
assert_eq!(mixed, vec![100i32, -200, 300, -400]); 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] #[test]
fn two_peers_sum_sample_by_sample() { fn two_peers_sum_sample_by_sample() {
let a = vec![100, -200, 300, -400]; let a = vec![100, -200, 300, -400];
@@ -2038,4 +2183,3 @@ mod tests {
assert!((level - 0.5).abs() < 1e-3, "mid-range level was {level}"); assert!((level - 0.5).abs() < 1e-3, "mid-range level was {level}");
} }
} }
+284
View File
@@ -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<Self> {
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<KeyBinding> {
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<KeyBinding>,
#[serde(default = "default_deafen")]
pub toggle_deafen: Option<KeyBinding>,
#[serde(default = "default_settings")]
pub open_settings: Option<KeyBinding>,
#[serde(default = "default_ptt")]
pub push_to_talk: Option<KeyBinding>,
#[serde(default)]
pub leave_room: Option<KeyBinding>,
}
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<KeyBinding> {
Some(KeyBinding::Named(name.to_string()))
}
fn default_mute() -> Option<KeyBinding> {
named("F9")
}
fn default_deafen() -> Option<KeyBinding> {
named("F10")
}
fn default_settings() -> Option<KeyBinding> {
named("F2")
}
fn default_ptt() -> Option<KeyBinding> {
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<KeyBinding>) {
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<HotkeyAction> {
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> {
HotkeyAction::ALL
.into_iter()
.find(|&action| context.allows(action) && self.binding(action) == Some(binding))
}
pub fn conflicts(&self) -> Vec<HotkeyConflict> {
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");
}
}
+1 -1
View File
@@ -16,6 +16,7 @@ pub mod sanitize;
pub mod avatar; pub mod avatar;
pub mod recents; pub mod recents;
pub mod discovery; pub mod discovery;
pub mod hotkeys;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::OnceLock; use std::sync::OnceLock;
@@ -60,4 +61,3 @@ pub fn log_msg(msg: &str) {
let _ = file.write_all(line.as_bytes()); let _ = file.write_all(line.as_bytes());
} }
} }
+6 -23
View File
@@ -79,8 +79,6 @@ enum GossipReject {
BadSignature, BadSignature,
/// Timestamp outside the freshness window — stale (replay) or implausibly future. /// Timestamp outside the freshness window — stale (replay) or implausibly future.
OutOfWindow, OutOfWindow,
/// A signed Announce advertised an address for a different node id.
AnnounceAddressMismatch,
} }
/// Authenticate a received payload against the room topic and local clock. The /// Authenticate a received payload against the room topic and local clock. The
@@ -101,10 +99,6 @@ fn verify_gossip(
if now_ms.abs_diff(payload.ts) > window_ms { if now_ms.abs_diff(payload.ts) > window_ms {
return Err(GossipReject::OutOfWindow); return Err(GossipReject::OutOfWindow);
} }
if let GossipMessage::Announce(state) = &payload.msg
&& state.addr.id != payload.author {
return Err(GossipReject::AnnounceAddressMismatch);
}
Ok(()) Ok(())
} }
@@ -496,8 +490,10 @@ mod tests {
use crate::network::PeerState; use crate::network::PeerState;
use iroh::SecretKey; use iroh::SecretKey;
fn sample_peer_state_for(id: EndpointId) -> PeerState { fn sample_peer_state() -> PeerState {
let addr = iroh::EndpointAddr::from(id); let secret = SecretKey::generate();
let public = secret.public();
let addr = iroh::EndpointAddr::from(public);
PeerState { PeerState {
name: "TestPeerGossip".to_string(), name: "TestPeerGossip".to_string(),
is_muted: true, is_muted: true,
@@ -567,7 +563,7 @@ mod tests {
fn test_gossip_payload_announce_round_trip() { fn test_gossip_payload_announce_round_trip() {
let secret = SecretKey::generate(); let secret = SecretKey::generate();
let topic = [9u8; 32]; let topic = [9u8; 32];
let peer_state = sample_peer_state_for(secret.public()); let peer_state = sample_peer_state();
let payload = sign_gossip(&secret, &topic, 1000, GossipMessage::Announce(peer_state.clone())); let payload = sign_gossip(&secret, &topic, 1000, GossipMessage::Announce(peer_state.clone()));
let serialized = serde_json::to_string(&payload).unwrap(); let serialized = serde_json::to_string(&payload).unwrap();
@@ -736,18 +732,5 @@ mod tests {
// Within the window (clock skew tolerance) → accepted. // Within the window (clock skew tolerance) → accepted.
assert!(verify_gossip(&p, &topic, 1_000_000 + GOSSIP_FRESHNESS_MS - 1, GOSSIP_FRESHNESS_MS).is_ok()); assert!(verify_gossip(&p, &topic, 1_000_000 + GOSSIP_FRESHNESS_MS - 1, GOSSIP_FRESHNESS_MS).is_ok());
} }
#[test]
fn verify_rejects_announce_with_address_for_another_identity() {
let signer = SecretKey::generate();
let advertised = SecretKey::generate();
let topic = [6u8; 32];
let state = sample_peer_state_for(advertised.public());
let p = sign_gossip(&signer, &topic, 5_000, GossipMessage::Announce(state));
assert_eq!(
verify_gossip(&p, &topic, 5_000, GOSSIP_FRESHNESS_MS),
Err(GossipReject::AnnounceAddressMismatch)
);
}
} }
+81
View File
@@ -0,0 +1,81 @@
# 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 <source-port-id> <sink-port-id>`. 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.
## Backlog A21/A22 - correctness fixes
- Fixed A21 in `src/core/jitter.rs`: implausibly large sequence discontinuities now reset the per-peer jitter stream instead of being treated as ordinary late packets or packet loss.
- The reset threshold is `500` frames, about 10 seconds at 20 ms/frame. That covers both same-identity sender restart back to sequence 0 and a faulty/malicious jump far ahead that would otherwise force a long PLC run.
- Added jitter regression tests for both far-behind restart and far-ahead jump cases.
- Fixed A22 in `src/audio/recorder.rs`: `WavWriter` now tracks data bytes as `u64`, checks additions before writing, and rejects data that cannot fit both the RIFF size field and the `data` chunk size field.
- Added a WAV overflow regression test that exercises the limit without creating a huge file.
Unverified: the same-identity peer restart has not been exercised in a live 2-machine call; the WAV fix is counter/size-field tested, not a real >12h recording.
## Backlog A14 - orderly window-close shutdown
- Added `CoreCommand::Shutdown` and `UiEvent::ShutdownComplete`.
- Window close now saves config, marks the GUI as closing, asynchronously queues `Shutdown`, and exits only after the core acknowledges completion or after a 5-second fallback timeout.
- Core shutdown finalizes active mixed/multitrack recordings before session teardown, stops the standalone mic monitor, runs `ActiveSession::shutdown()` for active calls, clears room presence/routing, closes the persistent network stack, sends `ShutdownComplete`, and ends the core loop.
- The shutdown command is queued with an awaited `mpsc::Sender::send` task instead of the best-effort `try_send`, so a full command queue does not immediately drop the close command.
Unverified: actual GUI window-close behavior during a live call/recording still needs a manual run; tests/builds only prove the path compiles and existing unit coverage still passes.
## Verification
- `cargo check` passed.
- `cargo test --lib` passed: 288 passed, 0 failed, 2 ignored.
- `cargo clippy --all-targets` passed.
- `cargo build --release` passed.
- Formatted the touched Rust files with `rustfmt --edition 2024`; I did not run repo-wide `cargo fmt` to avoid unrelated formatting churn.
No new dependencies were added. Runtime/manual/field verification is still pending for audio-device and 2-machine behavior.