feat: Add PTT, Volume Sliders, and Device Node Selection
This commit is contained in:
+99
-6
@@ -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
@@ -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>;
|
||||
|
||||
@@ -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()))?;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user