Compare commits

..
7 Commits
Author SHA1 Message Date
arabianq 0d0b5f9f29 change version to 1.15.0 2026-07-26 21:52:51 +03:00
arabianqandClaude Opus 5 425c1df61e docs(cli): stop the group descriptions from going stale
Each of the three groups listed its subcommands by hand, and all three lists had
drifted: `get` advertised a `current-file-path` that does not exist while
omitting tracks, outputs, hotkeys and both new volumes; `set` omitted loop,
output and hotkeys; `action` omitted kill, toggle-loop and the hotkey commands.

Replaced the hand-kept lists with a short description of what each group is for.
Clap already prints the real subcommands underneath, so duplicating them in the
parent was guaranteed to rot.

Also `save-daemon-config`, an action, was described as "Daemon configuration",
which says nothing about what it does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 21:49:40 +03:00
arabianqandClaude Opus 5 2073a410e8 docs: correct the README
Not one of the three CLI examples worked: commands live under `action`, `get` and
`set`, and `status` never existed. Replaced with commands that were actually run
against a live daemon, plus the separate monitoring/mic volumes.

Other corrections:

- Build deps were missing libssl-dev and pkg-config, which CI installs and
  openssl-sys needs — building from source failed by following the README.
- The Flatpak, which the README recommends first, has a single entry point, so
  none of the bare pwsp-* commands apply there. Documented `flatpak run`.
- Global hotkeys are a headline feature but need read access to /dev/input,
  i.e. membership in the `input` group. Nothing said so, and the failure is
  silent.
- AUR packages exist in packages/aur but were not mentioned.
- "without ... complex virtual sinks" was false: PWSP creates a
  support.null-audio-sink node itself. Reworded to say what it actually does.
- "Direct communication with the PipeWire API for the lowest possible latency"
  overstated it. The PipeWire API is used for the graph — the virtual mic,
  device discovery, links — while audio goes through rodio, which is why the
  node shows up as alsa_playback.pwsp-daemon.
- /etc/apt/keyrings does not exist on older Debian/Ubuntu; added mkdir -p.
- DeepWiki was called "our official Wiki"; it is generated from the source
  automatically, so it is now described as such.
- Each language link pointed at the file you were already reading.
- Features listed neither the separate volumes nor the localization.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 21:49:30 +03:00
arabianq cfa31200d4 docs: update screenshots 2026-07-26 21:36:22 +03:00
389640ee0a feat(gui): let the UI language be chosen manually (#179)
The interface followed the system locale with no way to override it. Settings now
carry a language picker, stored as forced_lang in gui.json, with "System" listed
first so the choice stays undoable.

rust-i18n keeps the current locale in a process-wide atomic and t! reads it on
every lookup, so switching takes effect on the next frame without a restart.

Languages are listed by their own name rather than a translated one, since that
is what a reader looking for their language recognises. A test asserts every
shipped locale has such a name, so adding a translation without naming it fails
rather than silently showing a bare language code.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 21:24:54 +03:00
arabianq 7b87dc02dd deps: arabianq/rodio.git -> RustAudio/rodio.git 2026-07-26 20:55:26 +03:00
785b835237 feat: separate monitoring and output streams (#178)
* feat: split playback into separate monitoring and mic streams

AudioPlayer held a single rodio stream. WirePlumber auto-connected its node to
the default sink (that is the monitoring path) while PWSP explicitly linked the
very same node into pwsp-virtual-mic. Both paths were fed by one Player, so
Player::set_volume scaled them together and they could not be separated — hence
the report of "volume 2 is fine on the other side but deafening locally".

There are now two independent streams with independent gains:

  mic stream        --ensure_route()--> pwsp-virtual-mic
  monitoring stream --ensure_route()--> selected Audio/Sink (or WirePlumber's
                                        choice when no device is pinned)

PipeWire side:
- DeviceType::Sink, so sinks are discoverable like inputs already were, plus the
  playback_* port mapping that Audio/Sink nodes need (monitor_* stays unmapped:
  linking a stream there would be a feedback loop).
- Link globals are tracked in the registry listener; GetLinks/DestroyGlobal make
  ensure_route() possible. It points a node at one target and prunes every other
  link leaving it, creating before pruning so the node is never left unlinked —
  otherwise WirePlumber's autoconnect would re-attach it behind our back.
  Replaces link_player_to_virtual_mic().

Audio side:
- PlayerPair wraps the two rodio Players of a track so transport controls reach
  both from one place instead of being fanned out at every call site.
- Streams are identified by diffing our own stream nodes before and after opening
  one, not by indexing a sorted list: PipeWire reuses freed node ids, so the
  stream opened second can end up with the lower id. Only nodes that look like
  ours are ever considered — pruning a stranger's node would silence another
  application. Opens are therefore sequential, and node discovery checks the
  graph before its first sleep to stay off the play latency path.
- Streams are dropped once nothing is playing, as before: an open stream keeps
  the audio device busy and stops laptops from suspending. The routing is
  rebuilt on the next play.
- Each track decodes twice, once per path. rodio's Buffered is the only shareable
  source and it cannot seek, which the position slider depends on.
- effective_gain() is the single home of the master * track * multiplier math and
  collapses non-finite or negative gains to silence.

Protocol, config and GUI:
- get/set_monitoring_volume, get/set_mic_volume, get/set_output, get_outputs.
- DaemonConfig gains default_output_name, default_monitoring_volume and
  default_mic_volume.
- Volumes arriving over IPC are validated: hotkeys store raw Request JSON, so a
  bad value can reach the daemon without passing through the CLI.
- The footer carries a monitoring slider and a mic slider, both running to 200%,
  plus an output combo box; with four widgets it no longer fits on one line and
  is laid out as two rows.
- SliderLatch owns the "local value wins while the user is interacting" logic
  every slider needs — the daemon is polled at 60 Hz, so without it a slider
  fights the user mid-drag and snaps back on release. That was previously
  copy-pasted per slider as a value/dragged/ignore-until triple.

Fixes along the way: setting the volume multiplier no longer overwrites the
master volume, and get_volume(Some(id)) reports the track's own volume instead of
the product of all three factors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(cli): add monitoring-volume, mic-volume and output commands

get/set volume keeps its old meaning — without --id it moves both masters at
once, which is the "make everything quieter" shortcut. The new commands address
one path each, and values above 1.0 pass through on purpose: amplifying what
goes into the microphone without deafening yourself is the point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(gui): move device selection into settings

The footer tried to hold two combo boxes, two sliders and two buttons on one
line. Their labels alone run ~300px, so at the 800px minimum window size the
sliders and buttons were pushed off the edge and simply vanished.

Device pickers are set-once controls and belong next to the theme selector, so
they move to the settings screen. The footer keeps the two volume sliders, which
are the ones worth reaching for mid-call, and now fits at the minimum width.

The right-edge spacer is also clamped at zero: computed negative, it used to
shove the buttons out of view rather than merely crowding them.

Verified with screenshots at 800px and 1200px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(gui): align the footer volume icons and sliders

The speaker icon and its slider sat a couple of pixels above the microphone
pair. A horizontal layout centres each widget against the row height known when
that widget is placed, so a row that grows while being filled leaves whatever
was added first sitting too high.

Every footer element is now allocated the same box height, which makes the
centring independent of placement order, and the row is given that height up
front. Measured from screenshots at 800px: icon ink centres were 776.5 and
780.5, now both 776.5; slider rails were 2.5px apart, now 0.5px, which is
sub-pixel rounding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(gui): let the output device be unpinned again

The output picker listed devices but no way back, so choosing one was a one-way
door. It now offers "system default" as the first entry, which sends set_output
with an empty name; the daemon takes that as "stop pinning".

Unpinning deliberately leaves the existing link alone rather than tearing it
down. WirePlumber's autoconnect only runs when a node first appears, so removing
the link would strand the monitoring stream with no output at all. Streams close
as soon as playback stops, so the next sound opens a fresh node that gets routed
like any other application's.

Picking the default target ourselves was tried and reverted: the global
default.audio.sink is only a fallback, and a per-stream target.node overrides it.
On this machine that meant monitoring would have gone straight to the hardware
sink, bypassing the user's EasyEffects chain that WirePlumber routes it through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor: trim comments and drop an abstraction that earned nothing

Removes the RouteTarget trait and its two unit-struct implementations. They
existed to let route() resolve a target lazily, but both call sites resolve one
line earlier just as well, so a trait, two types and an impl block collapse into
one parameter.

Narrows should_sync, commit and prune_links_from to private: all three were
public but only ever called from within their own module.

Comment pass: drops the decorative section dividers that this file never had,
and rewrites the ones that leaned on a particular machine's setup or narrated a
past bug. What is left explains things the code cannot: why streams close when
idle, why they are opened one at a time, why a link is created before stale ones
are pruned, why playback_* maps to inputs while monitor_* stays unmapped, and
why the footer row height is pinned up front.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 20:36:10 +03:00
34 changed files with 1743 additions and 521 deletions
Generated
+5 -5
View File
@@ -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
View File
@@ -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",
+81 -11
View File
@@ -1,25 +1,44 @@
# PipeWire Soundpad (PWSP) 🎵
![PWSP Screenshot](pwsp-gui/assets/screenshot.png)
<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)
[![GitHub Actions Build Status](https://img.shields.io/github/actions/workflow/status/arabianq/pipewire-soundpad/build.yml?branch=main&style=flat-square)](https://github.com/arabianq/pipewire-soundpad/actions)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT)
[![GitHub Release](https://img.shields.io/github/v/release/arabianq/pipewire-soundpad?style=flat-square)](https://github.com/arabianq/pipewire-soundpad/releases/latest)
[![Platform](https://img.shields.io/badge/Platform-Linux%20%7C%20PipeWire-blue?style=flat-square)](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:
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/arabianq/pipewire-soundpad)
+81 -11
View File
@@ -1,25 +1,44 @@
# PipeWire Soundpad (PWSP) 🎵
![PWSP Screenshot](pwsp-gui/assets/screenshot.png)
<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)
[![GitHub Actions Build Status](https://img.shields.io/github/actions/workflow/status/arabianq/pipewire-soundpad/build.yml?branch=main&style=flat-square)](https://github.com/arabianq/pipewire-soundpad/actions)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT)
[![GitHub Release](https://img.shields.io/github/v/release/arabianq/pipewire-soundpad?style=flat-square)](https://github.com/arabianq/pipewire-soundpad/releases/latest)
[![Platform](https://img.shields.io/badge/Platform-Linux%20%7C%20PipeWire-blue?style=flat-square)](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 автоматически строит по исходникам обзор архитектуры и позволяет задавать по нему вопросы. Он генерируется, а не пишется руками, поэтому воспринимайте его как карту, а не как спецификацию:
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/arabianq/pipewire-soundpad)
+4 -4
View File
@@ -1,6 +1,6 @@
pkgbase = pwsp-bin
pkgdesc = Lets you play audio files through your microphone (Pre-built binaries)
pkgver = 1.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 -1
View File
@@ -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')
+2 -2
View File
@@ -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 -1
View File
@@ -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>
+1 -1
View File
@@ -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
View File
@@ -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

+66
View File
@@ -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"
+12
View File
@@ -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
View File
@@ -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();
}
}
+63 -49
View File
@@ -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;
}
+6 -24
View File
@@ -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;
+4
View File
@@ -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);
}
}
+96 -17
View File
@@ -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);
}
}
}
+58
View File
@@ -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"
);
}
}
}
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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) {
+6 -1
View File
@@ -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
View File
@@ -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);
}
}
+37 -3
View File
@@ -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);
+66
View File
@@ -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
+86 -1
View File
@@ -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());
}
}
+20 -8
View File
@@ -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
View File
@@ -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(