diff --git a/README.md b/README.md new file mode 100644 index 0000000..38d8270 --- /dev/null +++ b/README.md @@ -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 ` | `60` | Target frames per second | +| `-p, --pipewire` | — | Capture audio directly from PipeWire | +| `-i, --input ` | — | Read PCM from a file (stdin if omitted) | +| `-l, --log-power ` | `1.4` | Frequency scaling power (0.5–3.0); higher values give more space to bass | +| `-d, --drop-off ` | `0.75` | Bar drop-off factor per frame (0.0 = instant, 1.0 = no drop) | +| `--rate ` | `48000` | Sample rate — must match the audio source | +| `--channels ` | `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 diff --git a/src/main.rs b/src/main.rs index 54816e1..622b695 100644 --- a/src/main.rs +++ b/src/main.rs @@ -65,6 +65,15 @@ fn render_bar(buf: &mut Vec>, col: usize, sr: usize, bar_height_f: f32 } } +fn build_freq_mapping(sc: usize, log_power: f32, fft_size: usize) -> Vec { + (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 u16, cols: &mut u16, @@ -98,12 +107,7 @@ 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); let mut out = stdout(); let _ = queue!(out, Clear(ClearType::All)); @@ -112,16 +116,21 @@ fn handle_terminal_resize( #[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")] @@ -130,11 +139,17 @@ struct Cli { #[arg(short, long, help = "Input file (reads from stdin if not provided)")] input: Option, - #[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.5–3.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() { @@ -153,9 +168,9 @@ fn main() { 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 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; @@ -170,14 +185,33 @@ fn main() { let _ = out.flush(); let reader: Box = if cli.pipewire { - let process = Command::new("pw-record") - .args(["--format=s16", "--rate=48000", "--channels=2", "--raw", "-"]) + 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 pipe = process.stdout.expect("failed to capture pw-record stdout"); - let _ = out.flush(); - Box::new(pipe) + .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 { @@ -236,19 +270,16 @@ fn main() { let mut last_bars: Vec = vec![0.0; sc]; let mut freq_bands: Vec = 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 mut last_frame_time = Instant::now(); let mut max_magnitudes: VecDeque = VecDeque::with_capacity(usize::from(cli.fps / 2)); - let mut freq_mapping: Vec = (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 freq_mapping: Vec = build_freq_mapping(sc, log_power, fft_size); let mut last_terminal_resize = Instant::now(); let resize_check_interval = Duration::from_millis(500); @@ -267,13 +298,33 @@ fn main() { 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, - cli.log_power, fft_size, + log_power, fft_size, ); sc = safe_cols as usize; sr = safe_rows as usize; @@ -290,7 +341,7 @@ fn main() { 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, + log_power, fft_size, ); sc = safe_cols 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); 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(); @@ -381,7 +433,8 @@ fn main() { } 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();