diff --git a/src/app/mod.rs b/src/app/mod.rs index 9ed6d56..36957bd 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2,10 +2,10 @@ use crate::core::{CoreController, messages::{CoreCommand, UiEvent}}; use crate::network::PeerState; use iced::widget::{ - container, column, row, text, button, text_input, scrollable, Column, + container, column, row, text, button, text_input, scrollable, slider, checkbox, Column, Space, }; use iced::{ - Color, Background, Border, Element, Subscription, Task, Theme, + Color, Background, Border, Element, Subscription, Task, Theme, Event, keyboard, }; use iroh::EndpointId; use std::collections::HashMap; @@ -25,6 +25,12 @@ pub enum AppMessage { ToggleDeafenPressed, UiEventReceived(UiEvent), CopyToClipboard, + TogglePtt(bool), + StartSettingHotkey, + PeerVolumeChanged(EndpointId, f32), + InputDeviceChanged(String), + OutputDeviceChanged(String), + EventOccurred(Event), } fn core_subscription() -> impl iced::futures::Stream { @@ -49,7 +55,14 @@ pub struct AppState { ticket: String, is_muted: bool, is_deafened: bool, + ptt_enabled: bool, + ptt_active: bool, + ptt_hotkey: keyboard::Key, + is_setting_hotkey: bool, + input_device: String, + output_device: String, peers: HashMap, + peer_volumes: HashMap, audio_levels: HashMap, controller: Arc, } @@ -68,7 +81,14 @@ impl Default for AppState { ticket: "".to_string(), is_muted: false, is_deafened: false, + ptt_enabled: false, + ptt_active: false, + ptt_hotkey: keyboard::Key::Named(keyboard::key::Named::Space), + is_setting_hotkey: false, + input_device: "".to_string(), + output_device: "".to_string(), peers: HashMap::new(), + peer_volumes: HashMap::new(), audio_levels: HashMap::new(), controller, } @@ -93,8 +113,10 @@ pub fn run_gui() -> iced::Result { .run() } -fn subscription(_state: &AppState) -> Subscription { - Subscription::run(core_subscription).map(AppMessage::UiEventReceived) +fn subscription(state: &AppState) -> Subscription { + let core_sub = Subscription::run(core_subscription).map(AppMessage::UiEventReceived); + let event_sub = iced::event::listen().map(AppMessage::EventOccurred); + Subscription::batch(vec![core_sub, event_sub]) } fn update(state: &mut AppState, message: AppMessage) -> Task { @@ -110,6 +132,8 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { let _ = state.controller.send(CoreCommand::Join { name: state.name.clone(), ticket: state.ticket_input.clone(), + input_device: if state.input_device.is_empty() { None } else { Some(state.input_device.clone()) }, + output_device: if state.output_device.is_empty() { None } else { Some(state.output_device.clone()) }, }); } AppMessage::CreatePressed => { @@ -117,6 +141,8 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { let _ = state.controller.send(CoreCommand::Join { name: state.name.clone(), ticket: "".to_string(), + input_device: if state.input_device.is_empty() { None } else { Some(state.input_device.clone()) }, + output_device: if state.output_device.is_empty() { None } else { Some(state.output_device.clone()) }, }); } AppMessage::LeavePressed => { @@ -170,6 +196,39 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { return iced::clipboard::write(state.ticket.clone()); } } + AppMessage::TogglePtt(enabled) => { + state.ptt_enabled = enabled; + let _ = state.controller.send(CoreCommand::SetPttMode(enabled)); + } + AppMessage::StartSettingHotkey => { + state.is_setting_hotkey = true; + } + AppMessage::PeerVolumeChanged(id, vol) => { + state.peer_volumes.insert(id, vol); + let _ = state.controller.send(CoreCommand::SetPeerVolume(id, vol)); + } + AppMessage::InputDeviceChanged(val) => { + state.input_device = val; + } + AppMessage::OutputDeviceChanged(val) => { + state.output_device = val; + } + AppMessage::EventOccurred(Event::Keyboard(keyboard::Event::KeyPressed { key, .. })) => { + if state.is_setting_hotkey { + state.ptt_hotkey = key.clone(); + state.is_setting_hotkey = false; + } else if state.ptt_enabled && key == state.ptt_hotkey && !state.ptt_active { + state.ptt_active = true; + let _ = state.controller.send(CoreCommand::SetPttActive(true)); + } + } + AppMessage::EventOccurred(Event::Keyboard(keyboard::Event::KeyReleased { key, .. })) => { + if state.ptt_enabled && key == state.ptt_hotkey && state.ptt_active { + state.ptt_active = false; + let _ = state.controller.send(CoreCommand::SetPttActive(false)); + } + } + AppMessage::EventOccurred(_) => {} } Task::none() } @@ -308,6 +367,13 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { text("— OR —").size(12).color(color_surface).align_x(iced::alignment::Horizontal::Center), vertical_space(16.0), join_group, + vertical_space(16.0), + text("Device Settings (Optional Node Target IDs)").size(14).color(color_subtext), + row![ + text_input("Input Target", &state.input_device).on_input(AppMessage::InputDeviceChanged).style(t_style.clone()).padding(8), + horizontal_space(), + text_input("Output Target", &state.output_device).on_input(AppMessage::OutputDeviceChanged).style(t_style.clone()).padding(8), + ].spacing(10), vertical_space(20.0), status ] @@ -380,7 +446,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { text("[Idle]").size(14).color(color_subtext) }; - let card = container( + let mut card_content = column![ row![ column![ text(&peer.name).size(16).color(color_text), @@ -390,7 +456,19 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { indicator ] .align_y(iced::alignment::Vertical::Center) - ) + ].spacing(8); + + // Peer volume slider + let current_vol = state.peer_volumes.get(peer_id).copied().unwrap_or(1.0); + let peer_id_clone = *peer_id; + card_content = card_content.push( + row![ + text("Vol:").size(12).color(color_subtext), + slider(0.0..=2.0, current_vol, move |v| AppMessage::PeerVolumeChanged(peer_id_clone, v)) + ].spacing(8).align_y(iced::alignment::Vertical::Center) + ); + + let card = container(card_content) .style(c_style( if is_speaking { color_base } else { color_mantle }, if is_speaking { color_green } else { color_surface }, @@ -446,6 +524,21 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .style(b_style(deafen_bg, deafen_hover, deafen_fg, 8.0)) .padding(14) .width(iced::Length::Fill), + vertical_space(20.0), + checkbox(state.ptt_enabled).label("Push-to-Talk").on_toggle(AppMessage::TogglePtt), + vertical_space(10.0), + if state.ptt_enabled { + column![ + text(format!("Hotkey: {}", if state.is_setting_hotkey { "Press any key...".to_string() } else { format!("{:?}", state.ptt_hotkey) })).size(14).color(color_subtext), + button(text("Set Hotkey").size(12).align_x(iced::alignment::Horizontal::Center)) + .on_press(AppMessage::StartSettingHotkey) + .style(b_style(color_surface, color_blue, color_text, 6.0)) + .padding(8) + .width(iced::Length::Fill) + ].spacing(8) + } else { + column![] + }, vertical_space(30.0), button( text("Leave Room") diff --git a/src/audio/mod.rs b/src/audio/mod.rs index f249fd6..4da3cdf 100644 --- a/src/audio/mod.rs +++ b/src/audio/mod.rs @@ -18,11 +18,11 @@ pub enum AudioError { pub trait AudioBackend: Send + Sync { /// Starts capturing raw PCM audio from the input device (microphone), /// sending chunks of samples (e.g. `Vec`) to the provided Sender. - fn start_capture(&self, tx: Sender>) -> Result<(), AudioError>; + fn start_capture(&self, tx: Sender>, target_node: Option) -> Result<(), AudioError>; /// Starts playing back raw PCM audio to the output device (speaker), /// reading mixed/incoming chunks of samples from the provided Receiver. - fn start_playback(&self, rx: Receiver>) -> Result<(), AudioError>; + fn start_playback(&self, rx: Receiver>, target_node: Option) -> Result<(), AudioError>; /// Stops both capture and playback streams. fn stop(&self) -> Result<(), AudioError>; diff --git a/src/audio/pipewire_impl.rs b/src/audio/pipewire_impl.rs index 7822818..d50a0fb 100644 --- a/src/audio/pipewire_impl.rs +++ b/src/audio/pipewire_impl.rs @@ -35,7 +35,7 @@ impl PipeWireBackend { } impl AudioBackend for PipeWireBackend { - fn start_capture(&self, tx: Sender>) -> Result<(), AudioError> { + fn start_capture(&self, tx: Sender>, target_node: Option) -> Result<(), AudioError> { let mut capture_guard = self.capture_state.lock().unwrap(); if capture_guard.is_some() { return Err(AudioError::Stream("Capture already started".to_string())); @@ -47,7 +47,7 @@ impl AudioBackend for PipeWireBackend { let thread = thread::Builder::new() .name("peerspeak-capture".to_string()) .spawn(move || { - if let Err(e) = run_capture(cmd_rx, tx_clone) { + if let Err(e) = run_capture(cmd_rx, tx_clone, target_node) { eprintln!("Capture thread error: {:?}", e); } }) @@ -57,7 +57,7 @@ impl AudioBackend for PipeWireBackend { Ok(()) } - fn start_playback(&self, rx: Receiver>) -> Result<(), AudioError> { + fn start_playback(&self, rx: Receiver>, target_node: Option) -> Result<(), AudioError> { let mut playback_guard = self.playback_state.lock().unwrap(); if playback_guard.is_some() { return Err(AudioError::Stream("Playback already started".to_string())); @@ -68,7 +68,7 @@ impl AudioBackend for PipeWireBackend { let thread = thread::Builder::new() .name("peerspeak-playback".to_string()) .spawn(move || { - if let Err(e) = run_playback(cmd_rx, rx) { + if let Err(e) = run_playback(cmd_rx, rx, target_node) { eprintln!("Playback thread error: {:?}", e); } }) @@ -97,7 +97,7 @@ impl AudioBackend for PipeWireBackend { } } -fn run_capture(cmd_rx: pw::channel::Receiver<()>, tx: Sender>) -> Result<(), AudioError> { +fn run_capture(cmd_rx: pw::channel::Receiver<()>, tx: Sender>, target_node: Option) -> Result<(), AudioError> { let mainloop = pw::main_loop::MainLoopRc::new(None) .map_err(|e| AudioError::Init(e.to_string()))?; let context = pw::context::ContextRc::new(&mainloop, None) @@ -115,11 +115,14 @@ fn run_capture(cmd_rx: pw::channel::Receiver<()>, tx: Sender>) -> Resul mainloop_clone.quit(); }); - let props = properties! { + let mut props = properties! { *pw::keys::MEDIA_TYPE => "Audio", *pw::keys::MEDIA_CATEGORY => "Capture", *pw::keys::MEDIA_ROLE => "Communication", }; + if let Some(target) = target_node { + props.insert("node.target", target); + } let stream = pw::stream::StreamBox::new(&core, "peerspeak-capture-stream", props) .map_err(|e| AudioError::Init(e.to_string()))?; @@ -210,7 +213,7 @@ fn run_capture(cmd_rx: pw::channel::Receiver<()>, tx: Sender>) -> Resul Ok(()) } -fn run_playback(cmd_rx: pw::channel::Receiver<()>, rx: Receiver>) -> Result<(), AudioError> { +fn run_playback(cmd_rx: pw::channel::Receiver<()>, rx: Receiver>, target_node: Option) -> Result<(), AudioError> { let mainloop = pw::main_loop::MainLoopRc::new(None) .map_err(|e| AudioError::Init(e.to_string()))?; let context = pw::context::ContextRc::new(&mainloop, None) @@ -228,11 +231,14 @@ fn run_playback(cmd_rx: pw::channel::Receiver<()>, rx: Receiver>) -> Re mainloop_clone.quit(); }); - let props = properties! { + let mut props = properties! { *pw::keys::MEDIA_TYPE => "Audio", *pw::keys::MEDIA_CATEGORY => "Playback", *pw::keys::MEDIA_ROLE => "Communication", }; + if let Some(target) = target_node { + props.insert("node.target", target); + } let stream = pw::stream::StreamBox::new(&core, "peerspeak-playback-stream", props) .map_err(|e| AudioError::Init(e.to_string()))?; diff --git a/src/core/messages.rs b/src/core/messages.rs index 153c809..f7182e8 100644 --- a/src/core/messages.rs +++ b/src/core/messages.rs @@ -3,10 +3,13 @@ use iroh::EndpointId; #[derive(Debug, Clone)] pub enum CoreCommand { - Join { name: String, ticket: String }, + Join { name: String, ticket: String, input_device: Option, output_device: Option }, Leave, ToggleMute, ToggleDeafen, + SetPttMode(bool), + SetPttActive(bool), + SetPeerVolume(EndpointId, f32), } #[derive(Debug, Clone)] diff --git a/src/core/mod.rs b/src/core/mod.rs index 1e60074..5a3ff97 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -101,13 +101,16 @@ async fn run_core_loop( let is_muted = Arc::new(AtomicBool::new(false)); let is_deafened = Arc::new(AtomicBool::new(false)); + let ptt_mode = Arc::new(AtomicBool::new(false)); + let ptt_active = Arc::new(AtomicBool::new(false)); + let peer_volumes = Arc::new(Mutex::new(HashMap::::new())); let mut current_name = "Anonymous".to_string(); let mut active_session: Option = None; while let Some(cmd) = cmd_rx.recv().await { match cmd { - CoreCommand::Join { name, ticket } => { + CoreCommand::Join { name, ticket, input_device, output_device } => { current_name = name.clone(); // Clean up any existing session @@ -165,14 +168,14 @@ async fn run_core_loop( let (capture_tx, capture_rx) = std::sync::mpsc::channel(); let (playback_tx, playback_rx) = std::sync::mpsc::channel(); - if let Err(e) = audio_backend.start_capture(capture_tx) { + if let Err(e) = audio_backend.start_capture(capture_tx, input_device) { let _ = ui_tx.send(UiEvent::Error(format!("Failed to start capture: {}", e))).await; let _ = room_state.leave().await; let _ = router.shutdown().await; continue; } - if let Err(e) = audio_backend.start_playback(playback_rx) { + if let Err(e) = audio_backend.start_playback(playback_rx, output_device) { let _ = ui_tx.send(UiEvent::Error(format!("Failed to start playback: {}", e))).await; let _ = audio_backend.stop(); let _ = room_state.leave().await; @@ -184,6 +187,8 @@ async fn run_core_loop( // 1. Capture & encoding thread let is_muted_clone = is_muted.clone(); + let ptt_mode_clone = ptt_mode.clone(); + let ptt_active_clone = ptt_active.clone(); let transport_clone = transport.clone(); let room_state_clone = room_state.clone(); let tokio_handle = tokio::runtime::Handle::current(); @@ -202,6 +207,9 @@ async fn run_core_loop( if is_muted_clone.load(Ordering::Relaxed) { continue; } + if ptt_mode_clone.load(Ordering::Relaxed) && !ptt_active_clone.load(Ordering::Relaxed) { + continue; + } if let Ok(encoded) = encoder.encode(&pcm) { let bytes = bytes::Bytes::from(encoded); let active = room_state_clone.active_peers(); @@ -263,6 +271,7 @@ async fn run_core_loop( // 3. Mixing & level extraction loop task let queues_mixer = queues.clone(); let is_deafened_clone = is_deafened.clone(); + let peer_volumes_mixer = peer_volumes.clone(); let ui_tx_mixer = ui_tx.clone(); let mixer_task = tokio::spawn(async move { let mut interval = tokio::time::interval(Duration::from_millis(20)); @@ -275,6 +284,7 @@ async fn run_core_loop( let mut mixed = vec![0i16; 960]; let mut active_levels = Vec::new(); let mut peer_frames = Vec::new(); + let current_volumes = peer_volumes_mixer.lock().await.clone(); for (&peer_id, queue) in guard.iter_mut() { let mut frame = vec![0i16; 960]; @@ -293,6 +303,11 @@ async fn run_core_loop( } } + let vol = current_volumes.get(&peer_id).copied().unwrap_or(1.0); + for sample in frame.iter_mut() { + *sample = (*sample as f32 * vol).clamp(i16::MIN as f32, i16::MAX as f32) as i16; + } + // Calculate speaking level (RMS normalized) let sum_sq: f32 = frame.iter().map(|&x| (x as f32).powi(2)).sum(); let rms = (sum_sq / 960.0).sqrt(); @@ -394,6 +409,19 @@ async fn run_core_loop( let current = is_deafened.load(Ordering::Relaxed); is_deafened.store(!current, Ordering::Relaxed); } + + CoreCommand::SetPttMode(enabled) => { + ptt_mode.store(enabled, Ordering::Relaxed); + } + + CoreCommand::SetPttActive(active) => { + ptt_active.store(active, Ordering::Relaxed); + } + + CoreCommand::SetPeerVolume(peer_id, vol) => { + let mut guard = peer_volumes.lock().await; + guard.insert(peer_id, vol); + } } }