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
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-13 11:16:53 +02:00
co-authored by Claude Opus 4.8
parent 3dbe5c4ddd
commit b230e6829a
6 changed files with 619 additions and 72 deletions
+52 -65
View File
@@ -17,7 +17,8 @@ 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 };
@@ -121,7 +122,7 @@ fn handle_terminal_resize(
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\n\n\
Runtime controls:\n\
@@ -133,9 +134,12 @@ struct Cli {
#[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>,
@@ -184,77 +188,60 @@ fn main() {
let _ = out.flush();
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();
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 {
Box::new(File::open(filename).expect("failed to open input file"))
} else {
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;
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 {
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,
}
Err(_) => break,
}
if valid_channels > 0 {
samples.push((frame_sum / valid_channels as f32) / 32768.0);
}
}
if valid_channels > 0 {
samples.push((frame_sum / valid_channels as f32) / 32768.0);
if !samples.is_empty() && tx.send(samples).is_err() {
break;
}
}
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,
}
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;