22#
33# SPDX-License-Identifier: MPL-2.0
44
5- from arduino .app_utils import brick
5+ from arduino .app_utils import brick , Logger
66from arduino .app_peripherals .speaker import Speaker
77import threading
88from typing import Iterable
99import numpy as np
1010import time
11+ import logging
1112from pathlib import Path
1213from collections import OrderedDict
1314
1415from .generator import WaveSamplesBuilder
1516from .effects import *
1617from .loaders import ABCNotationLoader
1718
19+ logger = Logger ("SoundGenerator" , logging .DEBUG )
20+
1821
1922class 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
0 commit comments