1+ # XRP Buzzer Library
2+ # An easy-to-use library for playing notes and songs on the XRP buzzer
3+ # Designed to be simple and fun for kids!
4+
5+ import machine
6+ import time
7+ import urandom
8+ from machine import Timer
9+
10+ class Buzzer :
11+
12+ """
13+ A class to control the buzzer on XRP robots.
14+
15+ Allows for playing individual notes or songs with various durations and tempos.
16+ Supports natural notes, sharps, and flats.
17+ """
18+
19+ # Pin configuration for the buzzer
20+ BUZZER_PIN = 13
21+
22+ # Duration constants (based on a default 120 BPM tempo)
23+ WHOLE = 4
24+ HALF = 2
25+ QUARTER = 1
26+ EIGHTH = 0.5
27+ SIXTEENTH = 0.25
28+
29+ # Note names to semitone offset (semitones from C4 = 0)
30+ NOTE_OFFSETS = {
31+ # Natural notes
32+ "C" : 0 , "D" : 2 , "E" : 4 , "F" : 5 , "G" : 7 , "A" : 9 , "B" : 11 ,
33+ # Sharps
34+ "C#" : 1 , "D#" : 3 , "F#" : 6 , "G#" : 8 , "A#" : 10 ,
35+ # Flats
36+ "DB" : 1 , "EB" : 3 , "GB" : 6 , "AB" : 8 , "BB" : 10 ,
37+ }
38+
39+ _DEFAULT_BUZZER_INSTANCE = None
40+
41+ @classmethod
42+ def get_default_buzzer (cls ):
43+ """
44+ Get the default XRP buzzer instance. This is a singleton.
45+
46+ :returns: The default Buzzer instance
47+ :rtype: Buzzer
48+ """
49+ if cls ._DEFAULT_BUZZER_INSTANCE is None :
50+ cls ._DEFAULT_BUZZER_INSTANCE = cls ()
51+ return cls ._DEFAULT_BUZZER_INSTANCE
52+
53+ def __init__ (self , pin : int = None ):
54+ """
55+ Initialize the buzzer.
56+
57+ :param pin: The pin number for the buzzer
58+ :type pin: int
59+ """
60+ if pin is None :
61+ pin = self .BUZZER_PIN
62+ self ._pin = machine .Pin (pin )
63+ self ._pwm = machine .PWM (self ._pin )
64+ self ._pwm .duty_u16 (0 ) # Start silent
65+
66+ # State variables for non-blocking playback
67+ self ._note_end_time = 0
68+ self ._tempo = 120 # Default BPM
69+
70+ # Song playback state
71+ self ._song = []
72+ self ._song_index = 0
73+ self ._song_tempo = 120
74+
75+ # Timer for non-blocking updates (use virtual timer -1 to not conflict with user timers)
76+ self ._timer = Timer (- 1 )
77+ self ._timer_in_use = False
78+
79+ def _compute_frequency (self , note : str , octave : int ) -> int :
80+ """
81+ Compute the frequency for a note at a given octave.
82+
83+ Uses the formula: f = 440 * 2^((n-69)/12)
84+ """
85+ if note == "R" or note == "REST" :
86+ return 0
87+
88+ note_offset = self .NOTE_OFFSETS .get (note .upper (), 0 )
89+ midi_note = 60 + (octave - 4 ) * 12 + note_offset
90+ frequency = int (440 * (2 ** ((midi_note - 69 ) / 12 )))
91+
92+ return frequency
93+
94+ def _duration_to_ms (self , duration , tempo : int = None ) -> int :
95+ """
96+ Convert a duration name to milliseconds.
97+ """
98+ if tempo is None :
99+ tempo = self ._tempo
100+
101+ if isinstance (duration , str ):
102+ duration_lower = duration .lower ()
103+ if duration_lower == "whole" :
104+ beats = 4
105+ elif duration_lower == "half" :
106+ beats = 2
107+ elif duration_lower == "quarter" :
108+ beats = 1
109+ elif duration_lower == "eighth" :
110+ beats = 0.5
111+ elif duration_lower == "sixteenth" :
112+ beats = 0.25
113+ else :
114+ beats = 1
115+ else :
116+ beats = duration
117+
118+ ms_per_beat = 60000 / tempo
119+ return int (beats * ms_per_beat )
120+
121+ def _parse_note (self , note_str : str ):
122+ """Parse a note string like "C4", "C#4", "Db4" or "rest"."""
123+ note_str = note_str .strip ().upper ()
124+
125+ if note_str == "REST" or note_str == "R" :
126+ return ("R" , 0 )
127+
128+ note_letter = note_str [0 ]
129+ if note_letter in "ABCDEFG" :
130+ # Check for sharp (#) or flat (b) modifier
131+ note_with_modifier = note_letter
132+ if len (note_str ) > 1 :
133+ if note_str [1 ] == "#" :
134+ note_with_modifier = note_letter + "#"
135+ octave_pos = 2
136+ elif note_str [1 ] == "B" :
137+ note_with_modifier = note_letter + "B"
138+ octave_pos = 2
139+ else :
140+ octave_pos = 1
141+
142+ try :
143+ if octave_pos < len (note_str ):
144+ octave = int (note_str [octave_pos ])
145+ else :
146+ octave = 4 # Default octave
147+ return (note_with_modifier , octave )
148+ except (ValueError , IndexError ):
149+ return None
150+
151+ return None
152+
153+ def _update (self , timer ):
154+ """
155+ Internal callback for non-blocking playback.
156+ Called by the timer when running in non-blocking mode.
157+ """
158+
159+ # Check if current note is done
160+ if time .ticks_diff (self ._note_end_time , time .ticks_ms ()) <= 0 :
161+ # Note done - stop the tone
162+ self ._pwm .duty_u16 (0 )
163+
164+ # Small gap between notes
165+ time .sleep_ms (20 )
166+
167+ # Play next note in song
168+ self ._song_index += 1
169+ if self ._song_index < len (self ._song ):
170+ note , duration = self ._song [self ._song_index ]
171+ self ._start_note (note , duration , self ._song_tempo )
172+ else :
173+ # Song finished
174+ self ._timer .deinit ()
175+ self ._timer_in_use = False
176+
177+ def _start_note (self , note : str , duration : str = "quarter" , tempo : int = None ):
178+ """Start playing a note (non-blocking helper)."""
179+ parsed = self ._parse_note (note )
180+ if parsed is None :
181+ return
182+
183+ note_letter , octave = parsed
184+
185+ if note_letter == "R" :
186+ frequency = 0
187+ else :
188+ frequency = self ._compute_frequency (note_letter , octave )
189+
190+ duration_ms = self ._duration_to_ms (duration , tempo )
191+
192+ if frequency > 0 :
193+ self ._pwm .freq (frequency )
194+ self ._pwm .duty_u16 (32768 ) # 50% duty cycle
195+
196+ self ._note_end_time = time .ticks_add (time .ticks_ms (), duration_ms )
197+
198+ def _play_tone_blocking (self , frequency : int , duration_ms : int ):
199+ """Play a tone synchronously (blocking)."""
200+ if frequency <= 0 :
201+ self ._pwm .duty_u16 (0 )
202+ else :
203+ self ._pwm .freq (frequency )
204+ self ._pwm .duty_u16 (32768 )
205+
206+ time .sleep_ms (duration_ms )
207+ self ._pwm .duty_u16 (0 )
208+ time .sleep_ms (20 )
209+
210+ def play_note (self , note : str , duration : str = "quarter" , blocking : bool = True , tempo : int = None ):
211+ """
212+ Play a single note on the buzzer.
213+
214+ :param note: The note to play (e.g., "C4", "G5", "C#4", "Db4", "rest")
215+ :type note: str
216+ :param duration: How long to play ("whole", "half", "quarter", "eighth", "sixteenth")
217+ :type duration: str
218+ :param blocking: If True, wait for note to finish. If False, return immediately.
219+ :type blocking: bool
220+ :param tempo: BPM (optional, uses default if not specified)
221+ :type tempo: int
222+ """
223+ parsed = self ._parse_note (note )
224+ if parsed is None :
225+ return
226+
227+ note_letter , octave = parsed
228+
229+ if note_letter == "R" :
230+ frequency = 0
231+ else :
232+ frequency = self ._compute_frequency (note_letter , octave )
233+
234+ duration_ms = self ._duration_to_ms (duration , tempo )
235+
236+ if blocking :
237+ self ._play_tone_blocking (frequency , duration_ms )
238+ else :
239+ # Non-blocking - start the note and set up timer if needed
240+ if frequency > 0 :
241+ self ._pwm .freq (frequency )
242+ self ._pwm .duty_u16 (32768 )
243+
244+ self ._note_end_time = time .ticks_add (time .ticks_ms (), duration_ms )
245+
246+ # Start timer if not already running
247+ if not self ._timer_in_use :
248+ self ._timer .init (freq = 100 , callback = lambda t : self ._update (t ))
249+ self ._timer_in_use = True
250+
251+ def set_tempo (self , bpm : int ):
252+ """
253+ Set the tempo for songs.
254+
255+ :param bpm: The tempo in beats per minute
256+ :type bpm: int
257+ """
258+ self ._tempo = bpm
259+
260+ def play_song (self , song : list , tempo : int = None , blocking : bool = True ):
261+ """
262+ Play a song from a list of notes.
263+
264+ :param song: A list of (note, duration) tuples
265+ :type song: list
266+ :param tempo: BPM for this song (optional, uses default)
267+ :type tempo: int
268+ :param blocking: If True, wait for song to finish. If False, play in background.
269+ :type blocking: bool
270+ """
271+
272+ if tempo is None :
273+ tempo = self ._tempo
274+
275+ if not song :
276+ return
277+
278+ if blocking :
279+ # Play all notes synchronously
280+ for note , duration in song :
281+ parsed = self ._parse_note (note )
282+ if parsed is None :
283+ continue
284+ note_letter , octave = parsed
285+ if note_letter == "R" :
286+ frequency = 0
287+ else :
288+ frequency = self ._compute_frequency (note_letter , octave )
289+ duration_ms = self ._duration_to_ms (duration , tempo )
290+ self ._play_tone_blocking (frequency , duration_ms )
291+ else :
292+ # Non-blocking - store song and start playing with timer
293+ self ._song = song
294+ self ._song_index = 0
295+ self ._song_tempo = tempo
296+ self ._timer_in_use = False
297+
298+ # Start playing first note
299+ note , duration = song [0 ]
300+ self ._start_note (note , duration , tempo )
301+ self ._song_index = 1
302+
303+ # Start the timer for non-blocking playback
304+ self ._timer .init (freq = 100 , callback = lambda t : self ._update (t ))
305+ self ._timer_in_use = True
306+
307+ def play_move_it (self , blocking : bool = True ):
308+ """
309+ Play "I Like to Move It" song.
310+
311+ :param blocking: If True, wait for song to finish. If False, return immediately.
312+ :type blocking: bool
313+ """
314+ tempo = 120
315+ move_it = [
316+ ("rest" , "eighth" ),
317+ ("C4" , "eighth" ), ("C4" , "eighth" ), ("C4" , "eighth" ), ("C4" , "eighth" ),
318+ ("C4" , "eighth" ), ("C4" , "eighth" ), ("G3" , "eighth" ),
319+ ("rest" , "eighth" ),
320+ ("C4" , "eighth" ), ("C4" , "eighth" ), ("C4" , "eighth" ), ("C4" , "eighth" ),
321+ ("C4" , "eighth" ), ("C4" , "eighth" ), ("G3" , "eighth" ),
322+ ("rest" , "eighth" ),
323+ ("C4" , "eighth" ), ("C4" , "eighth" ), ("C4" , "eighth" ), ("C4" , "eighth" ),
324+ ("C4" , "eighth" ), ("C4" , "eighth" ), ("G3" , "eighth" ),
325+ ("rest" , "eighth" ),
326+ ("C4" , "eighth" ), ("C4" , "eighth" ), ("C4" , "eighth" ),
327+ ("rest" , "eighth" ), ("rest" , "eighth" ),
328+ ("G4" , "eighth" ), ("G4" , "eighth" ),
329+ ]
330+ self .play_song (move_it , tempo , blocking )
331+
332+ def play_happy_birthday (self , blocking : bool = True ):
333+ """
334+ Play "Happy Birthday" song.
335+
336+ :param blocking: If True, wait for song to finish. If False, return immediately.
337+ :type blocking: bool
338+ """
339+ tempo = 140
340+ happy_birthday = [
341+ ("G4" , "quarter" ), ("G4" , "quarter" ), ("A4" , "half" ), ("G4" , "half" ),
342+ ("C5" , "half" ), ("B4" , "whole" ),
343+ ("G4" , "quarter" ), ("G4" , "quarter" ), ("A4" , "half" ), ("G4" , "half" ),
344+ ("D5" , "half" ), ("C5" , "whole" ),
345+ ("G4" , "quarter" ), ("G4" , "quarter" ), ("G5" , "half" ), ("C5" , "half" ),
346+ ("B4" , "quarter" ), ("A4" , "quarter" ), ("F5" , "half" ), ("E5" , "half" ),
347+ ("C5" , "half" ), ("D5" , "half" ), ("C5" , "whole" ),
348+ ]
349+ self .play_song (happy_birthday , tempo , blocking )
350+
351+ def play_twinkle_twinkle (self , blocking : bool = True ):
352+ """
353+ Play "Twinkle Twinkle Little Star" song.
354+
355+ :param blocking: If True, wait for song to finish. If False, return immediately.
356+ :type blocking: bool
357+ """
358+ tempo = 100
359+ twinkle_twinkle = [
360+ ("C4" , "quarter" ), ("C4" , "quarter" ), ("G4" , "quarter" ), ("G4" , "quarter" ),
361+ ("A4" , "quarter" ), ("A4" , "quarter" ), ("G4" , "half" ),
362+ ("F4" , "quarter" ), ("F4" , "quarter" ), ("E4" , "quarter" ), ("E4" , "quarter" ),
363+ ("D4" , "quarter" ), ("D4" , "quarter" ), ("C4" , "half" ),
364+ ("G4" , "quarter" ), ("G4" , "quarter" ), ("F4" , "quarter" ), ("F4" , "quarter" ),
365+ ("E4" , "quarter" ), ("E4" , "quarter" ), ("D4" , "half" ),
366+ ("G4" , "quarter" ), ("G4" , "quarter" ), ("F4" , "quarter" ), ("F4" , "quarter" ),
367+ ("E4" , "quarter" ), ("E4" , "quarter" ), ("D4" , "half" ),
368+ ("C4" , "quarter" ), ("C4" , "quarter" ), ("G4" , "quarter" ), ("G4" , "quarter" ),
369+ ("A4" , "quarter" ), ("A4" , "quarter" ), ("G4" , "half" ),
370+ ("F4" , "quarter" ), ("F4" , "quarter" ), ("E4" , "quarter" ), ("E4" , "quarter" ),
371+ ("D4" , "quarter" ), ("D4" , "quarter" ), ("C4" , "half" ),
372+ ]
373+ self .play_song (twinkle_twinkle , tempo , blocking )
0 commit comments