Skip to main content

ling_audio/
engine.rs

1// crates/ling-audio/src/engine.rs — 4D positional audio engine
2//
3// Each "tone" lives at a 3-D world position plus a 4th-dimension W value that
4// cross-modulates the oscillator for a hyperdimensional shimmer.
5//
6// Spatial audio:
7//   - Camera orientation (cry, sry, crx, srx) matches the Ling gfx Camera3D.
8//   - World position → camera-space X drives equal-power L/R panning.
9//   - Distance in camera space drives exponential attenuation.
10//   - tanh soft-clips the final mix so nothing blows up.
11//
12// BGM: raw WAV loaded via hound, linearly-resampled to the device rate, looped.
13
14use std::sync::{Arc, Mutex};
15use std::f32::consts::{TAU, FRAC_PI_2};
16use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
17
18// ─── Public types ─────────────────────────────────────────────────────────────
19
20/// Parameters for one positional tone.
21#[derive(Clone, Debug)]
22pub struct ToneParams {
23    /// World-space position of the sound source.
24    pub x: f32, pub y: f32, pub z: f32,
25    /// 4th-dimension value: drives a sub-oscillator at `freq * w * 0.007 Hz`
26    /// that cross-modulates the main carrier (0 → no 4D effect).
27    pub w: f32,
28    /// Carrier frequency in Hz.
29    pub freq: f32,
30    /// Linear amplitude (0..1 recommended).
31    pub amp: f32,
32    /// LFO rate in Hz (vibrato speed).
33    pub lfo_rate: f32,
34    /// LFO depth as a fraction of freq (0.03 = ±3 % pitch wobble).
35    pub lfo_depth: f32,
36}
37
38impl Default for ToneParams {
39    fn default() -> Self {
40        Self {
41            x: 0.0, y: 0.0, z: 0.0, w: 1.0,
42            freq: 220.0, amp: 0.15,
43            lfo_rate: 0.5, lfo_depth: 0.02,
44        }
45    }
46}
47
48// ─── Internal state ────────────────────────────────────────────────────────────
49
50struct Tone {
51    params:    ToneParams,
52    phase:     f32,   // carrier oscillator phase [0, 1)
53    lfo_phase: f32,   // LFO phase [0, 1)
54    w_phase:   f32,   // 4D sub-oscillator phase [0, 1)
55    cur_amp:   f32,   // smoothed amplitude (glides to params.amp — de-click)
56    cur_freq:  f32,   // smoothed frequency (glides to params.freq — de-zipper)
57}
58
59impl Tone {
60    fn new(params: ToneParams) -> Self {
61        let (a, f) = (params.amp, params.freq);
62        Self { params, phase: 0.0, lfo_phase: 0.0, w_phase: 0.0, cur_amp: a, cur_freq: f }
63    }
64}
65
66/// Oscillator waveform for one-shot UI blips.
67#[derive(Clone, Copy, Debug)]
68pub enum Wave { Sine, Square, Saw, Triangle, Noise }
69
70impl Wave {
71    pub fn from_name(s: &str) -> Wave {
72        match s.to_ascii_lowercase().as_str() {
73            "square" | "sq"   => Wave::Square,
74            "saw" | "sawtooth" => Wave::Saw,
75            "tri" | "triangle" => Wave::Triangle,
76            "noise" | "wn" | "ns" => Wave::Noise,
77            _ => Wave::Sine,
78        }
79    }
80    #[inline]
81    fn sample(self, phase: f32) -> f32 {
82        match self {
83            Wave::Sine     => (phase * TAU).sin(),
84            Wave::Square   => if phase < 0.5 { 1.0 } else { -1.0 },
85            Wave::Saw      => phase * 2.0 - 1.0,
86            Wave::Triangle => 1.0 - 4.0 * (phase - 0.5).abs(),
87            Wave::Noise    => 0.0,   // handled per-sample via LCG in voice render
88        }
89    }
90}
91
92/// A fire-and-forget interface sound with a fast attack + exponential decay.
93struct Blip {
94    freq:  f32,
95    amp:   f32,
96    wave:  Wave,
97    dur:   f32,   // seconds until it's removed
98    age:   f32,   // seconds elapsed
99    phase: f32,
100    seed:  u32,   // LCG state for Wave::Noise
101}
102
103impl Blip {
104    /// Advance one sample, returning the (mono) value; envelope folds in here.
105    #[inline]
106    fn next(&mut self, dt: f32) -> f32 {
107        // 5 ms attack ramp, then exponential decay across the remaining duration.
108        let atk = 0.005;
109        let env = if self.age < atk {
110            self.age / atk
111        } else {
112            (-(self.age - atk) / (self.dur * 0.4 + 1e-4)).exp()
113        };
114        let raw = if let Wave::Noise = self.wave {
115            self.seed = self.seed.wrapping_mul(1664525).wrapping_add(1013904223);
116            ((self.seed >> 8) as f32 / 8_388_608.0) - 1.0
117        } else { self.wave.sample(self.phase) };
118        let s = raw * self.amp * env;
119        self.phase = (self.phase + self.freq * dt).fract();
120        self.age += dt;
121        s
122    }
123    fn done(&self) -> bool { self.age >= self.dur }
124}
125
126struct BgmTrack {
127    /// Interleaved stereo samples at `src_rate`.
128    samples:  Vec<f32>,
129    src_rate: u32,
130    /// Fractional stereo-pair index (advances by `src_rate / device_rate` per sample).
131    pos:      f64,
132    volume:   f32,
133}
134
135/// Equal-power pan + distance attenuation gains for a world-space point, using
136/// the current listener (camera) orientation. Shared by tones, sfx and samples.
137#[inline]
138fn 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) {
139    let (x, y, z) = (x - lx, y - ly, z - lz);   // make the sound relative to the listener (camera) position
140    let rz1   = x * sry + z * cry;
141    let cam_x = x * cry - z * sry;
142    let cam_y = y * crx - rz1 * srx;
143    let cam_z = y * srx + rz1 * crx;
144    let dist  = (cam_x * cam_x + cam_y * cam_y + cam_z * cam_z).sqrt().max(0.5);
145    let atten = (1.0 / (1.0 + dist * 0.18)).clamp(0.0, 1.0);
146    let pan   = (cam_x / room_w.max(1.0)).clamp(-1.0, 1.0);
147    let angle = (pan + 1.0) * 0.5 * FRAC_PI_2;
148    (angle.cos() * atten, angle.sin() * atten)
149}
150
151/// A positional (2D/3D/4D) one-shot sound effect with a fast-attack/decay
152/// envelope — like a [`Blip`] but spatialized at a world position.
153struct SfxVoice {
154    x: f32, y: f32, z: f32, w: f32,
155    freq: f32, amp: f32, wave: Wave, dur: f32, age: f32, phase: f32, w_phase: f32, seed: u32,
156}
157impl SfxVoice {
158    #[inline]
159    fn next(&mut self, dt: f32) -> f32 {
160        let atk = 0.005;
161        let env = if self.age < atk { self.age / atk }
162                  else { (-(self.age - atk) / (self.dur * 0.4 + 1e-4)).exp() };
163        let w_mod = (self.w_phase * TAU).sin() * 0.25;
164        self.w_phase = (self.w_phase + self.freq * self.w.abs() * 0.007 * dt).fract();
165        let f = self.freq * (1.0 + w_mod * 0.06);
166        let raw = if let Wave::Noise = self.wave {
167            self.seed = self.seed.wrapping_mul(1664525).wrapping_add(1013904223);
168            ((self.seed >> 8) as f32 / 8_388_608.0) - 1.0
169        } else { self.wave.sample(self.phase) };
170        let s = raw * self.amp * env;
171        self.phase = (self.phase + f * dt).fract();
172        self.age += dt;
173        s
174    }
175    fn done(&self) -> bool { self.age >= self.dur }
176}
177
178/// A playing instance of a loaded sample buffer at a world position (looping or one-shot).
179#[allow(dead_code)] // `w` is reserved for spatial-audio weighting (not yet read)
180struct SampleVoice {
181    id: u32,
182    sample: usize,
183    pos: f64,
184    x: f32, y: f32, z: f32, w: f32,
185    vol: f32, looping: bool, active: bool,
186}
187
188/// Stereo feedback delay on the master bus.
189struct Delay {
190    bl: Vec<f32>, br: Vec<f32>, idx: usize, len: usize, fb: f32, mix: f32,
191}
192impl Delay {
193    fn new(rate: u32) -> Self {
194        let cap = (rate as usize * 2).max(1); // up to 2 s
195        Self { bl: vec![0.0; cap], br: vec![0.0; cap], idx: 0, len: 0, fb: 0.0, mix: 0.0 }
196    }
197    #[inline]
198    fn process(&mut self, l: f32, r: f32) -> (f32, f32) {
199        if self.mix <= 0.0 || self.len == 0 { return (l, r); }
200        let read = (self.idx + self.bl.len() - self.len) % self.bl.len();
201        let dl = self.bl[read]; let dr = self.br[read];
202        self.bl[self.idx] = l + dl * self.fb;
203        self.br[self.idx] = r + dr * self.fb;
204        self.idx = (self.idx + 1) % self.bl.len();
205        (l + dl * self.mix, r + dr * self.mix)
206    }
207}
208
209/// A single Freeverb-style comb filter.
210struct Comb { buf: Vec<f32>, idx: usize, fb: f32, store: f32, damp: f32 }
211impl Comb {
212    fn new(n: usize, fb: f32) -> Self { Self { buf: vec![0.0; n.max(1)], idx: 0, fb, store: 0.0, damp: 0.2 } }
213    #[inline]
214    fn process(&mut self, x: f32) -> f32 {
215        let y = self.buf[self.idx];
216        self.store = y * (1.0 - self.damp) + self.store * self.damp;
217        self.buf[self.idx] = x + self.store * self.fb;
218        self.idx = (self.idx + 1) % self.buf.len();
219        y
220    }
221}
222/// A single allpass filter.
223struct Allpass { buf: Vec<f32>, idx: usize }
224impl Allpass {
225    fn new(n: usize) -> Self { Self { buf: vec![0.0; n.max(1)], idx: 0 } }
226    #[inline]
227    fn process(&mut self, x: f32) -> f32 {
228        let buf = self.buf[self.idx];
229        let y = -x + buf;
230        self.buf[self.idx] = x + buf * 0.5;
231        self.idx = (self.idx + 1) % self.buf.len();
232        y
233    }
234}
235/// Cheap mono Schroeder reverb mixed back into stereo.
236struct Reverb { combs: Vec<Comb>, allpass: Vec<Allpass>, mix: f32 }
237impl Reverb {
238    fn new(rate: u32) -> Self {
239        let s = rate as f32 / 44100.0;
240        let comb = |n: usize, fb: f32| Comb::new((n as f32 * s) as usize, fb);
241        let ap = |n: usize| Allpass::new((n as f32 * s) as usize);
242        Self {
243            combs:   vec![comb(1116, 0.84), comb(1188, 0.83), comb(1277, 0.82), comb(1356, 0.81)],
244            allpass: vec![ap(225), ap(556)],
245            mix: 0.0,
246        }
247    }
248    #[inline]
249    fn process(&mut self, l: f32, r: f32) -> (f32, f32) {
250        if self.mix <= 0.0 { return (l, r); }
251        let x = (l + r) * 0.5;
252        let mut y = 0.0;
253        for c in &mut self.combs { y += c.process(x); }
254        y *= 0.25;
255        for a in &mut self.allpass { y = a.process(y); }
256        (l + y * self.mix, r + y * self.mix)
257    }
258}
259
260/// Master 2-pole low-pass (smoothed cutoff) for muffled / underwater scenes.
261/// `cutoff` ∈ (0,1]; 1.0 ≈ bypass.
262struct LowPass { yl: [f32; 2], yr: [f32; 2], cutoff: f32, target: f32 }
263impl LowPass {
264    fn new() -> Self { Self { yl: [0.0; 2], yr: [0.0; 2], cutoff: 1.0, target: 1.0 } }
265    #[inline]
266    fn process(&mut self, l: f32, r: f32) -> (f32, f32) {
267        // glide toward target to avoid zipper noise
268        self.cutoff += (self.target - self.cutoff) * 0.001;
269        if self.cutoff >= 0.999 { return (l, r); }
270        // map cutoff01 → one-pole coefficient (exp so low values are very dark)
271        let a = (self.cutoff * self.cutoff).clamp(0.0008, 1.0);
272        self.yl[0] += a * (l - self.yl[0]); self.yl[1] += a * (self.yl[0] - self.yl[1]);
273        self.yr[0] += a * (r - self.yr[0]); self.yr[1] += a * (self.yr[0] - self.yr[1]);
274        (self.yl[1], self.yr[1])
275    }
276}
277
278struct AudioState {
279    tones:         Vec<Option<Tone>>,
280    blips:         Vec<Blip>,
281    sfx:           Vec<SfxVoice>,
282    samples:       Vec<(std::sync::Arc<Vec<f32>>, u32)>, // (mono buffer, src rate)
283    sample_voices: Vec<SampleVoice>,
284    next_voice_id: u32,
285    delay:         Delay,
286    reverb:        Reverb,
287    lowpass:       LowPass,
288    bgm:           Option<BgmTrack>,
289    master_volume: f32,
290    // Camera orientation — mirrors Camera3D cry/sry/crx/srx.
291    cry: f32, sry: f32,
292    crx: f32, srx: f32,
293    /// Half-width of the room (used to normalise the pan value).
294    room_w: f32,
295    /// Listener (camera) world position — sounds are spatialised relative to it.
296    lx: f32, ly: f32, lz: f32,
297    sample_rate: u32,
298}
299
300impl AudioState {
301    fn new(sample_rate: u32) -> Self {
302        Self {
303            tones:         (0..16).map(|_| None).collect(),
304            blips:         Vec::new(),
305            sfx:           Vec::new(),
306            samples:       Vec::new(),
307            sample_voices: Vec::new(),
308            next_voice_id: 1,
309            delay:         Delay::new(sample_rate),
310            reverb:        Reverb::new(sample_rate),
311            lowpass:       LowPass::new(),
312            bgm:           None,
313            master_volume: 0.5,
314            cry: 1.0, sry: 0.0,
315            crx: 1.0, srx: 0.0,
316            room_w: 9.0,
317            lx: 0.0, ly: 0.0, lz: 0.0,
318            sample_rate,
319        }
320    }
321
322    /// Generate one stereo (L, R) sample pair.
323    #[inline]
324    fn next_sample(&mut self) -> (f32, f32) {
325        // Copy scalars so borrowck doesn't complain about &mut self during tone loop.
326        let cry   = self.cry;
327        let sry   = self.sry;
328        let crx   = self.crx;
329        let srx   = self.srx;
330        let room_w = self.room_w;
331        let lx    = self.lx;
332        let ly    = self.ly;
333        let lz    = self.lz;
334        let dt    = 1.0 / self.sample_rate as f32;
335
336        let mut l = 0.0f32;
337        let mut r = 0.0f32;
338
339        for slot in &mut self.tones {
340            let tone = match slot.as_mut() { Some(t) => t, None => continue };
341            // Copy params to locals (no borrow held → free to mutate the tone below).
342            let (px, py, pz, pfreq, pamp, plfo_depth, plfo_rate, pw) = {
343                let p = &tone.params;
344                (p.x, p.y, p.z, p.freq, p.amp, p.lfo_depth, p.lfo_rate, p.w)
345            };
346            // ── De-click / de-zipper: glide amp & freq toward their targets ──
347            // The game re-sets tone params every frame; jumping amp/freq makes the
348            // waveform discontinuous → audible snaps/pops. One-pole smoothing fixes it.
349            tone.cur_amp  += (pamp  - tone.cur_amp)  * 0.004;
350            tone.cur_freq += (pfreq - tone.cur_freq) * 0.012;
351
352            // ── World → camera-space ─────────────────────────────────────────
353            // Apply Y-rotation (yaw) then X-rotation (pitch) — same as Camera3D.
354            let rz1   =  px * sry + pz * cry;
355            let cam_x =  px * cry - pz * sry;
356            let cam_y =  py * crx - rz1  * srx;
357            let cam_z =  py * srx + rz1  * crx;
358
359            // ── Spatial attenuation ──────────────────────────────────────────
360            let dist  = (cam_x * cam_x + cam_y * cam_y + cam_z * cam_z).sqrt().max(0.5);
361            let atten = (1.0 / (1.0 + dist * 0.18)).clamp(0.0, 1.0);
362
363            // ── Equal-power panning ──────────────────────────────────────────
364            let pan   = (cam_x / room_w.max(1.0)).clamp(-1.0, 1.0);
365            let angle = (pan + 1.0) * 0.5 * FRAC_PI_2;
366            let l_gain = angle.cos() * atten;
367            let r_gain = angle.sin() * atten;
368
369            // ── LFO (vibrato) ────────────────────────────────────────────────
370            let lfo_mod = (tone.lfo_phase * TAU).sin() * plfo_depth;
371            tone.lfo_phase = (tone.lfo_phase + plfo_rate * dt).fract();
372
373            // ── 4D sub-oscillator ─────────────────────────────────────────────
374            // W drives a slow cross-modulator; the phase drift creates
375            // hyperdimensional beating that is unique per sound-source.
376            let w_mod  = (tone.w_phase * TAU).sin() * 0.25;
377            let w_freq = tone.cur_freq * pw.abs() * 0.007;
378            tone.w_phase = (tone.w_phase + w_freq * dt).fract();
379
380            // ── Carrier oscillator (smoothed amp & freq) ──────────────────────
381            let inst_freq = tone.cur_freq * (1.0 + lfo_mod) * (1.0 + w_mod * 0.08);
382            let sample    = (tone.phase * TAU).sin() * tone.cur_amp;
383            tone.phase    = (tone.phase + inst_freq * dt).fract();
384
385            l += sample * l_gain;
386            r += sample * r_gain;
387        }
388
389        // ── One-shot UI blips (centred, no spatialisation) ───────────────────
390        if !self.blips.is_empty() {
391            let mut mono = 0.0f32;
392            for b in &mut self.blips { mono += b.next(dt); }
393            self.blips.retain(|b| !b.done());
394            l += mono;
395            r += mono;
396        }
397
398        // ── Positional one-shot SFX (2D/3D/4D) ───────────────────────────────
399        if !self.sfx.is_empty() {
400            for v in &mut self.sfx {
401                let (lg, rg) = spatial_gains(cry, sry, crx, srx, lx, ly, lz, room_w, v.x, v.y, v.z);
402                let s = v.next(dt);
403                l += s * lg;
404                r += s * rg;
405            }
406            self.sfx.retain(|v| !v.done());
407        }
408
409        // ── Positional sample voices (one-shot or looping) ───────────────────
410        if !self.sample_voices.is_empty() {
411            let out_rate = self.sample_rate as f64;
412            for v in &mut self.sample_voices {
413                if !v.active { continue; }
414                let (buf, src_rate) = match self.samples.get(v.sample) { Some(s) => s, None => { v.active = false; continue; } };
415                let n = buf.len();
416                if n < 2 { v.active = false; continue; }
417                let idx = v.pos as usize;
418                let frac = (v.pos - idx as f64) as f32;
419                let s = if idx + 1 < n { buf[idx] + (buf[idx + 1] - buf[idx]) * frac } else { buf[idx.min(n - 1)] };
420                let (lg, rg) = spatial_gains(cry, sry, crx, srx, lx, ly, lz, room_w, v.x, v.y, v.z);
421                l += s * v.vol * lg;
422                r += s * v.vol * rg;
423                v.pos += *src_rate as f64 / out_rate;
424                if v.pos as usize >= n - 1 {
425                    if v.looping { v.pos = 0.0; } else { v.active = false; }
426                }
427            }
428            self.sample_voices.retain(|v| v.active);
429        }
430
431        // ── BGM ─────────────────────────────────────────────────────────────
432        if let Some(bgm) = &mut self.bgm {
433            let n_pairs = bgm.samples.len() / 2;
434            if n_pairs >= 2 {
435                let ratio = bgm.src_rate as f64 / self.sample_rate as f64;
436                let idx   = bgm.pos as usize;
437                let frac  = (bgm.pos - idx as f64) as f32;
438                let nxt   = (idx + 1) % n_pairs;
439
440                let bl = bgm.samples[idx * 2    ] + (bgm.samples[nxt * 2    ] - bgm.samples[idx * 2    ]) * frac;
441                let br = bgm.samples[idx * 2 + 1] + (bgm.samples[nxt * 2 + 1] - bgm.samples[idx * 2 + 1]) * frac;
442
443                l += bl * bgm.volume;
444                r += br * bgm.volume;
445
446                bgm.pos += ratio;
447                if bgm.pos as usize >= n_pairs.saturating_sub(1) {
448                    bgm.pos = 0.0;  // loop
449                }
450            }
451        }
452
453        // ── Master FX chain: delay → reverb → low-pass → volume ──────────────
454        let (l, r) = self.delay.process(l, r);
455        let (l, r) = self.reverb.process(l, r);
456        let (l, r) = self.lowpass.process(l, r);
457        let mv = self.master_volume;
458        ((l * mv).tanh(), (r * mv).tanh())
459    }
460}
461
462// ─── Public engine ─────────────────────────────────────────────────────────────
463
464/// The live audio engine.  Create once at startup; keep alive for the program duration.
465/// All methods take `&self` — mutation is routed through an interior `Arc<Mutex<>>`.
466pub struct AudioEngine {
467    state:    Arc<Mutex<AudioState>>,
468    /// Kept alive to prevent cpal from stopping the stream when it's dropped.
469    _stream:  cpal::Stream,
470    /// Device sample rate (informational).
471    pub out_rate: u32,
472}
473
474impl AudioEngine {
475    /// Initialise cpal, open the default output device, start the audio thread.
476    /// Returns `Err` if no audio device is available (e.g. headless CI).
477    pub fn new() -> Result<Self, Box<dyn std::error::Error>> {
478        let host     = cpal::default_host();
479        let device   = host.default_output_device()
480            .ok_or("no default audio output device")?;
481        let supported = device.default_output_config()?;
482
483        let channels  = supported.channels() as usize;
484        let out_rate  = supported.sample_rate().0;
485        let fmt       = supported.sample_format();
486        let config    = supported.config();
487
488        let state  = Arc::new(Mutex::new(AudioState::new(out_rate)));
489        let stream = build_stream(&device, &config, channels, Arc::clone(&state), fmt)?;
490        stream.play()?;
491
492        Ok(Self { state, _stream: stream, out_rate })
493    }
494
495    // ── Tone control ─────────────────────────────────────────────────────────
496
497    /// Insert or update the tone at slot `idx`.  At most 64 slots; grows as needed.
498    pub fn set_tone(&self, idx: usize, params: ToneParams) {
499        if let Ok(mut s) = self.state.lock() {
500            while s.tones.len() <= idx { s.tones.push(None); }
501            match &mut s.tones[idx] {
502                Some(t) => t.params = params,
503                slot    => *slot = Some(Tone::new(params)),
504            }
505        }
506    }
507
508    /// Fire a one-shot interface sound (fast attack, exponential decay).
509    /// `dur` is in seconds; up to 32 blips overlap before the oldest is dropped.
510    pub fn blip(&self, freq: f32, amp: f32, dur: f32, wave: Wave) {
511        if let Ok(mut s) = self.state.lock() {
512            if s.blips.len() >= 32 { s.blips.remove(0); }
513            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) });
514        }
515    }
516
517    /// Fire a positional (2D/3D/4D) one-shot sound effect at a world point.
518    pub fn sfx(&self, x: f32, y: f32, z: f32, w: f32, freq: f32, amp: f32, dur: f32, wave: Wave) {
519        if let Ok(mut s) = self.state.lock() {
520            if s.sfx.len() >= 64 { s.sfx.remove(0); }
521            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) });
522        }
523    }
524
525    /// Register a mono sample buffer (decoded elsewhere), returning its id.
526    pub fn add_sample(&self, mono: Vec<f32>, src_rate: u32) -> usize {
527        if let Ok(mut s) = self.state.lock() {
528            s.samples.push((std::sync::Arc::new(mono), src_rate.max(1)));
529            s.samples.len() - 1
530        } else { 0 }
531    }
532
533    /// Play a loaded sample at a world position (looping or one-shot). Returns a voice id.
534    pub fn play_sample(&self, id: usize, x: f32, y: f32, z: f32, w: f32, vol: f32, looping: bool) -> u32 {
535        if let Ok(mut s) = self.state.lock() {
536            if id >= s.samples.len() { return 0; }
537            let vid = s.next_voice_id; s.next_voice_id += 1;
538            if s.sample_voices.len() >= 64 { s.sample_voices.remove(0); }
539            s.sample_voices.push(SampleVoice { id: vid, sample: id, pos: 0.0, x, y, z, w, vol, looping, active: true });
540            vid
541        } else { 0 }
542    }
543
544    /// Stop a sample voice by id.
545    pub fn stop_sample(&self, voice: u32) {
546        if let Ok(mut s) = self.state.lock() {
547            if let Some(v) = s.sample_voices.iter_mut().find(|v| v.id == voice) { v.active = false; }
548        }
549    }
550
551    // ── master FX ─────────────────────────────────────────────────────────────
552    pub fn fx_delay(&self, time_s: f32, feedback: f32, mix: f32) {
553        if let Ok(mut s) = self.state.lock() {
554            let cap = s.delay.bl.len();
555            s.delay.len = ((time_s.max(0.0) * s.sample_rate as f32) as usize).min(cap.saturating_sub(1));
556            s.delay.fb = feedback.clamp(0.0, 0.95);
557            s.delay.mix = mix.clamp(0.0, 1.0);
558        }
559    }
560    pub fn fx_reverb(&self, mix: f32) {
561        if let Ok(mut s) = self.state.lock() { s.reverb.mix = mix.clamp(0.0, 1.0); }
562    }
563    /// Master low-pass cutoff ∈ (0,1]; 1.0 ≈ open, lower = muffled/underwater.
564    pub fn fx_lowpass(&self, cutoff01: f32) {
565        if let Ok(mut s) = self.state.lock() { s.lowpass.target = cutoff01.clamp(0.0, 1.0); }
566    }
567
568    /// Silence and remove the tone at `idx`.
569    pub fn clear_tone(&self, idx: usize) {
570        if let Ok(mut s) = self.state.lock() {
571            if let Some(slot) = s.tones.get_mut(idx) { *slot = None; }
572        }
573    }
574
575    // ── Listener (camera) ────────────────────────────────────────────────────
576
577    /// Update the listener orientation to match the Ling `set_camera` values.
578    pub fn set_listener(&self, cry: f32, sry: f32, crx: f32, srx: f32) {
579        if let Ok(mut s) = self.state.lock() {
580            s.cry = cry; s.sry = sry;
581            s.crx = crx; s.srx = srx;
582        }
583    }
584
585    /// Update the listener (camera) world position so positional sounds pan and
586    /// attenuate relative to where the camera actually is.
587    pub fn set_listener_pos(&self, x: f32, y: f32, z: f32) {
588        if let Ok(mut s) = self.state.lock() {
589            s.lx = x; s.ly = y; s.lz = z;
590        }
591    }
592
593    // ── BGM ─────────────────────────────────────────────────────────────────
594
595    /// Load a WAV file and start looping it as background music.
596    /// Silently ignores missing files so scenes still run in silent environments.
597    pub fn load_bgm(&self, path: &str, vol: f32) {
598        match load_wav(path) {
599            Ok((samples, src_rate)) => {
600                if let Ok(mut s) = self.state.lock() {
601                    s.bgm = Some(BgmTrack { samples, src_rate, pos: 0.0, volume: vol });
602                }
603            }
604            Err(e) => eprintln!("audio: bgm load failed ({path}): {e}"),
605        }
606    }
607
608    /// Adjust BGM playback volume without reloading.
609    pub fn set_bgm_volume(&self, vol: f32) {
610        if let Ok(mut s) = self.state.lock() {
611            if let Some(bgm) = &mut s.bgm { bgm.volume = vol; }
612        }
613    }
614
615    // ── Master ───────────────────────────────────────────────────────────────
616
617    pub fn set_master_volume(&self, vol: f32) {
618        if let Ok(mut s) = self.state.lock() { s.master_volume = vol; }
619    }
620}
621
622// ─── WAV loader ───────────────────────────────────────────────────────────────
623
624fn load_wav(path: &str) -> Result<(Vec<f32>, u32), Box<dyn std::error::Error>> {
625    let mut reader   = hound::WavReader::open(path)?;
626    let spec         = reader.spec();
627    let channels     = spec.channels as usize;
628    let src_rate     = spec.sample_rate;
629
630    let raw: Vec<f32> = match spec.sample_format {
631        hound::SampleFormat::Float => {
632            reader.samples::<f32>().filter_map(|s| s.ok()).collect()
633        }
634        hound::SampleFormat::Int => {
635            // Normalise to [-1, 1] regardless of bit depth.
636            let max = (1i32 << spec.bits_per_sample.saturating_sub(1)) as f32;
637            reader.samples::<i32>().filter_map(|s| s.ok())
638                .map(|s| s as f32 / max)
639                .collect()
640        }
641    };
642
643    // Normalise to interleaved stereo.
644    let stereo: Vec<f32> = match channels {
645        1 => raw.iter().flat_map(|&s| [s, s]).collect(),
646        2 => raw,
647        n => raw.chunks(n)
648                .flat_map(|c| [c[0], if c.len() > 1 { c[1] } else { c[0] }])
649                .collect(),
650    };
651
652    Ok((stereo, src_rate))
653}
654
655// ─── cpal stream builder ──────────────────────────────────────────────────────
656
657fn build_stream(
658    device:   &cpal::Device,
659    config:   &cpal::StreamConfig,
660    channels: usize,
661    state:    Arc<Mutex<AudioState>>,
662    fmt:      cpal::SampleFormat,
663) -> Result<cpal::Stream, Box<dyn std::error::Error>> {
664    let err_fn = |e: cpal::StreamError| eprintln!("cpal stream error: {e}");
665
666    Ok(match fmt {
667        cpal::SampleFormat::F32 => {
668            let st = Arc::clone(&state);
669            device.build_output_stream(
670                config,
671                move |data: &mut [f32], _| fill_f32(data, channels, &st),
672                err_fn,
673                None,
674            )?
675        }
676        cpal::SampleFormat::I16 => {
677            let st = Arc::clone(&state);
678            device.build_output_stream(
679                config,
680                move |data: &mut [i16], _| fill_i16(data, channels, &st),
681                err_fn,
682                None,
683            )?
684        }
685        _ => {
686            // Generic fallback: output as i16.
687            let st = Arc::clone(&state);
688            device.build_output_stream::<i16, _, _>(
689                config,
690                move |data: &mut [i16], _| fill_i16(data, channels, &st),
691                err_fn,
692                None,
693            )?
694        }
695    })
696}
697
698/// Fill a `&mut [f32]` buffer (interleaved, `channels` wide).
699fn fill_f32(data: &mut [f32], channels: usize, state: &Arc<Mutex<AudioState>>) {
700    let ch = channels.max(1);
701    if let Ok(mut s) = state.try_lock() {
702        for frame in data.chunks_mut(ch) {
703            let (l, r) = s.next_sample();
704            frame[0] = l;
705            if ch > 1 { frame[1] = r; }
706            for extra in frame.iter_mut().skip(2) { *extra = 0.0; }
707        }
708    } else {
709        for s in data.iter_mut() { *s = 0.0; }
710    }
711}
712
713/// Fill a `&mut [i16]` buffer (interleaved, `channels` wide).
714fn fill_i16(data: &mut [i16], channels: usize, state: &Arc<Mutex<AudioState>>) {
715    let ch = channels.max(1);
716    if let Ok(mut s) = state.try_lock() {
717        for frame in data.chunks_mut(ch) {
718            let (l, r) = s.next_sample();
719            frame[0] = (l * 32_767.0) as i16;
720            if ch > 1 { frame[1] = (r * 32_767.0) as i16; }
721            for extra in frame.iter_mut().skip(2) { *extra = 0; }
722        }
723    } else {
724        for s in data.iter_mut() { *s = 0; }
725    }
726}
727
728#[cfg(test)]
729mod tests {
730    use super::*;
731
732    #[test]
733    fn sfx_voice_envelopes_and_ends() {
734        let mut v = SfxVoice { x: 0.0, y: 0.0, z: 0.0, w: 1.0, freq: 440.0, amp: 0.5,
735                               wave: Wave::Sine, dur: 0.02, age: 0.0, phase: 0.0, w_phase: 0.0, seed: 1 };
736        let dt = 1.0 / 44100.0;
737        let mut peak = 0.0f32;
738        let mut steps = 0;
739        while !v.done() && steps < 44100 { peak = peak.max(v.next(dt).abs()); steps += 1; }
740        assert!(peak > 0.01, "sfx should produce sound");
741        assert!(v.done(), "sfx should finish after its duration");
742    }
743
744    #[test]
745    fn sample_voice_loops_and_oneshot_stops() {
746        let mut st = AudioState::new(44100);
747        st.samples.push((std::sync::Arc::new(vec![0.5f32; 100]), 44100));
748        // looping voice survives past the buffer end
749        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, looping: true, active: true });
750        for _ in 0..500 { let _ = st.next_sample(); }
751        assert_eq!(st.sample_voices.len(), 1, "looping voice should still be alive");
752        // one-shot voice deactivates after the buffer
753        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, looping: false, active: true });
754        for _ in 0..500 { let _ = st.next_sample(); }
755        assert!(st.sample_voices.iter().all(|v| v.id != 2), "one-shot should have stopped");
756    }
757
758    #[test]
759    fn master_fx_stay_finite() {
760        let mut st = AudioState::new(44100);
761        st.delay.len = 2000; st.delay.fb = 0.6; st.delay.mix = 0.4;
762        st.reverb.mix = 0.5;
763        st.lowpass.target = 0.2; st.lowpass.cutoff = 0.2;
764        // feed a tone and ensure the FX chain never blows up
765        st.tones[0] = Some(Tone::new(ToneParams { freq: 220.0, amp: 0.8, ..Default::default() }));
766        for _ in 0..44100 {
767            let (l, r) = st.next_sample();
768            assert!(l.is_finite() && r.is_finite() && l.abs() <= 1.0 && r.abs() <= 1.0);
769        }
770    }
771}