2 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
4 changed files with 59 additions and 78 deletions
Generated
+1 -1
View File
@@ -429,7 +429,7 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]] [[package]]
name = "spectrust" name = "spectrust"
version = "0.2.4" version = "0.2.5"
dependencies = [ dependencies = [
"byteorder", "byteorder",
"clap", "clap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "spectrust" name = "spectrust"
version = "0.2.4" version = "0.2.5"
edition = "2021" edition = "2021"
[profile.release] [profile.release]
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Bendik Aagaard Lynghaug <bendik.lynghaug@gmail.com> # Maintainer: Bendik Aagaard Lynghaug <bendik.lynghaug@gmail.com>
pkgname=spectrust pkgname=spectrust
pkgver=0.2.4 pkgver=0.2.5
pkgrel=1 pkgrel=1
pkgdesc="Terminal audio spectrum analyzer" pkgdesc="Terminal audio spectrum analyzer"
arch=('x86_64' 'aarch64') arch=('x86_64' 'aarch64')
+54 -73
View File
@@ -13,6 +13,8 @@ use rustfft::{algorithm::Radix4, num_complex::Complex, Fft, FftDirection};
use std::collections::VecDeque; use std::collections::VecDeque;
use std::io::{stdout, Read, Write}; use std::io::{stdout, Read, Write};
use std::fs::File; use std::fs::File;
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use byteorder::{LittleEndian, ReadBytesExt}; use byteorder::{LittleEndian, ReadBytesExt};
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
@@ -159,22 +161,15 @@ fn main() {
let fft = Radix4::new(fft_size, FftDirection::Forward); 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 mut complex_buffer = vec![Complex { re: 0.0f32, im: 0.0f32 }; fft_size];
let hann_window: Vec<f32> = (0..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())) .map(|i| 0.5 * (1.0 - (2.0 * std::f32::consts::PI * i as f32 / fft_size as f32).cos()))
.collect(); .collect();
enum AudioSource {
StdIn(std::io::StdinLock<'static>),
File(File),
PipeWire(std::process::ChildStdout),
}
let _ = out.flush(); 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") let process = Command::new("pw-record")
.args(["--format=s16", "--rate=48000", "--channels=2", "--raw", "-"]) .args(["--format=s16", "--rate=48000", "--channels=2", "--raw", "-"])
.stdout(Stdio::piped()) .stdout(Stdio::piped())
@@ -182,14 +177,51 @@ fn main() {
.expect("failed to start pw-record"); .expect("failed to start pw-record");
let pipe = process.stdout.expect("failed to capture pw-record stdout"); let pipe = process.stdout.expect("failed to capture pw-record stdout");
let _ = out.flush(); let _ = out.flush();
AudioSource::PipeWire(pipe) Box::new(pipe)
} else if let Some(filename) = &cli.input { } else if let Some(filename) = &cli.input {
let file = File::open(filename).expect("failed to open input file"); Box::new(File::open(filename).expect("failed to open input file"))
AudioSource::File(file)
} else { } 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_width: u16 = 1000;
let max_height: u16 = 1000; let max_height: u16 = 1000;
@@ -226,7 +258,8 @@ fn main() {
let mut max_magnitude: f32 = 1.0; let mut max_magnitude: f32 = 1.0;
loop { 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() { match event::read() {
Ok(Event::Key(KeyEvent { code: KeyCode::Char('q'), .. })) => break, Ok(Event::Key(KeyEvent { code: KeyCode::Char('q'), .. })) => break,
Ok(Event::Key(KeyEvent { Ok(Event::Key(KeyEvent {
@@ -272,68 +305,16 @@ fn main() {
} }
all_samples.clear(); all_samples.clear();
let mut buffer_is_empty = false;
loop { loop {
let read_result = match &mut audio_source { match rx.try_recv() {
AudioSource::StdIn(input) => input.read(&mut input_buffer), Ok(batch) => all_samples.extend_from_slice(&batch),
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, Err(_) => break,
} }
} }
if valid_channels > 0 { // Latency guard: if the audio thread got ahead, drop old samples
all_samples.push((frame_sum / valid_channels as f32) / 32768.0); if all_samples.len() > fft_size * 2 {
} let excess = all_samples.len() - fft_size * 2;
} all_samples.drain(..excess);
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;
}
}
} }
if !all_samples.is_empty() { if !all_samples.is_empty() {
@@ -364,7 +345,7 @@ fn main() {
if mapping_idx < fft_size / 2 { if mapping_idx < fft_size / 2 {
let magnitude = complex_buffer[mapping_idx].norm(); let magnitude = complex_buffer[mapping_idx].norm();
let freq_boost = 1.0 + (mapping_idx as f32 / (fft_size / 2) as f32) * 4.0; 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 }; 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); std::mem::swap(&mut current_buffer, &mut next_buffer);
last_frame_time = Instant::now(); last_frame_time = Instant::now();
let _ = out.flush(); let _ = out.flush();
} else if buffer_is_empty { } 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); render_bar(&mut next_buffer, i, sr, (last_bars[i] / max_magnitude) * sr as f32);