4 Commits

Author SHA1 Message Date
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
5 changed files with 80 additions and 78 deletions
Generated
+1 -1
View File
@@ -429,7 +429,7 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "spectrust"
version = "0.2.3"
version = "0.2.5"
dependencies = [
"byteorder",
"clap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "spectrust"
version = "0.2.3"
version = "0.2.5"
edition = "2021"
[profile.release]
+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.
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Bendik Aagaard Lynghaug <bendik.lynghaug@gmail.com>
pkgname=spectrust
pkgver=0.2.3
pkgver=0.2.5
pkgrel=1
pkgdesc="Terminal audio spectrum analyzer"
arch=('x86_64' 'aarch64')
+56 -75
View File
@@ -13,6 +13,8 @@ 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};
@@ -159,22 +161,15 @@ fn main() {
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 reader: Box<dyn Read + Send + 'static> = if cli.pipewire {
let process = Command::new("pw-record")
.args(["--format=s16", "--rate=48000", "--channels=2", "--raw", "-"])
.stdout(Stdio::piped())
@@ -182,14 +177,51 @@ fn main() {
.expect("failed to start pw-record");
let pipe = process.stdout.expect("failed to capture pw-record stdout");
let _ = out.flush();
AudioSource::PipeWire(pipe)
Box::new(pipe)
} 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(std::io::stdin().lock())
Box::new(std::io::stdin())
};
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 {
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;
@@ -226,7 +258,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 {
@@ -272,69 +305,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 +345,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 };
}
}
@@ -397,7 +378,7 @@ 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);