Skip to content

Commit 2a24ffb

Browse files
committed
Add Buzzer driver and include in defaults
Introduce XRPLib/buzzer.py: a Buzzer class for XRP robots that plays notes and songs (blocking and non-blocking). Features note parsing (naturals, sharps, flats), tempo/duration conversion, frequency computation, PWM control on default pin 13, a Timer-based non-blocking playback path, and several built-in songs. Also update XRPLib/defaults.py to import Buzzer and expose a default buzzer instance (buzzer) when running on NanoXRP via Buzzer.get_default_buzzer().
1 parent 537b9ee commit 2a24ffb

2 files changed

Lines changed: 378 additions & 1 deletion

File tree

XRPLib/buzzer.py

Lines changed: 373 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,373 @@
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)

XRPLib/defaults.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from .reflectance import Reflectance
99
from .servo import Servo
1010
from .webserver import Webserver
11+
from .buzzer import Buzzer
1112
from machine import Pin
1213
from sys import implementation
1314

@@ -39,4 +40,7 @@
3940
if hasattr(Pin.board, "SERVO_3"):
4041
servo_three = Servo.get_default_servo(index=3)
4142
if hasattr(Pin.board, "SERVO_4"):
42-
servo_four = Servo.get_default_servo(index=4)
43+
servo_four = Servo.get_default_servo(index=4)
44+
45+
if "NanoXRP" in implementation._machine:
46+
buzzer = Buzzer.get_default_buzzer()

0 commit comments

Comments
 (0)