Skip to content

Commit 5acf584

Browse files
committed
improvements
1 parent 579d9b8 commit 5acf584

File tree

2 files changed

+245
-5
lines changed

2 files changed

+245
-5
lines changed

src/arduino/app_bricks/sound_generator/__init__.py

Lines changed: 209 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,22 @@
22
#
33
# SPDX-License-Identifier: MPL-2.0
44

5-
from arduino.app_utils import brick
5+
from arduino.app_utils import brick, Logger
66
from arduino.app_peripherals.speaker import Speaker
77
import threading
88
from typing import Iterable
99
import numpy as np
1010
import time
11+
import logging
1112
from pathlib import Path
1213
from collections import OrderedDict
1314

1415
from .generator import WaveSamplesBuilder
1516
from .effects import *
1617
from .loaders import ABCNotationLoader
1718

19+
logger = Logger("SoundGenerator", logging.DEBUG)
20+
1821

1922
class LRUDict(OrderedDict):
2023
"""A dictionary-like object with a fixed size that evicts the least recently used items."""
@@ -357,6 +360,7 @@ def play_chord(self, notes: list[str], note_duration: float | str = 1 / 4, volum
357360
bytes: The audio block of the mixed sequences (float32).
358361
"""
359362
duration = self._note_duration(note_duration)
363+
logger.debug(f"play_chord: notes={notes}, note_duration={note_duration}, duration={duration}s, volume={volume}")
360364
if len(notes) == 1:
361365
self.play(notes[0], duration, volume)
362366
return
@@ -372,6 +376,7 @@ def play_chord(self, notes: list[str], note_duration: float | str = 1 / 4, volum
372376
volume = self._master_volume
373377
data = self._wave_gen.generate_block(float(frequency), duration, volume)
374378
waves.append(data)
379+
logger.debug(f" Generated wave for {note} @ {frequency}Hz, {len(data)} samples")
375380
else:
376381
continue
377382
if len(waves) == 0:
@@ -380,7 +385,9 @@ def play_chord(self, notes: list[str], note_duration: float | str = 1 / 4, volum
380385
chord /= np.max(np.abs(chord)) # Normalize to prevent clipping
381386
blk = chord.astype(np.float32)
382387
blk = self._apply_sound_effects(blk, base_frequency)
383-
return self._to_bytes(blk)
388+
audio_bytes = self._to_bytes(blk)
389+
logger.debug(f" Chord generated: {len(audio_bytes)} bytes")
390+
return audio_bytes
384391

385392
def play(self, note: str, note_duration: float | str = 1 / 4, volume: float = None) -> bytes:
386393
"""
@@ -394,12 +401,15 @@ def play(self, note: str, note_duration: float | str = 1 / 4, volume: float = No
394401
"""
395402
duration = self._note_duration(note_duration)
396403
frequency = self._get_note(note)
404+
logger.debug(f"play: note={note}, note_duration={note_duration}, duration={duration}s, frequency={frequency}Hz, volume={volume}")
397405
if frequency is not None and frequency >= 0.0:
398406
if volume is None:
399407
volume = self._master_volume
400408
data = self._wave_gen.generate_block(float(frequency), duration, volume)
401409
data = self._apply_sound_effects(data, frequency)
402-
return self._to_bytes(data)
410+
audio_bytes = self._to_bytes(data)
411+
logger.debug(f" Generated audio: {len(audio_bytes)} bytes")
412+
return audio_bytes
403413

404414
def play_tone(self, note: str, duration: float = 0.25, volume: float = None) -> bytes:
405415
"""
@@ -506,11 +516,26 @@ def __init__(
506516
self._started = threading.Event()
507517
if output_device is None:
508518
self.external_speaker = False
509-
self._output_device = Speaker(sample_rate=self.SAMPLE_RATE, format="FLOAT_LE")
519+
# Configure periodsize and queue for very responsive stop operations
520+
# Use 62.5ms periods (1000 frames @ 16kHz) for quick response to stop commands
521+
# Very small queue (maxsize=3) = ~190ms total buffer for ultra-responsive stop
522+
period_size = int(self.SAMPLE_RATE * 0.0625) # 1000 frames = 62.5ms
523+
self._output_device = Speaker(
524+
sample_rate=self.SAMPLE_RATE,
525+
format="FLOAT_LE",
526+
periodsize=period_size,
527+
queue_maxsize=3, # Ultra-low latency: 3 × 62.5ms = ~190ms max buffer
528+
)
510529
else:
511530
self.external_speaker = True
512531
self._output_device = output_device
513532

533+
# Step sequencer state
534+
self._sequence_thread = None
535+
self._sequence_stop_event = threading.Event()
536+
self._sequence_lock = threading.Lock()
537+
self._playback_session_id = 0 # Incremented each playback to invalidate stale callbacks
538+
514539
def start(self):
515540
if self._started.is_set():
516541
return
@@ -565,8 +590,10 @@ def play_chord(self, notes: list[str], note_duration: float | str = 1 / 4, volum
565590
volume (float, optional): Volume level (0.0 to 1.0). If None, uses master volume.
566591
block (bool): If True, block until the entire chord has been played.
567592
"""
593+
logger.debug(f"SoundGenerator.play_chord: notes={notes}, block_on_queue=False")
568594
blk = super().play_chord(notes, note_duration, volume)
569595
self._output_device.play(blk, block_on_queue=False)
596+
logger.debug(f" Audio sent to device queue")
570597
if block:
571598
duration = self._note_duration(note_duration)
572599
if duration > 0.0:
@@ -581,8 +608,10 @@ def play(self, note: str, note_duration: float | str = 1 / 4, volume: float = No
581608
volume (float, optional): Volume level (0.0 to 1.0). If None, uses master volume.
582609
block (bool): If True, block until the entire note has been played.
583610
"""
611+
logger.debug(f"SoundGenerator.play: note={note}, block_on_queue=False")
584612
data = super().play(note, note_duration, volume)
585613
self._output_device.play(data, block_on_queue=False)
614+
logger.debug(f" Audio sent to device queue")
586615
if block:
587616
duration = self._note_duration(note_duration)
588617
if duration > 0.0:
@@ -637,3 +666,179 @@ def clear_playback_queue(self):
637666
Clear the playback queue of the output device.
638667
"""
639668
self._output_device.clear_playback_queue()
669+
670+
def play_step_sequence(
671+
self,
672+
sequence: list[list[str]],
673+
note_duration: float | str = 1 / 8,
674+
bpm: int = None,
675+
loop: bool = False,
676+
on_step_callback: callable = None,
677+
on_complete_callback: callable = None,
678+
volume: float = None,
679+
):
680+
"""
681+
Play a step sequence with automatic timing, pre-buffering, and lookahead.
682+
This method handles all the complexity of buffer management internally,
683+
allowing the app to simply provide the sequence and let the brick manage playback.
684+
685+
Args:
686+
sequence (list[list[str]]): List of steps, where each step is a list of notes.
687+
Empty list or None means REST (silence) for that step.
688+
Example: [['C4'], ['E4', 'G4'], [], ['C5']]
689+
note_duration (float | str): Duration of each step as a float (like 1/8) or symbol ('E', 'Q', etc.).
690+
bpm (int, optional): Tempo in beats per minute. If None, uses instance BPM.
691+
loop (bool): If True, the sequence will loop indefinitely until stop_sequence() is called.
692+
on_step_callback (callable, optional): Callback function called for each step.
693+
Signature: on_step_callback(current_step: int, total_steps: int)
694+
on_complete_callback (callable, optional): Callback function called when sequence completes (only if loop=False).
695+
Signature: on_complete_callback()
696+
volume (float, optional): Volume level (0.0 to 1.0). If None, uses master volume.
697+
698+
Returns:
699+
None: Returns immediately after starting playback thread.
700+
701+
Example:
702+
```python
703+
# Simple melody with chords
704+
sequence = [
705+
["C4"], # Step 0: Single note
706+
["E4", "G4"], # Step 1: Chord
707+
[], # Step 2: REST
708+
["C5"], # Step 3: High note
709+
]
710+
sound_gen.play_step_sequence(sequence, note_duration=1 / 8, bpm=120)
711+
```
712+
"""
713+
# Stop any existing sequence
714+
self.stop_sequence()
715+
716+
# Use instance BPM if not specified
717+
if bpm is None:
718+
bpm = self._bpm
719+
720+
# Validate sequence
721+
if not sequence or len(sequence) == 0:
722+
logger.warning("play_step_sequence: Empty sequence provided")
723+
return
724+
725+
# Start playback thread with new session ID
726+
self._sequence_stop_event.clear()
727+
self._playback_session_id += 1
728+
session_id = self._playback_session_id
729+
self._sequence_thread = threading.Thread(
730+
target=self._playback_sequence_thread,
731+
args=(sequence, note_duration, bpm, loop, on_step_callback, on_complete_callback, volume, session_id),
732+
daemon=True,
733+
name="SoundGen-StepSeq",
734+
)
735+
self._sequence_thread.start()
736+
logger.info(f"Step sequence started: {len(sequence)} steps at {bpm} BPM (session {session_id})")
737+
738+
def stop_sequence(self):
739+
"""
740+
Stop the currently playing step sequence.
741+
This method signals the playback thread to stop and clears the queue immediately.
742+
The thread will detect the stop signal and exit at the next check point.
743+
"""
744+
logger.info("stop_sequence() called")
745+
with self._sequence_lock:
746+
if self._sequence_thread and self._sequence_thread.is_alive():
747+
logger.info("Stopping step sequence playback - calling drop_playback()")
748+
# Increment session ID to invalidate the running thread immediately
749+
self._playback_session_id += 1
750+
self._sequence_stop_event.set()
751+
# Clear reference immediately - thread will clean itself up
752+
self._sequence_thread = None
753+
self._output_device.drop_playback()
754+
else:
755+
logger.warning("stop_sequence called but no active sequence thread")
756+
757+
def is_sequence_playing(self) -> bool:
758+
"""
759+
Check if a step sequence is currently playing.
760+
761+
Returns:
762+
bool: True if a sequence is playing, False otherwise.
763+
"""
764+
with self._sequence_lock:
765+
return self._sequence_thread is not None and self._sequence_thread.is_alive()
766+
767+
def _playback_sequence_thread(
768+
self,
769+
sequence: list[list[str]],
770+
note_duration: float | str,
771+
bpm: int,
772+
loop: bool,
773+
on_step_callback: callable,
774+
on_complete_callback: callable,
775+
volume: float,
776+
session_id: int,
777+
):
778+
"""Internal thread for step sequence playback.
779+
780+
Simple approach: generate step-by-step, use block_on_queue=True for natural
781+
synchronization with ALSA consumption. Callbacks are emitted immediately after
782+
queuing each step, ensuring perfect sync with audio playback.
783+
"""
784+
from itertools import cycle
785+
import numpy as np
786+
787+
try:
788+
duration = self._note_duration(note_duration)
789+
total_steps = len(sequence)
790+
791+
logger.info(f"Starting sequence: {total_steps} steps at {bpm} BPM")
792+
793+
# PRE-FILL: Queue one period of silence to prevent first-note underrun
794+
# This gives ALSA something to consume while we generate the first real note
795+
silence_frames = int(duration * self._output_device.sample_rate)
796+
silence = np.zeros(silence_frames, dtype=np.float32).tobytes()
797+
self._output_device.play(silence, block_on_queue=False)
798+
logger.debug(f"Pre-filled queue with {len(silence)} bytes of silence")
799+
800+
# Create infinite iterator if looping, otherwise single pass
801+
step_iterator = cycle(enumerate(sequence)) if loop else enumerate(sequence)
802+
803+
for step_index, notes in step_iterator:
804+
# Check for stop signal
805+
if self._sequence_stop_event.is_set():
806+
logger.debug(f"Sequence stopped at step {step_index}")
807+
break
808+
809+
# Generate audio for this step
810+
if notes and len(notes) > 0:
811+
if len(notes) == 1:
812+
data = super(SoundGenerator, self).play(notes[0], note_duration, volume)
813+
else:
814+
data = super(SoundGenerator, self).play_chord(notes, note_duration, volume)
815+
else:
816+
# REST: silence
817+
data = super(SoundGenerator, self).play("REST", note_duration, volume)
818+
819+
# Queue audio - BLOCKS until there's space (natural sync with ALSA!)
820+
if data:
821+
self._output_device.play(data, block_on_queue=True)
822+
823+
# Emit callback IMMEDIATELY after queuing
824+
# This is synchronized with actual playback timing via blocking
825+
if on_step_callback:
826+
try:
827+
on_step_callback(step_index, total_steps)
828+
except Exception as e:
829+
logger.error(f"Error in step callback: {e}")
830+
831+
logger.info("Sequence playback ended")
832+
833+
# Call completion callback if provided and not looping
834+
if not loop and on_complete_callback:
835+
try:
836+
on_complete_callback()
837+
except Exception as e:
838+
logger.error(f"Error in complete callback: {e}")
839+
840+
except Exception as e:
841+
logger.error(f"Error in sequence playback: {e}", exc_info=True)
842+
finally:
843+
with self._sequence_lock:
844+
self._sequence_thread = None

src/arduino/app_peripherals/speaker/__init__.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@
77
import threading
88
import queue
99
import re
10+
import logging
1011
from arduino.app_utils import Logger
1112

12-
logger = Logger("Speaker")
13+
logger = Logger("Speaker", logging.DEBUG)
1314

1415

1516
class SpeakerException(Exception):
@@ -97,6 +98,7 @@ def __init__(
9798
self._pcm_lock = threading.Lock()
9899
self._native_rate = None
99100
self._is_reproducing = threading.Event()
101+
self._is_dropping = threading.Event() # Signal to playback loop to pause during drop
100102
self._periodsize = periodsize # Store configured periodsize (None = hardware default)
101103
self._playing_queue: bytes = queue.Queue(maxsize=queue_maxsize) # Queue for audio data to play with limited capacity
102104
self.device = self._resolve_device(device)
@@ -432,6 +434,7 @@ def _playback_loop(self):
432434
try:
433435
data = self._playing_queue.get(timeout=1) # Wait for audio data
434436
if data is None:
437+
logger.debug("Got None from queue, skipping")
435438
continue # Skip if no data is available
436439

437440
# Check queue depth periodically
@@ -444,7 +447,14 @@ def _playback_loop(self):
444447
with self._pcm_lock:
445448
if self._pcm is not None:
446449
try:
450+
# Skip writing if drop is in progress
451+
if self._is_dropping.is_set():
452+
logger.debug("Skipping PCM write during drop operation")
453+
continue
454+
455+
logger.debug(f"Writing {len(data)} bytes to PCM device")
447456
written = self._pcm.write(data)
457+
logger.debug(f"Successfully wrote {len(data)} bytes to PCM device")
448458

449459
# Check for ALSA errors (negative return values)
450460
if written < 0:
@@ -516,3 +526,28 @@ def is_reproducing(self) -> bool:
516526
def clear_playback_queue(self):
517527
"""Clear the playback queue."""
518528
self._clear_queue()
529+
530+
def drop_playback(self):
531+
"""Drop all pending audio data immediately (both queue and hardware buffer).
532+
533+
This method clears both the software queue and the ALSA hardware buffer,
534+
stopping audio playback immediately. Use this for responsive stop operations.
535+
"""
536+
# Signal playback loop to stop writing temporarily
537+
self._is_dropping.set()
538+
539+
# Clear software queue first
540+
self._clear_queue()
541+
542+
# Then drop ALSA hardware buffer
543+
with self._pcm_lock:
544+
if self._pcm is not None:
545+
try:
546+
self._pcm.drop() # Immediately stop PCM, drop pending frames
547+
logger.debug("ALSA PCM buffer dropped")
548+
except Exception as e:
549+
logger.warning(f"Failed to drop PCM buffer: {e}")
550+
551+
# Allow playback to resume
552+
self._is_dropping.clear()
553+
logger.debug("Playback queue and PCM buffer cleared")

0 commit comments

Comments
 (0)