Skip to main content

jugar_web/
audio.rs

1//! Procedural audio for Pong game.
2//!
3//! This module generates audio commands that JavaScript executes via Web Audio API.
4//! All audio logic (pitch calculation, timing) happens in Rust.
5//!
6//! ## Architecture
7//!
8//! ```text
9//! Rust (audio logic)          JavaScript (Web Audio API)
10//! ─────────────────────       ─────────────────────────────
11//! AudioEvent::PaddleHit  →    oscillator.frequency = pitch
12//! AudioEvent::WallBounce →    gain.value = volume
13//! AudioEvent::Goal       →    oscillator.start()/stop()
14//! ```
15
16// const fn with mutable references is not yet stable
17#![allow(clippy::missing_const_for_fn)]
18
19use serde::{Deserialize, Serialize};
20
21/// Audio events that JavaScript should play via Web Audio API.
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23pub enum AudioEvent {
24    /// Paddle hit sound (blip with pitch based on hit location)
25    PaddleHit {
26        /// Base frequency in Hz (200-800)
27        frequency: f32,
28        /// Duration in seconds (0.05-0.15)
29        duration: f32,
30        /// Volume (0.0-1.0)
31        volume: f32,
32    },
33    /// Wall bounce sound (lower blip)
34    WallBounce {
35        /// Base frequency in Hz (typically 150-250)
36        frequency: f32,
37        /// Duration in seconds
38        duration: f32,
39        /// Volume (0.0-1.0)
40        volume: f32,
41    },
42    /// Goal scored sound (rising tone sequence)
43    Goal {
44        /// Whether this is a player win (true) or AI win (false)
45        player_scored: bool,
46        /// Volume (0.0-1.0)
47        volume: f32,
48    },
49    /// Game start jingle
50    GameStart {
51        /// Volume (0.0-1.0)
52        volume: f32,
53    },
54    /// Rally milestone sound (every 5 hits)
55    RallyMilestone {
56        /// Rally count at this milestone (5, 10, 15, ...)
57        rally_count: u32,
58        /// Base frequency increases with rally
59        frequency: f32,
60        /// Volume (0.0-1.0)
61        volume: f32,
62    },
63    /// Sound toggle confirmation (plays when sound is enabled)
64    SoundToggle {
65        /// Whether sound is now enabled
66        enabled: bool,
67        /// Volume (0.0-1.0)
68        volume: f32,
69    },
70}
71
72/// Procedural audio generator for Pong.
73///
74/// Generates audio events based on game state changes.
75/// Events are returned as JSON to be executed by JavaScript's Web Audio API.
76///
77/// # Example
78///
79/// ```
80/// use jugar_web::audio::ProceduralAudio;
81///
82/// let mut audio = ProceduralAudio::new();
83/// audio.set_enabled(true);
84///
85/// // Trigger a paddle hit sound (hit_y, paddle_y, paddle_height)
86/// audio.on_paddle_hit(300.0, 250.0, 100.0);
87///
88/// // Get events to send to JavaScript
89/// let events = audio.take_events();
90/// assert!(!events.is_empty());
91/// ```
92#[derive(Debug, Clone)]
93pub struct ProceduralAudio {
94    /// Master volume (0.0-1.0)
95    master_volume: f32,
96    /// Whether audio is enabled
97    enabled: bool,
98    /// Pending audio events to be sent to JavaScript
99    events: Vec<AudioEvent>,
100}
101
102impl Default for ProceduralAudio {
103    fn default() -> Self {
104        Self::new()
105    }
106}
107
108impl ProceduralAudio {
109    /// Creates a new audio generator.
110    #[must_use]
111    pub fn new() -> Self {
112        Self {
113            master_volume: 0.7,
114            enabled: true,
115            events: Vec::with_capacity(4),
116        }
117    }
118
119    /// Sets the master volume (0.0-1.0).
120    pub fn set_volume(&mut self, volume: f32) {
121        self.master_volume = volume.clamp(0.0, 1.0);
122    }
123
124    /// Returns the current master volume.
125    #[must_use]
126    pub const fn volume(&self) -> f32 {
127        self.master_volume
128    }
129
130    /// Enables or disables audio.
131    pub fn set_enabled(&mut self, enabled: bool) {
132        self.enabled = enabled;
133    }
134
135    /// Returns whether audio is enabled.
136    #[must_use]
137    pub const fn is_enabled(&self) -> bool {
138        self.enabled
139    }
140
141    /// Generates a paddle hit sound.
142    ///
143    /// # Arguments
144    ///
145    /// * `hit_y` - Y position where ball hit the paddle
146    /// * `paddle_y` - Center Y position of the paddle
147    /// * `paddle_height` - Height of the paddle
148    pub fn on_paddle_hit(&mut self, hit_y: f32, paddle_y: f32, paddle_height: f32) {
149        if !self.enabled {
150            return;
151        }
152
153        // Normalize hit position to -1.0 to 1.0
154        let half_height = paddle_height / 2.0;
155        let relative_y = (hit_y - paddle_y) / half_height;
156        let normalized = relative_y.clamp(-1.0, 1.0);
157
158        // Map to frequency: center = 440Hz (A4), edges = 220Hz or 660Hz
159        let frequency = 440.0 + normalized * 220.0;
160
161        self.events.push(AudioEvent::PaddleHit {
162            frequency,
163            duration: 0.08,
164            volume: self.master_volume,
165        });
166    }
167
168    /// Generates a wall bounce sound with optional velocity-based pitch variation.
169    pub fn on_wall_bounce(&mut self) {
170        if !self.enabled {
171            return;
172        }
173
174        // Slight random variation in pitch (150-250 Hz range)
175        // Using a simple deterministic pattern based on event count
176        let variation = (self.events.len() % 5) as f32 * 10.0;
177        let frequency = 180.0 + variation;
178
179        self.events.push(AudioEvent::WallBounce {
180            frequency,
181            duration: 0.05,
182            volume: self.master_volume * 0.5, // Quieter than paddle hits
183        });
184    }
185
186    /// Generates a wall bounce sound with velocity-based pitch.
187    ///
188    /// # Arguments
189    ///
190    /// * `ball_speed` - Current ball speed (magnitude of velocity)
191    /// * `base_speed` - Reference speed for normal pitch
192    pub fn on_wall_bounce_with_velocity(&mut self, ball_speed: f32, base_speed: f32) {
193        if !self.enabled {
194            return;
195        }
196
197        // Higher speed = higher pitch (150-300 Hz range)
198        let speed_ratio = (ball_speed / base_speed).clamp(0.5, 2.0);
199        let frequency = 150.0 + speed_ratio * 75.0;
200
201        self.events.push(AudioEvent::WallBounce {
202            frequency,
203            duration: 0.05,
204            volume: self.master_volume * 0.5,
205        });
206    }
207
208    /// Generates a goal sound.
209    ///
210    /// # Arguments
211    ///
212    /// * `player_scored` - True if the player (left paddle) scored
213    pub fn on_goal(&mut self, player_scored: bool) {
214        if !self.enabled {
215            return;
216        }
217
218        self.events.push(AudioEvent::Goal {
219            player_scored,
220            volume: self.master_volume,
221        });
222    }
223
224    /// Generates a game start sound.
225    pub fn on_game_start(&mut self) {
226        if !self.enabled {
227            return;
228        }
229
230        self.events.push(AudioEvent::GameStart {
231            volume: self.master_volume,
232        });
233    }
234
235    /// Generates a rally milestone sound.
236    ///
237    /// Should be called when rally count reaches a milestone (5, 10, 15, etc.).
238    ///
239    /// # Arguments
240    ///
241    /// * `rally_count` - Current rally count at the milestone
242    pub fn on_rally_milestone(&mut self, rally_count: u32) {
243        if !self.enabled {
244            return;
245        }
246
247        // Frequency increases with rally count (300-800 Hz)
248        let base_freq = 300.0;
249        let freq_increase = (rally_count as f32 / 5.0) * 50.0;
250        let frequency = (base_freq + freq_increase).min(800.0);
251
252        self.events.push(AudioEvent::RallyMilestone {
253            rally_count,
254            frequency,
255            volume: self.master_volume,
256        });
257    }
258
259    /// Generates a sound toggle confirmation sound.
260    ///
261    /// Plays a brief confirmation when sound is enabled.
262    /// This provides immediate feedback that audio is working.
263    pub fn on_sound_toggle(&mut self, enabled: bool) {
264        // Only play sound when enabling (user wants to hear it)
265        // Skip check for self.enabled since we're specifically toggling it
266        if enabled {
267            self.events.push(AudioEvent::SoundToggle {
268                enabled,
269                volume: self.master_volume,
270            });
271        }
272    }
273
274    /// Takes all pending audio events (clears the internal buffer).
275    ///
276    /// # Returns
277    ///
278    /// Vector of audio events to be played by JavaScript.
279    pub fn take_events(&mut self) -> Vec<AudioEvent> {
280        core::mem::take(&mut self.events)
281    }
282
283    /// Returns pending events without clearing (for inspection).
284    #[must_use]
285    pub fn peek_events(&self) -> &[AudioEvent] {
286        &self.events
287    }
288
289    /// Clears all pending events.
290    pub fn clear_events(&mut self) {
291        self.events.clear();
292    }
293
294    /// Returns the number of pending events.
295    #[must_use]
296    pub fn event_count(&self) -> usize {
297        self.events.len()
298    }
299}
300
301#[cfg(test)]
302#[allow(clippy::unwrap_used, clippy::float_cmp, clippy::panic)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn test_procedural_audio_new() {
308        let audio = ProceduralAudio::new();
309        assert!(audio.is_enabled());
310        assert!((audio.volume() - 0.7).abs() < 0.001);
311        assert_eq!(audio.event_count(), 0);
312    }
313
314    #[test]
315    fn test_procedural_audio_default() {
316        let audio = ProceduralAudio::default();
317        assert!((audio.volume() - 0.7).abs() < 0.001);
318    }
319
320    #[test]
321    fn test_set_volume() {
322        let mut audio = ProceduralAudio::new();
323
324        audio.set_volume(0.5);
325        assert!((audio.volume() - 0.5).abs() < 0.001);
326
327        // Test clamping
328        audio.set_volume(1.5);
329        assert!((audio.volume() - 1.0).abs() < 0.001);
330
331        audio.set_volume(-0.5);
332        assert!(audio.volume().abs() < 0.001);
333    }
334
335    #[test]
336    fn test_set_enabled() {
337        let mut audio = ProceduralAudio::new();
338
339        audio.set_enabled(false);
340        assert!(!audio.is_enabled());
341
342        audio.set_enabled(true);
343        assert!(audio.is_enabled());
344    }
345
346    #[test]
347    fn test_on_paddle_hit() {
348        let mut audio = ProceduralAudio::new();
349
350        // Hit at center of paddle
351        audio.on_paddle_hit(300.0, 300.0, 100.0);
352
353        assert_eq!(audio.event_count(), 1);
354        let events = audio.take_events();
355        match &events[0] {
356            AudioEvent::PaddleHit {
357                frequency,
358                duration,
359                volume,
360            } => {
361                assert!((*frequency - 440.0).abs() < 1.0); // Center = A4
362                assert!((*duration - 0.08).abs() < 0.01);
363                assert!((*volume - 0.7).abs() < 0.01);
364            }
365            _ => panic!("Expected PaddleHit event"),
366        }
367    }
368
369    #[test]
370    fn test_on_paddle_hit_top() {
371        let mut audio = ProceduralAudio::new();
372
373        // Hit at top of paddle
374        audio.on_paddle_hit(250.0, 300.0, 100.0);
375
376        let events = audio.take_events();
377        match &events[0] {
378            AudioEvent::PaddleHit { frequency, .. } => {
379                assert!(*frequency < 440.0); // Above center = lower pitch
380            }
381            _ => panic!("Expected PaddleHit event"),
382        }
383    }
384
385    #[test]
386    fn test_on_paddle_hit_bottom() {
387        let mut audio = ProceduralAudio::new();
388
389        // Hit at bottom of paddle
390        audio.on_paddle_hit(350.0, 300.0, 100.0);
391
392        let events = audio.take_events();
393        match &events[0] {
394            AudioEvent::PaddleHit { frequency, .. } => {
395                assert!(*frequency > 440.0); // Below center = higher pitch
396            }
397            _ => panic!("Expected PaddleHit event"),
398        }
399    }
400
401    #[test]
402    fn test_on_wall_bounce() {
403        let mut audio = ProceduralAudio::new();
404
405        audio.on_wall_bounce();
406
407        assert_eq!(audio.event_count(), 1);
408        let events = audio.take_events();
409        match &events[0] {
410            AudioEvent::WallBounce {
411                frequency,
412                duration,
413                volume,
414            } => {
415                // Frequency now has slight variation (180-220 Hz range)
416                assert!(*frequency >= 180.0 && *frequency <= 220.0);
417                assert!((*duration - 0.05).abs() < 0.01);
418                assert!(*volume < 0.7); // Quieter than paddle hits
419            }
420            _ => panic!("Expected WallBounce event"),
421        }
422    }
423
424    #[test]
425    fn test_on_goal_player() {
426        let mut audio = ProceduralAudio::new();
427
428        audio.on_goal(true);
429
430        assert_eq!(audio.event_count(), 1);
431        let events = audio.take_events();
432        match &events[0] {
433            AudioEvent::Goal {
434                player_scored,
435                volume,
436            } => {
437                assert!(*player_scored);
438                assert!((*volume - 0.7).abs() < 0.01);
439            }
440            _ => panic!("Expected Goal event"),
441        }
442    }
443
444    #[test]
445    fn test_on_goal_ai() {
446        let mut audio = ProceduralAudio::new();
447
448        audio.on_goal(false);
449
450        let events = audio.take_events();
451        match &events[0] {
452            AudioEvent::Goal { player_scored, .. } => {
453                assert!(!*player_scored);
454            }
455            _ => panic!("Expected Goal event"),
456        }
457    }
458
459    #[test]
460    fn test_on_game_start() {
461        let mut audio = ProceduralAudio::new();
462
463        audio.on_game_start();
464
465        assert_eq!(audio.event_count(), 1);
466        let events = audio.take_events();
467        matches!(&events[0], AudioEvent::GameStart { .. });
468    }
469
470    #[test]
471    fn test_disabled_audio_no_events() {
472        let mut audio = ProceduralAudio::new();
473        audio.set_enabled(false);
474
475        audio.on_paddle_hit(300.0, 300.0, 100.0);
476        audio.on_wall_bounce();
477        audio.on_goal(true);
478        audio.on_game_start();
479
480        assert_eq!(audio.event_count(), 0);
481    }
482
483    #[test]
484    fn test_take_events_clears() {
485        let mut audio = ProceduralAudio::new();
486
487        audio.on_wall_bounce();
488        audio.on_wall_bounce();
489
490        assert_eq!(audio.event_count(), 2);
491        let events = audio.take_events();
492        assert_eq!(events.len(), 2);
493        assert_eq!(audio.event_count(), 0);
494    }
495
496    #[test]
497    fn test_peek_events_does_not_clear() {
498        let mut audio = ProceduralAudio::new();
499
500        audio.on_wall_bounce();
501
502        let events = audio.peek_events();
503        assert_eq!(events.len(), 1);
504        assert_eq!(audio.event_count(), 1); // Still there
505    }
506
507    #[test]
508    fn test_clear_events() {
509        let mut audio = ProceduralAudio::new();
510
511        audio.on_wall_bounce();
512        audio.on_wall_bounce();
513        audio.clear_events();
514
515        assert_eq!(audio.event_count(), 0);
516    }
517
518    #[test]
519    fn test_multiple_events() {
520        let mut audio = ProceduralAudio::new();
521
522        audio.on_paddle_hit(300.0, 300.0, 100.0);
523        audio.on_wall_bounce();
524        audio.on_goal(true);
525
526        assert_eq!(audio.event_count(), 3);
527
528        let events = audio.take_events();
529        assert!(matches!(events[0], AudioEvent::PaddleHit { .. }));
530        assert!(matches!(events[1], AudioEvent::WallBounce { .. }));
531        assert!(matches!(events[2], AudioEvent::Goal { .. }));
532    }
533
534    #[test]
535    fn test_audio_event_serialization() {
536        let event = AudioEvent::PaddleHit {
537            frequency: 440.0,
538            duration: 0.08,
539            volume: 0.7,
540        };
541
542        let json = serde_json::to_string(&event).unwrap();
543        assert!(json.contains("PaddleHit"));
544        assert!(json.contains("440"));
545
546        let deserialized: AudioEvent = serde_json::from_str(&json).unwrap();
547        assert_eq!(event, deserialized);
548    }
549
550    #[test]
551    fn test_wall_bounce_serialization() {
552        let event = AudioEvent::WallBounce {
553            frequency: 200.0,
554            duration: 0.05,
555            volume: 0.35,
556        };
557
558        let json = serde_json::to_string(&event).unwrap();
559        assert!(json.contains("WallBounce"));
560    }
561
562    #[test]
563    fn test_goal_serialization() {
564        let event = AudioEvent::Goal {
565            player_scored: true,
566            volume: 0.7,
567        };
568
569        let json = serde_json::to_string(&event).unwrap();
570        assert!(json.contains("Goal"));
571        assert!(json.contains("player_scored"));
572    }
573
574    #[test]
575    fn test_game_start_serialization() {
576        let event = AudioEvent::GameStart { volume: 0.7 };
577
578        let json = serde_json::to_string(&event).unwrap();
579        assert!(json.contains("GameStart"));
580    }
581
582    #[test]
583    fn test_wall_bounce_with_velocity_low_speed() {
584        let mut audio = ProceduralAudio::new();
585        audio.on_wall_bounce_with_velocity(125.0, 250.0); // Half speed
586
587        let events = audio.take_events();
588        match &events[0] {
589            AudioEvent::WallBounce { frequency, .. } => {
590                // At half speed (ratio 0.5), freq should be ~187.5 Hz
591                assert!(*frequency >= 150.0 && *frequency <= 200.0);
592            }
593            _ => panic!("Expected WallBounce event"),
594        }
595    }
596
597    #[test]
598    fn test_wall_bounce_with_velocity_high_speed() {
599        let mut audio = ProceduralAudio::new();
600        audio.on_wall_bounce_with_velocity(500.0, 250.0); // Double speed
601
602        let events = audio.take_events();
603        match &events[0] {
604            AudioEvent::WallBounce { frequency, .. } => {
605                // At double speed (ratio 2.0), freq should be ~300 Hz
606                assert!(*frequency >= 250.0 && *frequency <= 350.0);
607            }
608            _ => panic!("Expected WallBounce event"),
609        }
610    }
611
612    #[test]
613    fn test_rally_milestone() {
614        let mut audio = ProceduralAudio::new();
615        audio.on_rally_milestone(5);
616
617        assert_eq!(audio.event_count(), 1);
618        let events = audio.take_events();
619        match &events[0] {
620            AudioEvent::RallyMilestone {
621                rally_count,
622                frequency,
623                volume,
624            } => {
625                assert_eq!(*rally_count, 5);
626                assert!(*frequency >= 300.0);
627                assert!((*volume - 0.7).abs() < 0.01);
628            }
629            _ => panic!("Expected RallyMilestone event"),
630        }
631    }
632
633    #[test]
634    fn test_rally_milestone_increasing_frequency() {
635        let mut audio = ProceduralAudio::new();
636
637        audio.on_rally_milestone(5);
638        audio.on_rally_milestone(15);
639
640        let events = audio.take_events();
641
642        let freq_5 = match &events[0] {
643            AudioEvent::RallyMilestone { frequency, .. } => *frequency,
644            _ => panic!("Expected RallyMilestone"),
645        };
646
647        let freq_15 = match &events[1] {
648            AudioEvent::RallyMilestone { frequency, .. } => *frequency,
649            _ => panic!("Expected RallyMilestone"),
650        };
651
652        // Higher rally count should have higher frequency
653        assert!(freq_15 > freq_5);
654    }
655
656    #[test]
657    fn test_rally_milestone_serialization() {
658        let event = AudioEvent::RallyMilestone {
659            rally_count: 10,
660            frequency: 400.0,
661            volume: 0.7,
662        };
663
664        let json = serde_json::to_string(&event).unwrap();
665        assert!(json.contains("RallyMilestone"));
666        assert!(json.contains("rally_count"));
667        assert!(json.contains("10"));
668    }
669
670    #[test]
671    fn test_wall_bounce_pitch_variation() {
672        let mut audio = ProceduralAudio::new();
673
674        // Generate multiple wall bounces
675        audio.on_wall_bounce();
676        audio.on_wall_bounce();
677        audio.on_wall_bounce();
678
679        let events = audio.take_events();
680
681        // Frequencies should vary due to deterministic pattern
682        let freqs: Vec<f32> = events
683            .iter()
684            .map(|e| match e {
685                AudioEvent::WallBounce { frequency, .. } => *frequency,
686                _ => 0.0,
687            })
688            .collect();
689
690        // All should be in valid range (180-220 Hz)
691        for freq in &freqs {
692            assert!(*freq >= 180.0 && *freq <= 220.0);
693        }
694    }
695}