Skip to main content

proof_engine/audio/
math_source.rs

1//! Math-driven audio sources — the same MathFunctions that drive glyphs also drive sound.
2//!
3//! A MathAudioSource maps MathFunction output to frequency and amplitude in realtime.
4//! The visual and auditory are the same computation expressed through different senses.
5//!
6//! # Design
7//!
8//! Each source has:
9//!   - A `MathFunction` that evolves over time and produces a scalar in [-1, 1]
10//!   - A `frequency_range` mapping that scalar to Hz
11//!   - A `Waveform` type (sine, saw, square, etc.)
12//!   - An optional `AudioFilter` (LP, HP, BP, notch)
13//!   - A 3D `position` for spatial panning
14//!   - A `tag` for grouping/stopping sources
15//!   - A `lifetime` (-1.0 = infinite, positive = seconds)
16
17use crate::math::MathFunction;
18use glam::Vec3;
19
20// ── Waveform ──────────────────────────────────────────────────────────────────
21
22/// Waveform shape for this audio source's oscillator.
23#[derive(Clone, Copy, Debug, PartialEq)]
24pub enum Waveform {
25    Sine,
26    Triangle,
27    Square,
28    Sawtooth,
29    ReverseSaw,
30    Noise,
31    /// Pulse with duty cycle [0, 1].
32    Pulse(f32),
33}
34
35impl Waveform {
36    /// Returns the harmonic richness of this waveform (1.0 = rich, 0.0 = pure).
37    pub fn harmonic_richness(&self) -> f32 {
38        match self {
39            Waveform::Sine => 0.0,
40            Waveform::Triangle => 0.3,
41            Waveform::Pulse(_) => 0.5,
42            Waveform::Square => 0.6,
43            Waveform::Sawtooth | Waveform::ReverseSaw => 1.0,
44            Waveform::Noise => 1.0,
45        }
46    }
47}
48
49// ── Audio filter ──────────────────────────────────────────────────────────────
50
51/// A filter applied to the oscillator output.
52#[derive(Clone, Debug)]
53pub enum AudioFilter {
54    LowPass  { cutoff_hz: f32, resonance: f32 },
55    HighPass { cutoff_hz: f32, resonance: f32 },
56    BandPass { center_hz: f32, bandwidth: f32 },
57    Notch    { center_hz: f32, bandwidth: f32 },
58    /// Formant filter (vowel sound shaping).
59    Formant  { f1_hz: f32, f2_hz: f32, f3_hz: f32 },
60    /// Comb filter (metallic, string-like resonance).
61    Comb     { delay_ms: f32, feedback: f32 },
62}
63
64impl AudioFilter {
65    /// Whisper low-pass (removes harshness from noise sources).
66    pub fn whisper() -> Self { Self::LowPass { cutoff_hz: 1500.0, resonance: 0.5 } }
67    /// Telephone band-pass filter (300-3000 Hz telephone band).
68    pub fn telephone() -> Self { Self::BandPass { center_hz: 1500.0, bandwidth: 2700.0 } }
69    /// Muffled (very low cutoff, heavy felt mute effect).
70    pub fn muffled() -> Self { Self::LowPass { cutoff_hz: 400.0, resonance: 0.3 } }
71    /// Bright (high-pass to emphasize attack and transients).
72    pub fn bright() -> Self { Self::HighPass { cutoff_hz: 2000.0, resonance: 0.7 } }
73}
74
75// ── Math audio source ─────────────────────────────────────────────────────────
76
77/// A mathematical audio source — oscillator driven by a MathFunction.
78#[derive(Clone, Debug)]
79pub struct MathAudioSource {
80    /// The function driving this source's frequency/amplitude modulation.
81    pub function:         MathFunction,
82    /// Maps function output [-1, 1] to Hz: (freq_at_neg1, freq_at_pos1).
83    pub frequency_range:  (f32, f32),
84    /// Base amplitude [0, 1].
85    pub amplitude:        f32,
86    /// Waveform shape.
87    pub waveform:         Waveform,
88    /// Optional filter chain.
89    pub filter:           Option<AudioFilter>,
90    /// 3D world position for stereo panning and distance attenuation.
91    pub position:         Vec3,
92    /// Optional second filter (two-pole filtering).
93    pub filter2:          Option<AudioFilter>,
94    /// Tag for grouping related sources (e.g. "chaos_rift", "music", "sfx").
95    pub tag:              Option<String>,
96    /// Lifetime in seconds. -1.0 = infinite.
97    pub lifetime:         f32,
98    /// Frequency detune in cents (+100 = 1 semitone up).
99    pub detune_cents:     f32,
100    /// Whether this source should spatialize (attenuate with distance from listener).
101    pub spatial:          bool,
102    /// Maximum distance for spatialization (beyond this = silent).
103    pub max_distance:     f32,
104    /// Fade-in duration in seconds (0.0 = instant).
105    pub fade_in:          f32,
106    /// Fade-out duration in seconds before lifetime ends (0.0 = instant).
107    pub fade_out:         f32,
108}
109
110impl Default for MathAudioSource {
111    fn default() -> Self {
112        Self {
113            function:        MathFunction::Constant(0.0),
114            frequency_range: (220.0, 440.0),
115            amplitude:       0.5,
116            waveform:        Waveform::Sine,
117            filter:          None,
118            position:        Vec3::ZERO,
119            filter2:         None,
120            tag:             None,
121            lifetime:        -1.0,
122            detune_cents:    0.0,
123            spatial:         true,
124            max_distance:    50.0,
125            fade_in:         0.0,
126            fade_out:        0.0,
127        }
128    }
129}
130
131impl MathAudioSource {
132    // ── Factory methods ───────────────────────────────────────────────────────
133
134    /// Sine tone driven by a breathing function (volume/pitch gently pulsates).
135    pub fn ambient_tone(freq: f32, amplitude: f32, position: Vec3) -> Self {
136        Self {
137            function:        MathFunction::Breathing { rate: 0.25, depth: 0.2 },
138            frequency_range: (freq * 0.95, freq * 1.05),
139            amplitude,
140            waveform:        Waveform::Sine,
141            filter:          Some(AudioFilter::LowPass { cutoff_hz: freq * 6.0, resonance: 0.4 }),
142            position,
143            spatial:         true,
144            fade_in:         1.0,
145            ..Default::default()
146        }
147    }
148
149    /// Lorenz-driven chaotic tone (for Chaos Rifts and high-entropy regions).
150    pub fn chaos_tone(position: Vec3) -> Self {
151        Self {
152            function:        MathFunction::Lorenz { sigma: 10.0, rho: 28.0, beta: 2.67, scale: 0.1 },
153            frequency_range: (80.0, 800.0),
154            amplitude:       0.3,
155            waveform:        Waveform::Triangle,
156            filter:          Some(AudioFilter::BandPass { center_hz: 400.0, bandwidth: 300.0 }),
157            position,
158            tag:             Some("chaos_rift".to_string()),
159            spatial:         true,
160            fade_in:         0.5,
161            ..Default::default()
162        }
163    }
164
165    /// Sine sweep — frequency glides between two values over a period.
166    pub fn sweep(freq_start: f32, freq_end: f32, period: f32, position: Vec3) -> Self {
167        Self {
168            function:        MathFunction::Sine { frequency: 1.0 / period, amplitude: 1.0, phase: 0.0 },
169            frequency_range: (freq_start, freq_end),
170            amplitude:       0.4,
171            waveform:        Waveform::Sine,
172            spatial:         true,
173            position,
174            ..Default::default()
175        }
176    }
177
178    /// Low-frequency drone (sub-bass rumble for boss encounters).
179    pub fn boss_drone(position: Vec3) -> Self {
180        Self {
181            function:        MathFunction::Breathing { rate: 0.08, depth: 0.4 },
182            frequency_range: (30.0, 55.0),
183            amplitude:       0.6,
184            waveform:        Waveform::Sawtooth,
185            filter:          Some(AudioFilter::LowPass { cutoff_hz: 80.0, resonance: 0.8 }),
186            position,
187            tag:             Some("boss_drone".to_string()),
188            spatial:         false,  // boss drone fills the whole room
189            fade_in:         2.0,
190            fade_out:        3.0,
191            ..Default::default()
192        }
193    }
194
195    /// Death knell — descending pitch with exponential decay.
196    pub fn death_knell(position: Vec3) -> Self {
197        Self {
198            function:        MathFunction::Exponential { start: 1.0, target: 0.0, rate: 0.5 },
199            frequency_range: (600.0, 80.0),
200            amplitude:       0.5,
201            waveform:        Waveform::Triangle,
202            filter:          Some(AudioFilter::LowPass { cutoff_hz: 400.0, resonance: 0.6 }),
203            position,
204            tag:             Some("death".to_string()),
205            lifetime:        3.0,
206            spatial:         true,
207            fade_out:        1.0,
208            ..Default::default()
209        }
210    }
211
212    /// Electrical crackle (noise burst for lightning effects).
213    pub fn electrical_crackle(position: Vec3, duration: f32) -> Self {
214        Self {
215            function:        MathFunction::Perlin { frequency: 1.0, octaves: 1, amplitude: 1.0 },
216            frequency_range: (800.0, 4000.0),
217            amplitude:       0.7,
218            waveform:        Waveform::Noise,
219            filter:          Some(AudioFilter::BandPass { center_hz: 2000.0, bandwidth: 3000.0 }),
220            position,
221            tag:             Some("lightning".to_string()),
222            lifetime:        duration,
223            spatial:         true,
224            fade_out:        0.05,
225            ..Default::default()
226        }
227    }
228
229    /// Attractor-driven harmonic resonance (entropic, alien, chaotic but musical).
230    pub fn attractor_tone(attractor_scale: f32, root_hz: f32, position: Vec3) -> Self {
231        let harmonics = [1.0, 1.5, 2.0, 3.0, 4.0]; // partial series
232        let freq = root_hz * harmonics[(attractor_scale as usize) % harmonics.len()];
233        Self {
234            function:        MathFunction::Lorenz { sigma: 10.0, rho: 28.0, beta: 2.67, scale: attractor_scale },
235            frequency_range: (freq * 0.8, freq * 1.2),
236            amplitude:       0.25,
237            waveform:        Waveform::Sine,
238            filter:          Some(AudioFilter::BandPass { center_hz: freq, bandwidth: freq * 0.5 }),
239            position,
240            tag:             Some("attractor_tone".to_string()),
241            spatial:         true,
242            ..Default::default()
243        }
244    }
245
246    /// Wind ambience (noise with slow modulation, for outdoor environments).
247    pub fn wind(amplitude: f32) -> Self {
248        Self {
249            function:        MathFunction::Perlin { frequency: 0.3, octaves: 3, amplitude: 1.0 },
250            frequency_range: (100.0, 500.0),
251            amplitude,
252            waveform:        Waveform::Noise,
253            filter:          Some(AudioFilter::LowPass { cutoff_hz: 600.0, resonance: 0.3 }),
254            position:        Vec3::ZERO,
255            tag:             Some("ambient_wind".to_string()),
256            lifetime:        -1.0,
257            spatial:         false,
258            fade_in:         3.0,
259            fade_out:        3.0,
260            ..Default::default()
261        }
262    }
263
264    /// Combat pulse — rhythmic hit sound tied to gameplay events.
265    pub fn combat_pulse(position: Vec3, frequency_hz: f32) -> Self {
266        Self {
267            function:        MathFunction::Square { amplitude: 1.0, frequency: frequency_hz / 60.0, duty: 0.1 },
268            frequency_range: (120.0, 300.0),
269            amplitude:       0.4,
270            waveform:        Waveform::Square,
271            filter:          Some(AudioFilter::BandPass { center_hz: 200.0, bandwidth: 200.0 }),
272            position,
273            tag:             Some("combat".to_string()),
274            spatial:         true,
275            ..Default::default()
276        }
277    }
278
279    /// Victory fanfare tone — bright, rising, major third.
280    pub fn victory(position: Vec3) -> Self {
281        Self {
282            function:        MathFunction::Sine { frequency: 0.5, amplitude: 1.0, phase: 0.0 },
283            frequency_range: (440.0, 660.0),
284            amplitude:       0.5,
285            waveform:        Waveform::Triangle,
286            filter:          Some(AudioFilter::HighPass { cutoff_hz: 200.0, resonance: 0.5 }),
287            position,
288            tag:             Some("victory".to_string()),
289            lifetime:        3.0,
290            spatial:         false,
291            fade_out:        1.0,
292            ..Default::default()
293        }
294    }
295
296    /// Heartbeat — pulsing low frequency with biological timing.
297    pub fn heartbeat(bpm: f32, position: Vec3) -> Self {
298        let freq = bpm / 60.0;
299        Self {
300            function:        MathFunction::Square { amplitude: 1.0, frequency: freq, duty: 0.15 },
301            frequency_range: (60.0, 120.0),
302            amplitude:       0.5,
303            waveform:        Waveform::Sine,
304            filter:          Some(AudioFilter::LowPass { cutoff_hz: 150.0, resonance: 1.5 }),
305            position,
306            tag:             Some("heartbeat".to_string()),
307            spatial:         true,
308            ..Default::default()
309        }
310    }
311
312    /// Portal hum — steady resonant tone for dimensional gateways.
313    pub fn portal_hum(position: Vec3, frequency_hz: f32) -> Self {
314        Self {
315            function:        MathFunction::Breathing { rate: 0.3, depth: 0.15 },
316            frequency_range: (frequency_hz * 0.98, frequency_hz * 1.02),
317            amplitude:       0.35,
318            waveform:        Waveform::Sine,
319            filter:          Some(AudioFilter::BandPass { center_hz: frequency_hz, bandwidth: 50.0 }),
320            filter2:         Some(AudioFilter::Comb { delay_ms: 20.0, feedback: 0.6 }),
321            position,
322            tag:             Some("portal".to_string()),
323            spatial:         true,
324            fade_in:         2.0,
325            ..Default::default()
326        }
327    }
328
329    // ── Modifier methods ──────────────────────────────────────────────────────
330
331    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
332        self.tag = Some(tag.into());
333        self
334    }
335
336    pub fn with_lifetime(mut self, secs: f32) -> Self {
337        self.lifetime = secs;
338        self
339    }
340
341    pub fn with_amplitude(mut self, amp: f32) -> Self {
342        self.amplitude = amp.clamp(0.0, 1.0);
343        self
344    }
345
346    pub fn with_position(mut self, pos: Vec3) -> Self {
347        self.position = pos;
348        self
349    }
350
351    pub fn with_detune(mut self, cents: f32) -> Self {
352        self.detune_cents = cents;
353        self
354    }
355
356    pub fn non_spatial(mut self) -> Self {
357        self.spatial = false;
358        self
359    }
360
361    pub fn with_fade(mut self, fade_in: f32, fade_out: f32) -> Self {
362        self.fade_in  = fade_in;
363        self.fade_out = fade_out;
364        self
365    }
366
367    // ── Queries ───────────────────────────────────────────────────────────────
368
369    /// Whether this source is a one-shot (has a finite lifetime).
370    pub fn is_one_shot(&self) -> bool { self.lifetime > 0.0 }
371
372    /// Whether this source has expired.
373    pub fn is_expired(&self, age: f32) -> bool {
374        self.lifetime > 0.0 && age >= self.lifetime
375    }
376
377    /// Envelope factor accounting for fade-in and fade-out at a given age.
378    pub fn envelope(&self, age: f32) -> f32 {
379        let fade_in_factor = if self.fade_in > 0.0 {
380            (age / self.fade_in).min(1.0)
381        } else {
382            1.0
383        };
384
385        let fade_out_factor = if self.lifetime > 0.0 && self.fade_out > 0.0 {
386            let remaining = self.lifetime - age;
387            (remaining / self.fade_out).clamp(0.0, 1.0)
388        } else {
389            1.0
390        };
391
392        self.amplitude * fade_in_factor * fade_out_factor
393    }
394
395    /// Map a function output value in [-1, 1] to a frequency in Hz.
396    pub fn map_to_frequency(&self, value: f32) -> f32 {
397        let t = (value.clamp(-1.0, 1.0) + 1.0) * 0.5;
398        let (lo, hi) = self.frequency_range;
399        // Logarithmic interpolation for musical pitch perception
400        let lo_log = lo.max(1.0).ln();
401        let hi_log = hi.max(1.0).ln();
402        (lo_log + t * (hi_log - lo_log)).exp()
403    }
404}
405
406// ── Source preset library ──────────────────────────────────────────────────────
407
408/// Quick-access library of common audio source presets.
409pub struct AudioPresets;
410
411impl AudioPresets {
412    /// Ambient cave drip at a position.
413    pub fn cave_drip(position: Vec3) -> MathAudioSource {
414        MathAudioSource {
415            function:        MathFunction::Square { amplitude: 1.0, frequency: 0.05, duty: 0.02 },
416            frequency_range: (800.0, 1200.0),
417            amplitude:       0.3,
418            waveform:        Waveform::Sine,
419            filter:          Some(AudioFilter::LowPass { cutoff_hz: 1000.0, resonance: 2.0 }),
420            position,
421            tag:             Some("cave_ambient".to_string()),
422            lifetime:        -1.0,
423            spatial:         true,
424            ..Default::default()
425        }
426    }
427
428    /// Explosion impact — loud, brief, with sub-bass punch.
429    pub fn explosion(position: Vec3, scale: f32) -> MathAudioSource {
430        MathAudioSource {
431            function:        MathFunction::Exponential { start: 1.0, target: 0.0, rate: 2.0 },
432            frequency_range: (30.0, 200.0 * scale),
433            amplitude:       0.9,
434            waveform:        Waveform::Noise,
435            filter:          Some(AudioFilter::LowPass { cutoff_hz: 300.0 * scale, resonance: 0.3 }),
436            position,
437            tag:             Some("explosion".to_string()),
438            lifetime:        0.5 + scale * 0.5,
439            spatial:         true,
440            max_distance:    30.0 * scale,
441            fade_out:        0.3,
442            ..Default::default()
443        }
444    }
445
446    /// Magical sparkle — high-frequency sinusoidal shimmer.
447    pub fn magic_sparkle(position: Vec3) -> MathAudioSource {
448        MathAudioSource {
449            function:        MathFunction::Breathing { rate: 8.0, depth: 0.5 },
450            frequency_range: (2000.0, 6000.0),
451            amplitude:       0.2,
452            waveform:        Waveform::Sine,
453            filter:          Some(AudioFilter::HighPass { cutoff_hz: 1500.0, resonance: 0.5 }),
454            position,
455            tag:             Some("magic".to_string()),
456            lifetime:        0.8,
457            spatial:         true,
458            fade_out:        0.3,
459            ..Default::default()
460        }
461    }
462}
463
464// ── Tests ─────────────────────────────────────────────────────────────────────
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469
470    #[test]
471    fn map_to_frequency_at_neg1_gives_lo() {
472        let src = MathAudioSource::ambient_tone(440.0, 0.5, Vec3::ZERO);
473        let f = src.map_to_frequency(-1.0);
474        assert!((f - src.frequency_range.0).abs() < 1.0, "Expected ~lo, got {f}");
475    }
476
477    #[test]
478    fn map_to_frequency_at_pos1_gives_hi() {
479        let src = MathAudioSource::ambient_tone(440.0, 0.5, Vec3::ZERO);
480        let f = src.map_to_frequency(1.0);
481        assert!((f - src.frequency_range.1).abs() < 1.0, "Expected ~hi, got {f}");
482    }
483
484    #[test]
485    fn envelope_at_zero_is_zero_for_fade_in() {
486        let src = MathAudioSource::boss_drone(Vec3::ZERO);
487        let env = src.envelope(0.0);
488        assert!(env < 0.01, "Should be near zero at start of fade-in, got {env}");
489    }
490
491    #[test]
492    fn envelope_at_peak_is_amplitude() {
493        let src = MathAudioSource { fade_in: 0.0, lifetime: -1.0, amplitude: 0.7, ..Default::default() };
494        let env = src.envelope(1.0);
495        assert!((env - 0.7).abs() < 0.001);
496    }
497
498    #[test]
499    fn one_shot_expires() {
500        let src = MathAudioSource::death_knell(Vec3::ZERO);
501        assert!(!src.is_expired(1.0));
502        assert!(src.is_expired(10.0));
503    }
504
505    #[test]
506    fn non_spatial_builder() {
507        let src = MathAudioSource::wind(0.3);
508        assert!(!src.spatial);
509    }
510}