feat: Add PTT, Volume Sliders, and Device Node Selection

This commit is contained in:
2026-05-27 15:23:07 -04:00
parent ea5ffb861b
commit 5ff0201d94
5 changed files with 150 additions and 20 deletions
+99 -6
View File
@@ -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<Item = UiEvent> {
@@ -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<EndpointId, PeerState>,
peer_volumes: HashMap<EndpointId, f32>,
audio_levels: HashMap<EndpointId, f32>,
controller: Arc<CoreController>,
}
@@ -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<AppMessage> {
Subscription::run(core_subscription).map(AppMessage::UiEventReceived)
fn subscription(state: &AppState) -> Subscription<AppMessage> {
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<AppMessage> {
@@ -110,6 +132,8 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
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<AppMessage> {
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<AppMessage> {
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")
+2 -2
View File
@@ -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<i16>`) to the provided Sender.
fn start_capture(&self, tx: Sender<Vec<i16>>) -> Result<(), AudioError>;
fn start_capture(&self, tx: Sender<Vec<i16>>, target_node: Option<String>) -> 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<Vec<i16>>) -> Result<(), AudioError>;
fn start_playback(&self, rx: Receiver<Vec<i16>>, target_node: Option<String>) -> Result<(), AudioError>;
/// Stops both capture and playback streams.
fn stop(&self) -> Result<(), AudioError>;
+14 -8
View File
@@ -35,7 +35,7 @@ impl PipeWireBackend {
}
impl AudioBackend for PipeWireBackend {
fn start_capture(&self, tx: Sender<Vec<i16>>) -> Result<(), AudioError> {
fn start_capture(&self, tx: Sender<Vec<i16>>, target_node: Option<String>) -> 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<Vec<i16>>) -> Result<(), AudioError> {
fn start_playback(&self, rx: Receiver<Vec<i16>>, target_node: Option<String>) -> 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<Vec<i16>>) -> Result<(), AudioError> {
fn run_capture(cmd_rx: pw::channel::Receiver<()>, tx: Sender<Vec<i16>>, target_node: Option<String>) -> 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<Vec<i16>>) -> 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<Vec<i16>>) -> Resul
Ok(())
}
fn run_playback(cmd_rx: pw::channel::Receiver<()>, rx: Receiver<Vec<i16>>) -> Result<(), AudioError> {
fn run_playback(cmd_rx: pw::channel::Receiver<()>, rx: Receiver<Vec<i16>>, target_node: Option<String>) -> 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<Vec<i16>>) -> 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()))?;
+4 -1
View File
@@ -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<String>, output_device: Option<String> },
Leave,
ToggleMute,
ToggleDeafen,
SetPttMode(bool),
SetPttActive(bool),
SetPeerVolume(EndpointId, f32),
}
#[derive(Debug, Clone)]
+31 -3
View File
@@ -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::<EndpointId, f32>::new()));
let mut current_name = "Anonymous".to_string();
let mut active_session: Option<ActiveSession> = 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);
}
}
}