- 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
151 lines
5.1 KiB
Python
151 lines
5.1 KiB
Python
"""
|
|
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)
|