//! Capture / file-playback + analysis pipeline.
//!
//! source thread (cpal RT callback OR symphonia decode) --lock-free ring-->
//! analysis thread (overlapping STFT, Hann, rustfft, per-band AGC + envelope,
//! spectral-flux onset) --triple_buffer--> consumer (latest frame, no backlog).
//!
//! The STFT core lives in [`Analyzer`] so the live thread and the offline
//! batch path ([`analyze_file`]) share exactly the same numbers.
//!
//! [`Bands`] carries smoothed level (AGC-normalised 0..1), a fast onset spike
//! per band, plus richer descriptors used for colour and growth: a log-spaced
//! spectrum, spectral centroid (brightness), broadband flux, loudness, and a
//! 12-bin chroma vector (harmonic palette). Drop the [`AudioHandle`] to stop.

use std::fs::File;
use std::path::Path;
use std::thread;
use std::time::Duration;

use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{FromSample, Sample, SampleFormat};
use ringbuf::HeapRb;
use ringbuf::traits::{Consumer, Observer, Producer, Split};
use rustfft::FftPlanner;
use rustfft::num_complex::Complex;
use symphonia::core::codecs::CodecParameters;
use symphonia::core::codecs::audio::{AudioDecoder, AudioDecoderOptions};
use symphonia::core::formats::probe::Hint;
use symphonia::core::formats::{FormatOptions, FormatReader, TrackType};
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions;

pub const FFT_SIZE: usize = 2048;
pub const HOP: usize = 512; // 75% overlap
const RING_CAP: usize = FFT_SIZE * 16;

/// Log-spaced spectrum buckets exposed to the visualiser (per-strand drive).
pub const SPEC_N: usize = 16;
/// Pitch classes for the chroma / harmonic palette.
pub const CHROMA_N: usize = 12;
/// Decimated raw time-domain window taps exposed for the oscilloscope mode.
pub const WAVE_N: usize = 256;
const SPEC_LO: f32 = 30.0;
const SPEC_HI: f32 = 16_000.0;

// Band edges in Hz. Low = kick/sub, Mid = synths/vocals, High = hats/glitch.
const LOW: (f32, f32) = (20.0, 250.0);
const MID: (f32, f32) = (250.0, 2000.0);
const HIGH: (f32, f32) = (2000.0, 16000.0);

// Level envelope follower: snap up fast, decay slow.
const ATTACK: f32 = 0.6;
const RELEASE: f32 = 0.08;
// AGC: rolling per-metric ceiling decays slowly so quiet/loud tracks self-level.
const AGC_DECAY: f32 = 0.9992;
const AGC_FLOOR: f32 = 1e-4;
// Onset: instantaneous attack on rising spectral flux, fast release -> a hit.
const ONSET_RELEASE: f32 = 0.78;

/// Per-band level (AGC-normalised, smoothed) + onset spike + rich descriptors.
/// All scalar fields are 0..~1.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Bands {
    pub low: f32,
    pub mid: f32,
    pub high: f32,
    pub low_on: f32,
    pub mid_on: f32,
    pub high_on: f32,
    /// Log-spaced normalised magnitude buckets (`SPEC_LO`..`SPEC_HI`).
    pub spec: [f32; SPEC_N],
    /// Spectral centroid, AGC-normalised 0..1 -> palette hue.
    pub centroid: f32,
    /// Broadband half-wave spectral flux onset -> global transient flashes.
    pub flux: f32,
    /// Overall loudness (mean magnitude), AGC-normalised -> lightness/scale.
    pub loud: f32,
    /// Short decaying pulse on a predicted beat (like an onset, but tempo-gated).
    pub beat: f32,
    /// Sawtooth 0..1: fraction of the predicted beat interval elapsed. Lets
    /// the visualiser *anticipate* the next hit instead of reacting late.
    pub beat_phase: f32,
    /// Spectral flatness 0 (tonal/pad) .. 1 (noisy/break) -> smooth vs jagged.
    pub flatness: f32,
    /// Relative pitch-class energy (max-normalised) -> harmonic accent hues.
    pub chroma: [f32; CHROMA_N],
    /// Decimated raw waveform (latest `FFT_SIZE` window, un-windowed, ~-1..1).
    /// Not smoothed/AGC'd — the time-domain trace the scope mode draws.
    pub wave: [f32; WAVE_N],
}

impl Default for Bands {
    fn default() -> Self {
        Bands {
            low: 0.0,
            mid: 0.0,
            high: 0.0,
            low_on: 0.0,
            mid_on: 0.0,
            high_on: 0.0,
            spec: [0.0; SPEC_N],
            centroid: 0.0,
            flux: 0.0,
            loud: 0.0,
            beat: 0.0,
            beat_phase: 0.0,
            flatness: 0.0,
            chroma: [0.0; CHROMA_N],
            wave: [0.0; WAVE_N],
        }
    }
}

/// What to feed the analyser.
pub enum Source {
    /// cpal input by index (None = name "monitor", else system default).
    Capture(Option<usize>),
    /// First cpal input whose name contains this substring (case-insensitive).
    CaptureNamed(String),
    /// Decode + play this audio file, analysing it in lock-step.
    File(std::path::PathBuf),
}

/// Live handle. Keeps streams/threads alive; poll [`Self::bands`] per frame.
pub struct AudioHandle {
    _streams: Vec<cpal::Stream>,
    out: triple_buffer::Output<Bands>,
}

impl AudioHandle {
    /// Most recently published bands (latest-wins, never blocks).
    pub fn bands(&mut self) -> Bands {
        *self.out.read()
    }
}

/// A fully pre-analysed file: one [`Bands`] frame per STFT hop.
///
/// Used by the offline renderer so frame *N* of video maps deterministically
/// to audio time `N / video_fps`, regardless of how fast frames render.
pub struct Timeline {
    pub frames: Vec<Bands>,
    /// Analysis frame rate (`sample_rate / HOP`).
    pub rate_hz: f32,
    pub sample_rate: f32,
    /// Total decoded mono samples (track length = this / sample_rate).
    pub samples: usize,
}

impl Timeline {
    /// Bands at a given wall-clock second (clamped, nearest hop).
    pub fn at(&self, t_secs: f32) -> Bands {
        if self.frames.is_empty() {
            return Bands::default();
        }
        let i = (t_secs * self.rate_hz).round().max(0.0) as usize;
        self.frames[i.min(self.frames.len() - 1)]
    }
    /// Track duration in seconds.
    pub fn duration(&self) -> f32 {
        self.samples as f32 / self.sample_rate.max(1.0)
    }
}

/// List input devices to stdout, plus PipeWire routing hints.
#[allow(deprecated)] // cpal 0.17 deprecates Device::name; it's still the clearest label
pub fn print_devices() -> anyhow::Result<()> {
    let host = cpal::default_host();
    println!("Input devices:");
    for (i, d) in host.input_devices()?.enumerate() {
        println!("  [{i}] {}", d.name().unwrap_or_else(|_| "<unknown>".into()));
    }
    println!(
        "Tips: pass an index, or `monitor`/`loopback`, or a file path.\n  \
         pw-link route: run, then `pw-link <sink>:monitor_FL <thisapp>:input_FL` (and _FR)."
    );
    Ok(())
}

#[allow(deprecated)]
fn pick_device(host: &cpal::Host, sel: &Source) -> anyhow::Result<cpal::Device> {
    let devices: Vec<_> = host.input_devices()?.collect();
    let by_substr = |s: &str, devices: Vec<cpal::Device>| {
        devices.into_iter().find(|d| {
            d.name()
                .map(|n| n.to_lowercase().contains(s))
                .unwrap_or(false)
        })
    };
    let dev = match sel {
        Source::Capture(Some(i)) => devices.into_iter().nth(*i),
        Source::Capture(None) => {
            by_substr("monitor", devices.clone()).or_else(|| host.default_input_device())
        }
        Source::CaptureNamed(s) => by_substr(&s.to_lowercase(), devices),
        Source::File(_) => unreachable!(),
    };
    dev.ok_or_else(|| anyhow::anyhow!("no matching input device"))
}

/// Start capture or file playback + analysis.
#[allow(deprecated)]
pub fn start(src: Source) -> anyhow::Result<AudioHandle> {
    let (input, out) = triple_buffer::triple_buffer(&Bands::default());

    let rb = HeapRb::<f32>::new(RING_CAP);
    let (mut prod, cons) = rb.split();
    let mut push_mono = move |m: f32| {
        let _ = prod.try_push(m);
    };

    let mut streams: Vec<cpal::Stream> = Vec::new();
    let host = cpal::default_host();

    let sample_rate = match &src {
        Source::File(path) => spawn_file_source(path, push_mono, &mut streams)?,
        _ => {
            let device = pick_device(&host, &src)?;
            let cfg = device.default_input_config()?;
            let sr = cfg.sample_rate() as f32;
            let channels = cfg.channels() as usize;
            eprintln!(
                "audio: capture {} @ {} Hz, {} ch, {:?}",
                device.name().unwrap_or_else(|_| "<unknown>".into()),
                sr as u32,
                channels,
                cfg.sample_format(),
            );
            let scfg = cfg.config();
            let err_fn = |e| eprintln!("stream error: {e}");

            fn run<T>(
                device: &cpal::Device,
                cfg: &cpal::StreamConfig,
                channels: usize,
                mut push: impl FnMut(f32) + Send + 'static,
                err_fn: impl FnMut(cpal::StreamError) + Send + 'static,
            ) -> Result<cpal::Stream, cpal::BuildStreamError>
            where
                T: cpal::SizedSample,
                f32: FromSample<T>,
            {
                device.build_input_stream(
                    cfg,
                    move |data: &[T], _| {
                        for f in data.chunks(channels) {
                            let mut s = 0.0;
                            for &v in f {
                                s += f32::from_sample(v);
                            }
                            push(s / f.len().max(1) as f32);
                        }
                    },
                    err_fn,
                    None,
                )
            }

            let stream = match cfg.sample_format() {
                SampleFormat::F32 => device.build_input_stream(
                    &scfg,
                    move |data: &[f32], _| {
                        for f in data.chunks(channels) {
                            let s: f32 = f.iter().sum::<f32>() / f.len().max(1) as f32;
                            push_mono(s);
                        }
                    },
                    err_fn,
                    None,
                )?,
                SampleFormat::I16 => run::<i16>(&device, &scfg, channels, push_mono, err_fn)?,
                SampleFormat::U16 => run::<u16>(&device, &scfg, channels, push_mono, err_fn)?,
                SampleFormat::I32 => run::<i32>(&device, &scfg, channels, push_mono, err_fn)?,
                other => anyhow::bail!("unsupported sample format: {other:?}"),
            };
            stream.play()?;
            streams.push(stream);
            sr
        }
    };

    thread::spawn(move || analysis_loop(cons, sample_rate, input));
    Ok(AudioHandle {
        _streams: streams,
        out,
    })
}

/// Decode `path`, play it on the default output, tee mono into `push_mono`.
/// Returns the source sample rate. Falls back to the output device's native
/// rate with linear resampling if the device rejects the file's rate.
/// A probed file ready to decode: format reader + audio decoder + the
/// resolved track id, sample rate and channel count. (symphonia 0.6 API.)
struct DecodedFile {
    reader: Box<dyn FormatReader>,
    decoder: Box<dyn AudioDecoder>,
    track_id: u32,
    sample_rate: u32,
    channels: usize,
}

fn open_file(path: &Path) -> anyhow::Result<DecodedFile> {
    let file = File::open(path)?;
    let mss = MediaSourceStream::new(Box::new(file), Default::default());
    let mut hint = Hint::new();
    if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
        hint.with_extension(ext);
    }
    let reader = symphonia::default::get_probe().probe(
        &hint,
        mss,
        FormatOptions::default(),
        MetadataOptions::default(),
    )?;
    let (track_id, sample_rate, channels, decoder) = {
        let track = reader
            .default_track(TrackType::Audio)
            .ok_or_else(|| anyhow::anyhow!("no decodable audio track"))?;
        let id = track.id;
        let Some(CodecParameters::Audio(ap)) = track.codec_params.as_ref() else {
            anyhow::bail!("track has no audio codec parameters");
        };
        let sr = ap.sample_rate.unwrap_or(44_100);
        let ch = ap.channels.as_ref().map(|c| c.count()).unwrap_or(2).max(1);
        let dec = symphonia::default::get_codecs()
            .make_audio_decoder(ap, &AudioDecoderOptions::default())?;
        (id, sr, ch, dec)
    };
    Ok(DecodedFile {
        reader,
        decoder,
        track_id,
        sample_rate,
        channels,
    })
}

fn spawn_file_source(
    path: &Path,
    mut push_mono: impl FnMut(f32) + Send + 'static,
    streams: &mut Vec<cpal::Stream>,
) -> anyhow::Result<f32> {
    let DecodedFile {
        mut reader,
        mut decoder,
        track_id,
        sample_rate: file_sr,
        channels: file_ch,
    } = open_file(path)?;

    // Output device + config. Try the file's rate; fall back + resample.
    let host = cpal::default_host();
    let dev = host
        .default_output_device()
        .ok_or_else(|| anyhow::anyhow!("no output device"))?;
    let default_cfg = dev.default_output_config()?;
    let out_ch = default_cfg.channels() as usize;

    // Use the file's rate only if the device actually supports it; else fall
    // back to the device's native rate and linear-resample.
    let supports_file_sr = dev
        .supported_output_configs()
        .map(|mut it| {
            it.any(|c| c.min_sample_rate() <= file_sr && file_sr <= c.max_sample_rate())
        })
        .unwrap_or(false);
    let (scfg, out_sr) = if supports_file_sr {
        (
            cpal::StreamConfig {
                channels: default_cfg.channels(),
                sample_rate: file_sr,
                buffer_size: cpal::BufferSize::Default,
            },
            file_sr as f32,
        )
    } else {
        (default_cfg.config(), default_cfg.sample_rate() as f32)
    };

    // Playback ring holds interleaved f32 at the *output* device rate.
    let pb = HeapRb::<f32>::new(out_ch * out_sr as usize); // ~1s
    let (mut pb_prod, mut pb_cons) = pb.split();
    let err_fn = |e| eprintln!("output stream error: {e}");
    let stream = dev.build_output_stream(
        &scfg,
        move |data: &mut [f32], _| {
            for s in data.iter_mut() {
                *s = pb_cons.try_pop().unwrap_or(0.0);
            }
        },
        err_fn,
        None,
    )?;
    stream.play()?;
    streams.push(stream);

    eprintln!(
        "audio: file {} @ {} Hz {} ch -> output {} Hz {} ch",
        path.display(),
        file_sr,
        file_ch,
        out_sr as u32,
        out_ch
    );

    let resample = (out_sr / file_sr as f32).max(0.01);

    thread::spawn(move || {
        // Linear-resample state per output channel (mono dup across out_ch).
        let mut frac = 0.0f32;
        let mut prev_mono = 0.0f32;
        let mut ilv: Vec<f32> = Vec::new();

        loop {
            let packet = match reader.next_packet() {
                Ok(Some(p)) => p,
                Ok(None) => break,  // EOF -> stop feeding; output silences
                Err(_) => break,
            };
            if packet.track_id != track_id {
                continue;
            }
            let decoded = match decoder.decode(&packet) {
                Ok(d) => d,
                Err(_) => continue,
            };
            let ch = decoded.spec().channels().count().max(1);
            decoded.copy_to_vec_interleaved::<f32>(&mut ilv);

            for frame in ilv.chunks(ch) {
                let mono = frame.iter().sum::<f32>() / ch as f32;
                // Emit `resample` output frames per input frame (linear).
                frac += resample;
                while frac >= 1.0 {
                    frac -= 1.0;
                    let a = 1.0 - frac.min(1.0);
                    let s = prev_mono * (1.0 - a) + mono * a;
                    push_mono(s);
                    // Block until playback ring has room (back-pressure ==
                    // play speed; keeps analysis in lock-step with audio).
                    for _ in 0..out_ch {
                        while pb_prod.try_push(s).is_err() {
                            thread::sleep(Duration::from_millis(1));
                        }
                    }
                }
                prev_mono = mono;
            }
        }
    });

    Ok(file_sr as f32)
}

/// Streaming STFT analyser. Feed mono samples; emits one [`Bands`] per hop.
///
/// Holds all envelope / AGC / onset state so the live thread and the offline
/// batch produce bit-identical frames for the same input.
pub struct Analyzer {
    hann: Vec<f32>,
    fft: std::sync::Arc<dyn rustfft::Fft<f32>>,
    win: Vec<f32>,
    filled: usize,
    since_hop: usize,
    spectrum: Vec<Complex<f32>>,
    prev_mag: Vec<f32>,
    bin_hz: f32,
    env: Bands,
    // AGC ceilings: 3 level, 3 flux, SPEC_N spectrum, centroid, loud, broad flux.
    agc_lvl: [f32; 3],
    agc_flux: [f32; 3],
    agc_spec: [f32; SPEC_N],
    agc_centroid: f32,
    agc_loud: f32,
    agc_broad: f32,
    pop: [f32; 3],   // low/mid/high onset envelopes
    broad_pop: f32,  // broadband onset envelope
    spec_edges: [(usize, usize); SPEC_N],
    // Predictive-IOI beat model (robust for breakcore's unstable tempo: it
    // tracks the running inter-onset interval, not a brittle global BPM).
    hop_dt: f32,     // seconds advanced per STFT hop
    beat_ioi: f32,   // smoothed inter-onset interval (s)
    beat_clock: f32, // seconds since the last accepted beat
    prev_broad: f32, // for the broadband-onset rising edge
    beat_pop: f32,   // decaying beat pulse
}

fn norm(v: f32, c: &mut f32) -> f32 {
    *c = (*c * AGC_DECAY).max(AGC_FLOOR).max(v);
    (v / *c).clamp(0.0, 1.0)
}

fn follow(env: &mut f32, x: f32) {
    let coeff = if x > *env { ATTACK } else { RELEASE };
    *env += (x - *env) * coeff;
}

impl Analyzer {
    pub fn new(sample_rate: f32) -> Self {
        let hann: Vec<f32> = (0..FFT_SIZE)
            .map(|n| {
                let x = std::f32::consts::PI * 2.0 * n as f32 / (FFT_SIZE as f32 - 1.0);
                0.5 - 0.5 * x.cos()
            })
            .collect();
        let mut planner = FftPlanner::<f32>::new();
        let fft = planner.plan_fft_forward(FFT_SIZE);
        let bin_hz = sample_rate / FFT_SIZE as f32;

        // Pre-compute log-spaced spectrum bucket bin ranges once.
        let half = FFT_SIZE / 2;
        let mut spec_edges = [(0usize, 0usize); SPEC_N];
        for (i, e) in spec_edges.iter_mut().enumerate() {
            let f0 = SPEC_LO * (SPEC_HI / SPEC_LO).powf(i as f32 / SPEC_N as f32);
            let f1 = SPEC_LO * (SPEC_HI / SPEC_LO).powf((i + 1) as f32 / SPEC_N as f32);
            let a = ((f0 / bin_hz).floor() as usize).min(half - 1);
            let b = ((f1 / bin_hz).ceil() as usize).clamp(a + 1, half);
            *e = (a, b);
        }

        Analyzer {
            hann,
            fft,
            win: vec![0.0; FFT_SIZE],
            filled: 0,
            since_hop: 0,
            spectrum: vec![Complex::new(0.0, 0.0); FFT_SIZE],
            prev_mag: vec![0.0; half],
            bin_hz,
            env: Bands::default(),
            agc_lvl: [AGC_FLOOR; 3],
            agc_flux: [AGC_FLOOR; 3],
            agc_spec: [AGC_FLOOR; SPEC_N],
            agc_centroid: AGC_FLOOR,
            agc_loud: AGC_FLOOR,
            agc_broad: AGC_FLOOR,
            pop: [0.0; 3],
            broad_pop: 0.0,
            spec_edges,
            hop_dt: HOP as f32 / sample_rate.max(1.0),
            beat_ioi: 0.5, // ~120 BPM until the track tells us otherwise
            beat_clock: 0.0,
            prev_broad: 0.0,
            beat_pop: 0.0,
        }
    }

    /// Push one mono sample. Returns `Some(bands)` when a hop completes.
    pub fn push(&mut self, s: f32) -> Option<Bands> {
        self.win.copy_within(1..FFT_SIZE, 0);
        self.win[FFT_SIZE - 1] = s;
        self.filled = (self.filled + 1).min(FFT_SIZE);
        self.since_hop += 1;
        if self.filled < FFT_SIZE || self.since_hop < HOP {
            return None;
        }
        self.since_hop = 0;
        Some(self.compute())
    }

    fn compute(&mut self) -> Bands {
        for (i, (&x, &w)) in self.win.iter().zip(&self.hann).enumerate() {
            self.spectrum[i] = Complex::new(x * w, 0.0);
        }
        self.fft.process(&mut self.spectrum);

        let half = FFT_SIZE / 2;
        let bin_hz = self.bin_hz;
        let range = |lo: f32, hi: f32| -> (usize, usize) {
            let a = (lo / bin_hz).floor() as usize;
            let b = ((hi / bin_hz).ceil() as usize).min(half);
            (a, b.max(a + 1))
        };

        // Magnitudes (cache once), running flux + centroid + loudness.
        let mut mags = vec![0.0f32; half];
        let mut broad_flux = 0.0f32;
        let mut cen_num = 0.0f32;
        let mut cen_den = 0.0f32;
        let mut loud_sum = 0.0f32;
        for k in 0..half {
            let m = self.spectrum[k].norm();
            mags[k] = m;
            let d = m - self.prev_mag[k];
            if d > 0.0 {
                broad_flux += d;
            }
            let f = k as f32 * bin_hz;
            cen_num += f * m;
            cen_den += m;
            loud_sum += m;
        }

        // Three classic bands: mean level + half-wave flux.
        let mut lvl = [0.0f32; 3];
        let mut flux = [0.0f32; 3];
        for (bi, &(lo, hi)) in [LOW, MID, HIGH].iter().enumerate() {
            let (a, b) = range(lo, hi);
            let mut sum = 0.0;
            let mut fl = 0.0;
            for k in a..b {
                let m = mags[k];
                sum += m;
                let d = m - self.prev_mag[k];
                if d > 0.0 {
                    fl += d;
                }
            }
            let w = (b - a) as f32;
            lvl[bi] = sum / w;
            flux[bi] = fl / w;
        }

        // Log-spaced spectrum buckets (mean magnitude per bucket).
        let mut spec_raw = [0.0f32; SPEC_N];
        for (i, &(a, b)) in self.spec_edges.iter().enumerate() {
            let mut sum = 0.0;
            for &m in &mags[a..b] {
                sum += m;
            }
            spec_raw[i] = sum / (b - a) as f32;
        }

        // Chroma: fold bin magnitude onto 12 pitch classes (relative energy).
        let mut chroma = [0.0f32; CHROMA_N];
        for k in 1..half {
            let f = k as f32 * bin_hz;
            if f < 55.0 || f > 5000.0 {
                continue;
            }
            let pc = (12.0 * (f / 440.0).log2()).round() as i32;
            let idx = pc.rem_euclid(CHROMA_N as i32) as usize;
            chroma[idx] += mags[k];
        }
        let cmax = chroma.iter().cloned().fold(0.0f32, f32::max).max(1e-6);
        for c in &mut chroma {
            *c /= cmax;
        }

        // Spectral flatness = geometric mean / arithmetic mean of the
        // magnitude spectrum. ~0 for tonal/pad material, ~1 for noise/breaks.
        // Bounded 0..1 already, so it is followed (not AGC'd) like a level.
        let mut log_sum = 0.0f32;
        let mut lin_sum = 0.0f32;
        for &m in &mags[1..half] {
            log_sum += (m + 1e-9).ln();
            lin_sum += m;
        }
        let nbin = (half - 1).max(1) as f32;
        let gm = (log_sum / nbin).exp();
        let am = lin_sum / nbin;
        let flatness = if am > 1e-9 { (gm / am).clamp(0.0, 1.0) } else { 0.0 };

        // Advance prev_mag now that flux is computed.
        self.prev_mag.copy_from_slice(&mags);

        // AGC-normalise each metric against its decaying ceiling.
        let l = [
            norm(lvl[0], &mut self.agc_lvl[0]),
            norm(lvl[1], &mut self.agc_lvl[1]),
            norm(lvl[2], &mut self.agc_lvl[2]),
        ];
        let f = [
            norm(flux[0], &mut self.agc_flux[0]),
            norm(flux[1], &mut self.agc_flux[1]),
            norm(flux[2], &mut self.agc_flux[2]),
        ];
        let mut spec = [0.0f32; SPEC_N];
        for i in 0..SPEC_N {
            spec[i] = norm(spec_raw[i], &mut self.agc_spec[i]);
        }
        let centroid_hz = if cen_den > 1e-6 { cen_num / cen_den } else { 0.0 };
        let centroid = norm(centroid_hz, &mut self.agc_centroid);
        let loud = norm(loud_sum / half as f32, &mut self.agc_loud);
        let broad = norm(broad_flux / half as f32, &mut self.agc_broad);

        // Smoothed levels.
        follow(&mut self.env.low, l[0]);
        follow(&mut self.env.mid, l[1]);
        follow(&mut self.env.high, l[2]);
        follow(&mut self.env.centroid, centroid);
        follow(&mut self.env.loud, loud);
        for i in 0..SPEC_N {
            follow(&mut self.env.spec[i], spec[i]);
        }
        self.env.chroma = chroma;

        // Onsets: jump to flux instantly, decay fast -> spike.
        for i in 0..3 {
            self.pop[i] = if f[i] > self.pop[i] {
                f[i]
            } else {
                self.pop[i] * ONSET_RELEASE
            };
        }
        self.broad_pop = if broad > self.broad_pop {
            broad
        } else {
            self.broad_pop * ONSET_RELEASE
        };
        self.env.low_on = self.pop[0];
        self.env.mid_on = self.pop[1];
        self.env.high_on = self.pop[2];
        self.env.flux = self.broad_pop;
        follow(&mut self.env.flatness, flatness);

        // Predictive beat model. A rising broadband-onset edge past a floor
        // (with a refractory gap) is a candidate beat; the inter-onset
        // interval is smoothed only when it stays a plausible multiple of the
        // current estimate, so a chopped amen break can't ping-pong the tempo
        // octave. `beat_phase` then ramps 0->1 across the predicted interval,
        // which is what lets the visuals anticipate the next hit.
        self.beat_clock += self.hop_dt;
        let onset = broad > 0.40 && broad > self.prev_broad && self.beat_clock > 0.12;
        self.prev_broad = broad;
        if onset {
            let obs = self.beat_clock;
            if (0.18..1.20).contains(&obs) {
                let ratio = obs / self.beat_ioi.max(1e-3);
                // In-range: trust it. Way off (real tempo change): adopt slowly.
                let k = if (0.55..1.80).contains(&ratio) { 0.15 } else { 0.05 };
                self.beat_ioi = (self.beat_ioi + (obs - self.beat_ioi) * k).clamp(0.18, 1.0);
            }
            self.beat_clock = 0.0;
            self.beat_pop = 1.0;
        } else {
            self.beat_pop *= ONSET_RELEASE;
        }
        self.env.beat = self.beat_pop;
        self.env.beat_phase = (self.beat_clock / self.beat_ioi.max(1e-3)).clamp(0.0, 1.0);

        // Raw waveform tap: decimate the un-windowed sample window so the scope
        // mode has a real time-domain trace. Same numbers live + offline.
        let stride = FFT_SIZE / WAVE_N;
        for i in 0..WAVE_N {
            self.env.wave[i] = self.win[i * stride];
        }

        self.env
    }
}

fn analysis_loop(
    mut cons: impl Consumer<Item = f32> + Observer,
    sample_rate: f32,
    mut out: triple_buffer::Input<Bands>,
) {
    let mut an = Analyzer::new(sample_rate);
    let mut scratch = vec![0.0f32; HOP * 8];
    loop {
        let avail = cons.occupied_len();
        if avail == 0 {
            thread::sleep(Duration::from_millis(2));
            continue;
        }
        let take = avail.min(scratch.len());
        let got = cons.pop_slice(&mut scratch[..take]);
        for &s in &scratch[..got] {
            if let Some(b) = an.push(s) {
                out.write(b);
            }
        }
    }
}

/// Decode a whole file to mono and run the analyser over it, returning a
/// per-hop [`Bands`] [`Timeline`]. Used by the offline renderer; does not
/// touch any audio device. (Audio is muxed into the video later by ffmpeg.)
pub fn analyze_file(path: &Path) -> anyhow::Result<Timeline> {
    let DecodedFile {
        mut reader,
        mut decoder,
        track_id,
        sample_rate: sr,
        ..
    } = open_file(path)?;
    let sample_rate = sr as f32;

    let mut an = Analyzer::new(sample_rate);
    let mut frames = Vec::new();
    let mut samples = 0usize;
    let mut ilv: Vec<f32> = Vec::new();

    loop {
        let packet = match reader.next_packet() {
            Ok(Some(p)) => p,
            Ok(None) => break,
            Err(_) => break,
        };
        if packet.track_id != track_id {
            continue;
        }
        let decoded = match decoder.decode(&packet) {
            Ok(d) => d,
            Err(_) => continue,
        };
        let ch = decoded.spec().channels().count().max(1);
        decoded.copy_to_vec_interleaved::<f32>(&mut ilv);
        for frame in ilv.chunks(ch) {
            let mono = frame.iter().sum::<f32>() / ch as f32;
            samples += 1;
            if let Some(b) = an.push(mono) {
                frames.push(b);
            }
        }
    }

    Ok(Timeline {
        frames,
        rate_hz: sample_rate / HOP as f32,
        sample_rate,
        samples,
    })
}
