1use crate::math::MathFunction;
18use glam::Vec3;
19
20#[derive(Clone, Copy, Debug, PartialEq)]
24pub enum Waveform {
25 Sine,
26 Triangle,
27 Square,
28 Sawtooth,
29 ReverseSaw,
30 Noise,
31 Pulse(f32),
33}
34
35impl Waveform {
36 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#[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 { f1_hz: f32, f2_hz: f32, f3_hz: f32 },
60 Comb { delay_ms: f32, feedback: f32 },
62}
63
64impl AudioFilter {
65 pub fn whisper() -> Self { Self::LowPass { cutoff_hz: 1500.0, resonance: 0.5 } }
67 pub fn telephone() -> Self { Self::BandPass { center_hz: 1500.0, bandwidth: 2700.0 } }
69 pub fn muffled() -> Self { Self::LowPass { cutoff_hz: 400.0, resonance: 0.3 } }
71 pub fn bright() -> Self { Self::HighPass { cutoff_hz: 2000.0, resonance: 0.7 } }
73}
74
75#[derive(Clone, Debug)]
79pub struct MathAudioSource {
80 pub function: MathFunction,
82 pub frequency_range: (f32, f32),
84 pub amplitude: f32,
86 pub waveform: Waveform,
88 pub filter: Option<AudioFilter>,
90 pub position: Vec3,
92 pub filter2: Option<AudioFilter>,
94 pub tag: Option<String>,
96 pub lifetime: f32,
98 pub detune_cents: f32,
100 pub spatial: bool,
102 pub max_distance: f32,
104 pub fade_in: f32,
106 pub fade_out: f32,
108
109 pub pitch_env: (f32, f32),
117 pub partial: (f32, f32),
120 pub noise_mix: f32,
122 pub drive: f32,
124 pub reverb_send: f32,
126 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 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 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 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 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, fade_in: 2.0,
217 fade_out: 3.0,
218 ..Default::default()
219 }
220 }
221
222 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 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 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]; 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 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 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 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 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 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 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 pub fn is_one_shot(&self) -> bool { self.lifetime > 0.0 }
398
399 pub fn is_expired(&self, age: f32) -> bool {
401 self.lifetime > 0.0 && age >= self.lifetime
402 }
403
404 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 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 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
433pub struct AudioPresets;
437
438impl AudioPresets {
439 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 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 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#[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}