From 2849584b38d31f8a6c476aec0b1316ff60a753fe Mon Sep 17 00:00:00 2001 From: Konstantin <87010935+Lyten02@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:43:36 +0100 Subject: [PATCH 1/6] Desktop app for Linux (PipeWire): PC audio to the phone, phone mic as a PipeWire source - crates/desktop/src/linux: PipeWire engine (default-sink monitor capture, virtual mic audiobridge_mic, mic demand, default source, volume), MPRIS media keys, SNI tray, XDG autostart/paths, single instance (--quit); Windows modules behind cfg(windows) - packaging: scripts/package-linux.sh (tar.gz + install.sh, pacman package), launcher entry, icon - CI: linux job on the self-hosted runner; docs: README, AGENTS.md, CONTRIBUTING.md --- .github/workflows/ci.yml | 25 + AGENTS.md | 41 +- CONTRIBUTING.md | 4 +- Cargo.lock | 866 +++++++++++++++++++++- README.md | 30 +- README.ru.md | 26 +- crates/desktop/Cargo.toml | 16 +- crates/desktop/src/audio/devices.rs | 49 +- crates/desktop/src/audio/mod.rs | 12 +- crates/desktop/src/endpoints.rs | 52 ++ crates/desktop/src/linux/audio/graph.rs | 183 +++++ crates/desktop/src/linux/audio/mod.rs | 584 +++++++++++++++ crates/desktop/src/linux/audio/oneshot.rs | 171 +++++ crates/desktop/src/linux/audio/streams.rs | 217 ++++++ crates/desktop/src/linux/audio/volume.rs | 152 ++++ crates/desktop/src/linux/autostart.rs | 69 ++ crates/desktop/src/linux/instance.rs | 114 +++ crates/desktop/src/linux/media.rs | 362 +++++++++ crates/desktop/src/linux/paths.rs | 101 +++ crates/desktop/src/linux/tray.rs | 172 +++++ crates/desktop/src/main.rs | 121 ++- crates/desktop/src/service.rs | 4 +- crates/desktop/src/shared.rs | 68 +- crates/desktop/src/tray.rs | 17 +- crates/desktop/src/ui.rs | 127 +++- packaging/linux/audiobridge.desktop | 14 + packaging/linux/audiobridge.svg | 14 + packaging/linux/install.sh | 52 ++ scripts/package-linux.sh | 80 ++ 29 files changed, 3584 insertions(+), 159 deletions(-) create mode 100644 crates/desktop/src/endpoints.rs create mode 100644 crates/desktop/src/linux/audio/graph.rs create mode 100644 crates/desktop/src/linux/audio/mod.rs create mode 100644 crates/desktop/src/linux/audio/oneshot.rs create mode 100644 crates/desktop/src/linux/audio/streams.rs create mode 100644 crates/desktop/src/linux/audio/volume.rs create mode 100644 crates/desktop/src/linux/autostart.rs create mode 100644 crates/desktop/src/linux/instance.rs create mode 100644 crates/desktop/src/linux/media.rs create mode 100644 crates/desktop/src/linux/paths.rs create mode 100644 crates/desktop/src/linux/tray.rs create mode 100644 packaging/linux/audiobridge.desktop create mode 100644 packaging/linux/audiobridge.svg create mode 100755 packaging/linux/install.sh create mode 100755 scripts/package-linux.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13c8f5f..33c069c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,8 @@ env: # Self-hosted jobs run on the netcup server's `audiobridge` runner, which already has the # toolchains (Rust + targets, JDK 17, Android SDK/NDK r30, cargo-ndk 4.x, cargo-xwin + LLVM). +# The Linux desktop job also needs the PipeWire 1.x SDK (headers, pkg-config, libclang); on the +# runner it is unpacked without root under ~/opt/pipewire-sdk and enabled by its env.sh. # Fork PRs are never executed there. CARGO_BUILD_JOBS=2 leaves half of its 4 cores free. jobs: rust: @@ -56,6 +58,29 @@ jobs: path: target/x86_64-pc-windows-msvc/release/AudioBridge.exe if-no-files-found: error + linux: + name: Linux desktop (PipeWire) + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: [self-hosted, audiobridge] + env: + CARGO_BUILD_JOBS: 2 + steps: + - uses: actions/checkout@v4 + - name: Clippy + run: . "$HOME/opt/pipewire-sdk/env.sh" && cargo clippy -p audiobridge-desktop --all-targets -- -D warnings + - name: Tests + run: . "$HOME/opt/pipewire-sdk/env.sh" && cargo test -p audiobridge-desktop + - name: Build + package (tar.gz, pacman) + run: | + . "$HOME/opt/pipewire-sdk/env.sh" + cargo build -p audiobridge-desktop --release + scripts/package-linux.sh + - uses: actions/upload-artifact@v4 + with: + name: AudioBridge-linux-x86_64 + path: target/dist/* + if-no-files-found: error + android: name: Android APK if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository diff --git a/AGENTS.md b/AGENTS.md index 8ca2ac5..1b5ba9d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,9 @@ # Repository Guidelines ## Project Overview -AudioBridge turns an Android phone into the "headphones and microphone" of one or more Windows PCs: -- PC system audio (WASAPI loopback) → phone → Bluetooth headphones. -- Phone mic → PC virtual microphone (VB-CABLE "CABLE Output"). +AudioBridge turns an Android phone into the "headphones and microphone" of one or more Windows or Linux PCs: +- PC system audio (WASAPI loopback; on Linux the PipeWire default sink's monitor) → phone → Bluetooth headphones. +- Phone mic → PC virtual microphone (VB-CABLE "CABLE Output"; on Linux AudioBridge's own PipeWire source `audiobridge_mic`). Transport is iroh QUIC: LAN, hole-punched, relay fallback, and Tailscale IPs included in the pairing. Pairing is a single QR scan (`audiobridge://pair?d=`). After that, both sides run in the background and autostart on boot. The phone can be paired with up to 8 PCs at once: audio from all of them is mixed, and the mic goes only to PCs whose apps are actually recording. All UI text is Russian; code, comments and logs are English. `README.md` / `README.ru.md` are the user-facing docs; this file is the developer doc. Public repo: `github.com/Lyten02/AudioBridge` (MIT); `main` is protected, changes go through PRs with the CI in `.github/workflows/ci.yml`. @@ -20,7 +20,7 @@ PC (Server) WASAPI render "CABLE Input" ◄─ PlayoutHandle (jitter buffer, de - **Roles:** `session::Server` is the PC side; it serves one active phone and a new connection replaces the old one (close codes 1 = replaced, 2 = rejected, 3 = shutdown). `session::Hub` is the phone side: one iroh endpoint, one task per PC, `MAX_PEERS = 8`, `SLOTS = 16`. - **Dialing:** the phone always dials the PC, which works through NAT and double NAT. - **Control (protocol v3):** a single bi-stream carrying `ControlMsg` frames (`len u16 | tag | body`): Hello{secret, PhoneControls} → Welcome{PcControls}/Reject, PcState, MicDemand, PhoneState, SetPc, SetPhone, PcMedia (v3). Each side reads the stream through `spawn_control_reader` (a task feeding a channel), so `select!` never cancels a half-read frame. A Hello of another version still decodes far enough to be rejected with a readable reason. -- **Remote control:** each side owns its controls and reports them (`PcControls`: audio, mic, mic_default = CABLE Output is the Windows default recording device, default playback volume; `PhoneControls`: mic switch, mic_ready, media volume). The other side may ask for a change (`PcRequest` / `PhoneRequest`). Core never applies requests: `Server::take_requests` / `Hub::take_requests` hand them to the app, which applies them and reports the result through the setters (`Server::set_mic_default`, `set_volume`, …; `Hub::set_phone_controls`). `Status.pc` / `Status.phone` carry the reported state. +- **Remote control:** each side owns its controls and reports them (`PcControls`: audio, mic, mic_default = the virtual mic is the default recording device, default playback volume; `PhoneControls`: mic switch, mic_ready, media volume). The other side may ask for a change (`PcRequest` / `PhoneRequest`). Core never applies requests: `Server::take_requests` / `Hub::take_requests` hand them to the app, which applies them and reports the result through the setters (`Server::set_mic_default`, `set_volume`, …; `Hub::set_phone_controls`). `Status.pc` / `Status.phone` carry the reported state. - **One-switch actions (PC):** "Микрофон" on = accept the mic + ask the phone to switch its mic on + make CABLE Output the default recording device; off = restore the remembered recording device (the phone mic is left alone: other PCs may use it). "Звук ПК" on also undoes a CABLE takeover of the default playback device. - **Headphone buttons → PC media (v3):** the earbuds turn taps into AVRCP commands; Android's Bluetooth stack dispatches play/pause/next/previous as media key events to the media session of the app that played audio last. `HeadsetControl.kt` (owned by `BridgeService`) keeps a `MediaSession` while the feature is on and exactly one PC is the target (`MediaTarget` in `MediaKeys.kt`: the chosen PC while connected, else the unmuted connected PC that plays / played or was controlled last / the only one; ambiguous → none, never fan-out). Without a target the session is released, so the keys go to other players. Its `PlaybackState` mirrors the target's `PcMedia`, which is also what the earbuds use to choose "play" or "pause". A press (first ACTION_DOWN) becomes `PcRequest::Media(MediaCommand)` via `controlPc(id, PC_MEDIA, code)`; a 3 s timed wake lock covers the screen-off send. The PC applies it to the Windows media session (GSMTC, `crates/desktop/src/media.rs`; SendInput media keys only when no session exists or the call fails) and reports `PcMedia` (playback, app, title, artist) through `Server::set_media`. Volume taps are NOT media keys: AVRCP volume up/down is dropped by `MediaSessionService` (not `isMediaSessionKey`) and absolute volume sets the phone's STREAM_MUSIC directly, so apps can't see or remap them; don't add a system-volume observer to fake it. "Один наушник" remaps only what arrives: the play/pause group = 2 taps, next/previous = 3 taps. - **Datagram header:** 8 bytes, `0xAB | kind | stream | 0 | seq u32 LE`. Kinds: Audio, Silence (after 300 ms of digital silence the sender emits markers and stops), Pace (an empty keep-awake packet that the Hub sends every 20 ms while a PC's audio streams and that PC isn't receiving our mic; with the screen off, Android ≥14 won't let apps disable Wi-Fi power save, and uplink traffic is what keeps the radio awake. Receivers ignore it). @@ -36,6 +36,14 @@ PC (Server) WASAPI render "CABLE Input" ◄─ PlayoutHandle (jitter buffer, de - Loopback runs only when `connected && pc_audio_enabled` and the default device is not a VB-CABLE device. - Mic render runs only while `mic.active`. - Mic demand comes from `audio/demand.rs`: another process holding an active capture session on "CABLE Output". +- **Linux desktop (`crates/desktop/src/linux/`, PipeWire):** the same `service.rs`/`shared.rs`/`ui.rs`; `main.rs` swaps the platform modules with `#[cfg_attr(target_os = "linux", path = "linux/…")]` behind the same API (`audio`, `autostart`, `instance`, `media`, `paths`, `tray`). + - `audio/` runs one PipeWire main loop on `audio-supervisor` (reconnects every 2 s if PipeWire goes away). Every input is an event: registry globals, the `default` metadata, the default sink's `Props`, stream states, commands (`pw::channel`); callbacks only queue `Ev`s and `Ctx::post` drains them, because stream callbacks fire synchronously from `connect`/`disconnect`. + - Virtual mic `audiobridge_mic` (mono, `media.class = Audio/Source` + `node.virtual`; not `Audio/Source/Virtual`, which WirePlumber configures with input ports like a null sink, and a mono one never finishes configuring, stalling all its linking) exists for the whole connection; we never link it. PipeWire runs it only while something records, so mic demand = its stream state `Streaming`. It disappears with the process, also on a crash. + - Loopback `audiobridge_capture` (`stream.capture.sink`, no target: WirePlumber links it to the default sink's monitor and moves it with the default) runs only when `connected && pc_audio_enabled` and a default sink exists. Being linked keeps the sink running, so silence arrives as zeros and the sender's silence gate works without a gap filler. + - Core handles live in `Arc>>` slots that survive stream re-creation; the real-time process callbacks only `try_lock` them and never allocate. + - Devices: endpoint ids are `node.name`s; `DeviceSummary` (`endpoints.rs`) comes from the registry + `default` metadata (`graph.rs`). "Микрофон по умолчанию" writes `default.configured.audio.source`; the user's configured choice counts, so turning AudioBridge off restores the remembered mic even though WirePlumber already fell back. `service.rs` asks through short-lived connections (`oneshot.rs`). + - Volume: read from the default sink's `Props` (`channelVolumes` linear → cubic percent, `mute`), written with `wpctl` (WirePlumber's mixer API also updates hardware routes). + - Media keys: MPRIS on the session bus (`zbus`, `PropertiesChanged`/`NameOwnerChanged`), exactly one target: the playing player (most recently active first), else the most recent one. Tray: StatusNotifierItem (`ksni`), optional (no session bus → no tray; a second launch shows the window). Autostart: `~/.config/autostart/audiobridge.desktop`. Single instance: Unix socket `$XDG_RUNTIME_DIR/audiobridge.sock` (`show`/`quit`). SIGTERM/SIGINT/SIGHUP run the normal shutdown. - **Android audio policy** (`policy.rs`): output opens on `pcAudioActive` and closes after 30 s idle. Mic capture runs iff `micWanted && phone.mic_allowed()` (user switch && mic_ready). ## Key Directories @@ -44,9 +52,10 @@ PC (Server) WASAPI render "CABLE Input" ◄─ PlayoutHandle (jitter buffer, de - `pairing.rs`: QR payload. - `audio/`: `tx`, `rx` (packet queue), `playout` (jitter buffer + decode-on-demand), `mixer`, `codec`, `sim` (test traces). - `session/`: `server.rs`, `hub.rs`, `net.rs` (endpoints, persisted keys, address ranking). -- `crates/desktop/src/`: Windows tray app. +- `crates/desktop/src/`: PC tray app. Top-level platform modules are Windows; `linux/` holds their Linux counterparts; `endpoints.rs`, `service.rs`, `shared.rs`, `settings.rs`, `ui.rs` and `icon.rs` are shared. - `audio/`: WASAPI loopback/render, devices, demand, `policy.rs` (IPolicyConfig default-device restore). - `ui.rs` (eframe), `tray.rs`, `selfinstall.rs`, `autostart.rs`, `instance.rs`, `cable_install.rs`. + - `linux/audio/`: `mod.rs` (engine), `streams.rs` (loopback + virtual mic), `graph.rs`, `volume.rs`, `oneshot.rs`; `linux/{media,tray,instance,autostart,paths}.rs`. - The window uses the brand gradient (indigo `#5B4BFF` → teal `#19C3D0`, the same as the Android launcher icon) and draws its icons with the painter. It repaints on status changes; the only timer (`request_repaint_after`, 40 ms) drives the level bars while PC audio streams and the window is open. Child widgets placed at an absolute rect use `ui.new_child(..)`: `scope_builder` moves the parent cursor and makes later cards overlap. - Close destroys the window, not the service. `WindowState` coalesces show requests while creation is pending; tray UP/DBLCLK/UP must not enqueue multiple windows that open after successive closes. Status repaint never opens a window. - `crates/android-native/src/` @@ -57,6 +66,7 @@ PC (Server) WASAPI render "CABLE Input" ◄─ PlayoutHandle (jitter buffer, de - `BridgeService.kt`: the foreground service. `HeadsetControl.kt`: the media session for headphone buttons; `MediaKeys.kt`: its pure logic (key mapping, target choice), JVM-tested. - `BootReceiver.kt`, `AppState.kt` (prefs incl. headset settings, StatusHub), `BridgeStatus.kt` (statusJson parser, `PairedPc`), `ui/` (`HeadsetCard.kt`: headphone-button settings). - `scripts/install-desktop.ps1`: build plus self-install of the desktop app. +- `scripts/package-linux.sh` + `packaging/linux/`: Linux release packaging (per-user `.tar.gz` with `install.sh`, pacman `.pkg.tar.zst`, launcher entry, icon). ## Development Commands ```powershell @@ -68,12 +78,17 @@ powershell -File scripts\install-desktop.ps1 [-SkipBuild] [-NoFirewall] cargo ndk -t arm64-v8a --platform 26 build --release -p audiobridge-android # target/aarch64-linux-android/release/libaudiobridge.so (no -o: cargo-ndk 4 would also copy iroh's cdylibs) cargo ndk -t arm64-v8a --platform 26 clippy -p audiobridge-android --all-targets -- -D warnings # Android clippy needs cargo-ndk (cc needs NDK clang) ``` +- **Linux desktop:** needs the PipeWire dev files (`pkg-config libpipewire-0.3`) and libclang for bindgen. On the netcup runner they are unpacked without root under `~/opt/pipewire-sdk`; `. ~/opt/pipewire-sdk/env.sh` first (it also provides a headless `pipewire`/`wireplumber`/`pw-*`/`wpctl` for smoke tests). + ```sh + cargo clippy -p audiobridge-desktop --all-targets -- -D warnings && cargo test -p audiobridge-desktop + cargo build -p audiobridge-desktop --release && scripts/package-linux.sh # target/dist + ``` - Debug-only isolation: `AUDIOBRIDGE_DEV_DIR=` gives a separate identity, logs and instance mutex/events; seed `settings.json` with `"autostart": false` before testing beside the installed app. Release builds ignore this variable. - **APK:** run Gradle from inside `android/` (PowerShell: `Set-Location android; .\gradlew.bat :app:assembleDebug`). It runs `buildRustLib` (cargo-ndk) automatically; add `-PskipRust` to reuse the `.so` already in `jniLibs`. Also: `:app:testDebugUnitTest`, `:app:lintDebug`. - **Manual E2E without a phone or PC:** - Phone emulator against a running PC app: `cargo run -p audiobridge-core --example probe -- [--seconds N] [--mic-sine HZ] [--local]`. - Fake PC: `cargo run -p audiobridge-core --example local_server -- [--seconds N] [--sine HZ] [--local] [--mic-demand]` (prints `PAIRING_URI=`). -- **Get the PC's pairing URI:** `AudioBridge.exe --print-pairing`, or read `%APPDATA%\AudioBridge\pairing.txt`. +- **Get the PC's pairing URI:** `AudioBridge.exe --print-pairing` (Linux: `audiobridge --print-pairing`), or read `pairing.txt` in the data directory. - **Device:** `adb install -r android/app/build/outputs/apk/debug/app-debug.apk`; logs via `adb logcat -s AudioBridge:*`. You can add a PC with `adb shell am start -a android.intent.action.VIEW -d '' app.audiobridge`. ## Code Conventions & Common Patterns @@ -85,7 +100,7 @@ cargo ndk -t arm64-v8a --platform 26 clippy -p audiobridge-android --all-targets - **Threads** have kebab-case names: `audiobridge-tx-*`, `audio-supervisor`, `ab-audio`, `ab-listener`, `ab-rt`. Every `unsafe` block needs a `// SAFETY:` comment. - **Logging:** - Core: `tracing`. - - Desktop: daily files in `%LOCALAPPDATA%\AudioBridge\logs`; filter via the `AUDIOBRIDGE_LOG` env var. + - Desktop: daily files in `%LOCALAPPDATA%\AudioBridge\logs` (Linux: `~/.local/state/audiobridge/logs`); filter via the `AUDIOBRIDGE_LOG` env var. - Android: `tracing` → `log` → logcat tag `AudioBridge`. The filter in `logging.rs` must keep `tracing::span=off`, otherwise span enter/exit lines flood logcat several times per audio frame. - **Contract coupling:** these pieces change together. - The statusJson v2 schema (`android-native/src/status.rs`, incl. the per-PC remote controls `pcMic`, `micDefault`, `pcVolume`, `pcMuted` and `media`) ↔ `BridgeStatus.kt` (which throws on unknown states). @@ -95,6 +110,7 @@ cargo ndk -t arm64-v8a --platform 26 clippy -p audiobridge-android --all-targets - Persisted headset settings keys and `TapAction.pref` values (`AppState.kt`, `MediaKeys.kt`): never rename. - **Persisted state:** - PC: `%APPDATA%\AudioBridge` holds `server.key`, `pairing.secret`, `server.port`, `settings.json` and `pairing.txt`. Writes go through tmp+rename; corrupt files are regenerated. Changing `server.key` or `pairing.secret` breaks every existing pairing. + - Linux PC: the same files in `~/.config/audiobridge` (`$XDG_CONFIG_HOME`); endpoint ids in `settings.json` are PipeWire node names. - Phone: `/audiobridge/client.key`, plus SharedPreferences `audiobridge` (`paired_pcs` JSON list of `{id, name, uri, muted}`, `mic_enabled` (also flipped remotely by a PC), `autostart_done`, `service_enabled` (the global switch, missing = on), headphone buttons: `headset_enabled`, `headset_single_earbud`, `headset_double_tap`, `headset_triple_tap`, `headset_target`). - **Global switch (phone):** `Prefs.serviceEnabled` (written with `commit`). Off stops `BridgeService` (native `setPeers("[]")` → hub stopped, no notification, no media session); every start path (`MainActivity.startBridge`, `BootReceiver`, a sticky restart in `onStartCommand`) checks `Prefs.shouldRun()`. Pairings and settings stay. It never tells PCs to switch off: they just see the phone offline. - **Default UDP port:** 47130, falling back to a random port. @@ -106,7 +122,7 @@ cargo ndk -t arm64-v8a --platform 26 clippy -p audiobridge-android --all-targets - the dev profile is disk-saving (`line-tables-only`, no incremental); - release uses thin LTO and strip. - `crates/core/src/{proto,pairing}.rs`: the wire format. Bump `PROTOCOL_VERSION` / the pairing version on incompatible changes. -- `crates/desktop/src/main.rs`: CLI flags `--background`, `--print-pairing`, `--no-install`. Release builds self-install to `%LOCALAPPDATA%\Programs\AudioBridge\AudioBridge.exe`; debug builds run in place. +- `crates/desktop/src/main.rs`: CLI flags `--background`, `--print-pairing`, `--no-install` (Windows), `--quit` (Linux: asks the running instance to exit, used by `install.sh`). Windows release builds self-install to `%LOCALAPPDATA%\Programs\AudioBridge\AudioBridge.exe`; debug builds run in place. Linux is installed by the package (`/usr/bin/audiobridge`) or `install.sh` (`~/.local/bin/audiobridge`). - `android/app/build.gradle.kts`: `buildRustLib` task, compile/target SDK 35, minSdk 26, arm64-v8a only. - `android/app/src/main/AndroidManifest.xml`: - FGS types `connectedDevice|microphone`; @@ -129,6 +145,11 @@ cargo ndk -t arm64-v8a --platform 26 clippy -p audiobridge-android --all-targets - **Windows:** - Desktop `windows` crate features are listed explicitly; new Win32 APIs need the matching feature added. - Mic routing requires VB-CABLE. Installing it can hijack the default playback device; the app detects this and offers a restore. +- **Linux:** + - Runtime: libpipewire-0.3 (≥ 0.3.49) and WirePlumber (`wpctl`, the `default` metadata, linking); the GUI loads Wayland/X11, xkbcommon and EGL with `dlopen`. The release binary is built on Debian 13 and links only glibc, libgcc and libpipewire. + - Platform crates are target-specific dependencies (`pipewire`, `ksni`, `zbus`; eframe `wayland`+`x11`). Keep Windows-only code behind `#[cfg(windows)]` so `cargo xwin clippy` and Linux clippy both stay clean. + - The window has the app id/class `audiobridge` (Hyprland: `windowrule = float, class:^(audiobridge)$`). + - Headless smoke without real audio hardware: a private `pipewire` + `wireplumber` with a `support.null-audio-sink` as the default sink; play a tone into it, run `probe --mic-sine 440` against the app, record `pw-record --target audiobridge_mic`. A fake mic made from a null sink must be `Audio/Source/Virtual` (a null sink is an input node; as `Audio/Source` it stalls WirePlumber). - **Disk:** disk space is tight. Share `target/`, don't create extra target dirs, and don't commit generated paths (`target/`, `android/app/src/main/jniLibs/`, `android/local.properties`, Gradle build dirs, `android/.kotlin/`). `Cargo.lock` is committed. ## Testing & QA @@ -141,9 +162,11 @@ cargo ndk -t arm64-v8a --platform 26 clippy -p audiobridge-android --all-targets - android-native: host tests for the statusJson v2 schema, `NotifyGate` debounce, `controlPc` mapping, policies and peer parsing. - Android: JVM tests `BridgeStatusTest`, `PairedPcTest` and `MediaKeysTest` (key mapping incl. single-earbud, one press = one command, target choice, settings persistence) (`isReturnDefaultValues = false`); there is no instrumented test. - Desktop: resampler, persisted service default/roundtrip, show-request coalescing, and service-generation request gating tests. -- **What's not tested:** WASAPI, AAudio, JNI, the UI and the service. Verify those on real hardware: + - Linux desktop: device summary from the PipeWire graph (configured vs effective default mic), linear → cubic volume, MPRIS target choice and command resolution, PRETTY_HOSTNAME parsing, autostart `Exec=` quoting. +- **What's not tested:** WASAPI, PipeWire, AAudio, JNI, the UI and the service. Verify those on real hardware: - PC audio: play a tone on the PC → the phone UI shows "Звук" / buffer. - Mic: `ffmpeg -f dshow -i audio="CABLE Output (VB-Audio Virtual Cable)" -t 8 -af volumedetect -f null -` should report non-silent levels while the phone mic is allowed. + - Linux mic: `pw-record --target audiobridge_mic -` (or any app recording «AudioBridge: микрофон телефона») → the PC log shows `mic demand: true` and the recording isn't silent; after "Выход" or `kill -9`, `pw-cli ls Node` has no `audiobridge_*` nodes. - Autostart: reboot the phone → the PC log shows `phone 'POCO F5' connected`. - Headphone buttons: play music on the PC through AudioBridge, screen off, double/triple tap → the PC log shows `media command …` and the player reacts; the app's "Последняя команда" line shows what the phone received (`adb logcat -s AudioBridge:*` → `media command N -> PC`). - **Clippy:** `-D warnings` is the working bar for all crates. GitHub CI (Windows) runs clippy plus the core lib/rt_alloc, desktop and android-native host tests; the timing-sensitive integration tests and Android/Gradle checks run locally only. Android lint must stay at 0 errors. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e137cbc..6ce131e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,6 +22,8 @@ cargo test -p audiobridge-android `cargo test -p audiobridge-core` also runs real-network integration tests. They are timing-sensitive, so run them on an idle machine. +On Linux, `audiobridge-desktop` builds the PipeWire version and needs the PipeWire development files (`pkg-config libpipewire-0.3`) and `clang`; on Windows (or with `cargo xwin … --target x86_64-pc-windows-msvc`) it builds the WASAPI version. Platform code must compile cleanly on both. + For Android changes: `cd android; .\gradlew.bat :app:testDebugUnitTest :app:lintDebug`. ## Ground rules @@ -29,6 +31,6 @@ For Android changes: `cd android; .\gradlew.bat :app:testDebugUnitTest :app:lint - **Real-time audio code** (`CaptureHandle::push`, `PlayoutHandle::fill`, the mixer and playout) must never allocate, lock or block. `tests/rt_alloc.rs` enforces this. - The **wire format** (`crates/core/src/proto.rs`, `pairing.rs`) is shared by both apps. An incompatible change must bump the protocol or pairing version. - **UI text** is Russian, code, comments and logs are English. -- Things that can't be unit-tested (WASAPI, AAudio, the UI, the Android service) need a short note in the PR on how you checked them on real hardware. +- Things that can't be unit-tested (WASAPI, PipeWire, AAudio, the UI, the Android service) need a short note in the PR on how you checked them on real hardware. See [AGENTS.md](AGENTS.md) for the architecture and conventions. diff --git a/Cargo.lock b/Cargo.lock index 1af8761..3fc6d2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -59,6 +59,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -124,6 +125,22 @@ dependencies = [ "libc", ] +[[package]] +name = "annotate-snippets" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710e8eae58854cdc1790fcb56cca04d712a17be849eeb81da2a724bf4bae2bc4" +dependencies = [ + "anstyle", + "unicode-width", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + [[package]] name = "anyhow" version = "1.0.104" @@ -165,6 +182,46 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "async_io_stream" version = "0.3.3" @@ -238,6 +295,9 @@ dependencies = [ "anyhow", "audiobridge-core", "eframe", + "futures-util", + "ksni", + "pipewire", "qrcode", "raw-window-handle", "serde", @@ -248,6 +308,7 @@ dependencies = [ "tracing-subscriber", "windows", "windows-core", + "zbus", ] [[package]] @@ -298,6 +359,40 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "annotate-snippets", + "bitflags 2.13.2", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.3", + "shlex 1.3.0", + "syn 2.0.119", +] + +[[package]] +name = "bit-set" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + [[package]] name = "bitflags" version = "1.3.2" @@ -415,6 +510,18 @@ dependencies = [ "tracing", ] +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + [[package]] name = "calloop-wayland-source" version = "0.4.1" @@ -436,7 +543,7 @@ dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 2.0.1", ] [[package]] @@ -445,6 +552,25 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "cfg-expr" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ba9e9ec16c447027685b1f897b720e18e9a8afd00bd7332c483537e38086c9f" +dependencies = [ + "smallvec", + "target-lexicon", +] + [[package]] name = "cfg-if" version = "1.0.5" @@ -499,6 +625,17 @@ dependencies = [ "inout", ] +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -523,6 +660,15 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "unicode-width", +] + [[package]] name = "color" version = "0.3.3" @@ -572,6 +718,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" + [[package]] name = "cordyceps" version = "0.3.5" @@ -668,6 +820,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.23" @@ -952,6 +1113,7 @@ dependencies = [ "bytemuck", "document-features", "egui", + "egui-wgpu", "egui-winit", "egui_glow", "glow", @@ -987,7 +1149,7 @@ dependencies = [ "bitflags 2.13.2", "emath", "epaint", - "itertools", + "itertools 0.14.0", "log", "nohash-hasher", "profiling", @@ -995,6 +1157,26 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "egui-wgpu" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e6cfac0725563555fa4f91e9f799b9d7c6c5dd831fca6abc8234afc64b7a34" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "epaint", + "log", + "profiling", + "thiserror 2.0.21", + "type-map", + "web-time", + "wgpu", + "winit", +] + [[package]] name = "egui-winit" version = "0.35.0" @@ -1028,6 +1210,7 @@ dependencies = [ "log", "memoffset", "profiling", + "winit", ] [[package]] @@ -1057,6 +1240,12 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + [[package]] name = "enum-assoc" version = "1.4.1" @@ -1068,6 +1257,27 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "env_filter" version = "0.1.4" @@ -1140,6 +1350,26 @@ dependencies = [ "num-traits", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -1464,7 +1694,7 @@ checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e" dependencies = [ "bytemuck", "foldhash", - "hashbrown", + "hashbrown 0.17.1", "log", "peniko", "skrifa", @@ -1472,6 +1702,12 @@ dependencies = [ "vello_common", ] +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "gloo-timers" version = "0.3.0" @@ -1507,6 +1743,7 @@ dependencies = [ "cgl", "dispatch2", "glutin_egl_sys", + "glutin_glx_sys", "glutin_wgl_sys", "libloading", "objc2 0.6.4", @@ -1515,7 +1752,9 @@ dependencies = [ "objc2-foundation 0.3.2", "once_cell", "raw-window-handle", + "wayland-sys", "windows-sys 0.52.0", + "x11-dl", ] [[package]] @@ -1540,6 +1779,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + [[package]] name = "glutin_wgl_sys" version = "0.6.1" @@ -1585,6 +1834,7 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", + "num-traits", "zerocopy", ] @@ -1600,6 +1850,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -1621,6 +1880,18 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + [[package]] name = "http" version = "1.5.0" @@ -1921,7 +2192,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", ] [[package]] @@ -1987,7 +2258,7 @@ dependencies = [ "portmapper", "rand", "reqwest", - "rustc-hash", + "rustc-hash 2.1.3", "rustls", "rustls-pki-types", "serde", @@ -2115,6 +2386,15 @@ dependencies = [ "ws_stream_wasm", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -2231,6 +2511,21 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "ksni" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bcc1cb43797140f74b711f1c4a49f48a7a7429f211c5fb78264e7bcd1e99caf" +dependencies = [ + "arc-swap", + "crossbeam-queue", + "futures-util", + "pastey", + "serde", + "tokio", + "zbus", +] + [[package]] name = "kurbo" version = "0.13.1" @@ -2265,6 +2560,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.25" @@ -2277,6 +2578,33 @@ dependencies = [ "redox_syscall 0.9.4", ] +[[package]] +name = "libspa" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882f7427e7989dcc9d388b7f05c4630390a1d7696f9ffa469cd4a7a48f0b4c40" +dependencies = [ + "bitflags 2.13.2", + "cc", + "cookie-factory", + "libc", + "libspa-sys", + "nom 8.0.0", + "rustix 1.1.5", + "system-deps", +] + +[[package]] +name = "libspa-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b6e17bdaf63ed0d5e4144022624032b41fd9733112e8c74ac26fc9bf1291924" +dependencies = [ + "bindgen", + "cc", + "system-deps", +] + [[package]] name = "linebender_resource_handle" version = "0.1.1" @@ -2341,7 +2669,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" dependencies = [ - "hashbrown", + "hashbrown 0.17.1", ] [[package]] @@ -2389,6 +2717,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2445,7 +2779,7 @@ dependencies = [ "ndk-context", "rand", "reqwest", - "rustc-hash", + "rustc-hash 2.1.3", "rustls", "simple-dns", "system-configuration", @@ -2508,6 +2842,31 @@ dependencies = [ "n0-future", ] +[[package]] +name = "naga" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2bf919621e7975acb27d881bae2fb993e0d45c8e0446e85e6272971e00dc8df" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.16.1", + "hexf-parse", + "indexmap", + "libm", + "log", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "thiserror 2.0.21", + "unicode-ident", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2712,6 +3071,25 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "noq" version = "1.3.0" @@ -2724,7 +3102,7 @@ dependencies = [ "noq-proto", "noq-udp", "pin-project-lite", - "rustc-hash", + "rustc-hash 2.1.3", "rustls", "socket2", "thiserror 2.0.21", @@ -2750,7 +3128,7 @@ dependencies = [ "rand", "rand_pcg", "ring", - "rustc-hash", + "rustc-hash 2.1.3", "rustls", "rustls-pki-types", "slab", @@ -2796,6 +3174,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -3185,8 +3564,18 @@ dependencies = [ ] [[package]] -name = "papaya" -version = "0.2.5" +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "papaya" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da2442474a9404698c42509b8967f437249dbc7b50493e83020333d3943ec0ae" dependencies = [ @@ -3229,6 +3618,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pem-rfc7468" version = "1.0.0" @@ -3293,6 +3688,31 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pipewire" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde71084c4e25959d68f1ea54daa75e5ecdb338e5caf0b5510143b79baa32d5c" +dependencies = [ + "bitflags 2.13.2", + "libc", + "libspa", + "libspa-sys", + "pipewire-sys", + "rustix 1.1.5", +] + +[[package]] +name = "pipewire-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce653f53e63e5b93853218092ee9a8906a5d082c92f3f1db26316955dd63ce0" +dependencies = [ + "bindgen", + "libspa-sys", + "system-deps", +] + [[package]] name = "pkcs8" version = "0.11.0" @@ -3385,6 +3805,15 @@ dependencies = [ "serde", ] +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + [[package]] name = "portmapper" version = "0.19.3" @@ -3632,6 +4061,12 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + [[package]] name = "reqwest" version = "0.13.5" @@ -3689,6 +4124,12 @@ version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fae8ee26b0371a29a77d2b2d6b3ae13aa81def6f9bf1b1b92a32d279a5e709b7" +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.3" @@ -3947,6 +4388,26 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + [[package]] name = "serdect" version = "0.4.3" @@ -3983,6 +4444,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "shlex" version = "2.0.1" @@ -4070,6 +4537,31 @@ version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + [[package]] name = "smithay-client-toolkit" version = "0.20.0" @@ -4078,7 +4570,7 @@ checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" dependencies = [ "bitflags 2.13.2", "calloop 0.14.4", - "calloop-wayland-source", + "calloop-wayland-source 0.4.1", "cursor-icon", "libc", "log", @@ -4104,7 +4596,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" dependencies = [ "libc", - "smithay-client-toolkit", + "smithay-client-toolkit 0.20.0", "wayland-backend", ] @@ -4268,6 +4760,25 @@ dependencies = [ "libc", ] +[[package]] +name = "system-deps" +version = "7.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "tempfile" version = "3.27.0" @@ -4404,6 +4915,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -4478,11 +4990,26 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + [[package]] name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ "serde_core", ] @@ -4501,13 +5028,19 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.3+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ "winnow", ] +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + [[package]] name = "tower" version = "0.5.3" @@ -4634,12 +5167,32 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.3", +] + [[package]] name = "typenum" version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unicode-general-category" version = "1.1.0" @@ -4658,6 +5211,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-width" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -4704,6 +5263,11 @@ name = "uuid" version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] [[package]] name = "valuable" @@ -4720,7 +5284,7 @@ dependencies = [ "bytemuck", "fearless_simd", "guillotiere", - "hashbrown", + "hashbrown 0.17.1", "log", "peniko", "smallvec", @@ -4735,10 +5299,16 @@ checksum = "588691169aed86b5c8fb487266afee01323234e6fd0a3f2aaec0eaa8e4007f23" dependencies = [ "bytemuck", "glifo", - "hashbrown", + "hashbrown 0.17.1", "vello_common", ] +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + [[package]] name = "version_check" version = "0.9.5" @@ -4934,6 +5504,19 @@ dependencies = [ "wayland-scanner", ] +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + [[package]] name = "wayland-protocols-wlr" version = "0.3.12" @@ -5030,6 +5613,119 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "wgpu" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76e8840e1ba2881d4cbb18d2147627a56af426ff064c0401eb0c8410c6325d07" +dependencies = [ + "arrayvec", + "bitflags 2.13.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "js-sys", + "log", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f519832254e56965a9940c4af57dcb75f702b6f6fa4a0b172f685395843a4d7" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.13.2", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.21", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e592c1bbef6ad047647ae6e666ebd8cee7a32bb4544d9700ec96cbf73230257" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97ace1c17727311c22a46e4e3faf56ea6de81af99dcc839bdfb54857b94d448d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libloading", + "log", + "naga", + "portable-atomic", + "portable-atomic-util", + "raw-window-handle", + "renderdoc-sys", + "thiserror 2.0.21", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95226013f547544b223281cd16a4fb549aa9dcb562adbda0faae4c73ffbbc161" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84bf84cd9ca8ca45e2b223a3868f1adf9bfc0c66aeac212e76ee7e40fdadf8f5" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "js-sys", + "log", + "raw-window-handle", + "web-sys", +] + [[package]] name = "widestring" version = "1.2.1" @@ -5425,10 +6121,12 @@ version = "0.30.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" dependencies = [ + "ahash", "android-activity", "atomic-waker", "bitflags 2.13.2", "block2 0.5.1", + "bytemuck", "calloop 0.13.0", "cfg_aliases", "concurrent-queue", @@ -5438,24 +6136,33 @@ dependencies = [ "dpi", "js-sys", "libc", + "memmap2", "ndk", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", "objc2-ui-kit 0.2.2", "orbclient", + "percent-encoding", "pin-project", "raw-window-handle", "redox_syscall 0.4.1", "rustix 0.38.44", + "smithay-client-toolkit 0.19.2", "smol_str", "tracing", "unicode-segmentation", "wasm-bindgen", "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", "web-sys", "web-time", "windows-sys 0.52.0", + "x11-dl", + "x11rb", "xkbcommon-dl", ] @@ -5514,13 +6221,28 @@ dependencies = [ "web-sys", ] +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + [[package]] name = "x11rb" version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ + "as-raw-xcb-connection", "gethostname", + "libc", + "libloading", + "once_cell", "rustix 1.1.5", "x11rb-protocol", ] @@ -5594,6 +6316,71 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.5", + "serde", + "serde_repr", + "tokio", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "939f186d9ecebe044315a5cb6d6f78568bbc21152adc954d5a0c2f58bdbc9920" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" version = "0.8.59" @@ -5714,3 +6501,44 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] + +[[package]] +name = "zvariant" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.6", + "winnow", +] diff --git a/README.md b/README.md index ec16bc5..6961f9e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ![AudioBridge: your phone as wireless headphones and microphone for your PC](docs/screenshots/hero.png) -**Your phone becomes the wireless headphones and microphone of your Windows PC.** +**Your phone becomes the wireless headphones and microphone of your Windows or Linux PC.** AudioBridge streams everything your PC plays to your Android phone, which plays it in the Bluetooth headphones connected to the phone. In the other direction, the phone's microphone shows up on the PC as a regular microphone for Discord, Zoom, OBS or games. @@ -21,33 +21,36 @@ Pair once by scanning a QR code. After that both apps run in the background, sta ## Features - **PC audio → phone → Bluetooth headphones.** Opus at 192 kbit/s stereo, 48 kHz. Around 30–40 ms of network latency on Wi-Fi; Bluetooth itself adds its usual 100–200 ms on top. -- **Phone mic → PC.** Shows up as `CABLE Output` (VB-CABLE). The phone only sends the mic while some app on the PC is actually recording. +- **Phone mic → PC.** On Windows it shows up as `CABLE Output` (VB-CABLE); on Linux AudioBridge creates its own PipeWire microphone «AudioBridge: микрофон телефона» (no driver needed, it disappears when the app exits). The phone only sends the mic while some app on the PC is actually recording. - **One-scan pairing.** The PC shows a QR code, the phone scans it, and that's it. - **Works across networks.** Peer-to-peer over [iroh](https://iroh.computer) QUIC: LAN, NAT hole punching, Tailscale, or an encrypted relay as a last resort. The phone and the PC don't need to share a Wi-Fi network. - **Several PCs at once.** Pair up to 8 computers (desktop, laptop, …). Their audio is mixed, and the mic goes only to the ones that use it. Mute one PC, or all of them with a single button, right from the phone; they stay connected. -- **Full remote control from either side.** From the PC: PC and phone volume, the phone mic, and the Windows default mic. From the phone: the PC's audio and mic switches, its volume and mute. The PC's "Mic" switch does everything at once: it turns the phone mic on and makes `CABLE Output` the Windows default mic; switching it off restores your previous mic. -- **Headphone buttons control the music on the PC.** With the earbuds connected to the phone, double tap pauses/resumes and triple tap skips tracks in the PC's player (Yandex Music, Spotify, a browser — anything in the Windows media overlay), also with the phone screen off. With several PCs, the buttons control exactly one: the one playing, or the one you pick. A "Single earbud" mode lets you choose the 2- and 3-tap actions so either earbud alone can do everything. Volume taps keep changing the phone volume (what you hear): Android doesn't tell apps about them, so they can't be remapped. +- **Full remote control from either side.** From the PC: PC and phone volume, the phone mic, and the default mic. From the phone: the PC's audio and mic switches, its volume and mute. The PC's "Mic" switch does everything at once: it turns the phone mic on and makes the virtual mic the default mic; switching it off restores your previous mic. +- **Headphone buttons control the music on the PC.** With the earbuds connected to the phone, double tap pauses/resumes and triple tap skips tracks in the PC's player (Yandex Music, Spotify, a browser — anything in the Windows media overlay, or any MPRIS player on Linux), also with the phone screen off. With several PCs, the buttons control exactly one: the one playing, or the one you pick. A "Single earbud" mode lets you choose the 2- and 3-tap actions so either earbud alone can do everything. Volume taps keep changing the phone volume (what you hear): Android doesn't tell apps about them, so they can't be remapped. - **Background and autostart** on both sides. Low CPU: about 1–2 % of one core on the PC, 4–5 % on the phone while playing. -- **PC service on/off:** «Выключить AudioBridge» in the window or tray disconnects this PC, stops audio and media integration, and restores non-CABLE default devices where available. «Включить AudioBridge» restores the saved feature settings and pairing without rescanning. Off stays off after restarting Windows; other PCs and the phone's switches are untouched. Closing the window only returns it to the tray; reopen it with the tray icon or another launch. +- **PC service on/off:** «Выключить AudioBridge» in the window or tray disconnects this PC, stops audio and media integration, and restores your own default mic (and, on Windows, non-CABLE playback) where available. «Включить AudioBridge» restores the saved feature settings and pairing without rescanning. Off stays off after a restart; other PCs and the phone's switches are untouched. Closing the window only returns it to the tray; reopen it with the tray icon or another launch. - **End-to-end encrypted** (QUIC/TLS) and authenticated by the pairing secret from the QR code. ## Requirements | | | |---|---| -| PC | Windows 10/11 x64 | +| PC | Windows 10/11 x64, or Linux x86_64 with PipeWire and WirePlumber (tested on Arch / Omarchy) | | Phone | Android 8.0+ (arm64) | -| Mic on PC | [VB-CABLE](https://vb-audio.com/Cable/) (free); the app offers to install it | +| Mic on PC | Windows: [VB-CABLE](https://vb-audio.com/Cable/) (free); the app offers to install it. Linux: nothing extra | The UI is in Russian for now. Translations are welcome. ## Install -1. Download `AudioBridge.exe` and `AudioBridge.apk` from [Releases](https://github.com/Lyten02/AudioBridge/releases). +1. Download `AudioBridge.apk` and the PC app from [Releases](https://github.com/Lyten02/AudioBridge/releases): `AudioBridge.exe` for Windows, `audiobridge--1-x86_64.pkg.tar.zst` (Arch) or `AudioBridge--linux-x86_64.tar.gz` (any distro) for Linux. 2. **PC:** run `AudioBridge.exe`. It installs itself to `%LOCALAPPDATA%\Programs\AudioBridge`, enables autostart and shows a QR code. Allow it through the Windows firewall. + **Linux:** `sudo pacman -U audiobridge-*.pkg.tar.zst`, or unpack the `.tar.gz` and run `./install.sh` (installs to `~/.local` for your user; `./install.sh --uninstall` removes it). Start «AudioBridge» from the app launcher; it enables autostart at login and shows a QR code. 3. **Phone:** install the APK, open it, tap «Добавить компьютер» (Add computer) and scan the QR code. 4. On Xiaomi/HyperOS/MIUI: enable Autostart for AudioBridge, set battery to "No restrictions" and lock the app in recents, or the system will kill it. +On Linux the phone mic appears as the input «AudioBridge: микрофон телефона». Pick it in an app, or switch on «Микрофон по умолчанию» in AudioBridge. On Hyprland the window can float with a rule for the class `audiobridge`. + ## How it works ``` @@ -55,8 +58,10 @@ PC WASAPI loopback ─► Opus ─► QUIC datagrams ─► jitter buffer ─ PC VB-CABLE ◄─ jitter buffer ◄─ QUIC datagrams ◄─ Opus ◄─ AAudio mic Phone ``` +On Linux, PipeWire takes the place of WASAPI and VB-CABLE: AudioBridge captures the default output's monitor and plays the phone mic into its own virtual source node. + - A shared Rust core (`crates/core`) does networking, Opus encoding and the receive path. The receive path is a small NetEQ-style jitter buffer: an adaptive delay target, packet-loss concealment and clock-drift compensation. -- `crates/desktop` is the Windows tray app (WASAPI + egui). +- `crates/desktop` is the PC tray app (egui; WASAPI on Windows, PipeWire on Linux). - `crates/android-native` is the JNI library (AAudio); `android/` is the Kotlin/Compose app with a foreground service. See [AGENTS.md](AGENTS.md) for the detailed developer guide. @@ -69,6 +74,13 @@ cargo build -p audiobridge-desktop --release # target\release\AudioBridg cd android; .\gradlew.bat :app:assembleDebug # needs Android SDK, NDK r30 and cargo-ndk 4.x ``` +Linux desktop app (needs the PipeWire development files, e.g. `pipewire` on Arch or `libpipewire-0.3-dev` on Debian/Ubuntu, plus `clang` for bindgen): + +```sh +cargo build -p audiobridge-desktop --release # target/release/AudioBridge +scripts/package-linux.sh # target/dist: .tar.gz with install.sh, pacman package +``` + Rust ≥ 1.92. Opus is vendored and built with `cc`, so you don't need cmake. ## Contributing diff --git a/README.ru.md b/README.ru.md index 9bc6510..1e61731 100644 --- a/README.ru.md +++ b/README.ru.md @@ -2,7 +2,7 @@ ![AudioBridge: телефон как беспроводные наушники и микрофон для ПК](docs/screenshots/hero.png) -**Телефон становится беспроводными наушниками и микрофоном для компьютера с Windows.** +**Телефон становится беспроводными наушниками и микрофоном для компьютера с Windows или Linux.** AudioBridge передаёт весь звук компьютера на Android-телефон, а тот играет его в Bluetooth-наушники, подключённые к телефону. В обратную сторону микрофон телефона появляется на ПК как обычный микрофон для Discord, Zoom, OBS и игр. @@ -23,31 +23,34 @@ AudioBridge передаёт весь звук компьютера на Android ## Возможности - **Звук ПК → телефон → Bluetooth-наушники.** Opus 192 кбит/с, стерео, 48 кГц. Задержка сети по Wi-Fi около 30–40 мс; сам Bluetooth добавляет свои обычные 100–200 мс. -- **Микрофон телефона → ПК.** Виден как `CABLE Output` (VB-CABLE). Телефон отправляет микрофон, только когда какая-то программа на ПК действительно записывает. +- **Микрофон телефона → ПК.** В Windows виден как `CABLE Output` (VB-CABLE); в Linux AudioBridge сам создаёт микрофон PipeWire «AudioBridge: микрофон телефона» (драйвер не нужен, микрофон исчезает при выходе из программы). Телефон отправляет микрофон, только когда какая-то программа на ПК действительно записывает. - **Сопряжение одним сканом QR-кода.** - **Работает между разными сетями.** P2P через [iroh](https://iroh.computer) QUIC: локальная сеть, пробой NAT, Tailscale, а в крайнем случае зашифрованный relay. Телефону и ПК не обязательно быть в одном Wi-Fi. - **Несколько компьютеров сразу.** До 8 ПК (стационарный, ноутбук, …): их звук смешивается, а микрофон уходит только туда, где он нужен. Звук отдельного ПК или всех сразу можно выключить одной кнопкой прямо с телефона, связь при этом не рвётся. -- **Полный пульт с обеих сторон.** С ПК: громкость компьютера и телефона, микрофон телефона, выбор микрофона Windows. С телефона: звук и микрофон компьютера, его громкость и mute. Кнопка «Микрофон» на ПК делает всё сразу: включает микрофон на телефоне и ставит `CABLE Output` микрофоном Windows по умолчанию; выключение возвращает прежний микрофон. -- **Кнопки наушников управляют музыкой на ПК.** Наушники подключены к телефону: двойное касание ставит на паузу и продолжает, тройное переключает треки в плеере на ПК (Яндекс Музыка, Spotify, браузер — всё, что видно в медиапанели Windows), в том числе с выключенным экраном телефона. Если ПК несколько, кнопки управляют ровно одним: тем, где играет музыка, или выбранным. Режим «Один наушник» позволяет выбрать действия для 2 и 3 касаний, чтобы любой наушник в одиночку умел всё. Касание громкости по-прежнему меняет громкость телефона (то, что слышно): Android не сообщает о нём приложениям, поэтому переназначить его нельзя. +- **Полный пульт с обеих сторон.** С ПК: громкость компьютера и телефона, микрофон телефона, выбор микрофона по умолчанию. С телефона: звук и микрофон компьютера, его громкость и mute. Кнопка «Микрофон» на ПК делает всё сразу: включает микрофон на телефоне и ставит виртуальный микрофон микрофоном по умолчанию; выключение возвращает прежний микрофон. +- **Кнопки наушников управляют музыкой на ПК.** Наушники подключены к телефону: двойное касание ставит на паузу и продолжает, тройное переключает треки в плеере на ПК (Яндекс Музыка, Spotify, браузер — всё, что видно в медиапанели Windows, а в Linux — любой плеер с MPRIS), в том числе с выключенным экраном телефона. Если ПК несколько, кнопки управляют ровно одним: тем, где играет музыка, или выбранным. Режим «Один наушник» позволяет выбрать действия для 2 и 3 касаний, чтобы любой наушник в одиночку умел всё. Касание громкости по-прежнему меняет громкость телефона (то, что слышно): Android не сообщает о нём приложениям, поэтому переназначить его нельзя. - **Фон и автозапуск** на обеих сторонах. Нагрузка небольшая: на ПК 1–2 % одного ядра, на телефоне 4–5 % во время воспроизведения. -- **Выключатель сервиса на ПК:** «Выключить AudioBridge» в окне или трее отключает этот ПК от телефона, останавливает звук и управление медиаплеерами, возвращает прежние устройства Windows вместо CABLE, если они доступны. «Включить AudioBridge» восстанавливает настройки и прежнее сопряжение без повторного сканирования QR. Выключенное состояние сохраняется после перезагрузки Windows; телефон и другие ПК не выключаются. Крестик только закрывает окно в трей; открыть его можно значком в трее или повторным запуском. +- **Выключатель сервиса на ПК:** «Выключить AudioBridge» в окне или трее отключает этот ПК от телефона, останавливает звук и управление медиаплеерами, возвращает ваш прежний микрофон (а в Windows и устройство вывода вместо CABLE), если они доступны. «Включить AudioBridge» восстанавливает настройки и прежнее сопряжение без повторного сканирования QR. Выключенное состояние сохраняется после перезагрузки; телефон и другие ПК не выключаются. Крестик только закрывает окно в трей; открыть его можно значком в трее или повторным запуском. - **Сквозное шифрование** (QUIC/TLS) и проверка по секрету из QR-кода. ## Что нужно | | | |---|---| -| ПК | Windows 10/11 x64 | +| ПК | Windows 10/11 x64 или Linux x86_64 с PipeWire и WirePlumber (проверено на Arch / Omarchy) | | Телефон | Android 8.0+ (arm64) | -| Микрофон на ПК | [VB-CABLE](https://vb-audio.com/Cable/) (бесплатный), приложение предложит установить | +| Микрофон на ПК | Windows: [VB-CABLE](https://vb-audio.com/Cable/) (бесплатный), приложение предложит установить. Linux: ничего не нужно | ## Установка -1. Скачай `AudioBridge.exe` и `AudioBridge.apk` в [Releases](https://github.com/Lyten02/AudioBridge/releases). +1. Скачай `AudioBridge.apk` и программу для ПК в [Releases](https://github.com/Lyten02/AudioBridge/releases): `AudioBridge.exe` для Windows, `audiobridge-<версия>-1-x86_64.pkg.tar.zst` (Arch) или `AudioBridge-<версия>-linux-x86_64.tar.gz` (любой дистрибутив) для Linux. 2. **ПК:** запусти `AudioBridge.exe`. Программа сама установится в `%LOCALAPPDATA%\Programs\AudioBridge`, включит автозапуск и покажет QR-код. Разреши ей доступ в брандмауэре Windows. + **Linux:** `sudo pacman -U audiobridge-*.pkg.tar.zst` или распакуй `.tar.gz` и запусти `./install.sh` (ставит в `~/.local` для твоего пользователя; `./install.sh --uninstall` удаляет). Запусти «AudioBridge» из меню приложений: программа включит автозапуск при входе и покажет QR-код. 3. **Телефон:** установи APK, открой и нажми «Добавить компьютер», отсканируй QR-код. 4. На Xiaomi/HyperOS/MIUI: включи AudioBridge автозапуск, батарею «Без ограничений» и закрепи приложение в недавних (замок). Иначе система будет его выгружать. +В Linux микрофон телефона виден как вход «AudioBridge: микрофон телефона». Выбери его в программе или включи «Микрофон по умолчанию» в AudioBridge. В Hyprland окно можно сделать плавающим правилом для класса `audiobridge`. + ## Сборка из исходников ```powershell @@ -56,6 +59,13 @@ cargo build -p audiobridge-desktop --release # target\release\AudioBridg cd android; .\gradlew.bat :app:assembleDebug # нужны Android SDK, NDK r30 и cargo-ndk 4.x ``` +Linux (нужны файлы разработки PipeWire: пакет `pipewire` в Arch или `libpipewire-0.3-dev` в Debian/Ubuntu, и `clang` для bindgen): + +```sh +cargo build -p audiobridge-desktop --release # target/release/AudioBridge +scripts/package-linux.sh # target/dist: .tar.gz с install.sh и пакет pacman +``` + Подробное описание архитектуры — в [AGENTS.md](AGENTS.md). Pull request'ы приветствуются, см. [CONTRIBUTING.md](CONTRIBUTING.md). ## Лицензия diff --git a/crates/desktop/Cargo.toml b/crates/desktop/Cargo.toml index c246bad..26b6a0e 100644 --- a/crates/desktop/Cargo.toml +++ b/crates/desktop/Cargo.toml @@ -13,16 +13,28 @@ audiobridge-core = { path = "../core" } anyhow = "1" eframe = { version = "0.35", default-features = false, features = ["glow", "default_fonts"] } qrcode = { version = "0.14", default-features = false } -raw-window-handle = "0.6" serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["rt-multi-thread", "sync", "time", "macros"] } tracing = "0.1" tracing-appender = "0.2" tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "std", "registry", "ansi"] } + +[target.'cfg(target_os = "linux")'.dependencies] +# winit loads libwayland/libX11/libxkbcommon and GL with dlopen: no build-time system libraries. +eframe = { version = "0.35", default-features = false, features = ["wayland", "x11"] } +# links libpipewire-0.3 (build: libpipewire-0.3-dev + libclang for bindgen) +pipewire = { version = "0.10", features = ["v0_3_49"] } +ksni = { version = "0.3", features = ["blocking"] } +# MPRIS (media keys); the same zbus as ksni +zbus = { version = "5", default-features = false, features = ["tokio"] } +futures-util = { version = "0.3", default-features = false } + +[target.'cfg(windows)'.dependencies] +raw-window-handle = "0.6" windows-core = "0.62" -[dependencies.windows] +[target.'cfg(windows)'.dependencies.windows] version = "0.62" features = [ "ApplicationModel", diff --git a/crates/desktop/src/audio/devices.rs b/crates/desktop/src/audio/devices.rs index 4c9690d..4972cd1 100644 --- a/crates/desktop/src/audio/devices.rs +++ b/crates/desktop/src/audio/devices.rs @@ -8,59 +8,14 @@ use windows::Win32::Media::Audio::{ use windows::Win32::System::Com::StructuredStorage::PropVariantToStringAlloc; use windows::Win32::System::Com::{CoTaskMemFree, STGM_READ}; +pub use crate::endpoints::{DeviceSummary, EndpointInfo}; + pub struct Endpoint { pub id: String, pub name: String, pub device: IMMDevice, } -/// An active endpoint (playback or recording), as listed for the default-device fixes. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct EndpointInfo { - pub id: String, - pub name: String, - pub is_cable: bool, -} - -/// What the UI and the supervisor need to know about the current device landscape. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct DeviceSummary { - pub default_render_id: Option, - pub default_render_name: Option, - /// The default playback device is a VB-CABLE input (loopback would feed the mic back). - pub default_is_cable: bool, - pub cable_render_id: Option, - pub render_endpoints: Vec, - pub default_capture_id: Option, - pub default_capture_name: Option, - /// The default recording device is the VB-CABLE capture endpoint, i.e. our virtual mic. - pub default_capture_is_cable: bool, - /// The capture endpoint apps record the phone mic from ("CABLE Output"). - pub cable_capture_id: Option, - pub capture_endpoints: Vec, -} - -impl DeviceSummary { - /// The playback device to restore when VB-CABLE took over the default: the remembered one - /// if it is still active, otherwise the first active non-CABLE device. - pub fn render_restore_candidate(&self, remembered: Option<&str>) -> Option<&EndpointInfo> { - restore_candidate(&self.render_endpoints, remembered) - } - - /// The recording device to restore when the virtual mic stops being the default: the - /// remembered one if it is still active, otherwise the first active non-CABLE device. - pub fn capture_restore_candidate(&self, remembered: Option<&str>) -> Option<&EndpointInfo> { - restore_candidate(&self.capture_endpoints, remembered) - } -} - -fn restore_candidate<'a>(endpoints: &'a [EndpointInfo], remembered: Option<&str>) -> Option<&'a EndpointInfo> { - let usable = |r: &&EndpointInfo| !r.is_cable; - remembered - .and_then(|id| endpoints.iter().filter(usable).find(|r| r.id == id)) - .or_else(|| endpoints.iter().find(usable)) -} - fn take_pwstr(p: PWSTR) -> String { // SAFETY: `p` was allocated by COM and is NUL-terminated; freed right after copying. unsafe { diff --git a/crates/desktop/src/audio/mod.rs b/crates/desktop/src/audio/mod.rs index 14d45a6..3cbb7b5 100644 --- a/crates/desktop/src/audio/mod.rs +++ b/crates/desktop/src/audio/mod.rs @@ -61,6 +61,16 @@ pub struct Hooks { const TICK: Duration = Duration::from_secs(1); const RETRY: Duration = Duration::from_secs(2); +/// Sender half of the engine's command channel. +pub type AudioSender = Sender; + +/// The current device landscape, read now (not a stale notification snapshot). +pub fn current_devices() -> Option { + let _com = Com::init(); + let enumerator = com::enumerator().ok()?; + Some(devices::summarize(&enumerator)) +} + pub struct AudioEngine { tx: Sender, join: JoinHandle<()>, @@ -76,7 +86,7 @@ impl AudioEngine { Ok(Self { tx, join }) } - pub fn sender(&self) -> Sender { + pub fn sender(&self) -> AudioSender { self.tx.clone() } diff --git a/crates/desktop/src/endpoints.rs b/crates/desktop/src/endpoints.rs new file mode 100644 index 0000000..05b8cb2 --- /dev/null +++ b/crates/desktop/src/endpoints.rs @@ -0,0 +1,52 @@ +//! The device landscape as the UI, the service and the default-device fixes see it. +//! +//! "Cable" means the virtual microphone route: VB-CABLE ("CABLE Input" / "CABLE Output") on +//! Windows, AudioBridge's own PipeWire virtual source on Linux. Endpoint ids are WASAPI endpoint +//! ids on Windows and PipeWire `node.name`s on Linux. + +/// An active endpoint (playback or recording), as listed for the default-device fixes. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EndpointInfo { + pub id: String, + pub name: String, + pub is_cable: bool, +} + +/// What the UI and the supervisor need to know about the current device landscape. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct DeviceSummary { + pub default_render_id: Option, + pub default_render_name: Option, + /// The default playback device is a VB-CABLE input (loopback would feed the mic back). + pub default_is_cable: bool, + pub cable_render_id: Option, + pub render_endpoints: Vec, + pub default_capture_id: Option, + pub default_capture_name: Option, + /// The default recording device is the virtual mic. + pub default_capture_is_cable: bool, + /// The capture endpoint apps record the phone mic from. + pub cable_capture_id: Option, + pub capture_endpoints: Vec, +} + +impl DeviceSummary { + /// The playback device to restore when VB-CABLE took over the default: the remembered one + /// if it is still active, otherwise the first active non-CABLE device. + pub fn render_restore_candidate(&self, remembered: Option<&str>) -> Option<&EndpointInfo> { + restore_candidate(&self.render_endpoints, remembered) + } + + /// The recording device to restore when the virtual mic stops being the default: the + /// remembered one if it is still active, otherwise the first active non-virtual device. + pub fn capture_restore_candidate(&self, remembered: Option<&str>) -> Option<&EndpointInfo> { + restore_candidate(&self.capture_endpoints, remembered) + } +} + +fn restore_candidate<'a>(endpoints: &'a [EndpointInfo], remembered: Option<&str>) -> Option<&'a EndpointInfo> { + let usable = |r: &&EndpointInfo| !r.is_cable; + remembered + .and_then(|id| endpoints.iter().filter(usable).find(|r| r.id == id)) + .or_else(|| endpoints.iter().find(usable)) +} diff --git a/crates/desktop/src/linux/audio/graph.rs b/crates/desktop/src/linux/audio/graph.rs new file mode 100644 index 0000000..4e2ed27 --- /dev/null +++ b/crates/desktop/src/linux/audio/graph.rs @@ -0,0 +1,183 @@ +//! What the registry and the `default` metadata tell about the PipeWire graph, and how that maps +//! to the platform-neutral [`DeviceSummary`]. + +use std::collections::BTreeMap; + +use crate::endpoints::{DeviceSummary, EndpointInfo}; + +use super::MIC_NODE_NAME; + +/// A node of interest: an audio device (or a virtual device) apps can play to or record from. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NodeEntry { + pub name: String, + pub description: String, + pub class: String, +} + +impl NodeEntry { + fn is_sink(&self) -> bool { + matches!(self.class.as_str(), "Audio/Sink" | "Audio/Duplex") + } + + fn is_source(&self) -> bool { + matches!(self.class.as_str(), "Audio/Source" | "Audio/Source/Virtual" | "Audio/Duplex") + } + + fn label(&self) -> String { + if self.description.is_empty() { self.name.clone() } else { self.description.clone() } + } +} + +/// `default.*` keys of the `default` metadata object (values are node names). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Defaults { + /// `default.audio.sink`: the sink the session manager actually uses. + pub sink: Option, + /// `default.audio.source`: the source the session manager actually uses. + pub source: Option, + /// `default.configured.audio.source`: the user's choice (may name an absent node). + pub configured_source: Option, +} + +impl Defaults { + /// Applies one metadata property; returns whether it was one of ours. `value` is `None` + /// when the key is removed. + pub fn apply(&mut self, key: &str, value: Option<&str>) -> bool { + let slot = match key { + "default.audio.sink" => &mut self.sink, + "default.audio.source" => &mut self.source, + "default.configured.audio.source" => &mut self.configured_source, + _ => return false, + }; + *slot = value.and_then(node_name_from_json); + true + } +} + +/// Values of the `default` metadata are JSON objects: `{ "name": "alsa_output.pci-…" }`. +pub fn node_name_from_json(value: &str) -> Option { + let parsed: serde_json::Value = serde_json::from_str(value).ok()?; + parsed.get("name")?.as_str().filter(|s| !s.is_empty()).map(str::to_owned) +} + +/// The JSON value for `default.configured.audio.{sink,source}`. +pub fn node_name_json(name: &str) -> String { + serde_json::json!({ "name": name }).to_string() +} + +#[derive(Clone, Debug, Default)] +pub struct Graph { + /// Audio device nodes by global id. + pub nodes: BTreeMap, + pub defaults: Defaults, +} + +impl Graph { + /// Records a node global if it is an audio device; others are ignored. + pub fn add_node(&mut self, id: u32, name: Option<&str>, description: Option<&str>, class: Option<&str>) { + let (Some(name), Some(class)) = (name, class) else { return }; + let entry = NodeEntry { name: name.to_owned(), description: description.unwrap_or_default().to_owned(), class: class.to_owned() }; + if entry.is_sink() || entry.is_source() { + self.nodes.insert(id, entry); + } + } + + pub fn remove(&mut self, id: u32) -> bool { + self.nodes.remove(&id).is_some() + } + + pub fn find(&self, name: &str) -> Option<(u32, &NodeEntry)> { + self.nodes.iter().find(|(_, n)| n.name == name).map(|(id, n)| (*id, n)) + } + + /// The sink the session manager uses for playback (what the loopback captures), if present. + pub fn default_sink(&self) -> Option<(u32, &NodeEntry)> { + let name = self.defaults.sink.as_deref()?; + self.find(name).filter(|(_, n)| n.is_sink()) + } + + pub fn summary(&self) -> DeviceSummary { + let sinks = self.nodes.values().filter(|n| n.is_sink() && n.name != MIC_NODE_NAME); + let sources = self.nodes.values().filter(|n| n.is_source()); + let render_endpoints: Vec<_> = + sinks.map(|n| EndpointInfo { id: n.name.clone(), name: n.label(), is_cable: false }).collect(); + let capture_endpoints: Vec<_> = sources + .map(|n| EndpointInfo { id: n.name.clone(), name: n.label(), is_cable: n.name == MIC_NODE_NAME }) + .collect(); + let sink = self.default_sink().map(|(_, n)| n); + // The user's choice wins: it is what comes back when that device (or our mic) appears. + let capture_id = self.defaults.configured_source.clone().or_else(|| self.defaults.source.clone()); + let capture_name = capture_id.as_deref().map(|id| { + self.find(id).map_or_else(|| id.to_owned(), |(_, n)| n.label()) + }); + let mic_is_default = [&self.defaults.configured_source, &self.defaults.source] + .into_iter() + .any(|n| n.as_deref() == Some(MIC_NODE_NAME)); + DeviceSummary { + default_render_id: sink.map(|n| n.name.clone()), + default_render_name: sink.map(NodeEntry::label), + // Our virtual mic is a source; it can never become the playback default. + default_is_cable: false, + // The virtual mic is AudioBridge's own node: always available while the service runs, + // and a default set to it applies as soon as it appears. + cable_render_id: Some(MIC_NODE_NAME.to_owned()), + render_endpoints, + default_capture_id: capture_id, + default_capture_name: capture_name, + default_capture_is_cable: mic_is_default, + cable_capture_id: Some(MIC_NODE_NAME.to_owned()), + capture_endpoints, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn graph() -> Graph { + let mut g = Graph::default(); + g.add_node(40, Some("alsa_output.speakers"), Some("Speakers"), Some("Audio/Sink")); + g.add_node(41, Some("alsa_input.mic"), Some("Built-in Mic"), Some("Audio/Source")); + g.add_node(42, Some(MIC_NODE_NAME), Some("AudioBridge"), Some("Audio/Source/Virtual")); + g.add_node(43, Some("firefox"), None, Some("Stream/Output/Audio")); + g.defaults.apply("default.audio.sink", Some(r#"{"name":"alsa_output.speakers"}"#)); + g.defaults.apply("default.audio.source", Some(r#"{"name":"alsa_input.mic"}"#)); + g + } + + #[test] + fn streams_are_not_endpoints_and_our_mic_is_marked() { + let s = graph().summary(); + assert_eq!(s.render_endpoints.len(), 1); + assert_eq!(s.default_render_id.as_deref(), Some("alsa_output.speakers")); + assert_eq!(s.default_render_name.as_deref(), Some("Speakers")); + assert!(!s.default_capture_is_cable); + let cable: Vec<_> = s.capture_endpoints.iter().filter(|e| e.is_cable).map(|e| e.id.as_str()).collect(); + assert_eq!(cable, [MIC_NODE_NAME]); + assert_eq!(s.capture_restore_candidate(None).map(|e| e.id.as_str()), Some("alsa_input.mic")); + } + + #[test] + fn configured_mic_counts_as_default_even_while_absent() { + let mut g = graph(); + g.remove(42); + g.defaults.apply("default.configured.audio.source", Some(&node_name_json(MIC_NODE_NAME))); + let s = g.summary(); + // WirePlumber fell back to the real mic, but the user's choice is still the virtual mic: + // turning AudioBridge off must restore the real one as the configured default. + assert!(s.default_capture_is_cable); + assert_eq!(s.default_capture_id.as_deref(), Some(MIC_NODE_NAME)); + assert_eq!(s.capture_restore_candidate(Some("alsa_input.mic")).map(|e| e.id.as_str()), Some("alsa_input.mic")); + } + + #[test] + fn missing_default_sink_means_no_loopback_source() { + let mut g = graph(); + g.remove(40); + assert!(g.summary().default_render_id.is_none()); + g.defaults.apply("default.audio.sink", None); + assert!(g.default_sink().is_none()); + } +} diff --git a/crates/desktop/src/linux/audio/mod.rs b/crates/desktop/src/linux/audio/mod.rs new file mode 100644 index 0000000..de04c7a --- /dev/null +++ b/crates/desktop/src/linux/audio/mod.rs @@ -0,0 +1,584 @@ +//! Audio engine (Linux): one thread runs a PipeWire main loop. It keeps the virtual microphone +//! node alive, captures the default sink's monitor while the phone wants PC audio, follows the +//! default sink's volume, and reports devices, mic demand and volume through [`Hooks`]. Every +//! input is an event (registry, metadata, params, stream states, commands); nothing is polled. +//! If PipeWire goes away the engine reconnects every [`RETRY`]. + +mod graph; +mod oneshot; +mod streams; +mod volume; + +use std::cell::{Cell, RefCell}; +use std::collections::{HashMap, VecDeque}; +use std::rc::Rc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use audiobridge_core::audio::{CaptureHandle, PlayoutHandle}; +use audiobridge_core::session::Volume; +use pipewire as pw; +use pw::core::CoreRc; +use pw::metadata::{Metadata, MetadataListener}; +use pw::node::{Node, NodeListener}; +use pw::properties::{properties, PropertiesBox}; +use pw::registry::{GlobalObject, RegistryRc}; +use pw::spa::param::ParamType; +use pw::stream::StreamState; +use pw::types::ObjectType; + +pub use crate::endpoints::DeviceSummary; + +use self::graph::Graph; +use self::streams::{Capture, CaptureSlot, MicSlot, VirtualMic}; +use self::volume::{PropsUpdate, VolumeWriter}; + +/// `node.name` of the virtual microphone (the Linux counterpart of VB-CABLE's "CABLE Output"). +pub const MIC_NODE_NAME: &str = "audiobridge_mic"; +/// What mixers and apps show for it. +pub const MIC_NODE_DESCRIPTION: &str = "AudioBridge: микрофон телефона"; + +/// What the session wants from the audio side, derived from `Status`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Desired { + pub connected: bool, + pub pc_audio: bool, + pub mic_active: bool, +} + +pub enum AudioMsg { + Desired(Desired), + /// Report the device landscape again (e.g. after the default device was switched). + DevicesChanged, + /// Set the default playback device's volume, percent. + SetVolume(u8), + /// Mute/unmute the default playback device. + SetMute(bool), + Shutdown, +} + +/// Sender half of the engine's command channel; wakes the PipeWire loop. +pub type AudioSender = pw::channel::Sender; + +pub struct Hooks { + pub on_devices: Box, + pub on_demand: Box, + /// Default playback device volume, called on change only (`None` without a default device). + pub on_volume: Box) + Send>, +} + +const RETRY: Duration = Duration::from_secs(2); + +/// The current device landscape, read now through a short-lived PipeWire connection. +pub fn current_devices() -> Option { + match oneshot::current_devices() { + Ok(d) => Some(d), + Err(e) => { + tracing::warn!("reading audio devices: {e:#}"); + None + } + } +} + +/// Makes the node `id` (a `node.name`) the default playback or recording device. +pub fn set_default_endpoint(id: &str) -> Result<()> { + oneshot::set_default(id) +} + +pub struct AudioEngine { + tx: AudioSender, + stop: Arc, + join: JoinHandle<()>, +} + +impl AudioEngine { + pub fn spawn(capture: CaptureHandle, playout: PlayoutHandle, hooks: Hooks) -> std::io::Result { + let (tx, rx) = pw::channel::channel(); + let stop = Arc::new(AtomicBool::new(false)); + let thread_stop = stop.clone(); + let join = std::thread::Builder::new() + .name("audio-supervisor".into()) + .spawn(move || supervise(rx, &thread_stop, capture, playout, hooks))?; + Ok(Self { tx, stop, join }) + } + + pub fn sender(&self) -> AudioSender { + self.tx.clone() + } + + pub fn shutdown(self) { + self.stop.store(true, Ordering::SeqCst); + let _ = self.tx.send(AudioMsg::Shutdown); + let _ = self.join.join(); + } +} + +fn supervise( + mut rx: pw::channel::Receiver, + stop: &AtomicBool, + capture: CaptureHandle, + playout: PlayoutHandle, + hooks: Hooks, +) { + pw::init(); + let mut engine = Engine { + hooks, + capture_slot: Arc::new(Mutex::new(Some(capture))), + mic_slot: Arc::new(Mutex::new(Some(playout))), + writer: VolumeWriter::spawn(), + desired: Desired::default(), + reported_devices: None, + reported_volume: None, + demand: false, + reported_demand: None, + shutdown: false, + }; + let mut failures = 0u32; + while !stop.load(Ordering::SeqCst) { + let (back, back_rx, outcome) = run_session(engine, rx); + engine = back; + rx = back_rx; + match outcome { + Ok(()) => break, + Err(e) => { + // first failure loud, the retries quiet (PipeWire may simply not run yet) + if failures == 0 { + tracing::warn!("PipeWire: {e:#}; retrying every {RETRY:?}"); + } else { + tracing::debug!("PipeWire: {e:#}"); + } + failures = failures.saturating_add(1); + engine.disconnected(); + } + } + let deadline = Instant::now() + RETRY; + while Instant::now() < deadline && !stop.load(Ordering::SeqCst) { + std::thread::sleep(Duration::from_millis(50)); + } + } +} + +/// State that outlives PipeWire connections. +struct Engine { + hooks: Hooks, + capture_slot: CaptureSlot, + mic_slot: MicSlot, + writer: VolumeWriter, + desired: Desired, + reported_devices: Option, + reported_volume: Option>, + /// Something records from the virtual mic. + demand: bool, + reported_demand: Option, + shutdown: bool, +} + +impl Engine { + /// PipeWire went away: the virtual mic is gone with it, and so is the volume. + fn disconnected(&mut self) { + self.demand = false; + self.report(None); + } + + fn report(&mut self, volume: Option) { + if self.reported_volume != Some(volume) { + tracing::debug!("playback volume: {volume:?}"); + (self.hooks.on_volume)(volume); + self.reported_volume = Some(volume); + } + if self.desired.connected { + if self.reported_demand != Some(self.demand) { + tracing::info!("mic demand: {}", self.demand); + (self.hooks.on_demand)(self.demand); + self.reported_demand = Some(self.demand); + } + } else { + self.reported_demand = None; + } + } +} + +/// The default sink, bound to follow its `Props` (volume, mute). +struct SinkWatch { + id: u32, + volume: Option, + _listener: NodeListener, + _node: Node, +} + +/// Objects of one PipeWire connection. +struct Session { + mainloop: pw::main_loop::MainLoopWeak, + core: CoreRc, + registry: RegistryRc, + graph: Graph, + /// Audio device node globals, to bind the default sink when it becomes the default. + globals: HashMap>, + metadata: Option<(u32, Metadata, MetadataListener)>, + sink: Option, + capture: Option, + capture_failed: bool, + mic: Option, + mic_failed: bool, + lost: Option, +} + +enum Ev { + Global(GlobalObject), + Removed(u32), + Meta(String, Option), + Props(u32, PropsUpdate), + MicState(StreamState), + CaptureState(StreamState), + Msg(AudioMsg), + Lost(String), + /// Periodic: retry streams that failed to connect. + Tick, +} + +struct Inner { + engine: Engine, + session: Session, +} + +/// Every callback posts an event; the first one drains the queue and applies the result. Events +/// posted while draining (stream callbacks fire synchronously from `connect`) are queued, so +/// nothing is ever borrowed twice. +struct Ctx { + queue: RefCell>, + pumping: Cell, + inner: RefCell, +} + +impl Ctx { + fn post(self: &Rc, ev: Ev) { + self.queue.borrow_mut().push_back(ev); + if self.pumping.replace(true) { + return; + } + loop { + loop { + let next = self.queue.borrow_mut().pop_front(); + let Some(ev) = next else { break }; + self.inner.borrow_mut().handle(ev, self); + } + self.inner.borrow_mut().refresh(self); + if self.queue.borrow().is_empty() { + break; + } + } + self.pumping.set(false); + } + + fn poster(self: &Rc) -> impl Fn(Ev) + Clone + 'static { + let weak = Rc::downgrade(self); + move |ev| { + if let Some(ctx) = weak.upgrade() { + ctx.post(ev); + } + } + } +} + +type Back = (Engine, pw::channel::Receiver, Result<()>); + +/// Runs one PipeWire connection until shutdown (`Ok`) or until it fails or is lost (`Err`). +fn run_session(engine: Engine, rx: pw::channel::Receiver) -> Back { + let setup = || -> Result<_> { + let mainloop = pw::main_loop::MainLoopRc::new(None).context("main loop")?; + let context = pw::context::ContextRc::new( + &mainloop, + Some(properties! { + *pw::keys::APP_NAME => "AudioBridge", + "application.icon-name" => "audiobridge", + }), + ) + .context("context")?; + let core = context.connect_rc(None).context("connecting (is PipeWire running?)")?; + let registry = core.get_registry_rc().context("registry")?; + Ok((mainloop, context, core, registry)) + }; + let (mainloop, context, core, registry) = match setup() { + Ok(v) => v, + Err(e) => return (engine, rx, Err(e)), + }; + let session = Session { + mainloop: mainloop.downgrade(), + core: core.clone(), + registry: registry.clone(), + graph: Graph::default(), + globals: HashMap::new(), + metadata: None, + sink: None, + capture: None, + capture_failed: false, + mic: None, + mic_failed: false, + lost: None, + }; + let ctx = Rc::new(Ctx { + queue: RefCell::new(VecDeque::new()), + pumping: Cell::new(false), + inner: RefCell::new(Inner { engine, session }), + }); + tracing::info!("connected to PipeWire"); + + let post = ctx.poster(); + let registry_listener = registry + .add_listener_local() + .global({ + let post = post.clone(); + move |obj| post(Ev::Global(obj.to_owned())) + }) + .global_remove({ + let post = post.clone(); + move |id| post(Ev::Removed(id)) + }) + .register(); + let core_listener = core + .add_listener_local() + .error({ + let post = post.clone(); + move |id, _seq, res, message| { + if id == pw::core::PW_ID_CORE { + post(Ev::Lost(format!("connection lost ({res}): {message}"))); + } + } + }) + .register(); + let attached = rx.attach(mainloop.loop_(), { + let post = post.clone(); + move |msg| post(Ev::Msg(msg)) + }); + let tick = mainloop.loop_().add_timer({ + let post = post.clone(); + move |_| post(Ev::Tick) + }); + let _ = tick.update_timer(Some(RETRY), Some(RETRY)); + // The virtual mic exists for the whole connection. + post(Ev::Tick); + + mainloop.run(); + + let rx = attached.deattach(); + drop(tick); + drop(core_listener); + drop(registry_listener); + drop(post); + // Disconnecting streams fires their state callbacks synchronously: from here on posts only + // queue (and are dropped with the queue) instead of re-entering `inner`. + ctx.pumping.set(true); + { + // Streams disconnect first (their data-thread callbacks stop), then the proxies. + let mut inner = ctx.inner.borrow_mut(); + let s = &mut inner.session; + s.capture = None; + s.mic = None; + s.sink = None; + s.metadata = None; + } + let ctx = match Rc::try_unwrap(ctx) { + Ok(ctx) => ctx, + Err(_) => unreachable!("listeners only hold weak references"), + }; + let Inner { engine, session } = ctx.inner.into_inner(); + let outcome = match (engine.shutdown, session.lost) { + (true, _) => Ok(()), + (false, Some(reason)) => Err(anyhow::anyhow!(reason)), + (false, None) => Err(anyhow::anyhow!("main loop ended")), + }; + drop(registry); + drop(core); + drop(context); + drop(mainloop); + (engine, rx, outcome) +} + +impl Inner { + fn handle(&mut self, ev: Ev, ctx: &Rc) { + let (engine, s) = (&mut self.engine, &mut self.session); + match ev { + Ev::Global(obj) => s.global(obj, ctx), + Ev::Removed(id) => { + s.graph.remove(id); + s.globals.remove(&id); + if s.sink.as_ref().is_some_and(|w| w.id == id) { + s.sink = None; + } + if s.metadata.as_ref().is_some_and(|(mid, _, _)| *mid == id) { + s.metadata = None; + } + } + Ev::Meta(key, value) => { + s.graph.defaults.apply(&key, value.as_deref()); + } + Ev::Props(node, update) => { + if let Some(w) = s.sink.as_mut().filter(|w| w.id == node) { + w.volume = update.apply(w.volume); + } + } + Ev::MicState(state) => { + tracing::debug!("virtual mic: {state:?}"); + if let StreamState::Error(e) = &state { + tracing::warn!("virtual mic failed: {e}; recreating in {RETRY:?}"); + s.mic = None; + s.mic_failed = true; + } + engine.demand = state == StreamState::Streaming; + } + Ev::CaptureState(state) => { + tracing::debug!("loopback: {state:?}"); + match state { + StreamState::Streaming => tracing::info!("loopback started"), + StreamState::Error(e) => { + tracing::warn!("loopback failed: {e}; retrying in {RETRY:?}"); + s.capture = None; + s.capture_failed = true; + } + _ => {} + } + } + Ev::Msg(AudioMsg::Desired(d)) => engine.desired = d, + Ev::Msg(AudioMsg::DevicesChanged) => engine.reported_devices = None, + Ev::Msg(AudioMsg::SetVolume(level)) => match &s.sink { + Some(w) => engine.writer.set_level(w.id, level), + None => tracing::info!("volume request dropped: no default playback device"), + }, + Ev::Msg(AudioMsg::SetMute(muted)) => match &s.sink { + Some(w) => engine.writer.set_mute(w.id, muted), + None => tracing::info!("mute request dropped: no default playback device"), + }, + Ev::Msg(AudioMsg::Shutdown) => { + engine.shutdown = true; + s.quit(); + } + Ev::Lost(reason) => { + s.lost = Some(reason); + s.quit(); + } + Ev::Tick => { + s.capture_failed = false; + s.mic_failed = false; + } + } + } + + /// Brings streams, the volume watch and the reports in line with the current state. + fn refresh(&mut self, ctx: &Rc) { + let (engine, s) = (&mut self.engine, &mut self.session); + if engine.shutdown || s.lost.is_some() { + return; + } + let summary = s.graph.summary(); + if engine.reported_devices.as_ref() != Some(&summary) { + tracing::info!("audio devices: {summary:?}"); + (engine.hooks.on_devices)(&summary); + engine.reported_devices = Some(summary); + } + + let default_sink = s.graph.default_sink().map(|(id, _)| id); + if s.sink.as_ref().map(|w| w.id) != default_sink { + s.sink = default_sink.and_then(|id| s.watch_sink(id, ctx)); + } + + if s.mic.is_none() && !s.mic_failed { + let post = ctx.poster(); + match VirtualMic::connect(&s.core, engine.mic_slot.clone(), move |st| post(Ev::MicState(st))) { + Ok(mic) => { + tracing::info!("virtual mic \"{MIC_NODE_NAME}\" created"); + s.mic = Some(mic); + } + Err(e) => { + tracing::warn!("virtual mic: {e:#}; retrying in {RETRY:?}"); + s.mic_failed = true; + } + } + } + + let want_pc = engine.desired.connected && engine.desired.pc_audio && default_sink.is_some(); + if want_pc && s.capture.is_none() && !s.capture_failed { + let post = ctx.poster(); + match Capture::connect(&s.core, engine.capture_slot.clone(), move |st| post(Ev::CaptureState(st))) { + Ok(c) => s.capture = Some(c), + Err(e) => { + tracing::warn!("loopback: {e:#}; retrying in {RETRY:?}"); + s.capture_failed = true; + } + } + } else if !want_pc && s.capture.take().is_some() { + tracing::info!("loopback stopped"); + } + + let volume = s.sink.as_ref().and_then(|w| w.volume); + engine.report(volume); + } +} + +impl Session { + fn quit(&self) { + if let Some(l) = self.mainloop.upgrade() { + l.quit(); + } + } + + fn global(&mut self, obj: GlobalObject, ctx: &Rc) { + let get = |k: &str| obj.props.as_ref().and_then(|p| p.get(k)); + match obj.type_ { + ObjectType::Node => { + let before = self.graph.nodes.len(); + self.graph.add_node(obj.id, get("node.name"), get("node.description"), get("media.class")); + if self.graph.nodes.len() != before { + self.globals.insert(obj.id, obj); + } + } + ObjectType::Metadata if get("metadata.name") == Some("default") => { + let metadata = match self.registry.bind::(&obj) { + Ok(m) => m, + Err(e) => { + tracing::warn!("binding the default metadata: {e}"); + return; + } + }; + let post = ctx.poster(); + let listener = metadata + .add_listener_local() + .property(move |subject, key, _type, value| { + if let (0, Some(key)) = (subject, key) { + post(Ev::Meta(key.to_owned(), value.map(str::to_owned))); + } + 0 + }) + .register(); + self.metadata = Some((obj.id, metadata, listener)); + } + _ => {} + } + } + + fn watch_sink(&self, id: u32, ctx: &Rc) -> Option { + let global = self.globals.get(&id)?; + let node: Node = match self.registry.bind(global) { + Ok(n) => n, + Err(e) => { + tracing::warn!("binding the default sink: {e}"); + return None; + } + }; + let post = ctx.poster(); + let listener = node + .add_listener_local() + .param(move |_seq, param_id, _index, _next, pod| { + if param_id == ParamType::Props { + if let Some(update) = pod.and_then(volume::parse_props) { + post(Ev::Props(id, update)); + } + } + }) + .register(); + node.subscribe_params(&[ParamType::Props]); + Some(SinkWatch { id, volume: None, _listener: listener, _node: node }) + } +} diff --git a/crates/desktop/src/linux/audio/oneshot.rs b/crates/desktop/src/linux/audio/oneshot.rs new file mode 100644 index 0000000..6b525ca --- /dev/null +++ b/crates/desktop/src/linux/audio/oneshot.rs @@ -0,0 +1,171 @@ +//! Short-lived PipeWire connections for synchronous questions from the service thread: the +//! current device landscape, and switching the default device (also while the engine is down, +//! e.g. restoring the user's microphone after AudioBridge was switched off). + +use std::cell::RefCell; +use std::rc::Rc; +use std::time::Duration; + +use anyhow::{anyhow, bail, Context, Result}; +use pipewire as pw; +use pw::metadata::{Metadata, MetadataListener}; +use pw::properties::properties; +use pw::types::ObjectType; + +use super::graph::{node_name_json, Graph}; +use super::MIC_NODE_NAME; + +const TIMEOUT: Duration = Duration::from_secs(3); + +#[derive(Default)] +struct State { + graph: Graph, + metadata: Option<(Metadata, MetadataListener)>, + /// Roundtrips completed: 1 = all globals seen, 2 = bound metadata reported its properties. + phase: u8, + pending: Option, + timed_out: bool, +} + +/// Connects, collects nodes and the `default` metadata, runs `act` (which may write metadata), +/// flushes with one more roundtrip and disconnects. +fn with_graph(act: impl FnOnce(&Graph, Option<&Metadata>) -> Result) -> Result { + pw::init(); + let mainloop = pw::main_loop::MainLoopRc::new(None).context("PipeWire main loop")?; + let context = pw::context::ContextRc::new(&mainloop, Some(properties! { *pw::keys::APP_NAME => "AudioBridge" })) + .context("PipeWire context")?; + let core = context.connect_rc(None).context("connecting to PipeWire (is it running?)")?; + let registry = core.get_registry_rc().context("PipeWire registry")?; + let state = Rc::new(RefCell::new(State::default())); + + let registry_listener = registry + .add_listener_local() + .global({ + let state = state.clone(); + let registry = registry.downgrade(); + move |obj| { + let props = obj.props.as_ref(); + match obj.type_ { + ObjectType::Node => { + let get = |k| props.and_then(|p| p.get(k)); + state.borrow_mut().graph.add_node(obj.id, get("node.name"), get("node.description"), get("media.class")); + } + ObjectType::Metadata if props.and_then(|p| p.get("metadata.name")) == Some("default") => { + let Some(registry) = registry.upgrade() else { return }; + let Ok(metadata) = registry.bind::(obj) else { return }; + let listener = metadata + .add_listener_local() + .property({ + let state = state.clone(); + move |subject, key, _type, value| { + if let (0, Some(key)) = (subject, key) { + state.borrow_mut().graph.defaults.apply(key, value); + } + 0 + } + }) + .register(); + state.borrow_mut().metadata = Some((metadata, listener)); + } + _ => {} + } + } + }) + .register(); + + let core_listener = core + .add_listener_local() + .done({ + let state = state.clone(); + let core = core.downgrade(); + let mainloop = mainloop.downgrade(); + move |id, seq| { + if id != pw::core::PW_ID_CORE || state.borrow().pending != Some(seq.seq()) { + return; + } + let mut s = state.borrow_mut(); + s.phase += 1; + s.pending = None; + if s.phase < 2 { + if let Some(next) = core.upgrade().and_then(|c| c.sync(0).ok()) { + s.pending = Some(next.seq()); + return; + } + } + if let Some(l) = mainloop.upgrade() { + l.quit(); + } + } + }) + .error({ + let mainloop = mainloop.downgrade(); + move |id, _seq, res, message| { + if id == pw::core::PW_ID_CORE { + tracing::warn!("PipeWire error {res}: {message}"); + if let Some(l) = mainloop.upgrade() { + l.quit(); + } + } + } + }) + .register(); + + let timer = mainloop.loop_().add_timer({ + let state = state.clone(); + let mainloop = mainloop.downgrade(); + move |_| { + state.borrow_mut().timed_out = true; + if let Some(l) = mainloop.upgrade() { + l.quit(); + } + } + }); + let _ = timer.update_timer(Some(TIMEOUT), None); + + state.borrow_mut().pending = Some(core.sync(0).context("PipeWire roundtrip")?.seq()); + mainloop.run(); + if state.borrow().phase < 2 { + bail!(if state.borrow().timed_out { "PipeWire did not answer" } else { "PipeWire connection failed" }); + } + + let result = { + let s = state.borrow(); + act(&s.graph, s.metadata.as_ref().map(|(m, _)| m)) + }; + // Flush writes before disconnecting. + if result.is_ok() { + state.borrow_mut().phase = 1; + state.borrow_mut().pending = Some(core.sync(0).context("PipeWire roundtrip")?.seq()); + let _ = timer.update_timer(Some(TIMEOUT), None); + mainloop.run(); + } + drop(timer); + drop(core_listener); + drop(registry_listener); + state.borrow_mut().metadata = None; + result +} + +/// The current device landscape. +pub fn current_devices() -> Result { + with_graph(|graph, _| Ok(graph.summary())) +} + +/// Makes the node named `id` the configured default sink or source (by its media class). Our +/// virtual mic may be absent (service off or still starting): the session manager applies the +/// choice as soon as it appears. +pub fn set_default(id: &str) -> Result<()> { + with_graph(|graph, metadata| { + let metadata = metadata.ok_or_else(|| anyhow!("no \"default\" metadata (is WirePlumber running?)"))?; + let key = match graph.find(id) { + Some((_, n)) if n.class == "Audio/Sink" => "default.configured.audio.sink", + Some((_, n)) if n.class.starts_with("Audio/Source") || n.class == "Audio/Duplex" => { + "default.configured.audio.source" + } + None if id == MIC_NODE_NAME => "default.configured.audio.source", + _ => bail!("no audio device named {id}"), + }; + metadata.set_property(0, key, Some("Spa:String:JSON"), Some(&node_name_json(id))); + Ok(()) + }) +} diff --git a/crates/desktop/src/linux/audio/streams.rs b/crates/desktop/src/linux/audio/streams.rs new file mode 100644 index 0000000..045fff2 --- /dev/null +++ b/crates/desktop/src/linux/audio/streams.rs @@ -0,0 +1,217 @@ +//! The two PipeWire streams: the loopback capture of the default sink's monitor (PC audio → phone) +//! and the virtual microphone source (phone mic → PC apps). +//! +//! Both process callbacks run on PipeWire's real-time data thread. The core handles live in +//! `Arc>>` slots owned by the engine, so they survive stream re-creation; the +//! data thread only ever `try_lock`s them (never waits) and the engine never locks them while a +//! stream is connected. Neither callback allocates. + +use std::sync::{Arc, Mutex}; + +use anyhow::{Context, Result}; +use audiobridge_core::audio::{CaptureHandle, PlayoutHandle}; +use pipewire as pw; +use pw::core::CoreRc; +use pw::properties::properties; +use pw::spa; +use pw::spa::param::audio::{AudioFormat, AudioInfoRaw, MAX_CHANNELS}; +use pw::spa::pod::{serialize::PodSerializer, Object, Pod, Value}; +use pw::spa::utils::Direction; +use pw::stream::{StreamFlags, StreamListener, StreamRc, StreamState}; + +use super::{MIC_NODE_DESCRIPTION, MIC_NODE_NAME}; + +const RATE: u32 = 48_000; +/// 10 ms, the core's frame: asks the graph for a short quantum (the graph may still pick more). +const LATENCY: &str = "480/48000"; +/// Largest block handled per callback (samples); bigger buffers are processed in pieces. +const SCRATCH: usize = 8192; + +pub type CaptureSlot = Arc>>; +pub type MicSlot = Arc>>; + +/// `EnumFormat`: F32 interleaved at 48 kHz with the given channel positions; PipeWire's adapter +/// converts from/to whatever the graph runs at. +fn format_param(positions: &[u32]) -> Result> { + let mut info = AudioInfoRaw::new(); + info.set_format(AudioFormat::F32LE); + info.set_rate(RATE); + info.set_channels(positions.len() as u32); + let mut position = [0; MAX_CHANNELS]; + position[..positions.len()].copy_from_slice(positions); + info.set_position(position); + let object = Object { + type_: spa::utils::SpaTypes::ObjectParamFormat.as_raw(), + id: spa::param::ParamType::EnumFormat.as_raw(), + properties: info.into(), + }; + let (cursor, _) = PodSerializer::serialize(std::io::Cursor::new(Vec::new()), &Value::Object(object)) + .context("serializing the stream format")?; + Ok(cursor.into_inner()) +} + +/// A connected stream; disconnects (synchronously with the data thread) before its callbacks' +/// state is freed. +struct Connected { + process: Option>, + state: Option>, + stream: StreamRc, +} + +impl Drop for Connected { + fn drop(&mut self) { + if let Err(e) = self.stream.disconnect() { + tracing::debug!("stream disconnect: {e}"); + } + self.process.take(); + self.state.take(); + } +} + +struct CaptureData { + slot: CaptureSlot, + scratch: Vec, +} + +/// Loopback capture of the default sink's monitor (`stream.capture.sink`). Without a target the +/// session manager links it to the default sink and moves it when the default changes. +pub struct Capture { + _connected: Connected, +} + +impl Capture { + pub fn connect(core: &CoreRc, slot: CaptureSlot, on_state: impl Fn(StreamState) + 'static) -> Result { + let stream = StreamRc::new( + core.clone(), + "audiobridge-capture", + properties! { + *pw::keys::MEDIA_TYPE => "Audio", + *pw::keys::MEDIA_CATEGORY => "Capture", + *pw::keys::MEDIA_ROLE => "Music", + *pw::keys::STREAM_CAPTURE_SINK => "true", + *pw::keys::NODE_NAME => "audiobridge_capture", + *pw::keys::NODE_DESCRIPTION => "AudioBridge: звук компьютера → телефон", + *pw::keys::NODE_LATENCY => LATENCY, + *pw::keys::APP_NAME => "AudioBridge", + }, + ) + .context("creating the capture stream")?; + let data = CaptureData { slot, scratch: vec![0.0; SCRATCH] }; + let process = stream + .add_local_listener_with_user_data(data) + .process(|stream, data| { + let Some(mut buffer) = stream.dequeue_buffer() else { return }; + let Some(d) = buffer.datas_mut().first_mut() else { return }; + let (offset, size) = (d.chunk().offset() as usize, d.chunk().size() as usize); + let Some(bytes) = d.data() else { return }; + let end = offset.saturating_add(size).min(bytes.len()); + let bytes = &bytes[offset.min(end)..end]; + let Ok(mut guard) = data.slot.try_lock() else { return }; + let Some(handle) = guard.as_mut() else { return }; + for block in bytes.chunks(SCRATCH * 4) { + let n = block.len() / 4; + for (dst, src) in data.scratch[..n].iter_mut().zip(block.as_chunks::<4>().0) { + *dst = f32::from_le_bytes(*src); + } + handle.push(&data.scratch[..n]); + } + }) + .register() + .context("capture stream listener")?; + let state = stream + .add_local_listener_with_user_data(()) + .state_changed(move |_, _, _, new| on_state(new)) + .register() + .context("capture state listener")?; + let param = format_param(&[spa::sys::SPA_AUDIO_CHANNEL_FL, spa::sys::SPA_AUDIO_CHANNEL_FR])?; + let mut params = [Pod::from_bytes(¶m).context("format pod")?]; + let connected = Connected { process: Some(process), state: Some(state), stream }; + connected + .stream + .connect( + Direction::Input, + None, + StreamFlags::AUTOCONNECT | StreamFlags::MAP_BUFFERS | StreamFlags::RT_PROCESS, + &mut params, + ) + .context("connecting the capture stream")?; + Ok(Self { _connected: connected }) + } +} + +struct MicData { + slot: MicSlot, + scratch: Vec, +} + +/// The virtual microphone: an `Audio/Source` node (marked `node.virtual`) apps record from like +/// from any mic. Not `Audio/Source/Virtual`: WirePlumber treats that class as a null-sink style +/// source and configures *input* ports on it, which an output stream can't take (a mono one +/// never finishes, which stalls all of WirePlumber's linking). +/// It is not linked by us; the graph runs it (and calls `process`) only while something records. +/// The node disappears with the stream, and with the process if it dies. +pub struct VirtualMic { + _connected: Connected, +} + +impl VirtualMic { + pub fn connect(core: &CoreRc, slot: MicSlot, on_state: impl Fn(StreamState) + 'static) -> Result { + let stream = StreamRc::new( + core.clone(), + "audiobridge-mic", + properties! { + "media.class" => "Audio/Source", + *pw::keys::NODE_VIRTUAL => "true", + *pw::keys::NODE_NAME => MIC_NODE_NAME, + *pw::keys::NODE_DESCRIPTION => MIC_NODE_DESCRIPTION, + *pw::keys::NODE_LATENCY => LATENCY, + *pw::keys::AUDIO_CHANNELS => "1", + "audio.position" => "MONO", + *pw::keys::APP_NAME => "AudioBridge", + "device.icon-name" => "audio-input-microphone", + }, + ) + .context("creating the virtual microphone")?; + let data = MicData { slot, scratch: vec![0.0; SCRATCH] }; + let process = stream + .add_local_listener_with_user_data(data) + .process(|stream, data| { + let Some(mut buffer) = stream.dequeue_buffer() else { return }; + let requested = buffer.requested() as usize; + let Some(d) = buffer.datas_mut().first_mut() else { return }; + let Some(bytes) = d.data() else { return }; + let max = (bytes.len() / 4).min(SCRATCH); + let frames = if requested > 0 { requested.min(max) } else { max.min(480) }; + let out = &mut data.scratch[..frames]; + match data.slot.try_lock() { + Ok(mut guard) => match guard.as_mut() { + Some(handle) => handle.fill(out), + None => out.fill(0.0), + }, + Err(_) => out.fill(0.0), + } + for (dst, s) in bytes.as_chunks_mut::<4>().0.iter_mut().zip(out.iter()) { + *dst = s.to_le_bytes(); + } + let chunk = d.chunk_mut(); + *chunk.offset_mut() = 0; + *chunk.stride_mut() = 4; + *chunk.size_mut() = (frames * 4) as u32; + }) + .register() + .context("virtual microphone listener")?; + let state = stream + .add_local_listener_with_user_data(()) + .state_changed(move |_, _, _, new| on_state(new)) + .register() + .context("virtual microphone state listener")?; + let param = format_param(&[spa::sys::SPA_AUDIO_CHANNEL_MONO])?; + let mut params = [Pod::from_bytes(¶m).context("format pod")?]; + let connected = Connected { process: Some(process), state: Some(state), stream }; + connected + .stream + .connect(Direction::Output, None, StreamFlags::MAP_BUFFERS | StreamFlags::RT_PROCESS, &mut params) + .context("connecting the virtual microphone")?; + Ok(Self { _connected: connected }) + } +} diff --git a/crates/desktop/src/linux/audio/volume.rs b/crates/desktop/src/linux/audio/volume.rs new file mode 100644 index 0000000..15b3dde --- /dev/null +++ b/crates/desktop/src/linux/audio/volume.rs @@ -0,0 +1,152 @@ +//! Volume of the default sink. Reading: the node's `Props` param (PipeWire pushes every change, +//! from us, the desktop's mixer or media keys). Writing: `wpctl`, which goes through +//! WirePlumber's mixer API and so updates hardware routes the same way every desktop mixer does. + +use std::process::Command; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::thread::JoinHandle; + +use audiobridge_core::proto::MAX_LEVEL; +use audiobridge_core::session::Volume; +use pipewire::spa::pod::deserialize::PodDeserializer; +use pipewire::spa::pod::{Pod, Value, ValueArray}; +use pipewire::spa::sys::{SPA_PROP_channelVolumes, SPA_PROP_mute}; + +/// Volume and mute from a `Props` param. `None` if it carries neither (partial updates). +pub fn parse_props(pod: &Pod) -> Option { + let (_, value) = PodDeserializer::deserialize_any_from(pod.as_bytes()).ok()?; + let Value::Object(object) = value else { return None }; + let mut update = PropsUpdate::default(); + for prop in object.properties { + match (prop.key, prop.value) { + (key, Value::ValueArray(ValueArray::Float(v))) if key == SPA_PROP_channelVolumes && !v.is_empty() => { + update.level = Some(level_from_linear(v.iter().copied().fold(0.0, f32::max))); + } + (key, Value::Bool(m)) if key == SPA_PROP_mute => update.muted = Some(m), + _ => {} + } + } + (update.level.is_some() || update.muted.is_some()).then_some(update) +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct PropsUpdate { + pub level: Option, + pub muted: Option, +} + +impl PropsUpdate { + /// Merges into the last known state; a complete volume needs a level. + pub fn apply(self, current: Option) -> Option { + let level = self.level.or(current.map(|v| v.level))?; + let muted = self.muted.or(current.map(|v| v.muted)).unwrap_or(false); + Some(Volume { level, muted }) + } +} + +/// Channel volumes are linear amplitude; mixers (wpctl, pavucontrol, the phone's slider) show the +/// cubic scale: percent = cbrt(linear) × 100. +pub fn level_from_linear(linear: f32) -> u8 { + (linear.max(0.0).cbrt() * 100.0).round().min(f32::from(MAX_LEVEL)) as u8 +} + +enum Op { + Level(u32, u8), + Mute(u32, bool), +} + +/// Runs `wpctl` off the PipeWire thread; a dragged slider's burst collapses to the latest value. +pub struct VolumeWriter { + tx: Option>, + join: Option>, +} + +impl VolumeWriter { + pub fn spawn() -> Self { + let (tx, rx) = mpsc::channel(); + match std::thread::Builder::new().name("volume-writer".into()).spawn(move || write_loop(&rx)) { + Ok(join) => Self { tx: Some(tx), join: Some(join) }, + Err(e) => { + tracing::warn!("volume writer thread: {e}; PC volume control disabled"); + Self { tx: None, join: None } + } + } + } + + pub fn set_level(&self, node: u32, level: u8) { + self.send(Op::Level(node, level.min(MAX_LEVEL))); + } + + pub fn set_mute(&self, node: u32, muted: bool) { + self.send(Op::Mute(node, muted)); + } + + fn send(&self, op: Op) { + if let Some(tx) = &self.tx { + let _ = tx.send(op); + } + } +} + +impl Drop for VolumeWriter { + fn drop(&mut self) { + drop(self.tx.take()); + if let Some(join) = self.join.take() { + let _ = join.join(); + } + } +} + +fn write_loop(rx: &Receiver) { + while let Ok(first) = rx.recv() { + let mut level = None; + let mut mute = None; + for op in std::iter::once(first).chain(rx.try_iter()) { + match op { + Op::Level(node, l) => level = Some((node, l)), + Op::Mute(node, m) => mute = Some((node, m)), + } + } + if let Some((node, l)) = level { + wpctl(&["set-volume", &node.to_string(), &format!("{:.2}", f32::from(l) / 100.0)]); + } + if let Some((node, m)) = mute { + wpctl(&["set-mute", &node.to_string(), if m { "1" } else { "0" }]); + } + } +} + +fn wpctl(args: &[&str]) { + match Command::new("wpctl").args(args).output() { + Ok(out) if out.status.success() => tracing::debug!("wpctl {}", args.join(" ")), + Ok(out) => tracing::warn!( + "wpctl {} failed ({}): {}", + args.join(" "), + out.status, + String::from_utf8_lossy(&out.stderr).trim() + ), + Err(e) => tracing::warn!("cannot run wpctl (WirePlumber): {e}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn linear_volume_maps_to_mixer_percent() { + assert_eq!(level_from_linear(1.0), 100); + assert_eq!(level_from_linear(0.125), 50); + assert_eq!(level_from_linear(0.0), 0); + // software boost above 100 % is reported as the maximum the protocol carries + assert_eq!(level_from_linear(3.0), MAX_LEVEL); + } + + #[test] + fn partial_props_updates_keep_the_other_half() { + let before = Some(Volume { level: 40, muted: false }); + assert_eq!(PropsUpdate { level: None, muted: Some(true) }.apply(before), Some(Volume { level: 40, muted: true })); + assert_eq!(PropsUpdate { level: Some(70), muted: None }.apply(before), Some(Volume { level: 70, muted: false })); + assert_eq!(PropsUpdate { level: None, muted: Some(true) }.apply(None), None); + } +} diff --git a/crates/desktop/src/linux/autostart.rs b/crates/desktop/src/linux/autostart.rs new file mode 100644 index 0000000..7279236 --- /dev/null +++ b/crates/desktop/src/linux/autostart.rs @@ -0,0 +1,69 @@ +//! XDG autostart entry `~/.config/autostart/audiobridge.desktop`: `"" --background`. +//! Session managers (systemd's xdg-autostart-generator under uwsm/Hyprland, GNOME, KDE) start it +//! at login. + +use std::path::Path; + +use anyhow::{Context, Result}; + +use crate::paths; + +const FILE: &str = "audiobridge.desktop"; + +/// Enables (for the running executable) or disables autostart. +pub fn set(enabled: bool) -> Result<()> { + let path = paths::autostart_dir().join(FILE); + if !enabled { + return match std::fs::remove_file(&path) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => { + Err(e).with_context(|| format!("removing {}", path.display())) + } + _ => Ok(()), + }; + } + let exe = std::env::current_exe().context("current_exe")?; + let dir = paths::autostart_dir(); + std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?; + let tmp = path.with_extension("desktop.tmp"); + std::fs::write(&tmp, entry(&exe)) + .and_then(|()| std::fs::rename(&tmp, &path)) + .with_context(|| format!("writing {}", path.display())) +} + +fn entry(exe: &Path) -> String { + format!( + "[Desktop Entry]\nType=Application\nName=AudioBridge\nComment=Звук компьютера на телефон, микрофон телефона на компьютер\nExec={} --background\nIcon=audiobridge\nTerminal=false\nNoDisplay=true\nX-GNOME-Autostart-enabled=true\n", + quote_exec(&exe.to_string_lossy()) + ) +} + +/// Quotes an `Exec=` argument per the Desktop Entry spec (inside double quotes `"`, `` ` ``, `$` +/// and `\` are escaped with a backslash; `\` itself must then be escaped again for the +/// string-level unescaping of the key file). +fn quote_exec(arg: &str) -> String { + let mut out = String::with_capacity(arg.len() + 2); + out.push('"'); + for c in arg.chars() { + match c { + '"' | '`' | '$' => { + out.push_str("\\\\"); + out.push(c); + } + '\\' => out.push_str("\\\\\\\\"), + _ => out.push(c), + } + } + out.push('"'); + out +} + +#[cfg(test)] +mod tests { + use super::quote_exec; + + #[test] + fn exec_paths_are_quoted_for_desktop_files() { + assert_eq!(quote_exec("/usr/bin/audiobridge"), "\"/usr/bin/audiobridge\""); + assert_eq!(quote_exec("/home/a b/$x"), "\"/home/a b/\\\\$x\""); + } +} diff --git a/crates/desktop/src/linux/instance.rs b/crates/desktop/src/linux/instance.rs new file mode 100644 index 0000000..ea01830 --- /dev/null +++ b/crates/desktop/src/linux/instance.rs @@ -0,0 +1,114 @@ +//! Single instance: the primary listens on a Unix socket in `$XDG_RUNTIME_DIR`. A second launch +//! connects, sends `show` (or `quit`) and exits. A socket file left by a crashed instance is +//! detected (nobody accepts) and replaced. + +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use anyhow::Context; + +use crate::paths; + +type Callback = Arc>>>; + +pub struct Primary { + path: PathBuf, + show: Callback, + quit: Callback, +} + +/// `Some` if this process is now the primary instance, `None` if another one is running. +pub fn acquire() -> anyhow::Result> { + let path = paths::instance_socket(); + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?; + } + if UnixStream::connect(&path).is_ok() { + return Ok(None); + } + // Nobody accepts: a stale socket file of a crashed instance (or none at all). + let _ = std::fs::remove_file(&path); + let listener = match UnixListener::bind(&path) { + Ok(l) => l, + // another launch won the race between our connect and bind + Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => return Ok(None), + Err(e) => return Err(e).with_context(|| format!("binding {}", path.display())), + }; + let primary = Primary { path, show: Arc::default(), quit: Arc::default() }; + let (show, quit) = (primary.show.clone(), primary.quit.clone()); + std::thread::Builder::new() + .name("instance-listener".into()) + .spawn(move || serve(&listener, &show, &quit)) + .context("instance listener thread")?; + Ok(Some(primary)) +} + +fn serve(listener: &UnixListener, show: &Callback, quit: &Callback) { + for conn in listener.incoming() { + let Ok(conn) = conn else { continue }; + let _ = conn.set_read_timeout(Some(Duration::from_secs(2))); + let mut line = String::new(); + if BufReader::new(conn).read_line(&mut line).is_err() { + continue; + } + let callback = match line.trim() { + "show" => show, + "quit" => quit, + // `another_running()` probes by connecting without a request + "" => continue, + other => { + tracing::warn!("instance socket: unknown request {other:?}"); + continue; + } + }; + if let Some(f) = callback.lock().unwrap_or_else(|e| e.into_inner()).as_ref() { + f(); + } + } +} + +pub fn another_running() -> bool { + UnixStream::connect(paths::instance_socket()).is_ok() +} + +fn signal(request: &str) { + match UnixStream::connect(paths::instance_socket()) { + Ok(mut s) => { + let _ = s.write_all(format!("{request}\n").as_bytes()); + } + Err(e) => tracing::warn!("cannot reach the running instance: {e}"), + } +} + +/// Asks the running instance to show its window. +pub fn signal_existing() { + signal("show"); +} + +impl Primary { + /// `f` runs on the listener thread whenever another launch asks for the window. + pub fn listen_for_show(&self, f: impl Fn() + Send + 'static) { + *self.show.lock().unwrap_or_else(|e| e.into_inner()) = Some(Box::new(f)); + } + + /// `f` runs on the listener thread when another process asks this one to exit. + pub fn listen_for_quit(&self, f: impl Fn() + Send + 'static) { + *self.quit.lock().unwrap_or_else(|e| e.into_inner()) = Some(Box::new(f)); + } +} + +impl Drop for Primary { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + +/// Asks the running instance to exit (`--quit`). Returns whether one was reached. +pub fn signal_quit() -> bool { + UnixStream::connect(paths::instance_socket()) + .and_then(|mut s| s.write_all(b"quit\n")) + .is_ok() +} diff --git a/crates/desktop/src/linux/media.rs b/crates/desktop/src/linux/media.rs new file mode 100644 index 0000000..b5d3712 --- /dev/null +++ b/crates/desktop/src/linux/media.rs @@ -0,0 +1,362 @@ +//! Media transport over MPRIS (D-Bus session bus): headphone buttons on the phone control the +//! PC's player (browsers, Spotify, mpv, Yandex Music clients…), and the phone sees what it plays. +//! Event-driven: `PropertiesChanged` and `NameOwnerChanged` signals trigger a re-read. + +use std::collections::HashMap; +use std::thread::JoinHandle; + +use anyhow::{Context, Result}; +use audiobridge_core::session::{MediaCommand, PcMedia, Playback}; +use futures_util::StreamExt; +use tokio::sync::mpsc; +use zbus::message::Type as MessageType; +use zbus::proxy::CacheProperties; +use zbus::zvariant::{OwnedValue, Value}; +use zbus::{Connection, MatchRule, MessageStream, Proxy}; + +const MPRIS_PREFIX: &str = "org.mpris.MediaPlayer2."; +const MPRIS_PATH: &str = "/org/mpris/MediaPlayer2"; +const PLAYER_IFACE: &str = "org.mpris.MediaPlayer2.Player"; +const ROOT_IFACE: &str = "org.mpris.MediaPlayer2"; + +enum Message { + Command(MediaCommand), + Shutdown, +} + +#[derive(Clone)] +pub struct Sender(mpsc::UnboundedSender); + +impl Sender { + pub fn send(&self, command: MediaCommand) { + if self.0.send(Message::Command(command)).is_err() { + tracing::warn!(?command, "media transport thread unavailable"); + } + } +} + +pub struct MediaTransport { + sender: Sender, + thread: JoinHandle<()>, +} + +impl MediaTransport { + pub fn spawn(report: impl Fn(PcMedia) + Send + 'static) -> Result { + let (tx, rx) = mpsc::unbounded_channel(); + let thread = std::thread::Builder::new() + .name("media-transport".into()) + .spawn(move || { + match tokio::runtime::Builder::new_current_thread().enable_all().build() { + Ok(rt) => rt.block_on(run(rx, report)), + Err(e) => tracing::warn!("media transport runtime: {e}"), + } + }) + .context("spawn media transport thread")?; + Ok(Self { sender: Sender(tx), thread }) + } + + pub fn sender(&self) -> Sender { + self.sender.clone() + } + + pub fn shutdown(self) { + let _ = self.sender.0.send(Message::Shutdown); + if self.thread.join().is_err() { + tracing::warn!("media transport thread failed during shutdown"); + } + } +} + +async fn run(mut rx: mpsc::UnboundedReceiver, report: impl Fn(PcMedia)) { + let conn = match Connection::session().await { + Ok(c) => c, + Err(e) => { + tracing::warn!("D-Bus session bus unavailable; headphone buttons cannot control players: {e}"); + report(PcMedia::default()); + while let Some(Message::Command(command)) = rx.recv().await { + tracing::info!(?command, "media command ignored: no D-Bus session bus"); + } + return; + } + }; + let (mut props, mut owners) = match signals(&conn).await { + Ok(s) => s, + Err(e) => { + tracing::warn!("MPRIS signal subscription: {e:#}"); + (None, None) + } + }; + let mut players = Players::default(); + let mut last = None; + refresh(&conn, &mut players, &report, &mut last).await; + loop { + tokio::select! { + message = rx.recv() => match message { + None | Some(Message::Shutdown) => break, + Some(Message::Command(command)) => { + apply(&conn, &mut players, command).await; + refresh(&conn, &mut players, &report, &mut last).await; + } + }, + Some(_) = next(&mut props) => refresh(&conn, &mut players, &report, &mut last).await, + Some(_) = next(&mut owners) => refresh(&conn, &mut players, &report, &mut last).await, + } + } +} + +/// The next message of an optional stream; pending forever without one. +async fn next(stream: &mut Option) -> Option<()> { + match stream { + Some(s) => s.next().await.map(|_| ()), + None => std::future::pending().await, + } +} + +async fn signals(conn: &Connection) -> Result<(Option, Option)> { + let props = MatchRule::builder() + .msg_type(MessageType::Signal) + .interface("org.freedesktop.DBus.Properties")? + .member("PropertiesChanged")? + .path(MPRIS_PATH)? + .build(); + let owners = MatchRule::builder() + .msg_type(MessageType::Signal) + .sender("org.freedesktop.DBus")? + .interface("org.freedesktop.DBus")? + .member("NameOwnerChanged")? + .arg0ns("org.mpris.MediaPlayer2")? + .build(); + Ok(( + Some(MessageStream::for_match_rule(props, conn, Some(64)).await?), + Some(MessageStream::for_match_rule(owners, conn, Some(64)).await?), + )) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct Player { + bus_name: String, + playback: Playback, +} + +#[derive(Default)] +struct Players { + list: Vec, + /// The player that played or was controlled most recently. + last_active: Option, +} + +impl Players { + fn selected(&self) -> Option<&Player> { + select(&self.list, self.last_active.as_deref()).map(|i| &self.list[i]) + } +} + +/// Exactly one target, like the Windows media session: a playing player (the most recently +/// active one if several play), else the most recently active one, else the first one. +fn select(list: &[Player], last_active: Option<&str>) -> Option { + let last = last_active.and_then(|name| list.iter().position(|p| p.bus_name == name)); + let playing = |i: &usize| list[*i].playback == Playback::Playing; + last.filter(playing) + .or_else(|| (0..list.len()).find(playing)) + .or(last) + .or((!list.is_empty()).then_some(0)) +} + +async fn proxy<'a>(conn: &Connection, bus_name: &'a str, iface: &'a str) -> zbus::Result> { + zbus::proxy::Builder::new(conn) + .destination(bus_name)? + .path(MPRIS_PATH)? + .interface(iface)? + .cache_properties(CacheProperties::No) + .build() + .await +} + +async fn bus_names(conn: &Connection) -> zbus::Result> { + let reply = conn + .call_method(Some("org.freedesktop.DBus"), "/org/freedesktop/DBus", Some("org.freedesktop.DBus"), "ListNames", &()) + .await?; + let names: Vec = reply.body().deserialize()?; + Ok(names.into_iter().filter(|n| n.starts_with(MPRIS_PREFIX)).collect()) +} + +fn map_status(status: &str) -> Playback { + match status { + "Playing" => Playback::Playing, + "Paused" => Playback::Paused, + _ => Playback::Stopped, + } +} + +async fn playback(conn: &Connection, bus_name: &str) -> zbus::Result { + let p = proxy(conn, bus_name, PLAYER_IFACE).await?; + Ok(map_status(&p.get_property::("PlaybackStatus").await?)) +} + +async fn refresh(conn: &Connection, players: &mut Players, report: &impl Fn(PcMedia), last: &mut Option) { + let names = match bus_names(conn).await { + Ok(n) => n, + Err(e) => { + tracing::warn!("listing MPRIS players: {e}"); + Vec::new() + } + }; + let mut list = Vec::with_capacity(names.len()); + for bus_name in names { + match playback(conn, &bus_name).await { + Ok(playback) => list.push(Player { bus_name, playback }), + Err(e) => tracing::debug!("MPRIS player {bus_name}: {e}"), + } + } + players.list = list; + if let Some(playing) = players.list.iter().find(|p| p.playback == Playback::Playing) { + let keep = players.selected().is_some_and(|s| s.playback == Playback::Playing); + if !keep { + players.last_active = Some(playing.bus_name.clone()); + } + } + let media = match players.selected() { + Some(p) => read_media(conn, p).await.unwrap_or_else(|e| { + tracing::debug!("reading MPRIS metadata of {}: {e}", p.bus_name); + PcMedia { playback: p.playback, app: app_name(&p.bus_name), ..PcMedia::default() } + }), + None => PcMedia::default(), + }; + if last.as_ref() != Some(&media) { + tracing::debug!(?media, "media state"); + report(media.clone()); + *last = Some(media); + } +} + +/// Fallback display name from the bus name: `org.mpris.MediaPlayer2.spotify` → "spotify", +/// without the `.instance123` suffix browsers add. +fn app_name(bus_name: &str) -> String { + let rest = bus_name.strip_prefix(MPRIS_PREFIX).unwrap_or(bus_name); + rest.split('.').next().unwrap_or(rest).to_owned() +} + +async fn read_media(conn: &Connection, player: &Player) -> zbus::Result { + let root = proxy(conn, &player.bus_name, ROOT_IFACE).await?; + let app = root + .get_property::("Identity") + .await + .ok() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| app_name(&player.bus_name)); + let p = proxy(conn, &player.bus_name, PLAYER_IFACE).await?; + let metadata: HashMap = p.get_property("Metadata").await.unwrap_or_default(); + let title = metadata.get("xesam:title").map(|v| strings(v).join(" ")).unwrap_or_default(); + let artist = metadata.get("xesam:artist").map(|v| strings(v).join(", ")).unwrap_or_default(); + Ok(PcMedia { playback: player.playback, app, title, artist }) +} + +/// A string or an array of strings (`xesam:artist` is `as`). +fn strings(value: &Value<'_>) -> Vec { + match value { + Value::Str(s) => vec![s.to_string()], + Value::Array(a) => a.iter().flat_map(strings).collect(), + Value::Value(v) => strings(v), + _ => Vec::new(), + } +} + +fn resolve_command(command: MediaCommand, status: Playback) -> MediaCommand { + match command { + MediaCommand::PlayPause if status == Playback::Playing => MediaCommand::Pause, + MediaCommand::PlayPause => MediaCommand::Play, + command => command, + } +} + +/// Play while playing / pause while not playing (after [`resolve_command`]): earbuds send +/// "play" when they connect, and acting on it would toggle the music. +fn is_redundant(command: MediaCommand, status: Playback) -> bool { + match command { + MediaCommand::Play => status == Playback::Playing, + MediaCommand::Pause => status != Playback::Playing, + _ => false, + } +} + +#[derive(Debug, PartialEq, Eq)] +enum Applied { + Accepted, + /// The player does not allow it (e.g. no next track). + Declined, + Redundant, +} + +async fn apply(conn: &Connection, players: &mut Players, command: MediaCommand) { + let Some(player) = players.selected().cloned() else { + tracing::info!(?command, "media command not applied: no MPRIS player"); + return; + }; + // Re-read: the cached list may be a signal behind. + let status = playback(conn, &player.bus_name).await.unwrap_or(player.playback); + let result = send(conn, &player.bus_name, resolve_command(command, status), status).await; + match result { + Ok(applied) => { + if applied == Applied::Accepted { + players.last_active = Some(player.bus_name.clone()); + } + tracing::info!(?command, player = %player.bus_name, ?applied, "media command"); + } + Err(e) => tracing::info!(?command, player = %player.bus_name, "media command failed: {e}"), + } +} + +async fn send(conn: &Connection, bus_name: &str, command: MediaCommand, status: Playback) -> zbus::Result { + if is_redundant(command, status) { + return Ok(Applied::Redundant); + } + let (can, method) = match command { + MediaCommand::Play | MediaCommand::PlayPause => ("CanPlay", "Play"), + MediaCommand::Pause => ("CanPause", "Pause"), + MediaCommand::Next => ("CanGoNext", "Next"), + MediaCommand::Previous => ("CanGoPrevious", "Previous"), + }; + let p = proxy(conn, bus_name, PLAYER_IFACE).await?; + let allowed = p.get_property::("CanControl").await.unwrap_or(true) && p.get_property::(can).await.unwrap_or(true); + if !allowed { + return Ok(Applied::Declined); + } + p.call_method(method, &()).await?; + Ok(Applied::Accepted) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn player(name: &str, playback: Playback) -> Player { + Player { bus_name: name.into(), playback } + } + + #[test] + fn exactly_one_target_prefers_playing_then_recent() { + let list = [player("a", Playback::Paused), player("b", Playback::Playing), player("c", Playback::Playing)]; + assert_eq!(select(&list, None), Some(1)); + // the most recently active player wins among playing ones + assert_eq!(select(&list, Some("c")), Some(2)); + let paused = [player("a", Playback::Paused), player("b", Playback::Stopped)]; + assert_eq!(select(&paused, Some("b")), Some(1)); + assert_eq!(select(&paused, Some("gone")), Some(0)); + assert_eq!(select(&[], Some("a")), None); + } + + #[test] + fn toggle_resolves_from_real_state_and_skips_redundant() { + assert_eq!(resolve_command(MediaCommand::PlayPause, Playback::Playing), MediaCommand::Pause); + assert_eq!(resolve_command(MediaCommand::PlayPause, Playback::Paused), MediaCommand::Play); + assert!(is_redundant(MediaCommand::Play, Playback::Playing)); + assert!(is_redundant(MediaCommand::Pause, Playback::Stopped)); + assert!(!is_redundant(MediaCommand::Next, Playback::Playing)); + } + + #[test] + fn browser_instances_show_a_readable_name() { + assert_eq!(app_name("org.mpris.MediaPlayer2.chromium.instance1234"), "chromium"); + assert_eq!(app_name("org.mpris.MediaPlayer2.spotify"), "spotify"); + } +} diff --git a/crates/desktop/src/linux/paths.rs b/crates/desktop/src/linux/paths.rs new file mode 100644 index 0000000..ee8231f --- /dev/null +++ b/crates/desktop/src/linux/paths.rs @@ -0,0 +1,101 @@ +//! Well-known locations (XDG base directories) and machine identity. + +use std::path::PathBuf; + +const APP_DIR: &str = "audiobridge"; + +fn home() -> PathBuf { + std::env::var_os("HOME").filter(|v| !v.is_empty()).map_or_else(|| PathBuf::from("."), PathBuf::from) +} + +/// `$XDG_*_HOME` if set to an absolute path (the spec says to ignore relative ones), else `~/`. +fn xdg_dir(var: &str, fallback: &str) -> PathBuf { + std::env::var_os(var) + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .unwrap_or_else(|| home().join(fallback)) +} + +/// `~/.config/audiobridge`: keys, pairing secret, `settings.json`, `pairing.txt`. +pub fn data_dir() -> PathBuf { + #[cfg(debug_assertions)] + if let Some(dir) = dev_dir() { + return dir; + } + xdg_dir("XDG_CONFIG_HOME", ".config").join(APP_DIR) +} + +/// `~/.local/state/audiobridge/logs`. +pub fn log_dir() -> PathBuf { + #[cfg(debug_assertions)] + if let Some(dir) = dev_dir() { + return dir.join("logs"); + } + xdg_dir("XDG_STATE_HOME", ".local/state").join(APP_DIR).join("logs") +} + +/// `~/.config/autostart`. +pub fn autostart_dir() -> PathBuf { + xdg_dir("XDG_CONFIG_HOME", ".config").join("autostart") +} + +/// The single-instance socket: `$XDG_RUNTIME_DIR/audiobridge.sock` (per user and session). +pub fn instance_socket() -> PathBuf { + #[cfg(debug_assertions)] + if let Some(dir) = dev_dir() { + return dir.join("instance.sock"); + } + let runtime = std::env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + // without a runtime dir (no login session) the data dir is still per user + .unwrap_or_else(data_dir); + runtime.join("audiobridge.sock") +} + +/// Isolated debug identity and logs; release builds never inspect this override. +#[cfg(debug_assertions)] +pub fn dev_dir() -> Option { + std::env::var_os("AUDIOBRIDGE_DEV_DIR").filter(|v| !v.is_empty()).map(PathBuf::from) +} + +/// The computer's name as the user set it: `hostnamectl --pretty` (e.g. "Ноутбук"), else the +/// host name. +pub fn computer_name() -> String { + let pretty = std::fs::read_to_string("/etc/machine-info").ok().and_then(|s| pretty_hostname(&s)); + pretty + .or_else(|| { + std::fs::read_to_string("/proc/sys/kernel/hostname") + .ok() + .map(|s| s.trim().to_owned()) + .filter(|s| !s.is_empty()) + }) + .unwrap_or_else(|| "PC".to_owned()) +} + +/// `PRETTY_HOSTNAME=` from `/etc/machine-info` (an env-style file; values may be quoted). +fn pretty_hostname(machine_info: &str) -> Option { + machine_info.lines().find_map(|line| { + let value = line.trim().strip_prefix("PRETTY_HOSTNAME=")?.trim(); + let value = value + .strip_prefix('"') + .and_then(|v| v.strip_suffix('"')) + .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\''))) + .unwrap_or(value); + let value = value.replace("\\\"", "\"").replace("\\\\", "\\"); + (!value.trim().is_empty()).then_some(value) + }) +} + +#[cfg(test)] +mod tests { + use super::pretty_hostname; + + #[test] + fn pretty_hostname_is_unquoted() { + assert_eq!(pretty_hostname("PRETTY_HOSTNAME=\"Ноутбук\"\n").as_deref(), Some("Ноутбук")); + assert_eq!(pretty_hostname("CHASSIS=laptop\nPRETTY_HOSTNAME=Work PC\n").as_deref(), Some("Work PC")); + assert_eq!(pretty_hostname("PRETTY_HOSTNAME=\"\"\n"), None); + assert_eq!(pretty_hostname("CHASSIS=desktop\n"), None); + } +} diff --git a/crates/desktop/src/linux/tray.rs b/crates/desktop/src/linux/tray.rs new file mode 100644 index 0000000..be781df --- /dev/null +++ b/crates/desktop/src/linux/tray.rs @@ -0,0 +1,172 @@ +//! Tray icon (StatusNotifierItem over D-Bus: Waybar, Quickshell, KDE, GNOME with AppIndicator). +//! The SNI service runs on its own threads; `refresh` only enqueues, so it is safe from any +//! thread, including tokio tasks. Without a session bus the app runs without a tray: launching +//! AudioBridge again shows the window. + +use std::sync::mpsc::{self, Sender}; +use std::sync::Mutex; +use std::thread::JoinHandle; + +use anyhow::{Context, Result}; +use ksni::blocking::TrayMethods; +use ksni::menu::{CheckmarkItem, StandardItem}; +use ksni::{Icon, MenuItem, ToolTip}; + +use crate::icon; +use crate::shared::shared; + +enum Cmd { + Refresh, + Quit, +} + +static TX: Mutex>> = Mutex::new(None); + +pub struct Tray { + join: JoinHandle<()>, +} + +impl Tray { + /// Removes the icon and ends the tray thread. + pub fn quit(self) { + send(Cmd::Quit); + let _ = self.join.join(); + } +} + +fn send(cmd: Cmd) { + if let Some(tx) = TX.lock().unwrap_or_else(|e| e.into_inner()).as_ref() { + let _ = tx.send(cmd); + } +} + +/// Re-reads state, tooltip, icon and menu from `shared()`. +pub fn refresh() { + send(Cmd::Refresh); +} + +pub fn spawn() -> Result { + let (tx, rx) = mpsc::channel(); + *TX.lock().unwrap_or_else(|e| e.into_inner()) = Some(tx); + let join = std::thread::Builder::new() + .name("tray".into()) + .spawn(move || { + // Waits for the panel's StatusNotifierWatcher if it starts after us (autostart). + let handle = match AppTray.assume_sni_available(true).spawn() { + Ok(h) => Some(h), + Err(e) => { + tracing::warn!("tray icon unavailable: {e}"); + None + } + }; + while let Ok(cmd) = rx.recv() { + let Some(handle) = &handle else { + if matches!(cmd, Cmd::Quit) { + break; + } + continue; + }; + match cmd { + Cmd::Refresh => { + // coalesce a burst of refreshes into one D-Bus update + if rx.try_iter().any(|c| matches!(c, Cmd::Quit)) { + handle.shutdown().wait(); + break; + } + handle.update(|_| ()); + } + Cmd::Quit => { + handle.shutdown().wait(); + break; + } + } + } + }) + .context("tray thread")?; + Ok(Tray { join }) +} + +struct AppTray; + +fn pixmap(size: u32, connected: bool, disabled: bool) -> Icon { + let rgba = icon::render(size, connected); + let mut data = Vec::with_capacity(rgba.len()); + for px in rgba.as_chunks::<4>().0 { + let [r, g, b, a] = *px; + if disabled { + let grey = ((u16::from(r) + u16::from(g) + u16::from(b)) / 3) as u8; + data.extend_from_slice(&[a, grey, grey, grey]); + } else { + data.extend_from_slice(&[a, r, g, b]); + } + } + Icon { width: size as i32, height: size as i32, data } +} + +impl ksni::Tray for AppTray { + fn id(&self) -> String { + "audiobridge".into() + } + + fn title(&self) -> String { + "AudioBridge".into() + } + + fn icon_pixmap(&self) -> Vec { + let (connected, _) = shared().tray_state(); + let disabled = !shared().settings().service_enabled; + [22, 32, 48, 64].into_iter().map(|s| pixmap(s, connected, disabled)).collect() + } + + fn tool_tip(&self) -> ToolTip { + let (_, tip) = shared().tray_state(); + ToolTip { title: tip, ..Default::default() } + } + + fn activate(&mut self, _x: i32, _y: i32) { + shared().show_window(); + } + + fn menu(&self) -> Vec> { + let s = shared().settings(); + let enabled_label = if s.service_enabled { "Выключить AudioBridge" } else { "Включить AudioBridge" }; + vec![ + StandardItem { + label: "Открыть".into(), + activate: Box::new(|_: &mut Self| shared().show_window()), + ..Default::default() + } + .into(), + StandardItem { + label: enabled_label.into(), + activate: Box::new(move |_: &mut Self| shared().set_enabled(!s.service_enabled)), + ..Default::default() + } + .into(), + MenuItem::Separator, + CheckmarkItem { + label: "Звук компьютера".into(), + enabled: s.service_enabled, + checked: s.pc_audio_enabled, + activate: Box::new(move |_: &mut Self| shared().set_pc_audio(!s.pc_audio_enabled)), + ..Default::default() + } + .into(), + CheckmarkItem { + label: "Микрофон".into(), + enabled: s.service_enabled, + checked: s.mic_enabled, + activate: Box::new(move |_: &mut Self| shared().set_mic(!s.mic_enabled)), + ..Default::default() + } + .into(), + MenuItem::Separator, + StandardItem { + label: "Выход".into(), + activate: Box::new(|_: &mut Self| shared().request_exit()), + ..Default::default() + } + .into(), + ] + } +} diff --git a/crates/desktop/src/main.rs b/crates/desktop/src/main.rs index 72e9601..d785f76 100644 --- a/crates/desktop/src/main.rs +++ b/crates/desktop/src/main.rs @@ -1,27 +1,41 @@ -//! AudioBridge for Windows: streams PC audio to the paired phone and exposes the phone -//! microphone through VB-CABLE. Lives in the notification area. +//! AudioBridge for Windows and Linux: streams PC audio to the paired phone and exposes the phone +//! microphone as a PC microphone (VB-CABLE on Windows, a PipeWire virtual source on Linux). +//! Lives in the notification area / tray. //! //! Command line: //! * `--background` — start hidden in the tray (used by the autostart entry). //! * `--print-pairing` — print the pairing URI (the QR content) to stdout and exit. If AudioBridge -//! is already running, prints the URI the running instance published to -//! `%APPDATA%\AudioBridge\pairing.txt`; otherwise starts the server just long enough to obtain it. -//! * `--no-install` — run from the current location instead of self-installing (release builds -//! started from anywhere else copy themselves to `%LOCALAPPDATA%\Programs\AudioBridge`, -//! register autostart there, launch that copy and exit). +//! is already running, prints the URI the running instance published to `pairing.txt` in the +//! data directory; otherwise starts the server just long enough to obtain it. +//! * `--no-install` (Windows) — run from the current location instead of self-installing +//! (release builds started from anywhere else copy themselves to +//! `%LOCALAPPDATA%\Programs\AudioBridge`, register autostart there, launch that copy and exit). +//! * `--quit` (Linux) — ask the running instance to exit (used by the package scripts). #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +#[cfg(not(any(windows, target_os = "linux")))] +compile_error!("AudioBridge desktop supports Windows and Linux"); + +#[cfg_attr(target_os = "linux", path = "linux/audio/mod.rs")] mod audio; -mod cable_install; +#[cfg_attr(target_os = "linux", path = "linux/autostart.rs")] mod autostart; +#[cfg(windows)] +mod cable_install; +mod endpoints; mod icon; +#[cfg_attr(target_os = "linux", path = "linux/instance.rs")] mod instance; +#[cfg_attr(target_os = "linux", path = "linux/media.rs")] mod media; +#[cfg_attr(target_os = "linux", path = "linux/paths.rs")] mod paths; +#[cfg(windows)] +mod selfinstall; mod service; mod settings; mod shared; -mod selfinstall; +#[cfg_attr(target_os = "linux", path = "linux/tray.rs")] mod tray; mod ui; @@ -33,11 +47,17 @@ use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use audiobridge_core::pairing::PairingInfo; use audiobridge_core::session::{Server, ServerConfig}; +#[cfg(windows)] use windows::core::{w, HSTRING}; +#[cfg(windows)] use windows::Win32::System::Console::{AttachConsole, GetStdHandle, ATTACH_PARENT_PROCESS, STD_OUTPUT_HANDLE}; +#[cfg(windows)] use windows::Win32::System::ProcessStatus::K32EmptyWorkingSet; +#[cfg(windows)] use windows::Win32::System::Threading::GetCurrentProcess; +#[cfg(windows)] use windows::Win32::UI::HiDpi::{SetProcessDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2}; +#[cfg(windows)] use windows::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_ICONERROR, MB_OK}; use crate::settings::Settings; @@ -47,9 +67,14 @@ fn main() -> ExitCode { let args: Vec = std::env::args().skip(1).collect(); let has = |flag: &str| args.iter().any(|a| a == flag); // SAFETY: process-wide setting before any window exists; failure (already set) is harmless. + #[cfg(windows)] unsafe { let _ = SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); } + #[cfg(target_os = "linux")] + if has("--quit") { + return if instance::signal_quit() { ExitCode::SUCCESS } else { ExitCode::FAILURE }; + } let data_dir = paths::data_dir(); if let Err(e) = std::fs::create_dir_all(&data_dir) { fatal(&format!("Не удалось создать папку {}: {e}", data_dir.display())); @@ -66,6 +91,7 @@ fn main() -> ExitCode { } let _log_guard = init_logging(); install_panic_hook(); + #[cfg(windows)] if selfinstall::needed(&args) { match selfinstall::install_and_launch(&args, &data_dir) { Ok(()) => return ExitCode::SUCCESS, @@ -125,6 +151,8 @@ fn run(primary: instance::Primary, background: bool, data_dir: &Path) -> Result< let tray = tray::spawn().context("tray icon")?; primary.listen_for_show(|| shared().show_window()); primary.listen_for_quit(|| shared().request_exit()); + #[cfg(target_os = "linux")] + exit_on_signals(); if !background { ui::run_window(); @@ -147,6 +175,59 @@ fn run(primary: instance::Primary, background: bool, data_dir: &Path) -> Result< Ok(()) } +/// SIGTERM/SIGINT/SIGHUP (logout, `kill`, Ctrl+C) run the normal shutdown, so the PipeWire +/// nodes and the default-device changes are cleaned up like on "Выход". +#[cfg(target_os = "linux")] +fn exit_on_signals() { + use std::os::unix::net::UnixDatagram; + + static WAKE: std::sync::OnceLock = std::sync::OnceLock::new(); + extern "C" fn on_signal(_: libc_shim::c_int) { + // Only async-signal-safe work here: a non-blocking send wakes the watcher thread. + if let Some(w) = WAKE.get() { + let _ = w.send(&[1]); + } + } + let Ok((tx, rx)) = UnixDatagram::pair() else { + tracing::warn!("signal handling unavailable"); + return; + }; + let _ = tx.set_nonblocking(true); + if WAKE.set(tx).is_err() { + return; + } + for sig in [libc_shim::SIGTERM, libc_shim::SIGINT, libc_shim::SIGHUP] { + // SAFETY: installs a handler that only performs an async-signal-safe send(2). + unsafe { + libc_shim::signal(sig, on_signal as *const () as libc_shim::sighandler_t); + } + } + let spawned = std::thread::Builder::new().name("signals".into()).spawn(move || { + let mut buf = [0u8; 1]; + if rx.recv(&mut buf).is_ok() { + tracing::info!("termination signal received"); + shared().request_exit(); + } + }); + if let Err(e) = spawned { + tracing::warn!("signal thread: {e}"); + } +} + +/// The two libc entry points the signal handling needs (std links libc already). +#[cfg(target_os = "linux")] +mod libc_shim { + #![allow(non_camel_case_types)] + pub type c_int = i32; + pub type sighandler_t = usize; + pub const SIGHUP: c_int = 1; + pub const SIGINT: c_int = 2; + pub const SIGTERM: c_int = 15; + unsafe extern "C" { + pub fn signal(signum: c_int, handler: sighandler_t) -> sighandler_t; + } +} + /// `--print-pairing`: see the crate docs. fn print_pairing(data_dir: &Path) -> Result<()> { attach_console(); @@ -180,6 +261,7 @@ fn print_pairing(data_dir: &Path) -> Result<()> { } /// Release builds are GUI-subsystem: attach to the parent console only if stdout isn't redirected. +#[cfg(windows)] fn attach_console() { // SAFETY: plain console API calls. unsafe { @@ -190,6 +272,10 @@ fn attach_console() { } } +/// Linux processes always have their parent's stdout. +#[cfg(target_os = "linux")] +fn attach_console() {} + fn init_logging() -> Option { use tracing_subscriber::filter::Targets; use tracing_subscriber::layer::SubscriberExt; @@ -231,6 +317,7 @@ fn install_panic_hook() { } /// Return memory touched by the (now destroyed) window to the OS. +#[cfg(windows)] fn trim_working_set() { // SAFETY: pseudo-handle of the current process. unsafe { @@ -238,9 +325,25 @@ fn trim_working_set() { } } +/// glibc returns freed pages on its own; nothing to force. +#[cfg(target_os = "linux")] +fn trim_working_set() {} + +#[cfg(windows)] fn fatal(text: &str) { // SAFETY: modal message box with valid strings. unsafe { MessageBoxW(None, &HSTRING::from(text), w!("AudioBridge"), MB_OK | MB_ICONERROR); } } + +/// stderr plus a desktop notification (started from the launcher there is no terminal). +#[cfg(target_os = "linux")] +fn fatal(text: &str) { + eprintln!("AudioBridge: {text}"); + let _ = std::process::Command::new("notify-send") + .args(["--app-name=AudioBridge", "--urgency=critical", "AudioBridge", text]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn(); +} diff --git a/crates/desktop/src/service.rs b/crates/desktop/src/service.rs index ea54730..2c8660b 100644 --- a/crates/desktop/src/service.rs +++ b/crates/desktop/src/service.rs @@ -92,9 +92,7 @@ fn set_default(id: &str, what: &str) { } fn restore_devices(enabled: bool) { - let _com = audio::com::Com::init(); - let Ok(enumerator) = audio::com::enumerator() else { return }; - let devices = audio::devices::summarize(&enumerator); + let Some(devices) = audio::current_devices() else { return }; let settings = shared().settings(); // Inspect current devices, not a stale asynchronous notification snapshot. shared().set_devices(&devices); diff --git a/crates/desktop/src/shared.rs b/crates/desktop/src/shared.rs index 6072912..6fee6fd 100644 --- a/crates/desktop/src/shared.rs +++ b/crates/desktop/src/shared.rs @@ -9,12 +9,15 @@ use audiobridge_core::pairing::PairingInfo; use audiobridge_core::proto::MAX_LEVEL; use audiobridge_core::session::{ConnState, PcMedia, PcRequest, PhoneRequest, Server, Status, Volume}; use tokio::task::JoinHandle; -use windows::Win32::Foundation::{HWND, LPARAM, WPARAM}; +#[cfg(windows)] +use windows::Win32::Foundation::HWND; +#[cfg(windows)] use windows::Win32::UI::WindowsAndMessaging::{ - IsIconic, PostMessageW, SetForegroundWindow, ShowWindow, SW_RESTORE, SW_SHOW, + IsIconic, SetForegroundWindow, ShowWindow, SW_RESTORE, SW_SHOW, }; -use crate::audio::{AudioMsg, Desired, DeviceSummary}; +use crate::audio::{AudioMsg, AudioSender, Desired, DeviceSummary}; +#[cfg(windows)] use crate::cable_install::InstallState; use crate::settings::Settings; use crate::{autostart, tray}; @@ -36,16 +39,17 @@ pub struct Shared { settings: Mutex, devices: Mutex, ui_ctx: Mutex>, + /// Native window handle (HWND) on Windows; 0 elsewhere. window_hwnd: AtomicIsize, window_state: Mutex, - tray_hwnd: AtomicIsize, tray_connected: AtomicBool, tray_tip: Mutex, main_tx: Sender, exiting: AtomicBool, service_generation: AtomicU64, - audio_tx: Mutex>>, + audio_tx: Mutex>, media_tx: Mutex>, + #[cfg(windows)] cable_install: Mutex, } @@ -79,7 +83,6 @@ pub fn init( ui_ctx: Mutex::new(None), window_hwnd: AtomicIsize::new(0), window_state: Mutex::new(if background { WindowState::Closed } else { WindowState::Opening }), - tray_hwnd: AtomicIsize::new(0), tray_connected: AtomicBool::new(false), tray_tip: Mutex::new(tip.to_owned()), main_tx, @@ -87,6 +90,7 @@ pub fn init( service_generation: AtomicU64::new(0), audio_tx: Mutex::new(None), media_tx: Mutex::new(None), + #[cfg(windows)] cable_install: Mutex::new(InstallState::Idle), }; if SHARED.set(state).is_err() { @@ -187,13 +191,7 @@ impl Shared { } fn refresh_tray(&self) { - let hwnd = self.tray_hwnd.load(Ordering::SeqCst); - if hwnd != 0 { - // SAFETY: posting a refresh to our own tray window. - unsafe { - let _ = PostMessageW(Some(HWND(hwnd as *mut _)), tray::WM_TRAY_REFRESH, WPARAM(0), LPARAM(0)); - } - } + tray::refresh(); } pub fn settings(&self) -> Settings { @@ -369,7 +367,7 @@ impl Shared { self.repaint(); } - pub fn set_audio_sender(&self, tx: Sender) { + pub fn set_audio_sender(&self, tx: AudioSender) { *lock(&self.audio_tx) = Some(tx); } @@ -395,10 +393,12 @@ impl Shared { let _ = self.main_tx.send(MainCmd::DefaultDevice { generation, id, what }); } + #[cfg(windows)] pub fn cable_install_state(&self) -> InstallState { lock(&self.cable_install).clone() } + #[cfg(windows)] pub fn set_cable_install_state(&self, s: InstallState) { *lock(&self.cable_install) = s; self.repaint(); @@ -427,14 +427,26 @@ impl Shared { /// Brings the window to front, creating it if it is closed. pub fn show_window(&self) { - let hwnd = self.window_hwnd.load(Ordering::SeqCst); - if hwnd != 0 { - let hwnd = HWND(hwnd as *mut _); - // SAFETY: plain user32 calls on a window handle we created (stale handles fail harmlessly). - unsafe { - let _ = ShowWindow(hwnd, if IsIconic(hwnd).as_bool() { SW_RESTORE } else { SW_SHOW }); - let _ = SetForegroundWindow(hwnd); + #[cfg(windows)] + let open = { + let hwnd = self.window_hwnd.load(Ordering::SeqCst); + if hwnd != 0 { + let hwnd = HWND(hwnd as *mut _); + // SAFETY: plain user32 calls on a window handle we created (stale handles fail harmlessly). + unsafe { + let _ = ShowWindow(hwnd, if IsIconic(hwnd).as_bool() { SW_RESTORE } else { SW_SHOW }); + let _ = SetForegroundWindow(hwnd); + } } + hwnd != 0 + }; + // Wayland/X11: un-minimize and ask the compositor for focus (it may only mark the window urgent). + #[cfg(target_os = "linux")] + let open = lock(&self.ui_ctx).as_ref().map(|ctx| { + ctx.send_viewport_cmd(eframe::egui::ViewportCommand::Minimized(false)); + ctx.send_viewport_cmd(eframe::egui::ViewportCommand::Focus); + }).is_some(); + if open { self.repaint(); } else if !self.is_exiting() && lock(&self.window_state).request_open() { let _ = self.main_tx.send(MainCmd::Show); @@ -456,10 +468,6 @@ impl Shared { // ---- tray ------------------------------------------------------------------------------- - pub fn set_tray_hwnd(&self, hwnd: isize) { - self.tray_hwnd.store(hwnd, Ordering::SeqCst); - } - pub fn tray_state(&self) -> (bool, String) { (self.tray_connected.load(Ordering::SeqCst), lock(&self.tray_tip).clone()) } @@ -473,13 +481,7 @@ impl Shared { }; self.tray_connected.store(connected, Ordering::SeqCst); if changed { - let hwnd = self.tray_hwnd.load(Ordering::SeqCst); - if hwnd != 0 { - // SAFETY: posting to our own tray window. - unsafe { - let _ = PostMessageW(Some(HWND(hwnd as *mut _)), tray::WM_TRAY_REFRESH, WPARAM(0), LPARAM(0)); - } - } + tray::refresh(); } } @@ -489,7 +491,7 @@ impl Shared { pub fn spawn_watchers( &'static self, rt: &tokio::runtime::Runtime, - audio: Sender, + audio: AudioSender, generation: u64, ) -> Vec> { let server = lock(&self.server); diff --git a/crates/desktop/src/tray.rs b/crates/desktop/src/tray.rs index 514fa81..69fadff 100644 --- a/crates/desktop/src/tray.rs +++ b/crates/desktop/src/tray.rs @@ -27,7 +27,7 @@ use crate::icon; use crate::shared::shared; const WM_TRAY_CALLBACK: u32 = WM_APP + 1; -pub const WM_TRAY_REFRESH: u32 = WM_APP + 2; +const WM_TRAY_REFRESH: u32 = WM_APP + 2; const WM_TRAY_QUIT: u32 = WM_APP + 3; const TRAY_ID: u32 = 1; @@ -38,6 +38,19 @@ const ID_EXIT: usize = 4; const ID_ENABLED: usize = 5; static TASKBAR_CREATED: AtomicU32 = AtomicU32::new(0); +/// The hidden tray window, for refreshes posted from other threads (0 until created). +static TRAY_HWND: AtomicIsize = AtomicIsize::new(0); + +/// Re-reads state and tooltip from `shared()`; safe from any thread. +pub fn refresh() { + let hwnd = TRAY_HWND.load(Ordering::SeqCst); + if hwnd != 0 { + // SAFETY: posting a refresh to our own tray window. + unsafe { + let _ = PostMessageW(Some(HWND(hwnd as *mut _)), WM_TRAY_REFRESH, WPARAM(0), LPARAM(0)); + } + } +} static ICON_ON: AtomicIsize = AtomicIsize::new(0); static ICON_OFF: AtomicIsize = AtomicIsize::new(0); static ICON_DISABLED: AtomicIsize = AtomicIsize::new(0); @@ -70,7 +83,7 @@ pub fn spawn() -> Result { return; } }; - shared().set_tray_hwnd(hwnd.0 as isize); + TRAY_HWND.store(hwnd.0 as isize, Ordering::SeqCst); // SAFETY: creating icons and adding the notification icon for our own window. unsafe { let size = GetSystemMetrics(SM_CXSMICON).max(16) as u32; diff --git a/crates/desktop/src/ui.rs b/crates/desktop/src/ui.rs index 63eb775..acef2ae 100644 --- a/crates/desktop/src/ui.rs +++ b/crates/desktop/src/ui.rs @@ -2,7 +2,6 @@ //! window exists while AudioBridge sits in the tray. Repaints on status changes; the only //! animation (the level bars) runs while PC audio is streaming and the window is open. -use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -12,14 +11,21 @@ use eframe::egui::{ Margin, Mesh, Painter, Pos2, Rect, RichText, Sense, Shape, Stroke, StrokeKind, TextureHandle, TextureOptions, Ui, UiBuilder, }; +#[cfg(windows)] use raw_window_handle::{HasWindowHandle, RawWindowHandle}; +#[cfg(windows)] use windows::core::{w, BOOL, HSTRING}; +#[cfg(windows)] use windows::Win32::Foundation::HWND; +#[cfg(windows)] use windows::Win32::Graphics::Dwm::{DwmSetWindowAttribute, DWMWA_CAPTION_COLOR, DWMWA_USE_IMMERSIVE_DARK_MODE}; +#[cfg(windows)] use windows::Win32::UI::Shell::ShellExecuteW; +#[cfg(windows)] use windows::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL; use crate::audio::DeviceSummary; +#[cfg(windows)] use crate::cable_install::{self, InstallState}; use crate::icon; use crate::settings::Settings; @@ -42,22 +48,38 @@ const TOGGLE_OFF: Color32 = Color32::from_rgb(0x2E, 0x33, 0x45); const WARN_BG: Color32 = Color32::from_rgb(0x24, 0x1D, 0x10); const WARN_STROKE: Color32 = Color32::from_rgb(0x5A, 0x45, 0x15); +#[cfg(windows)] const VB_CABLE_URL: &str = "https://vb-audio.com/Cable/"; const REPO_URL: &str = "https://github.com/Lyten02/AudioBridge"; +#[cfg(windows)] +const AUTOSTART_TITLE: &str = "Запускать вместе с Windows"; +#[cfg(target_os = "linux")] +const AUTOSTART_TITLE: &str = "Запускать при входе в систему"; +#[cfg(windows)] +const MIC_DEFAULT_HINT: &str = "CABLE Output — основной микрофон Windows"; +#[cfg(target_os = "linux")] +const MIC_DEFAULT_HINT: &str = "«AudioBridge: микрофон телефона» — микрофон по умолчанию"; +/// Wayland app id / X11 class: lets compositor rules (e.g. Hyprland `float`) match the window. +#[cfg(target_os = "linux")] +const APP_ID: &str = "audiobridge"; + const PAD: f32 = 16.0; const GAP: f32 = 12.0; const TILE_H: f32 = 148.0; /// Opens the window and blocks until it is closed (the app keeps running in the tray). pub fn run_window() { + let viewport = egui::ViewportBuilder::default() + .with_title("AudioBridge") + .with_inner_size([400.0, 680.0]) + .with_resizable(false) + .with_maximize_button(false) + .with_icon(Arc::new(egui::IconData { rgba: icon::render(64, false), width: 64, height: 64 })); + #[cfg(target_os = "linux")] + let viewport = viewport.with_app_id(APP_ID); let options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_title("AudioBridge") - .with_inner_size([400.0, 680.0]) - .with_resizable(false) - .with_maximize_button(false) - .with_icon(Arc::new(egui::IconData { rgba: icon::render(64, false), width: 64, height: 64 })), + viewport, renderer: eframe::Renderer::Glow, centered: true, run_and_return: true, @@ -68,11 +90,16 @@ pub fn run_window() { options, Box::new(|cc| { setup_style(&cc.egui_ctx); + #[cfg(windows)] let hwnd = match cc.window_handle().map(|h| h.as_raw()) { Ok(RawWindowHandle::Win32(h)) => h.hwnd.get(), _ => 0, }; + #[cfg(windows)] dark_title_bar(hwnd); + // the compositor owns Wayland/X11 windows; raising goes through the egui context + #[cfg(target_os = "linux")] + let hwnd = 0; shared().register_window(hwnd, cc.egui_ctx.clone()); Ok(Box::new(UiApp::default())) }), @@ -85,16 +112,21 @@ pub fn run_window() { fn setup_style(ctx: &egui::Context) { let mut fonts = FontDefinitions::default(); - let dir = std::env::var_os("WINDIR").map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from).join("Fonts"); + let system = system_fonts(); let mut proportional = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default(); - if let Ok(bytes) = std::fs::read(dir.join("segoeui.ttf")) { - fonts.font_data.insert("segoe".into(), Arc::new(FontData::from_owned(bytes))); - proportional.insert(0, "segoe".into()); + if let Some(bytes) = system.symbols { + // second in line: glyphs the UI font lacks (e.g. "→" in Noto Sans) + fonts.font_data.insert("ui-symbols".into(), Arc::new(FontData::from_owned(bytes))); + proportional.insert(0, "ui-symbols".into()); + } + if let Some(bytes) = system.regular { + fonts.font_data.insert("ui".into(), Arc::new(FontData::from_owned(bytes))); + proportional.insert(0, "ui".into()); } let mut semibold = proportional.clone(); - if let Ok(bytes) = std::fs::read(dir.join("seguisb.ttf")) { - fonts.font_data.insert("segoe-sb".into(), Arc::new(FontData::from_owned(bytes))); - semibold.insert(0, "segoe-sb".into()); + if let Some(bytes) = system.semibold { + fonts.font_data.insert("ui-semibold".into(), Arc::new(FontData::from_owned(bytes))); + semibold.insert(0, "ui-semibold".into()); } fonts.families.insert(FontFamily::Proportional, proportional); fonts.families.insert(FontFamily::Name("semibold".into()), semibold); @@ -118,7 +150,58 @@ fn setup_style(ctx: &egui::Context) { }); } +/// Font files found on the system; `None` falls back to egui's bundled fonts. +struct SystemFonts { + regular: Option>, + semibold: Option>, + symbols: Option>, +} + +/// Segoe UI regular and semibold. +#[cfg(windows)] +fn system_fonts() -> SystemFonts { + let dir = std::env::var_os("WINDIR") + .map_or_else(|| std::path::PathBuf::from(r"C:\Windows"), std::path::PathBuf::from) + .join("Fonts"); + SystemFonts { + regular: std::fs::read(dir.join("segoeui.ttf")).ok(), + semibold: std::fs::read(dir.join("seguisb.ttf")).ok(), + symbols: None, + } +} + +/// A common sans-serif with Cyrillic (Noto on Arch/Omarchy, then DejaVu/Liberation), plus a +/// font with the arrows Noto Sans lacks. +#[cfg(target_os = "linux")] +fn system_fonts() -> SystemFonts { + const REGULAR: &[&str] = &[ + "/usr/share/fonts/noto/NotoSans-Regular.ttf", + "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", + "/usr/share/fonts/TTF/DejaVuSans.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/liberation/LiberationSans-Regular.ttf", + ]; + const SEMIBOLD: &[&str] = &[ + "/usr/share/fonts/noto/NotoSans-SemiBold.ttf", + "/usr/share/fonts/noto/NotoSans-Medium.ttf", + "/usr/share/fonts/truetype/noto/NotoSans-SemiBold.ttf", + "/usr/share/fonts/noto/NotoSans-Bold.ttf", + "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/liberation/LiberationSans-Bold.ttf", + ]; + const SYMBOLS: &[&str] = &[ + "/usr/share/fonts/liberation/LiberationSans-Regular.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + "/usr/share/fonts/TTF/DejaVuSans.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + ]; + let first = |paths: &[&str]| paths.iter().find_map(|p| std::fs::read(p).ok()); + SystemFonts { regular: first(REGULAR), semibold: first(SEMIBOLD), symbols: first(SYMBOLS) } +} + /// Dark caption matching the window background (Windows 11; ignored where unsupported). +#[cfg(windows)] fn dark_title_bar(hwnd: isize) { if hwnd == 0 { return; @@ -169,6 +252,7 @@ fn status_line(s: &Status) -> (String, Color32) { } } +#[cfg(windows)] fn open_url(url: &str) { // SAFETY: ShellExecuteW with valid NUL-terminated strings. unsafe { @@ -176,6 +260,16 @@ fn open_url(url: &str) { } } +#[cfg(target_os = "linux")] +fn open_url(url: &str) { + let spawned = std::process::Command::new("xdg-open").arg(url).spawn(); + match spawned { + // reap it off the UI thread; xdg-open returns once the browser was asked + Ok(mut child) => drop(std::thread::Builder::new().name("xdg-open".into()).spawn(move || child.wait())), + Err(e) => tracing::warn!("xdg-open {url}: {e}"), + } +} + // --------------------------------------------------------------------------------------------- // Drawing primitives @@ -434,7 +528,7 @@ impl eframe::App for UiApp { } } card(ui, CARD, CARD_STROKE, |ui| { - if switch_row(ui, "Запускать вместе с Windows", Some("Работает в фоне из трея"), settings.autostart) { + if switch_row(ui, AUTOSTART_TITLE, Some("Работает в фоне из трея"), settings.autostart) { sh.set_autostart(!settings.autostart); } }); @@ -682,7 +776,7 @@ fn controls_card(ui: &mut Ui, status: &Status, devices: &DeviceSummary, edit: &m let on = devices.default_capture_is_cable; let has_cable = devices.cable_capture_id.is_some(); - let sub = if has_cable { "CABLE Output — основной микрофон Windows" } else { "Нужен VB-CABLE" }; + let sub = if has_cable { MIC_DEFAULT_HINT } else { "Нужен VB-CABLE" }; if switch_row(ui, "Микрофон по умолчанию", Some(sub), on) && has_cable { sh.set_mic_default(!on); } @@ -855,6 +949,7 @@ fn warnings(ui: &mut Ui, devices: &DeviceSummary, settings: &Settings) { } }); } + #[cfg(windows)] if devices.cable_render_id.is_none() { let state = sh.cable_install_state(); card(ui, WARN_BG, WARN_STROKE, |ui| { diff --git a/packaging/linux/audiobridge.desktop b/packaging/linux/audiobridge.desktop new file mode 100644 index 0000000..bb8791e --- /dev/null +++ b/packaging/linux/audiobridge.desktop @@ -0,0 +1,14 @@ +[Desktop Entry] +Type=Application +Name=AudioBridge +GenericName=Phone as headphones and microphone +GenericName[ru]=Телефон как наушники и микрофон +Comment=PC audio to the phone, phone microphone to the PC +Comment[ru]=Звук компьютера на телефон, микрофон телефона на компьютер +Exec=audiobridge +Icon=audiobridge +Terminal=false +Categories=AudioVideo;Audio;Utility; +Keywords=audio;sound;phone;android;headphones;microphone;mic;bluetooth;pipewire;аудиобридж;аудио бридж;звук;телефон;наушники;микрофон; +StartupWMClass=audiobridge +StartupNotify=false diff --git a/packaging/linux/audiobridge.svg b/packaging/linux/audiobridge.svg new file mode 100644 index 0000000..4546d1e --- /dev/null +++ b/packaging/linux/audiobridge.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/packaging/linux/install.sh b/packaging/linux/install.sh new file mode 100755 index 0000000..0e7d1fb --- /dev/null +++ b/packaging/linux/install.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Installs AudioBridge for the current user (no root): ~/.local/bin/audiobridge, the launcher +# entry and the icon. `./install.sh --uninstall` removes them (settings and pairing in +# ~/.config/audiobridge stay). Arch/Omarchy users can install the .pkg.tar.zst with pacman instead. +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +data="${XDG_DATA_HOME:-$HOME/.local/share}" +bin="$HOME/.local/bin" +config="${XDG_CONFIG_HOME:-$HOME/.config}" + +stop_running() { + if [ -x "$bin/audiobridge" ] && "$bin/audiobridge" --quit 2>/dev/null; then + # let it restore the default microphone and remove its PipeWire nodes + for _ in $(seq 50); do "$bin/audiobridge" --quit 2>/dev/null || break; sleep 0.1; done + fi +} + +refresh_caches() { + command -v update-desktop-database >/dev/null && update-desktop-database -q "$data/applications" || true + command -v gtk-update-icon-cache >/dev/null && gtk-update-icon-cache -q -t "$data/icons/hicolor" 2>/dev/null || true +} + +if [ "${1:-}" = "--uninstall" ]; then + stop_running + rm -f "$bin/audiobridge" "$data/applications/audiobridge.desktop" \ + "$data/icons/hicolor/scalable/apps/audiobridge.svg" "$config/autostart/audiobridge.desktop" + refresh_caches + echo "AudioBridge removed (settings kept in $config/audiobridge)." + exit 0 +fi + +missing=() +ldconfig -p 2>/dev/null | grep -q 'libpipewire-0\.3\.so\.0' || missing+=("pipewire (libpipewire-0.3.so.0)") +command -v wpctl >/dev/null || missing+=("wireplumber (wpctl)") +if [ ${#missing[@]} -gt 0 ]; then + echo "Missing runtime dependencies: ${missing[*]}" >&2 + echo "Arch/Omarchy: sudo pacman -S --needed pipewire wireplumber" >&2 + exit 1 +fi + +stop_running +install -Dm755 "$here/audiobridge" "$bin/audiobridge.new" +mv -f "$bin/audiobridge.new" "$bin/audiobridge" +install -Dm644 "$here/audiobridge.svg" "$data/icons/hicolor/scalable/apps/audiobridge.svg" +# absolute Exec: ~/.local/bin is not on PATH for every session manager +mkdir -p "$data/applications" +sed "s|^Exec=audiobridge|Exec=$bin/audiobridge|" "$here/audiobridge.desktop" > "$data/applications/audiobridge.desktop.new" +mv -f "$data/applications/audiobridge.desktop.new" "$data/applications/audiobridge.desktop" +refresh_caches +echo "AudioBridge installed: $bin/audiobridge (launcher: AudioBridge)." +echo "Start it from the launcher or run: $bin/audiobridge" diff --git a/scripts/package-linux.sh b/scripts/package-linux.sh new file mode 100755 index 0000000..36ae6f3 --- /dev/null +++ b/scripts/package-linux.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Packages the Linux desktop app built by `cargo build -p audiobridge-desktop --release`: +# target/dist/AudioBridge--linux-x86_64.tar.gz binary + launcher entry + icon + install.sh (per-user, no root) +# target/dist/audiobridge--1-x86_64.pkg.tar.zst pacman package for Arch/Omarchy (sudo pacman -U …) +# target/dist/SHA256SUMS +# Needs bsdtar (libarchive) and zstd. Usage: scripts/package-linux.sh [path/to/AudioBridge] +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +target="${CARGO_TARGET_DIR:-$root/target}" +binary="${1:-$target/release/AudioBridge}" +version="$(sed -n '/^\[workspace.package\]/,/^\[/s/^version = "\(.*\)"/\1/p' "$root/Cargo.toml")" +[ -n "$version" ] || { echo "cannot read the workspace version" >&2; exit 1; } +[ -x "$binary" ] || { echo "no release binary at $binary" >&2; exit 1; } +for tool in bsdtar zstd; do + command -v "$tool" >/dev/null || { echo "$tool is required" >&2; exit 1; } +done + +dist="$target/dist" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +mkdir -p "$dist" +files="$root/packaging/linux" + +# --- per-user tarball ------------------------------------------------------------------------ +name="AudioBridge-$version-linux-x86_64" +mkdir -p "$work/$name" +install -m755 "$binary" "$work/$name/audiobridge" +install -m755 "$files/install.sh" "$work/$name/install.sh" +install -m644 "$files/audiobridge.desktop" "$files/audiobridge.svg" "$root/LICENSE" "$work/$name/" +tar -C "$work" --owner=0 --group=0 --numeric-owner -czf "$dist/$name.tar.gz" "$name" + +# --- pacman package -------------------------------------------------------------------------- +pkg="$work/pkg" +install -Dm755 "$binary" "$pkg/usr/bin/audiobridge" +install -Dm644 "$files/audiobridge.desktop" "$pkg/usr/share/applications/audiobridge.desktop" +install -Dm644 "$files/audiobridge.svg" "$pkg/usr/share/icons/hicolor/scalable/apps/audiobridge.svg" +install -Dm644 "$root/LICENSE" "$pkg/usr/share/licenses/audiobridge/LICENSE" +size="$(du -sb "$pkg/usr" | cut -f1)" +builddate="${SOURCE_DATE_EPOCH:-$(date +%s)}" +cat > "$pkg/.PKGINFO" < SHA256SUMS) +echo "packaged:" +ls -l "$dist/$name.tar.gz" "$dist/$pkgfile" +cat "$dist/SHA256SUMS" From d9dddb8a37ddaf92a37121539b0d6afe2ac6806a Mon Sep 17 00:00:00 2001 From: Lyten02 <87010935+Lyten02@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:08:34 +0100 Subject: [PATCH 2/6] fix(linux): consume ldconfig output during dependency check --- packaging/linux/install.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packaging/linux/install.sh b/packaging/linux/install.sh index 0e7d1fb..ca7f601 100755 --- a/packaging/linux/install.sh +++ b/packaging/linux/install.sh @@ -31,7 +31,8 @@ if [ "${1:-}" = "--uninstall" ]; then fi missing=() -ldconfig -p 2>/dev/null | grep -q 'libpipewire-0\.3\.so\.0' || missing+=("pipewire (libpipewire-0.3.so.0)") +# Consume the complete cache: grep -q can SIGPIPE ldconfig under pipefail. +ldconfig -p 2>/dev/null | grep 'libpipewire-0\.3\.so\.0' >/dev/null || missing+=("pipewire (libpipewire-0.3.so.0)") command -v wpctl >/dev/null || missing+=("wireplumber (wpctl)") if [ ${#missing[@]} -gt 0 ]; then echo "Missing runtime dependencies: ${missing[*]}" >&2 From 74d1faae5bd6018995657a6e5830a9c2cff6d80e Mon Sep 17 00:00:00 2001 From: Lyten02 <87010935+Lyten02@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:35:56 +0100 Subject: [PATCH 3/6] fix(linux): keep the window responsive on a hidden workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With EGL vsync the swap waits for a frame callback that compositors do not send to a window on a hidden workspace. Any repaint there (device change, streaming, show request) blocked the UI thread in wl_display_dispatch_queue: compositor pings went unanswered, Hyprland offered to kill the app with SIGKILL (no cleanup, phone mic left as the configured default), and SIGTERM or Выход could not finish until the window was shown again. Linux now renders without vsync; the UI repaints only on changes. --- AGENTS.md | 4 +++- crates/desktop/src/ui.rs | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 1b5ba9d..4fc5f31 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -149,6 +149,7 @@ cargo ndk -t arm64-v8a --platform 26 clippy -p audiobridge-android --all-targets - Runtime: libpipewire-0.3 (≥ 0.3.49) and WirePlumber (`wpctl`, the `default` metadata, linking); the GUI loads Wayland/X11, xkbcommon and EGL with `dlopen`. The release binary is built on Debian 13 and links only glibc, libgcc and libpipewire. - Platform crates are target-specific dependencies (`pipewire`, `ksni`, `zbus`; eframe `wayland`+`x11`). Keep Windows-only code behind `#[cfg(windows)]` so `cargo xwin clippy` and Linux clippy both stay clean. - The window has the app id/class `audiobridge` (Hyprland: `windowrule = float, class:^(audiobridge)$`). + - Keep EGL vsync off on Linux (`glow_options.vsync = false` in `ui.rs`). With vsync, a repaint while the window sits on a hidden workspace blocks the UI thread in the EGL swap (`wl_display_dispatch_queue`) until the window is shown again: compositor pings go unanswered (Hyprland's "not responding" dialog kills with SIGKILL, so no cleanup runs) and SIGTERM/"Выход" cannot finish. - Headless smoke without real audio hardware: a private `pipewire` + `wireplumber` with a `support.null-audio-sink` as the default sink; play a tone into it, run `probe --mic-sine 440` against the app, record `pw-record --target audiobridge_mic`. A fake mic made from a null sink must be `Audio/Source/Virtual` (a null sink is an input node; as `Audio/Source` it stalls WirePlumber). - **Disk:** disk space is tight. Share `target/`, don't create extra target dirs, and don't commit generated paths (`target/`, `android/app/src/main/jniLibs/`, `android/local.properties`, Gradle build dirs, `android/.kotlin/`). `Cargo.lock` is committed. @@ -166,7 +167,8 @@ cargo ndk -t arm64-v8a --platform 26 clippy -p audiobridge-android --all-targets - **What's not tested:** WASAPI, PipeWire, AAudio, JNI, the UI and the service. Verify those on real hardware: - PC audio: play a tone on the PC → the phone UI shows "Звук" / buffer. - Mic: `ffmpeg -f dshow -i audio="CABLE Output (VB-Audio Virtual Cable)" -t 8 -af volumedetect -f null -` should report non-silent levels while the phone mic is allowed. - - Linux mic: `pw-record --target audiobridge_mic -` (or any app recording «AudioBridge: микрофон телефона») → the PC log shows `mic demand: true` and the recording isn't silent; after "Выход" or `kill -9`, `pw-cli ls Node` has no `audiobridge_*` nodes. + - Linux mic: `pw-record --target audiobridge_mic -` (or any app recording «AudioBridge: микрофон телефона») → the PC log shows `mic demand: true` and the recording isn't silent; after "Выход" or `kill -9`, `pw-cli ls Node` has no `audiobridge_*` nodes. `wpctl status` lists `audiobridge_mic` under "Default Configured Devices" after a `kill -9` with the phone mic as default: that is the remembered default (metadata), not a node. + - Hidden window (Linux): put the window on another workspace, then add/remove an audio device or connect the phone. The main thread must stay in `epoll_pwait` (`/proc//syscall` starts with 281, not 7 = `poll`), and `kill ` must exit at once with `shutting down` in the log. - Autostart: reboot the phone → the PC log shows `phone 'POCO F5' connected`. - Headphone buttons: play music on the PC through AudioBridge, screen off, double/triple tap → the PC log shows `media command …` and the player reacts; the app's "Последняя команда" line shows what the phone received (`adb logcat -s AudioBridge:*` → `media command N -> PC`). - **Clippy:** `-D warnings` is the working bar for all crates. GitHub CI (Windows) runs clippy plus the core lib/rt_alloc, desktop and android-native host tests; the timing-sensitive integration tests and Android/Gradle checks run locally only. Android lint must stay at 0 errors. diff --git a/crates/desktop/src/ui.rs b/crates/desktop/src/ui.rs index acef2ae..33a7a1e 100644 --- a/crates/desktop/src/ui.rs +++ b/crates/desktop/src/ui.rs @@ -83,6 +83,13 @@ pub fn run_window() { renderer: eframe::Renderer::Glow, centered: true, run_and_return: true, + // Wayland: with vsync, EGL's swap waits for the compositor's frame callback, and + // compositors send none to a window on a hidden workspace. The UI thread then stops + // answering compositor pings (Hyprland offers to kill the "not responding" app with + // SIGKILL) and cannot exit on SIGTERM/"Выход" until the window is shown again. The UI + // repaints only on changes (every 40 ms at most while streaming), so it needs no vsync. + #[cfg(target_os = "linux")] + glow_options: eframe::egui_glow::GlowConfiguration { vsync: false, ..Default::default() }, ..Default::default() }; let result = eframe::run_native( From a059a3b75edf153271e172479259275b48fe762f Mon Sep 17 00:00:00 2001 From: Lyten02 <87010935+Lyten02@users.noreply.github.com> Date: Sat, 31 Oct 2026 14:30:31 +0100 Subject: [PATCH 4/6] fix(linux): clear mic demand when the last reader disappears --- README.md | 4 ++ README.ru.md | 4 ++ crates/desktop/src/linux/audio/graph.rs | 54 +++++++++++++++++++++++++ crates/desktop/src/linux/audio/mod.rs | 22 +++++----- 4 files changed, 75 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 6961f9e..cae0a2b 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,10 @@ The UI is in Russian for now. Translations are welcome. On Linux the phone mic appears as the input «AudioBridge: микрофон телефона». Pick it in an app, or switch on «Микрофон по умолчанию» in AudioBridge. On Hyprland the window can float with a rule for the class `audiobridge`. +Linux mic demand requires a live outgoing PipeWire link as well as a streaming source. The adapter can remain `Streaming` after the last reader closes; this no longer keeps the phone microphone active. Reader changes are also recorded while the phone is disconnected, so the next connection starts with current demand rather than stale demand. No Android update or protocol change is needed. + +To check the lifecycle, open two readers on `audiobridge_mic`: closing one must keep the mic active, closing the last must return the phone to standby. Repeat after a timeout/reconnect, including closing the last reader while disconnected, and after stopping/restarting AudioBridge. Check live graph nodes/links, not only the remembered default-device name. Starting or restarting the app observes the existing output volume; volume writes require a local or remote control action. + ## How it works ``` diff --git a/README.ru.md b/README.ru.md index 1e61731..a2411bf 100644 --- a/README.ru.md +++ b/README.ru.md @@ -51,6 +51,10 @@ AudioBridge передаёт весь звук компьютера на Android В Linux микрофон телефона виден как вход «AudioBridge: микрофон телефона». Выбери его в программе или включи «Микрофон по умолчанию» в AudioBridge. В Hyprland окно можно сделать плавающим правилом для класса `audiobridge`. +В Linux запрос микрофона требует живого исходящего соединения PipeWire и работающего источника. Адаптер может оставаться в состоянии `Streaming` после закрытия последнего читателя; теперь это не удерживает микрофон телефона включённым. Изменения читателей учитываются и при отключённом телефоне: новое подключение получает актуальный запрос, а не старое значение. Обновление Android и изменение протокола не нужны. + +Проверка жизненного цикла: открыть два читателя `audiobridge_mic`; закрытие одного оставляет микрофон активным, закрытие последнего возвращает телефон в ожидание. Повторить после timeout/reconnect, в том числе закрыть последнего читателя во время разрыва связи, затем после остановки и запуска AudioBridge. Проверять живые узлы и соединения графа, а не только сохранённое имя микрофона по умолчанию. При запуске приложение читает текущую громкость выхода; запись громкости требует действия местного или телефонного пульта. + ## Сборка из исходников ```powershell diff --git a/crates/desktop/src/linux/audio/graph.rs b/crates/desktop/src/linux/audio/graph.rs index 4e2ed27..a78e358 100644 --- a/crates/desktop/src/linux/audio/graph.rs +++ b/crates/desktop/src/linux/audio/graph.rs @@ -71,6 +71,8 @@ pub struct Graph { /// Audio device nodes by global id. pub nodes: BTreeMap, pub defaults: Defaults, + /// Links by global id, including readers (which are not device nodes). + links: BTreeMap, } impl Graph { @@ -83,10 +85,29 @@ impl Graph { } } + pub fn add_link(&mut self, id: u32, output: Option<&str>, input: Option<&str>) { + if let (Some(output), Some(input)) = ( + output.and_then(|v| v.parse().ok()), + input.and_then(|v| v.parse().ok()), + ) { + self.links.insert(id, (output, input)); + } + } + pub fn remove(&mut self, id: u32) -> bool { + self.links.remove(&id); + self.links.retain(|_, (output, input)| *output != id && *input != id); self.nodes.remove(&id).is_some() } + /// A source adapter can stay Streaming after its last consumer is unlinked. + /// Demand therefore also requires a live outgoing graph link, not just stream state. + pub fn mic_has_readers(&self) -> bool { + self.find(MIC_NODE_NAME).is_some_and(|(mic, _)| { + self.links.values().any(|(output, _)| *output == mic) + }) + } + pub fn find(&self, name: &str) -> Option<(u32, &NodeEntry)> { self.nodes.iter().find(|(_, n)| n.name == name).map(|(id, n)| (*id, n)) } @@ -180,4 +201,37 @@ mod tests { g.defaults.apply("default.audio.sink", None); assert!(g.default_sink().is_none()); } + + #[test] + fn mic_demand_ends_only_when_the_last_reader_link_is_removed() { + let mut g = graph(); + g.add_link(60, Some("41"), Some("50")); // unrelated microphone + assert!(!g.mic_has_readers()); + g.add_link(61, Some("42"), Some("51")); + g.add_link(62, Some("42"), Some("52")); + assert!(g.mic_has_readers()); + g.remove(61); + assert!(g.mic_has_readers()); + g.remove(62); + assert!(!g.mic_has_readers()); + g.add_link(63, Some("42"), Some("53")); + assert!(g.mic_has_readers()); + g.remove(53); // reader node removal can precede its link removal + assert!(!g.mic_has_readers()); + } + + #[test] + fn removed_mic_and_reused_node_ids_do_not_keep_demand() { + let mut g = graph(); + g.add_link(60, Some("42"), Some("51")); + assert!(g.mic_has_readers()); + g.remove(42); + assert!(!g.mic_has_readers()); + g.add_node(42, Some(MIC_NODE_NAME), None, Some("Audio/Source")); + assert!(!g.mic_has_readers()); + g.add_link(61, Some("42"), Some("51")); + assert!(g.mic_has_readers()); + // A new PipeWire session starts with an empty graph, never stale links. + assert!(!Graph::default().mic_has_readers()); + } } diff --git a/crates/desktop/src/linux/audio/mod.rs b/crates/desktop/src/linux/audio/mod.rs index de04c7a..96407fe 100644 --- a/crates/desktop/src/linux/audio/mod.rs +++ b/crates/desktop/src/linux/audio/mod.rs @@ -189,14 +189,12 @@ impl Engine { (self.hooks.on_volume)(volume); self.reported_volume = Some(volume); } - if self.desired.connected { - if self.reported_demand != Some(self.demand) { - tracing::info!("mic demand: {}", self.demand); - (self.hooks.on_demand)(self.demand); - self.reported_demand = Some(self.demand); - } - } else { - self.reported_demand = None; + // The server retains demand for the next Welcome, even while disconnected. + // Closing the last reader offline must clear it before a phone reconnects. + if self.reported_demand != Some(self.demand) { + tracing::info!("mic demand: {}", self.demand); + (self.hooks.on_demand)(self.demand); + self.reported_demand = Some(self.demand); } } } @@ -223,6 +221,7 @@ struct Session { capture_failed: bool, mic: Option, mic_failed: bool, + mic_streaming: bool, lost: Option, } @@ -317,6 +316,7 @@ fn run_session(engine: Engine, rx: pw::channel::Receiver) -> Back { capture_failed: false, mic: None, mic_failed: false, + mic_streaming: false, lost: None, }; let ctx = Rc::new(Ctx { @@ -427,7 +427,7 @@ impl Inner { s.mic = None; s.mic_failed = true; } - engine.demand = state == StreamState::Streaming; + s.mic_streaming = state == StreamState::Streaming; } Ev::CaptureState(state) => { tracing::debug!("loopback: {state:?}"); @@ -512,6 +512,7 @@ impl Inner { tracing::info!("loopback stopped"); } + engine.demand = s.mic_streaming && s.graph.mic_has_readers(); let volume = s.sink.as_ref().and_then(|w| w.volume); engine.report(volume); } @@ -534,6 +535,9 @@ impl Session { self.globals.insert(obj.id, obj); } } + ObjectType::Link => { + self.graph.add_link(obj.id, get("link.output.node"), get("link.input.node")); + } ObjectType::Metadata if get("metadata.name") == Some("default") => { let metadata = match self.registry.bind::(&obj) { Ok(m) => m, From 5989f7246d1fb2a0f3193630aa0c05373c4c7935 Mon Sep 17 00:00:00 2001 From: Lyten02 <87010935+Lyten02@users.noreply.github.com> Date: Sat, 31 Oct 2026 14:58:26 +0100 Subject: [PATCH 5/6] fix(linux): require active PipeWire mic links --- README.md | 4 +- README.ru.md | 4 +- crates/desktop/src/linux/audio/graph.rs | 94 +++++++++++++++++++------ crates/desktop/src/linux/audio/mod.rs | 29 +++++++- 4 files changed, 105 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index cae0a2b..1546745 100644 --- a/README.md +++ b/README.md @@ -51,9 +51,9 @@ The UI is in Russian for now. Translations are welcome. On Linux the phone mic appears as the input «AudioBridge: микрофон телефона». Pick it in an app, or switch on «Микрофон по умолчанию» in AudioBridge. On Hyprland the window can float with a rule for the class `audiobridge`. -Linux mic demand requires a live outgoing PipeWire link as well as a streaming source. The adapter can remain `Streaming` after the last reader closes; this no longer keeps the phone microphone active. Reader changes are also recorded while the phone is disconnected, so the next connection starts with current demand rather than stale demand. No Android update or protocol change is needed. +Linux mic demand requires an **Active** outgoing PipeWire link as well as a streaming source. Link activity is followed through PipeWire events: a permanent passive filter route does not request the microphone while idle, but does while a downstream app records through it. The adapter can remain `Streaming` after the last reader closes; this no longer keeps the phone microphone active. Reader changes are also recorded while the phone is disconnected, so the next connection starts with current demand rather than stale demand. No Android update or protocol change is needed. -To check the lifecycle, open two readers on `audiobridge_mic`: closing one must keep the mic active, closing the last must return the phone to standby. Repeat after a timeout/reconnect, including closing the last reader while disconnected, and after stopping/restarting AudioBridge. Check live graph nodes/links, not only the remembered default-device name. Starting or restarting the app observes the existing output volume; volume writes require a local or remote control action. +To check the lifecycle, open two readers on `audiobridge_mic`: closing one must keep the mic active, closing the last must return the phone to standby. Repeat after a timeout/reconnect, including closing the last reader while disconnected, and after stopping/restarting AudioBridge. Also keep a passive filter route connected: idle must stay in standby, recording through the filter must activate the mic, and pausing or closing the last downstream reader must return it to standby without removing the passive route. Check link **state** as well as live graph nodes/links, not only the remembered default-device name. Starting or restarting the app observes the existing output volume; volume writes require a local or remote control action. ## How it works diff --git a/README.ru.md b/README.ru.md index a2411bf..dac6dc5 100644 --- a/README.ru.md +++ b/README.ru.md @@ -51,9 +51,9 @@ AudioBridge передаёт весь звук компьютера на Android В Linux микрофон телефона виден как вход «AudioBridge: микрофон телефона». Выбери его в программе или включи «Микрофон по умолчанию» в AudioBridge. В Hyprland окно можно сделать плавающим правилом для класса `audiobridge`. -В Linux запрос микрофона требует живого исходящего соединения PipeWire и работающего источника. Адаптер может оставаться в состоянии `Streaming` после закрытия последнего читателя; теперь это не удерживает микрофон телефона включённым. Изменения читателей учитываются и при отключённом телефоне: новое подключение получает актуальный запрос, а не старое значение. Обновление Android и изменение протокола не нужны. +В Linux запрос микрофона требует исходящего соединения PipeWire в состоянии **Active** и работающего источника. Состояние соединений отслеживается по событиям PipeWire: постоянный пассивный маршрут через фильтр не запрашивает микрофон в простое, но поддерживает запись приложением за фильтром. Адаптер может оставаться в состоянии `Streaming` после закрытия последнего читателя; теперь это не удерживает микрофон телефона включённым. Изменения читателей учитываются и при отключённом телефоне: новое подключение получает актуальный запрос, а не старое значение. Обновление Android и изменение протокола не нужны. -Проверка жизненного цикла: открыть два читателя `audiobridge_mic`; закрытие одного оставляет микрофон активным, закрытие последнего возвращает телефон в ожидание. Повторить после timeout/reconnect, в том числе закрыть последнего читателя во время разрыва связи, затем после остановки и запуска AudioBridge. Проверять живые узлы и соединения графа, а не только сохранённое имя микрофона по умолчанию. При запуске приложение читает текущую громкость выхода; запись громкости требует действия местного или телефонного пульта. +Проверка жизненного цикла: открыть два читателя `audiobridge_mic`; закрытие одного оставляет микрофон активным, закрытие последнего возвращает телефон в ожидание. Повторить после timeout/reconnect, в том числе закрыть последнего читателя во время разрыва связи, затем после остановки и запуска AudioBridge. Отдельно оставить подключённым пассивный маршрут через фильтр: простой сохраняет ожидание, запись через фильтр включает микрофон, пауза или закрытие последнего читателя за фильтром возвращает ожидание без удаления пассивного маршрута. Проверять **состояние** соединений, живые узлы и соединения графа, а не только сохранённое имя микрофона по умолчанию. При запуске приложение читает текущую громкость выхода; запись громкости требует действия местного или телефонного пульта. ## Сборка из исходников diff --git a/crates/desktop/src/linux/audio/graph.rs b/crates/desktop/src/linux/audio/graph.rs index a78e358..1509e73 100644 --- a/crates/desktop/src/linux/audio/graph.rs +++ b/crates/desktop/src/linux/audio/graph.rs @@ -66,13 +66,20 @@ pub fn node_name_json(name: &str) -> String { serde_json::json!({ "name": name }).to_string() } +#[derive(Clone, Debug)] +struct LinkEntry { + output: u32, + input: u32, + active: bool, +} + #[derive(Clone, Debug, Default)] pub struct Graph { /// Audio device nodes by global id. pub nodes: BTreeMap, pub defaults: Defaults, /// Links by global id, including readers (which are not device nodes). - links: BTreeMap, + links: BTreeMap, } impl Graph { @@ -90,21 +97,28 @@ impl Graph { output.and_then(|v| v.parse().ok()), input.and_then(|v| v.parse().ok()), ) { - self.links.insert(id, (output, input)); + self.links.insert(id, LinkEntry { output, input, active: false }); + } + } + + /// Link info is authoritative for consumption, including passive filter routes. + pub fn set_link_active(&mut self, id: u32, active: bool) { + if let Some(link) = self.links.get_mut(&id) { + link.active = active; } } pub fn remove(&mut self, id: u32) -> bool { self.links.remove(&id); - self.links.retain(|_, (output, input)| *output != id && *input != id); + self.links.retain(|_, link| link.output != id && link.input != id); self.nodes.remove(&id).is_some() } - /// A source adapter can stay Streaming after its last consumer is unlinked. - /// Demand therefore also requires a live outgoing graph link, not just stream state. - pub fn mic_has_readers(&self) -> bool { - self.find(MIC_NODE_NAME).is_some_and(|(mic, _)| { - self.links.values().any(|(output, _)| *output == mic) + /// A source adapter can stay Streaming without consumption. A persistent passive + /// route counts while Active, but not while Paused after its downstream reader closes. + pub fn mic_demand(&self, streaming: bool) -> bool { + streaming && self.find(MIC_NODE_NAME).is_some_and(|(mic, _)| { + self.links.values().any(|link| link.output == mic && link.active) }) } @@ -161,7 +175,7 @@ mod tests { let mut g = Graph::default(); g.add_node(40, Some("alsa_output.speakers"), Some("Speakers"), Some("Audio/Sink")); g.add_node(41, Some("alsa_input.mic"), Some("Built-in Mic"), Some("Audio/Source")); - g.add_node(42, Some(MIC_NODE_NAME), Some("AudioBridge"), Some("Audio/Source/Virtual")); + g.add_node(42, Some(MIC_NODE_NAME), Some("AudioBridge"), Some("Audio/Source")); g.add_node(43, Some("firefox"), None, Some("Stream/Output/Audio")); g.defaults.apply("default.audio.sink", Some(r#"{"name":"alsa_output.speakers"}"#)); g.defaults.apply("default.audio.source", Some(r#"{"name":"alsa_input.mic"}"#)); @@ -206,32 +220,70 @@ mod tests { fn mic_demand_ends_only_when_the_last_reader_link_is_removed() { let mut g = graph(); g.add_link(60, Some("41"), Some("50")); // unrelated microphone - assert!(!g.mic_has_readers()); + g.set_link_active(60, true); + assert!(!g.mic_demand(true)); g.add_link(61, Some("42"), Some("51")); + g.set_link_active(61, true); g.add_link(62, Some("42"), Some("52")); - assert!(g.mic_has_readers()); + g.set_link_active(62, true); + assert!(g.mic_demand(true)); g.remove(61); - assert!(g.mic_has_readers()); + assert!(g.mic_demand(true)); g.remove(62); - assert!(!g.mic_has_readers()); + assert!(!g.mic_demand(true)); g.add_link(63, Some("42"), Some("53")); - assert!(g.mic_has_readers()); + g.set_link_active(63, true); + assert!(g.mic_demand(true)); g.remove(53); // reader node removal can precede its link removal - assert!(!g.mic_has_readers()); + assert!(!g.mic_demand(true)); } #[test] fn removed_mic_and_reused_node_ids_do_not_keep_demand() { let mut g = graph(); g.add_link(60, Some("42"), Some("51")); - assert!(g.mic_has_readers()); + g.set_link_active(60, true); + assert!(g.mic_demand(true)); g.remove(42); - assert!(!g.mic_has_readers()); + assert!(!g.mic_demand(true)); g.add_node(42, Some(MIC_NODE_NAME), None, Some("Audio/Source")); - assert!(!g.mic_has_readers()); + assert!(!g.mic_demand(true)); g.add_link(61, Some("42"), Some("51")); - assert!(g.mic_has_readers()); - // A new PipeWire session starts with an empty graph, never stale links. - assert!(!Graph::default().mic_has_readers()); + g.set_link_active(61, true); + assert!(g.mic_demand(true)); + } + #[test] + fn passive_route_requires_active_consumption_even_with_stale_streaming() { + let mut g = graph(); + g.add_link(60, Some("42"), Some("50")); // persistent passive filter input + assert!(!g.mic_demand(true)); // unknown/paused route, not a reader + g.set_link_active(60, true); // downstream recording activates the same route + assert!(g.mic_demand(true)); + assert!(!g.mic_demand(false)); // link activation can arrive before Streaming + g.set_link_active(60, false); // last downstream reader closed; route remains + assert!(!g.mic_demand(true)); // late/stale Streaming cannot reactivate demand + g.set_link_active(60, true); // a new downstream recording is still supported + assert!(g.mic_demand(true)); + } + + #[test] + fn direct_and_passive_routes_handle_both_close_event_orders() { + for pause_first in [false, true] { + let mut g = graph(); + g.add_link(60, Some("42"), Some("50")); // idle passive filter + g.add_link(61, Some("42"), Some("51")); // direct reader + g.set_link_active(61, true); + assert!(g.mic_demand(true)); + if pause_first { + assert!(!g.mic_demand(false)); + g.set_link_active(61, false); + assert!(!g.mic_demand(true)); + } + g.remove(51); // node removal may precede link removal/state notification + g.set_link_active(61, true); // an already queued info must not resurrect it + assert!(!g.mic_demand(true)); + g.remove(61); + assert!(!g.mic_demand(true)); + } } } diff --git a/crates/desktop/src/linux/audio/mod.rs b/crates/desktop/src/linux/audio/mod.rs index 96407fe..48ece48 100644 --- a/crates/desktop/src/linux/audio/mod.rs +++ b/crates/desktop/src/linux/audio/mod.rs @@ -22,6 +22,7 @@ use audiobridge_core::audio::{CaptureHandle, PlayoutHandle}; use audiobridge_core::session::Volume; use pipewire as pw; use pw::core::CoreRc; +use pw::link::{Link, LinkListener, LinkState}; use pw::metadata::{Metadata, MetadataListener}; use pw::node::{Node, NodeListener}; use pw::properties::{properties, PropertiesBox}; @@ -207,12 +208,19 @@ struct SinkWatch { _node: Node, } +/// A graph link and its event subscription; listener drops before the proxy. +struct LinkWatch { + _listener: LinkListener, + _link: Link, +} + /// Objects of one PipeWire connection. struct Session { mainloop: pw::main_loop::MainLoopWeak, core: CoreRc, registry: RegistryRc, graph: Graph, + links: HashMap, /// Audio device node globals, to bind the default sink when it becomes the default. globals: HashMap>, metadata: Option<(u32, Metadata, MetadataListener)>, @@ -228,6 +236,7 @@ struct Session { enum Ev { Global(GlobalObject), Removed(u32), + LinkActive(u32, bool), Meta(String, Option), Props(u32, PropsUpdate), MicState(StreamState), @@ -309,6 +318,7 @@ fn run_session(engine: Engine, rx: pw::channel::Receiver) -> Back { core: core.clone(), registry: registry.clone(), graph: Graph::default(), + links: HashMap::new(), globals: HashMap::new(), metadata: None, sink: None, @@ -379,6 +389,7 @@ fn run_session(engine: Engine, rx: pw::channel::Receiver) -> Back { s.mic = None; s.sink = None; s.metadata = None; + s.links.clear(); } let ctx = match Rc::try_unwrap(ctx) { Ok(ctx) => ctx, @@ -404,6 +415,7 @@ impl Inner { Ev::Global(obj) => s.global(obj, ctx), Ev::Removed(id) => { s.graph.remove(id); + s.links.remove(&id); s.globals.remove(&id); if s.sink.as_ref().is_some_and(|w| w.id == id) { s.sink = None; @@ -412,6 +424,7 @@ impl Inner { s.metadata = None; } } + Ev::LinkActive(id, active) => s.graph.set_link_active(id, active), Ev::Meta(key, value) => { s.graph.defaults.apply(&key, value.as_deref()); } @@ -512,7 +525,7 @@ impl Inner { tracing::info!("loopback stopped"); } - engine.demand = s.mic_streaming && s.graph.mic_has_readers(); + engine.demand = s.graph.mic_demand(s.mic_streaming); let volume = s.sink.as_ref().and_then(|w| w.volume); engine.report(volume); } @@ -537,6 +550,20 @@ impl Session { } ObjectType::Link => { self.graph.add_link(obj.id, get("link.output.node"), get("link.input.node")); + let link = match self.registry.bind::(&obj) { + Ok(link) => link, + Err(e) => { + tracing::warn!("binding graph link {}: {e}", obj.id); + return; + } + }; + let id = obj.id; + let post = ctx.poster(); + let listener = link + .add_listener_local() + .info(move |info| post(Ev::LinkActive(id, matches!(info.state(), LinkState::Active)))) + .register(); + self.links.insert(id, LinkWatch { _listener: listener, _link: link }); } ObjectType::Metadata if get("metadata.name") == Some("default") => { let metadata = match self.registry.bind::(&obj) { From bfe2666d0c912beddcedcf16ef319fc51aa78a4f Mon Sep 17 00:00:00 2001 From: Lyten02 <87010935+Lyten02@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:24:34 +0100 Subject: [PATCH 6/6] fix(linux): keep network mic out of passive leaf scheduling --- README.md | 4 ++++ README.ru.md | 4 ++++ crates/desktop/src/linux/audio/streams.rs | 6 ++++++ 3 files changed, 14 insertions(+) diff --git a/README.md b/README.md index 1546745..76c19f3 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,10 @@ On Linux the phone mic appears as the input «AudioBridge: микрофон те Linux mic demand requires an **Active** outgoing PipeWire link as well as a streaming source. Link activity is followed through PipeWire events: a permanent passive filter route does not request the microphone while idle, but does while a downstream app records through it. The adapter can remain `Streaming` after the last reader closes; this no longer keeps the phone microphone active. Reader changes are also recorded while the phone is disconnected, so the next connection starts with current demand rather than stale demand. No Android update or protocol change is needed. +The network-fed source has its own PipeWire link group, separate from PC-audio capture. This prevents PipeWire 1.6's passive-leaf scheduling from keeping the source and idle filters running just because an unrelated playback driver is active. Passive routes remain supported; they are not excluded from demand detection. + +Run these checks while unrelated audio playback keeps a driver active, not only in an otherwise idle graph. + To check the lifecycle, open two readers on `audiobridge_mic`: closing one must keep the mic active, closing the last must return the phone to standby. Repeat after a timeout/reconnect, including closing the last reader while disconnected, and after stopping/restarting AudioBridge. Also keep a passive filter route connected: idle must stay in standby, recording through the filter must activate the mic, and pausing or closing the last downstream reader must return it to standby without removing the passive route. Check link **state** as well as live graph nodes/links, not only the remembered default-device name. Starting or restarting the app observes the existing output volume; volume writes require a local or remote control action. ## How it works diff --git a/README.ru.md b/README.ru.md index dac6dc5..7aece89 100644 --- a/README.ru.md +++ b/README.ru.md @@ -53,6 +53,10 @@ AudioBridge передаёт весь звук компьютера на Android В Linux запрос микрофона требует исходящего соединения PipeWire в состоянии **Active** и работающего источника. Состояние соединений отслеживается по событиям PipeWire: постоянный пассивный маршрут через фильтр не запрашивает микрофон в простое, но поддерживает запись приложением за фильтром. Адаптер может оставаться в состоянии `Streaming` после закрытия последнего читателя; теперь это не удерживает микрофон телефона включённым. Изменения читателей учитываются и при отключённом телефоне: новое подключение получает актуальный запрос, а не старое значение. Обновление Android и изменение протокола не нужны. +Сетевой источник имеет отдельную link group PipeWire, не общую с захватом звука ПК. Это не даёт планировщику пассивных leaf-узлов PipeWire 1.6 удерживать источник и простаивающие фильтры работающими только из-за активного драйвера постороннего воспроизведения. Пассивные маршруты по-прежнему поддерживаются и не исключаются из определения запроса микрофона. + +Проверять нужно и при постороннем воспроизведении, которое удерживает драйвер активным, а не только в пустом графе. + Проверка жизненного цикла: открыть два читателя `audiobridge_mic`; закрытие одного оставляет микрофон активным, закрытие последнего возвращает телефон в ожидание. Повторить после timeout/reconnect, в том числе закрыть последнего читателя во время разрыва связи, затем после остановки и запуска AudioBridge. Отдельно оставить подключённым пассивный маршрут через фильтр: простой сохраняет ожидание, запись через фильтр включает микрофон, пауза или закрытие последнего читателя за фильтром возвращает ожидание без удаления пассивного маршрута. Проверять **состояние** соединений, живые узлы и соединения графа, а не только сохранённое имя микрофона по умолчанию. При запуске приложение читает текущую громкость выхода; запись громкости требует действия местного или телефонного пульта. ## Сборка из исходников diff --git a/crates/desktop/src/linux/audio/streams.rs b/crates/desktop/src/linux/audio/streams.rs index 045fff2..3b109d4 100644 --- a/crates/desktop/src/linux/audio/streams.rs +++ b/crates/desktop/src/linux/audio/streams.rs @@ -162,6 +162,12 @@ impl VirtualMic { properties! { "media.class" => "Audio/Source", *pw::keys::NODE_VIRTUAL => "true", + // This is a network-fed source, not an autonomous leaf producer. PipeWire 1.6 + // can run an ungrouped leaf just because its previous driver is still active + // (e.g. unrelated playback), activating idle passive filters as a side effect. + // A dedicated link group keeps scheduling demand-driven; do not share it with + // our PC-audio capture, which has independent consumers. + "node.link-group" => "audiobridge.mic", *pw::keys::NODE_NAME => MIC_NODE_NAME, *pw::keys::NODE_DESCRIPTION => MIC_NODE_DESCRIPTION, *pw::keys::NODE_LATENCY => LATENCY,