Compare commits

..

7 Commits

Author SHA1 Message Date
Tarasov Aleksandr 949307fcf8 Update dependencies and change version to 1.7.2 (#63)
* deps: bump tokio to 1.52.1

* deps: bump clap to 4.6.1

* deps: cargo update

bitflags -> v2.11.1
core2 -
dary_heap -> v0.3.9
font-types -> v0.11.3
include-flate -> v0.3.3
include-flate-codegen -> v0.3.3
include-flate-compress -> v0.3.3
libc -> v0.2.185
libflate -> v2.3.0
libflate_lz77 -> v2.3.0
no_std_io2 + v0.9.3
portable-atomic-util -> v0.2.7
pxfm -> v0.1.29
rayon -> v1.12.0
uuid -> v1.23.1
webbrowser -> v1.2.1

* change version to 1.7.2
2026-04-17 14:42:59 +03:00
Tarasov Aleksandr 2a8fcca06b Fix virtual mic audio linking (#62)
* Fix virtual mic audio linking by managing it in AudioPlayer lifecycle

- Moved `link_player_to_virtual_mic` to `src/utils/pipewire.rs` and updated it to return a termination sender.
- Added `player_link_sender` to `AudioPlayer` to manage the PipeWire link between the daemon and the virtual mic.
- Integrated linking logic into `AudioPlayer::play` and `AudioPlayer::update` to ensure the link is established when audio starts playing.
- Ensured the link is terminated in `AudioPlayer::drop_stream` when the audio sink is closed.
- Removed redundant and potentially failing startup linking loop from the daemon.
- Fixed log spam by ensuring `link_player` is only attempted when necessary and errors are handled gracefully.
- Maintained compatibility with stable Rust by avoiding unstable features.

Co-authored-by: arabianq <55220741+arabianq@users.noreply.github.com>

* small refactor

* refactor

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-04-17 14:24:58 +03:00
Tarasov Aleksandr 5c4b8f4b45 refactor(gui): replace verbose key matching with egui native methods (#60)
Replaced the large match blocks in `chord_from_event` and `parse_chord`
with `egui::Key::name()` and `egui::Key::from_name()`. This drastically
reduces boilerplate code while maintaining the existing behavior that
strictly allows only single-character alphanumeric keys and 'F' keys for
chords.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-04-17 13:58:08 +03:00
Tarasov Aleksandr 70c7e3789b 🔒 Fix potential memory exhaustion in socket reads (#59)
Addresses a security vulnerability where the daemon or client could be
forced to allocate up to 10MB of memory per malformed socket message,
potentially leading to Out-Of-Memory (OOM) crashes.

Changes:
- Introduced a central `MAX_MESSAGE_SIZE` constant of 128KB in `src/types/socket.rs`.
- Enforced the 128KB limit on incoming requests in `src/bin/daemon.rs`.
- Enforced the 128KB limit on incoming responses in `src/utils/daemon.rs`.
- Preserved detailed `eprintln!` logging when messages are rejected.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-04-17 13:56:29 +03:00
Tarasov Aleksandr 5367a3daae version 1.7.1, update deps, update docs (#57)
* refactor: removed garbage

* change version to 1.7.1

* cargo fmt

* cargo update

* docs: add information about hotkeys to README

* docs: small refactor
2026-04-12 17:23:04 +03:00
Tarasov Aleksandr 42c0170044 fix: hotkeys setting from pwsp-gui (#56)
* refactor: do not overwrite incorrect hotkeys config

* fix: hotkeys not saved via pwsp-gui
2026-04-12 17:05:10 +03:00
RiDDiX cb56cb3a04 fix: drop audio stream when idle to allow system suspend (#54)
* fix: drop audio stream when idle to allow system suspend

The daemon kept its ALSA playback stream open permanently, which
PipeWire reported as a running Stream/Output/Audio node even with
no tracks playing. This prevented desktop environments from detecting
idle state and entering suspend.

- Make the audio sink on-demand: created when playback starts,
  dropped when all tracks finish
- Reduce player loop polling from 100ms to 2s when idle
- Throttle PipeWire device enumeration to every ~5s while playing
- Log only first and last link retry attempt instead of all 60
2026-04-12 00:42:10 +03:00
19 changed files with 536 additions and 579 deletions
Generated
+226 -230
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "pwsp" name = "pwsp"
version = "1.7.0" version = "1.7.2"
edition = "2024" edition = "2024"
authors = ["arabian"] authors = ["arabian"]
description = "PWSP lets you play audio files through your microphone. Has both CLI and GUI clients." description = "PWSP lets you play audio files through your microphone. Has both CLI and GUI clients."
@@ -12,13 +12,13 @@ keywords = ["soundpad", "pipewire", "linux", "cli", "gui"]
[dependencies] [dependencies]
tokio = { version = "1.51.1", features = ["full"] } tokio = { version = "1.52.1", features = ["full"] }
async-trait = "0.1.89" async-trait = "0.1.89"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149" serde_json = "1.0.149"
clap = { version = "4.6.0", default-features = false, features = [ clap = { version = "4.6.1", default-features = false, features = [
"std", "std",
"suggestions", "suggestions",
"help", "help",
+3 -1
View File
@@ -27,6 +27,8 @@ chats on platforms like **Discord, Zoom, or Teamspeak**.
* **Collapsible Audio Tracks**: You can collapse every audio track to save space. * **Collapsible Audio Tracks**: You can collapse every audio track to save space.
* **Drag and Drop Directories**: Reorder your sound directories easily using drag and drop. * **Drag and Drop Directories**: Reorder your sound directories easily using drag and drop.
* **Automatic Device Detection**: PWSP automatically detects when an input device is connected or disconnected and handles linking/unlinking. * **Automatic Device Detection**: PWSP automatically detects when an input device is connected or disconnected and handles linking/unlinking.
* **Global Hotkeys**: Assign custom keyboard shortcuts to any sound file (or action) to trigger playback instantly, even when the application is not in focus.
# **⚙️ How It Works** # **⚙️ How It Works**
@@ -38,8 +40,8 @@ three main components:
* Creating and managing virtual audio devices. * Creating and managing virtual audio devices.
* Linking these devices within the PipeWire graph. * Linking these devices within the PipeWire graph.
* Handling all audio playback. * Handling all audio playback.
* **UnixSocket**. This is how you interact with your sound collection, control playback, and configure settings.
* **pwsp-gui**: This is the graphical user interface. It acts as a client that communicates with pwsp-daemon via a * **pwsp-gui**: This is the graphical user interface. It acts as a client that communicates with pwsp-daemon via a
**UnixSocket**. This is how you interact with your sound collection, control playback, and configure settings.
* **pwsp-cli**: This is the command-line interface, also acting as a client. It provides a way to control the daemon * **pwsp-cli**: This is the command-line interface, also acting as a client. It provides a way to control the daemon
without a GUI, allowing for scripting or quick command-based actions. without a GUI, allowing for scripting or quick command-based actions.
+3 -3
View File
@@ -1,6 +1,6 @@
pkgbase = pwsp-bin pkgbase = pwsp-bin
pkgdesc = Lets you play audio files through your microphone (Pre-built binaries) pkgdesc = Lets you play audio files through your microphone (Pre-built binaries)
pkgver = 1.7.0 pkgver = 1.7.2
pkgrel = 2 pkgrel = 2
url = https://github.com/arabianq/pipewire-soundpad url = https://github.com/arabianq/pipewire-soundpad
arch = x86_64 arch = x86_64
@@ -9,8 +9,8 @@ depends = pipewire
depends = alsa-lib depends = alsa-lib
provides = pwsp provides = pwsp
conflicts = pwsp conflicts = pwsp
source = pwsp-bin-1.7.0.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.7.0/pwsp-v1.7.0-linux-x64.zip source = pwsp-bin-1.7.2.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.7.2/pwsp-v1.7.2-linux-x64.zip
source = pipewire-soundpad-1.7.0.tar.gz :: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.7.0.tar.gz source = pipewire-soundpad-1.7.2.tar.gz :: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.7.2.tar.gz
sha256sums = SKIP sha256sums = SKIP
sha256sums = SKIP sha256sums = SKIP
+1 -1
View File
@@ -1,7 +1,7 @@
# Maintainer: Alexander Tarasov <a.tevg@ya.ru> # Maintainer: Alexander Tarasov <a.tevg@ya.ru>
pkgname=pwsp-bin pkgname=pwsp-bin
_pkgname=pipewire-soundpad _pkgname=pipewire-soundpad
pkgver=1.7.0 pkgver=1.7.2
pkgrel=2 pkgrel=2
pkgdesc="Lets you play audio files through your microphone (Pre-built binaries)" pkgdesc="Lets you play audio files through your microphone (Pre-built binaries)"
arch=('x86_64') arch=('x86_64')
+2 -2
View File
@@ -1,6 +1,6 @@
pkgbase = pwsp pkgbase = pwsp
pkgdesc = Lets you play audio files through your microphone pkgdesc = Lets you play audio files through your microphone
pkgver = 1.7.0 pkgver = 1.7.2
pkgrel = 1 pkgrel = 1
url = https://github.com/arabianq/pipewire-soundpad url = https://github.com/arabianq/pipewire-soundpad
arch = any arch = any
@@ -10,7 +10,7 @@ pkgbase = pwsp
makedepends = cargo makedepends = cargo
makedepends = pipewire makedepends = pipewire
makedepends = alsa-lib makedepends = alsa-lib
source = https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.7.0.tar.gz source = https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.7.2.tar.gz
sha256sums = SKIP sha256sums = SKIP
pkgname = pwsp pkgname = pwsp
+1 -1
View File
@@ -1,7 +1,7 @@
# Maintainer: Alexander Tarasov <a.tevg@ya.ru> # Maintainer: Alexander Tarasov <a.tevg@ya.ru>
pkgsubn=pwsp pkgsubn=pwsp
pkgname=pwsp pkgname=pwsp
pkgver=1.7.0 pkgver=1.7.2
pkgrel=1 pkgrel=1
pkgdesc="Lets you play audio files through your microphone" pkgdesc="Lets you play audio files through your microphone"
arch=('any') arch=('any')
+1 -1
View File
@@ -4,7 +4,7 @@
%global cargo_install_lib 0 %global cargo_install_lib 0
Name: pwsp Name: pwsp
Version: 1.7.0 Version: 1.7.2
Release: %autorelease Release: %autorelease
Summary: Lets you play audio files through your microphone Summary: Lets you play audio files through your microphone
+19 -1
View File
@@ -70,8 +70,10 @@ enum Actions {
}, },
/// Play a sound by hotkey slot name /// Play a sound by hotkey slot name
PlayHotkey { slot: String }, PlayHotkey { slot: String },
/// Remove a hotkey slot /// Remove the hotkey slot
ClearHotkey { slot: String }, ClearHotkey { slot: String },
/// Clear the key chord for a hotkey slot
ClearHotkeyKey { slot: String },
} }
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
@@ -135,6 +137,12 @@ enum SetCommands {
Hotkey { slot: String, file_path: PathBuf }, Hotkey { slot: String, file_path: PathBuf },
/// Set the key chord for a hotkey slot (e.g. "Ctrl+Alt+1") /// Set the key chord for a hotkey slot (e.g. "Ctrl+Alt+1")
HotkeyKey { slot: String, key_chord: String }, HotkeyKey { slot: String, key_chord: String },
/// Atomically set the action and key chord for a hotkey slot
HotkeyActionAndKey {
slot: String,
action: String,
key_chord: String,
},
} }
#[tokio::main] #[tokio::main]
@@ -158,6 +166,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
Actions::ToggleLoop { id } => Request::toggle_loop(id), Actions::ToggleLoop { id } => Request::toggle_loop(id),
Actions::PlayHotkey { slot } => Request::play_hotkey(&slot), Actions::PlayHotkey { slot } => Request::play_hotkey(&slot),
Actions::ClearHotkey { slot } => Request::clear_hotkey(&slot), Actions::ClearHotkey { slot } => Request::clear_hotkey(&slot),
Actions::ClearHotkeyKey { slot } => Request::clear_hotkey_key(&slot),
}, },
Commands::Get { parameter } => match parameter { Commands::Get { parameter } => match parameter {
GetCommands::IsPaused => Request::get_is_paused(), GetCommands::IsPaused => Request::get_is_paused(),
@@ -183,6 +192,15 @@ async fn main() -> Result<(), Box<dyn Error>> {
SetCommands::HotkeyKey { slot, key_chord } => { SetCommands::HotkeyKey { slot, key_chord } => {
Request::set_hotkey_key(&slot, &key_chord) Request::set_hotkey_key(&slot, &key_chord)
} }
SetCommands::HotkeyActionAndKey {
slot,
action,
key_chord,
} => Request::set_hotkey_action_and_key(
&slot,
&serde_json::from_str::<Request>(&action)?,
&key_chord,
),
}, },
}; };
+17 -26
View File
@@ -1,10 +1,10 @@
use pwsp::{ use pwsp::{
types::socket::{Request, Response}, types::socket::{MAX_MESSAGE_SIZE, Request, Response},
utils::{ utils::{
commands::parse_command, commands::parse_command,
daemon::{ daemon::{
create_runtime_dir, get_audio_player, get_daemon_config, get_runtime_dir, create_runtime_dir, get_audio_player, get_daemon_config, get_runtime_dir,
is_daemon_running, link_player_to_virtual_mic, is_daemon_running,
}, },
global_hotkeys::start_global_hotkey_listener, global_hotkeys::start_global_hotkey_listener,
pipewire::create_virtual_mic, pipewire::create_virtual_mic,
@@ -32,21 +32,6 @@ async fn main() -> Result<(), Box<dyn Error>> {
eprintln!("Failed to initialize audio player: {}", err); eprintln!("Failed to initialize audio player: {}", err);
} // Initialize audio player } // Initialize audio player
tokio::spawn(async {
let max_retries = 60;
for i in 0..=max_retries {
match link_player_to_virtual_mic().await {
Ok(_) => {
println!("Successfully linked player to virtual mic.");
break;
}
Err(e) => println!("{e}\t{i}/{max_retries}"),
}
sleep(Duration::from_millis(1000)).await;
}
});
tokio::spawn(async { tokio::spawn(async {
start_global_hotkey_listener().await; start_global_hotkey_listener().await;
}); });
@@ -105,7 +90,7 @@ async fn commands_loop(listener: UnixListener) -> Result<(), Box<dyn Error>> {
let request_len = u32::from_le_bytes(len_bytes) as usize; let request_len = u32::from_le_bytes(len_bytes) as usize;
if request_len > 10 * 1024 * 1024 { if request_len > MAX_MESSAGE_SIZE {
eprintln!( eprintln!(
"Failed to read message from client: request too large ({} bytes)!", "Failed to read message from client: request too large ({} bytes)!",
request_len request_len
@@ -174,19 +159,25 @@ async fn commands_loop(listener: UnixListener) -> Result<(), Box<dyn Error>> {
} }
async fn player_loop() { async fn player_loop() {
let mut device_check_counter: u32 = 0;
loop { loop {
match get_audio_player().await { let is_idle = match get_audio_player().await {
Ok(player_mutex) => { Ok(player_mutex) => {
let mut audio_player = player_mutex.lock().await; let mut audio_player = player_mutex.lock().await;
audio_player.update().await; let check_devices = device_check_counter == 0;
} audio_player.update(check_devices).await;
Err(_err) => { audio_player.tracks.is_empty()
// To avoid spamming logs every 100ms when audio player fails to init
// we can just sleep, or you might prefer to print the error.
// Assuming it failed to initialize, no player update is possible.
}
} }
Err(_err) => true,
};
if is_idle {
device_check_counter = 0;
sleep(Duration::from_secs(2)).await;
} else {
// Check devices every ~5 seconds (50 * 100ms) while playing
device_check_counter = (device_check_counter + 1) % 50;
sleep(Duration::from_millis(100)).await; sleep(Duration::from_millis(100)).await;
} }
} }
}
+29 -105
View File
@@ -7,57 +7,15 @@ use std::path::PathBuf;
/// Convert an egui Key + Modifiers to a normalized chord string like "Ctrl+Shift+A". /// Convert an egui Key + Modifiers to a normalized chord string like "Ctrl+Shift+A".
fn chord_from_event(modifiers: &Modifiers, key: &Key) -> Option<String> { fn chord_from_event(modifiers: &Modifiers, key: &Key) -> Option<String> {
let key_name = match key { let key_name = key.name();
Key::A => "A", let is_valid = (key_name.len() == 1
Key::B => "B", && key_name.chars().next().unwrap().is_ascii_alphanumeric())
Key::C => "C", || (key_name.starts_with('F')
Key::D => "D", && key_name.len() > 1
Key::E => "E", && key_name[1..].chars().all(|c| c.is_ascii_digit()));
Key::F => "F", if !is_valid {
Key::G => "G", return None;
Key::H => "H", }
Key::I => "I",
Key::J => "J",
Key::K => "K",
Key::L => "L",
Key::M => "M",
Key::N => "N",
Key::O => "O",
Key::P => "P",
Key::Q => "Q",
Key::R => "R",
Key::S => "S",
Key::T => "T",
Key::U => "U",
Key::V => "V",
Key::W => "W",
Key::X => "X",
Key::Y => "Y",
Key::Z => "Z",
Key::Num0 => "0",
Key::Num1 => "1",
Key::Num2 => "2",
Key::Num3 => "3",
Key::Num4 => "4",
Key::Num5 => "5",
Key::Num6 => "6",
Key::Num7 => "7",
Key::Num8 => "8",
Key::Num9 => "9",
Key::F1 => "F1",
Key::F2 => "F2",
Key::F3 => "F3",
Key::F4 => "F4",
Key::F5 => "F5",
Key::F6 => "F6",
Key::F7 => "F7",
Key::F8 => "F8",
Key::F9 => "F9",
Key::F10 => "F10",
Key::F11 => "F11",
Key::F12 => "F12",
_ => return None,
};
// Require at least one modifier for hotkey chords (ignoring command/Super due to Wayland/Niri bug) // Require at least one modifier for hotkey chords (ignoring command/Super due to Wayland/Niri bug)
if !modifiers.ctrl && !modifiers.alt && !modifiers.shift { if !modifiers.ctrl && !modifiers.alt && !modifiers.shift {
@@ -100,57 +58,18 @@ pub fn parse_chord(chord: &str) -> Option<(Modifiers, Key)> {
} }
} }
let key = match parts[parts.len() - 1] { let key_name = parts[parts.len() - 1];
"A" => Key::A, let is_valid = (key_name.len() == 1
"B" => Key::B, && key_name.chars().next().unwrap().is_ascii_alphanumeric())
"C" => Key::C, || (key_name.starts_with('F')
"D" => Key::D, && key_name.len() > 1
"E" => Key::E, && key_name[1..].chars().all(|c| c.is_ascii_digit()));
"F" => Key::F,
"G" => Key::G, if !is_valid {
"H" => Key::H, return None;
"I" => Key::I, }
"J" => Key::J,
"K" => Key::K, let key = Key::from_name(key_name)?;
"L" => Key::L,
"M" => Key::M,
"N" => Key::N,
"O" => Key::O,
"P" => Key::P,
"Q" => Key::Q,
"R" => Key::R,
"S" => Key::S,
"T" => Key::T,
"U" => Key::U,
"V" => Key::V,
"W" => Key::W,
"X" => Key::X,
"Y" => Key::Y,
"Z" => Key::Z,
"0" => Key::Num0,
"1" => Key::Num1,
"2" => Key::Num2,
"3" => Key::Num3,
"4" => Key::Num4,
"5" => Key::Num5,
"6" => Key::Num6,
"7" => Key::Num7,
"8" => Key::Num8,
"9" => Key::Num9,
"F1" => Key::F1,
"F2" => Key::F2,
"F3" => Key::F3,
"F4" => Key::F4,
"F5" => Key::F5,
"F6" => Key::F6,
"F7" => Key::F7,
"F8" => Key::F8,
"F9" => Key::F9,
"F10" => Key::F10,
"F11" => Key::F11,
"F12" => Key::F12,
_ => return None,
};
Some((modifiers, key)) Some((modifiers, key))
} }
@@ -221,16 +140,21 @@ impl SoundpadGui {
.to_string_lossy() .to_string_lossy()
.to_string(); .to_string();
let action = Request::play(&file_path.to_string_lossy(), false); let action = Request::play(&file_path.to_string_lossy(), false);
make_request_async(Request::set_hotkey_action(&slot_name, &action));
make_request_async(Request::set_hotkey_key(&slot_name, &chord)); make_request_async(Request::set_hotkey_action_and_key(
&slot_name, &action, &chord,
));
self.app_state self.app_state
.hotkey_config .hotkey_config
.set_slot(slot_name.clone(), action); .set_slot(slot_name.clone(), action);
self.app_state self.app_state
.hotkey_config .hotkey_config
.set_key_chord(&slot_name, Some(chord)); .set_key_chord(&slot_name, Some(chord.clone()));
} }
self.app_state.hotkey_capture_active = false; self.app_state.hotkey_capture_active = false;
self.app_state.assigning_hotkey_slot = None;
self.app_state.assigning_hotkey_for_file = None;
} }
return; return;
} }
+73 -14
View File
@@ -2,7 +2,7 @@ use crate::{
types::pipewire::{DeviceType, Terminate}, types::pipewire::{DeviceType, Terminate},
utils::{ utils::{
daemon::get_daemon_config, daemon::get_daemon_config,
pipewire::{create_link, get_device}, pipewire::{create_link, get_device, link_player_to_virtual_mic},
}, },
}; };
use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source}; use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source};
@@ -53,11 +53,12 @@ pub struct PlayingSound {
} }
pub struct AudioPlayer { pub struct AudioPlayer {
pub stream_handle: MixerDeviceSink, stream_handle: Option<MixerDeviceSink>,
pub tracks: HashMap<u32, PlayingSound>, pub tracks: HashMap<u32, PlayingSound>,
pub next_id: u32, pub next_id: u32,
input_link_sender: Option<pipewire::channel::Sender<Terminate>>, input_link_sender: Option<pipewire::channel::Sender<Terminate>>,
player_link_sender: Option<pipewire::channel::Sender<Terminate>>,
pub input_device_name: Option<String>, pub input_device_name: Option<String>,
pub volume: f32, // Master volume pub volume: f32, // Master volume
@@ -68,14 +69,13 @@ impl AudioPlayer {
let daemon_config = get_daemon_config(); let daemon_config = get_daemon_config();
let default_volume = daemon_config.default_volume.unwrap_or(1.0); let default_volume = daemon_config.default_volume.unwrap_or(1.0);
let stream_handle = DeviceSinkBuilder::open_default_sink()?;
let mut audio_player = AudioPlayer { let mut audio_player = AudioPlayer {
stream_handle, stream_handle: None,
tracks: HashMap::new(), tracks: HashMap::new(),
next_id: 1, next_id: 1,
input_link_sender: None, input_link_sender: None,
player_link_sender: None,
input_device_name: daemon_config.default_input_name.clone(), input_device_name: daemon_config.default_input_name.clone(),
volume: default_volume, volume: default_volume,
@@ -88,16 +88,56 @@ impl AudioPlayer {
Ok(audio_player) Ok(audio_player)
} }
fn ensure_stream(&mut self) -> Result<&MixerDeviceSink, Box<dyn Error>> {
if self.stream_handle.is_none() {
let mut sink = DeviceSinkBuilder::open_default_sink()?;
sink.log_on_drop(false);
self.stream_handle = Some(sink);
}
Ok(self.stream_handle.as_ref().unwrap())
}
fn drop_stream(&mut self) {
if self.stream_handle.is_some() {
self.stream_handle = None;
self.abort_player_link_thread();
}
}
fn abort_link_thread(&mut self) { fn abort_link_thread(&mut self) {
if let Some(sender) = &self.input_link_sender { if let Some(sender) = &self.input_link_sender {
match sender.send(Terminate {}) { if let Ok(_) = sender.send(Terminate {}) {
Ok(_) => { println!("Sent terminate signal to input link thread");
println!("Sent terminate signal to link thread");
self.input_link_sender = None; self.input_link_sender = None;
} else {
eprintln!("Failed to send terminate signal to input link thread");
} }
Err(_) => eprintln!("Failed to send terminate signal to link thread"),
} }
} }
fn abort_player_link_thread(&mut self) {
if let Some(sender) = &self.player_link_sender {
if let Ok(_) = sender.send(Terminate {}) {
println!("Sent terminate signal to player link thread");
self.player_link_sender = None;
} else {
eprintln!("Failed to send terminate signal to player link thread");
}
}
}
async fn link_player(&mut self) -> Result<(), Box<dyn Error>> {
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<(), Box<dyn Error>> { async fn link_devices(&mut self) -> Result<(), Box<dyn Error>> {
@@ -179,6 +219,9 @@ impl AudioPlayer {
} else { } else {
self.tracks.clear(); self.tracks.clear();
} }
if self.tracks.is_empty() {
self.drop_stream();
}
} }
pub fn is_paused(&self) -> bool { pub fn is_paused(&self) -> bool {
@@ -299,12 +342,16 @@ impl AudioPlayer {
self.tracks.clear(); self.tracks.clear();
} }
self.ensure_stream()?;
self.link_player().await.ok();
let id = self.next_id; let id = self.next_id;
self.next_id += 1; self.next_id += 1;
let duration = source.total_duration().map(|d| d.as_secs_f32()); let duration = source.total_duration().map(|d| d.as_secs_f32());
let sink = Player::connect_new(self.stream_handle.mixer()); let mixer = self.stream_handle.as_ref().unwrap().mixer();
let sink = Player::connect_new(mixer);
sink.set_volume(self.volume); // Default volume is 1.0 * master sink.set_volume(self.volume); // Default volume is 1.0 * master
sink.append(source); sink.append(source);
sink.play(); sink.play();
@@ -358,11 +405,12 @@ impl AudioPlayer {
tracks tracks
} }
pub async fn update(&mut self) { pub async fn update(&mut self, check_devices: bool) {
if check_devices {
if let Some(input_device_name) = &self.input_device_name { if let Some(input_device_name) = &self.input_device_name {
// Unlink devices if selected input device was removed // Unlink devices if selected input device was removed
if self.input_link_sender.is_some() && get_device(input_device_name).await.is_err() { if self.input_link_sender.is_some() && get_device(input_device_name).await.is_err()
// Selected input device was removed {
eprintln!( eprintln!(
"Selected input device {} was removed, unlinking devices", "Selected input device {} was removed, unlinking devices",
input_device_name input_device_name
@@ -375,6 +423,11 @@ impl AudioPlayer {
} }
} }
if self.stream_handle.is_some() && self.player_link_sender.is_none() {
self.link_player().await.ok();
}
}
// Handle looped sounds // Handle looped sounds
let mut restarts = vec![]; let mut restarts = vec![];
@@ -402,16 +455,22 @@ impl AudioPlayer {
} }
for handle in restart_futures { for handle in restart_futures {
if let Ok(Some((id, source))) = handle.await { if let Ok(res) = handle.await {
if let Some((id, source)) = res {
if let Some(sound) = self.tracks.get_mut(&id) { if let Some(sound) = self.tracks.get_mut(&id) {
sound.sink.append(source); sound.sink.append(source);
sound.sink.play(); sound.sink.play();
} }
} }
} }
}
self.tracks self.tracks
.retain(|_, sound| !sound.sink.empty() || sound.looped); .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<(), Box<dyn Error>> { pub async fn set_current_input_device(&mut self, name: &str) -> Result<(), Box<dyn Error>> {
+63 -17
View File
@@ -99,22 +99,28 @@ pub struct SetHotkeyCommand {
pub file_path: Option<PathBuf>, pub file_path: Option<PathBuf>,
} }
pub struct SetHotkeyActionCommand {
pub slot: Option<String>,
pub action: Option<Request>,
}
pub struct SetHotkeyKeyCommand { pub struct SetHotkeyKeyCommand {
pub slot: Option<String>, pub slot: Option<String>,
pub key_chord: Option<String>, pub key_chord: Option<String>,
} }
pub struct ClearHotkeyCommand { pub struct SetHotkeyActionAndKeyCommand {
pub slot: Option<String>, pub slot: Option<String>,
pub action: Option<Request>,
pub key_chord: Option<String>,
} }
pub struct PlayHotkeyCommand { pub struct PlayHotkeyCommand {
pub slot: Option<String>, pub slot: Option<String>,
} }
pub struct SetHotkeyActionCommand { pub struct ClearHotkeyCommand {
pub slot: Option<String>, pub slot: Option<String>,
pub action: Option<Request>,
} }
pub struct ClearHotkeyKeyCommand { pub struct ClearHotkeyKeyCommand {
@@ -553,6 +559,30 @@ impl Executable for SetHotkeyCommand {
} }
} }
#[async_trait]
impl Executable for SetHotkeyActionCommand {
async fn execute(&self) -> Response {
let Some(slot) = &self.slot else {
return Response::new(false, "Missing slot name");
};
let Some(action) = &self.action else {
return Response::new(false, "Missing or invalid action");
};
let mut config = match HotkeyConfig::load() {
Ok(c) => c,
Err(err) => return Response::new(false, format!("Failed to load hotkeys: {}", err)),
};
config.set_slot(slot.clone(), action.clone());
match config.save() {
Ok(_) => Response::new(true, format!("Hotkey slot '{}' set", slot)),
Err(err) => Response::new(false, format!("Failed to save hotkeys: {}", err)),
}
}
}
#[async_trait] #[async_trait]
impl Executable for SetHotkeyKeyCommand { impl Executable for SetHotkeyKeyCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
@@ -583,24 +613,41 @@ impl Executable for SetHotkeyKeyCommand {
} }
#[async_trait] #[async_trait]
impl Executable for ClearHotkeyCommand { impl Executable for SetHotkeyActionAndKeyCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let Some(slot) = &self.slot else { let Some(slot) = &self.slot else {
return Response::new(false, "Missing slot name"); return Response::new(false, "Missing slot name");
}; };
let Some(action) = &self.action else {
return Response::new(false, "Missing or invalid action");
};
let Some(key_chord) = &self.key_chord else {
return Response::new(false, "Missing key chord");
};
let mut config = match HotkeyConfig::load() { let mut config = match HotkeyConfig::load() {
Ok(c) => c, Ok(c) => c,
Err(err) => return Response::new(false, format!("Failed to load hotkeys: {}", err)), Err(err) => return Response::new(false, format!("Failed to load hotkeys: {}", err)),
}; };
if config.remove_slot(slot) { // Set the action and then the key chord
match config.save() { config.set_slot(slot.clone(), action.clone());
Ok(_) => Response::new(true, format!("Hotkey slot '{}' cleared", slot)), if !config.set_key_chord(slot, Some(key_chord.clone())) {
Err(err) => Response::new(false, format!("Failed to save hotkeys: {}", err)), return Response::new(
false,
format!("Slot '{}' not found after setting action", slot),
);
} }
} else {
Response::new(false, format!("Slot '{}' not found", slot)) match config.save() {
Ok(_) => Response::new(
true,
format!(
"Hotkey slot '{}' set with action and key chord '{}'",
slot, key_chord
),
),
Err(err) => Response::new(false, format!("Failed to save hotkeys: {}", err)),
} }
} }
} }
@@ -632,26 +679,25 @@ impl Executable for PlayHotkeyCommand {
} }
#[async_trait] #[async_trait]
impl Executable for SetHotkeyActionCommand { impl Executable for ClearHotkeyCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let Some(slot) = &self.slot else { let Some(slot) = &self.slot else {
return Response::new(false, "Missing slot name"); return Response::new(false, "Missing slot name");
}; };
let Some(action) = &self.action else {
return Response::new(false, "Missing or invalid action");
};
let mut config = match HotkeyConfig::load() { let mut config = match HotkeyConfig::load() {
Ok(c) => c, Ok(c) => c,
Err(err) => return Response::new(false, format!("Failed to load hotkeys: {}", err)), Err(err) => return Response::new(false, format!("Failed to load hotkeys: {}", err)),
}; };
config.set_slot(slot.clone(), action.clone()); if config.remove_slot(slot) {
match config.save() { match config.save() {
Ok(_) => Response::new(true, format!("Hotkey slot '{}' set", slot)), Ok(_) => Response::new(true, format!("Hotkey slot '{}' cleared", slot)),
Err(err) => Response::new(false, format!("Failed to save hotkeys: {}", err)), Err(err) => Response::new(false, format!("Failed to save hotkeys: {}", err)),
} }
} else {
Response::new(false, format!("Slot '{}' not found", slot))
}
} }
} }
+1 -2
View File
@@ -98,7 +98,6 @@ impl GuiConfig {
pub struct HotkeySlot { pub struct HotkeySlot {
pub slot: String, pub slot: String,
pub action: Request, pub action: Request,
#[serde(skip_serializing_if = "Option::is_none")]
pub key_chord: Option<String>, pub key_chord: Option<String>,
} }
@@ -121,7 +120,7 @@ impl HotkeyConfig {
let bytes = fs::read(&path)?; let bytes = fs::read(&path)?;
match serde_json::from_slice::<HotkeyConfig>(&bytes) { match serde_json::from_slice::<HotkeyConfig>(&bytes) {
Ok(config) => Ok(config), Ok(config) => Ok(config),
Err(_) => Ok(HotkeyConfig::default()), Err(e) => Err(e.into()),
} }
} }
+14
View File
@@ -1,6 +1,8 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
pub const MAX_MESSAGE_SIZE: usize = 128 * 1024;
#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Request { pub struct Request {
pub name: String, pub name: String,
@@ -208,6 +210,18 @@ impl Request {
pub fn clear_hotkey_key(slot: &str) -> Self { pub fn clear_hotkey_key(slot: &str) -> Self {
Request::new("clear_hotkey_key", vec![("slot", slot)]) Request::new("clear_hotkey_key", vec![("slot", slot)])
} }
pub fn set_hotkey_action_and_key(slot: &str, action: &Request, key_chord: &str) -> Self {
let action_json = serde_json::to_string(action).unwrap_or_default();
Request::new(
"set_hotkey_action_and_key",
vec![
("slot", slot),
("action", &action_json),
("key_chord", key_chord),
],
)
}
} }
#[derive(Default, Debug, Clone, Serialize, Deserialize)] #[derive(Default, Debug, Clone, Serialize, Deserialize)]
+13
View File
@@ -106,6 +106,19 @@ pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> {
let slot = request.args.get("slot").cloned(); let slot = request.args.get("slot").cloned();
Some(Box::new(ClearHotkeyKeyCommand { slot })) Some(Box::new(ClearHotkeyKeyCommand { slot }))
} }
"set_hotkey_action_and_key" => {
let slot = request.args.get("slot").cloned();
let action = request
.args
.get("action")
.and_then(|s| serde_json::from_str::<Request>(s).ok());
let key_chord = request.args.get("key_chord").cloned();
Some(Box::new(SetHotkeyActionAndKeyCommand {
slot,
action,
key_chord,
}))
}
_ => None, _ => None,
} }
} }
+10 -40
View File
@@ -2,10 +2,10 @@ use crate::{
types::{ types::{
audio_player::AudioPlayer, audio_player::AudioPlayer,
config::DaemonConfig, config::DaemonConfig,
socket::{Request, Response}, socket::{MAX_MESSAGE_SIZE, Request, Response},
}, },
utils::pipewire::{create_link, get_device},
}; };
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf; use std::path::PathBuf;
use std::{error::Error, fs}; use std::{error::Error, fs};
@@ -38,44 +38,6 @@ pub fn get_daemon_config() -> DaemonConfig {
}) })
} }
pub async fn link_player_to_virtual_mic() -> Result<(), Box<dyn Error>> {
let pwsp_daemon_output;
if let Ok(device) = get_device("pwsp-daemon").await {
pwsp_daemon_output = device;
} else {
return Err(
"Could not find alsa_playback.pwsp-daemon device, skipping device linking".into(),
);
}
let pwsp_daemon_input;
if let Ok(device) = get_device("pwsp-virtual-mic").await {
pwsp_daemon_input = device;
} else {
return Err("Could not find pwsp-virtual-mic device, skipping device linking".into());
}
let output_fl = pwsp_daemon_output
.clone()
.output_fl
.expect("Failed to get pwsp-daemon output_fl");
let output_fr = pwsp_daemon_output
.clone()
.output_fr
.expect("Failed to get pwsp-daemon output_fl");
let input_fl = pwsp_daemon_input
.clone()
.input_fl
.expect("Failed to get pwsp-daemon input_fl");
let input_fr = pwsp_daemon_input
.clone()
.input_fr
.expect("Failed to get pwsp-daemon input_fr");
create_link(output_fl, output_fr, input_fl, input_fr)?;
Ok(())
}
pub fn get_runtime_dir() -> PathBuf { pub fn get_runtime_dir() -> PathBuf {
dirs::runtime_dir().unwrap_or(PathBuf::from("/run/pwsp")) dirs::runtime_dir().unwrap_or(PathBuf::from("/run/pwsp"))
} }
@@ -135,6 +97,14 @@ pub async fn make_request(request: Request) -> Result<Response, Box<dyn Error +
} }
let response_len = u32::from_le_bytes(len_bytes) as usize; let response_len = u32::from_le_bytes(len_bytes) as usize;
if response_len > MAX_MESSAGE_SIZE {
eprintln!(
"Failed to read response from daemon: response too large ({} bytes)!",
response_len
);
return Err("Response too large".into());
}
let mut buffer = vec![0u8; response_len]; let mut buffer = vec![0u8; response_len];
if stream.read_exact(&mut buffer).await.is_err() { if stream.read_exact(&mut buffer).await.is_err() {
return Err("Failed to read response".into()); return Err("Failed to read response".into());
+38
View File
@@ -258,6 +258,44 @@ pub fn create_virtual_mic() -> Result<pipewire::channel::Sender<Terminate>, Box<
Ok(pw_sender) Ok(pw_sender)
} }
pub async fn link_player_to_virtual_mic()
-> Result<pipewire::channel::Sender<Terminate>, Box<dyn Error>> {
let pwsp_daemon_output = match get_device("pwsp-daemon").await {
Ok(device) => device,
Err(_) => {
return Err(
"Could not find alsa_playback.pwsp-daemon device, skipping device linking".into(),
);
}
};
let pwsp_daemon_input = match get_device("pwsp-virtual-mic").await {
Ok(device) => device,
Err(_) => {
return Err("Could not find pwsp-virtual-mic device, skipping device linking".into());
}
};
let output_fl = match pwsp_daemon_output.output_fl {
Some(port) => port,
None => return Err("Failed to get pwsp-daemon output_fl".into()),
};
let output_fr = match pwsp_daemon_output.output_fr {
Some(port) => port,
None => return Err("Failed to get pwsp-daemon output_fr".into()),
};
let input_fl = match pwsp_daemon_input.input_fl {
Some(port) => port,
None => return Err("Failed to get pwsp-virtual-mic input_fl".into()),
};
let input_fr = match pwsp_daemon_input.input_fr {
Some(port) => port,
None => return Err("Failed to get pwsp-virtual-mic input_fr".into()),
};
create_link(output_fl, output_fr, input_fl, input_fr)
}
pub fn create_link( pub fn create_link(
output_fl: Port, output_fl: Port,
output_fr: Port, output_fr: Port,
-113
View File
@@ -1,113 +0,0 @@
use rodio::{DeviceSinkBuilder, MixerDeviceSink};
use std::fs;
use std::path::Path;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::Mutex;
// A mock of AudioPlayer to isolate the play method's blocking behavior.
// We only implement the relevant part of the logic that needs optimizing.
pub struct AudioPlayerMock {
pub tracks: std::collections::HashMap<u32, ()>,
pub next_id: u32,
pub volume: f32,
}
impl AudioPlayerMock {
pub fn new() -> Self {
AudioPlayerMock {
tracks: std::collections::HashMap::new(),
next_id: 1,
volume: 1.0,
}
}
pub async fn play(
&mut self,
file_path: &Path,
concurrent: bool,
) -> Result<u32, Box<dyn std::error::Error + Send + Sync>> {
if !file_path.exists() {
return Err(format!("File does not exist: {}", file_path.display()).into());
}
let path_buf = file_path.to_path_buf();
let _file = tokio::task::spawn_blocking(move || {
// Simulate some blocking work like Decoder::try_from which reads file headers
let _f = fs::File::open(&path_buf).unwrap();
// Emulate the actual time spent reading file and decoding header (which is what Decoder::try_from does)
std::thread::sleep(std::time::Duration::from_millis(100)); // Simulate slow disk/decode
_f
})
.await?;
if !concurrent {
self.tracks.clear();
}
let id = self.next_id;
self.next_id += 1;
self.tracks.insert(id, ());
Ok(id)
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_performance_blocking() {
println!("Setting up mock environment...");
// Create a dummy file to read
let test_file = Path::new("test_dummy.wav");
fs::write(test_file, "dummy content").unwrap();
let player = Arc::new(Mutex::new(AudioPlayerMock::new()));
println!("Starting benchmark for synchronous behavior in async fn...");
// We launch a background task that measures event loop latency.
// If the main tasks block the executor, this task will suffer high latency.
let latency_task = tokio::spawn(async {
let mut max_latency = std::time::Duration::from_secs(0);
for _ in 0..50 {
let start = Instant::now();
tokio::task::yield_now().await;
let elapsed = start.elapsed();
if elapsed > max_latency {
max_latency = elapsed;
}
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
max_latency
});
// Launch multiple play operations
let mut tasks = vec![];
let start_time = Instant::now();
for _ in 0..10 {
let player_clone = Arc::clone(&player);
let file_path = test_file.to_path_buf();
tasks.push(tokio::spawn(async move {
let mut p = player_clone.lock().await;
let _ = p.play(&file_path, true).await;
}));
}
// Wait for all tasks to finish
for t in tasks {
let _ = t.await;
}
let total_time = start_time.elapsed();
let max_latency = latency_task.await.unwrap();
println!("Total execution time: {:?}", total_time);
println!(
"Max event loop latency (blocking indicator): {:?}",
max_latency
);
// Cleanup
fs::remove_file(test_file).unwrap();
}