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:
150
src/led_sync/audio/beat_detector.py
Normal file
150
src/led_sync/audio/beat_detector.py
Normal 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)
|
||||
Reference in New Issue
Block a user