diff --git a/pwsp-cli/src/main.rs b/pwsp-cli/src/main.rs index 35952f0..a3b648b 100644 --- a/pwsp-cli/src/main.rs +++ b/pwsp-cli/src/main.rs @@ -88,6 +88,10 @@ enum GetCommands { #[clap(short, long)] id: Option, }, + /// Volume of what you hear locally + MonitoringVolume, + /// Volume of what is sent to the virtual microphone + MicVolume, /// Volume multiplier for all tracks VolumeMultiplier, /// Playback position (in seconds) @@ -108,6 +112,10 @@ enum GetCommands { Input, /// All audio inputs Inputs, + /// Current audio output used for monitoring + Output, + /// All audio outputs + Outputs, /// Version of the daemon DaemonVersion, /// Daemon configuration @@ -120,12 +128,16 @@ enum GetCommands { #[derive(Subcommand, Debug)] enum SetCommands { - /// Playback volume + /// Playback volume. Without --id it sets both the monitoring and mic volumes Volume { volume: f32, #[clap(short, long)] id: Option, }, + /// Volume of what you hear locally. Values above 1.0 amplify + MonitoringVolume { volume: f32 }, + /// Volume of what is sent to the virtual microphone. Values above 1.0 amplify + MicVolume { volume: f32 }, /// Volume multiplier for all tracks VolumeMultiplier { volume: f32 }, /// Playback position (in seconds) @@ -136,6 +148,8 @@ enum SetCommands { }, /// Audio input id (see pwsp-cli get inputs) Input { name: String }, + /// Audio output used for monitoring (see pwsp-cli get outputs) + Output { name: String }, /// Enable or disable loop (true or false) Loop { enabled: String, @@ -181,6 +195,8 @@ async fn main() -> Result<()> { Commands::Get { parameter } => match parameter { GetCommands::IsPaused => Request::get_is_paused(), GetCommands::Volume { id } => Request::get_volume(id), + GetCommands::MonitoringVolume => Request::get_monitoring_volume(), + GetCommands::MicVolume => Request::get_mic_volume(), GetCommands::VolumeMultiplier => Request::get_volume_multiplier(), GetCommands::Position { id } => Request::get_position(id), GetCommands::Duration { id } => Request::get_duration(id), @@ -188,6 +204,8 @@ async fn main() -> Result<()> { GetCommands::Tracks => Request::get_tracks(), GetCommands::Input => Request::get_input(), GetCommands::Inputs => Request::get_inputs(), + GetCommands::Output => Request::get_output(), + GetCommands::Outputs => Request::get_outputs(), GetCommands::DaemonVersion => Request::get_daemon_version(), GetCommands::DaemonConfig => Request::get_daemon_config(), GetCommands::FullState => Request::get_full_state(), @@ -195,9 +213,12 @@ async fn main() -> Result<()> { }, Commands::Set { parameter } => match parameter { SetCommands::Volume { volume, id } => Request::set_volume(volume, id), + SetCommands::MonitoringVolume { volume } => Request::set_monitoring_volume(volume), + SetCommands::MicVolume { volume } => Request::set_mic_volume(volume), SetCommands::VolumeMultiplier { volume } => Request::set_volume_multiplier(volume), SetCommands::Position { position, id } => Request::seek(position, id), SetCommands::Input { name } => Request::set_input(&name), + SetCommands::Output { name } => Request::set_output(&name), SetCommands::Loop { enabled, id } => Request::set_loop(&enabled, id), SetCommands::Hotkey { slot, file_path } => { Request::set_hotkey(&slot, &file_path.to_string_lossy()) diff --git a/pwsp-gui/locales/app.toml b/pwsp-gui/locales/app.toml index 94244a7..ccea440 100644 --- a/pwsp-gui/locales/app.toml +++ b/pwsp-gui/locales/app.toml @@ -517,3 +517,47 @@ ar = "اضغط Esc للإلغاء" kz = "Болдырмау үшін Escape пернесін басыңыз" he = "לחץ על Escape לביטול" pt-BR = "Pressione Esc para cancelar" + +[gui.choose_output_select] +en = "Select output" +ru = "Выбрать вывод" +es = "Seleccionar salida" +fr = "Sélectionner la sortie" +zh = "选择输出" +ar = "اختر المخرج" +kz = "Шығысты таңдау" +he = "בחר פלט" +pt-BR = "Selecionar saída" + +[gui.default_output] +en = "System default" +ru = "Системный по умолчанию" +es = "Predeterminado del sistema" +fr = "Défaut du système" +zh = "系统默认" +ar = "افتراضي النظام" +kz = "Жүйелік әдепкі" +he = "ברירת מחדל של המערכת" +pt-BR = "Padrão do sistema" + +[gui.monitoring_volume] +en = "Monitoring volume" +ru = "Громкость мониторинга" +es = "Volumen de monitorización" +fr = "Volume d'écoute" +zh = "监听音量" +ar = "مستوى صوت المراقبة" +kz = "Мониторинг дыбысы" +he = "עוצמת ניטור" +pt-BR = "Volume de monitoramento" + +[gui.mic_volume] +en = "Microphone volume" +ru = "Громкость микрофона" +es = "Volumen del micrófono" +fr = "Volume du microphone" +zh = "麦克风音量" +ar = "مستوى صوت الميكروفون" +kz = "Микрофон дыбысы" +he = "עוצמת מיקרופון" +pt-BR = "Volume do microfone" diff --git a/pwsp-gui/src/gui/mod.rs b/pwsp-gui/src/gui/mod.rs index e1bfead..dc3928a 100644 --- a/pwsp-gui/src/gui/mod.rs +++ b/pwsp-gui/src/gui/mod.rs @@ -250,6 +250,18 @@ impl SoundpadGui { } } + pub fn set_output(&mut self, name: String) { + make_request_async(Request::set_output(&name)); + + if self.config.save_input + && let Ok(mut daemon_config) = get_daemon_config() + { + // Empty means "follow the system default", which is stored as no device. + daemon_config.default_output_name = Some(name).filter(|n| !n.is_empty()); + update_daemon_config(&daemon_config).ok(); + } + } + pub fn toggle_loop(&mut self, id: Option) { make_request_async(Request::toggle_loop(id)); } diff --git a/pwsp-gui/src/gui/update.rs b/pwsp-gui/src/gui/update.rs index 82bcdc8..2f47408 100644 --- a/pwsp-gui/src/gui/update.rs +++ b/pwsp-gui/src/gui/update.rs @@ -2,10 +2,25 @@ use crate::gui::SoundpadGui; use eframe::{App, Frame as EFrame}; use egui::{CentralPanel, Context, ThemePreference}; use pwsp_lib::{ - types::{config::PreferredTheme, socket::Request}, + types::{ + config::{DaemonConfig, PreferredTheme}, + socket::Request, + }, utils::gui::{get_daemon_config, make_request_async, update_daemon_config}, }; -use std::time::{Duration, Instant}; + +impl SoundpadGui { + /// Persists a master volume to the daemon config when the user asked us to remember it. + fn remember_volume(&self, apply: impl FnOnce(&mut DaemonConfig)) { + if !self.config.save_volume { + return; + } + if let Ok(mut daemon_config) = get_daemon_config() { + apply(&mut daemon_config); + update_daemon_config(&daemon_config).ok(); + } + } +} impl App for SoundpadGui { fn logic(&mut self, ctx: &Context, _frame: &mut EFrame) { @@ -43,70 +58,34 @@ impl App for SoundpadGui { self.config.save_to_file().ok(); } - // Seek and volume requests - let mut seek_requests = vec![]; - let mut volume_requests = vec![]; - + // Per-track seek and volume requests for (id, ui_state) in &mut self.app_state.track_ui_states { - if ui_state.position_dragged { - seek_requests.push((*id, ui_state.position_slider_value)); + if let Some(position) = ui_state.position.take_pending() { + make_request_async(Request::seek(position, Some(*id))); } - if ui_state.volume_dragged { - volume_requests.push((*id, ui_state.volume_slider_value)); - ui_state.volume_dragged = false; + if let Some(volume) = ui_state.volume.take_pending() { + make_request_async(Request::set_volume(volume, Some(*id))); } } - for (id, pos) in seek_requests { - make_request_async(Request::seek(pos, Some(id))); - if let Some(ui_state) = self.app_state.track_ui_states.get_mut(&id) { - ui_state.position_dragged = false; - ui_state.ignore_position_update_until = - Some(Instant::now() + Duration::from_millis(300)); - } + // Master volumes + if let Some(volume) = self.app_state.monitoring_volume.take_pending() { + make_request_async(Request::set_monitoring_volume(volume)); + self.remember_volume(|config| config.default_monitoring_volume = Some(volume)); } - for (id, vol) in volume_requests { - make_request_async(Request::set_volume(vol, Some(id))); - if let Some(ui_state) = self.app_state.track_ui_states.get_mut(&id) { - ui_state.volume_dragged = false; - ui_state.ignore_volume_update_until = - Some(Instant::now() + Duration::from_millis(300)); - } + if let Some(volume) = self.app_state.mic_volume.take_pending() { + make_request_async(Request::set_mic_volume(volume)); + self.remember_volume(|config| config.default_mic_volume = Some(volume)); } - if self.app_state.volume_dragged { - make_request_async(Request::set_volume( - self.app_state.volume_slider_value, - None, - )); - - self.app_state.volume_dragged = false; - self.app_state.ignore_volume_update_until = - Some(Instant::now() + Duration::from_millis(300)); - - if self.config.save_volume - && let Ok(mut daemon_config) = get_daemon_config() - { - daemon_config.default_volume = Some(self.app_state.volume_slider_value); - update_daemon_config(&daemon_config).ok(); - } - } - - if self.app_state.volume_multiplier_dragged { - make_request_async(Request::set_volume_multiplier( - self.app_state.volume_multiplier_slider_value, - )); - - self.app_state.volume_multiplier_dragged = false; - self.app_state.ignore_volume_multiplier_update_until = - Some(Instant::now() + Duration::from_millis(300)); + if let Some(multiplier) = self.app_state.volume_multiplier.take_pending() { + make_request_async(Request::set_volume_multiplier(multiplier)); if self.config.save_volume_multiplier && let Ok(mut daemon_config) = get_daemon_config() { - daemon_config.default_volume_multiplier = - Some(self.app_state.volume_multiplier_slider_value); + daemon_config.default_volume_multiplier = Some(multiplier); update_daemon_config(&daemon_config).ok(); } } diff --git a/pwsp-gui/src/gui/views/footer.rs b/pwsp-gui/src/gui/views/footer.rs index ec20cdb..d56516d 100644 --- a/pwsp-gui/src/gui/views/footer.rs +++ b/pwsp-gui/src/gui/views/footer.rs @@ -1,80 +1,93 @@ use crate::gui::SoundpadGui; -use egui::{AtomExt, Button, ComboBox, Label, RichText, Slider, Ui, Vec2}; +use egui::{AtomExt, Button, Label, RichText, Slider, Ui, Vec2}; use egui_material_icons::icons::*; +use pwsp_lib::types::gui::SliderLatch; use rust_i18n::t; -use std::time::Instant; + +/// Masters go past 100% on purpose: amplifying the mic feed without deafening yourself is +/// the point of splitting the two paths. +const MAX_MASTER_VOLUME: f32 = 2.0; +const VOLUME_SLIDER_WIDTH: f32 = 90.0; +const ICON_SIZE: f32 = 18.0; +/// Fixes the row height before anything is placed in it. +/// +/// A horizontal layout centres each widget against the row height known at the time, so a +/// row that grows while being filled leaves whatever was added first sitting too high. +const FOOTER_ROW_HEIGHT: f32 = 24.0; impl SoundpadGui { pub fn draw_footer(&mut self, ui: &mut Ui) { ui.add_space(5.0); ui.horizontal(|ui| { - self.draw_mic_selection(ui); - self.draw_master_volume(ui); + ui.set_min_height(FOOTER_ROW_HEIGHT); - ui.add_space(ui.available_width() - 18.0 * 2.0 - ui.spacing().item_spacing.x * 2.0); + self.draw_monitoring_volume(ui); + self.draw_mic_volume(ui); + + // Right-aligns the icon buttons. Clamped because a negative value pushes them + // out of view instead of merely crowding them. + let spacer = ui.available_width() - ICON_SIZE * 2.0 - ui.spacing().item_spacing.x * 2.0; + ui.add_space(spacer.max(0.0)); self.draw_hotkeys_button(ui); self.draw_settings_button(ui); }); } - fn draw_mic_selection(&mut self, ui: &mut Ui) { - let mics = &self.audio_player_state.all_inputs_sorted; + fn draw_monitoring_volume(&mut self, ui: &mut Ui) { + let volume = self.audio_player_state.monitoring_volume; + let icon = Self::get_volume_icon(volume); + ui.add_sized( + [ICON_SIZE, FOOTER_ROW_HEIGHT], + Label::new(RichText::new(icon).size(ICON_SIZE)), + ) + .on_hover_text(format!( + "{}: {:.0}%", + t!("gui.monitoring_volume"), + volume * 100.0 + )); - let mut selected_input = self.audio_player_state.current_input.to_owned(); - let prev_input = selected_input.to_owned(); - ComboBox::from_label(t!("gui.choose_mic_select")) - .height(30.0) - .selected_text( - self.audio_player_state - .all_inputs - .get(&selected_input) - .unwrap_or(&String::new()), - ) - .show_ui(ui, |ui| { - for (name, nick) in mics { - ui.selectable_value(&mut selected_input, name.clone(), nick); - } - }); - - if selected_input != prev_input { - self.set_input(selected_input); - } + Self::draw_volume_slider(ui, &mut self.app_state.monitoring_volume, volume); } - fn draw_master_volume(&mut self, ui: &mut Ui) { - let volume_icon = Self::get_volume_icon(self.audio_player_state.volume); - let volume_label = Label::new(RichText::new(volume_icon).size(18.0)); - ui.add_sized([18.0, 18.0], volume_label) - .on_hover_text(format!( - "Master Volume: {:.0}%", - self.audio_player_state.volume * 100.0 - )); + fn draw_mic_volume(&mut self, ui: &mut Ui) { + let volume = self.audio_player_state.mic_volume; + let icon = if volume <= 0.0 { + ICON_MIC_OFF.codepoint + } else { + ICON_MIC.codepoint + }; + ui.add_sized( + [ICON_SIZE, FOOTER_ROW_HEIGHT], + Label::new(RichText::new(icon).size(ICON_SIZE)), + ) + .on_hover_text(format!("{}: {:.0}%", t!("gui.mic_volume"), volume * 100.0)); - let should_update_volume = !self.app_state.volume_dragged - && self - .app_state - .ignore_volume_update_until - .map(|t| Instant::now() > t) - .unwrap_or(true); + Self::draw_volume_slider(ui, &mut self.app_state.mic_volume, volume); + } - if should_update_volume { - self.app_state.volume_slider_value = self.audio_player_state.volume; - } + fn draw_volume_slider(ui: &mut Ui, latch: &mut SliderLatch, daemon_value: f32) { + latch.sync(daemon_value); - let volume_slider = Slider::new(&mut self.app_state.volume_slider_value, 0.0..=1.0) + // A Slider draws its rail at spacing().slider_width regardless of what add_sized + // allocates, so both need the same number or the widget overruns its space. + ui.spacing_mut().slider_width = VOLUME_SLIDER_WIDTH; + + let slider = Slider::new(&mut latch.value, 0.0..=MAX_MASTER_VOLUME) .show_value(false) .step_by(0.01); - let volume_slider_response = ui.add_sized([150.0, 18.0], volume_slider); - if volume_slider_response.drag_stopped() { - self.app_state.volume_dragged = true; + if ui + .add_sized([VOLUME_SLIDER_WIDTH, FOOTER_ROW_HEIGHT], slider) + .drag_stopped() + { + latch.dragged = true; } } fn draw_hotkeys_button(&mut self, ui: &mut Ui) { let hotkeys_button = Button::new(ICON_KEYBOARD.atom_size(Vec2::new(18.0, 18.0))).frame(false); - let hotkeys_button_response = ui.add_sized([18.0, 18.0], hotkeys_button); + let hotkeys_button_response = ui.add_sized([ICON_SIZE, FOOTER_ROW_HEIGHT], hotkeys_button); if hotkeys_button_response.clicked() { self.app_state.show_hotkeys = true; } @@ -84,7 +97,8 @@ impl SoundpadGui { fn draw_settings_button(&mut self, ui: &mut Ui) { let settings_button = Button::new(ICON_SETTINGS.atom_size(Vec2::new(18.0, 18.0))).frame(false); - let settings_button_response = ui.add_sized([18.0, 18.0], settings_button); + let settings_button_response = + ui.add_sized([ICON_SIZE, FOOTER_ROW_HEIGHT], settings_button); if settings_button_response.clicked() { self.app_state.show_settings = true; } diff --git a/pwsp-gui/src/gui/views/header.rs b/pwsp-gui/src/gui/views/header.rs index 4500246..8b0c10d 100644 --- a/pwsp-gui/src/gui/views/header.rs +++ b/pwsp-gui/src/gui/views/header.rs @@ -3,7 +3,6 @@ use egui::{Button, CollapsingHeader, FontFamily, Label, RichText, Slider, Ui}; use egui_material_icons::icons::*; use pwsp_lib::types::{audio_player::TrackInfo, gui::AppState}; use pwsp_lib::utils::gui::format_time_pair; -use std::time::Instant; pub(crate) enum TrackAction { Pause(u32), @@ -96,7 +95,7 @@ impl SoundpadGui { default_slider_width: f32, ) { let duration = track.duration.unwrap_or(1.0); - let position_slider = Slider::new(&mut ui_state.position_slider_value, 0.0..=duration) + let position_slider = Slider::new(&mut ui_state.position.value, 0.0..=duration) .show_value(false) .step_by(0.01); @@ -107,7 +106,7 @@ impl SoundpadGui { ui.spacing_mut().slider_width = position_slider_width; if ui.add_sized([30.0, 30.0], position_slider).drag_stopped() { - ui_state.position_dragged = true; + ui_state.position.dragged = true; } let time_label = @@ -126,7 +125,7 @@ impl SoundpadGui { ui.add_sized([30.0, 30.0], volume_label) .on_hover_text(format!("Volume: {:.0}%", track.volume * 100.0)); - let volume_slider = Slider::new(&mut ui_state.volume_slider_value, 0.0..=1.0) + let volume_slider = Slider::new(&mut ui_state.volume.value, 0.0..=1.0) .show_value(false) .step_by(0.01); @@ -134,7 +133,7 @@ impl SoundpadGui { ui.spacing_mut().item_spacing.x = 0.0; if ui.add_sized([30.0, 30.0], volume_slider).drag_stopped() { - ui_state.volume_dragged = true; + ui_state.volume.dragged = true; } } @@ -154,25 +153,8 @@ impl SoundpadGui { ) -> Option { let ui_state = app_state.track_ui_states.entry(track.id).or_default(); - let should_update_position = !ui_state.position_dragged - && ui_state - .ignore_position_update_until - .map(|t| Instant::now() > t) - .unwrap_or(true); - - if should_update_position { - ui_state.position_slider_value = track.position; - } - - let should_update_volume = !ui_state.volume_dragged - && ui_state - .ignore_volume_update_until - .map(|t| Instant::now() > t) - .unwrap_or(true); - - if should_update_volume { - ui_state.volume_slider_value = track.volume; - } + ui_state.position.sync(track.position); + ui_state.volume.sync(track.volume); let mut action = None; diff --git a/pwsp-gui/src/gui/views/mod.rs b/pwsp-gui/src/gui/views/mod.rs index 360078d..64c06b7 100644 --- a/pwsp-gui/src/gui/views/mod.rs +++ b/pwsp-gui/src/gui/views/mod.rs @@ -51,5 +51,9 @@ mod tests { SoundpadGui::get_volume_icon(0.5), ICON_VOLUME_DOWN.codepoint ); + + // Masters go past 100%, which must not fall off the top of the ladder. + assert_eq!(SoundpadGui::get_volume_icon(1.0), ICON_VOLUME_UP.codepoint); + assert_eq!(SoundpadGui::get_volume_icon(2.0), ICON_VOLUME_UP.codepoint); } } diff --git a/pwsp-gui/src/gui/views/settings.rs b/pwsp-gui/src/gui/views/settings.rs index d1f9cee..45c1513 100644 --- a/pwsp-gui/src/gui/views/settings.rs +++ b/pwsp-gui/src/gui/views/settings.rs @@ -62,6 +62,9 @@ impl SoundpadGui { ui.separator(); // ---------- Selectors ----------- + self.draw_mic_selection(ui); + self.draw_output_selection(ui); + let mut selected_theme = self.config.preferred_theme.clone(); ComboBox::from_label(t!("gui.settings.theme.label")) .selected_text(match self.config.preferred_theme { @@ -97,30 +100,19 @@ impl SoundpadGui { // ----------- Sliders ------------ // Volume multiplier - let should_update_multiplier = !self.app_state.volume_multiplier_dragged - && self - .app_state - .ignore_volume_multiplier_update_until - .map(|t| std::time::Instant::now() > t) - .unwrap_or(true); - - if should_update_multiplier { - self.app_state.volume_multiplier_slider_value = - self.audio_player_state.volume_multiplier; - } + self.app_state + .volume_multiplier + .sync(self.audio_player_state.volume_multiplier); ui.horizontal(|ui| { - let slider = Slider::new( - &mut self.app_state.volume_multiplier_slider_value, - 0.01..=3.0, - ); + let slider = Slider::new(&mut self.app_state.volume_multiplier.value, 0.01..=3.0); let response = ui.add(slider); ui.label(t!("gui.settings.volume_multiplier")); if response.changed() { // This condition is required to avoid spamming requests while dragging, but to allow changing the value via TextEdit if !response.dragged() || (response.dragged() && response.drag_stopped()) { - self.app_state.volume_multiplier_dragged = true; + self.app_state.volume_multiplier.dragged = true; } } }); @@ -134,4 +126,62 @@ impl SoundpadGui { }); }); } + + fn draw_mic_selection(&mut self, ui: &mut Ui) { + let mics = &self.audio_player_state.all_inputs_sorted; + + let mut selected_input = self.audio_player_state.current_input.to_owned(); + let prev_input = selected_input.to_owned(); + ComboBox::from_label(t!("gui.choose_mic_select")) + .height(30.0) + .selected_text( + self.audio_player_state + .all_inputs + .get(&selected_input) + .unwrap_or(&String::new()), + ) + .show_ui(ui, |ui| { + for (name, nick) in mics { + ui.selectable_value(&mut selected_input, name.clone(), nick); + } + }); + + if selected_input != prev_input { + self.set_input(selected_input); + } + } + + fn draw_output_selection(&mut self, ui: &mut Ui) { + let outputs = &self.audio_player_state.all_outputs_sorted; + + let mut selected_output = self.audio_player_state.current_output.to_owned(); + let prev_output = selected_output.to_owned(); + + // An empty selection means no device is pinned. + let selected_text = self + .audio_player_state + .all_outputs + .get(&selected_output) + .cloned() + .unwrap_or_else(|| t!("gui.default_output").to_string()); + + ComboBox::from_label(t!("gui.choose_output_select")) + .height(30.0) + .selected_text(selected_text) + .show_ui(ui, |ui| { + // Listed first so pinning a device stays undoable. + ui.selectable_value( + &mut selected_output, + String::new(), + t!("gui.default_output"), + ); + for (name, nick) in outputs { + ui.selectable_value(&mut selected_output, name.clone(), nick); + } + }); + + if selected_output != prev_output { + self.set_output(selected_output); + } + } } diff --git a/pwsp-lib/src/types/audio_player.rs b/pwsp-lib/src/types/audio_player.rs index de87c97..36c9f91 100644 --- a/pwsp-lib/src/types/audio_player.rs +++ b/pwsp-lib/src/types/audio_player.rs @@ -1,21 +1,34 @@ use crate::{ - types::pipewire::DeviceType, + types::pipewire::{AudioDevice, DeviceType}, utils::{ daemon::with_daemon_config, - pipewire::{PwTerminator, create_link, get_device, link_player_to_virtual_mic}, + pipewire::{ + PwTerminator, create_link, ensure_route, get_all_devices, get_device, get_device_by_id, + get_sink, + }, }, }; use anyhow::{Result, anyhow}; use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source}; use serde::{Deserialize, Serialize}; use std::{ - collections::HashMap, - error::Error, + collections::{HashMap, HashSet}, fs, + io::BufReader, path::{Path, PathBuf}, time::Duration, }; +const VIRTUAL_MIC_NAME: &str = "pwsp-virtual-mic"; + +/// Streams are re-opened on the first play after an idle period, so this poll sits in the +/// path between pressing a key and hearing the sound. The node normally shows up within a +/// poll or two; the attempt count only bounds the pathological case. +const NODE_DISCOVERY_ATTEMPTS: u32 = 200; +const NODE_DISCOVERY_INTERVAL: Duration = Duration::from_millis(5); + +type FileDecoder = Decoder>; + #[derive(Debug, Eq, PartialEq, Default, Clone, Serialize, Deserialize)] pub enum PlayerState { #[default] @@ -39,55 +52,135 @@ pub struct TrackInfo { pub struct FullState { pub state: PlayerState, pub tracks: Vec, - pub volume: f32, + pub monitoring_volume: f32, + pub mic_volume: f32, pub volume_multiplier: f32, pub current_input: String, pub all_inputs: HashMap, + pub current_output: String, + pub all_outputs: HashMap, +} + +/// Which of the two independent output paths a volume applies to. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum VolumeTarget { + /// What the user hears locally. + Monitoring, + /// What is fed into the virtual microphone, i.e. what everybody else hears. + Mic, +} + +/// The two rodio players backing a single track, one per output path. +/// +/// Every playback control has to reach both, so they are wrapped rather than fanned out +/// by hand at each call site. +pub struct PlayerPair { + pub monitoring: Player, + pub mic: Option, +} + +impl PlayerPair { + pub fn for_each(&self, f: impl Fn(&Player)) { + f(&self.monitoring); + if let Some(mic) = &self.mic { + f(mic); + } + } + + /// The player that answers queries about position and paused state for the pair. + pub fn primary(&self) -> &Player { + &self.monitoring + } + + /// True once every player of the pair has run out of samples. + pub fn empty(&self) -> bool { + self.monitoring.empty() && self.mic.as_ref().is_none_or(|mic| mic.empty()) + } } pub struct PlayingSound { pub id: u32, - pub sink: Player, + pub players: PlayerPair, pub path: PathBuf, pub duration: Option, pub looped: bool, pub volume: f32, } +/// Final linear gain handed to a rodio `Player`. +/// +/// Values above `1.0` are allowed on purpose; anything non-finite or negative collapses to +/// silence rather than reaching the mixer. +pub fn effective_gain(master: f32, track: f32, multiplier: f32) -> f32 { + let gain = master * track * multiplier; + if gain.is_finite() && gain > 0.0 { + gain + } else { + 0.0 + } +} + pub struct AudioPlayer { - stream_handle: Option, + monitoring_stream: Option, + mic_stream: Option, + + /// PipeWire nodes backing the two streams, once discovered. + monitoring_node: Option, + mic_node: Option, + + /// Links we created ourselves; dropping them tears the routes down. + monitoring_route: Option, + mic_route: Option, + input_link_sender: Option, + pub tracks: HashMap, pub next_id: u32, - input_link_sender: Option, - player_link_sender: Option, pub input_device_name: Option, + /// `None` pins nothing, leaving the monitoring stream to be routed like any other + /// application's. + pub output_device_name: Option, - pub volume: f32, // Master volume + pub monitoring_volume: f32, + pub mic_volume: f32, pub volume_multiplier: f32, } impl AudioPlayer { pub async fn new() -> Result { - let (default_input_name, default_volume, default_volume_multiplier) = - with_daemon_config(|c| { - ( - c.default_input_name.clone(), - c.default_volume.unwrap_or(1.0), - c.default_volume_multiplier.unwrap_or(1.0), - ) - }); + let ( + default_input_name, + default_output_name, + default_monitoring_volume, + default_mic_volume, + default_volume_multiplier, + ) = with_daemon_config(|c| { + ( + c.default_input_name.clone(), + c.default_output_name.clone(), + c.default_monitoring_volume.unwrap_or(1.0), + c.default_mic_volume.unwrap_or(1.0), + c.default_volume_multiplier.unwrap_or(1.0), + ) + }); let mut audio_player = AudioPlayer { - stream_handle: None, + monitoring_stream: None, + mic_stream: None, + monitoring_node: None, + mic_node: None, + monitoring_route: None, + mic_route: None, + input_link_sender: None, + tracks: HashMap::new(), next_id: 1, - input_link_sender: None, - player_link_sender: None, input_device_name: default_input_name, + output_device_name: default_output_name, - volume: default_volume, + monitoring_volume: default_monitoring_volume, + mic_volume: default_mic_volume, volume_multiplier: default_volume_multiplier, }; @@ -98,22 +191,83 @@ impl AudioPlayer { Ok(audio_player) } - fn ensure_stream(&mut self) -> Result<&MixerDeviceSink> { - if self.stream_handle.is_none() { - let mut sink = DeviceSinkBuilder::open_default_sink()?; - sink.log_on_drop(false); - self.stream_handle = Some(sink); + /// Opens both output streams and routes them, if that has not happened yet. + /// + /// Streams are opened one at a time: node discovery below tells them apart by diffing + /// the graph around each open, which only works if the opens do not overlap. + async fn ensure_streams(&mut self) -> Result<()> { + if self.monitoring_stream.is_none() { + let (stream, node) = open_stream_and_identify().await?; + self.monitoring_stream = Some(stream); + self.monitoring_node = node; } - self.stream_handle - .as_ref() - .ok_or_else(|| anyhow!("Failed to initialize stream_handle")) + + if self.mic_stream.is_none() { + let (stream, node) = open_stream_and_identify().await?; + self.mic_stream = Some(stream); + self.mic_node = node; + } + + self.ensure_routes().await; + + Ok(()) } - fn drop_stream(&mut self) { - if self.stream_handle.is_some() { - self.stream_handle = None; - self.abort_player_link_thread(); + /// Closes both output streams once nothing is playing. + /// + /// This is what lets a laptop suspend: an open stream keeps the audio device busy and + /// blocks sleep. Routing is rebuilt on the next `play()`, since re-opening the streams + /// mints new node ids. + fn drop_streams(&mut self) { + if self.monitoring_stream.is_none() && self.mic_stream.is_none() { + return; } + + self.monitoring_stream = None; + self.mic_stream = None; + // The nodes and our links die with the streams, so none of this may outlive them. + self.monitoring_node = None; + self.mic_node = None; + self.monitoring_route = None; + self.mic_route = None; + } + + /// Re-asserts both routes. Idempotent, so it can run on every device-check tick. + async fn ensure_routes(&mut self) { + if let Some(node) = self.mic_node.clone() { + match self.route(&node, get_device(VIRTUAL_MIC_NAME).await).await { + Ok(Some(terminator)) => self.mic_route = Some(terminator), + Ok(None) => {} + Err(err) => eprintln!("Failed to route mic stream to virtual mic: {}", err), + } + } + + // With nothing pinned the monitoring stream is left alone: picking a target + // ourselves would mean re-implementing the session manager's policy, where the + // default sink is only a fallback that a per-stream target overrides. + if let Some(name) = self.output_device_name.clone() + && let Some(node) = self.monitoring_node.clone() + { + match self.route(&node, get_sink(&name).await).await { + Ok(Some(terminator)) => self.monitoring_route = Some(terminator), + Ok(None) => {} + Err(err) => eprintln!( + "Failed to route monitoring stream to output device {}: {}", + name, err + ), + } + } + } + + /// Re-reads the source node before linking, so a route survives the node's ports + /// being rediscovered. + async fn route( + &self, + source: &AudioDevice, + target: Result, + ) -> Result> { + let source = get_device_by_id(source.id).await?; + ensure_route(&source, &target?).await } fn abort_link_thread(&mut self) { @@ -123,27 +277,6 @@ impl AudioPlayer { } } - fn abort_player_link_thread(&mut self) { - if self.player_link_sender.is_some() { - println!("Sent terminate signal to player link thread"); - self.player_link_sender = None; - } - } - - async fn link_player(&mut self) -> Result<()> { - if self.player_link_sender.is_some() { - return Ok(()); - } - - match link_player_to_virtual_mic().await { - Ok(sender) => { - self.player_link_sender = Some(sender); - Ok(()) - } - Err(_) => Ok(()), - } - } - async fn link_devices(&mut self) -> Result<()> { self.abort_link_thread(); @@ -164,7 +297,7 @@ impl AudioPlayer { } let daemon_input; - if let Ok(device) = get_device("pwsp-virtual-mic").await { + if let Ok(device) = get_device(VIRTUAL_MIC_NAME).await { daemon_input = device; } else { eprintln!("Could not find pwsp-virtual-mic device, skipping device linking"); @@ -172,19 +305,19 @@ impl AudioPlayer { } let Some(output_fl) = input_device.output_fl.clone() else { - eprintln!("Failed to get pwsp-daemon output_fl"); + eprintln!("Failed to get input device output_fl"); return Ok(()); }; let Some(output_fr) = input_device.output_fr.clone() else { - eprintln!("Failed to get pwsp-daemon output_fr"); + eprintln!("Failed to get input device output_fr"); return Ok(()); }; let Some(input_fl) = daemon_input.input_fl.clone() else { - eprintln!("Failed to get pwsp-daemon input_fl"); + eprintln!("Failed to get pwsp-virtual-mic input_fl"); return Ok(()); }; let Some(input_fr) = daemon_input.input_fr.clone() else { - eprintln!("Failed to get pwsp-daemon input_fr"); + eprintln!("Failed to get pwsp-virtual-mic input_fr"); return Ok(()); }; @@ -194,25 +327,21 @@ impl AudioPlayer { } pub fn pause(&mut self, id: Option) { - if let Some(id) = id { - if let Some(sound) = self.tracks.get_mut(&id) { - sound.sink.pause(); - } - } else { - for sound in self.tracks.values_mut() { - sound.sink.pause(); - } - } + self.for_selected(id, |sound| sound.players.for_each(|p| p.pause())); } pub fn resume(&mut self, id: Option) { + self.for_selected(id, |sound| sound.players.for_each(|p| p.play())); + } + + fn for_selected(&mut self, id: Option, f: impl Fn(&mut PlayingSound)) { if let Some(id) = id { if let Some(sound) = self.tracks.get_mut(&id) { - sound.sink.play(); + f(sound); } } else { for sound in self.tracks.values_mut() { - sound.sink.play(); + f(sound); } } } @@ -224,7 +353,7 @@ impl AudioPlayer { self.tracks.clear(); } if self.tracks.is_empty() { - self.drop_stream(); + self.drop_streams(); } } @@ -232,7 +361,9 @@ impl AudioPlayer { if self.tracks.is_empty() { return false; } - self.tracks.values().all(|s| s.sink.is_paused()) + self.tracks + .values() + .all(|s| s.players.primary().is_paused()) } pub fn get_state(&self) -> PlayerState { @@ -243,7 +374,7 @@ impl AudioPlayer { if self .tracks .values() - .any(|s| !s.sink.is_paused() && !s.sink.empty()) + .any(|s| !s.players.primary().is_paused() && !s.players.primary().empty()) { return PlayerState::Playing; } @@ -255,59 +386,95 @@ impl AudioPlayer { PlayerState::Stopped } - pub fn get_volume(&mut self, id: Option) -> Option { - if let Some(id) = id { - if let Some(sound) = self.tracks.get_mut(&id) { - Some(sound.sink.volume()) - } else { - None - } - } else { - Some(self.volume) + pub fn get_volume(&self, id: Option) -> Option { + match id { + Some(id) => self.tracks.get(&id).map(|sound| sound.volume), + None => Some(self.monitoring_volume), } } + /// Pushes the current master/track/multiplier state onto every live player. + /// + /// Single source of truth for the gain math — every setter below funnels through it. + fn reapply_volumes(&mut self) { + for sound in self.tracks.values() { + sound.players.monitoring.set_volume(effective_gain( + self.monitoring_volume, + sound.volume, + self.volume_multiplier, + )); + if let Some(mic) = &sound.players.mic { + mic.set_volume(effective_gain( + self.mic_volume, + sound.volume, + self.volume_multiplier, + )); + } + } + } + + pub fn set_master_volume(&mut self, volume: f32, target: VolumeTarget) { + match target { + VolumeTarget::Monitoring => self.monitoring_volume = volume, + VolumeTarget::Mic => self.mic_volume = volume, + } + self.reapply_volumes(); + } + + pub fn get_master_volume(&self, target: VolumeTarget) -> f32 { + match target { + VolumeTarget::Monitoring => self.monitoring_volume, + VolumeTarget::Mic => self.mic_volume, + } + } + + pub fn set_volume_multiplier(&mut self, multiplier: f32) { + self.volume_multiplier = multiplier; + self.reapply_volumes(); + } + + /// Sets a single track's volume, or — without an id — moves both masters at once, + /// which is the "make everything quieter" shortcut. pub fn set_volume(&mut self, volume: f32, id: Option) { if let Some(id) = id { if let Some(sound) = self.tracks.get_mut(&id) { sound.volume = volume; - sound - .sink - .set_volume(self.volume * sound.volume * self.volume_multiplier); } } else { - self.volume = volume; - for sound in self.tracks.values_mut() { - sound - .sink - .set_volume(self.volume * sound.volume * self.volume_multiplier); - } + self.monitoring_volume = volume; + self.mic_volume = volume; } + self.reapply_volumes(); } pub fn get_position(&self, id: Option) -> f32 { if let Some(id) = id { if let Some(sound) = self.tracks.get(&id) { - return sound.sink.get_pos().as_secs_f32(); + return sound.players.primary().get_pos().as_secs_f32(); } } else if let Some(sound) = self.tracks.values().last() { // Fallback to last added track if no ID - return sound.sink.get_pos().as_secs_f32(); + return sound.players.primary().get_pos().as_secs_f32(); } 0.0 } pub fn seek(&mut self, position: f32, id: Option) -> Result<()> { let position = if position < 0.0 { 0.0 } else { position }; + let position = Duration::from_secs_f32(position); if let Some(id) = id { if let Some(sound) = self.tracks.get_mut(&id) { - sound.sink.try_seek(Duration::from_secs_f32(position))?; + sound.players.monitoring.try_seek(position)?; + if let Some(mic) = &sound.players.mic { + mic.try_seek(position).ok(); + } } } else { - // Seek all? Or last? Let's seek all for now if no ID provided for sound in self.tracks.values_mut() { - sound.sink.try_seek(Duration::from_secs_f32(position)).ok(); + sound.players.for_each(|p| { + p.try_seek(position).ok(); + }); } } Ok(()) @@ -325,74 +492,65 @@ impl AudioPlayer { } pub async fn play(&mut self, file_path: &Path, concurrent: bool) -> Result { - let path_buf = file_path.to_path_buf(); + // One decoder per output path: the two streams run on different device clocks, + // so they cannot share a source anyway, and rodio's `Buffered` (the only shareable + // source) does not support seeking. + let (monitoring_source, mic_source) = tokio::try_join!( + decode(file_path.to_path_buf()), + decode(file_path.to_path_buf()), + )?; - let decoder_result = - tokio::task::spawn_blocking(move || -> Result<_, Box> { - if !path_buf.exists() { - return Err(format!("File does not exist: {}", path_buf.display()).into()); - } - - let file = fs::File::open(&path_buf)?; - let decoder = Decoder::try_from(file) - .map_err(|e| Box::new(e) as Box)?; - Ok(decoder) - }) - .await?; - - match decoder_result { - Ok(source) => { - if !concurrent { - self.tracks.clear(); - } - - self.ensure_stream()?; - self.link_player().await.ok(); - - let id = self.next_id; - self.next_id += 1; - - let duration = source.total_duration().map(|d| d.as_secs_f32()); - - let mixer = self - .stream_handle - .as_ref() - .ok_or_else(|| anyhow::anyhow!("stream_handle is unexpectedly missing"))? - .mixer(); - let sink = Player::connect_new(mixer); - sink.set_volume(self.volume * self.volume_multiplier); // Default volume is 1.0 * master - sink.append(source); - sink.play(); - - let sound = PlayingSound { - id, - sink, - path: file_path.to_path_buf(), - duration, - looped: false, - volume: 1.0, - }; - - self.tracks.insert(id, sound); - - Ok(id) - } - Err(err) => Err(anyhow!(err)), + if !concurrent { + self.tracks.clear(); } + + self.ensure_streams().await?; + + let duration = monitoring_source.total_duration().map(|d| d.as_secs_f32()); + + let monitoring_mixer = self + .monitoring_stream + .as_ref() + .ok_or_else(|| anyhow!("monitoring stream is unexpectedly missing"))? + .mixer(); + let monitoring = Player::connect_new(monitoring_mixer); + monitoring.set_volume(effective_gain( + self.monitoring_volume, + 1.0, + self.volume_multiplier, + )); + monitoring.append(monitoring_source); + monitoring.play(); + + // A missing mic stream degrades to monitoring-only playback rather than failing. + let mic = self.mic_stream.as_ref().map(|stream| { + let player = Player::connect_new(stream.mixer()); + player.set_volume(effective_gain(self.mic_volume, 1.0, self.volume_multiplier)); + player.append(mic_source); + player.play(); + player + }); + + let id = self.next_id; + self.next_id += 1; + + self.tracks.insert( + id, + PlayingSound { + id, + players: PlayerPair { monitoring, mic }, + path: file_path.to_path_buf(), + duration, + looped: false, + volume: 1.0, + }, + ); + + Ok(id) } pub fn set_loop(&mut self, enabled: bool, id: Option) { - if let Some(id) = id { - if let Some(sound) = self.tracks.get_mut(&id) { - sound.looped = enabled; - } - } else { - // Set loop for all? Or just last? - // Let's set for all. - for sound in self.tracks.values_mut() { - sound.looped = enabled; - } - } + self.for_selected(id, |sound| sound.looped = enabled); } pub fn get_tracks(&self) -> Vec { @@ -403,10 +561,10 @@ impl AudioPlayer { id: sound.id, path: sound.path.clone(), duration: sound.duration, - position: sound.sink.get_pos().as_secs_f32(), + position: sound.players.primary().get_pos().as_secs_f32(), volume: sound.volume, looped: sound.looped, - paused: sound.sink.is_paused(), + paused: sound.players.primary().is_paused(), }) .collect(); tracks.sort_by_key(|t| t.id); @@ -431,53 +589,57 @@ impl AudioPlayer { } } - if self.stream_handle.is_some() && self.player_link_sender.is_none() { - self.link_player().await.ok(); + if self.monitoring_stream.is_some() || self.mic_stream.is_some() { + self.ensure_routes().await; } } - // Handle looped sounds - let mut restarts = vec![]; + self.restart_looped_tracks().await; - for (id, sound) in &self.tracks { - if sound.sink.empty() && sound.looped { - restarts.push(*id); - } + self.tracks + .retain(|_, sound| !sound.players.empty() || sound.looped); + + if self.tracks.is_empty() { + self.drop_streams(); } + } + + async fn restart_looped_tracks(&mut self) { + let restarts: Vec = self + .tracks + .iter() + .filter(|(_, sound)| sound.looped && sound.players.empty()) + .map(|(id, _)| *id) + .collect(); let mut restart_futures = vec![]; - for id in restarts { if let Some(sound) = self.tracks.get(&id) { let path = sound.path.clone(); - let handle = tokio::task::spawn_blocking(move || { - if let Ok(file) = fs::File::open(&path) - && let Ok(source) = Decoder::try_from(file) - { - return Some((id, source)); - } - None + restart_futures.push(async move { + tokio::try_join!(decode(path.clone()), decode(path)) + .ok() + .map(|(monitoring, mic)| (id, monitoring, mic)) }); - restart_futures.push(handle); } } - for handle in restart_futures { - if let Ok(res) = handle.await - && let Some((id, source)) = res + for future in restart_futures { + if let Some((id, monitoring_source, mic_source)) = future.await && let Some(sound) = self.tracks.get_mut(&id) { - sound.sink.append(source); - sound.sink.play(); + if sound.players.monitoring.empty() { + sound.players.monitoring.append(monitoring_source); + sound.players.monitoring.play(); + } + if let Some(mic) = &sound.players.mic + && mic.empty() + { + mic.append(mic_source); + mic.play(); + } } } - - self.tracks - .retain(|_, sound| !sound.sink.empty() || sound.looped); - - if self.tracks.is_empty() { - self.drop_stream(); - } } pub async fn set_current_input_device(&mut self, name: &str) -> Result<()> { @@ -493,4 +655,155 @@ impl AudioPlayer { Ok(()) } + + pub async fn set_current_output_device(&mut self, name: &str) -> Result<()> { + // Fails early with a useful message if the name does not name a sink. + get_sink(name).await?; + + self.output_device_name = Some(name.to_string()); + self.monitoring_route = None; + self.ensure_routes().await; + + Ok(()) + } + + /// Stops pinning an output device. + /// + /// The existing link is deliberately left in place: tearing it down would strand the + /// node with no output at all, since autoconnect only runs when a node first appears. + /// Streams close as soon as playback stops, so the next sound opens a fresh node that + /// gets routed normally. + pub fn clear_current_output_device(&mut self) { + self.output_device_name = None; + } +} + +async fn decode(path: PathBuf) -> Result { + tokio::task::spawn_blocking(move || { + if !path.exists() { + return Err(anyhow!("File does not exist: {}", path.display())); + } + let file = fs::File::open(&path)?; + Decoder::try_from(file).map_err(|e| anyhow!(e)) + }) + .await? +} + +/// True for the `Stream/Output/Audio` nodes that belong to us. +/// +/// The guard matters: node discovery below hands the result to link pruning, and pruning +/// a stranger's node would silence another application. +fn is_own_stream_node(device: &AudioDevice) -> bool { + let is_pwsp = |s: &str| s.to_ascii_lowercase().contains("pwsp"); + (is_pwsp(&device.name) || is_pwsp(&device.nick)) + && device.output_fl.is_some() + && device.output_fr.is_some() +} + +async fn own_stream_node_ids() -> HashSet { + match get_all_devices().await { + Ok(devices) => devices + .outputs + .iter() + .filter(|d| is_own_stream_node(d)) + .map(|d| d.id) + .collect(), + Err(_) => HashSet::new(), + } +} + +/// Opens one rodio output stream and works out which PipeWire node it produced. +/// +/// Identification is a before/after diff of our own stream nodes rather than an index +/// into a sorted list, so it does not depend on enumeration order. Callers must open +/// streams one at a time for the diff to stay unambiguous. +/// +/// A stream that cannot be matched to a node yields `None`: playback still works, only +/// explicit routing is skipped. +async fn open_stream_and_identify() -> Result<(MixerDeviceSink, Option)> { + let before = own_stream_node_ids().await; + + let mut stream = DeviceSinkBuilder::open_default_sink()?; + stream.log_on_drop(false); + + // Checked before the first sleep: the PCM is already open by the time open_stream + // returns, so the node is often registered and this costs nothing. + for attempt in 0..NODE_DISCOVERY_ATTEMPTS { + if attempt > 0 { + tokio::time::sleep(NODE_DISCOVERY_INTERVAL).await; + } + + let devices = match get_all_devices().await { + Ok(devices) => devices, + Err(_) => continue, + }; + + if let Some(node) = devices + .outputs + .into_iter() + .find(|d| !before.contains(&d.id) && is_own_stream_node(d)) + { + return Ok((stream, Some(node))); + } + } + + eprintln!("Timed out waiting for the new PipeWire node, routing will be skipped"); + Ok((stream, None)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_effective_gain() { + assert_eq!(effective_gain(1.0, 1.0, 1.0), 1.0); + assert_eq!(effective_gain(0.5, 0.5, 1.0), 0.25); + + // Amplification beyond 1.0 is allowed on purpose. + assert_eq!(effective_gain(2.0, 1.0, 1.0), 2.0); + assert_eq!(effective_gain(2.0, 1.0, 3.0), 6.0); + + // Anything that is not a usable gain collapses to silence. + assert_eq!(effective_gain(0.0, 1.0, 1.0), 0.0); + assert_eq!(effective_gain(-1.0, 1.0, 1.0), 0.0); + assert_eq!(effective_gain(f32::NAN, 1.0, 1.0), 0.0); + assert_eq!(effective_gain(f32::INFINITY, 1.0, 1.0), 0.0); + } + + fn stream_node(name: &str, with_ports: bool) -> AudioDevice { + use crate::types::pipewire::Port; + + let mut device = AudioDevice::new(1, None, None, Some(name), DeviceType::Output); + if with_ports { + device.add_port(Port { + node_id: 1, + port_id: 1, + name: "output_FL".to_string(), + }); + device.add_port(Port { + node_id: 1, + port_id: 2, + name: "output_FR".to_string(), + }); + } + device + } + + #[test] + fn test_is_own_stream_node() { + assert!(is_own_stream_node(&stream_node( + "alsa_playback.pwsp-daemon", + true + ))); + assert!(is_own_stream_node(&stream_node("PWSP-daemon", true))); + + // Somebody else's playback stream must never be treated as ours. + assert!(!is_own_stream_node(&stream_node("Firefox", true))); + // Nor a node whose ports have not been discovered yet. + assert!(!is_own_stream_node(&stream_node( + "alsa_playback.pwsp-daemon", + false + ))); + } } diff --git a/pwsp-lib/src/types/commands.rs b/pwsp-lib/src/types/commands.rs index b239514..3818a4c 100644 --- a/pwsp-lib/src/types/commands.rs +++ b/pwsp-lib/src/types/commands.rs @@ -1,6 +1,6 @@ use crate::{ types::{ - audio_player::{FullState, PlayerState}, + audio_player::{FullState, PlayerState, VolumeTarget}, config::{DaemonConfig, HotkeyConfig}, socket::{Request, Response}, }, @@ -57,6 +57,15 @@ pub struct SetVolumeMultiplierCommand { pub volume_multiplier: Option, } +pub struct GetMasterVolumeCommand { + pub target: VolumeTarget, +} + +pub struct SetMasterVolumeCommand { + pub volume: Option, + pub target: VolumeTarget, +} + pub struct GetPositionCommand { pub id: Option, } @@ -85,6 +94,14 @@ pub struct SetCurrentInputCommand { pub name: Option, } +pub struct GetCurrentOutputCommand {} + +pub struct GetAllOutputsCommand {} + +pub struct SetCurrentOutputCommand { + pub name: Option, +} + pub struct SetLoopCommand { pub enabled: Option, pub id: Option, @@ -198,7 +215,7 @@ impl Executable for TogglePauseCommand { if let Some(id) = self.id { if let Some(track) = audio_player.tracks.get(&id) { - if track.sink.is_paused() { + if track.players.primary().is_paused() { audio_player.resume(Some(id)); Response::new(true, "Audio was resumed") } else { @@ -262,7 +279,7 @@ impl Executable for GetStateCommand { #[async_trait] impl Executable for GetVolumeCommand { async fn execute(&self) -> Response { - let mut audio_player = match get_audio_player().await { + let audio_player = match get_audio_player().await { Ok(player) => player.lock().await, Err(err) => return Response::new(false, format!("Audio player error: {}", err)), }; @@ -288,10 +305,16 @@ impl Executable for GetVolumeMultiplierCommand { } } +/// Rejects values that would corrupt the mixer. The daemon is the trust boundary here: +/// hotkeys store raw `Request` JSON, so a bad value can arrive without passing the CLI. +fn validate_volume(volume: Option) -> Option { + volume.filter(|v| v.is_finite() && *v >= 0.0) +} + #[async_trait] impl Executable for SetVolumeCommand { async fn execute(&self) -> Response { - if let Some(volume) = self.volume { + if let Some(volume) = validate_volume(self.volume) { let mut audio_player = match get_audio_player().await { Ok(player) => player.lock().await, Err(err) => return Response::new(false, format!("Audio player error: {}", err)), @@ -305,15 +328,49 @@ impl Executable for SetVolumeCommand { } #[async_trait] -impl Executable for SetVolumeMultiplierCommand { +impl Executable for GetMasterVolumeCommand { async fn execute(&self) -> Response { - if let Some(volume_multiplier) = self.volume_multiplier { + let audio_player = match get_audio_player().await { + Ok(player) => player.lock().await, + Err(err) => return Response::new(false, format!("Audio player error: {}", err)), + }; + + Response::new( + true, + audio_player.get_master_volume(self.target).to_string(), + ) + } +} + +#[async_trait] +impl Executable for SetMasterVolumeCommand { + async fn execute(&self) -> Response { + if let Some(volume) = validate_volume(self.volume) { let mut audio_player = match get_audio_player().await { Ok(player) => player.lock().await, Err(err) => return Response::new(false, format!("Audio player error: {}", err)), }; - audio_player.volume_multiplier = volume_multiplier; - audio_player.set_volume(volume_multiplier, None); // Reset current volume for all tracks to apply multiplier + audio_player.set_master_volume(volume, self.target); + let name = match self.target { + VolumeTarget::Monitoring => "Monitoring", + VolumeTarget::Mic => "Microphone", + }; + Response::new(true, format!("{} volume was set to {}", name, volume)) + } else { + Response::new(false, "Invalid volume value") + } + } +} + +#[async_trait] +impl Executable for SetVolumeMultiplierCommand { + async fn execute(&self) -> Response { + if let Some(volume_multiplier) = validate_volume(self.volume_multiplier) { + let mut audio_player = match get_audio_player().await { + Ok(player) => player.lock().await, + Err(err) => return Response::new(false, format!("Audio player error: {}", err)), + }; + audio_player.set_volume_multiplier(volume_multiplier); Response::new( true, format!("Audio volume multiplier was set to {}", volume_multiplier), @@ -429,8 +486,8 @@ impl Executable for GetCurrentInputCommand { #[async_trait] impl Executable for GetAllInputsCommand { async fn execute(&self) -> Response { - let (input_devices, _output_devices) = match get_all_devices().await { - Ok(devices) => devices, + let input_devices = match get_all_devices().await { + Ok(devices) => devices.inputs, Err(err) => return Response::new(false, format!("Failed to get devices: {}", err)), }; let mut input_devices_strings = vec![]; @@ -466,6 +523,66 @@ impl Executable for SetCurrentInputCommand { } } +#[async_trait] +impl Executable for GetCurrentOutputCommand { + async fn execute(&self) -> Response { + let audio_player = match get_audio_player().await { + Ok(player) => player.lock().await, + Err(err) => return Response::new(false, format!("Audio player error: {}", err)), + }; + + match &audio_player.output_device_name { + Some(name) => Response::new(true, name), + // No pinned device means playback follows whatever the system default is. + None => Response::new(true, ""), + } + } +} + +#[async_trait] +impl Executable for GetAllOutputsCommand { + async fn execute(&self) -> Response { + let sinks = match get_all_devices().await { + Ok(devices) => devices.sinks, + Err(err) => return Response::new(false, format!("Failed to get devices: {}", err)), + }; + + let response_message = sinks + .into_iter() + .map(|device| format!("{} - {}", device.name, device.nick)) + .collect::>() + .join("; "); + + Response::new(true, response_message) + } +} + +#[async_trait] +impl Executable for SetCurrentOutputCommand { + async fn execute(&self) -> Response { + let Some(name) = &self.name else { + return Response::new(false, "Invalid device name"); + }; + + let mut audio_player = match get_audio_player().await { + Ok(player) => player.lock().await, + Err(err) => return Response::new(false, format!("Audio player error: {}", err)), + }; + + // An empty name is how a client asks to stop pinning a device and follow the + // system default again. + if name.is_empty() { + audio_player.clear_current_output_device(); + return Response::new(true, "Output device follows the system default"); + } + + match audio_player.set_current_output_device(name).await { + Ok(_) => Response::new(true, "Output device was set"), + Err(err) => Response::new(false, err.to_string()), + } + } +} + #[async_trait] impl Executable for SetLoopCommand { async fn execute(&self) -> Response { @@ -518,10 +635,11 @@ impl Executable for GetDaemonVersionCommand { #[async_trait] impl Executable for GetFullStateCommand { async fn execute(&self) -> Response { - let (input_devices, _output_devices) = match get_all_devices().await { + let devices = match get_all_devices().await { Ok(devices) => devices, Err(err) => return Response::new(false, format!("Failed to get devices: {}", err)), }; + let input_devices = devices.inputs; let mut all_inputs = HashMap::new(); let mut current_input_nick = String::new(); @@ -550,13 +668,22 @@ impl Executable for GetFullStateCommand { } } + let all_outputs: HashMap = devices + .sinks + .into_iter() + .map(|device| (device.name, device.nick)) + .collect(); + let full_state = FullState { state: audio_player.get_state(), tracks: audio_player.get_tracks(), - volume: audio_player.volume, + monitoring_volume: audio_player.monitoring_volume, + mic_volume: audio_player.mic_volume, volume_multiplier: audio_player.volume_multiplier, current_input: current_input_nick, all_inputs, + current_output: audio_player.output_device_name.clone().unwrap_or_default(), + all_outputs, }; match serde_json::to_string(&full_state) { diff --git a/pwsp-lib/src/types/config.rs b/pwsp-lib/src/types/config.rs index 0d2cc99..72b3100 100644 --- a/pwsp-lib/src/types/config.rs +++ b/pwsp-lib/src/types/config.rs @@ -16,7 +16,9 @@ use std::{ #[serde(default)] pub struct DaemonConfig { pub default_input_name: Option, - pub default_volume: Option, + pub default_output_name: Option, + pub default_monitoring_volume: Option, + pub default_mic_volume: Option, pub default_volume_multiplier: Option, } diff --git a/pwsp-lib/src/types/gui.rs b/pwsp-lib/src/types/gui.rs index 3bdeadb..25c827b 100644 --- a/pwsp-lib/src/types/gui.rs +++ b/pwsp-lib/src/types/gui.rs @@ -9,21 +9,64 @@ use std::{ collections::{HashMap, HashSet}, path::PathBuf, sync::{Arc, Mutex}, - time::Instant, + time::{Duration, Instant}, }; pub type ScanResult = (PathBuf, Vec, HashMap>); +/// How long the daemon's value is ignored after a slider commits, so the knob does not +/// snap back before the daemon has caught up. +const SETTLE_TIME: Duration = Duration::from_millis(300); + +/// A slider whose local value wins over the daemon's while the user is interacting. +/// +/// The daemon is polled at 60 Hz, so without this the value would fight the user mid-drag +/// and jump back right after release. +#[derive(Default, Debug)] +pub struct SliderLatch { + pub value: f32, + pub dragged: bool, + pub ignore_update_until: Option, +} + +impl SliderLatch { + /// Whether the daemon's value may overwrite the local one this frame. + fn should_sync(&self) -> bool { + !self.dragged + && self + .ignore_update_until + .is_none_or(|until| Instant::now() > until) + } + + /// Adopts the daemon's value unless the user is currently driving the slider. + pub fn sync(&mut self, value: f32) { + if self.should_sync() { + self.value = value; + } + } + + /// Called once the pending change has been sent to the daemon. + fn commit(&mut self) { + self.dragged = false; + self.ignore_update_until = Some(Instant::now() + SETTLE_TIME); + } + + /// Returns the value to send, if the user finished a change that has not been sent yet. + pub fn take_pending(&mut self) -> Option { + if self.dragged { + let value = self.value; + self.commit(); + Some(value) + } else { + None + } + } +} + #[derive(Default, Debug)] pub struct TrackUiState { - pub position_slider_value: f32, - pub volume_slider_value: f32, - - pub position_dragged: bool, - pub volume_dragged: bool, - - pub ignore_position_update_until: Option, - pub ignore_volume_update_until: Option, + pub position: SliderLatch, + pub volume: SliderLatch, } #[derive(Default, Debug)] @@ -33,18 +76,14 @@ pub struct AppState { pub track_ui_states: HashMap, pub show_settings: bool, - pub volume_dragged: bool, - pub volume_multiplier_dragged: bool, pub force_focus_search: bool, - pub volume_slider_value: f32, - pub volume_multiplier_slider_value: f32, + pub monitoring_volume: SliderLatch, + pub mic_volume: SliderLatch, + pub volume_multiplier: SliderLatch, pub search_field_id: Option, - pub ignore_volume_update_until: Option, - pub ignore_volume_multiplier_update_until: Option, - pub current_dir: Option, pub dirs: Vec, pub dirs_to_remove: HashSet, @@ -75,14 +114,61 @@ pub struct AudioPlayerState { pub tracks: Vec, - pub volume: f32, // Master volume + /// What the user hears locally. + pub monitoring_volume: f32, + /// What is sent to the virtual microphone. + pub mic_volume: f32, pub volume_multiplier: f32, pub current_input: String, pub all_inputs: HashMap, pub all_inputs_sorted: Vec<(String, String)>, + /// Empty means "follow the system default sink". + pub current_output: String, + pub all_outputs: HashMap, + pub all_outputs_sorted: Vec<(String, String)>, + pub is_daemon_running: bool, pub hotkey_config: Option, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_slider_latch_syncs_when_idle() { + let mut latch = SliderLatch::default(); + + latch.sync(0.7); + assert_eq!(latch.value, 0.7); + + // While the user drags, the daemon must not move the knob out from under them. + latch.dragged = true; + latch.sync(0.2); + assert_eq!(latch.value, 0.7); + } + + #[test] + fn test_slider_latch_take_pending() { + let mut latch = SliderLatch::default(); + + // Nothing to send until the user actually changes something. + assert_eq!(latch.take_pending(), None); + + latch.value = 1.5; + latch.dragged = true; + assert_eq!(latch.take_pending(), Some(1.5)); + + // The change is sent exactly once. + assert_eq!(latch.take_pending(), None); + + // The daemon is then ignored briefly, so a stale value already in flight cannot + // snap the slider back. + assert!(!latch.should_sync()); + latch.sync(0.1); + assert_eq!(latch.value, 1.5); + } +} diff --git a/pwsp-lib/src/types/pipewire.rs b/pwsp-lib/src/types/pipewire.rs index a0f7cb0..182d277 100644 --- a/pwsp-lib/src/types/pipewire.rs +++ b/pwsp-lib/src/types/pipewire.rs @@ -13,6 +13,15 @@ pub struct Port { pub enum DeviceType { Input, Output, + Sink, +} + +/// A link between two nodes in the PipeWire graph. +#[derive(Debug, Clone, Copy, Ord, PartialOrd, Eq, PartialEq)] +pub struct LinkInfo { + pub id: u32, + pub output_node: u32, + pub input_node: u32, } #[derive(Debug, Clone, Ord, PartialOrd, Eq, PartialEq)] @@ -55,12 +64,14 @@ impl AudioDevice { } pub fn add_port(&mut self, port: Port) { + // `playback_*` are the input ports of an `Audio/Sink` node. `monitor_*` are its + // output ports and are deliberately left unmapped — we never record from a sink. match port.name.as_str() { - "input_FL" => self.input_fl = Some(port), - "input_FR" => self.input_fr = Some(port), + "input_FL" | "playback_FL" => self.input_fl = Some(port), + "input_FR" | "playback_FR" => self.input_fr = Some(port), "output_FL" | "capture_FL" => self.output_fl = Some(port), "output_FR" | "capture_FR" => self.output_fr = Some(port), - "input_MONO" => { + "input_MONO" | "playback_MONO" => { self.input_fl = Some(port.clone()); self.input_fr = Some(port); } @@ -100,6 +111,29 @@ mod tests { assert_eq!(device_no_desc.nick, "Name"); } + #[test] + fn test_audio_device_sink_ports() { + let mut sink = AudioDevice::new(1, None, None, Some("speakers"), DeviceType::Sink); + + let port = |id: u32, name: &str| Port { + node_id: 1, + port_id: id, + name: name.to_string(), + }; + + // playback_* carries audio into the sink, so those are its inputs. + sink.add_port(port(10, "playback_FL")); + sink.add_port(port(11, "playback_FR")); + assert_eq!(sink.input_fl, Some(port(10, "playback_FL"))); + assert_eq!(sink.input_fr, Some(port(11, "playback_FR"))); + + // monitor_* is what the sink plays out; linking a stream there would be a loop. + sink.add_port(port(12, "monitor_FL")); + sink.add_port(port(13, "monitor_FR")); + assert_eq!(sink.output_fl, None); + assert_eq!(sink.output_fr, None); + } + #[test] fn test_audio_device_add_port() { let mut device = AudioDevice::new(1, None, None, Some("device-name"), DeviceType::Input); diff --git a/pwsp-lib/src/types/socket.rs b/pwsp-lib/src/types/socket.rs index 841c987..6ac1713 100644 --- a/pwsp-lib/src/types/socket.rs +++ b/pwsp-lib/src/types/socket.rs @@ -143,6 +143,25 @@ impl Request { Request::new("set_volume".to_string(), args) } + pub fn get_monitoring_volume() -> Self { + Request::new("get_monitoring_volume", vec![]) + } + + pub fn set_monitoring_volume(volume: f32) -> Self { + Request::new( + "set_monitoring_volume", + vec![("volume", &volume.to_string())], + ) + } + + pub fn get_mic_volume() -> Self { + Request::new("get_mic_volume", vec![]) + } + + pub fn set_mic_volume(volume: f32) -> Self { + Request::new("set_mic_volume", vec![("volume", &volume.to_string())]) + } + pub fn set_volume_multiplier(volume: f32) -> Self { Request::new( "set_volume_multiplier", @@ -162,6 +181,18 @@ impl Request { Request::new("set_input", vec![("input_name", name)]) } + pub fn get_output() -> Self { + Request::new("get_output", vec![]) + } + + pub fn get_outputs() -> Self { + Request::new("get_outputs", vec![]) + } + + pub fn set_output(name: &str) -> Self { + Request::new("set_output", vec![("output_name", name)]) + } + pub fn set_loop(enabled: &str, id: Option) -> Self { let mut args = vec![("enabled".to_string(), enabled.to_string())]; if let Some(id) = id { @@ -280,6 +311,41 @@ mod tests { assert_eq!(res.message, "success-msg"); } + #[test] + fn test_volume_path_request_constructors() { + let monitoring = Request::set_monitoring_volume(1.5); + assert_eq!(monitoring.name, "set_monitoring_volume"); + assert_eq!( + monitoring.args.get("volume").map(|s| s.as_str()), + Some("1.5") + ); + + let mic = Request::set_mic_volume(2.0); + assert_eq!(mic.name, "set_mic_volume"); + assert_eq!(mic.args.get("volume").map(|s| s.as_str()), Some("2")); + + assert_eq!( + Request::get_monitoring_volume().name, + "get_monitoring_volume" + ); + assert!(Request::get_monitoring_volume().args.is_empty()); + assert_eq!(Request::get_mic_volume().name, "get_mic_volume"); + } + + #[test] + fn test_output_request_constructors() { + let set_output = Request::set_output("some-sink"); + assert_eq!(set_output.name, "set_output"); + assert_eq!( + set_output.args.get("output_name").map(|s| s.as_str()), + Some("some-sink") + ); + + assert_eq!(Request::get_output().name, "get_output"); + assert_eq!(Request::get_outputs().name, "get_outputs"); + assert!(Request::get_outputs().args.is_empty()); + } + #[test] fn test_request_constructors() { // test ping diff --git a/pwsp-lib/src/utils/commands.rs b/pwsp-lib/src/utils/commands.rs index 1359d93..24f67d7 100644 --- a/pwsp-lib/src/utils/commands.rs +++ b/pwsp-lib/src/utils/commands.rs @@ -1,7 +1,18 @@ -use crate::types::{commands::*, config::DaemonConfig, socket::Request}; +use crate::types::{ + audio_player::VolumeTarget, commands::*, config::DaemonConfig, socket::Request, +}; use std::path::PathBuf; +/// Which of the two output paths a `*_monitoring_volume` / `*_mic_volume` command means. +fn volume_target(command_name: &str) -> Option { + match command_name { + "get_monitoring_volume" | "set_monitoring_volume" => Some(VolumeTarget::Monitoring), + "get_mic_volume" | "set_mic_volume" => Some(VolumeTarget::Mic), + _ => None, + } +} + pub fn parse_command(request: &Request) -> Option> { let id = request.args.get("id").and_then(|s| s.parse::().ok()); @@ -25,6 +36,21 @@ pub fn parse_command(request: &Request) -> Option> { .ok(); Some(Box::new(SetVolumeCommand { volume, id })) } + "get_monitoring_volume" | "get_mic_volume" => Some(Box::new(GetMasterVolumeCommand { + target: volume_target(&request.name)?, + })), + "set_monitoring_volume" | "set_mic_volume" => { + let volume = request + .args + .get("volume") + .unwrap_or(&String::new()) + .parse::() + .ok(); + Some(Box::new(SetMasterVolumeCommand { + volume, + target: volume_target(&request.name)?, + })) + } "set_volume_multiplier" => { let volume_multiplier = request .args @@ -70,6 +96,12 @@ pub fn parse_command(request: &Request) -> Option> { let name = Some(request.args.get("input_name").unwrap_or(&String::new())).cloned(); Some(Box::new(SetCurrentInputCommand { name })) } + "get_output" => Some(Box::new(GetCurrentOutputCommand {})), + "get_outputs" => Some(Box::new(GetAllOutputsCommand {})), + "set_output" => { + let name = Some(request.args.get("output_name").unwrap_or(&String::new())).cloned(); + Some(Box::new(SetCurrentOutputCommand { name })) + } "set_loop" => { let enabled = request .args @@ -227,4 +259,57 @@ mod tests { let cmd = parse_command(&request); assert!(cmd.is_some()); } + + #[test] + fn test_volume_target_mapping() { + assert_eq!( + volume_target("set_monitoring_volume"), + Some(VolumeTarget::Monitoring) + ); + assert_eq!( + volume_target("get_monitoring_volume"), + Some(VolumeTarget::Monitoring) + ); + assert_eq!(volume_target("set_mic_volume"), Some(VolumeTarget::Mic)); + assert_eq!(volume_target("get_mic_volume"), Some(VolumeTarget::Mic)); + + // The two paths share a dispatch arm, so a name that is neither must not + // silently fall through to one of them. + assert_eq!(volume_target("set_volume"), None); + assert_eq!(volume_target("mic"), None); + } + + #[test] + fn test_parse_new_commands() { + let with_volume = |name: &str| { + let mut args = HashMap::new(); + args.insert("volume".to_string(), "2.0".to_string()); + Request { + name: name.to_string(), + args, + } + }; + + assert!(parse_command(&with_volume("set_monitoring_volume")).is_some()); + assert!(parse_command(&with_volume("set_mic_volume")).is_some()); + + for name in [ + "get_monitoring_volume", + "get_mic_volume", + "get_output", + "get_outputs", + ] { + let request = Request::new(name, vec![]); + assert!( + parse_command(&request).is_some(), + "{} did not dispatch", + name + ); + } + + let set_output = Request::set_output("some-sink"); + assert!(parse_command(&set_output).is_some()); + + assert!(parse_command(&Request::new("set_speaker_volume", vec![])).is_none()); + } } diff --git a/pwsp-lib/src/utils/gui.rs b/pwsp-lib/src/utils/gui.rs index 79dcfc5..4919a8f 100644 --- a/pwsp-lib/src/utils/gui.rs +++ b/pwsp-lib/src/utils/gui.rs @@ -9,6 +9,7 @@ use crate::{ }; use anyhow::{Result, anyhow}; use std::{ + collections::HashMap, fs, path::PathBuf, sync::{Arc, Mutex}, @@ -77,6 +78,16 @@ pub fn format_time_pair(position: f32, duration: f32) -> String { format!("{}/{}", format_time(position), format_time(duration)) } +/// Turns a name→nickname map into the stable order the combo boxes render in. +fn sorted_devices(devices: &HashMap) -> Vec<(String, String)> { + let mut sorted: Vec<(String, String)> = devices + .iter() + .map(|(name, nick)| (name.clone(), nick.clone())) + .collect(); + sorted.sort_by(|a, b| a.0.cmp(&b.0)); + sorted +} + pub fn start_app_state_thread(audio_player_state_shared: Arc>) { tokio::spawn(async move { let sleep_duration = Duration::from_secs_f32(1.0 / 60.0); @@ -115,7 +126,8 @@ pub fn start_app_state_thread(audio_player_state_shared: Arc full_state.state, }; guard.tracks = full_state.tracks; - guard.volume = full_state.volume; + guard.monitoring_volume = full_state.monitoring_volume; + guard.mic_volume = full_state.mic_volume; guard.volume_multiplier = full_state.volume_multiplier; guard.current_input = full_state .current_input @@ -123,16 +135,16 @@ pub fn start_app_state_thread(audio_player_state_shared: Arc = guard - .all_inputs - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); - sorted.sort_by(|a, b| a.0.cmp(&b.0)); - guard.all_inputs_sorted = sorted; + guard.all_inputs_sorted = sorted_devices(&guard.all_inputs); + } + + if guard.all_outputs != full_state.all_outputs { + guard.all_outputs = full_state.all_outputs; + guard.all_outputs_sorted = sorted_devices(&guard.all_outputs); } guard.is_daemon_running = true; diff --git a/pwsp-lib/src/utils/pipewire.rs b/pwsp-lib/src/utils/pipewire.rs index db9f485..47e3892 100644 --- a/pwsp-lib/src/utils/pipewire.rs +++ b/pwsp-lib/src/utils/pipewire.rs @@ -1,4 +1,4 @@ -use crate::types::pipewire::{AudioDevice, DeviceType, Port}; +use crate::types::pipewire::{AudioDevice, DeviceType, LinkInfo, Port}; use anyhow::{Result, anyhow}; use pipewire::{ context::ContextRc, link::Link, main_loop::MainLoopRc, properties::properties, @@ -7,9 +7,31 @@ use pipewire::{ use std::{cell::RefCell, collections::HashMap, rc::Rc, sync::OnceLock, thread}; use tokio::sync::oneshot; +/// Every audio node PWSP knows about, split by role. +pub struct AllDevices { + /// `Audio/Source*` nodes — real and virtual microphones. + pub inputs: Vec, + /// `Stream/Output/Audio` nodes — application playback streams, including our own. + pub outputs: Vec, + /// `Audio/Sink` nodes — speakers and headphones. + pub sinks: Vec, +} + +impl AllDevices { + pub fn iter(&self) -> impl Iterator { + self.inputs + .iter() + .chain(self.outputs.iter()) + .chain(self.sinks.iter()) + } +} + pub enum PwCommand { GetDevices { - resp: oneshot::Sender<(Vec, Vec)>, + resp: oneshot::Sender, + }, + GetLinks { + resp: oneshot::Sender>, }, CreateVirtualMic { resp: oneshot::Sender>, @@ -21,14 +43,21 @@ pub enum PwCommand { input_fr: Port, resp: oneshot::Sender>, }, + /// Drops a proxy we created ourselves, which destroys the underlying object. DestroyObject { id: u32, }, + /// Destroys a global object owned by somebody else, such as an auto-created link. + DestroyGlobal { + id: u32, + }, } struct AppState { input_devices: HashMap, output_devices: HashMap, + sink_devices: HashMap, + links: HashMap, ports: HashMap, proxies: HashMap>, proxy_id_counter: u32, @@ -53,19 +82,23 @@ pub fn get_manager() -> &'static PipewireManager { let main_loop = Box::leak(Box::new(main_loop)); let context = Box::leak(Box::new(context)); - // Leak to fix lifetime issues since this thread lives forever - let core = Box::leak(Box::new( + // Leak to fix lifetime issues since this thread lives forever. Shared rather + // than mutable so both `core` and the `registry` borrowed from it can be + // captured by the command closure below. + let core: &'static _ = Box::leak(Box::new( context .connect(None) .expect("Failed to connect to pipewire"), )); - let registry = Box::leak(Box::new( + let registry: &'static _ = Box::leak(Box::new( core.get_registry().expect("Failed to get registry"), )); let state = Rc::new(RefCell::new(AppState { input_devices: HashMap::new(), output_devices: HashMap::new(), + sink_devices: HashMap::new(), + links: HashMap::new(), ports: HashMap::new(), proxies: HashMap::new(), proxy_id_counter: 10000, @@ -78,31 +111,44 @@ pub fn get_manager() -> &'static PipewireManager { let _listener = registry .add_listener_local() .global(move |global| { - let (device, port) = parse_global_object(global); let mut s = state_for_registry_add.borrow_mut(); - if let Some(device) = device { - match device.device_type { + match parse_global_object(global) { + ParsedGlobal::Device(device) => match device.device_type { DeviceType::Input => { s.input_devices.insert(device.id, device); } DeviceType::Output => { s.output_devices.insert(device.id, device); } + DeviceType::Sink => { + s.sink_devices.insert(device.id, device); + } + }, + ParsedGlobal::Port(port) => { + let node_id = port.node_id; + s.ports.insert(port.port_id, port.clone()); + if let Some(d) = s.input_devices.get_mut(&node_id) { + d.add_port(port); + } else if let Some(d) = s.output_devices.get_mut(&node_id) { + d.add_port(port); + } else if let Some(d) = s.sink_devices.get_mut(&node_id) { + d.add_port(port); + } } - } else if let Some(port) = port { - let node_id = port.node_id; - s.ports.insert(port.port_id, port.clone()); - if let Some(d) = s.input_devices.get_mut(&node_id) { - d.add_port(port.clone()); - } else if let Some(d) = s.output_devices.get_mut(&node_id) { - d.add_port(port); + ParsedGlobal::Link(link) => { + s.links.insert(link.id, link); } + ParsedGlobal::Unknown => {} } }) .global_remove(move |id| { let mut s = state_for_registry_remove.borrow_mut(); s.input_devices.remove(&id); s.output_devices.remove(&id); + s.sink_devices.remove(&id); + s.links.remove(&id); + s.links + .retain(|_, link| link.output_node != id && link.input_node != id); s.ports.retain(|_, port| port.node_id != id); s.ports.remove(&id); }) @@ -133,9 +179,21 @@ pub fn get_manager() -> &'static PipewireManager { s.input_devices.values().cloned().collect(); let mut outputs: Vec = s.output_devices.values().cloned().collect(); + let mut sinks: Vec = + s.sink_devices.values().cloned().collect(); inputs.sort_by_key(|a| a.id); outputs.sort_by_key(|a| a.id); - let _ = resp.send((inputs, outputs)); + sinks.sort_by_key(|a| a.id); + let _ = resp.send(AllDevices { + inputs, + outputs, + sinks, + }); + } + PwCommand::GetLinks { resp } => { + let mut links: Vec = s.links.values().copied().collect(); + links.sort_by_key(|l| l.id); + let _ = resp.send(links); } PwCommand::CreateVirtualMic { resp } => { let props = properties!( @@ -207,6 +265,10 @@ pub fn get_manager() -> &'static PipewireManager { PwCommand::DestroyObject { id } => { s.proxies.remove(&id); } + PwCommand::DestroyGlobal { id } => { + s.links.remove(&id); + registry.destroy_global(id); + } } }); @@ -227,12 +289,17 @@ pub fn setup_pipewire_context() -> Result<(MainLoopRc, ContextRc), String> { Ok((main_loop, context)) } -fn parse_global_object( - global_object: &GlobalObject<&DictRef>, -) -> (Option, Option) { +enum ParsedGlobal { + Device(AudioDevice), + Port(Port), + Link(LinkInfo), + Unknown, +} + +fn parse_global_object(global_object: &GlobalObject<&DictRef>) -> ParsedGlobal { let props = match global_object.props { Some(p) => p, - None => return (None, None), + None => return ParsedGlobal::Unknown, }; if let Some(media_class) = props.get("media.class") { @@ -241,26 +308,40 @@ fn parse_global_object( let node_name = props.get("node.name"); let node_description = props.get("node.description"); - if media_class.starts_with("Audio/Source") { - let input_device = AudioDevice::new( - node_id, - node_nick, - node_description, - node_name, - DeviceType::Input, - ); - return (Some(input_device), None); + // `Audio/Source/Virtual` (our own virtual mic) also matches "Audio/Source", + // which is intended — it is a microphone as far as the rest of PWSP cares. + let device_type = if media_class.starts_with("Audio/Source") { + DeviceType::Input } else if media_class.starts_with("Stream/Output/Audio") { - let output_device = AudioDevice::new( - node_id, - node_nick, - node_description, - node_name, - DeviceType::Output, - ); - return (Some(output_device), None); - } - return (None, None); + DeviceType::Output + } else if media_class.starts_with("Audio/Sink") { + DeviceType::Sink + } else { + return ParsedGlobal::Unknown; + }; + + return ParsedGlobal::Device(AudioDevice::new( + node_id, + node_nick, + node_description, + node_name, + device_type, + )); + } + + if let (Some(output_node), Some(input_node)) = ( + props + .get("link.output.node") + .and_then(|id| id.parse::().ok()), + props + .get("link.input.node") + .and_then(|id| id.parse::().ok()), + ) { + return ParsedGlobal::Link(LinkInfo { + id: global_object.id, + output_node, + input_node, + }); } if props.get("port.direction").is_some() @@ -270,18 +351,17 @@ fn parse_global_object( props.get("port.name"), ) { - let port = Port { + return ParsedGlobal::Port(Port { node_id, port_id, name: port_name.to_string(), - }; - return (None, Some(port)); + }); } - (None, None) + ParsedGlobal::Unknown } -pub async fn get_all_devices() -> Result<(Vec, Vec)> { +pub async fn get_all_devices() -> Result { let (tx, rx) = oneshot::channel(); let manager = get_manager(); manager @@ -294,19 +374,52 @@ pub async fn get_all_devices() -> Result<(Vec, Vec)> { Ok(res) } -pub async fn get_device(device_name: &str) -> Result { - let (input_devices, output_devices) = get_all_devices().await?; +pub async fn get_all_links() -> Result> { + let (tx, rx) = oneshot::channel(); + let manager = get_manager(); + manager + .sender + .send(PwCommand::GetLinks { resp: tx }) + .map_err(|_| anyhow!("Failed to send GetLinks to manager"))?; + rx.await + .map_err(|e| anyhow!("Failed to receive response: {}", e)) +} - input_devices +fn matches_device_name(device: &AudioDevice, device_name: &str) -> bool { + device.name == device_name + || device.nick == device_name + || device.name.contains(device_name) + || device.nick.contains(device_name) +} + +pub async fn get_device(device_name: &str) -> Result { + let devices = get_all_devices().await?; + + devices + .iter() + .find(|device| matches_device_name(device, device_name)) + .cloned() + .ok_or_else(|| anyhow!("Device not found: {}", device_name)) +} + +/// Re-reads a node by its PipeWire id, so callers always link against fresh ports. +pub async fn get_device_by_id(id: u32) -> Result { + get_all_devices() + .await? + .iter() + .find(|device| device.id == id) + .cloned() + .ok_or_else(|| anyhow!("Node {} is gone", id)) +} + +/// Looks up an `Audio/Sink` node by name. +pub async fn get_sink(name: &str) -> Result { + get_all_devices() + .await? + .sinks .into_iter() - .chain(output_devices) - .find(|device| { - device.name == device_name - || device.nick == device_name - || device.name.contains(device_name) - || device.nick.contains(device_name) - }) - .ok_or_else(|| anyhow!("Device not found")) + .find(|d| matches_device_name(d, name)) + .ok_or_else(|| anyhow!("Output device not found: {}", name)) } pub struct PwTerminator { @@ -338,43 +451,63 @@ pub async fn create_virtual_mic() -> Result { Ok(PwTerminator { ids: vec![id] }) } -pub async fn link_player_to_virtual_mic() -> Result { - let pwsp_daemon_output = match get_device("pwsp-daemon").await { - Ok(device) => device, - Err(_) => { - return Err(anyhow!( - "Could not find alsa_playback.pwsp-daemon device, skipping device linking" - )); +/// Makes `source_node` feed `target` and nothing else. +/// +/// Idempotent: safe to call on every device-check tick. Returns `Some` only when a new link +/// was created, so an already-correct route is left in place and the caller never tears +/// down a link it does not own. +/// +/// The link is created *before* stale ones are pruned. That order matters: the node is +/// never left unlinked, which is the state that would invite the session manager to +/// re-attach it somewhere else. +pub async fn ensure_route( + source_node: &AudioDevice, + target: &AudioDevice, +) -> Result> { + let existing = get_all_links().await?; + let already_routed = existing + .iter() + .any(|link| link.output_node == source_node.id && link.input_node == target.id); + + let terminator = if already_routed { + None + } else { + let output_fl = source_node + .output_fl + .clone() + .ok_or_else(|| anyhow!("Node {} has no output_FL port", source_node.name))?; + let output_fr = source_node + .output_fr + .clone() + .ok_or_else(|| anyhow!("Node {} has no output_FR port", source_node.name))?; + let input_fl = target + .input_fl + .clone() + .ok_or_else(|| anyhow!("Node {} has no input_FL port", target.name))?; + let input_fr = target + .input_fr + .clone() + .ok_or_else(|| anyhow!("Node {} has no input_FR port", target.name))?; + + Some(create_link(output_fl, output_fr, input_fl, input_fr).await?) + }; + + prune_links_from(source_node.id, target.id).await?; + + Ok(terminator) +} + +/// Destroys every link leaving `source_node` that does not end at `keep_target`. +async fn prune_links_from(source_node: u32, keep_target: u32) -> Result<()> { + let manager = get_manager(); + for link in get_all_links().await? { + if link.output_node == source_node && link.input_node != keep_target { + let _ = manager + .sender + .send(PwCommand::DestroyGlobal { id: link.id }); } - }; - - let pwsp_daemon_input = match get_device("pwsp-virtual-mic").await { - Ok(device) => device, - Err(_) => { - return Err(anyhow!( - "Could not find pwsp-virtual-mic device, skipping device linking" - )); - } - }; - - let output_fl = match pwsp_daemon_output.output_fl { - Some(port) => port, - None => return Err(anyhow!("Failed to get pwsp-daemon output_fl")), - }; - let output_fr = match pwsp_daemon_output.output_fr { - Some(port) => port, - None => return Err(anyhow!("Failed to get pwsp-daemon output_fr")), - }; - let input_fl = match pwsp_daemon_input.input_fl { - Some(port) => port, - None => return Err(anyhow!("Failed to get pwsp-virtual-mic input_fl")), - }; - let input_fr = match pwsp_daemon_input.input_fr { - Some(port) => port, - None => return Err(anyhow!("Failed to get pwsp-virtual-mic input_fr")), - }; - - create_link(output_fl, output_fr, input_fl, input_fr).await + } + Ok(()) } pub async fn create_link(