feat(02-05): implement BeatDetector — AUD-03 and AUD-04 fulfilled

- 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
This commit is contained in:
Claude
2026-04-03 14:51:11 +02:00
parent ea59bb0e27
commit f1af7ba175
2 changed files with 152 additions and 1 deletions

View File

@@ -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)