zik 0.1.0

A TUI web radio player with audio spectrum visualizer
use std::cell::UnsafeCell;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;

use libpulse_binding::def::BufferAttr;
use libpulse_binding::sample::{Format, Spec};
use libpulse_binding::stream::Direction;
use libpulse_simple_binding::Simple;
use rustfft::FftPlanner;
use rustfft::num_complex::Complex;

const SAMPLE_RATE: u32 = 44100;
const FFT_SIZE: usize = 1024;
const READ_CHUNK: usize = 1024;
pub const SPECTRUM_SIZE: usize = FFT_SIZE / 2;

/// Lock-free double buffer for spectrum data.
pub struct SpectrumBuffer {
    bufs: [UnsafeCell<[f32; SPECTRUM_SIZE]>; 2],
    active: AtomicUsize,
}

// Safety: only the audio thread writes (to the inactive buffer),
// and the main thread reads (from the active buffer). The atomic
// swap ensures they never access the same buffer simultaneously.
unsafe impl Sync for SpectrumBuffer {}

impl SpectrumBuffer {
    fn new() -> Self {
        Self {
            bufs: [
                UnsafeCell::new([0.0f32; SPECTRUM_SIZE]),
                UnsafeCell::new([0.0f32; SPECTRUM_SIZE]),
            ],
            active: AtomicUsize::new(0),
        }
    }

    pub fn read(&self) -> &[f32; SPECTRUM_SIZE] {
        let idx = self.active.load(Ordering::Acquire);
        // Safety: main thread only reads active buffer; audio thread only writes inactive buffer
        unsafe { &*self.bufs[idx].get() }
    }
}

pub fn spawn_capture() -> Arc<SpectrumBuffer> {
    let spectrum = Arc::new(SpectrumBuffer::new());
    let spectrum_clone = Arc::clone(&spectrum);

    thread::spawn(move || {
        let spec = Spec {
            format: Format::S16le,
            channels: 1,
            rate: SAMPLE_RATE,
        };

        let buf_attr = BufferAttr {
            maxlength: u32::MAX,
            tlength: u32::MAX,
            prebuf: u32::MAX,
            minreq: u32::MAX,
            fragsize: (READ_CHUNK * 2) as u32,
        };

        let source = match Simple::new(
            None,
            "zik",
            Direction::Record,
            Some("@DEFAULT_MONITOR@"),
            "visualizer",
            &spec,
            None,
            Some(&buf_attr),
        ) {
            Ok(s) => s,
            Err(e) => {
                eprintln!("Failed to open PulseAudio monitor: {e}");
                return;
            }
        };

        let mut planner = FftPlanner::<f32>::new();
        let fft = planner.plan_fft_forward(FFT_SIZE);

        let window: Vec<f32> = (0..FFT_SIZE)
            .map(|i| {
                0.5 * (1.0 - (2.0 * std::f32::consts::PI * i as f32 / (FFT_SIZE - 1) as f32).cos())
            })
            .collect();

        let fft_norm = 10.0 * (FFT_SIZE as f32).log10();

        let mut ring = vec![0.0f32; FFT_SIZE];
        let mut ring_pos: usize = 0;
        let mut raw_buf = vec![0u8; READ_CHUNK * 2];
        let mut fft_buf = vec![Complex::new(0.0f32, 0.0); FFT_SIZE];
        let mut smoothed = [0.0f32; SPECTRUM_SIZE];

        loop {
            if source.read(&mut raw_buf).is_err() {
                break;
            }

            for chunk in raw_buf.chunks_exact(2) {
                ring[ring_pos] = i16::from_le_bytes([chunk[0], chunk[1]]) as f32 / 32768.0;
                ring_pos = (ring_pos + 1) % FFT_SIZE;
            }

            for i in 0..FFT_SIZE {
                let idx = (ring_pos + i) % FFT_SIZE;
                fft_buf[i] = Complex::new(ring[idx] * window[i], 0.0);
            }

            fft.process(&mut fft_buf);

            const DB_FLOOR: f32 = -60.0;
            const DB_CEIL: f32 = -6.0;
            let db_range = DB_CEIL - DB_FLOOR;

            // Write to the INACTIVE buffer, then swap
            let active = spectrum_clone.active.load(Ordering::Relaxed);
            let write_idx = 1 - active;

            // Safety: only this thread writes, and only to the inactive buffer
            let write_buf = unsafe { &mut *spectrum_clone.bufs[write_idx].get() };

            for i in 0..SPECTRUM_SIZE {
                let power = fft_buf[i].norm_sqr();
                let db = if power > 1e-12 {
                    10.0 * power.log10() - fft_norm
                } else {
                    DB_FLOOR
                };
                let val = ((db - DB_FLOOR) / db_range).clamp(0.0, 1.0);
                smoothed[i] = smoothed[i] * 0.3 + val * 0.7;
                write_buf[i] = smoothed[i];
            }

            spectrum_clone.active.store(write_idx, Ordering::Release);
        }
    });

    spectrum
}

/// Pre-computed bin ranges for a given number of bars.
pub struct BinMapper {
    ranges: Vec<(usize, usize)>,
}

impl BinMapper {
    pub fn new(num_bars: usize) -> Self {
        let min_freq = 40.0f32;
        let max_freq = 16000.0f32;
        let freq_per_bin = SAMPLE_RATE as f32 / (SPECTRUM_SIZE * 2) as f32;
        let half = SPECTRUM_SIZE;
        let log_ratio = (max_freq / min_freq).ln();

        let ranges = (0..num_bars)
            .map(|bar_idx| {
                let f_low = min_freq * (log_ratio * bar_idx as f32 / num_bars as f32).exp();
                let f_high = min_freq * (log_ratio * (bar_idx + 1) as f32 / num_bars as f32).exp();
                let lo = (f_low / freq_per_bin) as usize;
                let hi = (f_high / freq_per_bin).ceil() as usize;
                (lo.max(1).min(half - 1), hi.max(lo + 1).min(half))
            })
            .collect();

        Self { ranges }
    }

    pub fn num_bars(&self) -> usize {
        self.ranges.len()
    }

    pub fn bin_into(&self, spectrum: &[f32], out: &mut [f32]) {
        for (bar_idx, &(lo, hi)) in self.ranges.iter().enumerate() {
            let sum: f32 = spectrum[lo..hi].iter().sum();
            out[bar_idx] = sum / (hi - lo) as f32;
        }
    }
}