//! audio.rs — Dual-CSS HF Mode
//! Real-time audio I/O via cpal (cross-platform: ALSA/PipeWire on Linux,
//! WASAPI on Windows, CoreAudio on macOS).
//!
//! Design (ported from audio.py):
//!   - Audio callbacks run on cpal's realtime thread and ONLY touch the shared
//!     ring buffer / TX buffer — never the GUI, never allocate.
//!   - A background FFT worker thread reads the ring buffer, computes a Hann-
//!     windowed rFFT in dBFS, and pushes frames down an mpsc channel.
//!   - The GUI thread drains that channel each frame (see app.rs), so spectra
//!     reach egui without any cross-thread UI calls.

use std::collections::VecDeque;
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, SyncSender};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;

use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use rustfft::num_complex::Complex;
use rustfft::FftPlanner;

use crate::config::AppConfig;

/// Default FFT size — the initial frequency resolution of the waterfall.
/// Runtime-adjustable via the Display settings; the live value lives in a shared
/// atomic so the background worker can pick up changes without a restart.
#[allow(dead_code)] // referenced as the documented default; live size is in DisplayConfig
pub const NFFT: usize = 4096;
/// Largest FFT the worker will ever run; the ring buffer is sized for this so
/// the FFT size can grow at runtime without reallocating shared buffers.
pub const MAX_FFT: usize = 32768;

/// Cap on the raw-audio tap feeding the demodulator (~10 s at 48 kHz). If the
/// GUI stalls longer than this the oldest samples are dropped rather than
/// growing without bound.
const RX_TAP_CAP: usize = 480_000;

// ── ALSA probe-spam suppression (Linux only) ─────────────────────────────────
// PortAudio/ALSA print device-probe errors to stderr during enumeration. cpal
// uses ALSA on Linux too, so we silence the C-level handler the same way the
// Python app did. No-op on Windows/macOS.
#[cfg(target_os = "linux")]
pub fn suppress_alsa_errors() {
    use std::os::raw::{c_char, c_int, c_void};

    // ALSA's error handler type:
    //   void (*)(const char *file, int line, const char *function,
    //            int err, const char *fmt, ...)
    // We define the five fixed parameters and ignore the trailing varargs.
    // The C cdecl convention lets the caller pass extra varargs that the callee
    // simply never reads, exactly as the Python CFUNCTYPE handler did.
    type AlsaHandler =
        unsafe extern "C" fn(*const c_char, c_int, *const c_char, c_int, *const c_char);

    unsafe extern "C" fn silent_handler(
        _file: *const c_char,
        _line: c_int,
        _function: *const c_char,
        _err: c_int,
        _fmt: *const c_char,
    ) {
    }

    const RTLD_NOW: c_int = 2;

    unsafe {
        let lib = dlopen(c"libasound.so.2".as_ptr() as *const c_char, RTLD_NOW);
        if lib.is_null() {
            return; // libasound absent — nothing to silence
        }
        let sym = dlsym(lib, c"snd_lib_error_set_handler".as_ptr() as *const c_char);
        if sym.is_null() {
            return;
        }
        // snd_lib_error_set_handler(snd_lib_error_handler_t handler)
        let set_handler: unsafe extern "C" fn(AlsaHandler) = std::mem::transmute(sym);
        set_handler(silent_handler);
    }

    extern "C" {
        fn dlopen(filename: *const c_char, flags: c_int) -> *mut c_void;
        fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void;
    }
}

#[cfg(not(target_os = "linux"))]
pub fn suppress_alsa_errors() {}

// ── Device enumeration ────────────────────────────────────────────────────────

#[derive(Debug, Clone)]
pub struct DeviceInfo {
    pub index: usize,
    pub name: String,
    pub in_channels: u16,
    pub out_channels: u16,
}

/// Return all host devices with their channel counts. Empty on failure.
pub fn list_devices() -> Vec<DeviceInfo> {
    let host = cpal::default_host();
    let mut out = Vec::new();
    let Ok(devices) = host.devices() else {
        return out;
    };
    for (index, dev) in devices.enumerate() {
        // cpal 0.18: human-readable name lives on DeviceDescription.
        let name = dev
            .description()
            .map(|d| d.name().to_string())
            .unwrap_or_else(|_| "<unknown>".to_string());
        let in_channels = dev
            .default_input_config()
            .map(|c| c.channels())
            .unwrap_or(0);
        let out_channels = dev
            .default_output_config()
            .map(|c| c.channels())
            .unwrap_or(0);
        out.push(DeviceInfo {
            index,
            name,
            in_channels,
            out_channels,
        });
    }
    out
}

/// A device list as `(stored index, display name)` pairs.
pub type DeviceList = Vec<(usize, String)>;

/// Enumerate input and output device lists in a SINGLE pass. Each `list_devices`
/// call probes `default_input_config` + `default_output_config` on every device
/// (blocking ALSA/PipeWire calls), so doing inputs and outputs separately would
/// enumerate everything twice. The Settings dialog uses this to do the work once
/// (and off the UI thread).
pub fn input_output_devices() -> (DeviceList, DeviceList) {
    let all = list_devices();
    let inputs = all
        .iter()
        .filter(|d| d.in_channels > 0)
        .map(|d| (d.index, d.name.clone()))
        .collect();
    let outputs = all
        .iter()
        .filter(|d| d.out_channels > 0)
        .map(|d| (d.index, d.name.clone()))
        .collect();
    (inputs, outputs)
}

/// Enumerate available serial ports, with a cross-platform fallback list.
pub fn serial_ports() -> Vec<String> {
    serialport::available_ports()
        .map(|ports| ports.into_iter().map(|p| p.port_name).collect())
        .unwrap_or_else(|_| {
            vec![
                "COM1".into(),
                "COM2".into(),
                "/dev/ttyUSB0".into(),
                "/dev/ttyUSB1".into(),
            ]
        })
}

/// Resolve a stored device index (-1 = system default) into a cpal Device.
fn resolve_device(host: &cpal::Host, index: i32, input: bool) -> Option<cpal::Device> {
    if index < 0 {
        return if input {
            host.default_input_device()
        } else {
            host.default_output_device()
        };
    }
    host.devices().ok()?.nth(index as usize)
}

// ── Audio engine ──────────────────────────────────────────────────────────────

struct RingBuffer {
    buf: Vec<f32>,
    write: usize,
    size: usize,
}

impl RingBuffer {
    fn new(size: usize) -> Self {
        Self {
            buf: vec![0.0; size],
            write: 0,
            size,
        }
    }

    fn push(&mut self, samples: &[f32]) {
        for &s in samples {
            self.buf[self.write] = s;
            self.write = (self.write + 1) % self.size;
        }
    }

    /// Copy the most recent `n` samples in chronological order.
    fn latest(&self, n: usize) -> Vec<f32> {
        let n = n.min(self.size);
        let mut out = Vec::with_capacity(n);
        let start = (self.write + self.size - n) % self.size;
        for i in 0..n {
            out.push(self.buf[(start + i) % self.size]);
        }
        out
    }
}

struct TxBuffer {
    samples: Vec<f32>,
    pos: usize,
}

/// Status flag shared with the FFT worker so it knows whether RX is live.
type RunningFlag = Arc<AtomicBool>;

pub struct AudioEngine {
    cfg: AppConfig,
    host: cpal::Host,
    in_stream: Option<cpal::Stream>,
    out_stream: Option<cpal::Stream>,
    running: RunningFlag,

    ring: Arc<Mutex<RingBuffer>>,
    tx_buffer: Arc<Mutex<TxBuffer>>,
    /// Raw mono capture tap for the demodulator (the ring buffer above is
    /// lossy/windowed and only suits the waterfall FFT).
    rx_tap: Arc<Mutex<VecDeque<f32>>>,
    /// Actual negotiated input sample rate (0 = no stream yet). The demod's
    /// chirp references must be built at this rate, not the configured one.
    input_rate: Arc<AtomicUsize>,

    spec_rx: Receiver<Vec<f32>>,
    // Kept alive so the worker thread's sender stays open.
    _spec_tx: SyncSender<Vec<f32>>,
    last_error: Arc<Mutex<Option<String>>>,
    /// Live FFT size, shared with the worker so changes take effect immediately.
    fft_size: Arc<AtomicUsize>,
    /// Whether the worker should use the GPU-compute FFT (CPU is the fallback).
    gpu_fft: Arc<AtomicBool>,
    /// Live linear input gain (f32 bits), read by the capture callback each
    /// block so the Quick Settings slider works without reopening the stream.
    input_gain: Arc<AtomicU32>,
}

impl AudioEngine {
    pub fn new(cfg: AppConfig) -> Self {
        let host = cpal::default_host();
        // Ring is sized for the largest FFT so the resolution can grow at
        // runtime without reallocating the shared buffer.
        let ring = Arc::new(Mutex::new(RingBuffer::new(MAX_FFT * 8)));
        let tx_buffer = Arc::new(Mutex::new(TxBuffer {
            samples: Vec::new(),
            pos: 0,
        }));
        let running: RunningFlag = Arc::new(AtomicBool::new(false));
        let input_gain = Arc::new(AtomicU32::new(
            10f32.powf(cfg.audio.input_gain_db / 20.0).to_bits(),
        ));
        let fft_size = Arc::new(AtomicUsize::new(cfg.display.clamped_fft_size()));
        let gpu_fft = Arc::new(AtomicBool::new(cfg.display.gpu_fft));
        let last_error = Arc::new(Mutex::new(None));

        // Bounded channel mirrors the Python queue(maxsize=16). `try_send`
        // drops the NEWEST frame when full, so if the GUI stalls the waterfall
        // pauses on what it has rather than blocking the producer; the GUI
        // drains the whole queue every frame, so it catches back up to live
        // as soon as it resumes.
        let (spec_tx, spec_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(16);

        // Background FFT worker — only numpy-equivalent math + channel send.
        {
            let ring = Arc::clone(&ring);
            let running = Arc::clone(&running);
            let spec_tx = spec_tx.clone();
            let fft_size = Arc::clone(&fft_size);
            let gpu_fft = Arc::clone(&gpu_fft);
            let err = Arc::clone(&last_error);
            thread::Builder::new()
                .name("dual_css_fft".into())
                .spawn(move || fft_worker(ring, running, spec_tx, fft_size, gpu_fft, err))
                .expect("spawn fft worker");
        }

        Self {
            cfg,
            host,
            in_stream: None,
            out_stream: None,
            running,
            ring,
            tx_buffer,
            rx_tap: Arc::new(Mutex::new(VecDeque::new())),
            input_rate: Arc::new(AtomicUsize::new(0)),
            spec_rx,
            _spec_tx: spec_tx,
            last_error,
            fft_size,
            gpu_fft,
            input_gain,
        }
    }

    pub fn is_running(&self) -> bool {
        self.running.load(Ordering::Relaxed)
    }

    /// Drain any pending error message (consumed by the GUI for the status bar).
    pub fn take_error(&self) -> Option<String> {
        self.last_error.lock().unwrap().take()
    }

    fn set_error(&self, msg: String) {
        *self.last_error.lock().unwrap() = Some(msg);
    }

    /// Drain ready spectra (each NFFT/2 dBFS bins). Called by the GUI per frame.
    pub fn drain_spectra(&self) -> Vec<Vec<f32>> {
        let mut out = Vec::new();
        while let Ok(spec) = self.spec_rx.try_recv() {
            out.push(spec);
        }
        out
    }

    // ── Start / stop capture ──────────────────────────────────────────────────

    pub fn start_rx(&mut self) {
        if self.is_running() {
            return;
        }
        let cfg = self.cfg.audio.clone();
        let device = match resolve_device(&self.host, cfg.input_device, true) {
            Some(d) => d,
            None => {
                self.set_error("No audio input device available".into());
                return;
            }
        };

        // Negotiate from the device's own default config rather than forcing a
        // mono / fixed-buffer stream. Many Linux backends (PipeWire/ALSA via
        // cpal) reject `BufferSize::Fixed` or a channels=1 request and fail to
        // open the stream — which left the waterfall black. We keep the device's
        // real channel count and `BufferSize::Default`, overriding only the
        // sample rate (and only when the device claims to support it).
        let default_cfg = match device.default_input_config() {
            Ok(c) => c,
            Err(e) => {
                self.set_error(format!("Audio input error: no default config ({e})"));
                return;
            }
        };
        let channels = default_cfg.channels().max(1);
        let mut stream_cfg = default_cfg.config(); // BufferSize::Default, real channels
                                                   // Honor the configured sample rate only if it is within the supported
                                                   // range; otherwise keep the device default to avoid an open failure.
        if let Ok(ranges) = device.supported_input_configs() {
            let supported = ranges.into_iter().any(|r| {
                cfg.sample_rate >= r.min_sample_rate() && cfg.sample_rate <= r.max_sample_rate()
            });
            if supported {
                stream_cfg.sample_rate = cfg.sample_rate;
            }
        }

        // Publish the actual stream rate and clear any stale demod samples.
        self.input_rate
            .store(stream_cfg.sample_rate as usize, Ordering::Relaxed);
        self.rx_tap.lock().unwrap().clear();

        let ring = Arc::clone(&self.ring);
        let tap = Arc::clone(&self.rx_tap);
        let gain_bits = Arc::clone(&self.input_gain);
        let err_slot = Arc::clone(&self.last_error);
        let n_ch = channels as usize;

        let result = device.build_input_stream(
            stream_cfg,
            move |data: &[f32], _: &cpal::InputCallbackInfo| {
                // Realtime thread: downmix interleaved frames to mono, apply
                // gain, push to ring + demod tap. No GUI calls, no allocation
                // beyond the local mono buffer.
                let gain = f32::from_bits(gain_bits.load(Ordering::Relaxed));
                let frames = data.len() / n_ch;
                let mut mono = Vec::with_capacity(frames);
                for f in 0..frames {
                    let base = f * n_ch;
                    let mut acc = 0.0f32;
                    for c in 0..n_ch {
                        acc += data[base + c];
                    }
                    mono.push(acc / n_ch as f32 * gain);
                }
                if let Ok(mut r) = ring.lock() {
                    r.push(&mono);
                }
                if let Ok(mut t) = tap.lock() {
                    t.extend(mono.iter().copied());
                    if t.len() > RX_TAP_CAP {
                        let excess = t.len() - RX_TAP_CAP;
                        t.drain(..excess);
                    }
                }
            },
            move |err| {
                *err_slot.lock().unwrap() = Some(format!("Audio input error: {err}"));
            },
            None,
        );

        match result {
            Ok(stream) => {
                if let Err(e) = stream.play() {
                    self.set_error(format!("Audio input error: {e}"));
                    return;
                }
                self.in_stream = Some(stream);
                self.running.store(true, Ordering::Relaxed);
            }
            Err(e) => self.set_error(format!("Audio input error: {e}")),
        }
    }

    pub fn stop_rx(&mut self) {
        self.in_stream = None; // dropping the stream stops it
        self.running.store(false, Ordering::Relaxed);
        self.rx_tap.lock().unwrap().clear();
    }

    /// Drain all raw capture samples accumulated since the last call (mono,
    /// at `input_sample_rate()`). Consumed by the GUI thread, which feeds
    /// them to the demodulator.
    pub fn drain_rx_audio(&self) -> Vec<f32> {
        let mut tap = self.rx_tap.lock().unwrap();
        tap.drain(..).collect()
    }

    /// The negotiated input sample rate, or `None` before the first stream.
    pub fn input_sample_rate(&self) -> Option<u32> {
        match self.input_rate.load(Ordering::Relaxed) {
            0 => None,
            r => Some(r as u32),
        }
    }

    // ── Transmit ────────────────────────────────────────────────────────────────

    /// Play back mono f32 samples. Kills any running TX stream first so zombie
    /// streams can't race on the new buffer (the 2x/3x-speed bug noted in audio.py).
    pub fn transmit(&mut self, samples: Vec<f32>) {
        self.stop_tx();
        {
            let mut tx = self.tx_buffer.lock().unwrap();
            tx.samples = samples;
            tx.pos = 0;
        }
        let cfg = self.cfg.audio.clone();
        let device = match resolve_device(&self.host, cfg.output_device, false) {
            Some(d) => d,
            None => {
                self.set_error("No audio output device available".into());
                return;
            }
        };

        // Negotiate from the device default (real channel count, default buffer),
        // overriding sample rate only when supported — same rationale as RX.
        let default_cfg = match device.default_output_config() {
            Ok(c) => c,
            Err(e) => {
                self.set_error(format!("Audio output error: no default config ({e})"));
                return;
            }
        };
        let channels = default_cfg.channels().max(1);
        let mut stream_cfg = default_cfg.config();
        if let Ok(ranges) = device.supported_output_configs() {
            let supported = ranges.into_iter().any(|r| {
                cfg.sample_rate >= r.min_sample_rate() && cfg.sample_rate <= r.max_sample_rate()
            });
            if supported {
                stream_cfg.sample_rate = cfg.sample_rate;
            }
        }

        // The buffer was synthesised at the configured rate. If the device
        // refused that rate, playing the samples unchanged would scale every
        // frequency by the rate ratio — detuned, wrong-speed chirps that no
        // receiver can decode. Resample so the on-air waveform stays correct.
        if stream_cfg.sample_rate != cfg.sample_rate {
            let mut tx = self.tx_buffer.lock().unwrap();
            tx.samples = resample_linear(&tx.samples, cfg.sample_rate, stream_cfg.sample_rate);
            drop(tx);
            self.set_error(format!(
                "Output opened at {} Hz (configured {}) — TX audio resampled",
                stream_cfg.sample_rate, cfg.sample_rate
            ));
        }

        let tx_buffer = Arc::clone(&self.tx_buffer);
        let err_slot = Arc::clone(&self.last_error);
        let n_ch = channels as usize;

        let result = device.build_output_stream(
            stream_cfg,
            move |out: &mut [f32], _: &cpal::OutputCallbackInfo| {
                // The TX buffer is mono; upmix each mono sample to all N output
                // channels. `tx.pos` counts mono samples consumed.
                let mut tx = tx_buffer.lock().unwrap();
                let frames = out.len() / n_ch;
                for f in 0..frames {
                    let sample = if tx.pos < tx.samples.len() {
                        let s = tx.samples[tx.pos];
                        tx.pos += 1;
                        s
                    } else {
                        0.0
                    };
                    let base = f * n_ch;
                    for c in 0..n_ch {
                        out[base + c] = sample;
                    }
                }
            },
            move |err| {
                *err_slot.lock().unwrap() = Some(format!("Audio output error: {err}"));
            },
            None,
        );

        match result {
            Ok(stream) => {
                if let Err(e) = stream.play() {
                    self.set_error(format!("Audio output error: {e}"));
                    return;
                }
                self.out_stream = Some(stream);
            }
            Err(e) => self.set_error(format!("Audio output error: {e}")),
        }
    }

    pub fn stop_tx(&mut self) {
        self.out_stream = None;
    }

    /// Whether the current TX buffer has finished playing out.
    pub fn tx_finished(&self) -> bool {
        let tx = self.tx_buffer.lock().unwrap();
        tx.pos >= tx.samples.len()
    }

    /// (samples played, total samples) of the current TX buffer — used for the
    /// remaining-time countdown in the TX panel.
    pub fn tx_progress(&self) -> (usize, usize) {
        let tx = self.tx_buffer.lock().unwrap();
        (tx.pos.min(tx.samples.len()), tx.samples.len())
    }

    pub fn update_config(&mut self, cfg: AppConfig) {
        // Publish runtime-adjustable worker params so they take effect on the
        // next tick without restarting the thread.
        self.fft_size
            .store(cfg.display.clamped_fft_size(), Ordering::Relaxed);
        self.gpu_fft.store(cfg.display.gpu_fft, Ordering::Relaxed);
        self.input_gain.store(
            10f32.powf(cfg.audio.input_gain_db / 20.0).to_bits(),
            Ordering::Relaxed,
        );
        self.cfg = cfg;
    }
}

// ── FFT worker — background thread ──────────────────────────────────────────
// NEVER touches the GUI. All output goes through the mpsc channel.

/// FFT plan handle returned by rustfft's planner.
type FftPlan = Arc<dyn rustfft::Fft<f32>>;

/// Build a Hann window of length `n` (matches numpy.hanning(n)).
fn hann(n: usize) -> Vec<f32> {
    if n <= 1 {
        return vec![1.0; n];
    }
    (0..n)
        .map(|i| 0.5 - 0.5 * (2.0 * std::f32::consts::PI * i as f32 / (n as f32 - 1.0)).cos())
        .collect()
}

fn fft_worker(
    ring: Arc<Mutex<RingBuffer>>,
    running: RunningFlag,
    tx: SyncSender<Vec<f32>>,
    fft_size: Arc<AtomicUsize>,
    gpu_enabled: Arc<AtomicBool>,
    err_slot: Arc<Mutex<Option<String>>>,
) {
    let mut planner = FftPlanner::<f32>::new();

    // Cache the window + FFT plan for the current size; rebuild only on change,
    // since planning/window-building is comparatively expensive.
    let mut n = fft_size.load(Ordering::Relaxed).max(2);
    let mut window = hann(n);
    let mut fft = planner.plan_fft_forward(n);

    // Lazily-created GPU FFT. `gpu` is Some once init succeeds; `gpu_failed`
    // latches after an init failure so we don't retry every tick. Any per-frame
    // GPU error falls back to CPU for that frame and is reported once.
    let mut gpu: Option<crate::gpu_fft::GpuFft> = None;
    let mut gpu_failed = false;
    let mut reported_fallback = false;
    // Status message awaiting delivery. The error slot only accepts a message
    // when it's empty (so a pending error isn't clobbered before the GUI sees
    // it), so we retry every tick instead of dropping the message — otherwise
    // e.g. the one-shot "GPU FFT error" report could be lost forever just
    // because the slot happened to be busy that tick.
    let mut pending_msg: Option<String> = None;

    loop {
        thread::sleep(Duration::from_millis(40));

        if let Some(msg) = pending_msg.take() {
            if !set_once(&err_slot, msg.clone()) {
                pending_msg = Some(msg);
            }
        }

        // Pick up a runtime resolution change.
        let requested = fft_size.load(Ordering::Relaxed).max(2);
        if requested != n {
            n = requested;
            window = hann(n);
            fft = planner.plan_fft_forward(n);
        }

        if !running.load(Ordering::Relaxed) {
            let _ = tx.try_send(vec![-100.0; n / 2]);
            continue;
        }

        // Window the latest block once; both paths consume the same input.
        let block = {
            let r = ring.lock().unwrap();
            r.latest(n)
        };
        let windowed: Vec<f32> = (0..n)
            .map(|i| block.get(i).copied().unwrap_or(0.0) * window[i])
            .collect();

        let want_gpu = gpu_enabled.load(Ordering::Relaxed);

        // Initialize the GPU context on first use (and only once).
        if want_gpu && gpu.is_none() && !gpu_failed {
            match crate::gpu_fft::GpuFft::new(n) {
                Ok(g) => {
                    pending_msg = Some(format!("GPU FFT active: {}", g.adapter_name()));
                    gpu = Some(g);
                }
                Err(e) => {
                    gpu_failed = true;
                    pending_msg = Some(format!("GPU FFT unavailable, using CPU: {e}"));
                }
            }
        }

        let spec_db: Vec<f32> = match (want_gpu, gpu.as_mut()) {
            (true, Some(g)) => {
                // GPU path with per-frame fallback to CPU on error.
                match g.power_spectrum(&windowed, n, -100.0) {
                    Ok(spec) => spec,
                    Err(e) => {
                        if !reported_fallback {
                            pending_msg = Some(format!("GPU FFT error, using CPU: {e}"));
                            reported_fallback = true;
                        }
                        cpu_spectrum(&fft, &windowed, n)
                    }
                }
            }
            _ => cpu_spectrum(&fft, &windowed, n),
        };

        // Non-blocking send; the new frame is dropped if the GUI has 16
        // frames backed up (it drains the whole queue every painted frame).
        let _ = tx.try_send(spec_db);
    }
}

/// Linear-interpolation resampler for the TX path (mono). Linear quality is
/// ample for the smooth narrowband chirps; only used when the output device
/// won't open at the configured rate.
fn resample_linear(input: &[f32], from: u32, to: u32) -> Vec<f32> {
    if from == to || input.is_empty() || from == 0 || to == 0 {
        return input.to_vec();
    }
    let step = from as f64 / to as f64;
    let out_len = (input.len() as f64 / step).floor() as usize;
    (0..out_len)
        .map(|i| {
            let x = i as f64 * step;
            let i0 = x as usize;
            let frac = (x - i0 as f64) as f32;
            let a = input[i0.min(input.len() - 1)];
            let b = input[(i0 + 1).min(input.len() - 1)];
            a + (b - a) * frac
        })
        .collect()
}

/// CPU power spectrum (dBFS, first n/2 bins) from a windowed real input.
fn cpu_spectrum(fft: &FftPlan, windowed: &[f32], n: usize) -> Vec<f32> {
    const EPS: f32 = 1e-12;
    let mut buf: Vec<Complex<f32>> = windowed.iter().map(|&re| Complex { re, im: 0.0 }).collect();
    fft.process(&mut buf);
    buf.iter()
        .take(n / 2)
        .map(|c| 20.0 * (c.norm() / n as f32 + EPS).log10())
        .collect()
}

/// Set the shared error slot only if currently empty (don't clobber a pending
/// message the GUI hasn't drained yet). Returns whether the message was
/// stored — callers keep it and retry later when it wasn't.
fn set_once(slot: &Arc<Mutex<Option<String>>>, msg: String) -> bool {
    let mut guard = slot.lock().unwrap();
    if guard.is_none() {
        *guard = Some(msg);
        true
    } else {
        false
    }
}
