mirror of
https://github.com/arabianq/pipewire-soundpad.git
synced 2026-07-27 14:14:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d0b5f9f29 | ||
|
|
425c1df61e | ||
|
|
2073a410e8 | ||
|
|
cfa31200d4 | ||
|
|
389640ee0a | ||
|
|
7b87dc02dd | ||
|
|
785b835237 |
Generated
+5
-5
@@ -3216,7 +3216,7 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
|
||||
|
||||
[[package]]
|
||||
name = "pwsp-cli"
|
||||
version = "1.14.0"
|
||||
version = "1.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -3227,7 +3227,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pwsp-daemon"
|
||||
version = "1.14.0"
|
||||
version = "1.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -3239,7 +3239,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pwsp-gui"
|
||||
version = "1.14.0"
|
||||
version = "1.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dirs",
|
||||
@@ -3264,7 +3264,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pwsp-lib"
|
||||
version = "1.14.0"
|
||||
version = "1.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -3513,7 +3513,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "rodio"
|
||||
version = "0.22.2"
|
||||
source = "git+https://github.com/arabianq/rodio.git?rev=610955cf524467690cc9abe1a6277e8bffcadb71#610955cf524467690cc9abe1a6277e8bffcadb71"
|
||||
source = "git+https://github.com/RustAudio/rodio.git?rev=c5f1e94290922fe4ee963ce6f85754ea6ba36243#c5f1e94290922fe4ee963ce6f85754ea6ba36243"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"dasp_sample",
|
||||
|
||||
+2
-2
@@ -8,7 +8,7 @@ members = [
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "1.14.0"
|
||||
version = "1.15.0"
|
||||
edition = "2024"
|
||||
authors = ["arabian"]
|
||||
homepage = "https://pwsp.arabianq.ru"
|
||||
@@ -52,7 +52,7 @@ 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 = "610955cf524467690cc9abe1a6277e8bffcadb71", default-features = false, features = [
|
||||
rodio = { git = "https://github.com/RustAudio/rodio.git", rev = "c5f1e94290922fe4ee963ce6f85754ea6ba36243", default-features = false, features = [
|
||||
"symphonia-all",
|
||||
"symphonia-libopus",
|
||||
"playback",
|
||||
|
||||
@@ -1,25 +1,44 @@
|
||||
# PipeWire Soundpad (PWSP) 🎵
|
||||
|
||||

|
||||
<table border="0">
|
||||
<tr>
|
||||
<td colspan="2" align="center">
|
||||
<h3>Main Screen</h3>
|
||||
<img src="./pwsp-gui/assets/screenshots/main.png" alt="Main UI">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center">
|
||||
<h3>Settings</h3>
|
||||
<img src="./pwsp-gui/assets/screenshots/settings.png" alt="Settings">
|
||||
</td>
|
||||
<td width="50%" align="center">
|
||||
<h3>Hotkeys</h3>
|
||||
<img src="./pwsp-gui/assets/screenshots/hotkeys.png" alt="Hotkeys">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
[🇷🇺 Читать на русском](README.ru.md) | [🇺🇸 Read in English](README.md)
|
||||
[🇷🇺 Читать на русском](README.ru.md) | 🇬🇧 English (you are here)
|
||||
|
||||
[](https://github.com/arabianq/pipewire-soundpad/actions)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/arabianq/pipewire-soundpad/releases/latest)
|
||||
[](https://pipewire.org/)
|
||||
|
||||
**PipeWire Soundpad (PWSP)** is a modern, low-latency application that lets you play audio files directly through your microphone. Designed specifically for Linux, it leverages the power of PipeWire to achieve native integration without the need for Pulseaudio bridges or complex virtual sinks.
|
||||
**PipeWire Soundpad (PWSP)** is a modern application that lets you play audio files directly through your microphone. Designed specifically for Linux, it talks to PipeWire natively: it creates its own virtual microphone and wires everything up itself, so you never have to build a routing setup by hand.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **Native PipeWire Integration:** Direct communication with the PipeWire API for the lowest possible latency.
|
||||
- **Native PipeWire Integration:** PWSP creates its own virtual microphone and manages the graph through the PipeWire API — no manual `pw-link` juggling, no PulseAudio modules to load.
|
||||
- **Separate Monitoring and Microphone Volumes:** Two independent streams, so you can amplify what your friends hear without deafening yourself, and pick which device the monitoring plays on.
|
||||
- **Modular Architecture:** Consists of a background `daemon`, a command-line interface (`cli`), and a graphical user interface (`gui`).
|
||||
- **Modern GUI:** Built with `egui`, supporting both Wayland and X11 seamlessly.
|
||||
- **Localized:** Ships in 9 languages, following your system locale or one you pick yourself in the settings.
|
||||
- **Global Hotkeys:** Powered by `evdev`, allowing you to play sounds from anywhere.
|
||||
- **Broad Audio Support:** Powered by `rodio` and `symphonia` to support a wide range of audio formats.
|
||||
- **Broad Audio Support:** Powered by `rodio` and `symphonia` to support a wide range of audio formats, including Opus.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,6 +67,7 @@ We maintain an official APT repository for seamless updates via `apt`:
|
||||
|
||||
```bash
|
||||
# 1. Download the public GPG key
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
wget -O- https://arabianq.github.io/pipewire-soundpad/apt/pubkey.gpg | sudo gpg --dearmor -o /etc/apt/keyrings/pwsp.gpg
|
||||
|
||||
# 2. Add the repository (Choose STABLE or NIGHTLY)
|
||||
@@ -71,13 +91,26 @@ sudo dnf copr enable arabianq/pwsp
|
||||
sudo dnf install pwsp
|
||||
```
|
||||
|
||||
### 🐦 Arch Linux (AUR)
|
||||
|
||||
Two packages are available: `pwsp` builds from source, `pwsp-bin` installs the prebuilt binaries.
|
||||
|
||||
```bash
|
||||
# Using your preferred AUR helper
|
||||
paru -S pwsp # or: pwsp-bin
|
||||
```
|
||||
|
||||
### ⚙️ Manual / Standalone
|
||||
|
||||
You can manually download `.deb` packages or standalone `.zip` binaries from the [Releases page](https://github.com/arabianq/pipewire-soundpad/releases).
|
||||
|
||||
### 🦀 Build from Source
|
||||
|
||||
Make sure you have Rust, Cargo, and the required dependencies (`libpipewire-0.3-dev`, `libclang-dev`, `libasound2-dev`, `libdbus-1-dev`) installed.
|
||||
Make sure you have Rust and Cargo installed, along with the build dependencies. On Debian/Ubuntu:
|
||||
|
||||
```bash
|
||||
sudo apt install libpipewire-0.3-dev libclang-dev libasound2-dev libdbus-1-dev libssl-dev pkg-config
|
||||
```
|
||||
|
||||
```bash
|
||||
git clone https://github.com/arabianq/pipewire-soundpad.git
|
||||
@@ -112,19 +145,56 @@ pwsp-gui
|
||||
|
||||
### 3. Use the CLI
|
||||
|
||||
You can also interact with the daemon directly via the command line:
|
||||
You can also interact with the daemon directly via the command line. Every command is grouped under `action`, `get` or `set`:
|
||||
|
||||
```bash
|
||||
pwsp-cli play /path/to/sound.mp3
|
||||
pwsp-cli stop
|
||||
pwsp-cli status
|
||||
pwsp-cli action play /path/to/sound.mp3
|
||||
pwsp-cli action stop
|
||||
pwsp-cli get state
|
||||
|
||||
# Separate volumes for what you hear and what is sent to the microphone.
|
||||
# Values above 1.0 amplify.
|
||||
pwsp-cli set monitoring-volume 0.3
|
||||
pwsp-cli set mic-volume 1.5
|
||||
|
||||
# Devices
|
||||
pwsp-cli get inputs
|
||||
pwsp-cli set input <name>
|
||||
```
|
||||
|
||||
Run `pwsp-cli <group> --help` for the full list.
|
||||
|
||||
### 🔑 Enabling Global Hotkeys
|
||||
|
||||
Hotkeys are read straight from the kernel via `evdev`, which means the daemon needs access to `/dev/input/event*`. On most distributions that means adding yourself to the `input` group:
|
||||
|
||||
```bash
|
||||
sudo usermod -aG input $USER
|
||||
```
|
||||
|
||||
Log out and back in for it to take effect. Without this, everything else works — only the global hotkeys stay silent.
|
||||
|
||||
### 📦 A Note for Flatpak Users
|
||||
|
||||
The Flatpak ships a single entry point, so the commands above are reached through `flatpak run`:
|
||||
|
||||
```bash
|
||||
# No arguments: starts the daemon and the GUI together
|
||||
flatpak run ru.arabianq.pwsp
|
||||
|
||||
# The daemon on its own
|
||||
flatpak run ru.arabianq.pwsp daemon --start
|
||||
flatpak run ru.arabianq.pwsp daemon --kill
|
||||
|
||||
# Anything after "cli" is passed to pwsp-cli
|
||||
flatpak run ru.arabianq.pwsp cli action play /path/to/sound.mp3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation & DeepWiki
|
||||
|
||||
For advanced configuration, troubleshooting, architecture details, and custom setups, please visit our official Wiki:
|
||||
DeepWiki generates a browsable overview of the architecture from the source, and lets you ask questions about it. It is generated automatically rather than written by hand, so treat it as a map, not as a specification:
|
||||
|
||||
[](https://deepwiki.com/arabianq/pipewire-soundpad)
|
||||
|
||||
|
||||
+81
-11
@@ -1,25 +1,44 @@
|
||||
# PipeWire Soundpad (PWSP) 🎵
|
||||
|
||||

|
||||
<table border="0">
|
||||
<tr>
|
||||
<td colspan="2" align="center">
|
||||
<h3>Главная страница</h3>
|
||||
<img src="./pwsp-gui/assets/screenshots/main.png" alt="Main UI">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center">
|
||||
<h3>Настройки</h3>
|
||||
<img src="./pwsp-gui/assets/screenshots/settings.png" alt="Settings">
|
||||
</td>
|
||||
<td width="50%" align="center">
|
||||
<h3>Горячие клавиши</h3>
|
||||
<img src="./pwsp-gui/assets/screenshots/hotkeys.png" alt="Hotkeys">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
[🇷🇺 Читать на русском](README.ru.md) | [🇺🇸 Read in English](README.md)
|
||||
🇷🇺 Русский (вы здесь) | [🇬🇧 Read in English](README.md)
|
||||
|
||||
[](https://github.com/arabianq/pipewire-soundpad/actions)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/arabianq/pipewire-soundpad/releases/latest)
|
||||
[](https://pipewire.org/)
|
||||
|
||||
**PipeWire Soundpad (PWSP)** — это современное приложение с низкой задержкой, которое позволяет воспроизводить аудиофайлы прямо в ваш микрофон. Разработано специально для Linux с использованием мощи PipeWire для достижения нативной интеграции без использования мостов Pulseaudio или сложных виртуальных устройств.
|
||||
**PipeWire Soundpad (PWSP)** — приложение, которое позволяет воспроизводить аудиофайлы прямо в ваш микрофон. Разработано специально для Linux и работает с PipeWire напрямую: создаёт собственный виртуальный микрофон и само выстраивает маршрутизацию, так что настраивать её руками не придётся.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Возможности
|
||||
|
||||
- **Нативная интеграция с PipeWire:** Прямое взаимодействие с PipeWire API для минимальной задержки.
|
||||
- **Нативная интеграция с PipeWire:** PWSP создаёт собственный виртуальный микрофон и управляет графом через PipeWire API — без ручного `pw-link` и без подгрузки модулей PulseAudio.
|
||||
- **Раздельная громкость мониторинга и микрофона:** Два независимых потока: можно усилить то, что слышат собеседники, не оглушив себя, и выбрать устройство для мониторинга.
|
||||
- **Модульная архитектура:** Состоит из фонового демона (`daemon`), интерфейса командной строки (`cli`) и графического интерфейса (`gui`).
|
||||
- **Современный GUI:** Построен на `egui`, плавно работает как на Wayland, так и на X11.
|
||||
- **Локализация:** 9 языков; используется системный или выбранный вручную в настройках.
|
||||
- **Глобальные горячие клавиши:** Работают через `evdev`, позволяя воспроизводить звуки из любого окна.
|
||||
- **Широкая поддержка форматов:** Использует `rodio` и `symphonia` для поддержки большинства аудиоформатов.
|
||||
- **Широкая поддержка форматов:** Использует `rodio` и `symphonia` и поддерживает большинство аудиоформатов, включая Opus.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,6 +67,7 @@ flatpak install pwsp ru.arabianq.pwsp//nightly
|
||||
|
||||
```bash
|
||||
# 1. Скачайте публичный GPG ключ
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
wget -O- https://arabianq.github.io/pipewire-soundpad/apt/pubkey.gpg | sudo gpg --dearmor -o /etc/apt/keyrings/pwsp.gpg
|
||||
|
||||
# 2. Добавьте репозиторий (Выберите STABLE или NIGHTLY)
|
||||
@@ -71,13 +91,26 @@ sudo dnf copr enable arabianq/pwsp
|
||||
sudo dnf install pwsp
|
||||
```
|
||||
|
||||
### 🐦 Arch Linux (AUR)
|
||||
|
||||
Доступны два пакета: `pwsp` собирается из исходников, `pwsp-bin` ставит готовые бинарники.
|
||||
|
||||
```bash
|
||||
# Через любой удобный AUR-хелпер
|
||||
paru -S pwsp # или: pwsp-bin
|
||||
```
|
||||
|
||||
### ⚙️ Ручная установка
|
||||
|
||||
Вы можете вручную скачать пакеты `.deb` или готовые бинарники `.zip` на [странице релизов](https://github.com/arabianq/pipewire-soundpad/releases).
|
||||
|
||||
### 🦀 Сборка из исходников
|
||||
|
||||
Убедитесь, что у вас установлены Rust, Cargo и необходимые зависимости (`libpipewire-0.3-dev`, `libclang-dev`, `libasound2-dev`, `libdbus-1-dev`).
|
||||
Убедитесь, что у вас установлены Rust и Cargo, а также зависимости для сборки. Для Debian/Ubuntu:
|
||||
|
||||
```bash
|
||||
sudo apt install libpipewire-0.3-dev libclang-dev libasound2-dev libdbus-1-dev libssl-dev pkg-config
|
||||
```
|
||||
|
||||
```bash
|
||||
git clone https://github.com/arabianq/pipewire-soundpad.git
|
||||
@@ -112,19 +145,56 @@ pwsp-gui
|
||||
|
||||
### 3. Использование CLI
|
||||
|
||||
Вы также можете взаимодействовать с демоном напрямую через командную строку:
|
||||
Вы также можете взаимодействовать с демоном напрямую через командную строку. Все команды сгруппированы в `action`, `get` и `set`:
|
||||
|
||||
```bash
|
||||
pwsp-cli play /path/to/sound.mp3
|
||||
pwsp-cli stop
|
||||
pwsp-cli status
|
||||
pwsp-cli action play /path/to/sound.mp3
|
||||
pwsp-cli action stop
|
||||
pwsp-cli get state
|
||||
|
||||
# Раздельная громкость: что слышите вы и что уходит в микрофон.
|
||||
# Значения выше 1.0 усиливают.
|
||||
pwsp-cli set monitoring-volume 0.3
|
||||
pwsp-cli set mic-volume 1.5
|
||||
|
||||
# Устройства
|
||||
pwsp-cli get inputs
|
||||
pwsp-cli set input <имя>
|
||||
```
|
||||
|
||||
Полный список — `pwsp-cli <группа> --help`.
|
||||
|
||||
### 🔑 Включение глобальных горячих клавиш
|
||||
|
||||
Горячие клавиши читаются напрямую из ядра через `evdev`, поэтому демону нужен доступ к `/dev/input/event*`. В большинстве дистрибутивов для этого достаточно добавить себя в группу `input`:
|
||||
|
||||
```bash
|
||||
sudo usermod -aG input $USER
|
||||
```
|
||||
|
||||
Изменение вступит в силу после перезахода в систему. Без него всё остальное работает — молчат только глобальные горячие клавиши.
|
||||
|
||||
### 📦 Замечание для пользователей Flatpak
|
||||
|
||||
У Flatpak-версии одна точка входа, поэтому команды выше вызываются через `flatpak run`:
|
||||
|
||||
```bash
|
||||
# Без аргументов: запускает демон и GUI вместе
|
||||
flatpak run ru.arabianq.pwsp
|
||||
|
||||
# Отдельно демон
|
||||
flatpak run ru.arabianq.pwsp daemon --start
|
||||
flatpak run ru.arabianq.pwsp daemon --kill
|
||||
|
||||
# Всё после "cli" передаётся в pwsp-cli
|
||||
flatpak run ru.arabianq.pwsp cli action play /path/to/sound.mp3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 Документация и DeepWiki
|
||||
|
||||
Для детальной настройки, решения проблем, описания архитектуры и кастомных конфигураций, пожалуйста, посетите нашу официальную Wiki:
|
||||
DeepWiki автоматически строит по исходникам обзор архитектуры и позволяет задавать по нему вопросы. Он генерируется, а не пишется руками, поэтому воспринимайте его как карту, а не как спецификацию:
|
||||
|
||||
[](https://deepwiki.com/arabianq/pipewire-soundpad)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
pkgbase = pwsp-bin
|
||||
pkgdesc = Lets you play audio files through your microphone (Pre-built binaries)
|
||||
pkgver = 1.14.0
|
||||
pkgver = 1.15.0
|
||||
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.14.0.tar.gz :: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.14.0.tar.gz
|
||||
source = pipewire-soundpad-1.15.0.tar.gz :: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.15.0.tar.gz
|
||||
sha256sums = SKIP
|
||||
source_x86_64 = pwsp-1.14.0-x86_64.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.14.0/pwsp-v1.14.0-linux-x64.zip
|
||||
source_x86_64 = pwsp-1.15.0-x86_64.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.15.0/pwsp-v1.15.0-linux-x64.zip
|
||||
sha256sums_x86_64 = SKIP
|
||||
source_aarch64 = pwsp-1.14.0-aarch64.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.14.0/pwsp-v1.14.0-linux-arm64.zip
|
||||
source_aarch64 = pwsp-1.15.0-aarch64.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.15.0/pwsp-v1.15.0-linux-arm64.zip
|
||||
sha256sums_aarch64 = SKIP
|
||||
|
||||
pkgname = pwsp-bin
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maintainer: Alexander Tarasov <a.tevg@ya.ru>
|
||||
pkgname=pwsp-bin
|
||||
_pkgname=pipewire-soundpad
|
||||
pkgver=1.14.0
|
||||
pkgver=1.15.0
|
||||
pkgrel=1
|
||||
pkgdesc="Lets you play audio files through your microphone (Pre-built binaries)"
|
||||
arch=('x86_64' 'aarch64')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
pkgbase = pwsp
|
||||
pkgdesc = Lets you play audio files through your microphone
|
||||
pkgver = 1.14.0
|
||||
pkgver = 1.15.0
|
||||
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.14.0.tar.gz
|
||||
source = https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.15.0.tar.gz
|
||||
sha256sums = SKIP
|
||||
|
||||
pkgname = pwsp
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maintainer: Alexander Tarasov <a.tevg@ya.ru>
|
||||
pkgsubn=pwsp
|
||||
pkgname=pwsp
|
||||
pkgver=1.14.0
|
||||
pkgver=1.15.0
|
||||
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
@@ -15,7 +15,13 @@
|
||||
<launchable type="desktop-id">ru.arabianq.pwsp.desktop</launchable>
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<image>https://raw.githubusercontent.com/arabianq/pipewire-soundpad/main/pwsp-gui/assets/screenshot.png</image>
|
||||
<image>https://raw.githubusercontent.com/arabianq/pipewire-soundpad/main/pwsp-gui/assets/screenshots/main.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://raw.githubusercontent.com/arabianq/pipewire-soundpad/main/pwsp-gui/assets/screenshots/settings.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://raw.githubusercontent.com/arabianq/pipewire-soundpad/main/pwsp-gui/assets/screenshots/hotkeys.png</image>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<url type="homepage">https://pwsp.arabianq.ru</url>
|
||||
@@ -25,7 +31,7 @@
|
||||
<name>arabian</name>
|
||||
</developer>
|
||||
<releases>
|
||||
<release version="1.14.0" date="2026-07-24" />
|
||||
<release version="1.15.0" date="2026-07-26" />
|
||||
</releases>
|
||||
<content_rating type="oars-1.1" />
|
||||
</component>
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
|
||||
Name: pwsp
|
||||
Version: 1.14.0
|
||||
Version: 1.15.0
|
||||
Release: %autorelease
|
||||
Summary: Lets you play audio files through your microphone
|
||||
|
||||
|
||||
+26
-5
@@ -15,17 +15,17 @@ struct Cli {
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum Commands {
|
||||
/// Perform an action (ping, pause, resume, toggle-pause, stop, play)
|
||||
/// Tell the player to do something: play, stop, pause, manage hotkeys
|
||||
Action {
|
||||
#[clap(subcommand)]
|
||||
action: Actions,
|
||||
},
|
||||
/// Get information from the player (is paused, volume, position, duration, state, current-file-path, input, inputs)
|
||||
/// Read player state: volumes, position, tracks, devices, hotkeys
|
||||
Get {
|
||||
#[clap(subcommand)]
|
||||
parameter: GetCommands,
|
||||
},
|
||||
/// Set information in the player (volume, position, input)
|
||||
/// Change player settings: volumes, position, devices, loop, hotkeys
|
||||
Set {
|
||||
#[clap(subcommand)]
|
||||
parameter: SetCommands,
|
||||
@@ -69,7 +69,7 @@ enum Actions {
|
||||
#[clap(short, long)]
|
||||
id: Option<u32>,
|
||||
},
|
||||
/// Daemon configuration
|
||||
/// Write the daemon's current settings to disk
|
||||
SaveDaemonConfig,
|
||||
/// Play a sound by hotkey slot name
|
||||
PlayHotkey { slot: String },
|
||||
@@ -88,6 +88,10 @@ enum GetCommands {
|
||||
#[clap(short, long)]
|
||||
id: Option<u32>,
|
||||
},
|
||||
/// 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<u32>,
|
||||
},
|
||||
/// 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())
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 94 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
@@ -517,3 +517,69 @@ 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"
|
||||
|
||||
[gui.settings.language.label]
|
||||
en = "Language"
|
||||
ru = "Язык"
|
||||
es = "Idioma"
|
||||
fr = "Langue"
|
||||
zh = "语言"
|
||||
ar = "اللغة"
|
||||
kz = "Тіл"
|
||||
he = "שפה"
|
||||
pt-BR = "Idioma"
|
||||
|
||||
[gui.settings.language.system]
|
||||
en = "System"
|
||||
ru = "Системный"
|
||||
es = "Sistema"
|
||||
fr = "Système"
|
||||
zh = "系统"
|
||||
ar = "النظام"
|
||||
kz = "Жүйе"
|
||||
he = "מערכת"
|
||||
pt-BR = "Sistema"
|
||||
|
||||
@@ -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<u32>) {
|
||||
make_request_async(Request::toggle_loop(id));
|
||||
}
|
||||
|
||||
+32
-53
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<TrackAction> {
|
||||
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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::gui::SoundpadGui;
|
||||
use crate::{gui::SoundpadGui, locale};
|
||||
use egui::{Align, Button, Color32, ComboBox, Layout, RichText, Slider, Ui};
|
||||
use egui_material_icons::icons::ICON_ARROW_BACK;
|
||||
use pwsp_lib::types::config::PreferredTheme;
|
||||
@@ -62,6 +62,10 @@ impl SoundpadGui {
|
||||
ui.separator();
|
||||
|
||||
// ---------- Selectors -----------
|
||||
self.draw_mic_selection(ui);
|
||||
self.draw_output_selection(ui);
|
||||
self.draw_language_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 +101,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 +127,90 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_language_selection(&mut self, ui: &mut Ui) {
|
||||
let mut selected = self.config.forced_lang.clone();
|
||||
let previous = selected.clone();
|
||||
|
||||
let selected_text = match &selected {
|
||||
Some(code) => locale::display_name(code).to_string(),
|
||||
None => t!("gui.settings.language.system").to_string(),
|
||||
};
|
||||
|
||||
ComboBox::from_label(t!("gui.settings.language.label"))
|
||||
.height(30.0)
|
||||
.selected_text(selected_text)
|
||||
.show_ui(ui, |ui| {
|
||||
// Listed first so picking a language stays undoable.
|
||||
ui.selectable_value(&mut selected, None, t!("gui.settings.language.system"));
|
||||
for code in locale::available() {
|
||||
let name = locale::display_name(&code);
|
||||
ui.selectable_value(&mut selected, Some(code.clone()), name);
|
||||
}
|
||||
});
|
||||
|
||||
if selected != previous {
|
||||
self.config.forced_lang = selected;
|
||||
self.config.save_to_file().ok();
|
||||
locale::apply(&self.config);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
use pwsp_lib::types::config::GuiConfig;
|
||||
|
||||
/// Applies the UI language: the one pinned in the config, or the system's when none is.
|
||||
///
|
||||
/// Safe to call at any time — `t!` reads the locale on every lookup, so the next frame
|
||||
/// already renders in the new language.
|
||||
pub fn apply(config: &GuiConfig) {
|
||||
let locale = config.forced_lang.clone().unwrap_or_else(system_locale);
|
||||
rust_i18n::set_locale(&locale);
|
||||
}
|
||||
|
||||
fn system_locale() -> String {
|
||||
sys_locale::get_locale().unwrap_or_else(|| String::from("en-US"))
|
||||
}
|
||||
|
||||
/// Locales shipped under `locales/`, in a stable order for the menu.
|
||||
pub fn available() -> Vec<String> {
|
||||
let mut locales: Vec<String> = rust_i18n::available_locales!()
|
||||
.into_iter()
|
||||
.map(|locale| locale.to_string())
|
||||
.collect();
|
||||
locales.sort_unstable();
|
||||
locales
|
||||
}
|
||||
|
||||
/// How a language names itself, which is how it should appear in a language picker
|
||||
/// regardless of the language the rest of the UI is in. An unnamed locale falls back to
|
||||
/// its code, so a newly shipped translation is still selectable.
|
||||
pub fn display_name(code: &str) -> &str {
|
||||
match code {
|
||||
"en" => "English",
|
||||
"ru" => "Русский",
|
||||
"es" => "Español",
|
||||
"fr" => "Français",
|
||||
"zh" => "中文",
|
||||
"ar" => "العربية",
|
||||
"kz" => "Қазақша",
|
||||
"he" => "עברית",
|
||||
"pt-BR" => "Português (Brasil)",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_shipped_locale_has_a_native_name() {
|
||||
for code in available() {
|
||||
assert_ne!(
|
||||
display_name(&code),
|
||||
code,
|
||||
"locale {code} falls back to its code in the picker"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
mod gui;
|
||||
mod locale;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use pwsp_lib::utils::gui::ensure_pwsp_audio_dir;
|
||||
use pwsp_lib::utils::gui::{ensure_pwsp_audio_dir, get_gui_config};
|
||||
use rust_i18n::i18n;
|
||||
use std::{
|
||||
env,
|
||||
@@ -13,8 +14,7 @@ i18n!("locales", fallback = "en");
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let locale = sys_locale::get_locale().unwrap_or(String::from("en-US"));
|
||||
rust_i18n::set_locale(&locale);
|
||||
locale::apply(&get_gui_config());
|
||||
|
||||
let args = env::args().skip(1).collect::<Vec<String>>();
|
||||
|
||||
|
||||
+504
-191
@@ -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<BufReader<fs::File>>;
|
||||
|
||||
#[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<TrackInfo>,
|
||||
pub volume: f32,
|
||||
pub monitoring_volume: f32,
|
||||
pub mic_volume: f32,
|
||||
pub volume_multiplier: f32,
|
||||
pub current_input: String,
|
||||
pub all_inputs: HashMap<String, String>,
|
||||
pub current_output: String,
|
||||
pub all_outputs: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// 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<Player>,
|
||||
}
|
||||
|
||||
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<f32>,
|
||||
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<MixerDeviceSink>,
|
||||
monitoring_stream: Option<MixerDeviceSink>,
|
||||
mic_stream: Option<MixerDeviceSink>,
|
||||
|
||||
/// PipeWire nodes backing the two streams, once discovered.
|
||||
monitoring_node: Option<AudioDevice>,
|
||||
mic_node: Option<AudioDevice>,
|
||||
|
||||
/// Links we created ourselves; dropping them tears the routes down.
|
||||
monitoring_route: Option<PwTerminator>,
|
||||
mic_route: Option<PwTerminator>,
|
||||
input_link_sender: Option<PwTerminator>,
|
||||
|
||||
pub tracks: HashMap<u32, PlayingSound>,
|
||||
pub next_id: u32,
|
||||
|
||||
input_link_sender: Option<PwTerminator>,
|
||||
player_link_sender: Option<PwTerminator>,
|
||||
pub input_device_name: Option<String>,
|
||||
/// `None` pins nothing, leaving the monitoring stream to be routed like any other
|
||||
/// application's.
|
||||
pub output_device_name: Option<String>,
|
||||
|
||||
pub volume: f32, // Master volume
|
||||
pub monitoring_volume: f32,
|
||||
pub mic_volume: f32,
|
||||
pub volume_multiplier: f32,
|
||||
}
|
||||
|
||||
impl AudioPlayer {
|
||||
pub async fn new() -> Result<Self> {
|
||||
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<AudioDevice>,
|
||||
) -> Result<Option<PwTerminator>> {
|
||||
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<u32>) {
|
||||
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<u32>) {
|
||||
self.for_selected(id, |sound| sound.players.for_each(|p| p.play()));
|
||||
}
|
||||
|
||||
fn for_selected(&mut self, id: Option<u32>, 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<u32>) -> Option<f32> {
|
||||
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<u32>) -> Option<f32> {
|
||||
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<u32>) {
|
||||
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<u32>) -> 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<u32>) -> 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<u32> {
|
||||
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<dyn Error + Send + Sync>> {
|
||||
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<dyn Error + Send + Sync>)?;
|
||||
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<u32>) {
|
||||
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<TrackInfo> {
|
||||
@@ -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<u32> = 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<FileDecoder> {
|
||||
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<u32> {
|
||||
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<AudioDevice>)> {
|
||||
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
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
+139
-12
@@ -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<f32>,
|
||||
}
|
||||
|
||||
pub struct GetMasterVolumeCommand {
|
||||
pub target: VolumeTarget,
|
||||
}
|
||||
|
||||
pub struct SetMasterVolumeCommand {
|
||||
pub volume: Option<f32>,
|
||||
pub target: VolumeTarget,
|
||||
}
|
||||
|
||||
pub struct GetPositionCommand {
|
||||
pub id: Option<u32>,
|
||||
}
|
||||
@@ -85,6 +94,14 @@ pub struct SetCurrentInputCommand {
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
pub struct GetCurrentOutputCommand {}
|
||||
|
||||
pub struct GetAllOutputsCommand {}
|
||||
|
||||
pub struct SetCurrentOutputCommand {
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
pub struct SetLoopCommand {
|
||||
pub enabled: Option<bool>,
|
||||
pub id: Option<u32>,
|
||||
@@ -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<f32>) -> Option<f32> {
|
||||
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::<Vec<_>>()
|
||||
.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<String, String> = 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) {
|
||||
|
||||
@@ -16,7 +16,9 @@ use std::{
|
||||
#[serde(default)]
|
||||
pub struct DaemonConfig {
|
||||
pub default_input_name: Option<String>,
|
||||
pub default_volume: Option<f32>,
|
||||
pub default_output_name: Option<String>,
|
||||
pub default_monitoring_volume: Option<f32>,
|
||||
pub default_mic_volume: Option<f32>,
|
||||
pub default_volume_multiplier: Option<f32>,
|
||||
}
|
||||
|
||||
@@ -83,6 +85,8 @@ pub struct GuiConfig {
|
||||
pub dirs_settings: HashMap<PathBuf, DirSettings>,
|
||||
|
||||
pub preferred_theme: PreferredTheme,
|
||||
/// UI language code, or `None` to follow the system locale.
|
||||
pub forced_lang: Option<String>,
|
||||
}
|
||||
|
||||
impl SortOrder {
|
||||
@@ -128,6 +132,7 @@ impl Default for GuiConfig {
|
||||
|
||||
preferred_theme: PreferredTheme::System,
|
||||
dirs_settings: HashMap::new(),
|
||||
forced_lang: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+103
-17
@@ -9,21 +9,64 @@ use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
path::PathBuf,
|
||||
sync::{Arc, Mutex},
|
||||
time::Instant,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
pub type ScanResult = (PathBuf, Vec<PathBuf>, HashMap<PathBuf, Vec<PathBuf>>);
|
||||
|
||||
/// 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<Instant>,
|
||||
}
|
||||
|
||||
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<f32> {
|
||||
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<Instant>,
|
||||
pub ignore_volume_update_until: Option<Instant>,
|
||||
pub position: SliderLatch,
|
||||
pub volume: SliderLatch,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
@@ -33,18 +76,14 @@ pub struct AppState {
|
||||
pub track_ui_states: HashMap<u32, TrackUiState>,
|
||||
|
||||
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<Id>,
|
||||
|
||||
pub ignore_volume_update_until: Option<Instant>,
|
||||
pub ignore_volume_multiplier_update_until: Option<Instant>,
|
||||
|
||||
pub current_dir: Option<PathBuf>,
|
||||
pub dirs: Vec<PathBuf>,
|
||||
pub dirs_to_remove: HashSet<PathBuf>,
|
||||
@@ -75,14 +114,61 @@ pub struct AudioPlayerState {
|
||||
|
||||
pub tracks: Vec<TrackInfo>,
|
||||
|
||||
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<String, String>,
|
||||
pub all_inputs_sorted: Vec<(String, String)>,
|
||||
|
||||
/// Empty means "follow the system default sink".
|
||||
pub current_output: String,
|
||||
pub all_outputs: HashMap<String, String>,
|
||||
pub all_outputs_sorted: Vec<(String, String)>,
|
||||
|
||||
pub is_daemon_running: bool,
|
||||
|
||||
pub hotkey_config: Option<HotkeyConfig>,
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<u32>) -> 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
|
||||
|
||||
@@ -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<VolumeTarget> {
|
||||
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<Box<dyn Executable + Send>> {
|
||||
let id = request.args.get("id").and_then(|s| s.parse::<u32>().ok());
|
||||
|
||||
@@ -25,6 +36,21 @@ pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> {
|
||||
.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::<f32>()
|
||||
.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<Box<dyn Executable + Send>> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, String>) -> 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<Mutex<AudioPlayerState>>) {
|
||||
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<Mutex<AudioPlayerSt
|
||||
None => 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<Mutex<AudioPlayerSt
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
guard.current_output = full_state.current_output;
|
||||
|
||||
if guard.all_inputs != full_state.all_inputs {
|
||||
guard.all_inputs = full_state.all_inputs;
|
||||
let mut sorted: Vec<(String, String)> = 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;
|
||||
|
||||
+224
-91
@@ -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<AudioDevice>,
|
||||
/// `Stream/Output/Audio` nodes — application playback streams, including our own.
|
||||
pub outputs: Vec<AudioDevice>,
|
||||
/// `Audio/Sink` nodes — speakers and headphones.
|
||||
pub sinks: Vec<AudioDevice>,
|
||||
}
|
||||
|
||||
impl AllDevices {
|
||||
pub fn iter(&self) -> impl Iterator<Item = &AudioDevice> {
|
||||
self.inputs
|
||||
.iter()
|
||||
.chain(self.outputs.iter())
|
||||
.chain(self.sinks.iter())
|
||||
}
|
||||
}
|
||||
|
||||
pub enum PwCommand {
|
||||
GetDevices {
|
||||
resp: oneshot::Sender<(Vec<AudioDevice>, Vec<AudioDevice>)>,
|
||||
resp: oneshot::Sender<AllDevices>,
|
||||
},
|
||||
GetLinks {
|
||||
resp: oneshot::Sender<Vec<LinkInfo>>,
|
||||
},
|
||||
CreateVirtualMic {
|
||||
resp: oneshot::Sender<Result<u32, String>>,
|
||||
@@ -21,14 +43,21 @@ pub enum PwCommand {
|
||||
input_fr: Port,
|
||||
resp: oneshot::Sender<Result<(u32, u32), String>>,
|
||||
},
|
||||
/// 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<u32, AudioDevice>,
|
||||
output_devices: HashMap<u32, AudioDevice>,
|
||||
sink_devices: HashMap<u32, AudioDevice>,
|
||||
links: HashMap<u32, LinkInfo>,
|
||||
ports: HashMap<u32, Port>,
|
||||
proxies: HashMap<u32, Box<dyn std::any::Any>>,
|
||||
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<AudioDevice> =
|
||||
s.output_devices.values().cloned().collect();
|
||||
let mut sinks: Vec<AudioDevice> =
|
||||
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<LinkInfo> = 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<AudioDevice>, Option<Port>) {
|
||||
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::<u32>().ok()),
|
||||
props
|
||||
.get("link.input.node")
|
||||
.and_then(|id| id.parse::<u32>().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<AudioDevice>, Vec<AudioDevice>)> {
|
||||
pub async fn get_all_devices() -> Result<AllDevices> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let manager = get_manager();
|
||||
manager
|
||||
@@ -294,19 +374,52 @@ pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
pub async fn get_device(device_name: &str) -> Result<AudioDevice> {
|
||||
let (input_devices, output_devices) = get_all_devices().await?;
|
||||
pub async fn get_all_links() -> Result<Vec<LinkInfo>> {
|
||||
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<AudioDevice> {
|
||||
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<AudioDevice> {
|
||||
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<AudioDevice> {
|
||||
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<PwTerminator> {
|
||||
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(_) => {
|
||||
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<Option<PwTerminator>> {
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user