Skip to content

Commit 42fd122

Browse files
rjtokenringdsammaruga
authored andcommitted
Sound generator Brick
1 parent a65829c commit 42fd122

File tree

8 files changed

+931
-3
lines changed

8 files changed

+931
-3
lines changed
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Sound Generator Brick
2+
3+
Play sounds and melodies
4+
5+
## Code example and usage
6+
7+
```python
8+
from arduino.app_bricks.sound_generator import SoundGenerator, SoundEffect
9+
from arduino.app_utils import App
10+
11+
player = SoundGenerator(sound_effects=[SoundEffect.adsr()])
12+
13+
fur_elise = [
14+
("E5", 1/4), ("D#5", 1/4), ("E5", 1/4), ("D#5", 1/4), ("E5", 1/4),
15+
("B4", 1/4), ("D5", 1/4), ("C5", 1/4), ("A4", 1/2),
16+
17+
("C4", 1/4), ("E4", 1/4), ("A4", 1/4), ("B4", 1/2),
18+
("E4", 1/4), ("G#4", 1/4), ("B4", 1/4), ("C5", 1/2),
19+
20+
("E4", 1/4), ("E5", 1/4), ("D#5", 1/4), ("E5", 1/4), ("D#5", 1/4), ("E5", 1/4),
21+
("B4", 1/4), ("D5", 1/4), ("C5", 1/4), ("A4", 1/2),
22+
23+
("C4", 1/4), ("E4", 1/4), ("A4", 1/4), ("B4", 1/2),
24+
("E4", 1/4), ("C5", 1/4), ("B4", 1/4), ("A4", 1.0),
25+
]
26+
for note, duration in fur_elise:
27+
player.play(note, duration)
28+
29+
App.run()
30+
```
31+
32+
waveform can be customized to change effect. For example, for a retro-gaming sound, you can configure "square" wave form.
33+
34+
```python
35+
player = SoundGenerator(wave_form="square")
36+
```
37+
38+
instead, to have a more "rock" like sound, you can add effect
39+
40+
```python
41+
player = SoundGenerator(sound_effects=[SoundEffect.adsr(), SoundEffect.overdrive(drive=180.0), SoundEffect.chorus(depth_ms=15, rate_hz=0.2, mix=0.4)])
42+
```
Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA <http://www.arduino.cc>
2+
#
3+
# SPDX-License-Identifier: MPL-2.0
4+
5+
from arduino.app_utils import WaveGenerator, brick
6+
from arduino.app_peripherals.speaker import Speaker
7+
import threading
8+
import numpy as np
9+
10+
from .effects import *
11+
from .loaders import ABCNotationLoader
12+
13+
14+
@brick
15+
class SoundGenerator:
16+
SAMPLE_RATE = 16000
17+
A4_FREQUENCY = 440.0
18+
19+
# Semitone mapping for the 12 notes (0 = C, 11 = B).
20+
# This is used to determine the relative position within an octave.
21+
SEMITONE_MAP = {
22+
"C": 0,
23+
"C#": 1,
24+
"DB": 1,
25+
"D": 2,
26+
"D#": 3,
27+
"EB": 3,
28+
"E": 4,
29+
"F": 5,
30+
"F#": 6,
31+
"GB": 6,
32+
"G": 7,
33+
"G#": 8,
34+
"AB": 8,
35+
"A": 9,
36+
"A#": 10,
37+
"BB": 10,
38+
"B": 11,
39+
}
40+
41+
NOTE_DURATTION = {
42+
"W": 1.0, # Whole
43+
"H": 0.5, # Half
44+
"Q": 0.25, # Quarter
45+
"E": 0.125, # Eighth
46+
"S": 0.0625, # Sixteenth
47+
"T": 0.03125, # Thirty-second
48+
"X": 0.015625, # Sixty-fourth
49+
}
50+
51+
# The reference point in the overall semitone count from C0. A4 is (4 * 12) + 9 semitones from C0.
52+
A4_SEMITONE_INDEX = (4 * 12) + 9
53+
54+
def __init__(
55+
self,
56+
output_device: Speaker = None,
57+
bpm: int = 120,
58+
time_signature: tuple = (4, 4),
59+
octaves: int = 8,
60+
wave_form: str = "sine",
61+
master_volume: float = 1.0,
62+
sound_effects: list = None,
63+
):
64+
"""Initialize the SoundGenerator.
65+
Args:
66+
output_device (Speaker, optional): The output device to play sound through.
67+
wave_form (str): The type of wave form to generate. Supported values
68+
are "sine" (default), "square", "triangle" and "sawtooth".
69+
bpm (int): The tempo in beats per minute for note duration calculations.
70+
master_volume (float): The master volume level (0.0 to 1.0).
71+
octaves (int): Number of octaves to generate notes for (starting from octave
72+
0 up to octaves-1).
73+
sound_effects (list, optional): List of sound effect instances to apply to the audio
74+
signal (e.g., [SoundEffect.adsr()]). See SoundEffect class for available effects.
75+
"""
76+
77+
self._wave_gen = WaveGenerator(sample_rate=self.SAMPLE_RATE, wave_form=wave_form)
78+
self._bpm = bpm
79+
self.time_signature = time_signature
80+
self._master_volume = master_volume
81+
self._sound_effects = sound_effects
82+
if output_device is None:
83+
self._self_created_device = True
84+
self._output_device = Speaker(sample_rate=self.SAMPLE_RATE, format="FLOAT_LE")
85+
else:
86+
self._self_created_device = False
87+
self._output_device = output_device
88+
89+
self._cfg_lock = threading.Lock()
90+
self._notes = {}
91+
for octave in range(octaves):
92+
notes = self._fill_node_frequencies(octave)
93+
self._notes.update(notes)
94+
95+
def start(self):
96+
if self._self_created_device:
97+
self._output_device.start(notify_if_started=False)
98+
99+
def stop(self):
100+
if self._self_created_device:
101+
self._output_device.stop()
102+
103+
def set_master_volume(self, volume: float):
104+
"""
105+
Set the master volume level.
106+
Args:
107+
volume (float): Volume level (0.0 to 1.0).
108+
"""
109+
self._master_volume = max(0.0, min(1.0, volume))
110+
111+
def set_effects(self, effects: list):
112+
"""
113+
Set the list of sound effects to apply to the audio signal.
114+
Args:
115+
effects (list): List of sound effect instances (e.g., [SoundEffect.adsr()]).
116+
"""
117+
with self._cfg_lock:
118+
self._sound_effects = effects
119+
120+
def _fill_node_frequencies(self, octave: int) -> dict:
121+
"""
122+
Given a sequence of notes with their names and octaves, fill in their frequencies.
123+
124+
"""
125+
notes = {}
126+
127+
notes[f"REST"] = 0.0 # Rest note
128+
129+
# Generate frequencies for all notes in the given octave
130+
for note_name in self.SEMITONE_MAP:
131+
frequency = self._note_to_frequency(note_name, octave)
132+
notes[f"{note_name}{octave}"] = frequency
133+
134+
return notes
135+
136+
def _note_to_frequency(self, note_name: str, octave: int) -> float:
137+
"""
138+
Calculates the frequency (in Hz) of a musical note based on its name and octave.
139+
140+
It uses the standard 12-tone equal temperament formula: f = f0 * 2^(n/12),
141+
where f0 is the reference frequency (A4=440Hz) and n is the number of
142+
semitones from the reference note.
143+
144+
Args:
145+
note_name: The name of the note (e.g., 'A', 'C#', 'Bb', case-insensitive).
146+
octave: The octave number (e.g., 4 for A4, 5 for C5).
147+
148+
Returns:
149+
The frequency in Hertz (float).
150+
"""
151+
# 1. Normalize the note name for lookup
152+
normalized_note = note_name.strip().upper()
153+
if len(normalized_note) > 1 and normalized_note[1] == "#":
154+
# Ensure sharps are treated correctly (e.g., 'C#' is fine)
155+
pass
156+
elif len(normalized_note) > 1 and normalized_note[1].lower() == "b":
157+
# Replace 'B' (flat) with 'B' for consistent dictionary key
158+
normalized_note = normalized_note[0] + "B"
159+
160+
# 2. Look up the semitone count within the octave
161+
if normalized_note not in self.SEMITONE_MAP:
162+
raise ValueError(f"Invalid note name: {note_name}. Please use notes like 'A', 'C#', 'Eb', etc.")
163+
164+
semitones_in_octave = self.SEMITONE_MAP[normalized_note]
165+
166+
# 3. Calculate the absolute semitone index (from C0)
167+
# Total semitones = (octave number * 12) + semitones_from_C_in_octave
168+
target_semitone_index = (octave * 12) + semitones_in_octave
169+
170+
# 4. Calculate 'n', the number of semitones from the reference pitch (A4)
171+
# A4 is the reference, so n is the distance from A4.
172+
semitones_from_a4 = target_semitone_index - self.A4_SEMITONE_INDEX
173+
174+
# 5. Calculate the frequency
175+
# f = 440 * 2^(n/12)
176+
frequency_hz = self.A4_FREQUENCY * (2.0 ** (semitones_from_a4 / 12.0))
177+
178+
return frequency_hz
179+
180+
def _note_duration(self, symbol: str | float | int) -> float:
181+
"""
182+
Decode a note duration symbol into its corresponding fractional value.
183+
Args:
184+
symbol (str | float | int): Note duration symbol (e.g., 'W', 'H', 'Q', etc.) or a float/int value.
185+
Returns:
186+
float: Corresponding fractional duration value or the float itself if provided.
187+
"""
188+
189+
if isinstance(symbol, float) or isinstance(symbol, int):
190+
return self._compute_time_duration(symbol)
191+
192+
duration = self.NOTE_DURATTION.get(symbol.upper(), None)
193+
if duration is not None:
194+
return self._compute_time_duration(duration)
195+
196+
return self._compute_time_duration(1 / 4) # Default to quarter note
197+
198+
def _compute_time_duration(self, note_fraction: float) -> float:
199+
"""
200+
Compute the time duration in seconds for a given note fraction and time signature.
201+
Args:
202+
note_fraction (float): The fraction of the note (e.g., 1.0 for whole, 0.5 for half).
203+
time_signature (tuple): The time signature as (numerator, denominator).
204+
Returns:
205+
float: Duration in seconds.
206+
"""
207+
208+
numerator, denominator = self.time_signature
209+
210+
# For compound time signatures (6/8, 9/8, 12/8), the beat is the dotted quarter note (3/8)
211+
if denominator == 8 and numerator % 3 == 0:
212+
beat_value = 3 / 8
213+
else:
214+
beat_value = 1 / denominator # es. 1/4 in 4/4
215+
216+
# Calculate the duration of a single beat in seconds
217+
beat_duration = 60.0 / self._bpm
218+
219+
# Compute the total duration
220+
return beat_duration * (note_fraction / beat_value)
221+
222+
def _apply_sound_effects(self, signal: np.ndarray, frequency: float) -> np.ndarray:
223+
"""
224+
Apply the configured sound effects to the audio signal.
225+
Args:
226+
signal (np.ndarray): Input audio signal.
227+
Returns:
228+
np.ndarray: Processed audio signal with sound effects applied.
229+
"""
230+
with self._cfg_lock:
231+
if self._sound_effects is None:
232+
return signal
233+
234+
processed_signal = signal
235+
for effect in self._sound_effects:
236+
if hasattr(effect, "apply_with_tone"):
237+
processed_signal = effect.apply_with_tone(processed_signal, frequency)
238+
else:
239+
processed_signal = effect.apply(processed_signal)
240+
241+
return processed_signal
242+
243+
def _get_note(self, note: str) -> float | None:
244+
if note is None:
245+
return None
246+
return self._notes.get(note.strip().upper())
247+
248+
def play_chord(self, notes: list[str], note_duration: float | str = 1 / 4, volume: float = None):
249+
"""
250+
Play a chord consisting of multiple musical notes simultaneously for a specified duration and volume.
251+
Args:
252+
notes (list[str]): List of musical notes to play (e.g., ['A4', 'C#5', 'E5']).
253+
note_duration (float | str): Duration of the chord as a float (like 1/4, 1/8) or a symbol ('W', 'H', 'Q', etc.).
254+
volume (float, optional): Volume level (0.0 to 1.0). If None, uses master volume.
255+
"""
256+
duration = self._note_duration(note_duration)
257+
if len(notes) == 1:
258+
self.play(notes[0], duration, volume)
259+
return
260+
261+
waves = []
262+
base_frequency = None
263+
for note in notes:
264+
frequency = self._get_note(note)
265+
if frequency:
266+
if base_frequency is None:
267+
base_frequency = frequency
268+
if volume is None:
269+
volume = self._master_volume
270+
data = self._wave_gen.generate_block(float(frequency), duration, volume)
271+
waves.append(data)
272+
else:
273+
continue
274+
if len(waves) == 0:
275+
return
276+
chord = np.sum(waves, axis=0, dtype=np.float32)
277+
chord /= np.max(np.abs(chord)) # Normalize to prevent clipping
278+
blk = chord.astype(np.float32)
279+
blk = self._apply_sound_effects(blk, base_frequency)
280+
try:
281+
self._output_device.play(blk, block_on_queue=False)
282+
except Exception as e:
283+
print(f"Error playing chord {notes}: {e}")
284+
285+
def play(self, note: str, note_duration: float | str = 1 / 4, volume: float = None):
286+
"""
287+
Play a musical note for a specified duration and volume.
288+
Args:
289+
note (str): The musical note to play (e.g., 'A4', 'C#5', 'REST').
290+
note_duration (float | str): Duration of the note as a float (like 1/4, 1/8) or a symbol ('W', 'H', 'Q', etc.).
291+
volume (float, optional): Volume level (0.0 to 1.0). If None, uses master volume.
292+
"""
293+
duration = self._note_duration(note_duration)
294+
frequency = self._get_note(note)
295+
if frequency is not None and frequency >= 0.0:
296+
if volume is None:
297+
volume = self._master_volume
298+
data = self._wave_gen.generate_block(float(frequency), duration, volume)
299+
data = self._apply_sound_effects(data, frequency)
300+
self._output_device.play(data, block_on_queue=False)
301+
302+
def play_tone(self, note: str, duration: float = 0.25, volume: float = None):
303+
"""
304+
Play a musical note for a specified duration and volume.
305+
Args:
306+
note (str): The musical note to play (e.g., 'A4', 'C#5', 'REST').
307+
duration (float): Duration of the note as a float in seconds.
308+
volume (float, optional): Volume level (0.0 to 1.0). If None, uses master volume.
309+
"""
310+
frequency = self._get_note(note)
311+
if frequency is not None and frequency >= 0.0 and duration > 0.0:
312+
if volume is None:
313+
volume = self._master_volume
314+
data = self._wave_gen.generate_block(float(frequency), duration, volume)
315+
data = self._apply_sound_effects(data, frequency)
316+
self._output_device.play(data, block_on_queue=False)
317+
318+
def play_abc(self, abc_string: str, volume: float = None):
319+
"""
320+
Play a sequence of musical notes defined in ABC notation.
321+
Args:
322+
abc_string (str): ABC notation string defining the sequence of notes.
323+
volume (float, optional): Volume level (0.0 to 1.0). If None, uses master volume.
324+
"""
325+
if not abc_string or abc_string.strip() == "":
326+
return
327+
if volume is None:
328+
volume = self._master_volume
329+
metadata, notes = ABCNotationLoader.parse_abc_notation(abc_string)
330+
for note, duration in notes:
331+
frequency = self._get_note(note)
332+
if frequency is not None and frequency >= 0.0:
333+
data = self._wave_gen.generate_block(float(frequency), duration, volume)
334+
data = self._apply_sound_effects(data, frequency)
335+
self._output_device.play(data, block_on_queue=False)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
id: arduino:sound_generator
2+
name: Sound Generator
3+
description: Generate sounds like notes, tones, or melodies using waveforms.
4+
category: audio
5+
required_devices:
6+
- microphone
7+

0 commit comments

Comments
 (0)