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    // ── Character ─────────────────────────────────────────────────────────
110    //
111    // What separates a drum from a beep. All default to nothing, so a source
112    // that does not set them sounds as it always did.
113    /// `(start_multiplier, seconds)`: the pitch starts at the multiple and
114    /// falls onto the note over the seconds. `(3.0, 0.08)` is a drum;
115    /// `(0.5, 0.3)` is a rising whoop. `(1.0, 0.0)` is off.
116    pub pitch_env:        (f32, f32),
117    /// `(ratio, mix)`: a second sine at `ratio` times the pitch, mixed in
118    /// at `mix`. Inharmonic ratios (2.76, 5.4) ring like metal.
119    pub partial:          (f32, f32),
120    /// Share of white noise mixed into the oscillator, 0.0 to 1.0.
121    pub noise_mix:        f32,
122    /// Soft saturation, 0.0 clean to about 1.0 crushed.
123    pub drive:            f32,
124    /// How much of this source goes to the master reverb, 0.0 to 1.0.
125    pub reverb_send:      f32,
126    /// Seconds of silence before the source starts. Lifetime and fades
127    /// count from the start, not from the spawn.
128    pub start_delay:      f32,
129}
130
131impl Default for MathAudioSource {
132    fn default() -> Self {
133        Self {
134            function:        MathFunction::Constant(0.0),
135            frequency_range: (220.0, 440.0),
136            amplitude:       0.5,
137            waveform:        Waveform::Sine,
138            filter:          None,
139            position:        Vec3::ZERO,
140            filter2:         None,
141            tag:             None,
142            lifetime:        -1.0,
143            detune_cents:    0.0,
144            spatial:         true,
145            max_distance:    50.0,
146            fade_in:         0.0,
147            fade_out:        0.0,
148            pitch_env:       (1.0, 0.0),
149            partial:         (0.0, 0.0),
150            noise_mix:       0.0,
151            drive:           0.0,
152            reverb_send:     0.0,
153            start_delay:     0.0,
154        }
155    }
156}
157
158impl MathAudioSource {
159    // ── Factory methods ───────────────────────────────────────────────────────
160
161    /// Sine tone driven by a breathing function (volume/pitch gently pulsates).
162    pub fn ambient_tone(freq: f32, amplitude: f32, position: Vec3) -> Self {
163        Self {
164            function:        MathFunction::Breathing { rate: 0.25, depth: 0.2 },
165            frequency_range: (freq * 0.95, freq * 1.05),
166            amplitude,
167            waveform:        Waveform::Sine,
168            filter:          Some(AudioFilter::LowPass { cutoff_hz: freq * 6.0, resonance: 0.4 }),
169            position,
170            spatial:         true,
171            fade_in:         1.0,
172            ..Default::default()
173        }
174    }
175
176    /// Lorenz-driven chaotic tone (for Chaos Rifts and high-entropy regions).
177    pub fn chaos_tone(position: Vec3) -> Self {
178        Self {
179            function:        MathFunction::Lorenz { sigma: 10.0, rho: 28.0, beta: 2.67, scale: 0.1 },
180            frequency_range: (80.0, 800.0),
181            amplitude:       0.3,
182            waveform:        Waveform::Triangle,
183            filter:          Some(AudioFilter::BandPass { center_hz: 400.0, bandwidth: 300.0 }),
184            position,
185            tag:             Some("chaos_rift".to_string()),
186            spatial:         true,
187            fade_in:         0.5,
188            ..Default::default()
189        }
190    }
191
192    /// Sine sweep — frequency glides between two values over a period.
193    pub fn sweep(freq_start: f32, freq_end: f32, period: f32, position: Vec3) -> Self {
194        Self {
195            function:        MathFunction::Sine { frequency: 1.0 / period, amplitude: 1.0, phase: 0.0 },
196            frequency_range: (freq_start, freq_end),
197            amplitude:       0.4,
198            waveform:        Waveform::Sine,
199            spatial:         true,
200            position,
201            ..Default::default()
202        }
203    }
204
205    /// Low-frequency drone (sub-bass rumble for boss encounters).
206    pub fn boss_drone(position: Vec3) -> Self {
207        Self {
208            function:        MathFunction::Breathing { rate: 0.08, depth: 0.4 },
209            frequency_range: (30.0, 55.0),
210            amplitude:       0.6,
211            waveform:        Waveform::Sawtooth,
212            filter:          Some(AudioFilter::LowPass { cutoff_hz: 80.0, resonance: 0.8 }),
213            position,
214            tag:             Some("boss_drone".to_string()),
215            spatial:         false,  // boss drone fills the whole room
216            fade_in:         2.0,
217            fade_out:        3.0,
218            ..Default::default()
219        }
220    }
221
222    /// Death knell — descending pitch with exponential decay.
223    pub fn death_knell(position: Vec3) -> Self {
224        Self {
225            function:        MathFunction::Exponential { start: 1.0, target: 0.0, rate: 0.5 },
226            frequency_range: (600.0, 80.0),
227            amplitude:       0.5,
228            waveform:        Waveform::Triangle,
229            filter:          Some(AudioFilter::LowPass { cutoff_hz: 400.0, resonance: 0.6 }),
230            position,
231            tag:             Some("death".to_string()),
232            lifetime:        3.0,
233            spatial:         true,
234            fade_out:        1.0,
235            ..Default::default()
236        }
237    }
238
239    /// Electrical crackle (noise burst for lightning effects).
240    pub fn electrical_crackle(position: Vec3, duration: f32) -> Self {
241        Self {
242            function:        MathFunction::Perlin { frequency: 1.0, octaves: 1, amplitude: 1.0 },
243            frequency_range: (800.0, 4000.0),
244            amplitude:       0.7,
245            waveform:        Waveform::Noise,
246            filter:          Some(AudioFilter::BandPass { center_hz: 2000.0, bandwidth: 3000.0 }),
247            position,
248            tag:             Some("lightning".to_string()),
249            lifetime:        duration,
250            spatial:         true,
251            fade_out:        0.05,
252            ..Default::default()
253        }
254    }
255
256    /// Attractor-driven harmonic resonance (entropic, alien, chaotic but musical).
257    pub fn attractor_tone(attractor_scale: f32, root_hz: f32, position: Vec3) -> Self {
258        let harmonics = [1.0, 1.5, 2.0, 3.0, 4.0]; // partial series
259        let freq = root_hz * harmonics[(attractor_scale as usize) % harmonics.len()];
260        Self {
261            function:        MathFunction::Lorenz { sigma: 10.0, rho: 28.0, beta: 2.67, scale: attractor_scale },
262            frequency_range: (freq * 0.8, freq * 1.2),
263            amplitude:       0.25,
264            waveform:        Waveform::Sine,
265            filter:          Some(AudioFilter::BandPass { center_hz: freq, bandwidth: freq * 0.5 }),
266            position,
267            tag:             Some("attractor_tone".to_string()),
268            spatial:         true,
269            ..Default::default()
270        }
271    }
272
273    /// Wind ambience (noise with slow modulation, for outdoor environments).
274    pub fn wind(amplitude: f32) -> Self {
275        Self {
276            function:        MathFunction::Perlin { frequency: 0.3, octaves: 3, amplitude: 1.0 },
277            frequency_range: (100.0, 500.0),
278            amplitude,
279            waveform:        Waveform::Noise,
280            filter:          Some(AudioFilter::LowPass { cutoff_hz: 600.0, resonance: 0.3 }),
281            position:        Vec3::ZERO,
282            tag:             Some("ambient_wind".to_string()),
283            lifetime:        -1.0,
284            spatial:         false,
285            fade_in:         3.0,
286            fade_out:        3.0,
287            ..Default::default()
288        }
289    }
290
291    /// Combat pulse — rhythmic hit sound tied to gameplay events.
292    pub fn combat_pulse(position: Vec3, frequency_hz: f32) -> Self {
293        Self {
294            function:        MathFunction::Square { amplitude: 1.0, frequency: frequency_hz / 60.0, duty: 0.1 },
295            frequency_range: (120.0, 300.0),
296            amplitude:       0.4,
297            waveform:        Waveform::Square,
298            filter:          Some(AudioFilter::BandPass { center_hz: 200.0, bandwidth: 200.0 }),
299            position,
300            tag:             Some("combat".to_string()),
301            spatial:         true,
302            ..Default::default()
303        }
304    }
305
306    /// Victory fanfare tone — bright, rising, major third.
307    pub fn victory(position: Vec3) -> Self {
308        Self {
309            function:        MathFunction::Sine { frequency: 0.5, amplitude: 1.0, phase: 0.0 },
310            frequency_range: (440.0, 660.0),
311            amplitude:       0.5,
312            waveform:        Waveform::Triangle,
313            filter:          Some(AudioFilter::HighPass { cutoff_hz: 200.0, resonance: 0.5 }),
314            position,
315            tag:             Some("victory".to_string()),
316            lifetime:        3.0,
317            spatial:         false,
318            fade_out:        1.0,
319            ..Default::default()
320        }
321    }
322
323    /// Heartbeat — pulsing low frequency with biological timing.
324    pub fn heartbeat(bpm: f32, position: Vec3) -> Self {
325        let freq = bpm / 60.0;
326        Self {
327            function:        MathFunction::Square { amplitude: 1.0, frequency: freq, duty: 0.15 },
328            frequency_range: (60.0, 120.0),
329            amplitude:       0.5,
330            waveform:        Waveform::Sine,
331            filter:          Some(AudioFilter::LowPass { cutoff_hz: 150.0, resonance: 1.5 }),
332            position,
333            tag:             Some("heartbeat".to_string()),
334            spatial:         true,
335            ..Default::default()
336        }
337    }
338
339    /// Portal hum — steady resonant tone for dimensional gateways.
340    pub fn portal_hum(position: Vec3, frequency_hz: f32) -> Self {
341        Self {
342            function:        MathFunction::Breathing { rate: 0.3, depth: 0.15 },
343            frequency_range: (frequency_hz * 0.98, frequency_hz * 1.02),
344            amplitude:       0.35,
345            waveform:        Waveform::Sine,
346            filter:          Some(AudioFilter::BandPass { center_hz: frequency_hz, bandwidth: 50.0 }),
347            filter2:         Some(AudioFilter::Comb { delay_ms: 20.0, feedback: 0.6 }),
348            position,
349            tag:             Some("portal".to_string()),
350            spatial:         true,
351            fade_in:         2.0,
352            ..Default::default()
353        }
354    }
355
356    // ── Modifier methods ──────────────────────────────────────────────────────
357
358    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
359        self.tag = Some(tag.into());
360        self
361    }
362
363    pub fn with_lifetime(mut self, secs: f32) -> Self {
364        self.lifetime = secs;
365        self
366    }
367
368    pub fn with_amplitude(mut self, amp: f32) -> Self {
369        self.amplitude = amp.clamp(0.0, 1.0);
370        self
371    }
372
373    pub fn with_position(mut self, pos: Vec3) -> Self {
374        self.position = pos;
375        self
376    }
377
378    pub fn with_detune(mut self, cents: f32) -> Self {
379        self.detune_cents = cents;
380        self
381    }
382
383    pub fn non_spatial(mut self) -> Self {
384        self.spatial = false;
385        self
386    }
387
388    pub fn with_fade(mut self, fade_in: f32, fade_out: f32) -> Self {
389        self.fade_in  = fade_in;
390        self.fade_out = fade_out;
391        self
392    }
393
394    // ── Queries ───────────────────────────────────────────────────────────────
395
396    /// Whether this source is a one-shot (has a finite lifetime).
397    pub fn is_one_shot(&self) -> bool { self.lifetime > 0.0 }
398
399    /// Whether this source has expired.
400    pub fn is_expired(&self, age: f32) -> bool {
401        self.lifetime > 0.0 && age >= self.lifetime
402    }
403
404    /// Envelope factor accounting for fade-in and fade-out at a given age.
405    pub fn envelope(&self, age: f32) -> f32 {
406        let fade_in_factor = if self.fade_in > 0.0 {
407            (age / self.fade_in).min(1.0)
408        } else {
409            1.0
410        };
411
412        let fade_out_factor = if self.lifetime > 0.0 && self.fade_out > 0.0 {
413            let remaining = self.lifetime - age;
414            (remaining / self.fade_out).clamp(0.0, 1.0)
415        } else {
416            1.0
417        };
418
419        self.amplitude * fade_in_factor * fade_out_factor
420    }
421
422    /// Map a function output value in [-1, 1] to a frequency in Hz.
423    pub fn map_to_frequency(&self, value: f32) -> f32 {
424        let t = (value.clamp(-1.0, 1.0) + 1.0) * 0.5;
425        let (lo, hi) = self.frequency_range;
426        // Logarithmic interpolation for musical pitch perception
427        let lo_log = lo.max(1.0).ln();
428        let hi_log = hi.max(1.0).ln();
429        (lo_log + t * (hi_log - lo_log)).exp()
430    }
431}
432
433// ── Source preset library ──────────────────────────────────────────────────────
434
435/// Quick-access library of common audio source presets.
436pub struct AudioPresets;
437
438impl AudioPresets {
439    /// Ambient cave drip at a position.
440    pub fn cave_drip(position: Vec3) -> MathAudioSource {
441        MathAudioSource {
442            function:        MathFunction::Square { amplitude: 1.0, frequency: 0.05, duty: 0.02 },
443            frequency_range: (800.0, 1200.0),
444            amplitude:       0.3,
445            waveform:        Waveform::Sine,
446            filter:          Some(AudioFilter::LowPass { cutoff_hz: 1000.0, resonance: 2.0 }),
447            position,
448            tag:             Some("cave_ambient".to_string()),
449            lifetime:        -1.0,
450            spatial:         true,
451            ..Default::default()
452        }
453    }
454
455    /// Explosion impact — loud, brief, with sub-bass punch.
456    pub fn explosion(position: Vec3, scale: f32) -> MathAudioSource {
457        MathAudioSource {
458            function:        MathFunction::Exponential { start: 1.0, target: 0.0, rate: 2.0 },
459            frequency_range: (30.0, 200.0 * scale),
460            amplitude:       0.9,
461            waveform:        Waveform::Noise,
462            filter:          Some(AudioFilter::LowPass { cutoff_hz: 300.0 * scale, resonance: 0.3 }),
463            position,
464            tag:             Some("explosion".to_string()),
465            lifetime:        0.5 + scale * 0.5,
466            spatial:         true,
467            max_distance:    30.0 * scale,
468            fade_out:        0.3,
469            ..Default::default()
470        }
471    }
472
473    /// Magical sparkle — high-frequency sinusoidal shimmer.
474    pub fn magic_sparkle(position: Vec3) -> MathAudioSource {
475        MathAudioSource {
476            function:        MathFunction::Breathing { rate: 8.0, depth: 0.5 },
477            frequency_range: (2000.0, 6000.0),
478            amplitude:       0.2,
479            waveform:        Waveform::Sine,
480            filter:          Some(AudioFilter::HighPass { cutoff_hz: 1500.0, resonance: 0.5 }),
481            position,
482            tag:             Some("magic".to_string()),
483            lifetime:        0.8,
484            spatial:         true,
485            fade_out:        0.3,
486            ..Default::default()
487        }
488    }
489}
490
491// ── Tests ─────────────────────────────────────────────────────────────────────
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496
497    #[test]
498    fn map_to_frequency_at_neg1_gives_lo() {
499        let src = MathAudioSource::ambient_tone(440.0, 0.5, Vec3::ZERO);
500        let f = src.map_to_frequency(-1.0);
501        assert!((f - src.frequency_range.0).abs() < 1.0, "Expected ~lo, got {f}");
502    }
503
504    #[test]
505    fn map_to_frequency_at_pos1_gives_hi() {
506        let src = MathAudioSource::ambient_tone(440.0, 0.5, Vec3::ZERO);
507        let f = src.map_to_frequency(1.0);
508        assert!((f - src.frequency_range.1).abs() < 1.0, "Expected ~hi, got {f}");
509    }
510
511    #[test]
512    fn envelope_at_zero_is_zero_for_fade_in() {
513        let src = MathAudioSource::boss_drone(Vec3::ZERO);
514        let env = src.envelope(0.0);
515        assert!(env < 0.01, "Should be near zero at start of fade-in, got {env}");
516    }
517
518    #[test]
519    fn envelope_at_peak_is_amplitude() {
520        let src = MathAudioSource { fade_in: 0.0, lifetime: -1.0, amplitude: 0.7, ..Default::default() };
521        let env = src.envelope(1.0);
522        assert!((env - 0.7).abs() < 0.001);
523    }
524
525    #[test]
526    fn one_shot_expires() {
527        let src = MathAudioSource::death_knell(Vec3::ZERO);
528        assert!(!src.is_expired(1.0));
529        assert!(src.is_expired(10.0));
530    }
531
532    #[test]
533    fn non_spatial_builder() {
534        let src = MathAudioSource::wind(0.3);
535        assert!(!src.spatial);
536    }
537}