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
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 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 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 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 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, fade_in: 2.0,
190 fade_out: 3.0,
191 ..Default::default()
192 }
193 }
194
195 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 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 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]; 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 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 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 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 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 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 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 pub fn is_one_shot(&self) -> bool { self.lifetime > 0.0 }
371
372 pub fn is_expired(&self, age: f32) -> bool {
374 self.lifetime > 0.0 && age >= self.lifetime
375 }
376
377 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 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 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
406pub struct AudioPresets;
410
411impl AudioPresets {
412 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 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 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#[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}