18 Commits
Author SHA1 Message Date
Bendik Aagaard Lynghaug c38ef745a3 chore: Release spectrust version 0.2.9
Release / build (aarch64, aarch64, aarch64-unknown-linux-gnu) (push) Successful in 3m23s
Release / build (x86_64, ubuntu-latest, x86_64-unknown-linux-gnu) (push) Successful in 5m38s
Release / update-aur (push) Successful in 21s
2026-09-13 11:44:22 +02:00
Bendik Aagaard LynghaugandClaude Opus 4.8 bee1c7726a CI: Arch-registry publish uses a scoped REGISTRY_TOKEN, gated + non-fatal
The ephemeral GITHUB_TOKEN isn't accepted for package upload; use a
dedicated write:package secret instead. Skip cleanly when unset and
continue-on-error so the registry mirror never blocks the AUR push/release.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLUwWE2KmFPzhKaf67tWbx
2026-09-13 11:37:20 +02:00
Bendik Aagaard LynghaugandClaude Opus 4.8 14ad11db98 CI: cache build artifacts + --locked build (speeds the aarch64 host build)
Release / build (aarch64, aarch64, aarch64-unknown-linux-gnu) (push) Successful in 2m37s
Release / build (x86_64, ubuntu-latest, x86_64-unknown-linux-gnu) (push) Successful in 5m36s
Release / update-aur (push) Failing after 7s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLUwWE2KmFPzhKaf67tWbx
2026-09-13 11:25:55 +02:00
Bendik Aagaard LynghaugandClaude Opus 4.8 ca42eeb386 CI: never install packages on bare runners — check deps, install only in containers
Bare runners are real hosts; pacman -Sy in CI mutates them (and can leave a
partial-sync state). Now the ephemeral container runner installs its deps as
before, while the bare (aarch64/klokka) runner is only checked for
libpipewire+clang and fails with a clear message if they're absent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLUwWE2KmFPzhKaf67tWbx
2026-09-13 11:21:08 +02:00
Bendik Aagaard Lynghaug ab7e2797a9 chore: Release spectrust version 0.2.8
Release / build (aarch64, aarch64, aarch64-unknown-linux-gnu) (push) Failing after 7s
Release / build (x86_64, ubuntu-latest, x86_64-unknown-linux-gnu) (push) Successful in 5m36s
Release / update-aur (push) Skipped
2026-09-13 11:18:35 +02:00
Bendik Aagaard LynghaugandClaude Opus 4.8 b230e6829a 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 <frames> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLUwWE2KmFPzhKaf67tWbx
2026-09-13 11:16:53 +02:00
Bendik Aagaard LynghaugandClaude Opus 4.8 3dbe5c4ddd CI: fix Arch-registry publish (use makepkg --packagelist; !strip for cross-arch repack)
The runner's PKGEXT is .pkg.tar.xz, so the .zst glob never matched;
--packagelist yields the exact filename, and options=('!strip') lets the
foreign-arch binary be packaged on the aarch64 host without stripping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLUwWE2KmFPzhKaf67tWbx
2026-09-13 11:05:36 +02:00
Bendik Aagaard Lynghaug 63d9650c55 chore: Release spectrust version 0.2.7
Release / build (aarch64, aarch64, aarch64-unknown-linux-gnu) (push) Successful in 3m21s
Release / build (x86_64, ubuntu-latest, x86_64-unknown-linux-gnu) (push) Successful in 4m53s
Release / update-aur (push) Failing after 8s
2026-09-12 20:25:56 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 1cd207180f Rehome to project.uhhm.no: PKGBUILD URLs, ephemeral CI token, Arch registry publishing
- PKGBUILD url/source now point at this instance's releases.
- Release uploads use the run's own ephemeral token instead of the
  GITEA_TOKEN secret.
- The publish job also builds both architectures' packages (repack
  PKGBUILD, CARCH override) and uploads them to the instance Arch
  package registry (repository name: uhhm).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLUwWE2KmFPzhKaf67tWbx
2026-09-12 17:30:43 +02:00
Bendik Lynghaug 87e40a2afa Update README.md 2026-06-23 21:51:22 +02:00
Bendik Aagaard Lynghaug cd8c4a8ff3 chore: Release spectrust version 0.2.6 2026-06-23 21:16:24 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 4.6 b7e861093e feat: runtime controls, configurable audio format, README, UX polish
- Add runtime key bindings: [/] drop-off, ,/. log scaling, -/+ sensitivity
- Add --fps named flag (was positional), --rate and --channels CLI args
- Fix version string to auto-read from Cargo.toml
- Graceful error when pw-record is not found
- Add README with usage table, input mode examples, runtime controls
- Improve arg help text with ranges and clearer descriptions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 21:16:02 +02:00
Bendik Aagaard Lynghaug 5201b503e6 chore: Release spectrust version 0.2.5 2026-06-23 15:50:04 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 4.6 79544f64c3 perf: decouple audio capture from render loop with dedicated thread
Move PCM decoding into a producer thread that feeds an mpsc channel,
letting the render loop drain samples non-blockingly each frame. Add a
frame-aligned sleep via event::poll to replace the implicit CPU yield
that the old blocking read provided. Also replace powf(0.5) with sqrt().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 15:49:30 +02:00
Bendik Aagaard Lynghaug df06d7fa36 chore: Release spectrust version 0.2.4 2026-06-22 16:18:11 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 4.6 af5a345458 add MIT LICENSE
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 16:17:53 +02:00
Bendik Aagaard Lynghaug cf05f6a796 chore: Release spectrust version 0.2.3 2026-06-22 16:05:22 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 4.6 cce05dbfcc fix: remove ncurses dependency from PKGBUILD
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 16:02:59 +02:00
8 changed files with 855 additions and 133 deletions
+63 -8
View File
@@ -30,14 +30,40 @@ jobs:
with:
targets: ${{ matrix.target }}
- name: Cache cargo registry
- name: Cache cargo + build artifacts
uses: actions/cache@v3
with:
path: ~/.cargo/registry
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-${{ matrix.arch }}-cargo-${{ hashFiles('Cargo.lock') }}
# Reuse a prior build's compiled deps (pipewire-sys/bindgen is slow,
# especially on the aarch64 host) even when Cargo.lock changed.
restore-keys: |
${{ runner.os }}-${{ matrix.arch }}-cargo-
- name: Ensure audio build deps (libpipewire + clang for native capture)
run: |
if command -v apt-get >/dev/null; then
# Ephemeral container runner: safe to install into it.
SUDO=""; command -v sudo >/dev/null && SUDO=sudo
$SUDO apt-get update
$SUDO apt-get install -y libpipewire-0.3-dev libspa-0.2-dev clang libclang-dev pkg-config
else
# Bare/host runner: never mutate it - require deps preinstalled.
missing=""
pkg-config --exists libpipewire-0.3 2>/dev/null || missing="$missing libpipewire(+dev headers)"
command -v clang >/dev/null || missing="$missing clang"
if [ -n "$missing" ]; then
echo "::error::bare runner missing build deps:$missing — install once on the host; CI does not modify bare runners"
exit 1
fi
echo "bare runner already has libpipewire + clang"
fi
- name: Build
run: cargo build --release --target ${{ matrix.target }}
run: cargo build --release --locked --target ${{ matrix.target }}
- name: Package
run: |
@@ -53,7 +79,7 @@ jobs:
- name: Create release
run: |
curl -sX POST \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/json" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases" \
-d "{\"tag_name\":\"${{ gitea.ref_name }}\",\"name\":\"${{ gitea.ref_name }}\"}" \
@@ -62,23 +88,23 @@ jobs:
- name: Upload assets
run: |
RELEASE_ID=$(curl -s \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/tags/${{ gitea.ref_name }}" \
| jq -r '.id')
for FILE in "${{ env.TARBALL }}" "${{ env.TARBALL }}.sha256"; do
EXISTING=$(curl -s \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets" \
| jq -r ".[] | select(.name == \"${FILE}\") | .id")
for AID in $EXISTING; do
curl -sX DELETE \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets/${AID}"
done
curl -sX POST \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/octet-stream" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets?name=${FILE}" \
--data-binary "@${FILE}" --fail-with-body
@@ -102,6 +128,35 @@ jobs:
sed -i "s/sha256sums_x86_64=('.*')/sha256sums_x86_64=('${SUM_X86}')/" aur/PKGBUILD
sed -i "s/sha256sums_aarch64=('.*')/sha256sums_aarch64=('${SUM_AARCH}')/" aur/PKGBUILD
# Also publish the built packages to this instance's Arch registry
# (docs.gitea.com/usage/packages/arch). The PKGBUILD only repacks the
# release tarballs, so CARCH can produce both architectures from this
# one host. Consumers: see the infrastructure README.
# Best-effort mirror to the instance Arch registry. The ephemeral
# GITHUB_TOKEN is not accepted as a package-write credential, so this
# uses a dedicated REGISTRY_TOKEN secret (a write:package token for bl);
# if it is unset the step is skipped, and continue-on-error keeps a
# registry hiccup from failing the release or the AUR push.
- name: Publish to the Arch package registry
continue-on-error: true
run: |
set -euo pipefail
if [ -z "${{ secrets.REGISTRY_TOKEN }}" ]; then
echo "::warning::REGISTRY_TOKEN not set — skipping Arch registry publish"
exit 0
fi
cd aur
# --packagelist gives the exact filename (honours the runner's
# PKGEXT); !strip (in the PKGBUILD) lets the foreign-arch binary be
# packaged on this host without stripping.
for carch in aarch64 x86_64; do
pkgfile=$(CARCH="$carch" makepkg --packagelist | tail -1)
CARCH="$carch" makepkg -f --nodeps --noconfirm --skipinteg
curl --fail-with-body --user "bl:${{ secrets.REGISTRY_TOKEN }}" \
--upload-file "$pkgfile" \
"${{ gitea.server_url }}/api/packages/${{ gitea.repository_owner }}/arch/uhhm"
done
- name: Push to AUR
env:
AUR_SSH_KEY: ${{ secrets.AUR_SSH_KEY }}
Generated
+403 -7
View File
@@ -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"
@@ -429,11 +737,12 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "spectrust"
version = "0.2.2"
version = "0.2.9"
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"
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "spectrust"
version = "0.2.2"
version = "0.2.9"
edition = "2021"
[profile.release]
@@ -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"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Bendik Aagaard Lynghaug
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+74
View File
@@ -0,0 +1,74 @@
# spectrust
A terminal audio spectrum analyzer written in Rust. Visualizes PCM audio in real time using block characters — reads from PipeWire, a file, or stdin.
```
▁▂▃▄▅▆▇█▇▆▅▄▃▂▁▁▂▃▄▅▆▇█▇▆▅▄▃▂▁▁▂▃▄▅▆▇█
```
## Installation
**Arch Linux (AUR)**
```
paru -S spectrust
```
**From source**
```
cargo install --path .
```
Requires Rust 1.70+. PipeWire (`pw-record`) is only needed for the `-p` capture mode.
## Usage
```
spectrust [OPTIONS]
```
| Option | Default | Description |
|--------|---------|-------------|
| `-f, --fps <N>` | `60` | Target frames per second |
| `-p, --pipewire` | — | Capture audio directly from PipeWire |
| `-i, --input <FILE>` | — | Read PCM from a file (stdin if omitted) |
| `-l, --log-power <N>` | `1.4` | Frequency scaling power (0.5–3.0); higher values give more space to bass |
| `-d, --drop-off <N>` | `0.75` | Bar drop-off factor per frame (0.0 = instant, 1.0 = no drop) |
| `--rate <HZ>` | `48000` | Sample rate — must match the audio source |
| `--channels <N>` | `2` | Channel count — must match the audio source |
### Input modes
**PipeWire** — capture system audio directly:
```
spectrust -p
```
**stdin** — pipe from any recorder:
```
pw-record --format=s16 --rate=48000 --channels=2 --raw - | spectrust
arecord -f S16_LE -r 48000 -c 2 | spectrust
```
**File** — replay a raw PCM file:
```
spectrust -i recording.pcm
```
### Runtime controls
| Key | Action |
|-----|--------|
| `q` / `Ctrl+C` | Quit |
| `[` / `]` | Decrease / increase drop-off speed |
| `,` / `.` | Decrease / increase log frequency scaling |
| `-` / `+` | Decrease / increase sensitivity |
## Notes
- Input is expected as signed 16-bit little-endian PCM (s16le). This matches `pw-record --format=s16 --raw` and `arecord -f S16_LE`.
- If `--rate` or `--channels` don't match the actual source the visualization will be distorted.
- The sensitivity control scales bar heights at render time; it has the most noticeable effect on quiet signals.
## License
MIT
+7 -6
View File
@@ -1,16 +1,17 @@
# Maintainer: Bendik Aagaard Lynghaug <bendik.lynghaug@gmail.com>
pkgname=spectrust
pkgver=0.2.2
pkgver=0.2.9
pkgrel=1
pkgdesc="Terminal audio spectrum analyzer using ncurses"
pkgdesc="Terminal audio spectrum analyzer"
arch=('x86_64' 'aarch64')
url="https://prosjekt.klingenbergbygg.no/bl/spectrust"
options=('!strip')
url="https://project.uhhm.no/bl/spectrust"
license=('MIT')
depends=('ncurses')
depends=('libpipewire')
provides=('spectrust')
conflicts=('spectrust-git' 'spectrust-bin')
source_x86_64=("spectrust-v${pkgver}-x86_64.tar.gz::https://prosjekt.klingenbergbygg.no/bl/spectrust/releases/download/v${pkgver}/spectrust-v${pkgver}-x86_64.tar.gz")
source_aarch64=("spectrust-v${pkgver}-aarch64.tar.gz::https://prosjekt.klingenbergbygg.no/bl/spectrust/releases/download/v${pkgver}/spectrust-v${pkgver}-aarch64.tar.gz")
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")
source_aarch64=("spectrust-v${pkgver}-aarch64.tar.gz::https://project.uhhm.no/bl/spectrust/releases/download/v${pkgver}/spectrust-v${pkgver}-aarch64.tar.gz")
sha256sums_x86_64=('SKIP')
sha256sums_aarch64=('SKIP')
+132 -111
View File
@@ -13,9 +13,12 @@ use rustfft::{algorithm::Radix4, num_complex::Complex, Fft, FftDirection};
use std::collections::VecDeque;
use std::io::{stdout, Read, Write};
use std::fs::File;
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 };
@@ -63,6 +66,15 @@ fn render_bar(buf: &mut Vec<Vec<Cell>>, col: usize, sr: usize, bar_height_f: f32
}
}
fn build_freq_mapping(sc: usize, log_power: f32, fft_size: usize) -> Vec<usize> {
(0..sc)
.map(|i| {
let log_pos = (i as f32 / sc as f32).powf(log_power * 0.8);
((log_pos * (fft_size / 2) as f32) as usize).min(fft_size / 2 - 1)
})
.collect()
}
fn handle_terminal_resize(
rows: &mut u16,
cols: &mut u16,
@@ -96,12 +108,7 @@ fn handle_terminal_resize(
freq_bands.clear();
freq_bands.resize(sc, 0.0);
freq_mapping.clear();
for i in 0..sc {
let log_pos = (i as f32 / sc as f32).powf(log_power * 0.8);
let index = (log_pos * (fft_size / 2) as f32) as usize;
freq_mapping.push(index.min(fft_size / 2 - 1));
}
*freq_mapping = build_freq_mapping(sc, log_power, fft_size);
let mut out = stdout();
let _ = queue!(out, Clear(ClearType::All));
@@ -110,29 +117,43 @@ fn handle_terminal_resize(
#[derive(Parser)]
#[command(name = "Spectrust")]
#[command(version = "0.1.0")]
#[command(version)]
#[command(about = "A PCM spectrum analyzer for audio visualization",
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")]
- From stdin: pw-record --raw - | spectrust\n\n\
Runtime controls:\n\
- q / Ctrl+C quit\n\
- [ / ] decrease / increase drop-off\n\
- , / . decrease / increase log scaling\n\
- - / + decrease / increase sensitivity")]
struct Cli {
#[arg(value_name = "FPS", default_value_t = 60)]
#[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<String>,
#[arg(short, long, help = "Logarithmic scaling power (higher values emphasize lower frequencies)", default_value_t = 1.4)]
#[arg(short, long, help = "Logarithmic frequency scaling power (0.5–3.0; higher values give more space to bass)", default_value_t = 1.4)]
log_power: f32,
#[arg(short, long, help = "Drop-off factor for bar animation (0.0-1.0)", default_value_t = 0.75)]
#[arg(short, long, help = "Bar drop-off factor per frame (0.0 = instant drop, 1.0 = no drop)", default_value_t = 0.75)]
drop_off: f32,
#[arg(long, help = "Sample rate in Hz — must match the audio source", default_value_t = 48000)]
rate: u32,
#[arg(long, help = "Number of audio channels — must match the audio source", default_value_t = 2)]
channels: u8,
}
fn main() {
@@ -151,44 +172,76 @@ fn main() {
let (mut cols, mut rows) = terminal::size().expect("failed to get terminal size");
let sample_rate: usize = 48000;
let sample_rate: usize = cli.rate as usize;
let bit_depth: usize = 16;
let num_channels: usize = 2;
let num_channels: usize = cli.channels as usize;
let samples_per_frame: usize = sample_rate / usize::from(cli.fps);
let fft_size: usize = 1024;
let fft = Radix4::new(fft_size, FftDirection::Forward);
let mut input_buffer = vec![0u8; samples_per_frame * num_channels * (bit_depth / 8)];
let mut complex_buffer = vec![Complex { re: 0.0f32, im: 0.0f32 }; fft_size];
let hann_window: Vec<f32> = (0..fft_size)
.map(|i| 0.5 * (1.0 - (2.0 * std::f32::consts::PI * i as f32 / fft_size as f32).cos()))
.collect();
enum AudioSource {
StdIn(std::io::StdinLock<'static>),
File(File),
PipeWire(std::process::ChildStdout),
}
let _ = out.flush();
let mut audio_source = if cli.pipewire {
let process = Command::new("pw-record")
.args(["--format=s16", "--rate=48000", "--channels=2", "--raw", "-"])
.stdout(Stdio::piped())
.spawn()
.expect("failed to start pw-record");
let pipe = process.stdout.expect("failed to capture pw-record stdout");
let _ = out.flush();
AudioSource::PipeWire(pipe)
} else if let Some(filename) = &cli.input {
let file = File::open(filename).expect("failed to open input file");
AudioSource::File(file)
let (tx, rx) = mpsc::channel::<Vec<f32>>();
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 {
AudioSource::StdIn(std::io::stdin().lock())
};
let reader: Box<dyn Read + Send + 'static> = 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::<LittleEndian>() {
Ok(sample) => {
frame_sum += sample as f32;
valid_channels += 1;
}
Err(_) => break,
}
}
if valid_channels > 0 {
samples.push((frame_sum / valid_channels as f32) / 32768.0);
}
}
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,
}
}
});
}
let max_width: u16 = 1000;
let max_height: u16 = 1000;
@@ -204,19 +257,16 @@ fn main() {
let mut last_bars: Vec<f32> = vec![0.0; sc];
let mut freq_bands: Vec<f32> = vec![0.0; sc];
let drop_factor: f32 = cli.drop_off.clamp(0.0, 1.0);
let mut drop_factor: f32 = cli.drop_off.clamp(0.0, 1.0);
let mut log_power: f32 = cli.log_power;
let mut sensitivity: f32 = 1.0;
let interval = Duration::from_secs_f32(1.0 / f32::from(cli.fps));
let mut last_frame_time = Instant::now();
let mut max_magnitudes: VecDeque<f32> = VecDeque::with_capacity(usize::from(cli.fps / 2));
let mut freq_mapping: Vec<usize> = (0..sc)
.map(|i| {
let log_pos = (i as f32 / sc as f32).powf(cli.log_power * 0.8);
((log_pos * (fft_size / 2) as f32) as usize).min(fft_size / 2 - 1)
})
.collect();
let mut freq_mapping: Vec<usize> = build_freq_mapping(sc, log_power, fft_size);
let mut last_terminal_resize = Instant::now();
let resize_check_interval = Duration::from_millis(500);
@@ -226,7 +276,8 @@ fn main() {
let mut max_magnitude: f32 = 1.0;
loop {
if event::poll(Duration::ZERO).unwrap_or(false) {
let time_to_next_frame = interval.saturating_sub(last_frame_time.elapsed());
if event::poll(time_to_next_frame).unwrap_or(false) {
match event::read() {
Ok(Event::Key(KeyEvent { code: KeyCode::Char('q'), .. })) => break,
Ok(Event::Key(KeyEvent {
@@ -234,13 +285,33 @@ fn main() {
modifiers: KeyModifiers::CONTROL,
..
})) => break,
Ok(Event::Key(KeyEvent { code: KeyCode::Char('['), .. })) => {
drop_factor = (drop_factor - 0.05).max(0.0);
}
Ok(Event::Key(KeyEvent { code: KeyCode::Char(']'), .. })) => {
drop_factor = (drop_factor + 0.05).min(1.0);
}
Ok(Event::Key(KeyEvent { code: KeyCode::Char(','), .. })) => {
log_power = (log_power - 0.1).max(0.5);
freq_mapping = build_freq_mapping(sc, log_power, fft_size);
}
Ok(Event::Key(KeyEvent { code: KeyCode::Char('.'), .. })) => {
log_power = (log_power + 0.1).min(3.0);
freq_mapping = build_freq_mapping(sc, log_power, fft_size);
}
Ok(Event::Key(KeyEvent { code: KeyCode::Char('-'), .. })) => {
sensitivity = (sensitivity / 1.2).max(0.1);
}
Ok(Event::Key(KeyEvent { code: KeyCode::Char('+' | '='), .. })) => {
sensitivity = (sensitivity * 1.2).min(10.0);
}
Ok(Event::Resize(new_cols, new_rows)) => {
handle_terminal_resize(
&mut rows, &mut cols, new_rows, new_cols,
max_width, max_height, &mut safe_rows, &mut safe_cols,
&mut current_buffer, &mut next_buffer,
&mut last_bars, &mut freq_bands, &mut freq_mapping,
cli.log_power, fft_size,
log_power, fft_size,
);
sc = safe_cols as usize;
sr = safe_rows as usize;
@@ -257,7 +328,7 @@ fn main() {
max_width, max_height, &mut safe_rows, &mut safe_cols,
&mut current_buffer, &mut next_buffer,
&mut last_bars, &mut freq_bands, &mut freq_mapping,
cli.log_power, fft_size,
log_power, fft_size,
);
sc = safe_cols as usize;
sr = safe_rows as usize;
@@ -272,69 +343,17 @@ fn main() {
}
all_samples.clear();
let mut buffer_is_empty = false;
loop {
let read_result = match &mut audio_source {
AudioSource::StdIn(input) => input.read(&mut input_buffer),
AudioSource::File(file) => file.read(&mut input_buffer),
AudioSource::PipeWire(pipe) => pipe.read(&mut input_buffer),
};
match read_result {
Ok(0) => {
buffer_is_empty = true;
break;
}
Ok(n) => {
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::<LittleEndian>() {
Ok(sample) => {
frame_sum += sample as f32;
valid_channels += 1;
}
Err(_) => break,
}
}
if valid_channels > 0 {
all_samples.push((frame_sum / valid_channels as f32) / 32768.0);
}
}
if all_samples.len() >= fft_size * 2 {
break;
}
}
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
buffer_is_empty = true;
break;
}
Err(e) => {
let error_msg = format!("Error: {}", e);
for (i, c) in error_msg.chars().enumerate() {
if i < sc {
next_buffer[0][i] = Cell { character: c, filled: true };
}
}
view_diff.clear();
for y in 0..sr {
for x in 0..sc {
if current_buffer[y][x] != next_buffer[y][x] {
view_diff.add_change(y, x, next_buffer[y][x].character);
}
}
}
view_diff.apply();
std::mem::swap(&mut current_buffer, &mut next_buffer);
let _ = out.flush();
break;
}
match rx.try_recv() {
Ok(batch) => all_samples.extend_from_slice(&batch),
Err(_) => break,
}
}
// Latency guard: if the audio thread got ahead, drop old samples
if all_samples.len() > fft_size * 2 {
let excess = all_samples.len() - fft_size * 2;
all_samples.drain(..excess);
}
if !all_samples.is_empty() {
if all_samples.len() > fft_size {
@@ -364,7 +383,7 @@ fn main() {
if mapping_idx < fft_size / 2 {
let magnitude = complex_buffer[mapping_idx].norm();
let freq_boost = 1.0 + (mapping_idx as f32 / (fft_size / 2) as f32) * 4.0;
let scaled = (magnitude * 80.0 * freq_boost).powf(0.5);
let scaled = (magnitude * 80.0 * freq_boost).sqrt();
freq_bands[i] = if scaled > last_bars[i] { scaled } else { last_bars[i] * drop_factor };
}
}
@@ -382,7 +401,8 @@ fn main() {
max_magnitude = max_magnitudes.iter().copied().fold(0.0f32, f32::max).max(0.1);
for (i, &magnitude) in last_bars[..sc].iter().enumerate() {
render_bar(&mut next_buffer, i, sr, (magnitude / max_magnitude) * sr as f32);
let height = ((magnitude / max_magnitude) * sr as f32 * sensitivity).min(sr as f32);
render_bar(&mut next_buffer, i, sr, height);
}
view_diff.clear();
@@ -397,10 +417,11 @@ fn main() {
std::mem::swap(&mut current_buffer, &mut next_buffer);
last_frame_time = Instant::now();
let _ = out.flush();
} else if buffer_is_empty {
} else {
for i in 0..sc {
last_bars[i] *= drop_factor;
render_bar(&mut next_buffer, i, sr, (last_bars[i] / max_magnitude) * sr as f32);
let height = ((last_bars[i] / max_magnitude) * sr as f32 * sensitivity).min(sr as f32);
render_bar(&mut next_buffer, i, sr, height);
}
view_diff.clear();
+153
View File
@@ -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<Vec<f32>>, rate: u32, channels: u8, latency: u32) -> Result<(), String> {
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<(), String>>();
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<Vec<f32>>,
rate: u32,
_channels: u8,
latency: u32,
ready: Sender<Result<(), String>>,
) -> 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<u8> = 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(())
}