umsh_ux_tracker/
buzzer.rs

1//! Buzzer melody engine.
2//!
3//! Pure-logic sequencer that plays short melodies on the T1000-E's
4//! piezo buzzer (P0.25, enable P1.05 — see `docs/hardware/t1000e-hardware.md`).
5//! Symmetric in shape to the [`led`](crate::led) module, but with
6//! tones instead of on/off pulses and with silence semantics.
7//!
8//! UX rules:
9//!
10//! - **Power-on:** rising melody.
11//! - **Power-off:** falling melody.
12//! - **Silence mode** (toggled by double-press) suppresses the buzzer
13//!   entirely. The LED is **not** affected by silence — that mapping
14//!   belongs to the LED engine, not here.
15//! - **Silence mid-melody** cuts the current melody short, so the
16//!   user's silence request is honored immediately rather than
17//!   waiting for the in-flight sequence to finish.
18//!
19//! Pure logic over `u64` milliseconds; no PWM, no GPIO. The real
20//! driver lives in `umsh-bsp-t1000e` and consumes
21//! [`BuzzerDecision`]s emitted by `tick`.
22
23use core::time::Duration;
24
25/// One note in a melody. `frequency_hz == 0` is a deliberate rest
26/// (silent gap between tones).
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct Tone {
29    pub frequency_hz: u16,
30    pub duration: Duration,
31}
32
33/// A short fixed sequence of tones / rests.
34#[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
45/// Pre-baked melodies for the standard firmware events.
46pub mod melodies {
47    use super::{Melody, Tone};
48    use core::time::Duration;
49
50    /// Rising chirp: 1000 → 1500 → 2000 Hz.
51    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    /// Falling chirp: 2000 → 1500 → 1000 Hz.
67    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    /// Short confirmation blip after a beacon is transmitted.
83    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    /// Locate alert: a two-tone warble, deliberately unlike any
95    /// notification the device makes in normal operation, so it reads as
96    /// "come find me" rather than "you have mail". Played on repeat by
97    /// [`BuzzerEngine::play_alert`](super::BuzzerEngine::play_alert),
98    /// which supplies the gap between passes.
99    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    /// Receiver switched on: two pips at one pitch, then a higher held
119    /// note — "searching, and now looking".
120    ///
121    /// Deliberately not another rising ramp. `POWER_ON` and `POWER_OFF`
122    /// already own that shape, and a fourth ramp would be a tone the
123    /// operator has to stop and decode. The repeated pip is what marks
124    /// this pair as being about the receiver.
125    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    /// Receiver switched off: the held note first, falling away into two
149    /// low pips — [`GNSS_ON`] in reverse.
150    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    /// Bright blip played when the buzzer is un-silenced.
174    pub static UNSILENCE: Melody = Melody::new(&[Tone {
175        frequency_hz: 2_000,
176        duration: Duration::from_millis(60),
177    }]);
178
179    /// Short low blip played just before the buzzer goes silent.
180    pub static DO_SILENCE: Melody = Melody::new(&[Tone {
181        frequency_hz: 1_200 / 8,
182        // The amplified T1000-E piezo needs at least ~60 ms to become
183        // audible. Keep this at the hardware floor so it reads as a subtle
184        // thump rather than a notification tone.
185        duration: Duration::from_millis(60),
186    }]);
187}
188
189/// State to apply to the buzzer driver right now.
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191pub enum BuzzerDecision {
192    /// No tone; driver should disable the buzzer.
193    Silent,
194    /// A rest *inside* a melody that is still playing: no tone, but the
195    /// sequence continues at `next_deadline_ms`.
196    ///
197    /// Distinct from [`Silent`](Self::Silent) because a driver that
198    /// powers its sounder down between notes pays a warm-up to bring it
199    /// back, and a warm-up that rewinds the melody turns every rest into
200    /// a loop. A driver with nothing to warm up may treat the two alike.
201    Rest { next_deadline_ms: u64 },
202    /// Drive a tone at `frequency_hz`. Re-invoke [`BuzzerEngine::tick`]
203    /// at `next_deadline_ms` to advance to the next note.
204    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    /// Replay the melody on this period instead of ending after one
215    /// pass. The gap between repeats is `period_ms` minus the melody's
216    /// own length, so a period shorter than the melody plays it back to
217    /// back.
218    repeat_every_ms: Option<u64>,
219    /// Play even while the buzzer is silenced. Reserved for the locate
220    /// alert: silencing is for not being a nuisance, and a radio nobody
221    /// can find is a different problem (spec §PROP_ALERT).
222    overrides_silence: bool,
223}
224
225impl ActiveMelody {
226    /// Resolve the current note. Returns `Some((tone, end_of_note_ms))`
227    /// if the melody is still playing, `None` if it has completed.
228    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    /// Resolve the current position, distinguishing a rest written into
242    /// the melody from the gap a repeating melody waits out between
243    /// passes.
244    ///
245    /// The difference is invisible on the wire and decisive at the
246    /// driver: a gap is dead time a board should power its sounder down
247    /// for, while a rest is part of a phrase that is still playing.
248    fn step(&self, now_ms: u64) -> Option<Step> {
249        let elapsed = now_ms.saturating_sub(self.started_at_ms);
250        // A repeating melody folds the clock into one period; the
251        // remainder of the period past the last note is the rest before
252        // the next pass.
253        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        // Past the last note. A one-shot melody is done; a repeating one
273        // rests until the next period boundary.
274        let period = self.repeat_every_ms?;
275        Some(Step::Gap(cycle_start_ms.saturating_add(period)))
276    }
277}
278
279/// Where a playing melody currently stands.
280enum Step {
281    /// A note written into the melody — a tone, or a rest when its
282    /// frequency is zero — ending at the given deadline.
283    Note(Tone, u64),
284    /// The dead time a repeating melody waits out before its next pass.
285    Gap(u64),
286}
287
288/// Buzzer melody engine.
289#[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    /// Returns true if the buzzer is currently silenced.
310    pub fn is_silenced(&self) -> bool {
311        self.silenced
312    }
313
314    /// Toggle silence. Engaging silence stops any in-flight melody so
315    /// the user's request is honored without waiting for the sequence
316    /// to finish — except a locate alert, which outranks it.
317    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    /// Whether a silence-overriding locate alert is playing.
325    pub fn alert_active(&self) -> bool {
326        self.active
327            .as_ref()
328            .is_some_and(|active| active.overrides_silence)
329    }
330
331    /// Toggle silence on/off and return the new state. Convenience for
332    /// the double-press handler.
333    pub fn toggle_silenced(&mut self) -> bool {
334        self.set_silenced(!self.silenced);
335        self.silenced
336    }
337
338    /// Rewind the active melody so its first note plays from `now_ms`.
339    /// No-op if no melody is active.
340    ///
341    /// Buzzer drivers that need an inaudible warmup period (e.g. the
342    /// T1000-E's piezo driver chip needs ~20 ms of PWM activity before
343    /// it starts emitting) should run that warmup with the engine
344    /// already loaded, then call this to drop the warmup interval out
345    /// of the engine's perceived clock so the first note gets its full
346    /// declared duration.
347    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    /// Start a melody. No-op if silenced, and no-op while a locate alert
354    /// is running — an ordinary notification must not displace the alarm
355    /// someone is currently homing in on.
356    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    /// Start the locate alert: `melody` on repeat every `period_ms`,
369    /// playing through silence, until [`Self::stop_alert`].
370    ///
371    /// Intermittent by construction rather than a continuous tone — a
372    /// lost radio is usually a nearly-flat radio, and a periodic chirp is
373    /// easier to home in on than a constant one.
374    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    /// Stop a locate alert. Leaves an ordinary melody alone, so this is
384    /// safe to call unconditionally when the alert clears.
385    pub fn stop_alert(&mut self) {
386        if self.alert_active() {
387            self.active = None;
388        }
389    }
390
391    /// When the engine next changes state, if it will.
392    ///
393    /// [`BuzzerDecision::Silent`] carries no deadline, but a repeating
394    /// alert is silent *between* passes and must be woken for the next
395    /// one — a driver that only re-ticks on `Tone` deadlines would play
396    /// the alert once and stop. Drivers arm a timer on this whenever the
397    /// decision is `Silent`.
398    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    /// Compute the buzzer state to apply at `now_ms`.
409    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            // The gap between passes of a repeating melody is dead time,
418            // not a phrase in progress: a board is free to power its
419            // sounder down for it, and `next_deadline_ms` brings it back.
420            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        // Note 1: 1000 Hz, 80ms
457        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        // Note 2: 1500 Hz, 80ms
473        assert_eq!(
474            e.tick(80),
475            BuzzerDecision::Tone {
476                frequency_hz: 1_500,
477                next_deadline_ms: 160
478            }
479        );
480
481        // Note 3: 2000 Hz, 120ms
482        assert_eq!(
483            e.tick(160),
484            BuzzerDecision::Tone {
485                frequency_hz: 2_000,
486                next_deadline_ms: 280
487            }
488        );
489
490        // Melody done.
491        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    /// A gap between notes has to be distinguishable from the end of
519    /// the melody. A driver that powers its sounder down for the one
520    /// pays a warm-up to bring it back, and the T1000-E's warm-up
521    /// rewinds the engine — so reporting a rest as `Silent` made every
522    /// melody containing one restart at each gap and play forever.
523    #[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        // Pip, gap, pip — the gap reports as a rest that still carries
529        // the sequence forward.
530        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        // And the melody does end, once: the held note runs to 350, and
552        // nothing follows it.
553        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    /// The two directions of the receiver switch must not be a
564    /// transposition of each other: told apart by ear is the whole job.
565    #[test]
566    fn the_receiver_switch_sounds_different_each_way() {
567        /// The first and last note a listener actually hears, rests
568        /// skipped.
569        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        // Pretend a board-side warmup ran for 80ms; now rewind.
604        e.restart_active(80);
605        // First note should still play in full from t=80 (deadline 160).
606        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        // Confirm a tone is playing.
628        match e.tick(10) {
629            BuzzerDecision::Tone { .. } => {}
630            d => panic!("expected Tone, got {:?}", d),
631        }
632        // Silence mid-melody.
633        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        // Active melody was discarded when silence engaged.
644        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    /// Total playing time of one pass of a melody.
657    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        // An ordinary melody stays suppressed.
670        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        // The silence preference was suspended, not cleared.
695        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        // Sounding during the melody, resting after it.
707        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        // And sounding again at the top of the next period, indefinitely.
714        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        // Regression: BuzzerDecision::Silent carries no deadline, so a
722        // driver that only re-ticks on Tone deadlines would sleep through
723        // the rest and play the alert exactly once.
724        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        // A silenced ordinary melody is not going to make a sound, so
745        // there is nothing to wake for.
746        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        // First note of POWER_OFF is 2000 Hz, ends at 50 + 80 = 130.
772        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        // Custom melody with a rest in the middle.
784        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            }, // rest
793            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        // 0..50: tone 1000
802        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        // 50..100: a rest, carrying the sequence to the next note. Not
810        // `Silent`, which is how a driver knows the melody has ended.
811        assert_eq!(
812            e.tick(75),
813            BuzzerDecision::Rest {
814                next_deadline_ms: 100
815            }
816        );
817        // 100..150: tone 2000
818        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}