Compare commits

..
48 Commits
Author SHA1 Message Date
arabianq cc152de59f change version to 1.12.2 2026-07-02 11:11:50 +03:00
arabianq 501ff9bd64 deps(flatpak): update cargo-sources.json 2026-07-01 18:43:49 +03:00
arabianq f83e18b800 cargo update 2026-07-01 18:03:57 +03:00
arabianq ba920473c7 refactor: deprecated CentralPanel::show_inside -> show 2026-07-01 18:03:34 +03:00
arabianq dfb5985e4a fix: egui 0.35.0 changes 2026-07-01 18:03:00 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Tarasov Aleksandr
52dc0e3a09 chore(deps): bump egui_dnd from 0.15.0 to 0.16.0 (#145)
Bumps [egui_dnd](https://github.com/lucasmerlin/hello_egui) from 0.15.0 to 0.16.0.
- [Release notes](https://github.com/lucasmerlin/hello_egui/releases)
- [Changelog](https://github.com/lucasmerlin/hello_egui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lucasmerlin/hello_egui/compare/egui_dnd-v0.15.0...egui_dnd-v0.16.0)

---
updated-dependencies:
- dependency-name: egui_dnd
  dependency-version: 0.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Tarasov Aleksandr <55220741+arabianq@users.noreply.github.com>
2026-07-01 17:57:57 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
4ce16d5301 chore(deps): bump egui_material_icons from 0.6.0 to 0.7.0 (#142)
Bumps [egui_material_icons](https://github.com/lucasmerlin/hello_egui) from 0.6.0 to 0.7.0.
- [Release notes](https://github.com/lucasmerlin/hello_egui/releases)
- [Changelog](https://github.com/lucasmerlin/hello_egui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lucasmerlin/hello_egui/compare/egui_material_icons-v0.6.0...egui_material_icons-v0.7.0)

---
updated-dependencies:
- dependency-name: egui_material_icons
  dependency-version: 0.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-01 17:55:46 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
48cda995ee chore(deps): bump egui_extras from 0.34.3 to 0.35.0 (#144)
Bumps [egui_extras](https://github.com/emilk/egui) from 0.34.3 to 0.35.0.
- [Release notes](https://github.com/emilk/egui/releases)
- [Changelog](https://github.com/emilk/egui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/emilk/egui/compare/0.34.3...0.35.0)

---
updated-dependencies:
- dependency-name: egui_extras
  dependency-version: 0.35.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-01 17:48:00 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
7950f75c07 chore(deps): bump eframe from 0.34.3 to 0.35.0 (#143)
Bumps [eframe](https://github.com/emilk/egui) from 0.34.3 to 0.35.0.
- [Release notes](https://github.com/emilk/egui/releases)
- [Changelog](https://github.com/emilk/egui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/emilk/egui/compare/0.34.3...0.35.0)

---
updated-dependencies:
- dependency-name: eframe
  dependency-version: 0.35.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-01 17:43:56 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
38ad8d5bc2 chore(deps): bump egui from 0.34.3 to 0.35.0 (#141)
Bumps [egui](https://github.com/emilk/egui) from 0.34.3 to 0.35.0.
- [Release notes](https://github.com/emilk/egui/releases)
- [Changelog](https://github.com/emilk/egui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/emilk/egui/compare/0.34.3...0.35.0)

---
updated-dependencies:
- dependency-name: egui
  dependency-version: 0.35.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-01 17:41:24 +03:00
arabianq 347dee713c deps: update cargo-sources.json [no ci] 2026-06-26 00:03:42 +03:00
arabianq e6e6293e20 change version to 1.12.1 [no ci] 2026-06-25 23:57:52 +03:00
arabianq a1e53aa536 deps: update cargo-sources.json 2026-06-25 23:43:08 +03:00
arabianq bff50b99f1 deps: update rodio 2026-06-25 23:35:09 +03:00
arabianq 09b86dc108 cargo update 2026-06-25 23:32:58 +03:00
arabianq 30070c439b add more options to FUNDING.yml 2026-06-25 23:30:28 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>arabianq
44c3a5e3a7 chore(deps): bump itertools from 0.14.0 to 0.15.0 (#140)
* chore(deps): bump itertools from 0.14.0 to 0.15.0

Bumps [itertools](https://github.com/rust-itertools/itertools) from 0.14.0 to 0.15.0.
- [Changelog](https://github.com/rust-itertools/itertools/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-itertools/itertools/compare/v0.14.0...v0.15.0)

---
updated-dependencies:
- dependency-name: itertools
  dependency-version: 0.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* deps: update cargo-sources.json

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: arabian <a.tevg@ya.ru>
2026-06-25 23:23:37 +03:00
Tarasov AleksandrGitHubgoogle-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
9a0bc92222 refactor(gui): pre-filter directory contents by supported extension (#136)
*  [performance] pre-filter directory contents by supported extension

💡 **What:** Moved the check for supported audio file extensions from the GUI rendering loop into the directory read/caching layer.
🎯 **Why:** The file extension string parsing and check was executing on every frame of the render loop for every file listed, causing unnecessary CPU overhead. By caching the pre-filtered items, we only execute the check once per directory load.
📊 **Measured Improvement:** In a micro-benchmark simulating 10k files (with 50% matching extensions), the unoptimized loop took ~14.2ms to execute, while the optimized loop takes ~5.8ms. This yields a ~59% speed improvement in the iteration logic over the baseline.

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

*  [performance] pre-filter directory contents by supported extension

💡 **What:** Moved the check for supported audio file extensions from the GUI rendering loop into the directory read/caching layer.
🎯 **Why:** The file extension string parsing and check was executing on every frame of the render loop for every file listed, causing unnecessary CPU overhead. By caching the pre-filtered items, we only execute the check once per directory load.
📊 **Measured Improvement:** In a micro-benchmark simulating 10k files (with 50% matching extensions), the unoptimized loop took ~14.2ms to execute, while the optimized loop takes ~5.8ms. This yields a ~59% speed improvement in the iteration logic over the baseline.

Flatpak cargo-sources.json regenerated to include criterion dependency to fix CI.

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

* chore: remove criterion bench dependency from gui app

This removes the `bench.rs` and `criterion` dependencies, which failed to compile in offline flatpak builds.

CI build is now fixed.

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

* cargo fmt

* deps: update cargo-sources.json

* chore: update cargo lock and flatpak sources

After removing criterion, the cargo lockfile and flatpak sources have been refreshed to properly remove the offline build dependencies issue.

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

* Revert "chore: update cargo lock and flatpak sources"

This reverts commit 27379d8e78.

[no ci]

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-06-25 23:23:14 +03:00
Tarasov AleksandrGitHubgoogle-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
3f85994f83 refactor(daemon): Consolidate PipeWire operations into a single background actor
Replaces the previous approach of spawning new PipeWire main loops and
awaiting a 100ms timeout for every `get_all_devices` call. Instead,
this introduces a `PipeWireManager` which maintains a single, persistent
background thread with a PipeWire main loop and registry listener. The
actor caches `AudioDevice`s and `Port`s continuously in a `HashMap`,
providing instantaneous responses to device queries via a channel.

This architectural change replaces `Sender<Terminate>` with a simple
`PwTerminator` drop-guard to manage the lifetimes of PipeWire objects
(virtual mic and links) via `PwCommand::DestroyObject` without creating
additional OS threads or PipeWire contexts.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-06-25 22:58:05 +03:00
arabianq caf8ac19a1 ci: add --no-wait flag to copr in release.yml 2026-06-19 15:31:05 +03:00
arabianq f563680ceb ci: add rust-cache 2026-06-19 15:29:03 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>arabianq
78ca2ddf68 chore(deps): bump opener from 0.8.4 to 0.8.5 (#139)
* chore(deps): bump opener from 0.8.4 to 0.8.5

Bumps [opener](https://github.com/Seeker14491/opener) from 0.8.4 to 0.8.5.
- [Changelog](https://github.com/Seeker14491/opener/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Seeker14491/opener/compare/v0.8.4...v0.8.5)

---
updated-dependencies:
- dependency-name: opener
  dependency-version: 0.8.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* deps(flatpak): update cargo-sources.json

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: arabian <a.tevg@ya.ru>
2026-06-19 02:47:19 +03:00
arabianq 838fc1ce29 fix: increment pkgrel to 2 for pwsp-bin aur package 2026-06-05 23:52:31 +03:00
arabianq 622cf39fa2 fix: correct asset paths in pwsp-bin aur package 2026-06-05 23:52:06 +03:00
arabianq 809b1a8490 fix: update Copr build command to use correct repository name 2026-06-04 20:51:36 +03:00
arabianq d1a5275173 fix: remove redundant name parameter from Copr build command 2026-06-04 20:42:11 +03:00
arabianq e67f174a59 change version to 1.12.0 2026-06-04 20:18:56 +03:00
arabianq 6545431ac2 deps: update cargo-sources.json 2026-06-04 20:17:27 +03:00
arabianq 026ef97a72 deps: cargo update 2026-06-04 20:16:59 +03:00
arabianq 9f833cc30b deps: update rodio 2026-06-04 20:16:35 +03:00
Tarasov AleksandrandGitHub 410a2c7959 feat(gui): sorting options (#134)
* feat(gui): added an ability to copy ```pwsp-cli action play``` command for every sound

* feat(gui): added files sorting options
2026-06-04 20:14:28 +03:00
Tarasov AleksandrandGitHub c173e602ad feat(gui): added an ability to copy ``pwsp-cli action play`` command for every sound (#133) 2026-06-04 20:06:35 +03:00
arabianq 3576c634fd packages(rpm): update version macro to use git describe for accurate versioning 2026-06-03 22:35:33 +03:00
arabianq 5747f39ace packages(rpm): fix version macro in spec file 2026-06-03 22:27:03 +03:00
arabianq c501033834 packages(rpm): fix source and setup macros in spec file 2026-06-03 21:26:16 +03:00
arabianq c0a27e0c3b packages(rpm): fix VCS macro in spec file 2026-06-03 21:23:55 +03:00
arabianq 3c2882ef1f packages(rpm): update version and changelog macros 2026-06-03 21:20:18 +03:00
arabianq 36aed3f55d packages(rpm): add copr-cli installation and trigger build step 2026-06-03 21:18:17 +03:00
arabianq c48a425bb0 packages(rpm): update version and source macros 2026-06-03 21:07:07 +03:00
arabianq 9a5436cd35 packages(rpm): add gcc 13 as BuildRequires for openSUSE compatibility 2026-06-03 20:01:54 +03:00
arabianq 2ce243e896 packages(rpm): replace pkgconf-pkg-config with pkgconfig in BuildRequires 2026-06-03 19:49:55 +03:00
arabianq 57fb3fd7a3 packages(rpm): unify BuildRequires for openSUSE compatibility 2026-06-03 18:48:17 +03:00
arabianq 82b02bf520 packages(rpm): fix builds for opensuse (maybe) 2026-06-03 18:26:29 +03:00
arabianq 3d4dbbe866 packages(rpm): pwsp-git.spec.rpkg -> pwsp-git.spec 2026-06-02 23:46:54 +03:00
arabianq 70bd3a889a packages(rpm): add fallback macros for systems without rpmautospec 2026-06-02 23:44:32 +03:00
arabianq a7b5bdd2a9 packages(rp[m): remove bcond check 1 to allow building on older systems 2026-06-02 23:32:22 +03:00
arabianq 99fef4a167 packages(rpm): add pwsp-git.spec.rpkg 2026-06-02 23:30:22 +03:00
arabianq ae08f7ddc3 packages(rpm): disable debuginfo 2026-06-02 23:15:31 +03:00
22 changed files with 1423 additions and 1649 deletions
+1 -1
View File
@@ -1 +1 @@
custom: ['https://boosty.to/arabian']
custom: ['https://www.donationalerts.com/r/arabianq', 'https://boosty.to/arabian', 'https://sponsr.ru/notultranx/']
+6
View File
@@ -45,6 +45,9 @@ jobs:
with:
toolchain: 1.96.0
- name: Rust Cache
uses: swatinem/rust-cache@v2
- name: Run tests
run: cargo test --locked
@@ -135,6 +138,9 @@ jobs:
with:
toolchain: 1.96.0
- name: Rust Cache
uses: swatinem/rust-cache@v2
- name: Build all binaries (debug-speed compilation into target/release)
env:
CARGO_PROFILE_RELEASE_OPT_LEVEL: 0
+6 -4
View File
@@ -60,9 +60,9 @@ jobs:
echo "default-branch: ${{ steps.set_branch.outputs.branch }}" >> packages/flatpak/ru.arabianq.pwsp.yaml
- name: Install SDK Extensions
run:
run: |
flatpak install -y flathub org.freedesktop.Sdk.Extension.rust-stable//25.08
org.freedesktop.Sdk.Extension.llvm20//25.08
flatpak install -y flathub org.freedesktop.Sdk.Extension.llvm20//25.08
- name: Build Flatpak
uses: andyholmes/flatter@main
@@ -72,6 +72,7 @@ jobs:
upload-bundles: false
upload-pages-artifact: false
arch: x86_64
cache: true
flatter-arm64:
name: Flatter (aarch64)
@@ -113,9 +114,9 @@ jobs:
echo "default-branch: ${{ steps.set_branch.outputs.branch }}" >> packages/flatpak/ru.arabianq.pwsp.yaml
- name: Install SDK Extensions
run:
run: |
flatpak install -y flathub org.freedesktop.Sdk.Extension.rust-stable//25.08
org.freedesktop.Sdk.Extension.llvm20//25.08
flatpak install -y flathub org.freedesktop.Sdk.Extension.llvm20//25.08
- name: Build Flatpak
uses: andyholmes/flatter@main
@@ -125,6 +126,7 @@ jobs:
upload-bundles: false
upload-pages-artifact: true
arch: aarch64
cache: true
deploy:
name: Deploy to GitHub Pages
+21
View File
@@ -97,6 +97,9 @@ jobs:
with:
toolchain: 1.96.0
- name: Rust Cache
uses: swatinem/rust-cache@v2
- name: Extract all binary names
id: cargo-meta
run: |
@@ -180,6 +183,9 @@ jobs:
with:
toolchain: 1.96.0
- name: Rust Cache
uses: swatinem/rust-cache@v2
- name: Build all release binaries
run: cargo build --release --locked
@@ -244,3 +250,18 @@ jobs:
files: |
./dist/pwsp-*.zip
./dist/*.deb
- name: Install copr-cli
run: pip install copr-cli
- name: Trigger Copr Build
env:
COPR_CONFIG: ${{ secrets.COPR_CONFIG }}
run: |
mkdir -p ~/.config
echo "$COPR_CONFIG" > ~/.config/copr
copr-cli buildscm --nowait \
--clone-url https://github.com/arabianq/pipewire-soundpad.git \
--commit ${{ needs.prepare.outputs.tag }} \
--spec packages/rpm/pwsp.spec \
arabianq/pwsp
Generated
+276 -539
View File
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -8,7 +8,7 @@ members = [
resolver = "2"
[workspace.package]
version = "1.11.0"
version = "1.12.2"
edition = "2024"
authors = ["arabian"]
homepage = "https://pwsp.arabianq.ru"
@@ -39,7 +39,7 @@ clap = { version = "4.6.1", default-features = false, features = [
] }
dirs = "6.0.0"
itertools = "0.14.0"
itertools = "0.15.0"
evdev = { version = "0.13.2", features = ["tokio"] }
rfd = { version = "0.17.2", default-features = false, features = [
"xdg-portal",
@@ -52,26 +52,26 @@ rustix = { version = "1.1.4", features = ["process"] }
rust-i18n = "4.0.0"
sys-locale = "0.3.2"
rodio = { git = "https://github.com/arabianq/rodio.git", rev = "a634dd471e9d59196e19bf01323fb45f2f899821", default-features = false, features = [
rodio = { git = "https://github.com/arabianq/rodio.git", rev = "4746803c2e5f67004264ac946708d47a9faf510c", default-features = false, features = [
"symphonia-all",
"symphonia-libopus",
"playback",
] }
pipewire = "0.10.0"
egui = { version = "0.34.2", default-features = false, features = [
egui = { version = "0.35.0", default-features = false, features = [
"default_fonts",
"rayon",
] }
eframe = { version = "0.34.2", default-features = false, features = [
eframe = { version = "0.35.0", default-features = false, features = [
"default_fonts",
"glow",
"x11",
"wayland",
] }
egui_extras = "0.34.1"
egui_material_icons = "0.6.0"
egui_dnd = "0.15.0"
egui_extras = "0.35.0"
egui_material_icons = "0.7.0"
egui_dnd = "0.16.0"
reqwest = "0.13.4"
percent-encoding = "2.3.2"
+4 -4
View File
@@ -1,6 +1,6 @@
pkgbase = pwsp-bin
pkgdesc = Lets you play audio files through your microphone (Pre-built binaries)
pkgver = 1.11.0
pkgver = 1.12.2
pkgrel = 1
url = https://github.com/arabianq/pipewire-soundpad
arch = x86_64
@@ -10,11 +10,11 @@ pkgbase = pwsp-bin
depends = alsa-lib
provides = pwsp
conflicts = pwsp
source = pipewire-soundpad-1.11.0.tar.gz :: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.11.0.tar.gz
source = pipewire-soundpad-1.12.2.tar.gz :: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.12.2.tar.gz
sha256sums = SKIP
source_x86_64 = pwsp-1.11.0-x86_64.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.11.0/pwsp-v1.11.0-linux-x64.zip
source_x86_64 = pwsp-1.12.2-x86_64.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.12.2/pwsp-v1.12.2-linux-x64.zip
sha256sums_x86_64 = SKIP
source_aarch64 = pwsp-1.11.0-aarch64.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.11.0/pwsp-v1.11.0-linux-arm64.zip
source_aarch64 = pwsp-1.12.2-aarch64.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.12.2/pwsp-v1.12.2-linux-arm64.zip
sha256sums_aarch64 = SKIP
pkgname = pwsp-bin
+4 -4
View File
@@ -1,7 +1,7 @@
# Maintainer: Alexander Tarasov <a.tevg@ya.ru>
pkgname=pwsp-bin
_pkgname=pipewire-soundpad
pkgver=1.11.0
pkgver=1.12.2
pkgrel=1
pkgdesc="Lets you play audio files through your microphone (Pre-built binaries)"
arch=('x86_64' 'aarch64')
@@ -26,9 +26,9 @@ package() {
install -Dm755 "${srcdir}/pwsp-daemon" "${pkgdir}/usr/bin/pwsp-daemon"
install -Dm755 "${srcdir}/pwsp-gui" "${pkgdir}/usr/bin/pwsp-gui"
install -Dm644 "$_srcsrc/assets/pwsp-gui.desktop" "${pkgdir}/usr/share/applications/pwsp-gui.desktop"
install -Dm644 "$_srcsrc/assets/icon.png" "${pkgdir}/usr/share/icons/hicolor/256x256/apps/pwsp.png"
install -Dm644 "$_srcsrc/assets/pwsp-daemon.service" "${pkgdir}/usr/lib/systemd/user/pwsp-daemon.service"
install -Dm644 "$_srcsrc/pwsp-gui/assets/pwsp-gui.desktop" "${pkgdir}/usr/share/applications/pwsp-gui.desktop"
install -Dm644 "$_srcsrc/pwsp-gui/assets/icon.png" "${pkgdir}/usr/share/icons/hicolor/256x256/apps/pwsp.png"
install -Dm644 "$_srcsrc/pwsp-gui/assets/pwsp-daemon.service" "${pkgdir}/usr/lib/systemd/user/pwsp-daemon.service"
install -Dm644 "$_srcsrc/LICENSE" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
}
+2 -2
View File
@@ -1,6 +1,6 @@
pkgbase = pwsp
pkgdesc = Lets you play audio files through your microphone
pkgver = 1.11.0
pkgver = 1.12.2
pkgrel = 1
url = https://github.com/arabianq/pipewire-soundpad
arch = x86_64
@@ -12,7 +12,7 @@ pkgbase = pwsp
makedepends = cmake
makedepends = pipewire
makedepends = alsa-lib
source = https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.11.0.tar.gz
source = https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.12.2.tar.gz
sha256sums = SKIP
pkgname = pwsp
+1 -1
View File
@@ -1,7 +1,7 @@
# Maintainer: Alexander Tarasov <a.tevg@ya.ru>
pkgsubn=pwsp
pkgname=pwsp
pkgver=1.11.0
pkgver=1.12.2
pkgrel=1
pkgdesc="Lets you play audio files through your microphone"
arch=('x86_64' 'aarch64')
File diff suppressed because one or more lines are too long
@@ -25,7 +25,7 @@
<name>arabian</name>
</developer>
<releases>
<release version="1.11.0" date="2026-06-02" />
<release version="1.12.2" date="2026-07-02" />
</releases>
<content_rating type="oars-1.1" />
</component>
+83
View File
@@ -0,0 +1,83 @@
# prevent library files from being installed
%global cargo_install_lib 0
# disable debuginfo package generation (debugsourcefiles.list is empty for Rust)
%global debug_package %{nil}
Name: pwsp-git
Version: {{{ git describe --tags --always | sed 's/^v//' | sed -E 's/-([0-9]+)-(g[0-9a-f]+)/^git.\1.\2/' }}}
Release: 1%{?dist}
Summary: Lets you play audio files through your microphone (git version)
License: MIT
URL: https://github.com/arabianq/pipewire-soundpad
VCS: {{{ git_dir_vcs }}}
Source: {{{ git_cwd_pack }}}
BuildRequires: rust
BuildRequires: cargo
BuildRequires: pipewire-devel
%if 0%{?suse_version}
BuildRequires: alsa-devel
BuildRequires: dbus-1-devel
%else
BuildRequires: alsa-lib-devel
BuildRequires: dbus-devel
%endif
BuildRequires: clang-devel
BuildRequires: cmake
BuildRequires: pkgconfig
%if 0%{?suse_version} && 0%{?suse_version} <= 1500
BuildRequires: gcc13-c++
%endif
# Declare compatibility and conflicts with the stable package
Provides: pwsp = %{version}-%{release}
Conflicts: pwsp
%global _description %{expand:
PWSP lets you play audio files through your microphone. Has both CLI and
GUI clients. This is the latest development (git) version.}
%description %{_description}
%prep
{{{ git_cwd_setup_macro }}}
%build
%if 0%{?suse_version} && 0%{?suse_version} <= 1500
export CC=gcc-13
export CXX=g++-13
%endif
cargo build --release --locked
%install
install -Dm755 target/release/pwsp-cli %{buildroot}%{_bindir}/pwsp-cli
install -Dm755 target/release/pwsp-daemon %{buildroot}%{_bindir}/pwsp-daemon
install -Dm755 target/release/pwsp-gui %{buildroot}%{_bindir}/pwsp-gui
install -Dm644 pwsp-gui/assets/pwsp-gui.desktop %{buildroot}%{_datadir}/applications/pwsp.desktop
install -Dm644 pwsp-gui/assets/icon.png %{buildroot}%{_datadir}/icons/hicolor/256x256/apps/pwsp.png
install -Dm644 pwsp-gui/assets/pwsp-daemon.service %{buildroot}/usr/lib/systemd/user/pwsp-daemon.service
%files
%license LICENSE
%doc README.md
%{_bindir}/pwsp-cli
%{_bindir}/pwsp-daemon
%{_bindir}/pwsp-gui
%{_datadir}/applications/pwsp.desktop
%{_datadir}/icons/hicolor/256x256/apps/pwsp.png
/usr/lib/systemd/user/pwsp-daemon.service
%changelog
{{{ git_dir_changelog }}}
+29 -5
View File
@@ -1,10 +1,19 @@
%bcond check 1
# prevent library files from being installed
%global cargo_install_lib 0
# Fallback macros for systems without rpmautospec (e.g. openSUSE)
%{!?autorelease: %global autorelease 1}
%{!?autochangelog: %global autochangelog \
* Tue Jun 02 2026 Arabian <arabianq@github> - %{version}-%{release}\
- Release build}
# disable debuginfo package generation (debugsourcefiles.list is empty for Rust)
%global debug_package %{nil}
Name: pwsp
Version: 1.11.0
Version: 1.12.2
Release: %autorelease
Summary: Lets you play audio files through your microphone
@@ -16,11 +25,21 @@ Source: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags
BuildRequires: rust
BuildRequires: cargo
BuildRequires: pipewire-devel
%if 0%{?suse_version}
BuildRequires: alsa-devel
BuildRequires: dbus-1-devel
%else
BuildRequires: alsa-lib-devel
BuildRequires: dbus-devel
%endif
BuildRequires: clang-devel
BuildRequires: cmake
BuildRequires: dbus-devel
BuildRequires: pkgconf-pkg-config
BuildRequires: pkgconfig
%if 0%{?suse_version} && 0%{?suse_version} <= 1500
BuildRequires: gcc13-c++
%endif
%global _description %{expand:
PWSP lets you play audio files through your microphone. Has both CLI and
@@ -32,8 +51,13 @@ GUI clients.}
%autosetup -n pipewire-soundpad-%{version} -p1
%build
%if 0%{?suse_version} && 0%{?suse_version} <= 1500
export CC=gcc-13
export CXX=g++-13
%endif
cargo build --release --locked
%install
install -Dm755 target/release/pwsp-cli %{buildroot}%{_bindir}/pwsp-cli
install -Dm755 target/release/pwsp-daemon %{buildroot}%{_bindir}/pwsp-daemon
+4 -1
View File
@@ -28,7 +28,10 @@ async fn main() -> Result<()> {
}
get_daemon_config(); // Initialize daemon config
create_virtual_mic()?;
// Virtual mic object must be kept alive by some variable until daemon exits
let _virtual_mic = create_virtual_mic().await?;
if let Err(err) = get_audio_player().await {
eprintln!("Failed to initialize audio player: {}", err);
} // Initialize audio player
+66
View File
@@ -70,6 +70,61 @@ kz = "Жою"
he = "הסר"
pt-BR = "Remover"
[gui.context.dirs.sort_by]
en = "Sort by"
ru = "Сортировка"
es = "Ordenar por"
fr = "Trier par"
zh = "排序方式"
ar = "ترتيب حسب"
kz = "Сұрыптау"
he = "מיין לפי"
pt-BR = "Ordenar por"
[gui.sort.alpha_asc]
en = "Alphabetical (A-Z)"
ru = "По алфавиту (А-Я)"
es = "Alfabético (A-Z)"
fr = "Alphabétique (A-Z)"
zh = "字母顺序 (A-Z)"
ar = "أبجدي (A-Z)"
kz = "Әліпби бойынша (А-Я)"
he = "אלפביתי (A-Z)"
pt-BR = "Alfabético (A-Z)"
[gui.sort.alpha_desc]
en = "Alphabetical (Z-A)"
ru = "По алфавиту (Я-А)"
es = "Alfabético (Z-A)"
fr = "Alphabétique (Z-A)"
zh = "字母顺序 (Z-A)"
ar = "أبجدي (Z-A)"
kz = "Әліпби бойынша (Я-А)"
he = "אלפביתי (Z-A)"
pt-BR = "Alfabético (Z-A)"
[gui.sort.date_newest]
en = "Date modified (Newest first)"
ru = "Дата изменения (Сначала новые)"
es = "Fecha de modificación (Más nuevo primero)"
fr = "Date de modification (Plus récent en premier)"
zh = "修改日期 (最新优先)"
ar = "تاريخ التعديل (الأحدث أولاً)"
kz = "Өзгертілген күні (Жаңалары бірінші)"
he = "תאריך שינוי (החדש ביותר ראשון)"
pt-BR = "Data de modificação (Mais novo primeiro)"
[gui.sort.date_oldest]
en = "Date modified (Oldest first)"
ru = "Дата изменения (Сначала старые)"
es = "Fecha de modificación (Más antiguo primero)"
fr = "Date de modification (Plus ancien en premier)"
zh = "修改日期 (最旧优先)"
ar = "تاريخ التعديل (الأقدم أولاً)"
kz = "Өзгертілген күні (Ескілері бірінші)"
he = "תאריך שינוי (הישן ביותר ראשון)"
pt-BR = "Data de modificação (Mais antigo primeiro)"
[gui.context.files.play_solo]
en = "Play Solo"
ru = "Играть"
@@ -125,6 +180,17 @@ kz = "Ыстық пернені тағайындау"
he = "הקצה מקש קיצור"
pt-BR = "Definir tecla de atalho"
[gui.context.files.copy_cli_command]
en = "Copy PWSP-CLI command"
ru = "Скопировать команду для PWSP-CLI"
es = "Copiar comando de PWSP-CLI"
fr = "Copier la commande PWSP-CLI"
zh = "复制 PWSP-CLI 命令"
ar = "نسخ أمر PWSP-CLI"
kz = "PWSP-CLI командасын көшіру"
he = "העתק פקודת PWSP-CLI"
pt-BR = "Copiar comando PWSP-CLI"
# ----------------
# Settings
# ----------------
+24 -5
View File
@@ -3,7 +3,7 @@ mod update;
mod views;
use anyhow::{Result, anyhow};
use eframe::{HardwareAcceleration, NativeOptions, icon_data::from_png_bytes, run_native};
use eframe::{NativeOptions, icon_data::from_png_bytes, run_native};
use egui::{Context, FontData, FontDefinitions, FontFamily, FontTweak, Vec2, ViewportBuilder};
use itertools::Itertools;
use pwsp_lib::{
@@ -159,6 +159,13 @@ impl SoundpadGui {
pub fn get_filtered_files(&self) -> Vec<PathBuf> {
let mut files: Vec<PathBuf> = self.app_state.listed_files.iter().cloned().collect();
let sort_order = self
.app_state
.current_dir
.as_ref()
.map(|d| self.config.get_sort_order(d))
.unwrap_or_default();
files.sort_by(|a, b| {
let a_is_dir = a.is_dir();
let b_is_dir = b.is_dir();
@@ -167,7 +174,7 @@ impl SoundpadGui {
} else if !a_is_dir && b_is_dir {
Ordering::Greater
} else {
a.cmp(b)
sort_order.compare(a, b)
}
});
@@ -212,7 +219,7 @@ impl SoundpadGui {
fn add_font(font_name: &str, font_bytes: &[u8], fonts: &mut FontDefinitions) -> Result<()> {
let font_data = FontData::from_owned(font_bytes.to_vec()).tweak(FontTweak {
scale: 1.0,
hinting_override: Some(true),
hinting: Some(true),
..Default::default()
});
@@ -258,9 +265,7 @@ pub async fn run() -> Result<()> {
const ICON: &[u8] = include_bytes!("../../assets/icon.png");
let options = NativeOptions {
vsync: true,
centered: true,
hardware_acceleration: HardwareAcceleration::Preferred,
viewport: ViewportBuilder::default()
.with_app_id("ru.arabianq.pwsp")
@@ -334,5 +339,19 @@ mod tests {
let filtered_search = gui.get_filtered_files();
assert_eq!(filtered_search.len(), 1);
assert_eq!(filtered_search[0], file_c);
// Test sort order descending
gui.app_state.current_dir = Some(PathBuf::from("dummy_dir"));
gui.config.dirs_settings.insert(
PathBuf::from("dummy_dir"),
pwsp_lib::types::config::DirSettings {
sort_order: pwsp_lib::types::config::SortOrder::AlphabeticalDesc,
},
);
gui.app_state.search_query = String::new();
let filtered_desc = gui.get_filtered_files();
assert_eq!(filtered_desc.len(), 2);
assert_eq!(filtered_desc[0], file_c);
assert_eq!(filtered_desc[1], file_b);
}
}
+1 -1
View File
@@ -121,7 +121,7 @@ impl App for SoundpadGui {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut EFrame) {
// Draw UI
CentralPanel::default().show_inside(ui, |ui| {
CentralPanel::default().show(ui, |ui| {
if !self.audio_player_state.is_daemon_running {
self.draw_waiting_for_daemon(ui);
return;
+102 -17
View File
@@ -5,7 +5,10 @@ use egui::{
};
use egui_dnd::dnd;
use egui_material_icons::icons::*;
use pwsp_lib::types::{gui::AppState, gui::AudioPlayerState};
use pwsp_lib::types::{
config::{GuiConfig, SortOrder},
gui::{AppState, AudioPlayerState},
};
use rust_i18n::t;
use std::{cmp::Ordering, path::Path, path::PathBuf};
@@ -135,6 +138,65 @@ impl SoundpadGui {
{
self.app_state.dirs_to_remove.insert(path.clone());
}
ui.separator();
ui.label(t!("gui.context.dirs.sort_by"));
let current_order = self
.config
.dirs_settings
.get(path)
.map(|s| s.sort_order)
.unwrap_or_default();
let mut new_order = None;
if ui
.radio(
current_order == SortOrder::AlphabeticalAsc,
t!("gui.sort.alpha_asc"),
)
.clicked()
{
new_order = Some(SortOrder::AlphabeticalAsc);
}
if ui
.radio(
current_order == SortOrder::AlphabeticalDesc,
t!("gui.sort.alpha_desc"),
)
.clicked()
{
new_order = Some(SortOrder::AlphabeticalDesc);
}
if ui
.radio(
current_order == SortOrder::DateModifiedNewest,
t!("gui.sort.date_newest"),
)
.clicked()
{
new_order = Some(SortOrder::DateModifiedNewest);
}
if ui
.radio(
current_order == SortOrder::DateModifiedOldest,
t!("gui.sort.date_oldest"),
)
.clicked()
{
new_order = Some(SortOrder::DateModifiedOldest);
}
if let Some(order) = new_order {
self.config
.dirs_settings
.entry(path.clone())
.or_default()
.sort_order = order;
self.config.save_to_file().ok();
self.app_state.dir_cache.remove(path);
self.open_dir(path);
}
});
});
});
@@ -192,6 +254,7 @@ impl SoundpadGui {
Self::draw_tree_node(
ui,
entry_path,
&self.config,
&mut self.app_state,
&self.audio_player_state,
&mut actions,
@@ -226,6 +289,7 @@ impl SoundpadGui {
fn draw_tree_node_dir(
ui: &mut Ui,
path: std::path::PathBuf,
config: &GuiConfig,
app_state: &mut AppState,
audio_player_state: &AudioPlayerState,
actions: &mut Vec<FileAction>,
@@ -244,9 +308,22 @@ impl SoundpadGui {
let mut read = Vec::new();
if let Ok(entries) = std::fs::read_dir(&path) {
for entry in entries.filter_map(|e| e.ok()) {
read.push(entry.path());
let child_path = entry.path();
if !child_path.is_dir()
&& !crate::gui::SUPPORTED_EXTENSIONS.contains(
&child_path
.extension()
.unwrap_or_default()
.to_str()
.unwrap_or_default(),
)
{
continue;
}
read.push(child_path);
}
}
let sort_order = config.get_sort_order(&path);
read.sort_by(|a, b| {
let a_is_dir = a.is_dir();
let b_is_dir = b.is_dir();
@@ -255,7 +332,7 @@ impl SoundpadGui {
} else if !a_is_dir && b_is_dir {
Ordering::Greater
} else {
a.cmp(b)
sort_order.compare(a, b)
}
});
app_state.dir_cache.insert(path.clone(), read.clone());
@@ -266,17 +343,7 @@ impl SoundpadGui {
let search_query = search_query.trim();
for child in children {
if !child.is_dir() {
if !crate::gui::SUPPORTED_EXTENSIONS.contains(
&child
.extension()
.unwrap_or_default()
.to_str()
.unwrap_or_default(),
) {
continue;
}
if !search_query.is_empty() {
if !child.is_dir() && !search_query.is_empty() {
let file_name = child
.file_name()
.unwrap_or_default()
@@ -286,8 +353,7 @@ impl SoundpadGui {
continue;
}
}
}
Self::draw_tree_node(ui, child, app_state, audio_player_state, actions);
Self::draw_tree_node(ui, child, config, app_state, audio_player_state, actions);
}
});
}
@@ -412,6 +478,24 @@ impl SoundpadGui {
actions.push(FileAction::AssignHotkey(path.clone()));
ui.close();
}
ui.separator();
if ui
.button(format!(
"{} {}",
ICON_FILE_COPY.codepoint,
t!("gui.context.files.copy_cli_command")
))
.clicked()
{
ui.ctx().copy_text(format!(
"pwsp-cli action play \"{}\"",
path.to_string_lossy()
.replace('\\', "\\\\")
.replace('"', "\\\"")
));
}
});
});
}
@@ -419,12 +503,13 @@ impl SoundpadGui {
fn draw_tree_node(
ui: &mut Ui,
path: std::path::PathBuf,
config: &GuiConfig,
app_state: &mut AppState,
audio_player_state: &AudioPlayerState,
actions: &mut Vec<FileAction>,
) {
if path.is_dir() {
Self::draw_tree_node_dir(ui, path, app_state, audio_player_state, actions);
Self::draw_tree_node_dir(ui, path, config, app_state, audio_player_state, actions);
} else {
Self::draw_tree_node_file(ui, path, app_state, audio_player_state, actions);
}
+7 -15
View File
@@ -1,8 +1,8 @@
use crate::{
types::pipewire::{DeviceType, Terminate},
types::pipewire::DeviceType,
utils::{
daemon::get_daemon_config,
pipewire::{create_link, get_device, link_player_to_virtual_mic},
pipewire::{PwTerminator, create_link, get_device, link_player_to_virtual_mic},
},
};
use anyhow::{Result, anyhow};
@@ -58,8 +58,8 @@ pub struct AudioPlayer {
pub tracks: HashMap<u32, PlayingSound>,
pub next_id: u32,
input_link_sender: Option<pipewire::channel::Sender<Terminate>>,
player_link_sender: Option<pipewire::channel::Sender<Terminate>>,
input_link_sender: Option<PwTerminator>,
player_link_sender: Option<PwTerminator>,
pub input_device_name: Option<String>,
pub volume: f32, // Master volume
@@ -108,24 +108,16 @@ impl AudioPlayer {
}
fn abort_link_thread(&mut self) {
if let Some(sender) = &self.input_link_sender {
if sender.send(Terminate {}).is_ok() {
if self.input_link_sender.is_some() {
println!("Sent terminate signal to input link thread");
self.input_link_sender = None;
} else {
eprintln!("Failed to send terminate signal to input link thread");
}
}
}
fn abort_player_link_thread(&mut self) {
if let Some(sender) = &self.player_link_sender {
if sender.send(Terminate {}).is_ok() {
if self.player_link_sender.is_some() {
println!("Sent terminate signal to player link thread");
self.player_link_sender = None;
} else {
eprintln!("Failed to send terminate signal to player link thread");
}
}
}
@@ -187,7 +179,7 @@ impl AudioPlayer {
return Ok(());
};
self.input_link_sender = Some(create_link(output_fl, output_fr, input_fl, input_fr)?);
self.input_link_sender = Some(create_link(output_fl, output_fr, input_fl, input_fr).await?);
Ok(())
}
+62 -1
View File
@@ -4,7 +4,13 @@ use crate::{
};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, fs, path::PathBuf};
use std::{
cmp::Ordering,
collections::HashMap,
fs,
path::{Path, PathBuf},
time::SystemTime,
};
#[derive(Default, Clone, Serialize, Deserialize)]
#[serde(default)]
@@ -45,6 +51,21 @@ pub enum PreferredTheme {
Dark,
}
#[derive(Default, Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum SortOrder {
#[default]
AlphabeticalAsc,
AlphabeticalDesc,
DateModifiedNewest,
DateModifiedOldest,
}
#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct DirSettings {
pub sort_order: SortOrder,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct GuiConfig {
@@ -57,10 +78,38 @@ pub struct GuiConfig {
pub pause_on_exit: bool,
pub dirs: Vec<PathBuf>,
pub dirs_settings: HashMap<PathBuf, DirSettings>,
pub preferred_theme: PreferredTheme,
}
impl SortOrder {
pub fn compare(&self, a: &Path, b: &Path) -> Ordering {
match self {
SortOrder::AlphabeticalAsc => a.cmp(b),
SortOrder::AlphabeticalDesc => b.cmp(a),
SortOrder::DateModifiedNewest => {
let a_time = fs::metadata(a)
.and_then(|m| m.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
let b_time = fs::metadata(b)
.and_then(|m| m.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
b_time.cmp(&a_time)
}
SortOrder::DateModifiedOldest => {
let a_time = fs::metadata(a)
.and_then(|m| m.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
let b_time = fs::metadata(b)
.and_then(|m| m.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
a_time.cmp(&b_time)
}
}
}
}
impl Default for GuiConfig {
fn default() -> Self {
GuiConfig {
@@ -75,11 +124,23 @@ impl Default for GuiConfig {
dirs: vec![ensure_pwsp_audio_dir()],
preferred_theme: PreferredTheme::System,
dirs_settings: HashMap::new(),
}
}
}
impl GuiConfig {
pub fn get_sort_order(&self, path: &Path) -> SortOrder {
let mut current = Some(path);
while let Some(p) = current {
if let Some(settings) = self.dirs_settings.get(p) {
return settings.sort_order;
}
current = p.parent();
}
SortOrder::default()
}
pub fn save_to_file(&mut self) -> Result<()> {
let config_path = get_config_path()?.join("gui.json");
+276 -252
View File
@@ -1,14 +1,224 @@
use crate::types::pipewire::{AudioDevice, DeviceType, Port, Terminate};
use crate::types::pipewire::{AudioDevice, DeviceType, Port};
use anyhow::{Result, anyhow};
use pipewire::{
context::ContextRc, link::Link, main_loop::MainLoopRc, properties::properties,
registry::GlobalObject, spa::utils::dict::DictRef,
};
use std::{collections::HashMap, thread};
use tokio::{
sync::mpsc,
time::{Duration, timeout},
};
use std::{cell::RefCell, collections::HashMap, rc::Rc, sync::OnceLock, thread};
use tokio::sync::oneshot;
pub enum PwCommand {
GetDevices {
resp: oneshot::Sender<(Vec<AudioDevice>, Vec<AudioDevice>)>,
},
CreateVirtualMic {
resp: oneshot::Sender<Result<u32, String>>,
},
CreateLink {
output_fl: Port,
output_fr: Port,
input_fl: Port,
input_fr: Port,
resp: oneshot::Sender<Result<(u32, u32), String>>,
},
DestroyObject {
id: u32,
},
}
struct AppState {
input_devices: HashMap<u32, AudioDevice>,
output_devices: HashMap<u32, AudioDevice>,
ports: HashMap<u32, Port>,
proxies: HashMap<u32, Box<dyn std::any::Any>>,
proxy_id_counter: u32,
ready_tx: Option<std::sync::mpsc::Sender<()>>,
}
pub struct PipewireManager {
pub sender: pipewire::channel::Sender<PwCommand>,
}
static MANAGER: OnceLock<PipewireManager> = OnceLock::new();
pub fn get_manager() -> &'static PipewireManager {
MANAGER.get_or_init(|| {
let (pw_sender, pw_receiver) = pipewire::channel::channel::<PwCommand>();
let (ready_tx, ready_rx) = std::sync::mpsc::channel();
thread::spawn(move || {
let (main_loop, context) = setup_pipewire_context().expect("Failed to setup pipewire");
// Leak main_loop and context so their borrows can be 'static
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(
context
.connect(None)
.expect("Failed to connect to pipewire"),
));
let registry = 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(),
ports: HashMap::new(),
proxies: HashMap::new(),
proxy_id_counter: 10000,
ready_tx: Some(ready_tx),
}));
let state_for_registry_add = state.clone();
let state_for_registry_remove = state.clone();
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 {
DeviceType::Input => {
s.input_devices.insert(device.id, device);
}
DeviceType::Output => {
s.output_devices.insert(device.id, device);
}
}
} 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);
}
}
})
.global_remove(move |id| {
let mut s = state_for_registry_remove.borrow_mut();
s.input_devices.remove(&id);
s.output_devices.remove(&id);
s.ports.retain(|_, port| port.node_id != id);
s.ports.remove(&id);
})
.register();
// sync to signal ready
let state_for_sync = state.clone();
let _core_listener = core
.add_listener_local()
.done(move |id, _seq| {
if id == 0 {
let mut s = state_for_sync.borrow_mut();
if let Some(tx) = s.ready_tx.take() {
let _ = tx.send(());
}
}
})
.register();
let _pending = core.sync(0).expect("sync failed");
let state_for_cmd = state.clone();
let _receiver = pw_receiver.attach(main_loop.loop_(), move |cmd| {
let mut s = state_for_cmd.borrow_mut();
match cmd {
PwCommand::GetDevices { resp } => {
let mut inputs: Vec<AudioDevice> =
s.input_devices.values().cloned().collect();
let mut outputs: Vec<AudioDevice> =
s.output_devices.values().cloned().collect();
inputs.sort_by_key(|a| a.id);
outputs.sort_by_key(|a| a.id);
let _ = resp.send((inputs, outputs));
}
PwCommand::CreateVirtualMic { resp } => {
let props = properties!(
"factory.name" => "support.null-audio-sink",
"node.name" => "pwsp-virtual-mic",
"node.description" => "PWSP Virtual Mic",
"media.class" => "Audio/Source/Virtual",
"audio.position" => "[ FL FR ]",
"audio.channels" => "2",
"object.linger" => "false",
);
match core.create_object::<pipewire::node::Node>("adapter", &props) {
Ok(node) => {
s.proxy_id_counter += 1;
let id = s.proxy_id_counter;
s.proxies.insert(id, Box::new(node));
let _ = resp.send(Ok(id));
}
Err(e) => {
let _ = resp.send(Err(e.to_string()));
}
}
}
PwCommand::CreateLink {
output_fl,
output_fr,
input_fl,
input_fr,
resp,
} => {
let props_fl = properties! {
"link.output.node" => format!("{}", output_fl.node_id).as_str(),
"link.output.port" => format!("{}", output_fl.port_id).as_str(),
"link.input.node" => format!("{}", input_fl.node_id).as_str(),
"link.input.port" => format!("{}", input_fl.port_id).as_str(),
};
let props_fr = properties! {
"link.output.node" => format!("{}", output_fr.node_id).as_str(),
"link.output.port" => format!("{}", output_fr.port_id).as_str(),
"link.input.node" => format!("{}", input_fr.node_id).as_str(),
"link.input.port" => format!("{}", input_fr.port_id).as_str(),
};
let link_fl = match core.create_object::<Link>("link-factory", &props_fl) {
Ok(link) => link,
Err(e) => {
let _ = resp.send(Err(e.to_string()));
return;
}
};
let link_fr = match core.create_object::<Link>("link-factory", &props_fr) {
Ok(link) => link,
Err(e) => {
let _ = resp.send(Err(e.to_string()));
return;
}
};
s.proxy_id_counter += 1;
let id_fl = s.proxy_id_counter;
s.proxies.insert(id_fl, Box::new(link_fl));
s.proxy_id_counter += 1;
let id_fr = s.proxy_id_counter;
s.proxies.insert(id_fr, Box::new(link_fr));
let _ = resp.send(Ok((id_fl, id_fr)));
}
PwCommand::DestroyObject { id } => {
s.proxies.remove(&id);
}
}
});
main_loop.run();
});
// Wait for the pipewire thread to be fully up and processed initial events
let _ = ready_rx.recv();
PipewireManager { sender: pw_sender }
})
}
pub fn setup_pipewire_context() -> Result<(MainLoopRc, ContextRc), String> {
pipewire::init();
@@ -71,127 +281,17 @@ fn parse_global_object(
(None, None)
}
async fn pw_get_global_objects_thread(
main_sender: mpsc::Sender<(Option<AudioDevice>, Option<Port>)>,
pw_receiver: pipewire::channel::Receiver<Terminate>,
init_sender: tokio::sync::oneshot::Sender<Result<(), String>>,
) {
let (main_loop, context) = match setup_pipewire_context() {
Ok(res) => res,
Err(e) => {
let _ = init_sender.send(Err(e));
return;
}
};
// Stop main loop on Terminate message
let _receiver = pw_receiver.attach(main_loop.loop_(), {
let _main_loop = main_loop.clone();
move |_| _main_loop.quit()
});
let core = match context.connect(None) {
Ok(core) => core,
Err(e) => {
let _ = init_sender.send(Err(format!("Failed to connect to pipewire context: {}", e)));
return;
}
};
let registry = match core.get_registry() {
Ok(registry) => registry,
Err(e) => {
let _ = init_sender.send(Err(format!(
"Failed to get registry from pipewire context: {}",
e
)));
return;
}
};
let _listener = registry
.add_listener_local()
.global(move |global| {
// Try to parse every global object pipewire finds
let (device, port) = parse_global_object(global);
// Send message to the main thread
let sender_clone = main_sender.clone();
tokio::task::spawn(async move {
sender_clone.send((device, port)).await.ok();
});
})
.register();
// Signal successful initialization
if init_sender.send(Ok(())).is_err() {
return;
}
main_loop.run();
}
pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
// Channels to communicate with pipewire thread
let (main_sender, mut main_receiver) = mpsc::channel(10);
let (pw_sender, pw_receiver) = pipewire::channel::channel();
let (init_sender, init_receiver) = tokio::sync::oneshot::channel();
// Spawn pipewire thread in background
let _pw_thread = tokio::spawn(async move {
pw_get_global_objects_thread(main_sender, pw_receiver, init_sender).await
});
// Wait for initialization to complete
if let Err(e) = init_receiver.await {
return Err(anyhow!(e));
}
let mut input_devices: HashMap<u32, AudioDevice> = HashMap::new();
let mut output_devices: HashMap<u32, AudioDevice> = HashMap::new();
let mut ports: Vec<Port> = vec![];
loop {
// If we don't receive a message in 100ms, we can assume that pipewire thread is finished
match timeout(Duration::from_millis(100), main_receiver.recv()).await {
Ok(Some((device, port))) => {
if let Some(device) = device {
match device.device_type {
DeviceType::Input => {
input_devices.insert(device.id, device);
}
DeviceType::Output => {
output_devices.insert(device.id, device);
}
}
} else if let Some(port) = port {
ports.push(port);
}
}
Ok(None) | Err(_) => {
// Pipewire thread is finished and we can collect our devices
let _ = pw_sender.send(Terminate {});
for port in ports {
let node_id = port.node_id;
if let Some(input_device) = input_devices.get_mut(&node_id) {
input_device.add_port(port);
} else if let Some(output_device) = output_devices.get_mut(&node_id) {
output_device.add_port(port);
}
}
let mut input_devices: Vec<AudioDevice> = input_devices.into_values().collect();
let mut output_devices: Vec<AudioDevice> = output_devices.into_values().collect();
input_devices.sort_by_key(|a| a.id);
output_devices.sort_by_key(|a| a.id);
return Ok((input_devices, output_devices));
}
}
}
let (tx, rx) = oneshot::channel();
let manager = get_manager();
manager
.sender
.send(PwCommand::GetDevices { resp: tx })
.map_err(|_| anyhow!("Failed to send GetDevices to manager"))?;
let res = rx
.await
.map_err(|e| anyhow!("Failed to receive response: {}", e))?;
Ok(res)
}
pub async fn get_device(device_name: &str) -> Result<AudioDevice> {
@@ -209,65 +309,36 @@ pub async fn get_device(device_name: &str) -> Result<AudioDevice> {
.ok_or_else(|| anyhow!("Device not found"))
}
pub fn create_virtual_mic() -> Result<pipewire::channel::Sender<Terminate>> {
let (pw_sender, pw_receiver) = pipewire::channel::channel::<Terminate>();
let (init_sender, init_receiver) = std::sync::mpsc::sync_channel(0);
let _pw_thread = thread::spawn(move || {
let (main_loop, context) = match setup_pipewire_context() {
Ok(res) => res,
Err(e) => {
let _ = init_sender.send(Err(e));
return;
}
};
let core = match context.connect(None) {
Ok(core) => core,
Err(e) => {
let _ =
init_sender.send(Err(format!("Failed to connect to pipewire context: {}", e)));
return;
}
};
let props = properties!(
"factory.name" => "support.null-audio-sink",
"node.name" => "pwsp-virtual-mic",
"node.description" => "PWSP Virtual Mic",
"media.class" => "Audio/Source/Virtual",
"audio.position" => "[ FL FR ]",
"audio.channels" => "2",
"object.linger" => "false", // Destroy the node on app exit
);
let _node = match core.create_object::<pipewire::node::Node>("adapter", &props) {
Ok(node) => node,
Err(e) => {
let _ = init_sender.send(Err(format!("Failed to create virtual mic: {}", e)));
return;
}
};
let _receiver = pw_receiver.attach(main_loop.loop_(), {
let _main_loop = main_loop.clone();
move |_| _main_loop.quit()
});
println!("Virtual mic created");
if init_sender.send(Ok(())).is_err() {
return;
}
main_loop.run();
});
if let Err(e) = init_receiver.recv()? {
return Err(anyhow!(e));
}
Ok(pw_sender)
pub struct PwTerminator {
ids: Vec<u32>,
}
pub async fn link_player_to_virtual_mic() -> Result<pipewire::channel::Sender<Terminate>> {
impl Drop for PwTerminator {
fn drop(&mut self) {
let manager = get_manager();
for id in &self.ids {
let _ = manager.sender.send(PwCommand::DestroyObject { id: *id });
}
}
}
pub async fn create_virtual_mic() -> Result<PwTerminator> {
let (tx, rx) = oneshot::channel();
let manager = get_manager();
manager
.sender
.send(PwCommand::CreateVirtualMic { resp: tx })
.map_err(|_| anyhow!("Failed to send CreateVirtualMic to manager"))?;
let res = rx
.await
.map_err(|e| anyhow!("Failed to receive response: {}", e))?;
let id = res.map_err(|e| anyhow!(e))?;
Ok(PwTerminator { ids: vec![id] })
}
pub async fn link_player_to_virtual_mic() -> Result<PwTerminator> {
let pwsp_daemon_output = match get_device("pwsp-daemon").await {
Ok(device) => device,
Err(_) => {
@@ -303,81 +374,34 @@ pub async fn link_player_to_virtual_mic() -> Result<pipewire::channel::Sender<Te
None => return Err(anyhow!("Failed to get pwsp-virtual-mic input_fr")),
};
create_link(output_fl, output_fr, input_fl, input_fr)
create_link(output_fl, output_fr, input_fl, input_fr).await
}
pub fn create_link(
pub async fn create_link(
output_fl: Port,
output_fr: Port,
input_fl: Port,
input_fr: Port,
) -> Result<pipewire::channel::Sender<Terminate>> {
let (pw_sender, pw_receiver) = pipewire::channel::channel::<Terminate>();
let (init_sender, init_receiver) = std::sync::mpsc::sync_channel(0);
) -> Result<PwTerminator> {
let (tx, rx) = oneshot::channel();
let manager = get_manager();
manager
.sender
.send(PwCommand::CreateLink {
output_fl,
output_fr,
input_fl,
input_fr,
resp: tx,
})
.map_err(|_| anyhow!("Failed to send CreateLink to manager"))?;
let _pw_thread = thread::spawn(move || {
let (main_loop, context) = match setup_pipewire_context() {
Ok(res) => res,
Err(e) => {
let _ = init_sender.send(Err(e));
return;
}
};
let core = match context.connect(None) {
Ok(core) => core,
Err(e) => {
let _ =
init_sender.send(Err(format!("Failed to connect to pipewire context: {}", e)));
return;
}
};
let res = rx
.await
.map_err(|e| anyhow!("Failed to receive response: {}", e))?;
let props_fl = properties! {
"link.output.node" => format!("{}", output_fl.node_id).as_str(),
"link.output.port" => format!("{}", output_fl.port_id).as_str(),
"link.input.node" => format!("{}", input_fl.node_id).as_str(),
"link.input.port" => format!("{}", input_fl.port_id).as_str(),
};
let props_fr = properties! {
"link.output.node" => format!("{}", output_fr.node_id).as_str(),
"link.output.port" => format!("{}", output_fr.port_id).as_str(),
"link.input.node" => format!("{}", input_fr.node_id).as_str(),
"link.input.port" => format!("{}", input_fr.port_id).as_str(),
};
let _link_fl = match core.create_object::<Link>("link-factory", &props_fl) {
Ok(link) => link,
Err(e) => {
let _ = init_sender.send(Err(format!("Failed to create link FL: {}", e)));
return;
}
};
let _link_fr = match core.create_object::<Link>("link-factory", &props_fr) {
Ok(link) => link,
Err(e) => {
let _ = init_sender.send(Err(format!("Failed to create link FR: {}", e)));
return;
}
};
let _receiver = pw_receiver.attach(main_loop.loop_(), {
let _main_loop = main_loop.clone();
move |_| _main_loop.quit()
});
println!(
"Link created: FL: {}-{} FR: {}-{}",
output_fl.node_id, input_fl.node_id, output_fr.node_id, input_fr.node_id
);
if init_sender.send(Ok(())).is_err() {
return;
}
main_loop.run();
});
if let Err(e) = init_receiver.recv()? {
return Err(anyhow!(e));
}
Ok(pw_sender)
let (id_fl, id_fr) = res.map_err(|e| anyhow!(e))?;
Ok(PwTerminator {
ids: vec![id_fl, id_fr],
})
}