12 Commits

Author SHA1 Message Date
bl cd8c4a8ff3 chore: Release spectrust version 0.2.6
Release / build (x86_64, ubuntu-latest, x86_64-unknown-linux-gnu) (push) Successful in 4m12s
Release / build (aarch64, aarch64, aarch64-unknown-linux-gnu) (push) Successful in 5m28s
Release / update-aur (push) Successful in 12s
2026-06-23 21:16:24 +02:00
bl 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
bl 5201b503e6 chore: Release spectrust version 0.2.5
Release / update-aur (push) Successful in 1m15s
Release / build (x86_64, ubuntu-latest, x86_64-unknown-linux-gnu) (push) Successful in 4m12s
Release / build (aarch64, aarch64, aarch64-unknown-linux-gnu) (push) Successful in 4m29s
2026-06-23 15:50:04 +02:00
bl 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
bl df06d7fa36 chore: Release spectrust version 0.2.4
Release / build (aarch64, aarch64, aarch64-unknown-linux-gnu) (push) Successful in 4m4s
Release / build (x86_64, ubuntu-latest, x86_64-unknown-linux-gnu) (push) Successful in 4m12s
Release / update-aur (push) Successful in 14s
2026-06-22 16:18:11 +02:00
bl af5a345458 add MIT LICENSE
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 16:17:53 +02:00
bl cf05f6a796 chore: Release spectrust version 0.2.3
Release / build (aarch64, aarch64, aarch64-unknown-linux-gnu) (push) Failing after 58s
Release / build (x86_64, ubuntu-latest, x86_64-unknown-linux-gnu) (push) Failing after 1m9s
Release / update-aur (push) Has been skipped
2026-06-22 16:05:22 +02:00
bl 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
bl 70543e210a chore: Release spectrust version 0.2.2
Release / build (x86_64, ubuntu-latest, x86_64-unknown-linux-gnu) (push) Failing after 1m7s
Release / build (aarch64, aarch64, aarch64-unknown-linux-gnu) (push) Failing after 1m6s
Release / update-aur (push) Has been skipped
2026-06-22 16:01:58 +02:00
bl ccfb20f8bf fix: replace ncurses with crossterm to fix CI builds on both arches
ncurses-rs is broken on Arch Linux ARM (missing TRUE constant in
generated bindings) and the git HEAD version breaks Ubuntu too.
crossterm is pure Rust with no C dependency, eliminating the issue
on all platforms.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 16:01:36 +02:00
bl 0bb335e429 chore: Release spectrust version 0.2.1
Release / build (aarch64, aarch64, aarch64-unknown-linux-gnu) (push) Failing after 32s
Release / build (x86_64, ubuntu-latest, x86_64-unknown-linux-gnu) (push) Failing after 57s
Release / update-aur (push) Has been skipped
2026-06-22 15:20:45 +02:00
bl 754527d029 fix: patch ncurses via git to fix aarch64 build
ncurses-6.0.1 on crates.io uses a TRUE constant that Arch Linux ARM's
ncurses headers no longer define, breaking the rpi runner. Patch to git
HEAD which has the fix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 15:20:20 +02:00
7 changed files with 587 additions and 196 deletions
-6
View File
@@ -25,12 +25,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Install system dependencies
if: matrix.arch == 'x86_64'
run: |
sudo apt-get update
sudo apt-get install -y libncurses-dev
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
Generated
+271 -23
View File
@@ -38,7 +38,7 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c"
dependencies = [
"windows-sys",
"windows-sys 0.59.0",
]
[[package]]
@@ -48,7 +48,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125"
dependencies = [
"anstyle",
"windows-sys",
"windows-sys 0.59.0",
]
[[package]]
@@ -57,6 +57,12 @@ version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
[[package]]
name = "bitflags"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "byteorder"
version = "1.5.0"
@@ -64,13 +70,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "cc"
version = "1.2.1"
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd9de9f2205d5ef3fd67e685b0df337994ddd4495e2a28d185500d0e1edfea47"
dependencies = [
"shlex",
]
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "clap"
@@ -118,6 +121,83 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
[[package]]
name = "convert_case"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "crossterm"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
dependencies = [
"bitflags",
"crossterm_winapi",
"derive_more",
"document-features",
"mio",
"parking_lot",
"rustix",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm_winapi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
dependencies = [
"winapi",
]
[[package]]
name = "derive_more"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
"convert_case",
"proc-macro2",
"quote",
"rustc_version",
"syn",
]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
]
[[package]]
name = "heck"
version = "0.5.0"
@@ -132,19 +212,47 @@ checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
[[package]]
name = "libc"
version = "0.2.164"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "433bfe06b8c75da9b2e3fbea6e5329ff87748f0b144ef75306e674c3f6f7c13f"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "ncurses"
version = "6.0.1"
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbd71afa95710e841d173521e8483e764004eb332bdf47bd01d00f568688027f"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "mio"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [
"cc",
"libc",
"pkg-config",
"log",
"wasi",
"windows-sys 0.61.2",
]
[[package]]
@@ -175,10 +283,27 @@ dependencies = [
]
[[package]]
name = "pkg-config"
version = "0.3.31"
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "primal-check"
@@ -207,6 +332,24 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "rustfft"
version = "6.2.0"
@@ -223,18 +366,74 @@ dependencies = [
]
[[package]]
name = "shlex"
version = "1.3.0"
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.59.0",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-mio"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
"libc",
"mio",
"signal-hook",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "smallvec"
version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "spectrust"
version = "0.2.0"
version = "0.2.6"
dependencies = [
"byteorder",
"clap",
"ncurses",
"crossterm",
"rustfft",
]
@@ -277,6 +476,12 @@ version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe"
[[package]]
name = "unicode-segmentation"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "utf8parse"
version = "0.2.2"
@@ -289,6 +494,40 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.59.0"
@@ -298,6 +537,15 @@ dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "spectrust"
version = "0.2.0"
version = "0.2.6"
edition = "2021"
[profile.release]
@@ -10,5 +10,5 @@ codegen-units = 1
[dependencies]
byteorder = "1.5.0"
clap = { version = "4.5.21", features = ["derive"] }
ncurses = "6.0.1"
crossterm = "0.29.0"
rustfft = "6.2.0"
+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)**
```
yay -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.53.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
+3 -3
View File
@@ -1,12 +1,12 @@
# Maintainer: Bendik Aagaard Lynghaug <bendik.lynghaug@gmail.com>
pkgname=spectrust
pkgver=0.2.0
pkgver=0.2.6
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"
license=('MIT')
depends=('ncurses')
depends=()
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")
+227 -173
View File
@@ -1,10 +1,20 @@
use clap::Parser;
use ncurses::*;
use ncurses::{LcCategory, setlocale};
use crossterm::{
cursor::{Hide, MoveTo},
event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
execute, queue,
style::Print,
terminal::{
self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen,
disable_raw_mode, enable_raw_mode,
},
};
use rustfft::{algorithm::Radix4, num_complex::Complex, Fft, FftDirection};
use std::collections::VecDeque;
use std::io::{stdin, Read};
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};
@@ -20,7 +30,7 @@ struct Cell {
}
struct ViewDiff {
changes: Vec<(i32, i32, char)>,
changes: Vec<(u16, u16, char)>,
}
impl ViewDiff {
@@ -33,13 +43,13 @@ impl ViewDiff {
}
fn add_change(&mut self, y: usize, x: usize, character: char) {
self.changes.push((y as i32, x as i32, character));
self.changes.push((y as u16, x as u16, character));
}
fn apply(&self) {
let mut buf = [0u8; 4];
let mut out = stdout();
for &(y, x, ch) in &self.changes {
let _ = mvprintw(y, x, ch.encode_utf8(&mut buf));
let _ = queue!(out, MoveTo(x, y), Print(ch));
}
}
}
@@ -55,15 +65,24 @@ 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 i32,
cols: &mut i32,
new_rows: i32,
new_cols: i32,
max_width: i32,
max_height: i32,
safe_rows: &mut i32,
safe_cols: &mut i32,
rows: &mut u16,
cols: &mut u16,
new_rows: u16,
new_cols: u16,
max_width: u16,
max_height: u16,
safe_rows: &mut u16,
safe_cols: &mut u16,
current_buffer: &mut Vec<Vec<Cell>>,
next_buffer: &mut Vec<Vec<Cell>>,
last_bars: &mut Vec<f32>,
@@ -88,29 +107,30 @@ 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);
clear();
refresh();
let mut out = stdout();
let _ = queue!(out, Clear(ClearType::All));
let _ = out.flush();
}
#[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\
- 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")]
@@ -119,157 +139,94 @@ struct Cli {
#[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.53.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() {
let cli = Cli::parse();
let _ = setlocale(LcCategory::all, "");
initscr();
noecho();
curs_set(CURSOR_VISIBILITY::CURSOR_INVISIBLE);
keypad(stdscr(), true);
timeout(0);
enable_raw_mode().expect("failed to enable raw mode");
let mut out = stdout();
execute!(out, EnterAlternateScreen, Hide).expect("failed to initialize terminal");
let mut rows: i32 = 0;
let mut cols: i32 = 0;
getmaxyx(stdscr(), &mut rows, &mut cols);
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = disable_raw_mode();
let _ = execute!(stdout(), LeaveAlternateScreen);
default_hook(info);
}));
let sample_rate: usize = 48000;
let (mut cols, mut rows) = terminal::size().expect("failed to get terminal size");
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();
refresh();
let mut audio_source = if cli.pipewire {
let process = Command::new("pw-record")
.args(["--format=s16", "--rate=48000", "--channels=2", "--raw", "-"])
let reader: Box<dyn Read + Send + 'static> = 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()
.expect("Failed to start pw-record");
let stdout = process.stdout.expect("Failed to capture pw-record stdout");
refresh();
AudioSource::PipeWire(stdout)
.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 {
let file = File::open(filename).expect("Failed to open input file");
AudioSource::File(file)
Box::new(File::open(filename).expect("failed to open input file"))
} else {
AudioSource::StdIn(stdin().lock())
Box::new(std::io::stdin())
};
let max_width = 1000;
let max_height = 1000;
let mut safe_cols = cols.min(max_width);
let mut safe_rows = rows.min(max_height);
let mut sc = safe_cols as usize;
let mut sr = safe_rows as usize;
let mut current_buffer: Vec<Vec<Cell>> = vec![vec![EMPTY_CELL; sc]; sr];
let mut next_buffer: Vec<Vec<Cell>> = vec![vec![EMPTY_CELL; sc]; sr];
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 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 last_terminal_resize = Instant::now();
let resize_check_interval = Duration::from_millis(500);
let mut all_samples: Vec<f32> = Vec::with_capacity(fft_size * 4);
let mut view_diff = ViewDiff::new();
let mut max_magnitude: f32 = 1.0;
let (tx, rx) = mpsc::channel::<Vec<f32>>();
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 {
if last_terminal_resize.elapsed() >= resize_check_interval {
let mut new_rows = 0;
let mut new_cols = 0;
getmaxyx(stdscr(), &mut new_rows, &mut new_cols);
if new_rows != rows || new_cols != cols {
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,
);
sc = safe_cols as usize;
sr = safe_rows as usize;
}
last_terminal_resize = Instant::now();
}
let ch = getch();
if ch == KEY_RESIZE {
let mut new_rows = 0;
let mut new_cols = 0;
getmaxyx(stdscr(), &mut new_rows, &mut new_cols);
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,
);
sc = safe_cols as usize;
sr = safe_rows as usize;
}
if last_frame_time.elapsed() >= interval {
for row in next_buffer.iter_mut() {
row.fill(EMPTY_CELL);
}
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(stdout) => stdout.read(&mut input_buffer),
};
match read_result {
Ok(0) => {
buffer_is_empty = true;
break;
}
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;
@@ -284,39 +241,131 @@ fn main() {
}
}
if valid_channels > 0 {
all_samples.push((frame_sum / valid_channels as f32) / 32768.0);
samples.push((frame_sum / valid_channels as f32) / 32768.0);
}
}
if all_samples.len() >= fft_size * 2 {
if !samples.is_empty() && tx.send(samples).is_err() {
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;
thread::sleep(Duration::from_millis(1));
}
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 };
Err(_) => break,
}
}
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);
});
let max_width: u16 = 1000;
let max_height: u16 = 1000;
let mut safe_cols = cols.min(max_width);
let mut safe_rows = rows.min(max_height);
let mut sc = safe_cols as usize;
let mut sr = safe_rows as usize;
let mut current_buffer: Vec<Vec<Cell>> = vec![vec![EMPTY_CELL; sc]; sr];
let mut next_buffer: Vec<Vec<Cell>> = vec![vec![EMPTY_CELL; sc]; sr];
let mut last_bars: Vec<f32> = vec![0.0; sc];
let mut freq_bands: Vec<f32> = vec![0.0; sc];
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> = build_freq_mapping(sc, log_power, fft_size);
let mut last_terminal_resize = Instant::now();
let resize_check_interval = Duration::from_millis(500);
let mut all_samples: Vec<f32> = Vec::with_capacity(fft_size * 4);
let mut view_diff = ViewDiff::new();
let mut max_magnitude: f32 = 1.0;
loop {
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 {
code: KeyCode::Char('c'),
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,
log_power, fft_size,
);
sc = safe_cols as usize;
sr = safe_rows as usize;
}
_ => {}
}
}
}
view_diff.apply();
std::mem::swap(&mut current_buffer, &mut next_buffer);
refresh();
break;
if last_terminal_resize.elapsed() >= resize_check_interval {
if let Ok((new_cols, new_rows)) = terminal::size() {
if new_rows != rows || new_cols != cols {
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,
log_power, fft_size,
);
sc = safe_cols as usize;
sr = safe_rows as usize;
}
}
last_terminal_resize = Instant::now();
}
if last_frame_time.elapsed() >= interval {
for row in next_buffer.iter_mut() {
row.fill(EMPTY_CELL);
}
all_samples.clear();
loop {
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() {
@@ -347,7 +396,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 };
}
}
@@ -365,7 +414,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();
@@ -379,11 +429,12 @@ fn main() {
view_diff.apply();
std::mem::swap(&mut current_buffer, &mut next_buffer);
last_frame_time = Instant::now();
refresh();
} else if buffer_is_empty {
let _ = out.flush();
} 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();
@@ -397,8 +448,11 @@ fn main() {
view_diff.apply();
std::mem::swap(&mut current_buffer, &mut next_buffer);
last_frame_time = Instant::now();
refresh();
let _ = out.flush();
}
}
}
let _ = execute!(out, LeaveAlternateScreen);
let _ = disable_raw_mode();
}