1use core::time::Duration;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct Tone {
29 pub frequency_hz: u16,
30 pub duration: Duration,
31}
32
33#[derive(Debug)]
35pub struct Melody {
36 pub notes: &'static [Tone],
37}
38
39impl Melody {
40 pub const fn new(notes: &'static [Tone]) -> Self {
41 Self { notes }
42 }
43}
44
45pub mod melodies {
47 use super::{Melody, Tone};
48 use core::time::Duration;
49
50 pub static POWER_ON: Melody = Melody::new(&[
52 Tone {
53 frequency_hz: 1_000,
54 duration: Duration::from_millis(80),
55 },
56 Tone {
57 frequency_hz: 1_500,
58 duration: Duration::from_millis(80),
59 },
60 Tone {
61 frequency_hz: 2_000,
62 duration: Duration::from_millis(120),
63 },
64 ]);
65
66 pub static POWER_OFF: Melody = Melody::new(&[
68 Tone {
69 frequency_hz: 2_000,
70 duration: Duration::from_millis(80),
71 },
72 Tone {
73 frequency_hz: 1_500,
74 duration: Duration::from_millis(80),
75 },
76 Tone {
77 frequency_hz: 1_000,
78 duration: Duration::from_millis(120),
79 },
80 ]);
81
82 pub static BEACON_ACK: Melody = Melody::new(&[
84 Tone {
85 frequency_hz: 1_800,
86 duration: Duration::from_millis(60),
87 },
88 Tone {
89 frequency_hz: 2_200,
90 duration: Duration::from_millis(60),
91 },
92 ]);
93
94 pub static LOCATE: Melody = Melody::new(&[
100 Tone {
101 frequency_hz: 2_600,
102 duration: Duration::from_millis(150),
103 },
104 Tone {
105 frequency_hz: 1_900,
106 duration: Duration::from_millis(150),
107 },
108 Tone {
109 frequency_hz: 2_600,
110 duration: Duration::from_millis(150),
111 },
112 Tone {
113 frequency_hz: 1_900,
114 duration: Duration::from_millis(150),
115 },
116 ]);
117
118 pub static GNSS_ON: Melody = Melody::new(&[
126 Tone {
127 frequency_hz: 1_900,
128 duration: Duration::from_millis(45),
129 },
130 Tone {
131 frequency_hz: 0,
132 duration: Duration::from_millis(45),
133 },
134 Tone {
135 frequency_hz: 1_900,
136 duration: Duration::from_millis(45),
137 },
138 Tone {
139 frequency_hz: 0,
140 duration: Duration::from_millis(45),
141 },
142 Tone {
143 frequency_hz: 2_600,
144 duration: Duration::from_millis(170),
145 },
146 ]);
147
148 pub static GNSS_OFF: Melody = Melody::new(&[
151 Tone {
152 frequency_hz: 2_600,
153 duration: Duration::from_millis(170),
154 },
155 Tone {
156 frequency_hz: 0,
157 duration: Duration::from_millis(45),
158 },
159 Tone {
160 frequency_hz: 1_500,
161 duration: Duration::from_millis(45),
162 },
163 Tone {
164 frequency_hz: 0,
165 duration: Duration::from_millis(45),
166 },
167 Tone {
168 frequency_hz: 1_500,
169 duration: Duration::from_millis(45),
170 },
171 ]);
172
173 pub static UNSILENCE: Melody = Melody::new(&[Tone {
175 frequency_hz: 2_000,
176 duration: Duration::from_millis(60),
177 }]);
178
179 pub static DO_SILENCE: Melody = Melody::new(&[Tone {
181 frequency_hz: 1_200 / 8,
182 duration: Duration::from_millis(60),
186 }]);
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191pub enum BuzzerDecision {
192 Silent,
194 Rest { next_deadline_ms: u64 },
202 Tone {
205 frequency_hz: u16,
206 next_deadline_ms: u64,
207 },
208}
209
210#[derive(Debug)]
211struct ActiveMelody {
212 melody: &'static Melody,
213 started_at_ms: u64,
214 repeat_every_ms: Option<u64>,
219 overrides_silence: bool,
223}
224
225impl ActiveMelody {
226 fn resolve(&self, now_ms: u64) -> Option<(Tone, u64)> {
229 self.step(now_ms).map(|step| match step {
230 Step::Note(tone, end_ms) => (tone, end_ms),
231 Step::Gap(end_ms) => (
232 Tone {
233 frequency_hz: 0,
234 duration: Duration::from_millis(0),
235 },
236 end_ms,
237 ),
238 })
239 }
240
241 fn step(&self, now_ms: u64) -> Option<Step> {
249 let elapsed = now_ms.saturating_sub(self.started_at_ms);
250 let (elapsed, cycle_start_ms) = match self.repeat_every_ms {
254 Some(period) if period > 0 => {
255 let cycle = elapsed / period;
256 (
257 elapsed % period,
258 self.started_at_ms
259 .saturating_add(cycle.saturating_mul(period)),
260 )
261 }
262 _ => (elapsed, self.started_at_ms),
263 };
264 let mut cumulative_ms: u64 = 0;
265 for &tone in self.melody.notes {
266 let dur_ms = tone.duration.as_millis() as u64;
267 cumulative_ms = cumulative_ms.saturating_add(dur_ms);
268 if elapsed < cumulative_ms {
269 return Some(Step::Note(tone, cycle_start_ms + cumulative_ms));
270 }
271 }
272 let period = self.repeat_every_ms?;
275 Some(Step::Gap(cycle_start_ms.saturating_add(period)))
276 }
277}
278
279enum Step {
281 Note(Tone, u64),
284 Gap(u64),
286}
287
288#[derive(Debug)]
290pub struct BuzzerEngine {
291 silenced: bool,
292 active: Option<ActiveMelody>,
293}
294
295impl Default for BuzzerEngine {
296 fn default() -> Self {
297 Self::new()
298 }
299}
300
301impl BuzzerEngine {
302 pub const fn new() -> Self {
303 Self {
304 silenced: false,
305 active: None,
306 }
307 }
308
309 pub fn is_silenced(&self) -> bool {
311 self.silenced
312 }
313
314 pub fn set_silenced(&mut self, silenced: bool) {
318 self.silenced = silenced;
319 if silenced && !self.alert_active() {
320 self.active = None;
321 }
322 }
323
324 pub fn alert_active(&self) -> bool {
326 self.active
327 .as_ref()
328 .is_some_and(|active| active.overrides_silence)
329 }
330
331 pub fn toggle_silenced(&mut self) -> bool {
334 self.set_silenced(!self.silenced);
335 self.silenced
336 }
337
338 pub fn restart_active(&mut self, now_ms: u64) {
348 if let Some(active) = self.active.as_mut() {
349 active.started_at_ms = now_ms;
350 }
351 }
352
353 pub fn play(&mut self, melody: &'static Melody, now_ms: u64) {
357 if self.silenced || self.alert_active() {
358 return;
359 }
360 self.active = Some(ActiveMelody {
361 melody,
362 started_at_ms: now_ms,
363 repeat_every_ms: None,
364 overrides_silence: false,
365 });
366 }
367
368 pub fn play_alert(&mut self, melody: &'static Melody, now_ms: u64, period_ms: u64) {
375 self.active = Some(ActiveMelody {
376 melody,
377 started_at_ms: now_ms,
378 repeat_every_ms: Some(period_ms),
379 overrides_silence: true,
380 });
381 }
382
383 pub fn stop_alert(&mut self) {
386 if self.alert_active() {
387 self.active = None;
388 }
389 }
390
391 pub fn next_deadline_ms(&self, now_ms: u64) -> Option<u64> {
399 if self.silenced && !self.alert_active() {
400 return None;
401 }
402 self.active
403 .as_ref()
404 .and_then(|active| active.resolve(now_ms))
405 .map(|(_, end_ms)| end_ms)
406 }
407
408 pub fn tick(&mut self, now_ms: u64) -> BuzzerDecision {
410 if self.silenced && !self.alert_active() {
411 return BuzzerDecision::Silent;
412 }
413 let Some(active) = &self.active else {
414 return BuzzerDecision::Silent;
415 };
416 match active.step(now_ms) {
417 Some(Step::Gap(_)) => BuzzerDecision::Silent,
421 Some(Step::Note(tone, end_ms)) => {
422 if tone.frequency_hz == 0 {
423 BuzzerDecision::Rest {
424 next_deadline_ms: end_ms,
425 }
426 } else {
427 BuzzerDecision::Tone {
428 frequency_hz: tone.frequency_hz,
429 next_deadline_ms: end_ms,
430 }
431 }
432 }
433 None => {
434 self.active = None;
435 BuzzerDecision::Silent
436 }
437 }
438 }
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444
445 #[test]
446 fn idle_engine_is_silent() {
447 let mut e = BuzzerEngine::new();
448 assert_eq!(e.tick(0), BuzzerDecision::Silent);
449 }
450
451 #[test]
452 fn power_on_melody_steps_through_notes() {
453 let mut e = BuzzerEngine::new();
454 e.play(&melodies::POWER_ON, 0);
455
456 assert_eq!(
458 e.tick(0),
459 BuzzerDecision::Tone {
460 frequency_hz: 1_000,
461 next_deadline_ms: 80
462 }
463 );
464 assert_eq!(
465 e.tick(79),
466 BuzzerDecision::Tone {
467 frequency_hz: 1_000,
468 next_deadline_ms: 80
469 }
470 );
471
472 assert_eq!(
474 e.tick(80),
475 BuzzerDecision::Tone {
476 frequency_hz: 1_500,
477 next_deadline_ms: 160
478 }
479 );
480
481 assert_eq!(
483 e.tick(160),
484 BuzzerDecision::Tone {
485 frequency_hz: 2_000,
486 next_deadline_ms: 280
487 }
488 );
489
490 assert_eq!(e.tick(280), BuzzerDecision::Silent);
492 }
493
494 #[test]
495 fn power_on_is_rising() {
496 for w in melodies::POWER_ON.notes.windows(2) {
497 assert!(
498 w[0].frequency_hz < w[1].frequency_hz,
499 "expected rising melody, got {} then {}",
500 w[0].frequency_hz,
501 w[1].frequency_hz
502 );
503 }
504 }
505
506 #[test]
507 fn power_off_is_falling() {
508 for w in melodies::POWER_OFF.notes.windows(2) {
509 assert!(
510 w[0].frequency_hz > w[1].frequency_hz,
511 "expected falling melody, got {} then {}",
512 w[0].frequency_hz,
513 w[1].frequency_hz
514 );
515 }
516 }
517
518 #[test]
524 fn a_rest_is_not_the_end_of_the_melody() {
525 let mut e = BuzzerEngine::new();
526 e.play(&melodies::GNSS_ON, 0);
527
528 assert_eq!(
531 e.tick(0),
532 BuzzerDecision::Tone {
533 frequency_hz: 1_900,
534 next_deadline_ms: 45
535 }
536 );
537 assert_eq!(
538 e.tick(45),
539 BuzzerDecision::Rest {
540 next_deadline_ms: 90
541 }
542 );
543 assert_eq!(
544 e.tick(90),
545 BuzzerDecision::Tone {
546 frequency_hz: 1_900,
547 next_deadline_ms: 135
548 }
549 );
550
551 assert_eq!(
554 e.tick(300),
555 BuzzerDecision::Tone {
556 frequency_hz: 2_600,
557 next_deadline_ms: 350
558 }
559 );
560 assert_eq!(e.tick(350), BuzzerDecision::Silent);
561 }
562
563 #[test]
566 fn the_receiver_switch_sounds_different_each_way() {
567 fn voiced(melody: &Melody) -> (u16, u16) {
570 let mut heard = melody
571 .notes
572 .iter()
573 .map(|tone| tone.frequency_hz)
574 .filter(|frequency| *frequency != 0);
575 let first = heard.next().expect("a melody with no notes");
576 (first, heard.last().unwrap_or(first))
577 }
578
579 assert!(
580 melodies::GNSS_ON
581 .notes
582 .iter()
583 .map(|tone| tone.frequency_hz)
584 .ne(melodies::GNSS_OFF
585 .notes
586 .iter()
587 .map(|tone| tone.frequency_hz))
588 );
589
590 let (on_first, on_last) = voiced(&melodies::GNSS_ON);
591 assert!(on_last > on_first, "switching on should resolve upward");
592 let (off_first, off_last) = voiced(&melodies::GNSS_OFF);
593 assert!(
594 off_last < off_first,
595 "switching off should resolve downward"
596 );
597 }
598
599 #[test]
600 fn restart_active_rewinds_start_time() {
601 let mut e = BuzzerEngine::new();
602 e.play(&melodies::POWER_ON, 0);
603 e.restart_active(80);
605 assert_eq!(
607 e.tick(80),
608 BuzzerDecision::Tone {
609 frequency_hz: 1_000,
610 next_deadline_ms: 160
611 }
612 );
613 }
614
615 #[test]
616 fn silence_suppresses_play() {
617 let mut e = BuzzerEngine::new();
618 e.set_silenced(true);
619 e.play(&melodies::POWER_ON, 0);
620 assert_eq!(e.tick(0), BuzzerDecision::Silent);
621 }
622
623 #[test]
624 fn engaging_silence_stops_in_flight_melody() {
625 let mut e = BuzzerEngine::new();
626 e.play(&melodies::POWER_ON, 0);
627 match e.tick(10) {
629 BuzzerDecision::Tone { .. } => {}
630 d => panic!("expected Tone, got {:?}", d),
631 }
632 e.set_silenced(true);
634 assert_eq!(e.tick(10), BuzzerDecision::Silent);
635 }
636
637 #[test]
638 fn unsilencing_does_not_resume_killed_melody() {
639 let mut e = BuzzerEngine::new();
640 e.play(&melodies::POWER_ON, 0);
641 e.set_silenced(true);
642 e.set_silenced(false);
643 assert_eq!(e.tick(0), BuzzerDecision::Silent);
645 }
646
647 #[test]
648 fn toggle_silenced_returns_new_state() {
649 let mut e = BuzzerEngine::new();
650 assert_eq!(e.toggle_silenced(), true);
651 assert_eq!(e.is_silenced(), true);
652 assert_eq!(e.toggle_silenced(), false);
653 assert_eq!(e.is_silenced(), false);
654 }
655
656 fn melody_len_ms(melody: &Melody) -> u64 {
658 melody
659 .notes
660 .iter()
661 .map(|tone| tone.duration.as_millis() as u64)
662 .sum()
663 }
664
665 #[test]
666 fn alert_sounds_through_silence() {
667 let mut e = BuzzerEngine::new();
668 e.set_silenced(true);
669 e.play(&melodies::POWER_ON, 0);
671 assert_eq!(e.tick(0), BuzzerDecision::Silent);
672
673 e.play_alert(&melodies::LOCATE, 0, 3_000);
674 assert!(e.alert_active());
675 assert!(matches!(e.tick(0), BuzzerDecision::Tone { .. }));
676 }
677
678 #[test]
679 fn silencing_does_not_stop_a_running_alert() {
680 let mut e = BuzzerEngine::new();
681 e.play_alert(&melodies::LOCATE, 0, 3_000);
682 e.set_silenced(true);
683 assert!(e.alert_active());
684 assert!(matches!(e.tick(10), BuzzerDecision::Tone { .. }));
685 }
686
687 #[test]
688 fn stopping_an_alert_restores_the_previous_silence() {
689 let mut e = BuzzerEngine::new();
690 e.set_silenced(true);
691 e.play_alert(&melodies::LOCATE, 0, 3_000);
692 e.stop_alert();
693 assert!(!e.alert_active());
694 assert!(e.is_silenced());
696 assert_eq!(e.tick(0), BuzzerDecision::Silent);
697 }
698
699 #[test]
700 fn alert_repeats_on_its_period_with_a_rest_between() {
701 let mut e = BuzzerEngine::new();
702 let period = 3_000;
703 let length = melody_len_ms(&melodies::LOCATE);
704 e.play_alert(&melodies::LOCATE, 0, period);
705
706 assert!(matches!(e.tick(0), BuzzerDecision::Tone { .. }));
708 assert_eq!(
709 e.tick(length + 1),
710 BuzzerDecision::Silent,
711 "the gap between passes is silent"
712 );
713 assert!(matches!(e.tick(period), BuzzerDecision::Tone { .. }));
715 assert!(matches!(e.tick(period * 20), BuzzerDecision::Tone { .. }));
716 assert!(e.alert_active());
717 }
718
719 #[test]
720 fn the_gap_between_alert_passes_still_reports_a_deadline() {
721 let mut e = BuzzerEngine::new();
725 let period = 3_000;
726 let length = melody_len_ms(&melodies::LOCATE);
727 e.play_alert(&melodies::LOCATE, 0, period);
728
729 let resting = length + 1;
730 assert_eq!(e.tick(resting), BuzzerDecision::Silent);
731 assert_eq!(
732 e.next_deadline_ms(resting),
733 Some(period),
734 "wakes for the next pass"
735 );
736 }
737
738 #[test]
739 fn an_idle_engine_has_no_deadline() {
740 let mut e = BuzzerEngine::new();
741 assert_eq!(e.next_deadline_ms(0), None);
742 e.play(&melodies::POWER_ON, 0);
743 assert!(e.next_deadline_ms(0).is_some());
744 e.set_silenced(true);
747 assert_eq!(e.next_deadline_ms(0), None);
748 }
749
750 #[test]
751 fn an_alert_is_not_displaced_by_an_ordinary_melody() {
752 let mut e = BuzzerEngine::new();
753 e.play_alert(&melodies::LOCATE, 0, 3_000);
754 e.play(&melodies::BEACON_ACK, 10);
755 assert!(e.alert_active(), "the alarm outranks a notification");
756 }
757
758 #[test]
759 fn stop_alert_leaves_an_ordinary_melody_alone() {
760 let mut e = BuzzerEngine::new();
761 e.play(&melodies::POWER_ON, 0);
762 e.stop_alert();
763 assert!(matches!(e.tick(0), BuzzerDecision::Tone { .. }));
764 }
765
766 #[test]
767 fn second_play_replaces_first() {
768 let mut e = BuzzerEngine::new();
769 e.play(&melodies::POWER_ON, 0);
770 e.play(&melodies::POWER_OFF, 50);
771 assert_eq!(
773 e.tick(50),
774 BuzzerDecision::Tone {
775 frequency_hz: 2_000,
776 next_deadline_ms: 130
777 }
778 );
779 }
780
781 #[test]
782 fn rest_note_makes_no_tone_within_melody() {
783 static REST_MELODY: Melody = Melody::new(&[
785 Tone {
786 frequency_hz: 1_000,
787 duration: Duration::from_millis(50),
788 },
789 Tone {
790 frequency_hz: 0,
791 duration: Duration::from_millis(50),
792 }, Tone {
794 frequency_hz: 2_000,
795 duration: Duration::from_millis(50),
796 },
797 ]);
798
799 let mut e = BuzzerEngine::new();
800 e.play(&REST_MELODY, 0);
801 match e.tick(25) {
803 BuzzerDecision::Tone {
804 frequency_hz: 1_000,
805 ..
806 } => {}
807 d => panic!("expected 1000 Hz tone, got {:?}", d),
808 }
809 assert_eq!(
812 e.tick(75),
813 BuzzerDecision::Rest {
814 next_deadline_ms: 100
815 }
816 );
817 match e.tick(125) {
819 BuzzerDecision::Tone {
820 frequency_hz: 2_000,
821 ..
822 } => {}
823 d => panic!("expected 2000 Hz tone, got {:?}", d),
824 }
825 }
826}