visyx 1.0.0

A terminal-based audio spectrum visualizer written in Rust
Documentation
use anyhow::Result;
use cpal::traits::{DeviceTrait, StreamTrait};
use cpal::SampleFormat;
use crossterm::{
    cursor, execute, queue,
    style::{Color, SetForegroundColor},
    terminal::{self, ClearType},
};
use rustfft::FftPlanner;
use std::{
    env,
    io::{stdout, Write},
    sync::{Arc, Mutex},
    thread,
    time::{Duration, Instant},
};
use visyx::{
    analyzer::SpectrumAnalyzer,
    audio::{best_config_for, build_stream, pick_input_device},
    buffer::SharedBuf,
    dsp::{hann, prepare_fft_input_inplace},
    filterbank::build_filterbank,
    render::{draw_blocks_vertical, layout_for},
    utils::scopeguard,
};

#[inline]
fn get_env<T: std::str::FromStr>(name: &str, default: T) -> T {
    match env::var(name) {
        Ok(val) => val.parse::<T>().unwrap_or(default),
        Err(_) => default,
    }
}

fn main() -> Result<()> {
    #[cfg(unix)]
    unsafe {
        use libc::dup2;
        use std::fs::OpenOptions;
        use std::os::unix::io::AsRawFd;

        if let Ok(devnull) =
            OpenOptions::new().write(true).open("/dev/null")
        {
            dup2(devnull.as_raw_fd(), libc::STDERR_FILENO);
        }
    }

    let fmin: f32 = get_env("VISYX_FMIN", 30.0);
    let fmax: f32 = get_env("VISYX_FMAX", 16_000.0);
    let target_fps_ms: u64 = get_env("VISYX_TARGET_FPS_MS", 16);
    let fft_size: usize = get_env("VISYX_FFT_SIZE", 2048);
    let tau_spec: f32 = get_env("VISYX_TAU_SPEC", 0.06);
    let gate_db: f32 = get_env("VISYX_GATE_DB", -55.0);
    let tilt_alpha: f32 = get_env("VISYX_TILT_ALPHA", 0.30);
    let flow_k: f32 = get_env("VISYX_FLOW_K", 0.18);
    let spr_k: f32 = get_env("VISYX_SPR_K", 60.0);
    let spr_zeta: f32 = get_env("VISYX_SPR_ZETA", 1.0);

    let show_hud: bool = get_env("VISYX_HUD", 0u8) != 0;
    let top_pad: u16 = if show_hud { 1 } else { 0 };

    let mut out = stdout();
    terminal::enable_raw_mode()?;
    execute!(
        out,
        terminal::EnterAlternateScreen,
        cursor::Hide,
        terminal::Clear(ClearType::All),
        SetForegroundColor(Color::White),
    )?;

    let _cleanup = scopeguard::guard((), |_| {
        let mut out = stdout();
        let _ = execute!(
            out,
            cursor::Show,
            terminal::LeaveAlternateScreen
        );
        let _ = terminal::disable_raw_mode();
    });

    let device = pick_input_device()?;
    let name = device.name().unwrap_or_else(|_| "<unknown>".into());
    let cfg = best_config_for(&device)?;
    let sr = cfg.sample_rate.0 as f32;

    let ring_len = (sr as usize / 10).max(fft_size * 3);
    let shared = Arc::new(Mutex::new(SharedBuf::new(ring_len)));

    let stream = match device.default_input_config()?.sample_format()
    {
        SampleFormat::F32 => {
            build_stream::<f32>(device, cfg.clone(), shared.clone())?
        }
        SampleFormat::I16 => {
            build_stream::<i16>(device, cfg.clone(), shared.clone())?
        }
        SampleFormat::U16 => {
            build_stream::<u16>(device, cfg.clone(), shared.clone())?
        }
        _ => anyhow::bail!("Unsupported sample format"),
    };
    stream.play()?;

    execute!(
        out,
        terminal::Clear(ClearType::All),
        cursor::MoveTo(0, 0)
    )?;

    let window = hann(fft_size);
    let mut planner = FftPlanner::<f32>::new();
    let fft = planner.plan_fft_forward(fft_size);
    let half = fft_size / 2;

    let mut last = Instant::now();
    let target_dt = Duration::from_millis(target_fps_ms);
    let mut analyzer = SpectrumAnalyzer::new(half);

    let mut buf = Vec::with_capacity(fft_size);
    let mut spec_pow = vec![0.0; half];
    let mut header = String::with_capacity(256);

    loop {
        if crossterm::event::poll(Duration::ZERO)? {
            if let crossterm::event::Event::Key(k) =
                crossterm::event::read()?
            {
                use crossterm::event::KeyCode::*;
                if let Char('q') = k.code {
                    return Ok(());
                }
            }
        }

        let now = Instant::now();
        let dt = now.duration_since(last);
        if dt < target_dt {
            thread::sleep(target_dt - dt);
            continue;
        }
        let dt_s = dt.as_secs_f32();
        last = now;

        let (w, h) = terminal::size()?;
        let lay = layout_for(w, top_pad);
        let desired_bars = lay.bars;

        if analyzer.filters.len() != desired_bars {
            analyzer.filters = build_filterbank(
                sr,
                fft_size,
                desired_bars,
                fmin,
                fmax,
            );
            analyzer.resize(desired_bars);
        }

        let samples = if let Ok(buf) = shared.try_lock() {
            buf.latest()
        } else {
            thread::sleep(Duration::from_millis(1));
            continue;
        };
        if samples.len() < fft_size {
            continue;
        }
        let tail = &samples[samples.len() - fft_size..];

        let mut rms = 0.0;
        for &x in tail {
            rms += x * x;
        }
        rms /= fft_size as f32;
        let rms_db = 10.0 * (rms.max(1e-12)).log10();
        let gate_open = rms_db > gate_db;

        prepare_fft_input_inplace(tail, &window, &mut buf);
        fft.process(&mut buf);

        for i in 0..half {
            let re = buf[i].re;
            let im = buf[i].im;
            spec_pow[i] = (re * re + im * im)
                / (fft_size as f32 * fft_size as f32);
        }

        analyzer.update_spectrum(&spec_pow, tau_spec, dt_s);
        let bars_target =
            analyzer.analyze_bands(tilt_alpha, dt_s, gate_open);
        analyzer.apply_flow_and_spring(
            &bars_target,
            flow_k,
            spr_k,
            spr_zeta,
            dt_s,
            gate_open,
        );

        queue!(
            out,
            terminal::Clear(ClearType::All),
            cursor::MoveTo(0, 0),
            SetForegroundColor(Color::White)
        )?;

        if show_hud {
            header.clear();
            header.push_str("  visyx  |  input: ");
            header.push_str(&name);
            header.push_str("  |  auto gain [");
            use std::fmt::Write;
            let _ = write!(
                header,
                "{:.1}..{:.1} dB",
                analyzer.db_low - 3.0,
                analyzer.db_high + 6.0
            );
            header.push_str("]  |  q quits\n");
            out.write_all(header.as_bytes())?;
        }

        draw_blocks_vertical(&mut out, &analyzer.bars_y, w, h, &lay)?;
    }
}