灵符lingfu

ling-lang

↓ 0 downloads

Ling - The Omniglot Systems Language

Keywords systems, polyglot, ai, crypto, game
lingfu install ling-lang
LicenseApache-2.0 OR MIT
AuthorsLing Lin, Sanny Lin
READMECode / FilesVersionsDependency GraphDependentsSecurityBuildsSettings

Code / Files (995)

crates/ling-audio/src/engine.rs
// crates/ling-audio/src/engine.rs — 4D positional audio engine
//
// Each "tone" lives at a 3-D world position plus a 4th-dimension W value that
// cross-modulates the oscillator for a hyperdimensional shimmer.
//
// Spatial audio:
//   - Camera orientation (cry, sry, crx, srx) matches the Ling gfx Camera3D.
//   - World position → camera-space X drives equal-power L/R panning.
//   - Distance in camera space drives exponential attenuation.
//   - tanh soft-clips the final mix so nothing blows up.
//
// BGM: raw WAV loaded via hound, linearly-resampled to the device rate, looped.

use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use std::f32::consts::{FRAC_PI_2, TAU};
use std::sync::{Arc, Mutex};

// ─── Public types ─────────────────────────────────────────────────────────────

/// Parameters for one positional tone.
#[derive(Clone, Debug)]
pub struct ToneParams {
    /// World-space position of the sound source.
    pub x: f32,
    pub y: f32,
    pub z: f32,
    /// 4th-dimension value: drives a sub-oscillator at `freq * w * 0.007 Hz`
    /// that cross-modulates the main carrier (0 → no 4D effect).
    pub w: f32,
    /// Carrier frequency in Hz.
    pub freq: f32,
    /// Linear amplitude (0..1 recommended).
    pub amp: f32,
    /// LFO rate in Hz (vibrato speed).
    pub lfo_rate: f32,
    /// LFO depth as a fraction of freq (0.03 = ±3 % pitch wobble).
    pub lfo_depth: f32,
}

impl Default for ToneParams {
    fn default() -> Self {
        Self {
            x: 0.0,
            y: 0.0,
            z: 0.0,
            w: 1.0,
            freq: 220.0,
            amp: 0.15,
            lfo_rate: 0.5,
            lfo_depth: 0.02,
        }
    }
}

// ─── Internal state ────────────────────────────────────────────────────────────

struct Tone {
    params: ToneParams,
    phase: f32,     // carrier oscillator phase [0, 1)
    lfo_phase: f32, // LFO phase [0, 1)
    w_phase: f32,   // 4D sub-oscillator phase [0, 1)
    cur_amp: f32,   // smoothed amplitude (glides to params.amp — de-click)
    cur_freq: f32,  // smoothed frequency (glides to params.freq — de-zipper)
}

impl Tone {
    fn new(params: ToneParams) -> Self {
        let (a, f) = (params.amp, params.freq);
        Self {
            params,
            phase: 0.0,
            lfo_phase: 0.0,
            w_phase: 0.0,
            cur_amp: a,
            cur_freq: f,
        }
    }
}

/// Oscillator waveform for one-shot UI blips.
#[derive(Clone, Copy, Debug)]
pub enum Wave {
    Sine,
    Square,
    Saw,
    Triangle,
    Noise,
}

impl Wave {
    pub fn from_name(s: &str) -> Wave {
        match s.to_ascii_lowercase().as_str() {
            "square" | "sq" => Wave::Square,
            "saw" | "sawtooth" => Wave::Saw,
            "tri" | "triangle" => Wave::Triangle,
            "noise" | "wn" | "ns" => Wave::Noise,
            _ => Wave::Sine,
        }
    }

    #[inline]
    fn sample(self, phase: f32) -> f32 {
        match self {
            Wave::Sine => (phase * TAU).sin(),
            Wave::Square => {
                if phase < 0.5 {
                    1.0
                } else {
                    -1.0
                }
            },
            Wave::Saw => phase * 2.0 - 1.0,
            Wave::Triangle => 1.0 - 4.0 * (phase - 0.5).abs(),
            Wave::Noise => 0.0, // handled per-sample via LCG in voice render
        }
    }
}

/// A fire-and-forget interface sound with a fast attack + exponential decay.
struct Blip {
    freq: f32,
    amp: f32,
    wave: Wave,
    dur: f32, // seconds until it's removed
    age: f32, // seconds elapsed
    phase: f32,
    seed: u32, // LCG state for Wave::Noise
}

impl Blip {
    /// Advance one sample, returning the (mono) value; envelope folds in here.
    #[inline]
    fn next(&mut self, dt: f32) -> f32 {
        // 5 ms attack ramp, then exponential decay across the remaining duration.
        let atk = 0.005;
        let env = if self.age < atk {
            self.age / atk
        } else {
            (-(self.age - atk) / (self.dur * 0.4 + 1e-4)).exp()
        };
        let raw = if let Wave::Noise = self.wave {
            self.seed = self.seed.wrapping_mul(1664525).wrapping_add(1013904223);
            ((self.seed >> 8) as f32 / 8_388_608.0) - 1.0
        } else {
            self.wave.sample(self.phase)
        };
        let s = raw * self.amp * env;
        self.phase = (self.phase + self.freq * dt).fract();
        self.age += dt;
        s
    }

    fn done(&self) -> bool {
        self.age >= self.dur
    }
}

struct BgmTrack {
    /// Interleaved stereo samples at `src_rate`.
    samples: Vec<f32>,
    src_rate: u32,
    /// Fractional stereo-pair index (advances by `src_rate / device_rate` per sample).
    pos: f64,
    volume: f32,
}

/// Equal-power pan + distance attenuation gains for a world-space point, using
/// the current listener (camera) orientation. Shared by tones, sfx and samples.
#[allow(clippy::too_many_arguments)]
#[inline]
fn spatial_gains(
    cry: f32,
    sry: f32,
    crx: f32,
    srx: f32,
    lx: f32,
    ly: f32,
    lz: f32,
    room_w: f32,
    x: f32,
    y: f32,
    z: f32,
) -> (f32, f32) {
    let (x, y, z) = (x - lx, y - ly, z - lz); // make the sound relative to the listener (camera) position
    let rz1 = x * sry + z * cry;
    let cam_x = x * cry - z * sry;
    let cam_y = y * crx - rz1 * srx;
    let cam_z = y * srx + rz1 * crx;
    let dist = (cam_x * cam_x + cam_y * cam_y + cam_z * cam_z)
        .sqrt()
        .max(0.5);
    let atten = (1.0 / (1.0 + dist * 0.18)).clamp(0.0, 1.0);
    let pan = (cam_x / room_w.max(1.0)).clamp(-1.0, 1.0);
    let angle = (pan + 1.0) * 0.5 * FRAC_PI_2;
    (angle.cos() * atten, angle.sin() * atten)
}

/// A positional (2D/3D/4D) one-shot sound effect with a fast-attack/decay
/// envelope — like a [`Blip`] but spatialized at a world position.
struct SfxVoice {
    x: f32,
    y: f32,
    z: f32,
    w: f32,
    freq: f32,
    amp: f32,
    wave: Wave,
    dur: f32,
    age: f32,
    phase: f32,
    w_phase: f32,
    seed: u32,
}
impl SfxVoice {
    #[inline]
    fn next(&mut self, dt: f32) -> f32 {
        let atk = 0.005;
        let env = if self.age < atk {
            self.age / atk
        } else {
            (-(self.age - atk) / (self.dur * 0.4 + 1e-4)).exp()
        };
        let w_mod = (self.w_phase * TAU).sin() * 0.25;
        self.w_phase = (self.w_phase + self.freq * self.w.abs() * 0.007 * dt).fract();
        let f = self.freq * (1.0 + w_mod * 0.06);
        let raw = if let Wave::Noise = self.wave {
            self.seed = self.seed.wrapping_mul(1664525).wrapping_add(1013904223);
            ((self.seed >> 8) as f32 / 8_388_608.0) - 1.0
        } else {
            self.wave.sample(self.phase)
        };
        let s = raw * self.amp * env;
        self.phase = (self.phase + f * dt).fract();
        self.age += dt;
        s
    }

    fn done(&self) -> bool {
        self.age >= self.dur
    }
}

/// A playing instance of a loaded sample buffer at a world position (looping or one-shot).
#[allow(dead_code)] // `w` is reserved for spatial-audio weighting (not yet read)
struct SampleVoice {
    id: u32,
    sample: usize,
    pos: f64,
    x: f32,
    y: f32,
    z: f32,
    w: f32,
    vol: f32,
    /// Playback rate multiplier (1.0 = recorded pitch, 2.0 = up an octave,
    /// 0.5 = down an octave) — resampling-based pitch shift, so it also
    /// scales duration (a higher pitch plays faster), same trade-off as
    /// classic sample-rate-based pitch shifting (tape/turntable speed).
    pitch: f32,
    looping: bool,
    active: bool,
    /// false ⇒ skip `spatial_gains` entirely and mix at flat `vol` (both
    /// channels) regardless of listener distance/orientation — for VO/
    /// narration, which should read at a consistent level no matter where
    /// the cutscene camera happens to be, not fall off like a diegetic
    /// world-space sound effect. true = existing positional behaviour.
    positional: bool,
}

/// YIN-YANG morph voice — one voice that spans the acoustic↔digital continuum.
/// "Light" core = a Karplus-Strong physical model (excited string/air/membrane →
/// organic plucked/bowed/blown/struck tones). "Dark" core = an FM operator pair
/// through a bit/sample-rate crusher (digital grit). The `morph` dial (0=light ..
/// 1=dark) crossfades the two AND tilts the shaping — more FM index, deeper crush
/// and more tanh drive toward dark. Material presets pick excitation + damping so
/// a single voice covers a wide palette. Self-contained DSP; existing voices are
/// untouched (fully backwards compatible).
#[allow(dead_code)] // `w` reserved for 4D spatial weighting (not yet read)
struct MorphVoice {
    freq: f32,
    ks: Vec<f32>, // Karplus-Strong delay line (the resonator)
    ks_len: usize,
    ks_idx: usize,
    ks_damp: f32, // brightness: high-freq retained per round-trip
    ks_fb: f32,   // string feedback (ring length)
    ks_lp: f32,   // 1-pole damping state
    excite: u8,   // 0 pluck · 1 bow · 2 blow · 3 strike
    seed: u32,
    car: f32, // FM carrier phase
    md: f32,  // FM modulator phase
    ratio: f32,
    index: f32,
    hold: f32,      // crusher sample-hold
    crush_acc: f32, // sample-rate-reduction accumulator
    morph: f32,
    amp: f32,
    dur: f32,
    age: f32,
    x: f32,
    y: f32,
    z: f32,
    w: f32,
}
impl MorphVoice {
    #[allow(clippy::too_many_arguments)]
    fn new(
        sr: f32,
        freq: f32,
        amp: f32,
        dur: f32,
        material: u8,
        morph: f32,
        x: f32,
        y: f32,
        z: f32,
        w: f32,
    ) -> Self {
        let freq = freq.clamp(20.0, sr * 0.45);
        let ks_len = ((sr / freq).round() as usize).clamp(2, 4096);
        // material → (excite, damp, feedback, fm ratio, fm index)
        let (excite, ks_damp, ks_fb, ratio, index) = match material {
            0 => (1u8, 0.55, 0.992, 2.0, 0.5), // Bowed Silk (sustained string)
            1 => (0u8, 0.80, 0.990, 3.0, 0.9), // Plucked Bamboo (bright pluck)
            2 => (2u8, 0.48, 0.991, 1.0, 0.4), // Blown Jade (air column)
            _ => (3u8, 0.90, 0.997, 1.414, 1.3), // Struck Bronze (long inharmonic metal)
        };
        let mut s = freq.to_bits().wrapping_mul(2654435761).wrapping_add(1);
        let mut ks = vec![0.0f32; ks_len];
        if excite == 0 || excite == 3 {
            // pluck/strike: prefill the line with a noise burst so it rings
            for v in ks.iter_mut() {
                s = s.wrapping_mul(1664525).wrapping_add(1013904223);
                *v = ((s >> 9) as f32 / 4_194_304.0) - 1.0;
            }
        }
        Self {
            freq,
            ks,
            ks_len,
            ks_idx: 0,
            ks_damp,
            ks_fb,
            ks_lp: 0.0,
            excite,
            seed: s | 1,
            car: 0.0,
            md: 0.0,
            ratio,
            index,
            hold: 0.0,
            crush_acc: 0.0,
            morph: morph.clamp(0.0, 1.0),
            amp,
            dur: dur.max(0.02),
            age: 0.0,
            x,
            y,
            z,
            w,
        }
    }

    #[inline]
    fn noise(&mut self) -> f32 {
        self.seed = self.seed.wrapping_mul(1664525).wrapping_add(1013904223);
        ((self.seed >> 9) as f32 / 4_194_304.0) - 1.0
    }

    #[inline]
    fn next(&mut self, dt: f32) -> f32 {
        let atk = 0.006;
        let env = if self.age < atk {
            self.age / atk
        } else {
            (-(self.age - atk) / (self.dur * 0.5 + 1e-4)).exp()
        };
        // ── excitation into the resonator ──
        let n = self.noise();
        let exc = match self.excite {
            1 => n * 0.5 * env,  // bow: sustained noise
            2 => n * 0.32 * env, // blow: breath
            3 => {
                if self.age < 0.0015 {
                    n * 1.5
                } else {
                    0.0
                }
            }, // strike: impulse (+prefill)
            _ => {
                if self.age < 0.004 {
                    n * 0.4
                } else {
                    0.0
                }
            }, // pluck: brief top-up (+prefill)
        };
        // ── organic core: Karplus-Strong ──
        let cur = self.ks[self.ks_idx];
        let nxt = self.ks[(self.ks_idx + 1) % self.ks_len];
        let avg = (cur + nxt) * 0.5;
        self.ks_lp += (avg - self.ks_lp) * self.ks_damp;
        self.ks[self.ks_idx] = self.ks_lp * self.ks_fb + exc;
        self.ks_idx = (self.ks_idx + 1) % self.ks_len;
        let organic = cur;
        // ── digital core: FM operator pair ──
        let idx = self.index * (0.3 + self.morph * 1.8);
        let modv = (self.md * TAU).sin();
        let fm = ((self.car + modv * idx) * TAU).sin();
        self.car = (self.car + self.freq * dt).fract();
        self.md = (self.md + self.freq * self.ratio * dt).fract();
        // ── bit + sample-rate crush (deeper toward dark) ──
        let step = 1.0 + self.morph * 10.0;
        self.crush_acc += 1.0;
        if self.crush_acc >= step {
            self.crush_acc -= step;
            let bits = 11.0 - self.morph * 8.0;
            let q = 2f32.powf(bits);
            self.hold = (fm * q).round() / q;
        }
        let digital = self.hold;
        // ── morph crossfade + dark drive ──
        let m = self.morph;
        let mut out = organic * (1.0 - m) + digital * m;
        out = (out * (1.0 + m * 3.5)).tanh();
        self.age += dt;
        out * self.amp * env
    }

    fn done(&self) -> bool {
        self.age >= self.dur
    }
}

/// Stereo feedback delay on the master bus.
struct Delay {
    bl: Vec<f32>,
    br: Vec<f32>,
    idx: usize,
    len: usize,
    fb: f32,
    mix: f32,
}
impl Delay {
    fn new(rate: u32) -> Self {
        let cap = (rate as usize * 2).max(1); // up to 2 s
        Self {
            bl: vec![0.0; cap],
            br: vec![0.0; cap],
            idx: 0,
            len: 0,
            fb: 0.0,
            mix: 0.0,
        }
    }

    #[inline]
    fn process(&mut self, l: f32, r: f32) -> (f32, f32) {
        if self.mix <= 0.0 || self.len == 0 {
            return (l, r);
        }
        let read = (self.idx + self.bl.len() - self.len) % self.bl.len();
        let dl = self.bl[read];
        let dr = self.br[read];
        self.bl[self.idx] = l + dl * self.fb;
        self.br[self.idx] = r + dr * self.fb;
        self.idx = (self.idx + 1) % self.bl.len();
        (l + dl * self.mix, r + dr * self.mix)
    }
}

/// A single Freeverb-style comb filter.
struct Comb {
    buf: Vec<f32>,
    idx: usize,
    fb: f32,
    store: f32,
    damp: f32,
}
impl Comb {
    fn new(n: usize, fb: f32) -> Self {
        Self { buf: vec![0.0; n.max(1)], idx: 0, fb, store: 0.0, damp: 0.2 }
    }

    #[inline]
    fn process(&mut self, x: f32) -> f32 {
        let y = self.buf[self.idx];
        self.store = y * (1.0 - self.damp) + self.store * self.damp;
        self.buf[self.idx] = x + self.store * self.fb;
        self.idx = (self.idx + 1) % self.buf.len();
        y
    }
}
/// A single allpass filter.
struct Allpass {
    buf: Vec<f32>,
    idx: usize,
}
impl Allpass {
    fn new(n: usize) -> Self {
        Self { buf: vec![0.0; n.max(1)], idx: 0 }
    }

    #[inline]
    fn process(&mut self, x: f32) -> f32 {
        let buf = self.buf[self.idx];
        let y = -x + buf;
        self.buf[self.idx] = x + buf * 0.5;
        self.idx = (self.idx + 1) % self.buf.len();
        y
    }
}
/// Cheap mono Schroeder reverb mixed back into stereo.
struct Reverb {
    combs: Vec<Comb>,
    allpass: Vec<Allpass>,
    mix: f32,
}
impl Reverb {
    fn new(rate: u32) -> Self {
        let s = rate as f32 / 44100.0;
        let comb = |n: usize, fb: f32| Comb::new((n as f32 * s) as usize, fb);
        let ap = |n: usize| Allpass::new((n as f32 * s) as usize);
        Self {
            combs: vec![
                comb(1116, 0.84),
                comb(1188, 0.83),
                comb(1277, 0.82),
                comb(1356, 0.81),
            ],
            allpass: vec![ap(225), ap(556)],
            mix: 0.0,
        }
    }

    #[inline]
    fn process(&mut self, l: f32, r: f32) -> (f32, f32) {
        if self.mix <= 0.0 {
            return (l, r);
        }
        let x = (l + r) * 0.5;
        let mut y = 0.0;
        for c in &mut self.combs {
            y += c.process(x);
        }
        y *= 0.25;
        for a in &mut self.allpass {
            y = a.process(y);
        }
        (l + y * self.mix, r + y * self.mix)
    }
}

/// Master 2-pole low-pass (smoothed cutoff) for muffled / underwater scenes.
/// `cutoff` ∈ (0,1]; 1.0 ≈ bypass.
struct LowPass {
    yl: [f32; 2],
    yr: [f32; 2],
    cutoff: f32,
    target: f32,
}
impl LowPass {
    fn new() -> Self {
        Self { yl: [0.0; 2], yr: [0.0; 2], cutoff: 1.0, target: 1.0 }
    }

    #[inline]
    fn process(&mut self, l: f32, r: f32) -> (f32, f32) {
        // glide toward target to avoid zipper noise
        self.cutoff += (self.target - self.cutoff) * 0.001;
        if self.cutoff >= 0.999 {
            return (l, r);
        }
        // map cutoff01 → one-pole coefficient (exp so low values are very dark)
        let a = (self.cutoff * self.cutoff).clamp(0.0008, 1.0);
        self.yl[0] += a * (l - self.yl[0]);
        self.yl[1] += a * (self.yl[0] - self.yl[1]);
        self.yr[0] += a * (r - self.yr[0]);
        self.yr[1] += a * (self.yr[0] - self.yr[1]);
        (self.yl[1], self.yr[1])
    }
}

struct AudioState {
    tones: Vec<Option<Tone>>,
    blips: Vec<Blip>,
    sfx: Vec<SfxVoice>,
    morph_voices: Vec<MorphVoice>, // YIN-YANG morph synth voices
    samples: Vec<(std::sync::Arc<Vec<f32>>, u32)>, // (mono buffer, src rate)
    sample_voices: Vec<SampleVoice>,
    next_voice_id: u32,
    delay: Delay,
    reverb: Reverb,
    lowpass: LowPass,
    bgm: Option<BgmTrack>,
    master_volume: f32,
    // Camera orientation — mirrors Camera3D cry/sry/crx/srx.
    cry: f32,
    sry: f32,
    crx: f32,
    srx: f32,
    /// Half-width of the room (used to normalise the pan value).
    room_w: f32,
    /// Listener (camera) world position — sounds are spatialised relative to it.
    lx: f32,
    ly: f32,
    lz: f32,
    sample_rate: u32,
}

impl AudioState {
    fn new(sample_rate: u32) -> Self {
        Self {
            tones: (0..16).map(|_| None).collect(),
            blips: Vec::new(),
            sfx: Vec::new(),
            morph_voices: Vec::new(),
            samples: Vec::new(),
            sample_voices: Vec::new(),
            next_voice_id: 1,
            delay: Delay::new(sample_rate),
            reverb: Reverb::new(sample_rate),
            lowpass: LowPass::new(),
            bgm: None,
            master_volume: 0.5,
            cry: 1.0,
            sry: 0.0,
            crx: 1.0,
            srx: 0.0,
            room_w: 9.0,
            lx: 0.0,
            ly: 0.0,
            lz: 0.0,
            sample_rate,
        }
    }

    /// Generate one stereo (L, R) sample pair.
    #[inline]
    fn next_sample(&mut self) -> (f32, f32) {
        // Copy scalars so borrowck doesn't complain about &mut self during tone loop.
        let cry = self.cry;
        let sry = self.sry;
        let crx = self.crx;
        let srx = self.srx;
        let room_w = self.room_w;
        let lx = self.lx;
        let ly = self.ly;
        let lz = self.lz;
        let dt = 1.0 / self.sample_rate as f32;

        let mut l = 0.0f32;
        let mut r = 0.0f32;

        for slot in &mut self.tones {
            let tone = match slot.as_mut() {
                Some(t) => t,
                None => continue,
            };
            // Copy params to locals (no borrow held → free to mutate the tone below).
            let (px, py, pz, pfreq, pamp, plfo_depth, plfo_rate, pw) = {
                let p = &tone.params;
                (p.x, p.y, p.z, p.freq, p.amp, p.lfo_depth, p.lfo_rate, p.w)
            };
            // ── De-click / de-zipper: glide amp & freq toward their targets ──
            // The game re-sets tone params every frame; jumping amp/freq makes the
            // waveform discontinuous → audible snaps/pops. One-pole smoothing fixes it.
            tone.cur_amp += (pamp - tone.cur_amp) * 0.004;
            tone.cur_freq += (pfreq - tone.cur_freq) * 0.012;

            // ── World → camera-space ─────────────────────────────────────────
            // Apply Y-rotation (yaw) then X-rotation (pitch) — same as Camera3D.
            let rz1 = px * sry + pz * cry;
            let cam_x = px * cry - pz * sry;
            let cam_y = py * crx - rz1 * srx;
            let cam_z = py * srx + rz1 * crx;

            // ── Spatial attenuation ──────────────────────────────────────────
            let dist = (cam_x * cam_x + cam_y * cam_y + cam_z * cam_z)
                .sqrt()
                .max(0.5);
            let atten = (1.0 / (1.0 + dist * 0.18)).clamp(0.0, 1.0);

            // ── Equal-power panning ──────────────────────────────────────────
            let pan = (cam_x / room_w.max(1.0)).clamp(-1.0, 1.0);
            let angle = (pan + 1.0) * 0.5 * FRAC_PI_2;
            let l_gain = angle.cos() * atten;
            let r_gain = angle.sin() * atten;

            // ── LFO (vibrato) ────────────────────────────────────────────────
            let lfo_mod = (tone.lfo_phase * TAU).sin() * plfo_depth;
            tone.lfo_phase = (tone.lfo_phase + plfo_rate * dt).fract();

            // ── 4D sub-oscillator ─────────────────────────────────────────────
            // W drives a slow cross-modulator; the phase drift creates
            // hyperdimensional beating that is unique per sound-source.
            let w_mod = (tone.w_phase * TAU).sin() * 0.25;
            let w_freq = tone.cur_freq * pw.abs() * 0.007;
            tone.w_phase = (tone.w_phase + w_freq * dt).fract();

            // ── Carrier oscillator (smoothed amp & freq) ──────────────────────
            let inst_freq = tone.cur_freq * (1.0 + lfo_mod) * (1.0 + w_mod * 0.08);
            let sample = (tone.phase * TAU).sin() * tone.cur_amp;
            tone.phase = (tone.phase + inst_freq * dt).fract();

            l += sample * l_gain;
            r += sample * r_gain;
        }

        // ── One-shot UI blips (centred, no spatialisation) ───────────────────
        if !self.blips.is_empty() {
            let mut mono = 0.0f32;
            for b in &mut self.blips {
                mono += b.next(dt);
            }
            self.blips.retain(|b| !b.done());
            l += mono;
            r += mono;
        }

        // ── Positional one-shot SFX (2D/3D/4D) ───────────────────────────────
        if !self.sfx.is_empty() {
            for v in &mut self.sfx {
                let (lg, rg) = spatial_gains(cry, sry, crx, srx, lx, ly, lz, room_w, v.x, v.y, v.z);
                let s = v.next(dt);
                l += s * lg;
                r += s * rg;
            }
            self.sfx.retain(|v| !v.done());
        }

        // ── YIN-YANG morph synth voices (physical-model ↔ FM/crush) ──────────
        if !self.morph_voices.is_empty() {
            for v in &mut self.morph_voices {
                let (lg, rg) = spatial_gains(cry, sry, crx, srx, lx, ly, lz, room_w, v.x, v.y, v.z);
                let s = v.next(dt);
                l += s * lg;
                r += s * rg;
            }
            self.morph_voices.retain(|v| !v.done());
        }

        // ── Positional sample voices (one-shot or looping) ───────────────────
        if !self.sample_voices.is_empty() {
            let out_rate = self.sample_rate as f64;
            for v in &mut self.sample_voices {
                if !v.active {
                    continue;
                }
                let (buf, src_rate) = match self.samples.get(v.sample) {
                    Some(s) => s,
                    None => {
                        v.active = false;
                        continue;
                    },
                };
                let n = buf.len();
                if n < 2 {
                    v.active = false;
                    continue;
                }
                let idx = v.pos as usize;
                let frac = (v.pos - idx as f64) as f32;
                let s = if idx + 1 < n {
                    buf[idx] + (buf[idx + 1] - buf[idx]) * frac
                } else {
                    buf[idx.min(n - 1)]
                };
                let (lg, rg) = if v.positional {
                    spatial_gains(cry, sry, crx, srx, lx, ly, lz, room_w, v.x, v.y, v.z)
                } else {
                    (1.0, 1.0)
                };
                l += s * v.vol * lg;
                r += s * v.vol * rg;
                v.pos += (*src_rate as f64 / out_rate) * v.pitch as f64;
                if v.pos as usize >= n - 1 {
                    if v.looping {
                        v.pos = 0.0;
                    } else {
                        v.active = false;
                    }
                }
            }
            self.sample_voices.retain(|v| v.active);
        }

        // ── BGM ─────────────────────────────────────────────────────────────
        if let Some(bgm) = &mut self.bgm {
            let n_pairs = bgm.samples.len() / 2;
            if n_pairs >= 2 {
                let ratio = bgm.src_rate as f64 / self.sample_rate as f64;
                let idx = bgm.pos as usize;
                let frac = (bgm.pos - idx as f64) as f32;
                let nxt = (idx + 1) % n_pairs;

                let bl =
                    bgm.samples[idx * 2] + (bgm.samples[nxt * 2] - bgm.samples[idx * 2]) * frac;
                let br = bgm.samples[idx * 2 + 1]
                    + (bgm.samples[nxt * 2 + 1] - bgm.samples[idx * 2 + 1]) * frac;

                l += bl * bgm.volume;
                r += br * bgm.volume;

                bgm.pos += ratio;
                if bgm.pos as usize >= n_pairs.saturating_sub(1) {
                    bgm.pos = 0.0; // loop
                }
            }
        }

        // ── Master FX chain: delay → reverb → low-pass → volume ──────────────
        let (l, r) = self.delay.process(l, r);
        let (l, r) = self.reverb.process(l, r);
        let (l, r) = self.lowpass.process(l, r);
        let mv = self.master_volume;
        ((l * mv).tanh(), (r * mv).tanh())
    }
}

// ─── Public engine ─────────────────────────────────────────────────────────────

/// The live audio engine.  Create once at startup; keep alive for the program duration.
/// All methods take `&self` — mutation is routed through an interior `Arc<Mutex<>>`.
pub struct AudioEngine {
    state: Arc<Mutex<AudioState>>,
    /// Kept alive to prevent cpal from stopping the stream when it's dropped.
    _stream: cpal::Stream,
    /// Device sample rate (informational).
    pub out_rate: u32,
}

impl AudioEngine {
    /// Initialise cpal, open the default output device, start the audio thread.
    /// Returns `Err` if no audio device is available (e.g. headless CI).
    pub fn new() -> Result<Self, Box<dyn std::error::Error>> {
        let host = cpal::default_host();
        let device = host
            .default_output_device()
            .ok_or("no default audio output device")?;
        let supported = device.default_output_config()?;

        let channels = supported.channels() as usize;
        let out_rate = supported.sample_rate().0;
        let fmt = supported.sample_format();
        let config = supported.config();

        let state = Arc::new(Mutex::new(AudioState::new(out_rate)));
        let stream = build_stream(&device, &config, channels, Arc::clone(&state), fmt)?;
        stream.play()?;

        Ok(Self { state, _stream: stream, out_rate })
    }

    // ── Tone control ─────────────────────────────────────────────────────────

    /// Insert or update the tone at slot `idx`.  At most 64 slots; grows as needed.
    pub fn set_tone(&self, idx: usize, params: ToneParams) {
        if let Ok(mut s) = self.state.lock() {
            while s.tones.len() <= idx {
                s.tones.push(None);
            }
            match &mut s.tones[idx] {
                Some(t) => t.params = params,
                slot => *slot = Some(Tone::new(params)),
            }
        }
    }

    /// Fire a one-shot interface sound (fast attack, exponential decay).
    /// `dur` is in seconds; up to 32 blips overlap before the oldest is dropped.
    pub fn blip(&self, freq: f32, amp: f32, dur: f32, wave: Wave) {
        if let Ok(mut s) = self.state.lock() {
            if s.blips.len() >= 32 {
                s.blips.remove(0);
            }
            s.blips.push(Blip {
                freq,
                amp,
                wave,
                dur: dur.max(0.01),
                age: 0.0,
                phase: 0.0,
                seed: freq.to_bits().wrapping_mul(2654435761).wrapping_add(1),
            });
        }
    }

    /// Fire a positional (2D/3D/4D) one-shot sound effect at a world point.
    #[allow(clippy::too_many_arguments)]
    pub fn sfx(&self, x: f32, y: f32, z: f32, w: f32, freq: f32, amp: f32, dur: f32, wave: Wave) {
        if let Ok(mut s) = self.state.lock() {
            if s.sfx.len() >= 64 {
                s.sfx.remove(0);
            }
            s.sfx.push(SfxVoice {
                x,
                y,
                z,
                w,
                freq,
                amp,
                wave,
                dur: dur.max(0.01),
                age: 0.0,
                phase: 0.0,
                w_phase: 0.0,
                seed: freq.to_bits().wrapping_mul(2654435761).wrapping_add(1),
            });
        }
    }

    /// Stop every one-shot effect immediately — the SFX pool, the YIN-YANG morph
    /// voices, and sample voices — so an effect tail (slash / monster callout /
    /// explosion) can't bleed across a scene transition. Continuous tones and
    /// music are left alone (silence those via `tone(..,amp=0)` / `stop_music`).
    pub fn stop_all_sfx(&self) {
        if let Ok(mut s) = self.state.lock() {
            s.sfx.clear();
            s.morph_voices.clear();
            s.sample_voices.clear();
        }
    }

    /// Fire a YIN-YANG morph synth note. `material`: 0 bowed-string · 1 plucked ·
    /// 2 blown · 3 struck-metal. `morph` 0=light/acoustic .. 1=dark/digital
    /// (FM + bit/sample crush + drive). Up to 32 morph voices; oldest dropped.
    #[allow(clippy::too_many_arguments)]
    pub fn morph_note(
        &self,
        x: f32,
        y: f32,
        z: f32,
        w: f32,
        freq: f32,
        amp: f32,
        dur: f32,
        material: u8,
        morph: f32,
    ) {
        if let Ok(mut s) = self.state.lock() {
            if s.morph_voices.len() >= 32 {
                s.morph_voices.remove(0);
            }
            let sr = s.sample_rate as f32;
            s.morph_voices.push(MorphVoice::new(
                sr, freq, amp, dur, material, morph, x, y, z, w,
            ));
        }
    }

    /// Register a mono sample buffer (decoded elsewhere), returning its id.
    pub fn add_sample(&self, mono: Vec<f32>, src_rate: u32) -> usize {
        if let Ok(mut s) = self.state.lock() {
            s.samples.push((std::sync::Arc::new(mono), src_rate.max(1)));
            s.samples.len() - 1
        } else {
            0
        }
    }

    /// Play a loaded sample at a world position (looping or one-shot).
    /// `pitch` is a playback-rate multiplier (1.0 = unchanged). Returns a voice id.
    #[allow(clippy::too_many_arguments)]
    pub fn play_sample(
        &self,
        id: usize,
        x: f32,
        y: f32,
        z: f32,
        w: f32,
        vol: f32,
        looping: bool,
        pitch: f32,
    ) -> u32 {
        if let Ok(mut s) = self.state.lock() {
            if id >= s.samples.len() {
                return 0;
            }
            let vid = s.next_voice_id;
            s.next_voice_id += 1;
            if s.sample_voices.len() >= 64 {
                s.sample_voices.remove(0);
            }
            s.sample_voices.push(SampleVoice {
                id: vid,
                sample: id,
                pos: 0.0,
                x,
                y,
                z,
                w,
                vol,
                pitch: if pitch > 0.0 { pitch } else { 1.0 },
                looping,
                active: true,
                positional: true,
            });
            vid
        } else {
            0
        }
    }

    /// Play a loaded sample at a flat, non-positional volume (no distance
    /// falloff, no stereo pan) — for VO/narration/UI cues that should read at
    /// a consistent level regardless of where the camera/listener currently
    /// is, unlike `play_sample`'s world-space sound effects. `pitch` is a
    /// playback-rate multiplier (1.0 = unchanged). Returns a voice id.
    pub fn play_sample_flat(&self, id: usize, vol: f32, looping: bool, pitch: f32) -> u32 {
        if let Ok(mut s) = self.state.lock() {
            if id >= s.samples.len() {
                return 0;
            }
            let vid = s.next_voice_id;
            s.next_voice_id += 1;
            if s.sample_voices.len() >= 64 {
                s.sample_voices.remove(0);
            }
            s.sample_voices.push(SampleVoice {
                id: vid,
                sample: id,
                pos: 0.0,
                x: 0.0,
                y: 0.0,
                z: 0.0,
                w: 1.0,
                vol,
                pitch: if pitch > 0.0 { pitch } else { 1.0 },
                looping,
                active: true,
                positional: false,
            });
            vid
        } else {
            0
        }
    }

    /// Change a playing voice's volume and playback rate (a rolling ball's loop
    /// follows its speed). Negative values leave that setting alone.
    pub fn set_sample_voice(&self, voice: u32, vol: f32, pitch: f32) {
        if let Ok(mut s) = self.state.lock() {
            if let Some(v) = s.sample_voices.iter_mut().find(|v| v.id == voice) {
                if vol >= 0.0 {
                    v.vol = vol;
                }
                if pitch > 0.0 {
                    v.pitch = pitch;
                }
            }
        }
    }

    /// Stop a sample voice by id.
    pub fn stop_sample(&self, voice: u32) {
        if let Ok(mut s) = self.state.lock() {
            if let Some(v) = s.sample_voices.iter_mut().find(|v| v.id == voice) {
                v.active = false;
            }
        }
    }

    /// Duration in seconds of a loaded sample (mono buffer length / its own
    /// source rate — independent of the device's output rate). 0.0 if `id`
    /// is out of range. Lets a script pace a cutscene line to the ACTUAL
    /// length of whichever language's VO clip is loaded, instead of a
    /// hand-measured constant that only covers one language and goes stale
    /// the moment a new recording is dropped in.
    pub fn sample_duration(&self, id: usize) -> f32 {
        if let Ok(s) = self.state.lock() {
            if let Some((buf, src_rate)) = s.samples.get(id) {
                return buf.len() as f32 / (*src_rate).max(1) as f32;
            }
        }
        0.0
    }

    // ── master FX ─────────────────────────────────────────────────────────────
    pub fn fx_delay(&self, time_s: f32, feedback: f32, mix: f32) {
        if let Ok(mut s) = self.state.lock() {
            let cap = s.delay.bl.len();
            s.delay.len =
                ((time_s.max(0.0) * s.sample_rate as f32) as usize).min(cap.saturating_sub(1));
            s.delay.fb = feedback.clamp(0.0, 0.95);
            s.delay.mix = mix.clamp(0.0, 1.0);
        }
    }

    pub fn fx_reverb(&self, mix: f32) {
        if let Ok(mut s) = self.state.lock() {
            s.reverb.mix = mix.clamp(0.0, 1.0);
        }
    }

    /// Master low-pass cutoff ∈ (0,1]; 1.0 ≈ open, lower = muffled/underwater.
    pub fn fx_lowpass(&self, cutoff01: f32) {
        if let Ok(mut s) = self.state.lock() {
            s.lowpass.target = cutoff01.clamp(0.0, 1.0);
        }
    }

    /// Silence and remove the tone at `idx`.
    pub fn clear_tone(&self, idx: usize) {
        if let Ok(mut s) = self.state.lock() {
            if let Some(slot) = s.tones.get_mut(idx) {
                *slot = None;
            }
        }
    }

    // ── Listener (camera) ────────────────────────────────────────────────────

    /// Update the listener orientation to match the Ling `set_camera` values.
    pub fn set_listener(&self, cry: f32, sry: f32, crx: f32, srx: f32) {
        if let Ok(mut s) = self.state.lock() {
            s.cry = cry;
            s.sry = sry;
            s.crx = crx;
            s.srx = srx;
        }
    }

    /// Update the listener (camera) world position so positional sounds pan and
    /// attenuate relative to where the camera actually is.
    pub fn set_listener_pos(&self, x: f32, y: f32, z: f32) {
        if let Ok(mut s) = self.state.lock() {
            s.lx = x;
            s.ly = y;
            s.lz = z;
        }
    }

    // ── BGM ─────────────────────────────────────────────────────────────────

    /// Load a WAV file and start looping it as background music.
    /// Silently ignores missing files so scenes still run in silent environments.
    pub fn load_bgm(&self, path: &str, vol: f32) {
        match load_wav(path) {
            Ok((samples, src_rate)) => {
                if let Ok(mut s) = self.state.lock() {
                    s.bgm = Some(BgmTrack { samples, src_rate, pos: 0.0, volume: vol });
                }
            },
            Err(e) => eprintln!("audio: bgm load failed ({path}): {e}"),
        }
    }

    /// Adjust BGM playback volume without reloading.
    pub fn set_bgm_volume(&self, vol: f32) {
        if let Ok(mut s) = self.state.lock() {
            if let Some(bgm) = &mut s.bgm {
                bgm.volume = vol;
            }
        }
    }

    // ── Master ───────────────────────────────────────────────────────────────

    pub fn set_master_volume(&self, vol: f32) {
        if let Ok(mut s) = self.state.lock() {
            s.master_volume = vol;
        }
    }
}

// ─── WAV loader ───────────────────────────────────────────────────────────────

fn load_wav(path: &str) -> Result<(Vec<f32>, u32), Box<dyn std::error::Error>> {
    let mut reader = hound::WavReader::open(path)?;
    let spec = reader.spec();
    let channels = spec.channels as usize;
    let src_rate = spec.sample_rate;

    let raw: Vec<f32> = match spec.sample_format {
        hound::SampleFormat::Float => reader.samples::<f32>().filter_map(|s| s.ok()).collect(),
        hound::SampleFormat::Int => {
            // Normalise to [-1, 1] regardless of bit depth.
            let max = (1i32 << spec.bits_per_sample.saturating_sub(1)) as f32;
            reader
                .samples::<i32>()
                .filter_map(|s| s.ok())
                .map(|s| s as f32 / max)
                .collect()
        },
    };

    // Normalise to interleaved stereo.
    let stereo: Vec<f32> = match channels {
        1 => raw.iter().flat_map(|&s| [s, s]).collect(),
        2 => raw,
        n => raw
            .chunks(n)
            .flat_map(|c| [c[0], if c.len() > 1 { c[1] } else { c[0] }])
            .collect(),
    };

    Ok((stereo, src_rate))
}

// ─── cpal stream builder ──────────────────────────────────────────────────────

fn build_stream(
    device: &cpal::Device,
    config: &cpal::StreamConfig,
    channels: usize,
    state: Arc<Mutex<AudioState>>,
    fmt: cpal::SampleFormat,
) -> Result<cpal::Stream, Box<dyn std::error::Error>> {
    let err_fn = |e: cpal::StreamError| eprintln!("cpal stream error: {e}");

    Ok(match fmt {
        cpal::SampleFormat::F32 => {
            let st = Arc::clone(&state);
            device.build_output_stream(
                config,
                move |data: &mut [f32], _| fill_f32(data, channels, &st),
                err_fn,
                None,
            )?
        },
        cpal::SampleFormat::I16 => {
            let st = Arc::clone(&state);
            device.build_output_stream(
                config,
                move |data: &mut [i16], _| fill_i16(data, channels, &st),
                err_fn,
                None,
            )?
        },
        _ => {
            // Generic fallback: output as i16.
            let st = Arc::clone(&state);
            device.build_output_stream::<i16, _, _>(
                config,
                move |data: &mut [i16], _| fill_i16(data, channels, &st),
                err_fn,
                None,
            )?
        },
    })
}

/// Fill a `&mut [f32]` buffer (interleaved, `channels` wide).
fn fill_f32(data: &mut [f32], channels: usize, state: &Arc<Mutex<AudioState>>) {
    let ch = channels.max(1);
    // `lock()`, NOT `try_lock()`: the game thread makes many short audio calls
    // per frame (4 hum tones + low-pass + SFX + monster callouts…), each briefly
    // holding this mutex. With `try_lock` the callback dropped to SILENCE on any
    // contention → choppy/stuttering continuous tones (the "ball whirr" hum).
    // The lock holders are all microsecond-short, so blocking here is negligible
    // and gives glitch-free audio. (A lock-free ring buffer would be the textbook
    // real-time fix; this is the pragmatic one for a game mixer.)
    let mut s = match state.lock() {
        Ok(s) => s,
        Err(p) => p.into_inner(), // poisoned (a panic mid-mix) → keep playing
    };
    for frame in data.chunks_mut(ch) {
        let (l, r) = s.next_sample();
        frame[0] = l;
        if ch > 1 {
            frame[1] = r;
        }
        for extra in frame.iter_mut().skip(2) {
            *extra = 0.0;
        }
    }
}

/// Fill a `&mut [i16]` buffer (interleaved, `channels` wide).
fn fill_i16(data: &mut [i16], channels: usize, state: &Arc<Mutex<AudioState>>) {
    let ch = channels.max(1);
    // `lock()` not `try_lock()` — see fill_f32: try_lock dropped to silence on
    // contention with the game thread's per-frame audio calls → choppy tones.
    let mut s = match state.lock() {
        Ok(s) => s,
        Err(p) => p.into_inner(),
    };
    for frame in data.chunks_mut(ch) {
        let (l, r) = s.next_sample();
        frame[0] = (l * 32_767.0) as i16;
        if ch > 1 {
            frame[1] = (r * 32_767.0) as i16;
        }
        for extra in frame.iter_mut().skip(2) {
            *extra = 0;
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn sfx_voice_envelopes_and_ends() {
        let mut v = SfxVoice {
            x: 0.0,
            y: 0.0,
            z: 0.0,
            w: 1.0,
            freq: 440.0,
            amp: 0.5,
            wave: Wave::Sine,
            dur: 0.02,
            age: 0.0,
            phase: 0.0,
            w_phase: 0.0,
            seed: 1,
        };
        let dt = 1.0 / 44100.0;
        let mut peak = 0.0f32;
        let mut steps = 0;
        while !v.done() && steps < 44100 {
            peak = peak.max(v.next(dt).abs());
            steps += 1;
        }
        assert!(peak > 0.01, "sfx should produce sound");
        assert!(v.done(), "sfx should finish after its duration");
    }

    #[test]
    fn sample_voice_loops_and_oneshot_stops() {
        let mut st = AudioState::new(44100);
        st.samples
            .push((std::sync::Arc::new(vec![0.5f32; 100]), 44100));
        // looping voice survives past the buffer end
        st.sample_voices.push(SampleVoice {
            id: 1,
            sample: 0,
            pos: 0.0,
            x: 0.0,
            y: 0.0,
            z: 0.0,
            w: 1.0,
            vol: 1.0,
            pitch: 1.0,
            looping: true,
            active: true,
            positional: true,
        });
        for _ in 0..500 {
            let _ = st.next_sample();
        }
        assert_eq!(
            st.sample_voices.len(),
            1,
            "looping voice should still be alive"
        );
        // one-shot voice deactivates after the buffer
        st.sample_voices.push(SampleVoice {
            id: 2,
            sample: 0,
            pos: 0.0,
            x: 0.0,
            y: 0.0,
            z: 0.0,
            w: 1.0,
            vol: 1.0,
            pitch: 1.0,
            looping: false,
            active: true,
            positional: true,
        });
        for _ in 0..500 {
            let _ = st.next_sample();
        }
        assert!(
            st.sample_voices.iter().all(|v| v.id != 2),
            "one-shot should have stopped"
        );
    }

    #[test]
    fn master_fx_stay_finite() {
        let mut st = AudioState::new(44100);
        st.delay.len = 2000;
        st.delay.fb = 0.6;
        st.delay.mix = 0.4;
        st.reverb.mix = 0.5;
        st.lowpass.target = 0.2;
        st.lowpass.cutoff = 0.2;
        // feed a tone and ensure the FX chain never blows up
        st.tones[0] = Some(Tone::new(ToneParams {
            freq: 220.0,
            amp: 0.8,
            ..Default::default()
        }));
        for _ in 0..44100 {
            let (l, r) = st.next_sample();
            assert!(l.is_finite() && r.is_finite() && l.abs() <= 1.0 && r.abs() <= 1.0);
        }
    }

    // ── Wave channel synthesis ────────────────────────────────────────────────
    #[test]
    fn wave_from_name_maps_and_samples_stay_bounded() {
        assert!(matches!(Wave::from_name("square"), Wave::Square));
        assert!(matches!(Wave::from_name("SAW"), Wave::Saw)); // case-insensitive
        assert!(matches!(Wave::from_name("triangle"), Wave::Triangle));
        assert!(matches!(Wave::from_name("noise"), Wave::Noise));
        assert!(matches!(Wave::from_name("whatever"), Wave::Sine)); // default
        for wave in [
            Wave::Sine,
            Wave::Square,
            Wave::Saw,
            Wave::Triangle,
            Wave::Noise,
        ] {
            for i in 0..256 {
                let v = wave.sample(i as f32 / 256.0);
                assert!(
                    v.is_finite() && v.abs() <= 1.0,
                    "{wave:?} out of range: {v}"
                );
            }
        }
    }

    // ── Mixer combines independent channels (tone + sfx + sample) ─────────────
    #[test]
    fn channels_mix_together() {
        let mut st = AudioState::new(44100);
        st.tones[0] = Some(Tone::new(ToneParams {
            freq: 330.0,
            amp: 0.4,
            ..Default::default()
        }));
        st.sfx.push(SfxVoice {
            x: 0.0,
            y: 0.0,
            z: 0.0,
            w: 1.0,
            freq: 660.0,
            amp: 0.4,
            wave: Wave::Square,
            dur: 0.5,
            age: 0.0,
            phase: 0.0,
            w_phase: 0.0,
            seed: 7,
        });
        st.samples
            .push((std::sync::Arc::new(vec![0.3f32; 256]), 44100));
        st.sample_voices.push(SampleVoice {
            id: 1,
            sample: 0,
            pos: 0.0,
            x: 0.0,
            y: 0.0,
            z: 0.0,
            w: 1.0,
            vol: 0.6,
            pitch: 1.0,
            looping: true,
            active: true,
            positional: true,
        });
        let mut energy = 0.0f64;
        for _ in 0..2000 {
            let (l, r) = st.next_sample();
            assert!(l.is_finite() && r.is_finite());
            energy += (l * l + r * r) as f64;
        }
        assert!(
            energy > 1.0,
            "all three channels should contribute audible energy"
        );
    }

    // ── Many simultaneous loud channels must never exceed digital full-scale ──
    #[test]
    fn many_loud_channels_stay_within_headroom() {
        let mut st = AudioState::new(44100);
        st.master_volume = 1.0;
        for i in 0..st.tones.len() {
            st.tones[i] = Some(Tone::new(ToneParams {
                freq: 110.0 + i as f32 * 25.0,
                amp: 1.0,
                ..Default::default()
            }));
        }
        for i in 0..60 {
            st.sfx.push(SfxVoice {
                x: 0.0,
                y: 0.0,
                z: 0.0,
                w: 1.0,
                freq: 200.0 + i as f32 * 30.0,
                amp: 1.0,
                wave: Wave::Saw,
                dur: 1.0,
                age: 0.0,
                phase: 0.0,
                w_phase: 0.0,
                seed: i + 1,
            });
        }
        for _ in 0..44100 {
            let (l, r) = st.next_sample();
            assert!(
                l.is_finite() && r.is_finite() && l.abs() <= 1.0 && r.abs() <= 1.0,
                "soft-clip must keep the mix bus within [-1, 1]: ({l}, {r})"
            );
        }
    }

    // ── Positional channels pan correctly (identity listener: world -x = left) ─
    #[test]
    fn positional_channel_pans_left_then_right() {
        let energy = |x: f32| -> (f64, f64) {
            let mut st = AudioState::new(44100);
            // Dry signal so panning isn't smeared by the master FX bus.
            st.delay.mix = 0.0;
            st.reverb.mix = 0.0;
            st.lowpass.target = 1.0;
            st.lowpass.cutoff = 1.0;
            st.tones[0] = Some(Tone::new(ToneParams {
                x,
                freq: 440.0,
                amp: 0.6,
                ..Default::default()
            }));
            let (mut el, mut er) = (0.0f64, 0.0f64);
            for _ in 0..4000 {
                let (l, r) = st.next_sample();
                el += (l * l) as f64;
                er += (r * r) as f64;
            }
            (el, er)
        };
        let (ll, lr) = energy(-8.0); // hard left
        assert!(
            ll > lr * 1.5,
            "left-positioned source should favour L ({ll} vs {lr})"
        );
        let (rl, rr) = energy(8.0); // hard right
        assert!(
            rr > rl * 1.5,
            "right-positioned source should favour R ({rl} vs {rr})"
        );
    }

    // ── THREADS: the cpal callback thread and the game thread share AudioState
    // through Arc<Mutex<>>. A producer (game: set_tone/sfx) and a consumer
    // (audio callback: next_sample) hammering it concurrently must not panic,
    // deadlock, or emit garbage. ───────────────────────────────────────────────
    #[test]
    fn audio_thread_shared_state_stays_safe_under_contention() {
        use std::sync::atomic::{AtomicBool, Ordering};
        let state = Arc::new(Mutex::new(AudioState::new(44100)));
        let stop = Arc::new(AtomicBool::new(false));

        // Producer: mimics the game thread re-setting tones + firing sfx each frame.
        let producer = {
            let state = Arc::clone(&state);
            let stop = Arc::clone(&stop);
            std::thread::spawn(move || {
                let mut n: u32 = 0;
                while !stop.load(Ordering::Relaxed) {
                    if let Ok(mut s) = state.lock() {
                        let slot = (n as usize) % s.tones.len();
                        s.tones[slot] = Some(Tone::new(ToneParams {
                            freq: 200.0 + (n % 800) as f32,
                            amp: 0.5,
                            ..Default::default()
                        }));
                        if s.sfx.len() >= 64 {
                            s.sfx.remove(0);
                        } // same cap the engine enforces
                        s.sfx.push(SfxVoice {
                            x: ((n % 17) as f32) - 8.0,
                            y: 0.0,
                            z: 0.0,
                            w: 1.0,
                            freq: 440.0,
                            amp: 0.4,
                            wave: Wave::Triangle,
                            dur: 0.05,
                            age: 0.0,
                            phase: 0.0,
                            w_phase: 0.0,
                            seed: n.max(1),
                        });
                    }
                    n = n.wrapping_add(1);
                    std::thread::yield_now();
                }
            })
        };

        // Consumer: mimics the cpal callback pulling sample blocks under the lock.
        let consumer = {
            let state = Arc::clone(&state);
            let stop = Arc::clone(&stop);
            std::thread::spawn(move || {
                let mut produced = 0u64;
                let mut bad = 0u64;
                while !stop.load(Ordering::Relaxed) {
                    if let Ok(mut s) = state.lock() {
                        for _ in 0..256 {
                            let (l, r) = s.next_sample();
                            if !(l.is_finite() && r.is_finite() && l.abs() <= 1.0 && r.abs() <= 1.0)
                            {
                                bad += 1;
                            }
                            produced += 1;
                        }
                    }
                    std::thread::yield_now();
                }
                (produced, bad)
            })
        };

        std::thread::sleep(std::time::Duration::from_millis(200));
        stop.store(true, Ordering::Relaxed);

        producer.join().expect("producer thread must not panic");
        let (produced, bad) = consumer.join().expect("consumer thread must not panic");
        assert!(produced > 0, "consumer should have mixed some samples");
        assert_eq!(
            bad, 0,
            "every concurrently-mixed sample must be finite and in range"
        );
    }

    // ── YIN-YANG morph voice: every material makes sound, stays bounded ───────
    #[test]
    fn morph_voice_each_material_is_audible_and_bounded() {
        for material in 0u8..4 {
            let mut v =
                MorphVoice::new(44100.0, 220.0, 0.7, 0.4, material, 0.5, 0.0, 0.0, 0.0, 1.0);
            let dt = 1.0 / 44100.0;
            let mut peak = 0.0f32;
            let mut steps = 0;
            while !v.done() && steps < 44100 {
                let s = v.next(dt);
                assert!(
                    s.is_finite() && s.abs() <= 1.0,
                    "material {material} out of range: {s}"
                );
                peak = peak.max(s.abs());
                steps += 1;
            }
            assert!(
                peak > 0.01,
                "material {material} should produce sound (peak {peak})"
            );
        }
    }

    // ── The morph dial actually changes the timbre (light ≠ dark) ─────────────
    #[test]
    fn morph_light_and_dark_differ() {
        let render = |morph: f32| -> f64 {
            let mut v = MorphVoice::new(44100.0, 330.0, 0.7, 0.3, 1, morph, 0.0, 0.0, 0.0, 1.0);
            let dt = 1.0 / 44100.0;
            let mut acc = 0.0f64;
            for _ in 0..6000 {
                let s = v.next(dt);
                acc += (s.abs()) as f64; // crude spectral-energy proxy
            }
            acc
        };
        let light = render(0.0);
        let dark = render(1.0);
        // Dark adds FM sidebands + crush + tanh drive → a materially different signal.
        assert!(
            (light - dark).abs() / (light + dark + 1e-6) > 0.05,
            "light {light} vs dark {dark} should differ"
        );
    }
}

← Back to index