diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml new file mode 100644 index 0000000..8d498cd --- /dev/null +++ b/.github/workflows/build-linux.yml @@ -0,0 +1,111 @@ +name: Build Linux + +# Runs only on a `linux-*` tag (e.g. `git tag linux-1 && git push origin linux-1`) +# or a manual dispatch -- never on every commit, and separate from the `v*` +# release tags so it won't trigger the macOS Release workflow. Produces a +# downloadable artifact (no GitHub release). +on: + push: + tags: + - 'linux-*' + workflow_dispatch: + +permissions: + contents: read + packages: read + +jobs: + build: + # ubuntu-22.04 = oldest base shipping WebKitGTK 4.1, so glibc stays 2.35 + # for broad AppImage compatibility (newer bases raise the glibc floor). + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo build + uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri + + - name: Install Linux system deps + # 22.04 ships libpipewire 0.3.48, too old for the libspa crate + # (spa_video_info_raw.flags). The upstream PPA backports a current + # libpipewire while keeping the glibc 2.35 base. + run: | + sudo add-apt-repository -y ppa:pipewire-debian/pipewire-upstream + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libwebkit2gtk-4.1-dev libjavascriptcoregtk-4.1-dev \ + libgtk-3-dev librsvg2-dev librsvg2-bin libgtk-3-bin \ + libayatana-appindicator3-dev libsoup-3.0-dev libssl-dev \ + libpipewire-0.3-dev clang libclang-dev \ + libasound2-dev libopus-dev \ + desktop-file-utils xdg-utils patchelf rpm + + - name: Install JS deps + run: bun install --frozen-lockfile + + - name: Build AppImage + deb + rpm + uses: tauri-apps/tauri-action@v0 + with: + projectPath: src-tauri + args: '--bundles appimage,deb,rpm -c {"bundle":{"createUpdaterArtifacts":false}}' + + - name: Strip bundled host libs from AppImage + # linuxdeploy bundles host-integration libs that aren't on the + # AppImage excludelist. They then shadow the system copies and + # break against the host's plugin ecosystem: + # - libwayland-* -> WebKit EGL init fails (EGL_BAD_PARAMETER / + # blank window) against newer Mesa + # - libpipewire/libspa -> can't load SPA plugins (support.system), + # "pipewire: Creation failed", since the spa-0.2/ plugin dir + # is not bundled + # Remove them so the app links the host's copies + their plugins. + run: | + set -euxo pipefail + export APPIMAGE_EXTRACT_AND_RUN=1 + app=$(find src-tauri/target/release/bundle/appimage -name '*.AppImage' | head -1) + "$app" --appimage-extract + rm -fv squashfs-root/usr/lib/libwayland-client.so* \ + squashfs-root/usr/lib/libwayland-egl.so* \ + squashfs-root/usr/lib/libwayland-cursor.so* \ + squashfs-root/usr/lib/libpipewire-0.3.so* \ + squashfs-root/usr/lib/libspa-0.2.so* + curl -fsSL -o /tmp/appimagetool \ + https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage + chmod +x /tmp/appimagetool + rm -f "$app" + ARCH=x86_64 /tmp/appimagetool squashfs-root "$app" + rm -rf squashfs-root + + # Separate artifacts so each downloads on its own -- grabbing the + # small deb/rpm doesn't pull the heavy AppImage. + - name: Upload AppImage + uses: actions/upload-artifact@v4 + with: + name: Splitwave-AppImage-x86_64 + path: src-tauri/target/release/bundle/appimage/*.AppImage + if-no-files-found: error + + - name: Upload deb + uses: actions/upload-artifact@v4 + with: + name: Splitwave-deb-x86_64 + path: src-tauri/target/release/bundle/deb/*.deb + if-no-files-found: error + + - name: Upload rpm + uses: actions/upload-artifact@v4 + with: + name: Splitwave-rpm-x86_64 + path: src-tauri/target/release/bundle/rpm/*.rpm + if-no-files-found: error diff --git a/README.md b/README.md index 7c745be..2e23a64 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,14 @@ [![Downloads](https://img.shields.io/github/downloads/Horuse/Splitwave/total.svg)](https://github.com/Horuse/Splitwave/releases/latest) # Splitwave -Splitwave is a node-based audio router for macOS. Wire microphones, system audio, per-app capture, and WAV files into a visual graph, run them through a chain of effects — EQ, compression, reverb, limiting, and more — then send the result to speakers or record it in WAV, FLAC, AIFF, MP3, Opus, or AAC. +Splitwave is a node-based audio router for macOS and Linux. Wire microphones, system audio, per-app capture, and WAV files into a visual graph, run them through a chain of effects — EQ, compression, reverb, limiting, and more — then send the result to speakers or record it in WAV, FLAC, AIFF, MP3, Opus, or AAC. ![Splitwave preview](./preview.png) ## Installation +### macOS + Download the latest `.dmg` from [Releases](https://github.com/Horuse/Splitwave/releases/latest), open it, and drag Splitwave to Applications. @@ -21,10 +23,19 @@ Then open Splitwave normally. **After each update, Screen Recording permission resets** (macOS revokes it when the binary changes and the app is unsigned). To re-grant it: open System Settings → Privacy & Security → Screen Recording, click **−** to remove Splitwave, then click **+** and add it back. +### Linux + +Requires a PipeWire-based audio session (default on most current distros). +Download the build for your system from [Releases](https://github.com/Horuse/Splitwave/releases/latest): + +- **AppImage** — `chmod +x Splitwave_*.AppImage && ./Splitwave_*.AppImage` +- **`.deb`** (Debian/Ubuntu) — `sudo apt install ./Splitwave_*.deb` +- **`.rpm`** (Fedora/RHEL/openSUSE) — `sudo rpm -i Splitwave-*.rpm` + ## Features -- **Inputs:** microphones, system audio, per-application audio - (ScreenCaptureKit), WAV files, virtual device loopback +- **Inputs:** microphones, system audio, per-application audio, WAV files, + virtual device loopback - **Outputs:** physical speakers/interfaces, file recording in WAV (16/24-bit PCM + 32-float), FLAC, AIFF, Opus, MP3, AAC (M4A), virtual devices - **Effects:** Gain, Mute, Channel Balance, Saturator, 10-band Graphic EQ, @@ -35,23 +46,40 @@ Then open Splitwave normally. Use them to capture loopback audio from any app or to feed processed audio into apps that accept a microphone input (DAWs, Discord, etc.) +System and per-app capture use **ScreenCaptureKit** on macOS and **PipeWire** on +Linux; virtual devices are AudioServerPlugin drivers on macOS and PipeWire +null-sinks on Linux. + ## Stack - **Frontend:** Svelte, Tauri, @xyflow/svelte -- **Engine:** Rust -- `cpal` (device I/O), `rtrb` (SPSC ring buffers), `rubato` - (resampling), `hound` (WAV), `flac-codec`, `opus`, `mp3lame-encoder`, - `ebur128` -- **macOS-specific:** custom Swift static library for ScreenCaptureKit, - compiled by `build.rs` via `swiftc`; CoreAudio HAL FFI for device - enumeration; libASPL-based AudioServerPlugin for virtual device driver +- **Engine:** Rust -- `rtrb` (SPSC ring buffers), `rubato` (resampling), + `hound` (WAV), `flac-codec`, `opus`, `mp3lame-encoder`, `ebur128` +- **macOS:** `cpal` device I/O; custom Swift static library for ScreenCaptureKit, + compiled by `build.rs` via `swiftc`; CoreAudio HAL FFI for device enumeration; + libASPL-based AudioServerPlugin for the virtual device driver +- **Linux:** `pipewire` for device I/O, system/app capture, and virtual + null-sinks; `freedesktop-desktop-entry` / `freedesktop-icons` for app icons ## Development ### Prerequisites +**macOS:** + - macOS 13+ with Xcode Command Line Tools (`xcode-select --install`) -- gives you `swiftc` and the SDKs Tauri needs. + +**Linux:** + +- A PipeWire session and these dev packages (Debian/Ubuntu names): + `libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev libayatana-appindicator3-dev` + `libsoup-3.0-dev libpipewire-0.3-dev clang libclang-dev libasound2-dev` + `libopus-dev` + +**Both:** + - [Rust](https://rustup.rs) (stable toolchain) - [Bun](https://bun.sh) (`curl -fsSL https://bun.sh/install | bash`) @@ -65,28 +93,33 @@ bun run tauri dev ### Useful commands ```bash -bun run check # svelte-check + tsc -bun run generate # regenerate TypeScript types from Rust (ts-rs) +bun run check # svelte-check + tsc +bun run generate # regenerate TypeScript types from Rust (ts-rs) cargo check --manifest-path src-tauri/Cargo.toml -bun run tauri build --bundles app # local .app build +bun run tauri build --bundles app # local .app build (macOS) +bun run tauri build --bundles appimage # local AppImage build (Linux) ``` ### Project layout ``` src/lib/modules/ - audio/ cpal/SCK bridge, device enumeration, meter store + audio/ device enumeration, meter store flow/ xyflow node graph editor, sidebar, context menu pipeline/ pipeline state, snapshot history, ts-rs generated types form/ shared form primitives (combobox, slider) error/ global error modal (Rust panics + JS errors) updater/ auto-update modal + skip-version persistence app_info/ OS / app version cache -src-tauri/src/ - audio/ DSP engine, effects, encoders, pipeline DAG - commands.rs Tauri command surface - lib.rs app entry, plugin wiring, panic hook +src-tauri/src/audio/ + capture/ system + per-app capture (macos.rs SCK / linux.rs PipeWire) + device/ device enumeration (macos.rs CoreAudio / linux.rs PipeWire) + volume/ device volume (macos.rs / linux.rs) + virtual_device/ null-sink / driver management per OS + streams/ cpal stream builders (macOS) + playback.rs PipeWire speaker output (Linux) + pipeline/ DSP engine, effects, encoders, pipeline DAG (input/, output/ per OS) src-tauri/native/virtual_driver/ - SplitAudioDriver.cpp AudioServerPlugin implementation (libASPL) + SplitAudioDriver.cpp AudioServerPlugin implementation (libASPL, macOS) Info.plist CFPlugin manifest ``` diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index cd25282..49df870 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -63,6 +63,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.102" @@ -78,6 +94,12 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + [[package]] name = "arrayvec" version = "0.7.6" @@ -235,7 +257,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -288,6 +310,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ + "annotate-snippets", "bitflags 2.11.1", "cexpr", "clang-sys", @@ -339,6 +362,12 @@ dependencies = [ "no_std_io2", ] +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + [[package]] name = "block-buffer" version = "0.10.4" @@ -400,6 +429,17 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -418,6 +458,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -449,7 +495,7 @@ checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" dependencies = [ "glib-sys", "libc", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -518,7 +564,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -539,7 +585,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" dependencies = [ "smallvec", - "target-lexicon", + "target-lexicon 0.12.16", +] + +[[package]] +name = "cfg-expr" +version = "0.20.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c6b04e07d8080154ed4ac03546d9a2b303cc2fe1901ba0b35b301516e289368" +dependencies = [ + "smallvec", + "target-lexicon 0.13.3", ] [[package]] @@ -586,6 +642,12 @@ dependencies = [ "cc", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "combine" version = "4.6.7" @@ -605,6 +667,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "convert_case" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.18.1" @@ -615,6 +686,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" + [[package]] name = "core-foundation" version = "0.10.1" @@ -655,6 +732,15 @@ dependencies = [ "libc", ] +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + [[package]] name = "coreaudio-rs" version = "0.11.3" @@ -829,6 +915,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + [[package]] name = "dbus" version = "0.9.11" @@ -892,13 +984,34 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", ] [[package]] @@ -909,7 +1022,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -1117,6 +1230,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "event-listener" version = "5.4.1" @@ -1206,6 +1328,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + [[package]] name = "fnv" version = "1.0.7" @@ -1224,6 +1352,29 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree 0.20.0", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1260,6 +1411,35 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "freedesktop-desktop-entry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28273c5c6b97a5f07724f6652f064c0c7f637f9aa5e7c09c83bc3bc4ad4ea245" +dependencies = [ + "bstr", + "gettext-rs", + "log", + "memchr", + "thiserror 2.0.18", + "unicase", + "xdg 3.0.0", +] + +[[package]] +name = "freedesktop-icons" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95f87364ea709292a3b3f74014ce3ee78412c89807eea75a358c8e029b000994" +dependencies = [ + "dirs 5.0.1", + "ini_core", + "once_cell", + "thiserror 1.0.69", + "tracing", + "xdg 2.5.2", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1382,7 +1562,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -1399,7 +1579,7 @@ dependencies = [ "libc", "pango-sys", "pkg-config", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -1413,7 +1593,7 @@ dependencies = [ "gobject-sys", "libc", "pkg-config", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -1439,7 +1619,7 @@ dependencies = [ "gdk-sys", "glib-sys", "libc", - "system-deps", + "system-deps 6.2.2", "x11", ] @@ -1499,6 +1679,36 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gettext-rs" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5857dc1b7f0fee86961de833f434e29494d72af102ce5355738c0664222bdf" +dependencies = [ + "gettext-sys", + "locale_config", +] + +[[package]] +name = "gettext-sys" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea859ab0dd7e70ff823032b3e077d03d39c965d68c6c10775add60e999d8ee9" +dependencies = [ + "cc", + "temp-dir", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gio" version = "0.18.4" @@ -1527,7 +1737,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 6.2.2", "winapi", ] @@ -1575,7 +1785,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" dependencies = [ "libc", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -1592,7 +1802,7 @@ checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" dependencies = [ "glib-sys", "libc", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -1631,7 +1841,7 @@ dependencies = [ "gobject-sys", "libc", "pango-sys", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -1954,6 +2164,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imagesize" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" + [[package]] name = "indexmap" version = "1.9.3" @@ -1986,6 +2212,15 @@ dependencies = [ "cfb", ] +[[package]] +name = "ini_core" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a467a31a9f439b5262fa99c17084537bff57f24703d5a09a2b5c9657ec73a61" +dependencies = [ + "cfg-if", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2046,7 +2281,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -2178,6 +2413,18 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -2249,6 +2496,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[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.16" @@ -2258,6 +2511,34 @@ dependencies = [ "libc", ] +[[package]] +name = "libspa" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b8cfa2a7656627b4c92c6b9ef929433acd673d5ab3708cda1b18478ac00df4" +dependencies = [ + "bitflags 2.11.1", + "cc", + "convert_case", + "cookie-factory", + "libc", + "libspa-sys", + "nix", + "nom 8.0.0", + "system-deps 7.0.8", +] + +[[package]] +name = "libspa-sys" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901049455d2eb6decf9058235d745237952f4804bc584c5fcb41412e6adcc6e0" +dependencies = [ + "bindgen", + "cc", + "system-deps 7.0.8", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2270,6 +2551,19 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "locale_config" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d2c35b16f4483f6c26f0e4e9550717a2f6575bcd6f12a53ff0c490a94a6934" +dependencies = [ + "lazy_static", + "objc", + "objc-foundation", + "regex", + "winapi", +] + [[package]] name = "lock_api" version = "0.4.14" @@ -2294,6 +2588,15 @@ dependencies = [ "libc", ] +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + [[package]] name = "markup5ever" version = "0.38.0" @@ -2326,6 +2629,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -2505,6 +2817,15 @@ dependencies = [ "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 = "nu-ansi-term" version = "0.50.3" @@ -2580,6 +2901,26 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + [[package]] name = "objc2" version = "0.6.4" @@ -2810,6 +3151,15 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + [[package]] name = "oboe" version = "0.6.1" @@ -2943,7 +3293,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -3040,6 +3390,12 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -3057,6 +3413,34 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pipewire" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9688b89abf11d756499f7c6190711d6dbe5a3acdb30c8fbf001d6596d06a8d44" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "libc", + "libspa", + "libspa-sys", + "nix", + "once_cell", + "pipewire-sys", + "thiserror 2.0.18", +] + +[[package]] +name = "pipewire-sys" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb028afee0d6ca17020b090e3b8fa2d7de23305aef975c7e5192a5050246ea36" +dependencies = [ + "bindgen", + "libspa-sys", + "system-deps 7.0.8", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -3116,6 +3500,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -3218,6 +3611,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.39.4" @@ -3272,6 +3671,17 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -3377,6 +3787,23 @@ dependencies = [ "web-sys", ] +[[package]] +name = "resvg" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9be183ad6a216aa96f33e4c8033b0988b8b3ea6fd2359d19af5bac4643fd8e81" +dependencies = [ + "gif", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", + "zune-jpeg", +] + [[package]] name = "rfd" version = "0.16.0" @@ -3401,6 +3828,15 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + [[package]] name = "ring" version = "0.17.14" @@ -3415,6 +3851,21 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "rtrb" version = "0.3.4" @@ -3554,6 +4005,24 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.11.1", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + [[package]] name = "same-file" version = "1.0.6" @@ -3897,6 +4366,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + [[package]] name = "siphasher" version = "1.0.3" @@ -3909,6 +4387,15 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.15.1" @@ -3970,7 +4457,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -3979,9 +4466,12 @@ version = "0.2.0" dependencies = [ "base64 0.22.1", "cpal", + "dirs 5.0.1", "ebur128", "embed_plist", "flac-codec", + "freedesktop-desktop-entry", + "freedesktop-icons", "hound", "mp3lame-encoder", "objc2", @@ -3989,6 +4479,8 @@ dependencies = [ "objc2-foundation", "ogg", "opus", + "pipewire", + "resvg", "rtrb", "rubato", "serde", @@ -4020,6 +4512,15 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + [[package]] name = "string_cache" version = "0.9.0" @@ -4056,6 +4557,16 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "svgtypes" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" +dependencies = [ + "kurbo", + "siphasher", +] + [[package]] name = "swift-rs" version = "1.0.7" @@ -4321,13 +4832,26 @@ version = "6.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" dependencies = [ - "cfg-expr", + "cfg-expr 0.15.8", "heck 0.5.0", "pkg-config", "toml 0.8.2", "version-compare", ] +[[package]] +name = "system-deps" +version = "7.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" +dependencies = [ + "cfg-expr 0.20.7", + "heck 0.5.0", + "pkg-config", + "toml 1.1.2+spec-1.1.0", + "version-compare", +] + [[package]] name = "tao" version = "0.35.2" @@ -4396,6 +4920,12 @@ version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" +[[package]] +name = "target-lexicon" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" + [[package]] name = "tauri" version = "2.11.1" @@ -4405,7 +4935,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -4455,7 +4985,7 @@ checksum = "3a318b234cc2dea65f575467bafcfb76286bce228ebc3778e337d61d03213007" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -4640,7 +5170,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" dependencies = [ "base64 0.22.1", - "dirs", + "dirs 6.0.0", "flate2", "futures-util", "http", @@ -4766,6 +5296,12 @@ dependencies = [ "toml 1.1.2+spec-1.1.0", ] +[[package]] +name = "temp-dir" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83176759e9416cf81ee66cb6508dbfe9c96f20b8b56265a39917551c23c70964" + [[package]] name = "tempfile" version = "3.27.0" @@ -4878,6 +5414,32 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png 0.18.1", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -5195,7 +5757,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2", @@ -5238,6 +5800,15 @@ dependencies = [ "termcolor", ] +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + [[package]] name = "typeid" version = "1.0.3" @@ -5302,18 +5873,66 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + [[package]] name = "unicode-segmentation" version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -5351,6 +5970,34 @@ dependencies = [ "url", ] +[[package]] +name = "usvg" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d46cf96c5f498d36b7a9693bc6a7075c0bb9303189d61b2249b0dc3d309c07de" +dependencies = [ + "base64 0.22.1", + "data-url", + "flate2", + "fontdb", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree 0.21.1", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "ttf-parser", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + [[package]] name = "utf-8" version = "0.7.6" @@ -5621,7 +6268,7 @@ dependencies = [ "libc", "pkg-config", "soup3-sys", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -5669,6 +6316,12 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "winapi" version = "0.3.9" @@ -5892,6 +6545,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -5943,6 +6605,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -6000,6 +6677,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -6018,6 +6701,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -6036,6 +6725,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -6066,6 +6761,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -6084,6 +6785,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -6102,6 +6809,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -6120,6 +6833,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -6276,7 +6995,7 @@ dependencies = [ "block2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dom_query", "dpi", "dunce", @@ -6341,6 +7060,24 @@ dependencies = [ "rustix", ] +[[package]] +name = "xdg" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213b7324336b53d2414b2db8537e56544d981803139155afa84f76eeebb7a546" + +[[package]] +name = "xdg" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5" + +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + [[package]] name = "yoke" version = "0.8.2" @@ -6503,6 +7240,21 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "5.11.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 49263a9..6769186 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -53,6 +53,13 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } ts-rs = { version = "12", features = ["serde-compat"] } +[target.'cfg(target_os = "linux")'.dependencies] +pipewire = { version = "0.9", features = ["v0_3_44"] } +freedesktop-desktop-entry = "0.8" +freedesktop-icons = "0.4" +resvg = "0.47" +dirs = "5" + [target.'cfg(target_os = "macos")'.dependencies] embed_plist = "1.2" objc2 = "0.6" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 9bdffaa..cc5589e 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -6,6 +6,9 @@ "permissions": [ "core:default", "core:window:allow-start-dragging", + "core:window:allow-minimize", + "core:window:allow-toggle-maximize", + "core:window:allow-close", "opener:default", "store:default", "dialog:default", diff --git a/src-tauri/src/audio/capture/linux.rs b/src-tauri/src/audio/capture/linux.rs new file mode 100644 index 0000000..114243b --- /dev/null +++ b/src-tauri/src/audio/capture/linux.rs @@ -0,0 +1,224 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use pipewire as pw; +use pw::spa; +use pw::spa::param::audio::{AudioFormat, AudioInfoRaw}; +use pw::spa::pod::Pod; + +use crate::error::{AppError, AppResult}; + +struct Terminate; + +struct UserData { + callback: Box, +} + +pub struct Capture { + sender: pw::channel::Sender, + thread: Option>, +} + +impl Drop for Capture { + fn drop(&mut self) { + let _ = self.sender.send(Terminate); + if let Some(t) = self.thread.take() { + let _ = t.join(); + } + } +} + +impl Capture { + // Monitor of the default sink (whole-system audio). + pub fn start_system(callback: impl FnMut(&[f32]) + Send + 'static) -> AppResult { + spawn(None, true, Box::new(callback)) + } + + // Tap a specific app's output stream, found by binary/name. + pub fn start_app( + binary: &str, + callback: impl FnMut(&[f32]) + Send + 'static, + ) -> AppResult { + let serial = resolve_serial(binary)?.ok_or_else(|| { + AppError::Stream(format!("no audio stream found for {binary:?}")) + })?; + spawn(Some(serial.to_string()), false, Box::new(callback)) + } + + // Capture a real source node (microphone) by node.name. + pub fn start_source( + node_name: &str, + callback: impl FnMut(&[f32]) + Send + 'static, + ) -> AppResult { + spawn(Some(node_name.to_string()), false, Box::new(callback)) + } + + // Capture the monitor of a specific sink by its node.name. + pub fn start_sink_monitor( + sink_node_name: &str, + callback: impl FnMut(&[f32]) + Send + 'static, + ) -> AppResult { + spawn(Some(sink_node_name.to_string()), true, Box::new(callback)) + } +} + +fn spawn( + target: Option, + capture_sink: bool, + callback: Box, +) -> AppResult { + let (sender, receiver) = pw::channel::channel::(); + let thread = std::thread::spawn(move || { + if let Err(e) = run(receiver, target, capture_sink, callback) { + tracing::error!("pipewire capture: {e:?}"); + } + }); + Ok(Capture { sender, thread: Some(thread) }) +} + +fn run( + receiver: pw::channel::Receiver, + target: Option, + capture_sink: bool, + callback: Box, +) -> Result<(), pw::Error> { + let mainloop = pw::main_loop::MainLoopRc::new(None)?; + let context = pw::context::ContextRc::new(&mainloop, None)?; + let core = context.connect_rc(None)?; + + let _stopper = { + let ml = mainloop.clone(); + receiver.attach(mainloop.loop_(), move |_| ml.quit()) + }; + + let mut props = pw::properties::properties! { + *pw::keys::MEDIA_TYPE => "Audio", + *pw::keys::MEDIA_CATEGORY => "Capture", + *pw::keys::MEDIA_ROLE => "Music", + }; + if capture_sink { + props.insert(*pw::keys::STREAM_CAPTURE_SINK, "true"); + } + if let Some(target) = target { + props.insert(*pw::keys::TARGET_OBJECT, target); + } + + let stream = pw::stream::StreamRc::new(core.clone(), "splitwave-capture", props)?; + let user_data = UserData { callback }; + + let _listener = stream + .add_local_listener_with_user_data(user_data) + .process(|stream, user_data| { + let Some(mut buffer) = stream.dequeue_buffer() else { return }; + let datas = buffer.datas_mut(); + if datas.is_empty() { + return; + } + let data = &mut datas[0]; + let offset = data.chunk().offset() as usize; + let size = data.chunk().size() as usize; + if size == 0 { + return; + } + let Some(raw) = data.data() else { return }; + let bytes = &raw[offset..offset + size]; + let n = size / std::mem::size_of::(); + // F32LE is negotiated below and PipeWire aligns its buffers. + let samples = unsafe { std::slice::from_raw_parts(bytes.as_ptr() as *const f32, n) }; + (user_data.callback)(samples); + }) + .register()?; + + let mut audio_info = AudioInfoRaw::new(); + audio_info.set_format(AudioFormat::F32LE); + audio_info.set_rate(48_000); + audio_info.set_channels(2); + + let obj = spa::pod::Object { + type_: spa::utils::SpaTypes::ObjectParamFormat.as_raw(), + id: spa::param::ParamType::EnumFormat.as_raw(), + properties: audio_info.into(), + }; + let values: Vec = spa::pod::serialize::PodSerializer::serialize( + std::io::Cursor::new(Vec::new()), + &spa::pod::Value::Object(obj), + ) + .unwrap() + .0 + .into_inner(); + let mut params = [Pod::from_bytes(&values).unwrap()]; + + stream.connect( + spa::utils::Direction::Input, + None, + pw::stream::StreamFlags::AUTOCONNECT | pw::stream::StreamFlags::MAP_BUFFERS, + &mut params, + )?; + + mainloop.run(); + Ok(()) +} + +// TARGET_OBJECT takes the node's object.serial, so resolve it from the binary +// reported on the app's output stream. +fn resolve_serial(binary: &str) -> AppResult> { + let binary = binary.to_string(); + std::thread::spawn(move || serial_snapshot(&binary)) + .join() + .map_err(|_| AppError::Host("pipewire serial lookup thread panicked".into()))? +} + +fn serial_snapshot(binary: &str) -> AppResult> { + let mainloop = pw::main_loop::MainLoopRc::new(None).map_err(pw_err)?; + let context = pw::context::ContextRc::new(&mainloop, None).map_err(pw_err)?; + let core = context.connect_rc(None).map_err(pw_err)?; + let registry = core.get_registry_rc().map_err(pw_err)?; + + let found: Rc>> = Rc::new(RefCell::new(None)); + let found_cb = found.clone(); + let want = binary.to_string(); + + let _reg = registry + .add_listener_local() + .global(move |global| { + if global.type_ != pw::types::ObjectType::Node { + return; + } + let Some(props) = &global.props else { return }; + if props.get("media.class") != Some("Stream/Output/Audio") { + return; + } + // Match the same fields the app list builds bundle_id from: binary + // first, then application.name, then node.name. + let want = want.as_str(); + let matches = props.get("application.process.binary") == Some(want) + || props.get("application.name") == Some(want) + || props.get("node.name") == Some(want); + if !matches { + return; + } + if let Some(serial) = props.get("object.serial").and_then(|s| s.parse().ok()) { + found_cb.borrow_mut().get_or_insert(serial); + } + }) + .register(); + + let pending = core.sync(0).map_err(pw_err)?; + let ml = mainloop.clone(); + let _core = core + .add_listener_local() + .done(move |id, seq| { + if id == 0 && seq == pending { + ml.quit(); + } + }) + .register(); + + mainloop.run(); + let serial = *found.borrow(); + Ok(serial) +} + +fn pw_err(e: impl std::fmt::Display) -> AppError { + AppError::Host(format!("pipewire: {e}")) +} diff --git a/src-tauri/src/audio/sck_capture.rs b/src-tauri/src/audio/capture/macos.rs similarity index 100% rename from src-tauri/src/audio/sck_capture.rs rename to src-tauri/src/audio/capture/macos.rs diff --git a/src-tauri/src/audio/capture/mod.rs b/src-tauri/src/audio/capture/mod.rs new file mode 100644 index 0000000..a02d402 --- /dev/null +++ b/src-tauri/src/audio/capture/mod.rs @@ -0,0 +1,9 @@ +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "macos")] +pub use macos::SckCapture as Capture; + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "linux")] +pub use linux::Capture; diff --git a/src-tauri/src/audio/device.rs b/src-tauri/src/audio/device.rs deleted file mode 100644 index 6baaa6e..0000000 --- a/src-tauri/src/audio/device.rs +++ /dev/null @@ -1,192 +0,0 @@ -use std::collections::HashSet; - -use cpal::traits::{DeviceTrait, HostTrait}; -use serde::{Deserialize, Serialize}; - -use crate::error::{AppError, AppResult}; - -#[cfg(target_os = "macos")] -use crate::audio::macos_hal; - -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum DeviceKind { - Input, - Output, -} - -#[derive(Debug, Clone, Serialize)] -pub struct DeviceInfo { - pub id: String, - pub name: String, - pub kind: DeviceKind, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct NativeDeviceInfo { - pub sample_rate: u32, - pub channels: u16, - pub sample_format: &'static str, -} - -#[cfg(target_os = "macos")] -pub fn device_info(kind: DeviceKind, name: &str) -> AppResult { - let hal = match kind { - DeviceKind::Input => macos_hal::find_input_device(name), - DeviceKind::Output => macos_hal::find_output_device(name), - } - .ok_or_else(|| AppError::Device(format!("device not found: {name}")))?; - let channels: u16 = hal - .channels - .try_into() - .map_err(|_| AppError::Device(format!("device {name:?} has {} channels", hal.channels)))?; - Ok(NativeDeviceInfo { - sample_rate: hal.sample_rate, - channels, - sample_format: "f32", - }) -} - -#[cfg(not(target_os = "macos"))] -pub fn device_info(kind: DeviceKind, name: &str) -> AppResult { - let device = find(kind, name)?; - let configs: Vec = match kind { - DeviceKind::Input => device - .supported_input_configs() - .map_err(|e| AppError::Device(e.to_string()))? - .collect(), - DeviceKind::Output => device - .supported_output_configs() - .map_err(|e| AppError::Device(e.to_string()))? - .collect(), - }; - let best = configs - .into_iter() - .max_by_key(|c| c.max_sample_rate().0) - .ok_or_else(|| AppError::Device("device exposes no configs".into()))? - .with_max_sample_rate(); - Ok(NativeDeviceInfo { - sample_rate: best.sample_rate().0, - channels: best.channels(), - sample_format: "f32", - }) -} - -pub fn list_inputs() -> AppResult> { - #[cfg(target_os = "macos")] - { - return Ok(unique_named( - macos_hal::list_input_devices().into_iter().map(|d| d.name).collect(), - DeviceKind::Input, - )); - } - #[cfg(not(target_os = "macos"))] - { - let host = cpal::default_host(); - let devices = host - .input_devices() - .map_err(|e| AppError::Host(e.to_string()))?; - Ok(collect_cpal(devices, DeviceKind::Input)) - } -} - -pub fn list_outputs() -> AppResult> { - #[cfg(target_os = "macos")] - { - return Ok(unique_named( - macos_hal::list_output_devices().into_iter().map(|d| d.name).collect(), - DeviceKind::Output, - )); - } - #[cfg(not(target_os = "macos"))] - { - let host = cpal::default_host(); - let devices = host - .output_devices() - .map_err(|e| AppError::Host(e.to_string()))?; - Ok(collect_cpal(devices, DeviceKind::Output)) - } -} - -#[cfg(not(target_os = "macos"))] -fn collect_cpal>(devices: I, kind: DeviceKind) -> Vec { - devices - .filter_map(|d| { - let name = d.name().ok()?; - Some(DeviceInfo { - id: name.clone(), - name, - kind, - }) - }) - .collect() -} - -fn unique_named(names: Vec, kind: DeviceKind) -> Vec { - let mut seen = HashSet::new(); - names - .into_iter() - .filter(|n| seen.insert(n.clone())) - .map(|name| DeviceInfo { - id: name.clone(), - name, - kind, - }) - .collect() -} - -// `host.devices()` returns is_default=false -> cpal uses HalOutput bound to a -// specific AudioDeviceID. `default_*_device` returns is_default=true -> cpal -// uses DefaultOutput which silently follows the system default when it changes. -pub fn find(kind: DeviceKind, id: &str) -> AppResult { - let host = cpal::default_host(); - let matches: Vec = host - .devices() - .map_err(|e| AppError::Host(e.to_string()))? - .filter(|d| d.name().map(|n| n == id).unwrap_or(false)) - .collect(); - if matches.len() < 2 { - return matches - .into_iter() - .next() - .ok_or_else(|| AppError::Device(format!("device not found: {id}"))); - } - // Bluetooth headsets expose same-named input- and output-scope devices; pick the one valid in the requested scope. - let in_scope = |d: &cpal::Device| { - match kind { - DeviceKind::Input => d.supported_input_configs().map(|mut c| c.next().is_some()), - DeviceKind::Output => d.supported_output_configs().map(|mut c| c.next().is_some()), - } - .unwrap_or(false) - }; - matches - .iter() - .find(|d| in_scope(d)) - .or(matches.first()) - .cloned() - .ok_or_else(|| AppError::Device(format!("device not found: {id}"))) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn enumerates_inputs_without_panicking() { - let inputs = list_inputs().expect("inputs"); - println!("found {} input device(s):", inputs.len()); - for d in &inputs { - println!(" - {}", d.name); - } - } - - #[test] - fn enumerates_outputs_without_panicking() { - let outputs = list_outputs().expect("outputs"); - println!("found {} output device(s):", outputs.len()); - for d in &outputs { - println!(" - {}", d.name); - } - } -} diff --git a/src-tauri/src/audio/device/linux.rs b/src-tauri/src/audio/device/linux.rs new file mode 100644 index 0000000..a1078b5 --- /dev/null +++ b/src-tauri/src/audio/device/linux.rs @@ -0,0 +1,37 @@ +use crate::audio::pw_enum::nodes_by_class; +use crate::error::AppResult; + +use super::{DeviceInfo, DeviceKind, NativeDeviceInfo}; + +pub fn device_info(_kind: DeviceKind, _name: &str) -> AppResult { + // PipeWire converts to whatever we request, so the graph always runs at + // 48 kHz stereo f32. + Ok(NativeDeviceInfo { + sample_rate: 48_000, + channels: 2, + sample_format: "f32", + }) +} + +pub fn list_inputs() -> AppResult> { + let mut out: Vec = nodes_by_class("Audio/Source")? + .into_iter() + .map(|n| DeviceInfo { id: n.name, name: n.description, kind: DeviceKind::Input }) + .collect(); + // Every sink exposes a monitor we can record; offer them as inputs too. + for sink in nodes_by_class("Audio/Sink")? { + out.push(DeviceInfo { + id: format!("monitor:{}", sink.name), + name: format!("{} (Monitor)", sink.description), + kind: DeviceKind::Input, + }); + } + Ok(out) +} + +pub fn list_outputs() -> AppResult> { + Ok(nodes_by_class("Audio/Sink")? + .into_iter() + .map(|n| DeviceInfo { id: n.name, name: n.description, kind: DeviceKind::Output }) + .collect()) +} diff --git a/src-tauri/src/audio/device/macos.rs b/src-tauri/src/audio/device/macos.rs new file mode 100644 index 0000000..e90a2ab --- /dev/null +++ b/src-tauri/src/audio/device/macos.rs @@ -0,0 +1,84 @@ +use std::collections::HashSet; + +use cpal::traits::{DeviceTrait, HostTrait}; + +use crate::audio::macos_hal; +use crate::error::{AppError, AppResult}; + +use super::{DeviceInfo, DeviceKind, NativeDeviceInfo}; + +pub fn device_info(kind: DeviceKind, name: &str) -> AppResult { + let hal = match kind { + DeviceKind::Input => macos_hal::find_input_device(name), + DeviceKind::Output => macos_hal::find_output_device(name), + } + .ok_or_else(|| AppError::Device(format!("device not found: {name}")))?; + let channels: u16 = hal + .channels + .try_into() + .map_err(|_| AppError::Device(format!("device {name:?} has {} channels", hal.channels)))?; + Ok(NativeDeviceInfo { + sample_rate: hal.sample_rate, + channels, + sample_format: "f32", + }) +} + +pub fn list_inputs() -> AppResult> { + Ok(unique_named( + macos_hal::list_input_devices().into_iter().map(|d| d.name).collect(), + DeviceKind::Input, + )) +} + +pub fn list_outputs() -> AppResult> { + Ok(unique_named( + macos_hal::list_output_devices().into_iter().map(|d| d.name).collect(), + DeviceKind::Output, + )) +} + +fn unique_named(names: Vec, kind: DeviceKind) -> Vec { + let mut seen = HashSet::new(); + names + .into_iter() + .filter(|n| seen.insert(n.clone())) + .map(|name| DeviceInfo { + id: name.clone(), + name, + kind, + }) + .collect() +} + +// `host.devices()` returns is_default=false -> cpal uses HalOutput bound to a +// specific AudioDeviceID. `default_*_device` returns is_default=true -> cpal +// uses DefaultOutput which silently follows the system default when it changes. +pub fn find(kind: DeviceKind, id: &str) -> AppResult { + let host = cpal::default_host(); + let matches: Vec = host + .devices() + .map_err(|e| AppError::Host(e.to_string()))? + .filter(|d| d.name().map(|n| n == id).unwrap_or(false)) + .collect(); + if matches.len() < 2 { + return matches + .into_iter() + .next() + .ok_or_else(|| AppError::Device(format!("device not found: {id}"))); + } + // Bluetooth headsets expose same-named input- and output-scope devices; pick the one valid in the requested scope. + let in_scope = |d: &cpal::Device| { + match kind { + DeviceKind::Input => d.supported_input_configs().map(|mut c| c.next().is_some()), + DeviceKind::Output => d.supported_output_configs().map(|mut c| c.next().is_some()), + } + .unwrap_or(false) + }; + matches + .iter() + .find(|d| in_scope(d)) + .or(matches.first()) + .cloned() + .ok_or_else(|| AppError::Device(format!("device not found: {id}"))) +} diff --git a/src-tauri/src/audio/device/mod.rs b/src-tauri/src/audio/device/mod.rs new file mode 100644 index 0000000..903a35c --- /dev/null +++ b/src-tauri/src/audio/device/mod.rs @@ -0,0 +1,56 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DeviceKind { + Input, + Output, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DeviceInfo { + pub id: String, + pub name: String, + pub kind: DeviceKind, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NativeDeviceInfo { + pub sample_rate: u32, + pub channels: u16, + pub sample_format: &'static str, +} + +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "macos")] +pub use macos::{device_info, find, list_inputs, list_outputs}; + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "linux")] +pub use linux::{device_info, list_inputs, list_outputs}; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn enumerates_inputs_without_panicking() { + let inputs = list_inputs().expect("inputs"); + println!("found {} input device(s):", inputs.len()); + for d in &inputs { + println!(" - {}", d.name); + } + } + + #[test] + fn enumerates_outputs_without_panicking() { + let outputs = list_outputs().expect("outputs"); + println!("found {} output device(s):", outputs.len()); + for d in &outputs { + println!(" - {}", d.name); + } + } +} diff --git a/src-tauri/src/audio/encoders/mod.rs b/src-tauri/src/audio/encoders/mod.rs index 2c54739..67f51dd 100644 --- a/src-tauri/src/audio/encoders/mod.rs +++ b/src-tauri/src/audio/encoders/mod.rs @@ -60,7 +60,7 @@ pub fn build_encoder( { Ok(Box::new(AacRecorder::create(path, sample_rate, bitrate)?)) } - #[cfg(not(target_os = "macos"))] + #[cfg(target_os = "linux")] { let _ = (path, sample_rate, bitrate); Err(crate::error::AppError::Stream( diff --git a/src-tauri/src/audio/macos_hal.rs b/src-tauri/src/audio/macos_hal.rs index 7518acd..d734bff 100644 --- a/src-tauri/src/audio/macos_hal.rs +++ b/src-tauri/src/audio/macos_hal.rs @@ -6,7 +6,7 @@ //! workflows need the full device list, so we go straight to the HAL and use //! cpal only for the stream once the user selects a device. //! -//! The FFI surface is intentionally tiny — no `coreaudio-sys` dependency. +//! The FFI surface is intentionally tiny no `coreaudio-sys` dependency. use std::ffi::c_void; use std::mem; diff --git a/src-tauri/src/audio/mod.rs b/src-tauri/src/audio/mod.rs index 93451a9..b43dae2 100644 --- a/src-tauri/src/audio/mod.rs +++ b/src-tauri/src/audio/mod.rs @@ -2,17 +2,22 @@ pub mod clock; pub mod device; pub mod effects; pub mod engine; +#[cfg(target_os = "macos")] pub mod format; pub mod graph; +pub mod capture; pub mod input_bridge; #[cfg(target_os = "macos")] pub mod macos_hal; pub mod permission; pub mod encoders; +#[cfg(target_os = "linux")] +pub mod playback; +#[cfg(target_os = "linux")] +pub mod pw_enum; pub mod pipeline; pub mod resample; -#[cfg(target_os = "macos")] -pub mod sck_capture; pub mod streams; pub mod system_audio; pub mod virtual_device; +pub mod volume; diff --git a/src-tauri/src/audio/permission.rs b/src-tauri/src/audio/permission.rs index df7c891..ec583ce 100644 --- a/src-tauri/src/audio/permission.rs +++ b/src-tauri/src/audio/permission.rs @@ -1,5 +1,6 @@ #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "lowercase")] +#[cfg_attr(target_os = "linux", allow(dead_code))] pub enum PermissionState { Allowed, Denied, @@ -24,7 +25,7 @@ pub fn screen_recording() -> PermissionState { } } -#[cfg(not(target_os = "macos"))] +#[cfg(target_os = "linux")] pub fn screen_recording() -> PermissionState { PermissionState::Unknown } diff --git a/src-tauri/src/audio/pipeline/input.rs b/src-tauri/src/audio/pipeline/input.rs deleted file mode 100644 index 08c7e62..0000000 --- a/src-tauri/src/audio/pipeline/input.rs +++ /dev/null @@ -1,191 +0,0 @@ -use std::path::PathBuf; -use std::sync::atomic::AtomicBool; -use std::sync::Arc; - -use serde_json::json; -use tauri::{AppHandle, Emitter}; -use tracing::info; - -use crate::audio::device::{self, DeviceKind}; -use crate::audio::graph::{InputSpec, ValidInput}; -use crate::audio::input_bridge::BroadcastRx; -use crate::audio::streams; -#[cfg(not(target_os = "macos"))] -use crate::error::AppError; -use crate::error::AppResult; - -use super::file_reader::{probe_audio_file, start_audio_file_reader, AudioFileReader}; -use super::STATE_EVENT; - -/// ScreenCaptureKit always delivers interleaved stereo by configuration. -const SCK_CHANNELS: usize = 2; -/// ScreenCaptureKit sample rate request. 48 kHz is macOS's universal audio -/// rate and matches AVAudioSession / CoreAudio's preferred output, so no -/// resampling happens on the SCK delivery side. -const SCK_SR: u32 = 48_000; - -/// RAII handle held only for its `Drop` -- stops the cpal stream, tears -/// down the SCStream, or signals + joins the file reader thread. -#[allow(dead_code)] -pub(super) enum InputHandle { - Cpal(cpal::Stream), - #[cfg(target_os = "macos")] - Sck(crate::audio::sck_capture::SckCapture), - AudioFile(AudioFileReader), -} - -pub(super) enum ResolvedInput { - Cpal { - device: cpal::Device, - config: cpal::StreamConfig, - sample_format: cpal::SampleFormat, - src_channels: usize, - sample_rate: u32, - }, - SystemAudio { - sample_rate: u32, - exclude_current_app: bool, - }, - AppAudio { - sample_rate: u32, - bundle_id: String, - }, - AudioFile { - sample_rate: u32, - path: PathBuf, - }, -} - -impl ResolvedInput { - pub(super) fn sample_rate(&self) -> u32 { - match self { - ResolvedInput::Cpal { sample_rate, .. } => *sample_rate, - ResolvedInput::SystemAudio { sample_rate, .. } => *sample_rate, - ResolvedInput::AppAudio { sample_rate, .. } => *sample_rate, - ResolvedInput::AudioFile { sample_rate, .. } => *sample_rate, - } - } -} - -pub(super) fn resolve_input(inp: &ValidInput) -> AppResult { - match &inp.spec { - InputSpec::Microphone { device_id } => { - let device = device::find(DeviceKind::Input, device_id)?; - let native = super::native_config(DeviceKind::Input, &device, device_id)?; - Ok(ResolvedInput::Cpal { - device, - config: native.config, - sample_format: native.sample_format, - src_channels: native.channels as usize, - sample_rate: native.sample_rate, - }) - } - InputSpec::SystemAudio { - exclude_current_app, - } => Ok(ResolvedInput::SystemAudio { - sample_rate: SCK_SR, - exclude_current_app: *exclude_current_app, - }), - InputSpec::AppAudio { bundle_id } => Ok(ResolvedInput::AppAudio { - sample_rate: SCK_SR, - bundle_id: bundle_id.clone(), - }), - InputSpec::AudioFile { file_path } => { - let path = PathBuf::from(file_path); - let info = probe_audio_file(&path)?; - Ok(ResolvedInput::AudioFile { - sample_rate: info.sample_rate, - path, - }) - } - } -} - -pub(super) fn start_input_stream( - node_id: &str, - resolved: ResolvedInput, - bridge: BroadcastRx, - paused: Option>, - app: &AppHandle, -) -> AppResult { - let app_err = app.clone(); - let err_cb = move |e: cpal::StreamError| { - let _ = app_err.emit( - STATE_EVENT, - json!({ "kind": "error", "message": format!("input: {e}") }), - ); - }; - - match resolved { - ResolvedInput::Cpal { - device, - config, - sample_format, - src_channels, - .. - } => { - let stream = streams::build_input_stream( - &device, - &config, - sample_format, - src_channels, - bridge, - None, - err_cb, - )?; - Ok(InputHandle::Cpal(stream)) - } - #[cfg(target_os = "macos")] - ResolvedInput::SystemAudio { - sample_rate, - exclude_current_app, - } => { - info!( - sample_rate, - exclude_current_app, "starting system-audio capture (ScreenCaptureKit)" - ); - let capture = crate::audio::sck_capture::SckCapture::start_system( - exclude_current_app, - sample_rate, - SCK_CHANNELS as u32, - bridge, - )?; - Ok(InputHandle::Sck(capture)) - } - #[cfg(target_os = "macos")] - ResolvedInput::AppAudio { - sample_rate, - bundle_id, - } => { - info!(sample_rate, %bundle_id, "starting app-audio capture (ScreenCaptureKit)"); - let capture = crate::audio::sck_capture::SckCapture::start_app( - &bundle_id, - sample_rate, - SCK_CHANNELS as u32, - bridge, - )?; - Ok(InputHandle::Sck(capture)) - } - #[cfg(not(target_os = "macos"))] - ResolvedInput::SystemAudio { .. } | ResolvedInput::AppAudio { .. } => { - drop(bridge); - Err(AppError::Stream( - "System/App Audio capture is only supported on macOS".into(), - )) - } - ResolvedInput::AudioFile { path, .. } => { - // Loop is a runtime atomic, not in InputSpec; frontend syncs it - // via `set_audio_file_loop` after pipeline start. - let paused_arc = paused.unwrap_or_else(|| Arc::new(AtomicBool::new(false))); - let reader = start_audio_file_reader( - node_id.to_string(), - path, - bridge, - false, - paused_arc, - app.clone(), - )?; - Ok(InputHandle::AudioFile(reader)) - } - } -} diff --git a/src-tauri/src/audio/pipeline/input/linux.rs b/src-tauri/src/audio/pipeline/input/linux.rs new file mode 100644 index 0000000..f954813 --- /dev/null +++ b/src-tauri/src/audio/pipeline/input/linux.rs @@ -0,0 +1,78 @@ +use std::sync::atomic::AtomicBool; +use std::sync::Arc; + +use tauri::AppHandle; +use tracing::info; + +use crate::audio::graph::{InputSpec, ValidInput}; +use crate::audio::input_bridge::BroadcastRx; +use crate::error::AppResult; + +use super::{resolve_audio_file, start_audio_file, InputHandle, ResolvedInput, SCK_SR}; + +pub(in crate::audio::pipeline) fn resolve_input(inp: &ValidInput) -> AppResult { + match &inp.spec { + InputSpec::Microphone { device_id } => Ok(ResolvedInput::PwSource { + node_id: device_id.clone(), + sample_rate: 48_000, + }), + InputSpec::SystemAudio { + exclude_current_app, + } => Ok(ResolvedInput::SystemAudio { + sample_rate: SCK_SR, + exclude_current_app: *exclude_current_app, + }), + InputSpec::AppAudio { bundle_id } => Ok(ResolvedInput::AppAudio { + sample_rate: SCK_SR, + bundle_id: bundle_id.clone(), + }), + InputSpec::AudioFile { file_path } => resolve_audio_file(file_path), + } +} + +pub(in crate::audio::pipeline) fn start_input_stream( + node_id: &str, + resolved: ResolvedInput, + bridge: BroadcastRx, + paused: Option>, + app: &AppHandle, +) -> AppResult { + match resolved { + ResolvedInput::PwSource { node_id, .. } => { + let mut bridge = bridge; + let cb = move |samples: &[f32]| { + bridge.apply_commands(); + bridge.broadcast(samples); + }; + let capture = if let Some(sink) = node_id.strip_prefix("monitor:") { + info!(sink, "starting microphone capture (PipeWire sink monitor)"); + crate::audio::capture::Capture::start_sink_monitor(sink, cb)? + } else { + info!(%node_id, "starting microphone capture (PipeWire source)"); + crate::audio::capture::Capture::start_source(&node_id, cb)? + }; + Ok(InputHandle::Capture(capture)) + } + ResolvedInput::SystemAudio { .. } => { + info!("starting system-audio capture (PipeWire sink monitor)"); + let mut bridge = bridge; + let capture = crate::audio::capture::Capture::start_system(move |samples| { + bridge.apply_commands(); + bridge.broadcast(samples); + })?; + Ok(InputHandle::Capture(capture)) + } + ResolvedInput::AppAudio { bundle_id, .. } => { + info!(%bundle_id, "starting app-audio capture (PipeWire tap)"); + let mut bridge = bridge; + let capture = crate::audio::capture::Capture::start_app(&bundle_id, move |samples| { + bridge.apply_commands(); + bridge.broadcast(samples); + })?; + Ok(InputHandle::Capture(capture)) + } + ResolvedInput::AudioFile { path, .. } => { + start_audio_file(node_id, path, bridge, paused, app) + } + } +} diff --git a/src-tauri/src/audio/pipeline/input/macos.rs b/src-tauri/src/audio/pipeline/input/macos.rs new file mode 100644 index 0000000..bab96cf --- /dev/null +++ b/src-tauri/src/audio/pipeline/input/macos.rs @@ -0,0 +1,112 @@ +use std::sync::atomic::AtomicBool; +use std::sync::Arc; + +use serde_json::json; +use tauri::{AppHandle, Emitter}; +use tracing::info; + +use crate::audio::device::{self, DeviceKind}; +use crate::audio::graph::{InputSpec, ValidInput}; +use crate::audio::input_bridge::BroadcastRx; +use crate::audio::streams; +use crate::error::AppResult; + +use super::super::native::native_config; +use super::super::STATE_EVENT; +use super::{resolve_audio_file, start_audio_file, InputHandle, ResolvedInput, SCK_SR}; + +/// ScreenCaptureKit always delivers interleaved stereo by configuration. +const SCK_CHANNELS: usize = 2; + +pub(in crate::audio::pipeline) fn resolve_input(inp: &ValidInput) -> AppResult { + match &inp.spec { + InputSpec::Microphone { device_id } => { + let device = device::find(DeviceKind::Input, device_id)?; + let native = native_config(DeviceKind::Input, &device, device_id)?; + Ok(ResolvedInput::Cpal { + device, + config: native.config, + sample_format: native.sample_format, + src_channels: native.channels as usize, + sample_rate: native.sample_rate, + }) + } + InputSpec::SystemAudio { exclude_current_app } => Ok(ResolvedInput::SystemAudio { + sample_rate: SCK_SR, + exclude_current_app: *exclude_current_app, + }), + InputSpec::AppAudio { bundle_id } => Ok(ResolvedInput::AppAudio { + sample_rate: SCK_SR, + bundle_id: bundle_id.clone(), + }), + InputSpec::AudioFile { file_path } => resolve_audio_file(file_path), + } +} + +pub(in crate::audio::pipeline) fn start_input_stream( + node_id: &str, + resolved: ResolvedInput, + bridge: BroadcastRx, + paused: Option>, + app: &AppHandle, +) -> AppResult { + match resolved { + ResolvedInput::Cpal { + device, + config, + sample_format, + src_channels, + .. + } => { + let app_err = app.clone(); + let err_cb = move |e: cpal::StreamError| { + let _ = app_err.emit( + STATE_EVENT, + json!({ "kind": "error", "message": format!("input: {e}") }), + ); + }; + let stream = streams::build_input_stream( + &device, + &config, + sample_format, + src_channels, + bridge, + None, + err_cb, + )?; + Ok(InputHandle::Cpal(stream)) + } + ResolvedInput::SystemAudio { + sample_rate, + exclude_current_app, + } => { + info!( + sample_rate, + exclude_current_app, "starting system-audio capture (ScreenCaptureKit)" + ); + let capture = crate::audio::capture::Capture::start_system( + exclude_current_app, + sample_rate, + SCK_CHANNELS as u32, + bridge, + )?; + Ok(InputHandle::Capture(capture)) + } + ResolvedInput::AppAudio { + sample_rate, + bundle_id, + } => { + info!(sample_rate, %bundle_id, "starting app-audio capture (ScreenCaptureKit)"); + let capture = crate::audio::capture::Capture::start_app( + &bundle_id, + sample_rate, + SCK_CHANNELS as u32, + bridge, + )?; + Ok(InputHandle::Capture(capture)) + } + ResolvedInput::AudioFile { path, .. } => { + start_audio_file(node_id, path, bridge, paused, app) + } + } +} diff --git a/src-tauri/src/audio/pipeline/input/mod.rs b/src-tauri/src/audio/pipeline/input/mod.rs new file mode 100644 index 0000000..7b6498c --- /dev/null +++ b/src-tauri/src/audio/pipeline/input/mod.rs @@ -0,0 +1,111 @@ +use std::path::PathBuf; +use std::sync::atomic::AtomicBool; +use std::sync::Arc; + +use tauri::AppHandle; + +use crate::audio::input_bridge::BroadcastRx; +use crate::error::AppResult; + +use super::file_reader::{probe_audio_file, start_audio_file_reader, AudioFileReader}; + +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "macos")] +use macos as platform; +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "linux")] +use linux as platform; + +pub(super) use platform::{resolve_input, start_input_stream}; + +/// ScreenCaptureKit (macOS) and PipeWire (Linux) both deliver 48 kHz, matching +/// the device side so no resampling happens on capture delivery. +pub(super) const SCK_SR: u32 = 48_000; + +/// RAII handle held only for its `Drop` -- stops the cpal stream, tears +/// down the capture, or signals + joins the file reader thread. +#[allow(dead_code)] +pub(super) enum InputHandle { + #[cfg(target_os = "macos")] + Cpal(cpal::Stream), + Capture(crate::audio::capture::Capture), + AudioFile(AudioFileReader), +} + +pub(super) enum ResolvedInput { + #[cfg(target_os = "macos")] + Cpal { + device: cpal::Device, + config: cpal::StreamConfig, + sample_format: cpal::SampleFormat, + src_channels: usize, + sample_rate: u32, + }, + #[cfg(target_os = "linux")] + PwSource { + node_id: String, + sample_rate: u32, + }, + SystemAudio { + sample_rate: u32, + // PipeWire sink-monitor capture can't exclude our own output. + #[cfg_attr(target_os = "linux", allow(dead_code))] + exclude_current_app: bool, + }, + AppAudio { + sample_rate: u32, + bundle_id: String, + }, + AudioFile { + sample_rate: u32, + path: PathBuf, + }, +} + +impl ResolvedInput { + pub(super) fn sample_rate(&self) -> u32 { + match self { + #[cfg(target_os = "macos")] + ResolvedInput::Cpal { sample_rate, .. } => *sample_rate, + #[cfg(target_os = "linux")] + ResolvedInput::PwSource { sample_rate, .. } => *sample_rate, + ResolvedInput::SystemAudio { sample_rate, .. } => *sample_rate, + ResolvedInput::AppAudio { sample_rate, .. } => *sample_rate, + ResolvedInput::AudioFile { sample_rate, .. } => *sample_rate, + } + } +} + +/// Shared file probe -- both platforms resolve audio files identically. +pub(super) fn resolve_audio_file(file_path: &str) -> AppResult { + let path = PathBuf::from(file_path); + let info = probe_audio_file(&path)?; + Ok(ResolvedInput::AudioFile { + sample_rate: info.sample_rate, + path, + }) +} + +/// Shared file-reader start -- both platforms drive audio files identically. +pub(super) fn start_audio_file( + node_id: &str, + path: PathBuf, + bridge: BroadcastRx, + paused: Option>, + app: &AppHandle, +) -> AppResult { + // Loop is a runtime atomic, not in InputSpec; frontend syncs it + // via `set_audio_file_loop` after pipeline start. + let paused_arc = paused.unwrap_or_else(|| Arc::new(AtomicBool::new(false))); + let reader = start_audio_file_reader( + node_id.to_string(), + path, + bridge, + false, + paused_arc, + app.clone(), + )?; + Ok(InputHandle::AudioFile(reader)) +} diff --git a/src-tauri/src/audio/pipeline/mod.rs b/src-tauri/src/audio/pipeline/mod.rs index ad0ca63..e92acc4 100644 --- a/src-tauri/src/audio/pipeline/mod.rs +++ b/src-tauri/src/audio/pipeline/mod.rs @@ -20,7 +20,6 @@ use rtrb::Producer; use tauri::AppHandle; use tracing::{info, warn}; -use crate::audio::device::DeviceKind; use crate::audio::effects::{EffectControl, EffectRegistry, GrHandle, LufsHandle, MeterHandle, WaveformHandle}; use crate::audio::graph::{EffectSpec, InputSpec, OutputSpec, RecordingFormat, ValidGraph}; use crate::audio::input_bridge::{broadcast_channel, BroadcastTx}; @@ -30,6 +29,8 @@ mod dag; mod file_reader; mod input; mod meter; +#[cfg(target_os = "macos")] +mod native; mod output; mod sig; mod worker; @@ -44,6 +45,7 @@ use output::{ use sig::{compute_output_sig, OutputSig, MONITOR_KEY}; use worker::WorkerCtrl; +#[cfg(target_os = "macos")] pub(super) const STATE_EVENT: &str = "audio://state"; /// Long-lived audio runtime. Owns every cpal/SCK stream, every DspWorker @@ -416,8 +418,9 @@ impl ActivePipeline { } } - // Bluetooth devices used as both Mic and Speaker get forced into HFP - // (16/24 kHz mono), conflicting with the A2DP profile we resolved. + // A Bluetooth device used as both Mic and Speaker gets forced into the + // HFP profile (16/24 kHz mono), conflicting with the A2DP config we + // resolved -- the OS picks one profile for the whole device. { let mic_devices: HashSet<&str> = graph .inputs @@ -432,7 +435,7 @@ impl ActivePipeline { if mic_devices.contains(device_id.as_str()) { warn!( device = %device_id, - "speaker device is also used as microphone -- macOS will force HFP profile" + "speaker device is also used as microphone -- the OS may force a reduced Bluetooth (HFP) profile" ); } } @@ -775,98 +778,4 @@ impl ActivePipeline { } } -// ---------- native config resolution ---------- -// -// We never ask cpal "what is this device's default/supported config?": -// - `default_*_config` reads the *currently active* CoreAudio stream format, -// which is absent for non-default routes (built-in speakers while AirPods -// are connected) -> "Invalid property value". -// - `supported_*_configs` reads `kAudioStreamPropertyAvailableVirtualFormats`, -// which is also empty for those same non-default routes. -// -// AUHAL (cpal's underlying output unit on macOS) does NOT need to be told the -// device's "current" format up front -- it accepts whatever StreamConfig we -// hand it and asks CoreAudio to convert. So we read the device's nominal -// sample rate and channel count *directly* from CoreAudio HAL (which works -// regardless of routing state) and feed those into `build_*_stream`. -// -// Sample format is always `f32` -- the universal macOS audio type and the -// internal pipeline format. - -pub(super) struct NativeConfig { - pub config: cpal::StreamConfig, - pub sample_format: cpal::SampleFormat, - pub sample_rate: u32, - pub channels: u16, -} - -#[cfg(target_os = "macos")] -pub(super) fn native_config( - kind: DeviceKind, - _device: &cpal::Device, - name: &str, -) -> AppResult { - use crate::audio::macos_hal; - let hal = match kind { - DeviceKind::Input => macos_hal::find_input_device(name), - DeviceKind::Output => macos_hal::find_output_device(name), - } - .ok_or_else(|| { - AppError::Device(format!( - "{kind:?} device {name:?} disappeared between enumeration and open" - )) - })?; - - let channels: u16 = hal - .channels - .try_into() - .map_err(|_| AppError::Device(format!("device {name:?} has {} channels (too many)", hal.channels)))?; - - Ok(NativeConfig { - config: cpal::StreamConfig { - channels, - sample_rate: cpal::SampleRate(hal.sample_rate), - buffer_size: cpal::BufferSize::Default, - }, - sample_format: cpal::SampleFormat::F32, - sample_rate: hal.sample_rate, - channels, - }) -} - -#[cfg(not(target_os = "macos"))] -pub(super) fn native_config( - kind: DeviceKind, - device: &cpal::Device, - name: &str, -) -> AppResult { - // On Linux/Windows cpal's `supported_*_configs` is reliable for any device - // the OS exposes -- no inactive-route quirk like macOS. Pick the range with - // the highest max sample rate; force f32 sample format. - let configs: Vec = match kind { - DeviceKind::Input => device - .supported_input_configs() - .map_err(|e| AppError::Device(format!("query input configs for {name:?}: {e}")))? - .collect(), - DeviceKind::Output => device - .supported_output_configs() - .map_err(|e| AppError::Device(format!("query output configs for {name:?}: {e}")))? - .collect(), - }; - let best = configs - .into_iter() - .max_by_key(|c| c.max_sample_rate().0) - .ok_or_else(|| AppError::Device(format!("device {name:?} exposes no configs")))? - .with_max_sample_rate(); - Ok(NativeConfig { - config: cpal::StreamConfig { - channels: best.channels(), - sample_rate: best.sample_rate(), - buffer_size: cpal::BufferSize::Default, - }, - sample_format: cpal::SampleFormat::F32, - sample_rate: best.sample_rate().0, - channels: best.channels(), - }) -} diff --git a/src-tauri/src/audio/pipeline/native.rs b/src-tauri/src/audio/pipeline/native.rs new file mode 100644 index 0000000..f9c9365 --- /dev/null +++ b/src-tauri/src/audio/pipeline/native.rs @@ -0,0 +1,60 @@ +//! Read a device's nominal config straight from CoreAudio HAL (macOS). +//! +//! We never ask cpal "what is this device's default/supported config?": +//! - `default_*_config` reads the *currently active* CoreAudio stream format, +//! which is absent for non-default routes (built-in speakers while AirPods +//! are connected) -> "Invalid property value". +//! - `supported_*_configs` reads `kAudioStreamPropertyAvailableVirtualFormats`, +//! which is also empty for those same non-default routes. +//! +//! AUHAL (cpal's underlying output unit on macOS) does NOT need to be told the +//! device's "current" format up front -- it accepts whatever StreamConfig we +//! hand it and asks CoreAudio to convert. So we read the device's nominal +//! sample rate and channel count *directly* from CoreAudio HAL (which works +//! regardless of routing state) and feed those into `build_*_stream`. +//! +//! Sample format is always `f32` -- the universal macOS audio type and the +//! internal pipeline format. + +use crate::audio::device::DeviceKind; +use crate::error::{AppError, AppResult}; + +pub(in crate::audio::pipeline) struct NativeConfig { + pub config: cpal::StreamConfig, + pub sample_format: cpal::SampleFormat, + pub sample_rate: u32, + pub channels: u16, +} + +pub(in crate::audio::pipeline) fn native_config( + kind: DeviceKind, + _device: &cpal::Device, + name: &str, +) -> AppResult { + use crate::audio::macos_hal; + let hal = match kind { + DeviceKind::Input => macos_hal::find_input_device(name), + DeviceKind::Output => macos_hal::find_output_device(name), + } + .ok_or_else(|| { + AppError::Device(format!( + "{kind:?} device {name:?} disappeared between enumeration and open" + )) + })?; + + let channels: u16 = hal + .channels + .try_into() + .map_err(|_| AppError::Device(format!("device {name:?} has {} channels (too many)", hal.channels)))?; + + Ok(NativeConfig { + config: cpal::StreamConfig { + channels, + sample_rate: cpal::SampleRate(hal.sample_rate), + buffer_size: cpal::BufferSize::Default, + }, + sample_format: cpal::SampleFormat::F32, + sample_rate: hal.sample_rate, + channels, + }) +} diff --git a/src-tauri/src/audio/pipeline/output/linux.rs b/src-tauri/src/audio/pipeline/output/linux.rs new file mode 100644 index 0000000..09e1860 --- /dev/null +++ b/src-tauri/src/audio/pipeline/output/linux.rs @@ -0,0 +1,54 @@ +use std::sync::atomic::AtomicBool; +use std::sync::Arc; + +use rtrb::RingBuffer; +use tauri::AppHandle; +use tracing::info; + +use crate::audio::streams; +use crate::error::AppResult; + +use super::super::dag::OutputGraph; +use super::super::worker::WorkerCtrl; +use super::{spawn_speaker_worker, SpeakerWorker, SPEAKER_RING_CAPACITY}; + +pub(in crate::audio::pipeline) struct SpeakerResolved { + pub node_id: String, + pub sample_rate: u32, +} + +pub(in crate::audio::pipeline) struct SpeakerHandle { + _playback: crate::audio::playback::Playback, + _worker: SpeakerWorker, +} + +pub(in crate::audio::pipeline) fn resolve_speaker(device_id: &str) -> AppResult { + Ok(SpeakerResolved { + node_id: device_id.to_string(), + sample_rate: 48_000, + }) +} + +pub(in crate::audio::pipeline) fn start_speaker_stream( + _node_id: &str, + spec: SpeakerResolved, + graph: OutputGraph, + _app: &AppHandle, +) -> AppResult<(SpeakerHandle, WorkerCtrl, Arc)> { + info!(node = %spec.node_id, sample_rate = spec.sample_rate, "opening speaker stream (PipeWire)"); + let dead = Arc::new(AtomicBool::new(false)); + + let (producer, mut consumer) = RingBuffer::::new(SPEAKER_RING_CAPACITY); + let fill = move |out: &mut [f32]| { + streams::bulk_pop(&mut consumer, out); + out.len() + }; + let playback = crate::audio::playback::Playback::start(&spec.node_id, fill)?; + + let (worker_handle, ctrl) = spawn_speaker_worker(producer, spec.sample_rate, graph)?; + Ok(( + SpeakerHandle { _playback: playback, _worker: worker_handle }, + ctrl, + dead, + )) +} diff --git a/src-tauri/src/audio/pipeline/output/macos.rs b/src-tauri/src/audio/pipeline/output/macos.rs new file mode 100644 index 0000000..940cb10 --- /dev/null +++ b/src-tauri/src/audio/pipeline/output/macos.rs @@ -0,0 +1,145 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +use cpal::traits::DeviceTrait; +use rtrb::RingBuffer; +use serde_json::json; +use tauri::{AppHandle, Emitter}; +use tracing::{info, warn}; + +use crate::audio::device::{self, DeviceKind}; +use crate::audio::streams; +use crate::error::{AppError, AppResult}; + +use super::super::dag::OutputGraph; +use super::super::native::native_config; +use super::super::worker::WorkerCtrl; +use super::{spawn_speaker_worker, SpeakerWorker, SPEAKER_RING_CAPACITY}; + +// Bluetooth AUHAL often returns DeviceNotAvailable on first bind; retry covers settling. +const SPEAKER_MAX_ATTEMPTS: u32 = 3; +const SPEAKER_RETRY_DELAY: Duration = Duration::from_millis(300); + +pub(in crate::audio::pipeline) struct SpeakerResolved { + pub device: cpal::Device, + pub config: cpal::StreamConfig, + pub sample_format: cpal::SampleFormat, + pub out_channels: usize, + pub sample_rate: u32, +} + +// Field order: the stream drops before the worker so the audio callback stops +// before the ring is freed. +pub(in crate::audio::pipeline) struct SpeakerHandle { + _stream: cpal::Stream, + _worker: SpeakerWorker, +} + +pub(in crate::audio::pipeline) fn resolve_speaker(device_id: &str) -> AppResult { + let device = device::find(DeviceKind::Output, device_id)?; + let native = native_config(DeviceKind::Output, &device, device_id)?; + Ok(SpeakerResolved { + device, + config: native.config, + sample_format: native.sample_format, + out_channels: native.channels as usize, + sample_rate: native.sample_rate, + }) +} + +// Substring match on cpal's stable Display -- AppError flattens the variant. +fn is_device_not_available(e: &AppError) -> bool { + matches!(e, AppError::Stream(s) if s.contains("no longer available")) +} + +pub(in crate::audio::pipeline) fn start_speaker_stream( + node_id: &str, + spec: SpeakerResolved, + graph: OutputGraph, + app: &AppHandle, +) -> AppResult<(SpeakerHandle, WorkerCtrl, Arc)> { + let device_name = spec + .device + .name() + .unwrap_or_else(|_| "".into()); + info!( + device = %device_name, + sample_rate = spec.sample_rate, + channels = spec.out_channels, + format = ?spec.sample_format, + "opening speaker stream", + ); + + // AirPods A2DP/HFP switch can race resolve_output; verify state fresh. + { + let fresh = crate::audio::macos_hal::find_output_device(&device_name); + match fresh { + None => warn!(device = %device_name, "HAL no longer sees the device"), + Some(hal) if hal.sample_rate != spec.sample_rate => warn!( + device = %device_name, + resolved_sample_rate = spec.sample_rate, + current_sample_rate = hal.sample_rate, + "device sample rate changed between resolve and open" + ), + Some(hal) if hal.channels as usize != spec.out_channels => warn!( + device = %device_name, + resolved_channels = spec.out_channels, + current_channels = hal.channels, + "device channel count changed between resolve and open" + ), + Some(_) => {} + } + } + + let dead = Arc::new(AtomicBool::new(false)); + + let mut producer_holder: Option> = None; + let mut stream_holder: Option = None; + for attempt in 1..=SPEAKER_MAX_ATTEMPTS { + let (producer, mut consumer) = RingBuffer::::new(SPEAKER_RING_CAPACITY); + let fill = move |stereo_out: &mut [f32], _frames: usize| { + streams::bulk_pop(&mut consumer, stereo_out); + }; + let app_err = app.clone(); + let dead_cb = dead.clone(); + let node_id_cb = node_id.to_string(); + let err_cb = move |_e: cpal::StreamError| { + dead_cb.store(true, Ordering::Relaxed); + let _ = app_err.emit("audio://speaker_error", json!({ "nodeId": node_id_cb })); + }; + match streams::build_output_stream( + &spec.device, + &spec.config, + spec.sample_format, + spec.out_channels, + fill, + err_cb, + ) { + Ok(s) => { + producer_holder = Some(producer); + stream_holder = Some(s); + break; + } + Err(e) if attempt < SPEAKER_MAX_ATTEMPTS && is_device_not_available(&e) => { + warn!( + attempt, + error = %e, + "DeviceNotAvailable from cpal; retrying after delay" + ); + thread::sleep(SPEAKER_RETRY_DELAY); + } + Err(e) => return Err(e), + } + } + let producer = producer_holder.expect("loop sets producer on success or returns Err"); + let stream = stream_holder.expect("loop sets stream on success or returns Err"); + + let (worker_handle, ctrl) = spawn_speaker_worker(producer, spec.sample_rate, graph)?; + Ok(( + SpeakerHandle { _stream: stream, _worker: worker_handle }, + ctrl, + dead, + )) +} diff --git a/src-tauri/src/audio/pipeline/output.rs b/src-tauri/src/audio/pipeline/output/mod.rs similarity index 58% rename from src-tauri/src/audio/pipeline/output.rs rename to src-tauri/src/audio/pipeline/output/mod.rs index d1528bf..72849d6 100644 --- a/src-tauri/src/audio/pipeline/output.rs +++ b/src-tauri/src/audio/pipeline/output/mod.rs @@ -4,13 +4,12 @@ use std::sync::Arc; use std::thread::{self, JoinHandle}; use std::time::Duration; -use rtrb::{Producer, RingBuffer}; +use rtrb::Producer; use serde_json::json; use tauri::{AppHandle, Emitter}; -use tracing::{info, warn}; +use tracing::warn; use crate::audio::clock::{ClockSource, SystemClockTicker}; -use crate::audio::device::{self, DeviceKind}; use crate::audio::encoders::{build_encoder, AudioEncoder}; use crate::audio::graph::{OutputSpec, RecordingFormat, ValidOutput}; use crate::audio::streams; @@ -18,25 +17,23 @@ use crate::error::{AppError, AppResult}; use super::dag::{OutputGraph, DSP_BLOCK_FRAMES}; use super::worker::{dsp_worker, WorkerCtrl, WorkerPacing}; -use super::native_config; + +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "macos")] +use macos as platform; +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "linux")] +use linux as platform; + +pub(super) use platform::{start_speaker_stream, SpeakerHandle, SpeakerResolved}; // No live inputs -> fall back to 48 kHz for the recorder. const RECORDER_DEFAULT_SR: u32 = 48_000; // 32k f32 samples = ~340 ms @ 48 kHz stereo; absorbs cpal/scheduler jitter. -const SPEAKER_RING_CAPACITY: usize = 32_768; - -// Bluetooth AUHAL often returns DeviceNotAvailable on first bind; retry covers settling. -const SPEAKER_MAX_ATTEMPTS: u32 = 3; -const SPEAKER_RETRY_DELAY: Duration = Duration::from_millis(300); - -pub(super) struct SpeakerResolved { - pub device: cpal::Device, - pub config: cpal::StreamConfig, - pub sample_format: cpal::SampleFormat, - pub out_channels: usize, - pub sample_rate: u32, -} +pub(super) const SPEAKER_RING_CAPACITY: usize = 32_768; pub(super) enum ResolvedOutput { Speaker(SpeakerResolved), @@ -62,15 +59,7 @@ pub(super) fn resolve_output( ) -> AppResult { match &out.spec { OutputSpec::Speaker { device_id } => { - let device = device::find(DeviceKind::Output, device_id)?; - let native = native_config(DeviceKind::Output, &device, device_id)?; - Ok(ResolvedOutput::Speaker(SpeakerResolved { - device, - config: native.config, - sample_format: native.sample_format, - out_channels: native.channels as usize, - sample_rate: native.sample_rate, - })) + Ok(ResolvedOutput::Speaker(platform::resolve_speaker(device_id)?)) } OutputSpec::FileRecording { file_path, format } => Ok(ResolvedOutput::File { path: PathBuf::from(file_path), @@ -80,18 +69,7 @@ pub(super) fn resolve_output( } } -// Substring match on cpal's stable Display -- AppError flattens the variant. -fn is_device_not_available(e: &AppError) -> bool { - matches!(e, AppError::Stream(s) if s.contains("no longer available")) -} - -// Field order: stream drops before worker so the cpal callback stops before the ring is freed. -pub(super) struct SpeakerHandle { - _stream: cpal::Stream, - _worker: SpeakerWorker, -} - -struct SpeakerWorker { +pub(super) struct SpeakerWorker { stop: Arc, join: Option>, } @@ -119,98 +97,21 @@ impl Drop for RecorderWorker { } } -pub(super) fn start_speaker_stream( - node_id: &str, - spec: SpeakerResolved, +// Shared by both platforms' `start_speaker_stream`: a Clock-paced worker that +// mixes the output sub-graph and bulk-pushes blocks into the speaker ring. +pub(super) fn spawn_speaker_worker( + mut producer: Producer, + sample_rate: u32, graph: OutputGraph, - app: &AppHandle, -) -> AppResult<(SpeakerHandle, WorkerCtrl, Arc)> { - use cpal::traits::DeviceTrait; - let device_name = spec - .device - .name() - .unwrap_or_else(|_| "".into()); - info!( - device = %device_name, - sample_rate = spec.sample_rate, - channels = spec.out_channels, - format = ?spec.sample_format, - "opening speaker stream", - ); - - // AirPods A2DP/HFP switch can race resolve_output; verify state fresh. - #[cfg(target_os = "macos")] - { - let fresh = crate::audio::macos_hal::find_output_device(&device_name); - match fresh { - None => warn!(device = %device_name, "HAL no longer sees the device"), - Some(hal) if hal.sample_rate != spec.sample_rate => warn!( - device = %device_name, - resolved_sample_rate = spec.sample_rate, - current_sample_rate = hal.sample_rate, - "device sample rate changed between resolve and open" - ), - Some(hal) if hal.channels as usize != spec.out_channels => warn!( - device = %device_name, - resolved_channels = spec.out_channels, - current_channels = hal.channels, - "device channel count changed between resolve and open" - ), - Some(_) => {} - } - } - - let dead = Arc::new(AtomicBool::new(false)); - - let mut producer_holder: Option> = None; - let mut stream_holder: Option = None; - for attempt in 1..=SPEAKER_MAX_ATTEMPTS { - let (producer, mut consumer) = RingBuffer::::new(SPEAKER_RING_CAPACITY); - let fill = move |stereo_out: &mut [f32], _frames: usize| { - streams::bulk_pop(&mut consumer, stereo_out); - }; - let app_err = app.clone(); - let dead_cb = dead.clone(); - let node_id_cb = node_id.to_string(); - let err_cb = move |_e: cpal::StreamError| { - dead_cb.store(true, Ordering::Relaxed); - let _ = app_err.emit("audio://speaker_error", json!({ "nodeId": node_id_cb })); - }; - match streams::build_output_stream( - &spec.device, - &spec.config, - spec.sample_format, - spec.out_channels, - fill, - err_cb, - ) { - Ok(s) => { - producer_holder = Some(producer); - stream_holder = Some(s); - break; - } - Err(e) if attempt < SPEAKER_MAX_ATTEMPTS && is_device_not_available(&e) => { - warn!( - attempt, - error = %e, - "DeviceNotAvailable from cpal; retrying after delay" - ); - thread::sleep(SPEAKER_RETRY_DELAY); - } - Err(e) => return Err(e), - } - } - let mut producer = producer_holder.expect("loop sets producer on success or returns Err"); - let stream = stream_holder.expect("loop sets stream on success or returns Err"); - +) -> AppResult<(SpeakerWorker, WorkerCtrl)> { let stop = Arc::new(AtomicBool::new(false)); let stop_thread = stop.clone(); let (worker, ctrl) = dsp_worker(graph); let clock: Box = - Box::new(SystemClockTicker::new(spec.sample_rate, DSP_BLOCK_FRAMES)); + Box::new(SystemClockTicker::new(sample_rate, DSP_BLOCK_FRAMES)); let pacing = WorkerPacing::Clock(clock); let join = thread::Builder::new() - .name(format!("speaker:{}", spec.sample_rate)) + .name(format!("speaker:{sample_rate}")) .spawn(move || { worker.run(stop_thread, pacing, |block| { streams::bulk_push(&mut producer, block); @@ -218,18 +119,7 @@ pub(super) fn start_speaker_stream( }); }) .map_err(|e| AppError::Stream(format!("spawn speaker worker: {e}")))?; - - Ok(( - SpeakerHandle { - _stream: stream, - _worker: SpeakerWorker { - stop, - join: Some(join), - }, - }, - ctrl, - dead, - )) + Ok((SpeakerWorker { stop, join: Some(join) }, ctrl)) } // Drives analyzers when there's no real output; sink discards the mix. diff --git a/src-tauri/src/audio/playback.rs b/src-tauri/src/audio/playback.rs new file mode 100644 index 0000000..04ac1d6 --- /dev/null +++ b/src-tauri/src/audio/playback.rs @@ -0,0 +1,164 @@ +use pipewire as pw; +use pw::spa; +use pw::spa::param::audio::{AudioFormat, AudioInfoRaw}; +use pw::spa::pod::Pod; + +use crate::error::AppResult; + +const CHANNELS: usize = 2; +const RATE: u32 = 48_000; +const F32_SIZE: usize = std::mem::size_of::(); +const STRIDE: usize = F32_SIZE * CHANNELS; + +struct Terminate; + +struct UserData { + fill: Box usize>, +} + +pub struct Playback { + sender: pw::channel::Sender, + thread: Option>, +} + +impl Drop for Playback { + fn drop(&mut self) { + let _ = self.sender.send(Terminate); + if let Some(t) = self.thread.take() { + let _ = t.join(); + } + } +} + +impl Playback { + pub fn start( + sink_node_name: &str, + fill: impl FnMut(&mut [f32]) -> usize + Send + 'static, + ) -> AppResult { + let (sender, receiver) = pw::channel::channel::(); + let target = sink_node_name.to_string(); + let thread = std::thread::spawn(move || { + if let Err(e) = run(receiver, &target, Box::new(fill)) { + tracing::error!("pipewire playback: {e:?}"); + } + }); + Ok(Playback { sender, thread: Some(thread) }) + } +} + +fn run( + receiver: pw::channel::Receiver, + sink_node_name: &str, + fill: Box usize + Send>, +) -> Result<(), pw::Error> { + let mainloop = pw::main_loop::MainLoopRc::new(None)?; + let context = pw::context::ContextRc::new(&mainloop, None)?; + let core = context.connect_rc(None)?; + + let _stopper = { + let ml = mainloop.clone(); + receiver.attach(mainloop.loop_(), move |_| ml.quit()) + }; + + let mut props = pw::properties::properties! { + *pw::keys::MEDIA_TYPE => "Audio", + *pw::keys::MEDIA_CATEGORY => "Playback", + *pw::keys::MEDIA_ROLE => "Music", + }; + props.insert(*pw::keys::TARGET_OBJECT, sink_node_name); + + let stream = pw::stream::StreamRc::new(core.clone(), "splitwave-playback", props)?; + let user_data = UserData { fill }; + + let _listener = stream + .add_local_listener_with_user_data(user_data) + .process(|stream, user_data| { + let Some(mut buffer) = stream.dequeue_buffer() else { return }; + let datas = buffer.datas_mut(); + if datas.is_empty() { + return; + } + let data = &mut datas[0]; + let Some(raw) = data.data() else { return }; + let capacity = raw.len() / F32_SIZE; + if capacity == 0 { + return; + } + let mut samples = vec![0.0f32; capacity]; + let written = (user_data.fill)(&mut samples).min(capacity); + for (i, s) in samples[..written].iter().enumerate() { + raw[i * F32_SIZE..(i + 1) * F32_SIZE].copy_from_slice(&s.to_le_bytes()); + } + let chunk = data.chunk_mut(); + *chunk.offset_mut() = 0; + *chunk.stride_mut() = STRIDE as i32; + *chunk.size_mut() = (written * F32_SIZE) as u32; + }) + .register()?; + + let mut audio_info = AudioInfoRaw::new(); + audio_info.set_format(AudioFormat::F32LE); + audio_info.set_rate(RATE); + audio_info.set_channels(CHANNELS as u32); + + let obj = spa::pod::Object { + type_: spa::utils::SpaTypes::ObjectParamFormat.as_raw(), + id: spa::param::ParamType::EnumFormat.as_raw(), + properties: audio_info.into(), + }; + let values: Vec = spa::pod::serialize::PodSerializer::serialize( + std::io::Cursor::new(Vec::new()), + &spa::pod::Value::Object(obj), + ) + .unwrap() + .0 + .into_inner(); + let mut params = [Pod::from_bytes(&values).unwrap()]; + + stream.connect( + spa::utils::Direction::Output, + None, + pw::stream::StreamFlags::AUTOCONNECT | pw::stream::StreamFlags::MAP_BUFFERS, + &mut params, + )?; + + mainloop.run(); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + #[test] + fn playback_pulls_samples() { + let calls = Arc::new(AtomicUsize::new(0)); + let total = Arc::new(AtomicUsize::new(0)); + let c = calls.clone(); + let t = total.clone(); + let mut phase = 0.0f32; + let pb = Playback::start("alsa_output.pci-0000_00_0a.0.stereo-fallback", move |buf| { + c.fetch_add(1, Ordering::Relaxed); + t.fetch_add(buf.len(), Ordering::Relaxed); + for f in buf.chunks_mut(2) { + let s = (phase * 2.0 * std::f32::consts::PI * 440.0 / 48000.0).sin() * 0.2; + phase += 1.0; + if f.len() == 2 { + f[0] = s; + f[1] = s; + } + } + buf.len() + }) + .expect("start playback"); + std::thread::sleep(std::time::Duration::from_millis(1500)); + drop(pb); + let n = calls.load(Ordering::Relaxed); + let samples = total.load(Ordering::Relaxed); + println!("playback: {n} process calls, {samples} f32 requested"); + assert!(n > 0, "process never called"); + assert!(samples > 0, "no samples requested"); + } +} diff --git a/src-tauri/src/audio/pw_enum.rs b/src-tauri/src/audio/pw_enum.rs new file mode 100644 index 0000000..ec750f4 --- /dev/null +++ b/src-tauri/src/audio/pw_enum.rs @@ -0,0 +1,69 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use pipewire as pw; +use pw::types::ObjectType; + +use crate::error::{AppError, AppResult}; + +pub struct PwNode { + pub id: u32, + pub name: String, + pub description: String, +} + +pub fn nodes_by_class(media_class: &'static str) -> AppResult> { + std::thread::spawn(move || snapshot(media_class)) + .join() + .map_err(|_| AppError::Host("pipewire enum thread panicked".into()))? +} + +fn snapshot(media_class: &str) -> AppResult> { + let mainloop = pw::main_loop::MainLoopRc::new(None).map_err(pw_err)?; + let context = pw::context::ContextRc::new(&mainloop, None).map_err(pw_err)?; + let core = context.connect_rc(None).map_err(pw_err)?; + let registry = core.get_registry_rc().map_err(pw_err)?; + + let nodes: Rc>> = Rc::new(RefCell::new(Vec::new())); + let nodes_cb = nodes.clone(); + let want = media_class.to_string(); + + let _reg = registry + .add_listener_local() + .global(move |global| { + if global.type_ != ObjectType::Node { + return; + } + let Some(props) = &global.props else { return }; + if props.get("media.class") != Some(want.as_str()) { + return; + } + let Some(name) = props.get("node.name") else { return }; + let description = props + .get("node.description") + .filter(|d| !d.is_empty()) + .unwrap_or(name) + .to_string(); + nodes_cb.borrow_mut().push(PwNode { id: global.id, name: name.to_string(), description }); + }) + .register(); + + let pending = core.sync(0).map_err(pw_err)?; + let ml = mainloop.clone(); + let _core = core + .add_listener_local() + .done(move |id, seq| { + if id == 0 && seq == pending { + ml.quit(); + } + }) + .register(); + + mainloop.run(); + let out = std::mem::take(&mut *nodes.borrow_mut()); + Ok(out) +} + +fn pw_err(e: impl std::fmt::Display) -> AppError { + AppError::Host(format!("pipewire: {e}")) +} diff --git a/src-tauri/src/audio/streams.rs b/src-tauri/src/audio/streams/macos.rs similarity index 80% rename from src-tauri/src/audio/streams.rs rename to src-tauri/src/audio/streams/macos.rs index 899a70a..e78d880 100644 --- a/src-tauri/src/audio/streams.rs +++ b/src-tauri/src/audio/streams/macos.rs @@ -7,11 +7,10 @@ //! //! Each cpal input is broadcast to N subscriber producer rings (one per output //! that uses this input). On any ring full, that ring drops the current frame -//! rather than blocking — non-RT safe operations are disallowed in the callback. +//! rather than blocking -- non-RT safe operations are disallowed in the callback. use cpal::traits::{DeviceTrait, StreamTrait}; use cpal::{Sample, SampleFormat, StreamConfig}; -use rtrb::Producer; use tracing::error; use crate::audio::effects::{update_meter, MeterHandle}; @@ -19,58 +18,6 @@ use crate::audio::format::{convert_to_stereo, write_stereo_to_output}; use crate::audio::input_bridge::BroadcastRx; use crate::error::{AppError, AppResult}; -/// Bulk drain `dst.len()` samples from an SPSC ring. Anything we couldn't -/// read (consumer faster than producer) is zero-filled — that's the device -/// playing silence, not glitching. -pub fn bulk_pop(cons: &mut rtrb::Consumer, dst: &mut [f32]) { - let want = dst.len(); - if want == 0 { - return; - } - let avail = cons.slots(); - let to_read = want.min(avail); - if to_read > 0 { - if let Ok(chunk) = cons.read_chunk(to_read) { - let (first, second) = chunk.as_slices(); - let n1 = first.len(); - dst[..n1].copy_from_slice(first); - let n2 = second.len(); - if n2 > 0 { - dst[n1..n1 + n2].copy_from_slice(second); - } - chunk.commit_all(); - } - } - for s in &mut dst[to_read..] { - *s = 0.0; - } -} - -/// Bulk push via one `write_chunk` reservation — one atomic-CAS per block -/// instead of one per sample. On overflow only the head fits and the rest is -/// dropped (consumer is behind anyway; staying RT-safe beats blocking). -pub fn bulk_push(prod: &mut Producer, samples: &[f32]) { - let want = samples.len(); - if want == 0 { - return; - } - let avail = prod.slots(); - let to_write = want.min(avail); - if to_write == 0 { - return; - } - if let Ok(mut chunk) = prod.write_chunk(to_write) { - let (first, second) = chunk.as_mut_slices(); - let n1 = first.len(); - first.copy_from_slice(&samples[..n1]); - let n2 = second.len(); - if n2 > 0 { - second.copy_from_slice(&samples[n1..n1 + n2]); - } - chunk.commit_all(); - } -} - /// Build and start an input stream. `bridge` carries broadcast subscribers /// at runtime; the callback drains pending add/remove commands at the top /// of each block before broadcasting the converted-to-stereo f32 frames. diff --git a/src-tauri/src/audio/streams/mod.rs b/src-tauri/src/audio/streams/mod.rs new file mode 100644 index 0000000..37359cf --- /dev/null +++ b/src-tauri/src/audio/streams/mod.rs @@ -0,0 +1,65 @@ +//! Cross-platform SPSC ring helpers plus the cpal stream builders (macOS). +//! +//! `bulk_pop` / `bulk_push` move whole blocks between the pipeline and the +//! audio callback on every platform. The cpal `build_*_stream` builders are +//! macOS-only; Linux opens its mic via `capture/linux.rs` and its speaker via +//! `playback.rs`, so it needs no builders here. + +use rtrb::Producer; + +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "macos")] +pub use macos::{build_input_stream, build_output_stream}; + +/// Bulk drain `dst.len()` samples from an SPSC ring. Anything we couldn't +/// read (consumer faster than producer) is zero-filled -- that's the device +/// playing silence, not glitching. +pub fn bulk_pop(cons: &mut rtrb::Consumer, dst: &mut [f32]) { + let want = dst.len(); + if want == 0 { + return; + } + let avail = cons.slots(); + let to_read = want.min(avail); + if to_read > 0 { + if let Ok(chunk) = cons.read_chunk(to_read) { + let (first, second) = chunk.as_slices(); + let n1 = first.len(); + dst[..n1].copy_from_slice(first); + let n2 = second.len(); + if n2 > 0 { + dst[n1..n1 + n2].copy_from_slice(second); + } + chunk.commit_all(); + } + } + for s in &mut dst[to_read..] { + *s = 0.0; + } +} + +/// Bulk push via one `write_chunk` reservation -- one atomic-CAS per block +/// instead of one per sample. On overflow only the head fits and the rest is +/// dropped (consumer is behind anyway; staying RT-safe beats blocking). +pub fn bulk_push(prod: &mut Producer, samples: &[f32]) { + let want = samples.len(); + if want == 0 { + return; + } + let avail = prod.slots(); + let to_write = want.min(avail); + if to_write == 0 { + return; + } + if let Ok(mut chunk) = prod.write_chunk(to_write) { + let (first, second) = chunk.as_mut_slices(); + let n1 = first.len(); + first.copy_from_slice(&samples[..n1]); + let n2 = second.len(); + if n2 > 0 { + second.copy_from_slice(&samples[n1..n1 + n2]); + } + chunk.commit_all(); + } +} diff --git a/src-tauri/src/audio/system_audio.rs b/src-tauri/src/audio/system_audio.rs deleted file mode 100644 index 195b40d..0000000 --- a/src-tauri/src/audio/system_audio.rs +++ /dev/null @@ -1,196 +0,0 @@ -use serde::Serialize; - -use crate::error::AppResult; - -#[derive(Debug, Clone, Serialize)] -pub struct AudioApplication { - #[serde(rename = "bundleId")] - pub bundle_id: String, - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub icon: Option, -} - -#[cfg(target_os = "macos")] -pub fn list_audio_applications() -> AppResult> { - Ok(macos::list_running_applications()) -} - -#[cfg(target_os = "macos")] -pub fn load_app_icons(bundle_ids: Vec) -> std::collections::HashMap { - macos::load_icons(bundle_ids) -} - -#[cfg(not(target_os = "macos"))] -pub fn list_audio_applications() -> AppResult> { - Ok(Vec::new()) -} - -#[cfg(not(target_os = "macos"))] -pub fn load_app_icons(_bundle_ids: Vec) -> std::collections::HashMap { - std::collections::HashMap::new() -} - -#[cfg(target_os = "macos")] -mod macos { - use std::collections::{HashMap, HashSet}; - use std::path::PathBuf; - use std::sync::{Mutex, OnceLock}; - - use base64::{engine::general_purpose::STANDARD, Engine as _}; - use objc2_app_kit::{NSRunningApplication, NSWorkspace}; - - use super::AudioApplication; - - fn bundle_path_cache() -> &'static Mutex> { - static C: OnceLock>> = OnceLock::new(); - C.get_or_init(|| Mutex::new(HashMap::new())) - } - - fn icon_cache() -> &'static Mutex>> { - static C: OnceLock>>> = OnceLock::new(); - C.get_or_init(|| Mutex::new(HashMap::new())) - } - - pub fn list_running_applications() -> Vec { - let workspace = NSWorkspace::sharedWorkspace(); - let apps = workspace.runningApplications(); - let mut seen = HashSet::new(); - let mut out = Vec::with_capacity(apps.len()); - let mut paths = bundle_path_cache().lock().unwrap(); - - for app in apps.iter() { - let Some(bundle_id) = bundle_identifier(&app) else { continue }; - let name = localized_name(&app).unwrap_or_else(|| bundle_id.clone()); - if !paths.contains_key(&bundle_id) { - if let Some(path) = bundle_path(&app) { - paths.insert(bundle_id.clone(), path); - } - } - if seen.insert(bundle_id.clone()) { - out.push(AudioApplication { bundle_id, name, icon: None }); - } - } - out.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); - out - } - - pub fn load_icons(bundle_ids: Vec) -> HashMap { - let mut result = HashMap::new(); - let mut to_load: Vec<(String, PathBuf)> = Vec::new(); - - { - let paths = bundle_path_cache().lock().unwrap(); - let icons = icon_cache().lock().unwrap(); - for id in &bundle_ids { - if let Some(cached) = icons.get(id) { - if let Some(icon) = cached { - result.insert(id.clone(), icon.clone()); - } - continue; - } - if let Some(path) = paths.get(id) { - to_load.push((id.clone(), path.clone())); - } - } - } - - let loaded: Vec<(String, Option)> = to_load - .iter() - .map(|(id, path)| (id.clone(), icon_from_bundle(path))) - .collect(); - - { - let mut icons = icon_cache().lock().unwrap(); - for (id, icon) in loaded { - if let Some(ref png) = icon { - result.insert(id.clone(), png.clone()); - } - icons.insert(id, icon); - } - } - result - } - - fn bundle_path(app: &NSRunningApplication) -> Option { - let url = app.bundleURL()?; - let path = url.path()?; - Some(PathBuf::from(path.to_string())) - } - - fn bundle_identifier(app: &NSRunningApplication) -> Option { - app.bundleIdentifier().map(|s| s.to_string()) - } - - fn localized_name(app: &NSRunningApplication) -> Option { - app.localizedName().map(|s| s.to_string()) - } - - fn icon_from_bundle(bundle: &std::path::Path) -> Option { - let resources = bundle.join("Contents/Resources"); - let bundle_stem = bundle.file_stem()?.to_str()?.to_string(); - let from_plist = icon_name_from_plist(&bundle.join("Contents/Info.plist")); - - let mut candidates: Vec = Vec::new(); - if let Some(name) = from_plist { - candidates.push(name.trim_end_matches(".icns").to_string()); - } - candidates.push(bundle_stem); - candidates.push("AppIcon".to_string()); - candidates.push("app".to_string()); - candidates.dedup(); - - for name in &candidates { - if name.is_empty() { - continue; - } - let path = resources.join(format!("{}.icns", name)); - if let Ok(data) = std::fs::read(&path) { - if let Some(png) = icns_png(&data) { - return Some(STANDARD.encode(&png)); - } - } - } - None - } - - fn icon_name_from_plist(path: &std::path::Path) -> Option { - let text = std::fs::read_to_string(path).ok()?; - let after_key = &text[text.find("CFBundleIconFile")?..]; - let start = after_key.find("")? + "".len(); - let end = after_key[start..].find("")?; - let name = after_key[start..start + end].trim().to_string(); - if name.is_empty() { None } else { Some(name) } - } - - fn icns_png(data: &[u8]) -> Option> { - if data.len() < 8 || &data[..4] != b"icns" { - return None; - } - const ORDER: &[&[u8; 4]] = &[b"ic08", b"ic07", b"ic09", b"ic10", b"ic14", b"ic13"]; - let mut best: Option<(usize, &[u8])> = None; - let mut pos = 8usize; - while pos + 8 <= data.len() { - let tag = &data[pos..pos + 4]; - let size = - u32::from_be_bytes(data[pos + 4..pos + 8].try_into().ok()?) as usize; - if size < 8 { - break; - } - let end = pos + size; - if end > data.len() { - break; - } - let chunk = &data[pos + 8..end]; - if let Some(rank) = ORDER.iter().position(|t| t.as_slice() == tag) { - if chunk.starts_with(b"\x89PNG") { - if best.as_ref().map_or(true, |(r, _)| rank < *r) { - best = Some((rank, chunk)); - } - } - } - pos = end; - } - best.map(|(_, png)| png.to_vec()) - } -} diff --git a/src-tauri/src/audio/system_audio/linux.rs b/src-tauri/src/audio/system_audio/linux.rs new file mode 100644 index 0000000..a23a728 --- /dev/null +++ b/src-tauri/src/audio/system_audio/linux.rs @@ -0,0 +1,171 @@ +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::rc::Rc; + +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use freedesktop_desktop_entry::{ + desktop_entries, find_app_by_id, get_languages_from_env, unicase::Ascii, +}; +use freedesktop_icons::lookup; +use pipewire::{context::ContextRc, main_loop::MainLoopRc, types::ObjectType}; + +use super::AudioApplication; +use crate::error::{AppError, AppResult}; + +const ICON_SIZE: u32 = 128; + +pub fn list_audio_applications() -> AppResult> { + // pipewire objects are !Send, so the whole snapshot lives on one thread. + std::thread::spawn(snapshot) + .join() + .map_err(|_| AppError::Host("pipewire enumeration thread panicked".into()))? +} + +pub fn load_app_icons(bundle_ids: Vec) -> HashMap { + bundle_ids + .into_iter() + .filter_map(|binary| Some((binary.clone(), STANDARD.encode(resolve_icon(&binary)?)))) + .collect() +} + +fn resolve_icon(binary: &str) -> Option> { + let locales = get_languages_from_env(); + let entries = desktop_entries(&locales); + let entry = find_app_by_id(&entries, Ascii::new(binary))?; + let icon = entry.icon()?; + + if icon.starts_with('/') { + return read_icon_file(Path::new(icon)); + } + + let theme = current_icon_theme(); + let path = lookup(icon) + .with_size(ICON_SIZE as u16) + .with_scale(1) + .with_theme(&theme) + .find() + .or_else(|| lookup(icon).with_size(ICON_SIZE as u16).with_scale(1).find())?; + read_icon_file(&path) +} + +fn read_icon_file(path: &Path) -> Option> { + let svg = path + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("svg")); + if svg { + rasterize_svg(path) + } else { + std::fs::read(path).ok() + } +} + +fn rasterize_svg(path: &Path) -> Option> { + let data = std::fs::read(path).ok()?; + let tree = resvg::usvg::Tree::from_data(&data, &resvg::usvg::Options::default()).ok()?; + let orig = tree.size().to_int_size(); + let scale = ICON_SIZE as f32 / orig.width().max(orig.height()) as f32; + let w = (orig.width() as f32 * scale) as u32; + let h = (orig.height() as f32 * scale) as u32; + let mut pixmap = resvg::tiny_skia::Pixmap::new(w, h)?; + resvg::render( + &tree, + resvg::tiny_skia::Transform::from_scale(scale, scale), + &mut pixmap.as_mut(), + ); + pixmap.encode_png().ok() +} + +// The icon name resolves against the user's chosen theme, so read it from the +// GTK settings; fall back to hicolor, which every theme inherits from. +fn current_icon_theme() -> String { + let Some(config) = dirs::config_dir() else { + return "hicolor".into(); + }; + for sub in ["gtk-4.0/settings.ini", "gtk-3.0/settings.ini"] { + let Ok(content) = std::fs::read_to_string(config.join(sub)) else { + continue; + }; + for line in content.lines() { + if let Some(val) = line.trim().strip_prefix("gtk-icon-theme-name") { + let val = val.trim_start_matches('=').trim(); + if !val.is_empty() { + return val.to_string(); + } + } + } + } + "hicolor".into() +} + +fn snapshot() -> AppResult> { + let mainloop = MainLoopRc::new(None).map_err(pw_err)?; + let context = ContextRc::new(&mainloop, None).map_err(pw_err)?; + let core = context.connect_rc(None).map_err(pw_err)?; + let registry = core.get_registry_rc().map_err(pw_err)?; + + let apps: Rc>> = Rc::new(RefCell::new(Vec::new())); + let apps_cb = apps.clone(); + + let _reg_listener = registry + .add_listener_local() + .global(move |global| { + if global.type_ != ObjectType::Node { + return; + } + let Some(props) = &global.props else { return }; + if props.get("media.class") != Some("Stream/Output/Audio") { + return; + } + let name = props + .get("application.name") + .or_else(|| props.get("node.name")) + .unwrap_or("") + .to_string(); + if name.is_empty() { + return; + } + // binary is the stable handle for matching a .desktop entry; fall + // back to the display name when a stream doesn't report one. + let bundle_id = props + .get("application.process.binary") + .filter(|b| !b.is_empty()) + .unwrap_or(&name) + .to_string(); + apps_cb.borrow_mut().push(AudioApplication { + bundle_id, + name, + icon: None, + }); + }) + .register(); + + let pending = core.sync(0).map_err(pw_err)?; + let ml_quit = mainloop.clone(); + let _core_listener = core + .add_listener_local() + .done(move |id, seq| { + if id == 0 && seq == pending { + ml_quit.quit(); + } + }) + .register(); + + mainloop.run(); + + // The registry listener still holds a clone of `apps`, so take the contents + // out of the RefCell rather than unwrapping the Rc. + let collected = std::mem::take(&mut *apps.borrow_mut()); + let mut seen = HashSet::new(); + let mut out: Vec = collected + .into_iter() + .filter(|a| seen.insert(a.bundle_id.clone())) + .collect(); + out.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + Ok(out) +} + +fn pw_err(e: impl std::fmt::Display) -> AppError { + AppError::Host(format!("pipewire: {e}")) +} diff --git a/src-tauri/src/audio/system_audio/macos.rs b/src-tauri/src/audio/system_audio/macos.rs new file mode 100644 index 0000000..90bbf08 --- /dev/null +++ b/src-tauri/src/audio/system_audio/macos.rs @@ -0,0 +1,161 @@ +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::{Mutex, OnceLock}; + +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use objc2_app_kit::{NSRunningApplication, NSWorkspace}; + +use super::AudioApplication; +use crate::error::AppResult; + +fn bundle_path_cache() -> &'static Mutex> { + static C: OnceLock>> = OnceLock::new(); + C.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn icon_cache() -> &'static Mutex>> { + static C: OnceLock>>> = OnceLock::new(); + C.get_or_init(|| Mutex::new(HashMap::new())) +} + +pub fn list_audio_applications() -> AppResult> { + let workspace = NSWorkspace::sharedWorkspace(); + let apps = workspace.runningApplications(); + let mut seen = HashSet::new(); + let mut out = Vec::with_capacity(apps.len()); + let mut paths = bundle_path_cache().lock().unwrap(); + + for app in apps.iter() { + let Some(bundle_id) = bundle_identifier(&app) else { continue }; + let name = localized_name(&app).unwrap_or_else(|| bundle_id.clone()); + if !paths.contains_key(&bundle_id) { + if let Some(path) = bundle_path(&app) { + paths.insert(bundle_id.clone(), path); + } + } + if seen.insert(bundle_id.clone()) { + out.push(AudioApplication { bundle_id, name, icon: None }); + } + } + out.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + Ok(out) +} + +pub fn load_app_icons(bundle_ids: Vec) -> HashMap { + let mut result = HashMap::new(); + let mut to_load: Vec<(String, PathBuf)> = Vec::new(); + + { + let paths = bundle_path_cache().lock().unwrap(); + let icons = icon_cache().lock().unwrap(); + for id in &bundle_ids { + if let Some(cached) = icons.get(id) { + if let Some(icon) = cached { + result.insert(id.clone(), icon.clone()); + } + continue; + } + if let Some(path) = paths.get(id) { + to_load.push((id.clone(), path.clone())); + } + } + } + + let loaded: Vec<(String, Option)> = to_load + .iter() + .map(|(id, path)| (id.clone(), icon_from_bundle(path))) + .collect(); + + { + let mut icons = icon_cache().lock().unwrap(); + for (id, icon) in loaded { + if let Some(ref png) = icon { + result.insert(id.clone(), png.clone()); + } + icons.insert(id, icon); + } + } + result +} + +fn bundle_path(app: &NSRunningApplication) -> Option { + let url = app.bundleURL()?; + let path = url.path()?; + Some(PathBuf::from(path.to_string())) +} + +fn bundle_identifier(app: &NSRunningApplication) -> Option { + app.bundleIdentifier().map(|s| s.to_string()) +} + +fn localized_name(app: &NSRunningApplication) -> Option { + app.localizedName().map(|s| s.to_string()) +} + +fn icon_from_bundle(bundle: &std::path::Path) -> Option { + let resources = bundle.join("Contents/Resources"); + let bundle_stem = bundle.file_stem()?.to_str()?.to_string(); + let from_plist = icon_name_from_plist(&bundle.join("Contents/Info.plist")); + + let mut candidates: Vec = Vec::new(); + if let Some(name) = from_plist { + candidates.push(name.trim_end_matches(".icns").to_string()); + } + candidates.push(bundle_stem); + candidates.push("AppIcon".to_string()); + candidates.push("app".to_string()); + candidates.dedup(); + + for name in &candidates { + if name.is_empty() { + continue; + } + let path = resources.join(format!("{}.icns", name)); + if let Ok(data) = std::fs::read(&path) { + if let Some(png) = icns_png(&data) { + return Some(STANDARD.encode(&png)); + } + } + } + None +} + +fn icon_name_from_plist(path: &std::path::Path) -> Option { + let text = std::fs::read_to_string(path).ok()?; + let after_key = &text[text.find("CFBundleIconFile")?..]; + let start = after_key.find("")? + "".len(); + let end = after_key[start..].find("")?; + let name = after_key[start..start + end].trim().to_string(); + if name.is_empty() { None } else { Some(name) } +} + +fn icns_png(data: &[u8]) -> Option> { + if data.len() < 8 || &data[..4] != b"icns" { + return None; + } + const ORDER: &[&[u8; 4]] = &[b"ic08", b"ic07", b"ic09", b"ic10", b"ic14", b"ic13"]; + let mut best: Option<(usize, &[u8])> = None; + let mut pos = 8usize; + while pos + 8 <= data.len() { + let tag = &data[pos..pos + 4]; + let size = + u32::from_be_bytes(data[pos + 4..pos + 8].try_into().ok()?) as usize; + if size < 8 { + break; + } + let end = pos + size; + if end > data.len() { + break; + } + let chunk = &data[pos + 8..end]; + if let Some(rank) = ORDER.iter().position(|t| t.as_slice() == tag) { + if chunk.starts_with(b"\x89PNG") { + if best.as_ref().map_or(true, |(r, _)| rank < *r) { + best = Some((rank, chunk)); + } + } + } + pos = end; + } + best.map(|(_, png)| png.to_vec()) +} diff --git a/src-tauri/src/audio/system_audio/mod.rs b/src-tauri/src/audio/system_audio/mod.rs new file mode 100644 index 0000000..486054c --- /dev/null +++ b/src-tauri/src/audio/system_audio/mod.rs @@ -0,0 +1,34 @@ +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +pub struct AudioApplication { + #[serde(rename = "bundleId")] + pub bundle_id: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, +} + +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "macos")] +pub use macos::{list_audio_applications, load_app_icons}; + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "linux")] +pub use linux::{list_audio_applications, load_app_icons}; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn enumerates_audio_apps_without_hanging() { + let apps = list_audio_applications().expect("apps"); + println!("found {} audio application(s):", apps.len()); + for a in &apps { + println!(" - {} ({})", a.name, a.bundle_id); + } + } +} diff --git a/src-tauri/src/audio/virtual_device/linux.rs b/src-tauri/src/audio/virtual_device/linux.rs new file mode 100644 index 0000000..5606431 --- /dev/null +++ b/src-tauri/src/audio/virtual_device/linux.rs @@ -0,0 +1,119 @@ +use std::path::PathBuf; +use std::process::Command; + +use tauri::AppHandle; + +use crate::audio::pw_enum::nodes_by_class; + +use super::{VirtualDeviceConfig, VirtualDriverStatus}; + +const CONF_NAME: &str = "50-splitwave-sinks.conf"; +const NODE_PREFIX: &str = "splitwave"; + +fn conf_path() -> Option { + Some(dirs::config_dir()?.join("pipewire/pipewire.conf.d").join(CONF_NAME)) +} + +// node.name is the stable handle, node.description is the label shown in +// system settings. Quotes would break both the .conf and the pw-cli props, so +// drop them from the label. +fn clean_label(name: &str) -> String { + name.replace(['"', '\''], "") +} + +pub fn status() -> VirtualDriverStatus { + // Native PipeWire (no PulseAudio/pactl dependency). Success means a session + // is reachable, which is all we need to create null-sinks. + let ok = Command::new("pw-cli") + .args(["info", "0"]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + VirtualDriverStatus { installed: ok } +} + +pub fn install(_app: &AppHandle) -> Result<(), String> { + // No driver to install on PipeWire; sinks are created directly. + Ok(()) +} + +pub fn uninstall() -> Result<(), String> { + unload_runtime_sinks(); + if let Some(p) = conf_path() { + let _ = std::fs::remove_file(p); + } + Ok(()) +} + +pub fn apply_virtual_devices(devices: Vec) -> Result<(), String> { + unload_runtime_sinks(); + + let conf = conf_path().ok_or("no config directory")?; + if devices.is_empty() { + let _ = std::fs::remove_file(&conf); + return Ok(()); + } + + if let Some(parent) = conf.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("create conf dir: {e}"))?; + } + std::fs::write(&conf, conf_contents(&devices)).map_err(|e| format!("write conf: {e}"))?; + + for d in &devices { + create_runtime_sink(&d.id, &clean_label(&d.name))?; + } + Ok(()) +} + +fn conf_contents(devices: &[VirtualDeviceConfig]) -> String { + let mut out = String::from("# Auto-generated by Splitwave. Do not edit.\n\ncontext.objects = [\n"); + for d in devices { + let desc = clean_label(&d.name); + out.push_str(" {\n"); + out.push_str(" factory = adapter\n"); + out.push_str(" args = {\n"); + out.push_str(" factory.name = support.null-audio-sink\n"); + out.push_str(&format!(" node.name = \"{NODE_PREFIX}.{}\"\n", d.id)); + out.push_str(&format!(" node.description = \"{desc}\"\n")); + out.push_str(" media.class = Audio/Sink\n"); + out.push_str(" audio.position = [ FL FR ]\n"); + out.push_str(" object.linger = true\n"); + out.push_str(" }\n"); + out.push_str(" }\n"); + } + out.push_str("]\n"); + out +} + +// Create the sink in the running session so it shows up immediately. The .conf +// only takes effect on the next PipeWire start; object.linger keeps the node +// alive after pw-cli disconnects. +fn create_runtime_sink(id: &str, label: &str) -> Result<(), String> { + let props = format!( + "{{ factory.name=support.null-audio-sink node.name={NODE_PREFIX}.{id} node.description=\"{label}\" media.class=Audio/Sink audio.position=[ FL FR ] object.linger=true }}" + ); + let out = Command::new("pw-cli") + .args(["create-node", "adapter", props.as_str()]) + .output() + .map_err(|e| format!("pw-cli create-node: {e}"))?; + if !out.status.success() { + return Err(format!( + "pw-cli create-node failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + )); + } + Ok(()) +} + +fn unload_runtime_sinks() { + let prefix = format!("{NODE_PREFIX}."); + let Ok(nodes) = nodes_by_class("Audio/Sink") else { + return; + }; + for n in nodes { + if n.name.starts_with(&prefix) { + let id = n.id.to_string(); + let _ = Command::new("pw-cli").args(["destroy", id.as_str()]).status(); + } + } +} diff --git a/src-tauri/src/audio/virtual_device.rs b/src-tauri/src/audio/virtual_device/macos.rs similarity index 95% rename from src-tauri/src/audio/virtual_device.rs rename to src-tauri/src/audio/virtual_device/macos.rs index 1e4c14a..cd491f0 100644 --- a/src-tauri/src/audio/virtual_device.rs +++ b/src-tauri/src/audio/virtual_device/macos.rs @@ -6,6 +6,11 @@ use std::process::Command; use tauri::{AppHandle, Manager}; use tracing::{error, info}; +use super::{VirtualDeviceConfig, VirtualDriverStatus}; + +const DRIVER_NAME: &str = "Splitwave.driver"; +const HAL_DIR: &str = "/Library/Audio/Plug-Ins/HAL"; + // Reject paths that would escape single-quote shell quoting or the enclosing AppleScript string. fn shell_safe(path: &Path) -> Result<&str, String> { let s = path.to_str().ok_or("path is not valid UTF-8")?; @@ -45,12 +50,6 @@ fn write_temp_plist(content: &str) -> Result { Ok(guard) } -#[derive(Debug, Clone, serde::Deserialize)] -pub struct VirtualDeviceConfig { - pub id: String, - pub name: String, -} - fn xml_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") @@ -104,15 +103,6 @@ end run"#; Ok(()) } -const DRIVER_NAME: &str = "Splitwave.driver"; -const HAL_DIR: &str = "/Library/Audio/Plug-Ins/HAL"; - -#[derive(Debug, Clone, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct VirtualDriverStatus { - pub installed: bool, -} - pub fn status() -> VirtualDriverStatus { let installed = Path::new(HAL_DIR).join(DRIVER_NAME).exists(); info!(installed, "virtual driver status"); diff --git a/src-tauri/src/audio/virtual_device/mod.rs b/src-tauri/src/audio/virtual_device/mod.rs new file mode 100644 index 0000000..4a1df11 --- /dev/null +++ b/src-tauri/src/audio/virtual_device/mod.rs @@ -0,0 +1,21 @@ +#[derive(Debug, Clone, serde::Deserialize)] +pub struct VirtualDeviceConfig { + pub id: String, + pub name: String, +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VirtualDriverStatus { + pub installed: bool, +} + +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "macos")] +pub use macos::{apply_virtual_devices, install, status, uninstall}; + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "linux")] +pub use linux::{apply_virtual_devices, install, status, uninstall}; diff --git a/src-tauri/src/audio/volume/linux.rs b/src-tauri/src/audio/volume/linux.rs new file mode 100644 index 0000000..09a9180 --- /dev/null +++ b/src-tauri/src/audio/volume/linux.rs @@ -0,0 +1,63 @@ +use std::process::Command; + +use crate::audio::device::DeviceKind; +use crate::audio::pw_enum; + +// wpctl takes @DEFAULT_*@ or a numeric node id. Default routes use the alias; +// named devices resolve through the registry to their current node id. +fn target(kind: DeviceKind, name: &str) -> Option { + match name { + "default" | "pipewire" | "sysdefault" => Some( + match kind { + DeviceKind::Input => "@DEFAULT_AUDIO_SOURCE@", + DeviceKind::Output => "@DEFAULT_AUDIO_SINK@", + } + .to_string(), + ), + _ => resolve_id(kind, name).map(|id| id.to_string()), + } +} + +fn resolve_id(kind: DeviceKind, name: &str) -> Option { + let class = match kind { + DeviceKind::Input => "Audio/Source", + DeviceKind::Output => "Audio/Sink", + }; + let nodes = pw_enum::nodes_by_class(class).ok()?; + nodes.into_iter().find(|n| n.name == name).map(|n| n.id) +} + +pub fn device_volume(kind: DeviceKind, name: &str) -> Option { + let id = target(kind, name)?; + let out = Command::new("wpctl").args(["get-volume", &id]).output().ok()?; + if !out.status.success() { + return None; + } + let s = String::from_utf8_lossy(&out.stdout); + if s.contains("[MUTED]") { + return Some(0.0); + } + let v: f32 = s.split_whitespace().nth(1)?.parse().ok()?; + Some(v.clamp(0.0, 1.0)) +} + +pub fn set_device_volume(kind: DeviceKind, name: &str, scalar: f32) -> bool { + let Some(id) = target(kind, name) else { + return false; + }; + if scalar <= 0.0 { + return run(&["set-mute", &id, "1"]); + } + if !run(&["set-mute", &id, "0"]) { + return false; + } + run(&["set-volume", &id, &format!("{scalar:.4}")]) +} + +fn run(args: &[&str]) -> bool { + Command::new("wpctl") + .args(args) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} diff --git a/src-tauri/src/audio/volume/macos.rs b/src-tauri/src/audio/volume/macos.rs new file mode 100644 index 0000000..ca7b10e --- /dev/null +++ b/src-tauri/src/audio/volume/macos.rs @@ -0,0 +1,9 @@ +use crate::audio::device::DeviceKind; + +pub fn device_volume(kind: DeviceKind, name: &str) -> Option { + crate::audio::macos_hal::device_volume(kind, name) +} + +pub fn set_device_volume(kind: DeviceKind, name: &str, scalar: f32) -> bool { + crate::audio::macos_hal::set_device_volume(kind, name, scalar) +} diff --git a/src-tauri/src/audio/volume/mod.rs b/src-tauri/src/audio/volume/mod.rs new file mode 100644 index 0000000..4751e1f --- /dev/null +++ b/src-tauri/src/audio/volume/mod.rs @@ -0,0 +1,9 @@ +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "macos")] +pub use macos::{device_volume, set_device_volume}; + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "linux")] +pub use linux::{device_volume, set_device_volume}; diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 9780818..ac73df4 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -105,33 +105,17 @@ pub fn update_effect( #[tauri::command] pub fn get_device_volume(kind: DeviceKind, name: String) -> Option { - #[cfg(target_os = "macos")] - { - return crate::audio::macos_hal::device_volume(kind, &name); - } - #[cfg(not(target_os = "macos"))] - { - let _ = (kind, name); - None - } + crate::audio::volume::device_volume(kind, &name) } #[tauri::command] pub fn set_device_volume(kind: DeviceKind, name: String, scalar: f32) -> AppResult<()> { - #[cfg(target_os = "macos")] - { - if crate::audio::macos_hal::set_device_volume(kind, &name, scalar) { - Ok(()) - } else { - Err(AppError::Device(format!( - "device {name:?} has no settable {kind:?} volume" - ))) - } - } - #[cfg(not(target_os = "macos"))] - { - let _ = (kind, name, scalar); - Err(AppError::Device("device volume control is macOS-only".into())) + if crate::audio::volume::set_device_volume(kind, &name, scalar) { + Ok(()) + } else { + Err(AppError::Device(format!( + "device {name:?} has no settable {kind:?} volume" + ))) } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 127cfdb..db5a038 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,14 +8,17 @@ use std::sync::OnceLock; use serde_json::json; use tracing::info; use state::AppState; +#[cfg(target_os = "macos")] use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder}; use tauri::{AppHandle, Emitter}; const PANIC_EVENT: &str = "error://panic"; +#[cfg(target_os = "macos")] const MENU_EVENT: &str = "menu://action"; static APP_HANDLE: OnceLock = OnceLock::new(); +#[cfg(target_os = "macos")] fn build_menu(app: &AppHandle) -> tauri::Result> { let about_action = MenuItemBuilder::with_id("about", "About Splitwave").build(app)?; let check_updates = MenuItemBuilder::with_id("check_updates", "Check for Updates...").build(app)?; @@ -89,11 +92,17 @@ pub fn run() { let handle = app.handle().clone(); let _ = APP_HANDLE.set(handle.clone()); - let menu = build_menu(&handle)?; - app.set_menu(menu)?; - app.on_menu_event(|app, event| { - let _ = app.emit(MENU_EVENT, event.id().0.as_str()); - }); + // Native menu only on macOS (top menu bar). On Linux GTK renders it + // as an in-window bar that clashes with the custom titlebar, so the + // menu actions live in the in-app header instead. + #[cfg(target_os = "macos")] + { + let menu = build_menu(&handle)?; + app.set_menu(menu)?; + app.on_menu_event(|app, event| { + let _ = app.emit(MENU_EVENT, event.id().0.as_str()); + }); + } Ok(()) }) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e364de9..aee9b5e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -15,10 +15,7 @@ "title": "Splitwave", "width": 1200, "height": 800, - "dragDropEnabled": false, - "titleBarStyle": "Overlay", - "hiddenTitle": true, - "trafficLightPosition": { "x": 14, "y": 18 } + "dragDropEnabled": false } ], "security": { @@ -29,20 +26,13 @@ "active": true, "targets": "all", "createUpdaterArtifacts": true, - "resources": { - "resources/Splitwave.driver": "Splitwave.driver" - }, "icon": [ "icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" - ], - "macOS": { - "infoPlist": "Info.plist", - "minimumSystemVersion": "13.0" - } + ] }, "plugins": { "updater": { diff --git a/src-tauri/tauri.linux.conf.json b/src-tauri/tauri.linux.conf.json new file mode 100644 index 0000000..0fa86a5 --- /dev/null +++ b/src-tauri/tauri.linux.conf.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "app": { + "windows": [ + { + "title": "Splitwave", + "width": 1200, + "height": 800, + "dragDropEnabled": false, + "decorations": false + } + ] + } +} diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json new file mode 100644 index 0000000..be973c9 --- /dev/null +++ b/src-tauri/tauri.macos.conf.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "app": { + "windows": [ + { + "title": "Splitwave", + "width": 1200, + "height": 800, + "dragDropEnabled": false, + "titleBarStyle": "Overlay", + "hiddenTitle": true, + "trafficLightPosition": { "x": 14, "y": 18 } + } + ] + }, + "bundle": { + "resources": { + "resources/Splitwave.driver": "Splitwave.driver" + }, + "macOS": { + "infoPlist": "Info.plist", + "minimumSystemVersion": "13.0" + } + } +} diff --git a/src/lib/components/layout/header.svelte b/src/lib/components/layout/header.svelte index d57ec37..d174fe8 100644 --- a/src/lib/components/layout/header.svelte +++ b/src/lib/components/layout/header.svelte @@ -1,6 +1,13 @@
{@render left?.()} -
- {@render right?.()} - - -
+
+ {@render right?.()} + + + + {#if isLinux} + + {#snippet trigger()} + + {/snippet} + +
+ + +
+ + +
+
+ +
+ + + +
+ {/if} +
diff --git a/src/lib/modules/about/ui/about_modal.svelte b/src/lib/modules/about/ui/about_modal.svelte index 4468e50..b497ea1 100644 --- a/src/lib/modules/about/ui/about_modal.svelte +++ b/src/lib/modules/about/ui/about_modal.svelte @@ -38,7 +38,7 @@

- Audio routing for macOS. Build a node graph of inputs, effects, and outputs; + Audio routing for macOS and Linux. Build a node graph of inputs, effects, and outputs; the engine processes audio in real time and writes to files in any of six formats.

diff --git a/src/lib/modules/app_info/index.ts b/src/lib/modules/app_info/index.ts index 1e2bf72..4b89c13 100644 --- a/src/lib/modules/app_info/index.ts +++ b/src/lib/modules/app_info/index.ts @@ -29,6 +29,7 @@ export function getCachedAppInfo(): AppInfo | null { } export function formatAppInfo(info: AppInfo): string { - const osLabel = info.platform === 'macos' ? 'macOS' : info.platform; + const osLabel = + info.platform === 'macos' ? 'macOS' : info.platform === 'linux' ? 'Linux' : info.platform; return `${osLabel} ${info.osVersion} (${info.arch})`; } diff --git a/src/lib/modules/flow/ui/input/app_audio.svelte b/src/lib/modules/flow/ui/input/app_audio.svelte index 87f23b3..b364b99 100644 --- a/src/lib/modules/flow/ui/input/app_audio.svelte +++ b/src/lib/modules/flow/ui/input/app_audio.svelte @@ -10,6 +10,9 @@ import { Refresh } from '$lib/components/icons'; import { onNodeAction } from '$lib/modules/flow/utils'; import { onDestroy, onMount } from 'svelte'; + import { platform } from '@tauri-apps/plugin-os'; + + const isLinux = platform() === 'linux'; type AppAudioNodeType = Node; let { id, data }: NodeProps = $props(); @@ -64,7 +67,11 @@

- Capture audio from a specific running app (ScreenCaptureKit, macOS 13+). + {#if isLinux} + Capture audio from an app. Only apps currently playing sound are listed. + {:else} + Capture audio from a specific running app (ScreenCaptureKit, macOS 13+). + {/if}

diff --git a/src/lib/modules/flow/ui/input/system_audio.svelte b/src/lib/modules/flow/ui/input/system_audio.svelte index 735e46c..c7b2768 100644 --- a/src/lib/modules/flow/ui/input/system_audio.svelte +++ b/src/lib/modules/flow/ui/input/system_audio.svelte @@ -8,6 +8,9 @@ import Wrapper from '../node.svelte'; import InputMeter from './_input_meter.svelte'; import Slider from '../effect/_slider.svelte'; + import { platform } from '@tauri-apps/plugin-os'; + + const isLinux = platform() === 'linux'; type SystemAudioNodeType = Node; let { id, data }: NodeProps = $props(); @@ -33,7 +36,9 @@ } } - onMount(refreshPermission); + onMount(() => { + if (!isLinux) refreshPermission(); + }); async function openPrivacySettings() { try { @@ -59,9 +64,13 @@

- Captures all system output via ScreenCaptureKit (macOS 13+). + {#if isLinux} + Captures all system output (PipeWire monitor). + {:else} + Captures all system output via ScreenCaptureKit (macOS 13+). + {/if}

- {#if permission !== 'allowed'} + {#if !isLinux && permission !== 'allowed'}
{/if} - + {#if !isLinux} + + {/if} = { kind: 'systemAudio', category: 'input', label: 'System Audio', - description: 'Capture everything the system is playing (ScreenCaptureKit, macOS 13+).', + description: 'Capture everything the system is playing.', component: SystemAudio, defaultData: { excludeCurrentApp: true, volume: 1 } }), diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 763ad1c..e27a2ca 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -14,6 +14,7 @@ import { ModalRender } from '$lib/modules/overlay/ui'; import { modalManager } from '$lib/modules/overlay/modal'; import { AboutModal } from '$lib/modules/about/ui'; + import { platform } from '@tauri-apps/plugin-os'; const isDev = import.meta.env.DEV; @@ -38,6 +39,17 @@ } } + // Linux has no native menu, so its Cmd/Ctrl+Z accelerators are gone -- wire + // undo/redo here. Skip while typing so text-field undo still works. + function onKeydown(e: KeyboardEvent) { + if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== 'z') return; + const t = e.target as HTMLElement | null; + if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return; + e.preventDefault(); + if (e.shiftKey) pipelineStore.editorActions?.redo(); + else pipelineStore.editorActions?.undo(); + } + onMount(() => { installErrorHandlers().catch(() => {}); loadAppInfo().catch(() => {}); @@ -47,10 +59,12 @@ listen('menu://action', (e) => handleMenu(e.payload)) .then((fn) => { unlistenMenu = fn; }) .catch(() => {}); + if (platform() === 'linux') window.addEventListener('keydown', onKeydown); }); onDestroy(() => { unlistenMenu?.(); + window.removeEventListener('keydown', onKeydown); audioStore.destroy(); }); diff --git a/src/routes/virtual-devices/+page.svelte b/src/routes/virtual-devices/+page.svelte index d598a78..39da17a 100644 --- a/src/routes/virtual-devices/+page.svelte +++ b/src/routes/virtual-devices/+page.svelte @@ -5,6 +5,9 @@ import type { VirtualDeviceConfig, VirtualDriverStatus } from '$lib/modules/audio/types'; import Header from '$lib/components/layout/header.svelte'; import { page } from '$app/state'; + import { platform } from '@tauri-apps/plugin-os'; + + const isLinux = platform() === 'linux'; const store = new LazyStore('virtual-devices.json'); const STORE_KEY = 'devices'; @@ -105,28 +108,30 @@

-
-
-
Audio Server Plugin
-
- {status?.installed ? 'Installed' : 'Not installed'} — required for virtual devices to - appear in system audio + {#if !isLinux} +
+
+
Audio Server Plugin
+
+ {status?.installed ? 'Installed' : 'Not installed'} — required for virtual devices to + appear in system audio +
+ {#if status?.installed} + + {:else} + + {/if}
- {#if status?.installed} - - {:else} - - {/if} -
+ {/if} - {#if status?.installed} + {#if isLinux || status?.installed}

Devices

@@ -163,7 +168,11 @@ {#if dirty}
Changes not applied - Press Apply to update the system audio devices. macOS will briefly interrupt audio playback. + {#if isLinux} + Press Apply to update the system audio devices. + {:else} + Press Apply to update the system audio devices. macOS will briefly interrupt audio playback. + {/if}
{/if}