From b230e6829aeeb9b60127f3b77e3339f13f8880d1 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Sun, 13 Sep 2026 11:16:53 +0200 Subject: [PATCH] Native low-latency PipeWire capture for -p (drop pw-record subprocess) -p now connects a real capture stream via the pipewire crate and pushes mono f32 straight into the render channel from the RT process callback, instead of spawning pw-record and parsing its s16 stdout. It captures the default sink monitor (what you hear), negotiates F32 at the graph's native rate/channels, and takes a --latency quantum hint (default 256) so you can trade CPU/xrun-risk for lower latency. libpipewire becomes a build+runtime dependency: CI installs the dev headers + clang, and the PKGBUILD depends on libpipewire. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GLUwWE2KmFPzhKaf67tWbx --- .gitea/workflows/release.yml | 10 + Cargo.lock | 408 ++++++++++++++++++++++++++++++++++- Cargo.toml | 1 + aur/PKGBUILD | 2 +- src/main.rs | 117 +++++----- src/pipewire_capture.rs | 153 +++++++++++++ 6 files changed, 619 insertions(+), 72 deletions(-) create mode 100644 src/pipewire_capture.rs diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 1b314e1..456ceb3 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -36,6 +36,16 @@ jobs: path: ~/.cargo/registry key: ${{ runner.os }}-${{ matrix.arch }}-cargo-${{ hashFiles('Cargo.lock') }} + - name: Install audio build deps (libpipewire + clang for native capture) + run: | + SUDO=""; command -v sudo >/dev/null && SUDO=sudo + if command -v apt-get >/dev/null; then + $SUDO apt-get update + $SUDO apt-get install -y libpipewire-0.3-dev libspa-0.2-dev clang libclang-dev pkg-config + elif command -v pacman >/dev/null; then + $SUDO pacman -Sy --needed --noconfirm pipewire clang pkgconf + fi + - name: Build run: cargo build --release --target ${{ matrix.target }} diff --git a/Cargo.lock b/Cargo.lock index a05c003..9a689bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,25 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[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 = "anstream" version = "0.6.18" @@ -57,6 +76,25 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "annotate-snippets", + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.87", +] + [[package]] name = "bitflags" version = "2.13.0" @@ -69,12 +107,52 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "cc" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +dependencies = [ + "find-msvc-tools", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "cfg-expr" +version = "0.20.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4ece8474b5f766c63426647e7b4b316b67431ade1036a8313cee24a03ae917" +dependencies = [ + "smallvec", + "target-lexicon", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" version = "4.5.21" @@ -106,7 +184,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.87", ] [[package]] @@ -130,6 +208,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" + [[package]] name = "crossterm" version = "0.29.0" @@ -176,7 +260,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn", + "syn 2.0.87", ] [[package]] @@ -188,6 +272,18 @@ dependencies = [ "litrs", ] +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -195,27 +291,101 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libspa" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882f7427e7989dcc9d388b7f05c4630390a1d7696f9ffa469cd4a7a48f0b4c40" +dependencies = [ + "bitflags", + "cc", + "cookie-factory", + "libc", + "libspa-sys", + "nom 8.0.0", + "rustix", + "system-deps", +] + +[[package]] +name = "libspa-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b6e17bdaf63ed0d5e4144022624032b41fd9733112e8c74ac26fc9bf1291924" +dependencies = [ + "bindgen", + "cc", + "system-deps", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -243,6 +413,18 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.2.1" @@ -255,6 +437,25 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "num-complex" version = "0.4.6" @@ -305,6 +506,37 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pipewire" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde71084c4e25959d68f1ea54daa75e5ecdb338e5caf0b5510143b79baa32d5c" +dependencies = [ + "bitflags", + "libc", + "libspa", + "libspa-sys", + "pipewire-sys", + "rustix", +] + +[[package]] +name = "pipewire-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce653f53e63e5b93853218092ee9a8906a5d082c92f3f1db26316955dd63ce0" +dependencies = [ + "bindgen", + "libspa-sys", + "system-deps", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + [[package]] name = "primal-check" version = "0.3.4" @@ -316,9 +548,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.89" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -341,6 +573,41 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -375,7 +642,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -390,6 +657,47 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook" version = "0.3.18" @@ -434,6 +742,7 @@ dependencies = [ "byteorder", "clap", "crossterm", + "pipewire", "rustfft", ] @@ -460,6 +769,75 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "system-deps" +version = "7.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "transpose" version = "0.2.3" @@ -482,12 +860,24 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + [[package]] name = "version_check" version = "0.9.5" @@ -609,3 +999,9 @@ name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" diff --git a/Cargo.toml b/Cargo.toml index 0e111a5..559a107 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,3 +12,4 @@ byteorder = "1.5.0" clap = { version = "4.5.21", features = ["derive"] } crossterm = "0.29.0" rustfft = "6.2.0" +pipewire = "0.10" diff --git a/aur/PKGBUILD b/aur/PKGBUILD index d5f2328..780579e 100644 --- a/aur/PKGBUILD +++ b/aur/PKGBUILD @@ -7,7 +7,7 @@ arch=('x86_64' 'aarch64') options=('!strip') url="https://project.uhhm.no/bl/spectrust" license=('MIT') -depends=() +depends=('libpipewire') provides=('spectrust') conflicts=('spectrust-git' 'spectrust-bin') source_x86_64=("spectrust-v${pkgver}-x86_64.tar.gz::https://project.uhhm.no/bl/spectrust/releases/download/v${pkgver}/spectrust-v${pkgver}-x86_64.tar.gz") diff --git a/src/main.rs b/src/main.rs index 622b695..b4d372e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,7 +17,8 @@ use std::sync::mpsc; use std::thread; use std::time::{Duration, Instant}; use byteorder::{LittleEndian, ReadBytesExt}; -use std::process::{Command, Stdio}; + +mod pipewire_capture; const EMPTY_CELL: Cell = Cell { character: ' ', filled: false }; const BLOCK_CELL: Cell = Cell { character: '█', filled: true }; @@ -121,7 +122,7 @@ fn handle_terminal_resize( long_about = "Spectrust is a PCM spectrum analyzer that visualizes audio in your terminal.\n\ It can read from stdin, a file, or capture directly from PipeWire.\n\n\ Examples:\n\ - - Direct PipeWire capture: spectrust -p\n\ + - Direct PipeWire capture (native, low-latency): spectrust -p\n\ - From audio file: spectrust -i audio.pcm\n\ - From stdin: pw-record --raw - | spectrust\n\n\ Runtime controls:\n\ @@ -133,9 +134,12 @@ struct Cli { #[arg(short, long, help = "Target frames per second", default_value_t = 60)] fps: u16, - #[arg(short, long, help = "Use PipeWire to capture audio directly")] + #[arg(short, long, help = "Capture audio directly via native low-latency PipeWire (default sink monitor)")] pipewire: bool, + #[arg(long, help = "PipeWire capture quantum in frames — lower is lower latency (more CPU/xrun risk)", default_value_t = 256)] + latency: u32, + #[arg(short, long, help = "Input file (reads from stdin if not provided)")] input: Option, @@ -184,77 +188,60 @@ fn main() { let _ = out.flush(); - let reader: Box = if cli.pipewire { - let spawn_result = Command::new("pw-record") - .args([ - "--format=s16", - &format!("--rate={}", cli.rate), - &format!("--channels={}", cli.channels), - "--raw", - "-", - ]) - .stdout(Stdio::piped()) - .spawn(); - match spawn_result { - Ok(mut process) => { - Box::new(process.stdout.take().expect("failed to capture pw-record stdout")) - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - let _ = execute!(out, LeaveAlternateScreen); - let _ = disable_raw_mode(); - eprintln!("error: pw-record not found — install PipeWire or pipe audio via stdin."); - std::process::exit(1); - } - Err(e) => { - let _ = execute!(out, LeaveAlternateScreen); - let _ = disable_raw_mode(); - eprintln!("error: failed to start pw-record: {e}"); - std::process::exit(1); - } - } - } else if let Some(filename) = &cli.input { - Box::new(File::open(filename).expect("failed to open input file")) - } else { - Box::new(std::io::stdin()) - }; - let (tx, rx) = mpsc::channel::>(); - let _audio_thread = thread::spawn(move || { - let mut reader = reader; - let mut input_buffer = vec![0u8; samples_per_frame * num_channels * (bit_depth / 8)]; - loop { - match reader.read(&mut input_buffer) { - Ok(0) => break, - Ok(n) => { - let mut samples = Vec::with_capacity(n / (num_channels * (bit_depth / 8))); - let mut cursor = std::io::Cursor::new(&input_buffer[..n]); - while cursor.position() < (n as u64 - (num_channels * (bit_depth / 8)) as u64 + 1) { - let mut frame_sum = 0.0f32; - let mut valid_channels = 0u32; - for _ in 0..num_channels { - match cursor.read_i16::() { - Ok(sample) => { - frame_sum += sample as f32; - valid_channels += 1; + + if cli.pipewire { + // Native low-latency capture: pushes mono f32 straight into `tx`. + if let Err(e) = pipewire_capture::spawn(tx, cli.rate, cli.channels, cli.latency) { + let _ = execute!(out, LeaveAlternateScreen); + let _ = disable_raw_mode(); + eprintln!("error: {e}"); + std::process::exit(1); + } + } else { + let reader: Box = if let Some(filename) = &cli.input { + Box::new(File::open(filename).expect("failed to open input file")) + } else { + Box::new(std::io::stdin()) + }; + + let _audio_thread = thread::spawn(move || { + let mut reader = reader; + let mut input_buffer = vec![0u8; samples_per_frame * num_channels * (bit_depth / 8)]; + loop { + match reader.read(&mut input_buffer) { + Ok(0) => break, + Ok(n) => { + let mut samples = Vec::with_capacity(n / (num_channels * (bit_depth / 8))); + let mut cursor = std::io::Cursor::new(&input_buffer[..n]); + while cursor.position() < (n as u64 - (num_channels * (bit_depth / 8)) as u64 + 1) { + let mut frame_sum = 0.0f32; + let mut valid_channels = 0u32; + for _ in 0..num_channels { + match cursor.read_i16::() { + Ok(sample) => { + frame_sum += sample as f32; + valid_channels += 1; + } + Err(_) => break, } - Err(_) => break, + } + if valid_channels > 0 { + samples.push((frame_sum / valid_channels as f32) / 32768.0); } } - if valid_channels > 0 { - samples.push((frame_sum / valid_channels as f32) / 32768.0); + if !samples.is_empty() && tx.send(samples).is_err() { + break; } } - if !samples.is_empty() && tx.send(samples).is_err() { - break; + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(1)); } + Err(_) => break, } - Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { - thread::sleep(Duration::from_millis(1)); - } - Err(_) => break, } - } - }); + }); + } let max_width: u16 = 1000; let max_height: u16 = 1000; diff --git a/src/pipewire_capture.rs b/src/pipewire_capture.rs new file mode 100644 index 0000000..cb45625 --- /dev/null +++ b/src/pipewire_capture.rs @@ -0,0 +1,153 @@ +//! Native, low-latency PipeWire capture. +//! +//! Instead of shelling out to `pw-record` (an extra process, its own +//! buffering, and an s16 round-trip), this connects a real capture stream to +//! the graph, negotiates F32 at a small quantum, and pushes mono f32 samples +//! straight into the render channel from the stream's realtime `process` +//! callback. By default it captures the monitor of the default sink (what you +//! hear); `latency` is a frames/rate hint the server honours by shrinking the +//! quantum. Rate and channel count follow the native graph format. + +use std::sync::mpsc::Sender; +use std::thread; + +use pipewire as pw; +use pw::spa; +use pw::{properties::properties, spa::pod::Pod}; +use spa::param::audio::AudioInfoRaw; +use spa::param::format::{MediaSubtype, MediaType}; +use spa::param::format_utils; + +/// Start capturing on a dedicated thread. Returns once the stream is connected +/// (or with an error describing why it could not start), so the caller can +/// report cleanly before taking over the terminal. +pub fn spawn(tx: Sender>, rate: u32, channels: u8, latency: u32) -> Result<(), String> { + let (ready_tx, ready_rx) = std::sync::mpsc::channel::>(); + thread::Builder::new() + .name("pipewire-capture".into()) + .spawn(move || { + if let Err(e) = run(tx, rate, channels, latency, ready_tx.clone()) { + let _ = ready_tx.send(Err(e)); + } + }) + .map_err(|e| format!("failed to spawn capture thread: {e}"))?; + ready_rx + .recv() + .map_err(|_| "pipewire capture thread exited before connecting".to_string())? +} + +fn run( + tx: Sender>, + rate: u32, + _channels: u8, + latency: u32, + ready: Sender>, +) -> Result<(), String> { + pw::init(); + + let mainloop = pw::main_loop::MainLoopRc::new(None).map_err(|e| format!("mainloop: {e}"))?; + let context = + pw::context::ContextRc::new(&mainloop, None).map_err(|e| format!("context: {e}"))?; + let core = context + .connect_rc(None) + .map_err(|e| format!("connect to PipeWire (is it running?): {e}"))?; + + let props = properties! { + *pw::keys::MEDIA_TYPE => "Audio", + *pw::keys::MEDIA_CATEGORY => "Capture", + *pw::keys::MEDIA_ROLE => "Music", + // Capture the monitor of the default sink - visualize what's playing. + *pw::keys::STREAM_CAPTURE_SINK => "true", + // Ask the server for a small quantum: lower = lower latency. + *pw::keys::NODE_LATENCY => format!("{latency}/{rate}"), + *pw::keys::NODE_NAME => "spectrust", + }; + + let stream = pw::stream::StreamBox::new(&core, "spectrust", props) + .map_err(|e| format!("stream: {e}"))?; + + // Channel count is learned from the negotiated format (param_changed) and + // read back in the realtime process callback; 0 until negotiated. + let _listener = stream + .add_local_listener_with_user_data(AudioInfoRaw::default()) + .param_changed(|_, info, id, param| { + let Some(param) = param else { return }; + if id != pw::spa::param::ParamType::Format.as_raw() { + return; + } + let Ok((mt, ms)) = format_utils::parse_format(param) else { + return; + }; + if mt != MediaType::Audio || ms != MediaSubtype::Raw { + return; + } + let _ = info.parse(param); + }) + .process(move |stream, info| { + let Some(mut buffer) = stream.dequeue_buffer() else { + return; + }; + let ch = info.channels() as usize; + if ch == 0 { + return; + } + let datas = buffer.datas_mut(); + let Some(data) = datas.first_mut() else { + return; + }; + let n_bytes = data.chunk().size() as usize; + let Some(bytes) = data.data() else { return }; + let bytes = &bytes[..n_bytes.min(bytes.len())]; + let frame_bytes = ch * 4; + let frames = bytes.len() / frame_bytes; + if frames == 0 { + return; + } + let mut mono = Vec::with_capacity(frames); + for f in 0..frames { + let base = f * frame_bytes; + let mut sum = 0.0f32; + for c in 0..ch { + let o = base + c * 4; + sum += f32::from_le_bytes([bytes[o], bytes[o + 1], bytes[o + 2], bytes[o + 3]]); + } + mono.push(sum / ch as f32); + } + let _ = tx.send(mono); + }) + .register() + .map_err(|e| format!("listener: {e}"))?; + + // EnumFormat pod: force F32; leave rate/channels empty to accept the + // native graph format (the server converts as needed). + let mut audio_info = AudioInfoRaw::new(); + audio_info.set_format(spa::param::audio::AudioFormat::F32LE); + 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), + ) + .map_err(|e| format!("pod serialize: {e}"))? + .0 + .into_inner(); + let mut params = [Pod::from_bytes(&values).ok_or("invalid format pod")?]; + + stream + .connect( + spa::utils::Direction::Input, + None, + pw::stream::StreamFlags::AUTOCONNECT + | pw::stream::StreamFlags::MAP_BUFFERS + | pw::stream::StreamFlags::RT_PROCESS, + &mut params, + ) + .map_err(|e| format!("connect stream: {e}"))?; + + let _ = ready.send(Ok(())); + mainloop.run(); + Ok(()) +}