From f1af7ba1759c9078e6f643f0dd870b6dbe388789 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 14:51:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(02-05):=20implement=20BeatDetector=20?= =?UTF-8?q?=E2=80=94=20AUD-03=20and=20AUD-04=20fulfilled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sounddevice InputStream at 44100Hz/512-sample blocks (~11.6ms latency) - aubio.tempo beat detection with call_soon_threadsafe bridge (D-05) - Thread-safe beat_count and bpm properties via threading.Lock (D-04) - Double-trigger suppression with 200ms hold period - ImportError wrapped in RuntimeError with clear message - BeatDetector exported from led_sync.audio package --- src/led_sync/audio/__init__.py | 3 +- src/led_sync/audio/beat_detector.py | 150 ++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 src/led_sync/audio/beat_detector.py diff --git a/src/led_sync/audio/__init__.py b/src/led_sync/audio/__init__.py index 71cfa67..f34d021 100644 --- a/src/led_sync/audio/__init__.py +++ b/src/led_sync/audio/__init__.py @@ -1,3 +1,4 @@ from .player import AudioPlayer +from .beat_detector import BeatDetector -__all__ = ["AudioPlayer"] +__all__ = ["AudioPlayer", "BeatDetector"] diff --git a/src/led_sync/audio/beat_detector.py b/src/led_sync/audio/beat_detector.py new file mode 100644 index 0000000..03948af --- /dev/null +++ b/src/led_sync/audio/beat_detector.py @@ -0,0 +1,150 @@ +""" +BeatDetector: real-time beat/onset detection via sounddevice + aubio. +Implements AUD-03 (system audio capture) and AUD-04 (beat detection). + +Threading (D-04/D-05): + sounddevice _audio_callback runs on OS audio thread. + Beat events bridged to asyncio via loop.call_soon_threadsafe(). + NEVER call asyncio APIs directly from _audio_callback. + +PipeWire (D-06): + device=None -> PipeWire default input (usually mic). + For system audio loopback, pass the monitor source name: + e.g. device="alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" + List devices: python -c "import sounddevice; print(sounddevice.query_devices())" +""" +import asyncio +import logging +import threading +import time +from typing import Callable, Coroutine + +logger = logging.getLogger(__name__) + +SAMPLE_RATE = 44100 +HOP_SIZE = 512 # latency = 512/44100 ≈ 11ms +BUF_SIZE = 1024 # aubio FFT window +BEAT_HOLD_MS = 200 # suppress double-triggers within 200ms + + +class BeatDetector: + """ + Real-time beat detector. Runs sounddevice InputStream on OS audio thread. + Posts beat events into asyncio event loop via call_soon_threadsafe (D-05). + + Usage: + detector = BeatDetector(loop=asyncio.get_running_loop(), on_beat=my_async_fn) + detector.start() # starts audio capture + ... + detector.stop() # stops capture, closes stream + """ + + def __init__( + self, + loop: asyncio.AbstractEventLoop, + on_beat: Callable[[float], Coroutine], + ): + self._loop = loop + self._on_beat = on_beat + self._stream = None + self._lock = threading.Lock() + self._beat_count: int = 0 + self._bpm: float = 0.0 + self._last_beat_time: float = 0.0 # for hold suppression + self._tempo = None # aubio tempo object; created in start() + + @property + def beat_count(self) -> int: + with self._lock: + return self._beat_count + + @property + def bpm(self) -> float: + with self._lock: + return self._bpm + + def start(self, device=None) -> None: + """ + Open sounddevice InputStream and begin beat detection. + device=None uses PipeWire default. Pass monitor source name for loopback. + Raises RuntimeError if sounddevice or aubio is unavailable. + """ + try: + import sounddevice as sd + import aubio + import numpy as np + except ImportError as e: + raise RuntimeError( + f"Beat detection requires sounddevice, aubio, and numpy: {e}" + ) from e + + # Create aubio tempo detector + self._tempo = aubio.tempo( + method="default", + buf_size=BUF_SIZE, + hop_size=HOP_SIZE, + samplerate=SAMPLE_RATE, + ) + + # Capture hold time in seconds for double-trigger suppression + self._hold_seconds = BEAT_HOLD_MS / 1000.0 + self._last_beat_time = 0.0 + + def _audio_callback(indata, frames, time_info, status): + """ + Called on OS audio thread by sounddevice. + MUST NOT call asyncio APIs directly (D-05). + """ + if status: + logger.debug("sounddevice status: %s", status) + + # Mono float32 samples for aubio + samples = indata[:, 0].astype("float32") + is_beat = self._tempo(samples) + + if is_beat[0]: + beat_time = time_info.inputBufferAdcTime + # Double-trigger suppression + now = time.monotonic() + with self._lock: + if now - self._last_beat_time < self._hold_seconds: + return + self._last_beat_time = now + self._beat_count += 1 + self._bpm = float(self._tempo.get_bpm()) + + logger.debug("Beat detected at %.3fs, BPM=%.1f", beat_time, self._bpm) + + # Bridge to asyncio: non-blocking, thread-safe (D-05) + self._loop.call_soon_threadsafe( + self._loop.create_task, + self._on_beat(beat_time), + ) + + try: + self._stream = sd.InputStream( + samplerate=SAMPLE_RATE, + blocksize=HOP_SIZE, + channels=1, + dtype="float32", + callback=_audio_callback, + device=device, + ) + self._stream.start() + logger.info( + "BeatDetector started: device=%s, sample_rate=%d, hop=%d (%.1fms latency)", + device, SAMPLE_RATE, HOP_SIZE, HOP_SIZE / SAMPLE_RATE * 1000, + ) + except Exception as e: + raise RuntimeError(f"Failed to open audio input stream: {e}") from e + + def stop(self) -> None: + """Stop beat detection and close the sounddevice stream.""" + if self._stream is not None: + try: + self._stream.stop() + self._stream.close() + except Exception: + pass + self._stream = None + logger.info("BeatDetector stopped (total beats: %d)", self.beat_count)