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>
This commit is contained in:
2026-06-23 21:16:02 +02:00
parent 5201b503e6
commit b7e861093e
2 changed files with 158 additions and 31 deletions
+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
+84 -31
View File
@@ -65,6 +65,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( fn handle_terminal_resize(
rows: &mut u16, rows: &mut u16,
cols: &mut u16, cols: &mut u16,
@@ -98,12 +107,7 @@ fn handle_terminal_resize(
freq_bands.clear(); freq_bands.clear();
freq_bands.resize(sc, 0.0); freq_bands.resize(sc, 0.0);
freq_mapping.clear(); *freq_mapping = build_freq_mapping(sc, log_power, fft_size);
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));
}
let mut out = stdout(); let mut out = stdout();
let _ = queue!(out, Clear(ClearType::All)); let _ = queue!(out, Clear(ClearType::All));
@@ -112,16 +116,21 @@ fn handle_terminal_resize(
#[derive(Parser)] #[derive(Parser)]
#[command(name = "Spectrust")] #[command(name = "Spectrust")]
#[command(version = "0.1.0")] #[command(version)]
#[command(about = "A PCM spectrum analyzer for audio visualization", #[command(about = "A PCM spectrum analyzer for audio visualization",
long_about = "Spectrust is a PCM spectrum analyzer that visualizes audio in your terminal.\n\ 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\ It can read from stdin, a file, or capture directly from PipeWire.\n\n\
Examples:\n\ Examples:\n\
- Direct PipeWire capture: spectrust -p\n\ - Direct PipeWire capture: spectrust -p\n\
- From audio file: spectrust -i audio.pcm\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 { struct Cli {
#[arg(value_name = "FPS", default_value_t = 60)] #[arg(short, long, help = "Target frames per second", default_value_t = 60)]
fps: u16, fps: u16,
#[arg(short, long, help = "Use PipeWire to capture audio directly")] #[arg(short, long, help = "Use PipeWire to capture audio directly")]
@@ -130,11 +139,17 @@ struct Cli {
#[arg(short, long, help = "Input file (reads from stdin if not provided)")] #[arg(short, long, help = "Input file (reads from stdin if not provided)")]
input: Option<String>, 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, 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, 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() { fn main() {
@@ -153,9 +168,9 @@ fn main() {
let (mut cols, mut rows) = terminal::size().expect("failed to get terminal size"); 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 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 samples_per_frame: usize = sample_rate / usize::from(cli.fps);
let fft_size: usize = 1024; let fft_size: usize = 1024;
@@ -170,14 +185,33 @@ fn main() {
let _ = out.flush(); let _ = out.flush();
let reader: Box<dyn Read + Send + 'static> = if cli.pipewire { let reader: Box<dyn Read + Send + 'static> = if cli.pipewire {
let process = Command::new("pw-record") let spawn_result = Command::new("pw-record")
.args(["--format=s16", "--rate=48000", "--channels=2", "--raw", "-"]) .args([
"--format=s16",
&format!("--rate={}", cli.rate),
&format!("--channels={}", cli.channels),
"--raw",
"-",
])
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.spawn() .spawn();
.expect("failed to start pw-record"); match spawn_result {
let pipe = process.stdout.expect("failed to capture pw-record stdout"); Ok(mut process) => {
let _ = out.flush(); Box::new(process.stdout.take().expect("failed to capture pw-record stdout"))
Box::new(pipe) }
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 { } else if let Some(filename) = &cli.input {
Box::new(File::open(filename).expect("failed to open input file")) Box::new(File::open(filename).expect("failed to open input file"))
} else { } else {
@@ -236,19 +270,16 @@ fn main() {
let mut last_bars: Vec<f32> = vec![0.0; sc]; let mut last_bars: Vec<f32> = vec![0.0; sc];
let mut freq_bands: 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 interval = Duration::from_secs_f32(1.0 / f32::from(cli.fps));
let mut last_frame_time = Instant::now(); let mut last_frame_time = Instant::now();
let mut max_magnitudes: VecDeque<f32> = VecDeque::with_capacity(usize::from(cli.fps / 2)); let mut max_magnitudes: VecDeque<f32> = VecDeque::with_capacity(usize::from(cli.fps / 2));
let mut freq_mapping: Vec<usize> = (0..sc) let mut freq_mapping: Vec<usize> = build_freq_mapping(sc, log_power, fft_size);
.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 mut last_terminal_resize = Instant::now();
let resize_check_interval = Duration::from_millis(500); let resize_check_interval = Duration::from_millis(500);
@@ -267,13 +298,33 @@ fn main() {
modifiers: KeyModifiers::CONTROL, modifiers: KeyModifiers::CONTROL,
.. ..
})) => break, })) => 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)) => { Ok(Event::Resize(new_cols, new_rows)) => {
handle_terminal_resize( handle_terminal_resize(
&mut rows, &mut cols, new_rows, new_cols, &mut rows, &mut cols, new_rows, new_cols,
max_width, max_height, &mut safe_rows, &mut safe_cols, max_width, max_height, &mut safe_rows, &mut safe_cols,
&mut current_buffer, &mut next_buffer, &mut current_buffer, &mut next_buffer,
&mut last_bars, &mut freq_bands, &mut freq_mapping, &mut last_bars, &mut freq_bands, &mut freq_mapping,
cli.log_power, fft_size, log_power, fft_size,
); );
sc = safe_cols as usize; sc = safe_cols as usize;
sr = safe_rows as usize; sr = safe_rows as usize;
@@ -290,7 +341,7 @@ fn main() {
max_width, max_height, &mut safe_rows, &mut safe_cols, max_width, max_height, &mut safe_rows, &mut safe_cols,
&mut current_buffer, &mut next_buffer, &mut current_buffer, &mut next_buffer,
&mut last_bars, &mut freq_bands, &mut freq_mapping, &mut last_bars, &mut freq_bands, &mut freq_mapping,
cli.log_power, fft_size, log_power, fft_size,
); );
sc = safe_cols as usize; sc = safe_cols as usize;
sr = safe_rows as usize; sr = safe_rows as usize;
@@ -363,7 +414,8 @@ fn main() {
max_magnitude = max_magnitudes.iter().copied().fold(0.0f32, f32::max).max(0.1); max_magnitude = max_magnitudes.iter().copied().fold(0.0f32, f32::max).max(0.1);
for (i, &magnitude) in last_bars[..sc].iter().enumerate() { 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(); view_diff.clear();
@@ -381,7 +433,8 @@ fn main() {
} else { } else {
for i in 0..sc { for i in 0..sc {
last_bars[i] *= drop_factor; 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(); view_diff.clear();