Skip to content

Commit c5d1b82

Browse files
authored
Fix audio clipping and add low-latency WaveGenerator configuration (#30)
1 parent 5299b05 commit c5d1b82

File tree

7 files changed

+261
-48
lines changed

7 files changed

+261
-48
lines changed

src/arduino/app_bricks/wave_generator/README.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,22 +81,33 @@ from arduino.app_bricks.wave_generator import WaveGenerator
8181
from arduino.app_peripherals.speaker import Speaker
8282
from arduino.app_utils import App
8383

84+
# Create Speaker with optimal real-time configuration
8485
speaker = Speaker(
8586
device=Speaker.USB_SPEAKER_2,
8687
sample_rate=16000,
8788
channels=1,
88-
format="S16_LE"
89+
format="FLOAT_LE",
90+
periodsize=480, # 16000 Hz × 0.03s = 480 frames (eliminates buffer mismatch)
91+
queue_maxsize=10 # Low latency configuration
8992
)
9093

94+
# Start external Speaker manually (WaveGenerator won't manage its lifecycle)
95+
speaker.start()
96+
9197
wave_gen = WaveGenerator(sample_rate=16000, speaker=speaker)
9298

9399
App.start_brick(wave_gen)
94100
wave_gen.set_frequency(440.0)
95101
wave_gen.set_amplitude(0.7)
96102

97103
App.run()
104+
105+
# Stop external Speaker manually
106+
speaker.stop()
98107
```
99108

109+
**Note:** When providing an external Speaker, you manage its lifecycle (start/stop). WaveGenerator only validates configuration and uses it for playback.
110+
100111
## Understanding Wave Generation
101112

102113
The Wave Generator brick produces audio through continuous waveform synthesis.

src/arduino/app_bricks/wave_generator/examples/05_external_speaker.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,23 +22,33 @@
2222
print(f"Available USB speakers: {available_speakers}")
2323

2424
# Create and configure a Speaker with specific parameters
25+
# For optimal real-time synthesis, align periodsize with WaveGenerator block_duration
26+
block_duration = 0.03 # Default WaveGenerator block duration
27+
sample_rate = 16000
28+
periodsize = int(sample_rate * block_duration) # 480 frames @ 16kHz
29+
2530
speaker = Speaker(
26-
device=Speaker.USB_SPEAKER_1, # or None for auto-detect, or specific device
27-
sample_rate=16000,
31+
device=Speaker.USB_SPEAKER_1, # or explicit device like "plughw:CARD=Device"
32+
sample_rate=sample_rate,
2833
channels=1,
2934
format="FLOAT_LE",
35+
periodsize=periodsize, # Align with WaveGenerator blocks (eliminates glitches)
36+
queue_maxsize=10, # Low latency for real-time audio
3037
)
3138

39+
# Start the external Speaker manually
40+
# WaveGenerator won't manage its lifecycle (ownership pattern)
41+
speaker.start()
42+
3243
# Create WaveGenerator with the external speaker
33-
# WaveGenerator will manage the speaker's lifecycle (start/stop)
3444
wave_gen = WaveGenerator(
35-
sample_rate=16000,
45+
sample_rate=sample_rate,
3646
speaker=speaker, # Pass pre-configured speaker
3747
wave_type="sine",
3848
glide=0.02,
3949
)
4050

41-
# Start the WaveGenerator (which will also start the speaker)
51+
# Start the WaveGenerator (speaker already started above)
4252
App.start_brick(wave_gen)
4353

4454

@@ -62,5 +72,6 @@ def play_sequence():
6272

6373
App.run(user_loop=play_sequence)
6474

65-
# WaveGenerator automatically stops the speaker when it stops
75+
# Stop external Speaker manually (WaveGenerator doesn't manage external lifecycle)
76+
speaker.stop()
6677
print("Done")

src/arduino/app_bricks/wave_generator/wave_generator.py

Lines changed: 97 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def __init__(
3939
self,
4040
sample_rate: int = 16000,
4141
wave_type: WaveType = "sine",
42-
block_duration: float = 0.03,
42+
block_duration: float = 0.01,
4343
attack: float = 0.01,
4444
release: float = 0.03,
4545
glide: float = 0.02,
@@ -50,13 +50,29 @@ def __init__(
5050
Args:
5151
sample_rate (int): Audio sample rate in Hz (default: 16000).
5252
wave_type (WaveType): Initial waveform type (default: "sine").
53-
block_duration (float): Duration of each audio block in seconds (default: 0.03).
53+
block_duration (float): Duration of each audio block in seconds (default: 0.01).
5454
attack (float): Attack time for amplitude envelope in seconds (default: 0.01).
5555
release (float): Release time for amplitude envelope in seconds (default: 0.03).
5656
glide (float): Frequency glide time (portamento) in seconds (default: 0.02).
57-
speaker (Speaker, optional): Pre-configured Speaker instance. If None, a new Speaker
58-
will be created with default settings (auto-detect device, FLOAT_LE format).
59-
WaveGenerator will manage the speaker's lifecycle (calling start/stop).
57+
speaker (Speaker, optional): Pre-configured Speaker instance. If None, WaveGenerator
58+
will create an internal Speaker optimized for real-time synthesis with:
59+
- periodsize aligned to block_duration (eliminates buffer mismatch)
60+
- queue_maxsize=8 (low latency: ~80ms max buffer)
61+
- format=FLOAT_LE, channels=1
62+
63+
If providing an external Speaker, ensure:
64+
- sample_rate matches WaveGenerator's sample_rate
65+
- periodsize = int(sample_rate × block_duration) for optimal alignment
66+
- Speaker is started/stopped manually (WaveGenerator won't manage its lifecycle)
67+
68+
Example external Speaker configuration:
69+
speaker = Speaker(
70+
device="plughw:CARD=UH34",
71+
sample_rate=16000,
72+
format="FLOAT_LE",
73+
periodsize=160, # 16000 × 0.01 = 160 frames
74+
queue_maxsize=8
75+
)
6076
6177
Raises:
6278
SpeakerException: If no USB speaker is found or device is busy.
@@ -90,18 +106,51 @@ def __init__(
90106
if speaker is not None:
91107
# Use externally provided Speaker instance
92108
self._speaker = speaker
93-
logger.info("Using externally provided Speaker instance")
109+
self._owns_speaker = False
110+
111+
# Validate external speaker configuration
112+
if self._speaker.sample_rate != sample_rate:
113+
logger.warning(
114+
f"External Speaker sample_rate ({self._speaker.sample_rate}Hz) differs from "
115+
f"WaveGenerator sample_rate ({sample_rate}Hz). This may cause issues."
116+
)
117+
118+
# Check if periodsize is aligned with block_duration
119+
expected_periodsize = int(sample_rate * block_duration)
120+
if self._speaker._periodsize is not None and self._speaker._periodsize != expected_periodsize:
121+
logger.warning(
122+
f"External Speaker periodsize ({self._speaker._periodsize} frames) does not match "
123+
f"WaveGenerator block size ({expected_periodsize} frames @ {sample_rate}Hz). "
124+
f"This may cause audio glitches. Consider using periodsize={expected_periodsize} "
125+
f"or omitting the speaker parameter to use auto-configured internal Speaker."
126+
)
127+
elif self._speaker._periodsize is None:
128+
logger.warning(
129+
f"External Speaker has periodsize=None (hardware default). For optimal real-time "
130+
f"synthesis, consider setting periodsize={expected_periodsize} frames to match "
131+
f"WaveGenerator block size."
132+
)
133+
134+
logger.info("Using externally provided Speaker instance (not managed by WaveGenerator)")
94135
else:
95-
# Create internal Speaker instance with default settings
136+
# Create internal Speaker instance optimized for real-time synthesis
137+
# WaveGenerator requires low-latency configuration:
138+
# - periodsize matches block_duration for aligned I/O
139+
# - queue_maxsize reduced for responsive audio
140+
block_size_frames = int(sample_rate * block_duration)
96141
self._speaker = Speaker(
97-
device=None, # Auto-detect first available USB speaker
142+
device=Speaker.USB_SPEAKER_1, # Auto-detect first available USB speaker
98143
sample_rate=sample_rate,
99144
channels=1,
100145
format="FLOAT_LE",
146+
periodsize=block_size_frames, # Align with generation block
147+
queue_maxsize=8, # Extreme low latency: 8 blocks = 80ms @ 10ms/block
101148
)
149+
self._owns_speaker = True
102150
logger.info(
103-
"Created internal Speaker: device=auto-detect, sample_rate=%d, format=FLOAT_LE",
151+
"Created internal Speaker: device=auto-detect, sample_rate=%d, format=FLOAT_LE, periodsize=%d frames",
104152
sample_rate,
153+
block_size_frames,
105154
)
106155

107156
# Producer thread control
@@ -119,22 +168,29 @@ def __init__(
119168
def start(self):
120169
"""Start the wave generator and audio output.
121170
122-
This starts the speaker device and launches the producer thread that
123-
continuously generates and streams audio blocks.
171+
This starts the speaker device (if internally owned) and launches the producer thread
172+
that continuously generates and streams audio blocks.
124173
"""
125174
if self._running.is_set():
126175
logger.warning("WaveGenerator is already running")
127176
return
128177

129178
logger.info("Starting WaveGenerator...")
130-
self._speaker.start()
131179

132-
# Set hardware speaker volume to maximum (100%)
133-
try:
134-
self._speaker.set_volume(100)
135-
logger.info("Speaker hardware volume set to 100%")
136-
except Exception as e:
137-
logger.warning(f"Could not set speaker volume: {e}")
180+
if self._owns_speaker:
181+
self._speaker.start()
182+
183+
# Set hardware speaker volume to maximum (100%)
184+
try:
185+
self._speaker.set_volume(100)
186+
logger.info("Speaker hardware volume set to 100%")
187+
except Exception as e:
188+
logger.warning(f"Could not set speaker volume: {e}")
189+
else:
190+
logger.info("Using external Speaker (lifecycle not managed by WaveGenerator)")
191+
# Verify external speaker is started
192+
if not self._speaker._is_reproducing.is_set():
193+
logger.warning("External Speaker is not started! Call speaker.start() before starting WaveGenerator, or audio playback will fail.")
138194

139195
self._running.set()
140196

@@ -146,7 +202,7 @@ def start(self):
146202
def stop(self):
147203
"""Stop the wave generator and audio output.
148204
149-
This stops the producer thread and closes the speaker device.
205+
This stops the producer thread and closes the speaker device (if internally owned).
150206
"""
151207
if not self._running.is_set():
152208
logger.warning("WaveGenerator is not running")
@@ -161,8 +217,12 @@ def stop(self):
161217
logger.warning("Producer thread did not terminate in time")
162218
self._producer_thread = None
163219

164-
self._speaker.stop()
165-
logger.info("WaveGenerator stopped")
220+
# Only stop speaker if we own it (internal instance)
221+
if self._owns_speaker:
222+
self._speaker.stop()
223+
logger.info("WaveGenerator stopped (internal Speaker closed)")
224+
else:
225+
logger.info("WaveGenerator stopped (external Speaker not closed)")
166226

167227
def set_frequency(self, frequency: float):
168228
"""Set the target output frequency.
@@ -265,11 +325,13 @@ def _producer_loop(self):
265325
"""Main producer loop running in background thread.
266326
267327
Continuously generates audio blocks at a steady cadence and streams
268-
them to the speaker device.
328+
them to the speaker device. Uses adaptive generation: when queue is low,
329+
generates extra blocks to prevent underruns and glitches.
269330
"""
270331
logger.debug("Producer loop started")
271332
next_time = time.perf_counter()
272333
block_count = 0
334+
emergency_refill_threshold = 2 # Generate extra blocks if queue below this (~20ms @ 10ms/block)
273335

274336
while self._running.is_set():
275337
next_time += self.block_duration
@@ -285,10 +347,20 @@ def _producer_loop(self):
285347
if block_count % 100 == 0 or (block_count < 5):
286348
logger.debug(f"Producer: block={block_count}, freq={target_freq:.1f}Hz, amp={target_amp:.3f}")
287349

288-
# Generate audio block
350+
# Check queue depth and generate extra blocks if needed
351+
queue_depth = self._speaker._playing_queue.qsize()
352+
blocks_to_generate = 1
353+
if queue_depth < emergency_refill_threshold:
354+
# Emergency: generate 2-3 blocks at once to quickly refill
355+
blocks_to_generate = min(3, emergency_refill_threshold - queue_depth)
356+
if blocks_to_generate > 1:
357+
logger.debug(f"Emergency refill: queue={queue_depth}, generating {blocks_to_generate} blocks")
358+
359+
# Generate audio block(s)
289360
try:
290-
audio_block = self._generate_block(target_freq, target_amp, wave_type)
291-
self._speaker.play(audio_block, block_on_queue=False)
361+
for _ in range(blocks_to_generate):
362+
audio_block = self._generate_block(target_freq, target_amp, wave_type)
363+
self._speaker.play(audio_block, block_on_queue=False)
292364
except Exception as e:
293365
logger.error(f"Error generating audio block: {e}")
294366

src/arduino/app_peripherals/microphone/__init__.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,13 @@ def get_volume(self) -> int:
184184
if self._mixer is None:
185185
return -1 # No mixer available, return -1 to indicate no volume control
186186
try:
187-
volume = self._mixer.getvolume()[0]
188-
return volume
187+
# Get current mixer value and map it back to 0-100 range
188+
current_value = self._mixer.getvolume()[0]
189+
min_vol, max_vol = self._mixer.getrange()
190+
if max_vol == min_vol:
191+
return 100 # Avoid division by zero
192+
percentage = int(((current_value - min_vol) / (max_vol - min_vol)) * 100)
193+
return max(0, min(100, percentage))
189194
except alsaaudio.ALSAAudioError as e:
190195
logger.error(f"Error getting volume: {e}")
191196
raise MicrophoneException(f"Error getting volume: {e}")
@@ -204,8 +209,11 @@ def set_volume(self, volume: int):
204209
if not (0 <= volume <= 100):
205210
raise ValueError("Volume must be between 0 and 100.")
206211
try:
207-
self._mixer.setvolume(volume)
208-
logger.info(f"Volume set to {volume}%")
212+
# Get mixer's actual range and map 0-100 to it
213+
min_vol, max_vol = self._mixer.getrange()
214+
actual_value = int(min_vol + (max_vol - min_vol) * (volume / 100.0))
215+
self._mixer.setvolume(actual_value)
216+
logger.info(f"Volume set to {volume}% (mixer value: {actual_value}/{max_vol})")
209217
except alsaaudio.ALSAAudioError as e:
210218
logger.error(f"Error setting volume: {e}")
211219
raise MicrophoneException(f"Error setting volume: {e}")

src/arduino/app_peripherals/speaker/README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,14 @@ speak.stop()
1616

1717
## Parameters
1818

19-
- `device`: (optional) ALSA device name (default: 'USB_MIC_1'. It can be the real ALSA device nome or USB_MIC_1, USB_MIC_2, ..)
20-
- `rate`: (optional) sampling frequency (default: 16000 Hz)
19+
- `device`: (optional) ALSA device name (default: 'USB_SPEAKER_1'. It can be the real ALSA device name or USB_SPEAKER_1, USB_SPEAKER_2, ..)
20+
- `sample_rate`: (optional) sampling frequency (default: 16000 Hz)
2121
- `channels`: (optional) channels (default: 1)
2222
- `format`: (optional) ALSA audio format (default: 'S16_LE')
23+
- `periodsize`: (optional) ALSA period size in frames (default: None = hardware default)
24+
- For **real-time synthesis** (WaveGenerator, etc.): set to match generation block size to eliminate buffer mismatches
25+
- For **streaming/file playback**: leave as None for hardware-optimal buffering
26+
- Example: `periodsize=480` for 30ms blocks @ 16kHz (480 = 16000 × 0.03)
27+
- `queue_maxsize`: (optional) application queue depth in blocks (default: 100)
28+
- Lower values (5-20): reduce latency for interactive audio
29+
- Higher values (50-200): provide stability for streaming

0 commit comments

Comments
 (0)