feat(audio): move beat routing server-side and extend presets

Route beat-triggered manual selects from the controller server, add preset background and beat-counter UI support, and bump led-driver to include the matching pattern/runtime fixes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-09 20:08:05 +12:00
parent 1db905eaae
commit 822d9d8e01
21 changed files with 2453 additions and 109 deletions

282
src/util/audio_detector.py Normal file
View File

@@ -0,0 +1,282 @@
import collections
import importlib.util
import os
import queue
import threading
import time
class AudioBeatDetector:
def __init__(self):
self._lock = threading.Lock()
self._thread = None
self._stream = None
self._running = False
self._stop_event = threading.Event()
self._status = {
"running": False,
"bpm": None,
"last_beat_ts": None,
"beat_seq": 0,
"beat_type": "unknown",
"beat_type_confidence": 0.0,
"error": None,
"device": None,
}
def list_input_devices(self):
import sounddevice as sd
devices = sd.query_devices()
hostapis = sd.query_hostapis()
default_input_idx = None
try:
default_input_idx = int(sd.default.device[0])
except Exception:
default_input_idx = None
out = []
for idx, dev in enumerate(devices):
name = str(dev.get("name", f"Input {idx}"))
chans = int(dev.get("max_input_channels", 0))
is_monitor_named = "monitor" in name.lower()
if chans <= 0 and not is_monitor_named:
continue
sr = int(dev.get("default_samplerate", 44100))
hostapi_idx = int(dev.get("hostapi", -1))
hostapi_name = (
str(hostapis[hostapi_idx].get("name", "unknown"))
if 0 <= hostapi_idx < len(hostapis)
else "unknown"
)
is_default = default_input_idx is not None and idx == default_input_idx
ch_label = f"{chans}ch" if chans > 0 else "0ch?"
label = f"[{idx}] {name} ({ch_label} @ {sr}Hz, {hostapi_name})"
if is_default:
label = f"{label} [default]"
if is_monitor_named:
label = f"{label} [monitor]"
out.append(
{
"id": idx,
"name": name,
"label": label,
"max_input_channels": chans,
"default_samplerate": sr,
"is_default": is_default,
"hostapi": hostapi_name,
}
)
return out
def diagnostics(self):
import sounddevice as sd
devices = sd.query_devices()
hostapis = sd.query_hostapis()
default_input = None
try:
default_input = sd.default.device[0]
except Exception:
default_input = None
return {
"default_input": default_input,
"hostapis": hostapis,
"devices": devices,
}
def start(self, device=None):
should_restart = False
with self._lock:
should_restart = self._running
if should_restart:
self.stop()
with self._lock:
self._stop_event.clear()
self._status.update(
{
"running": True,
"bpm": None,
"last_beat_ts": None,
"beat_seq": 0,
"beat_type": "unknown",
"beat_type_confidence": 0.0,
"error": None,
"device": device,
}
)
self._running = True
self._thread = threading.Thread(
target=self._run_loop, args=(device,), daemon=True
)
self._thread.start()
def stop(self):
with self._lock:
self._stop_event.set()
t = self._thread
stream = self._stream
try:
import sounddevice as sd
sd.stop(ignore_errors=True)
except Exception:
pass
if stream is not None:
try:
stream.abort()
except Exception:
pass
try:
stream.stop()
except Exception:
pass
try:
stream.close()
except Exception:
pass
if t and t.is_alive():
t.join(timeout=3.0)
with self._lock:
self._running = False
self._thread = None
self._stream = None
self._status["running"] = False
def status(self):
with self._lock:
return dict(self._status)
def _set_error(self, msg):
print(f"[audio] {msg}")
with self._lock:
self._status["error"] = msg
self._status["running"] = False
self._running = False
def _record_beat(self, bpm, beat_type="unknown", beat_type_confidence=0.0):
now = time.time()
with self._lock:
self._status["last_beat_ts"] = now
self._status["bpm"] = bpm
self._status["beat_type"] = beat_type
self._status["beat_type_confidence"] = float(beat_type_confidence or 0.0)
self._status["beat_seq"] = int(self._status.get("beat_seq", 0)) + 1
try:
from util.beat_driver_route import notify_beat_detected
notify_beat_detected()
except Exception as e:
print(f"[audio] beat driver route: {e}")
def _run_loop(self, device):
try:
import argparse
import numpy as np
import sounddevice as sd
except Exception as e:
self._set_error(f"audio deps unavailable: {e}")
return
try:
root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
beat_detect_path = os.path.join(root_dir, "tests", "beat_detect.py")
spec = importlib.util.spec_from_file_location("beat_detect_runtime", beat_detect_path)
if spec is None or spec.loader is None:
raise RuntimeError("cannot load tests/beat_detect.py")
beat_mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(beat_mod)
if device is None:
try:
device = int(sd.default.device[0])
except Exception:
device = -1
if device is None or device < 0:
raise RuntimeError(
"no default input device; open Audio, pick an input, then Start"
)
dev_info = sd.query_devices(device, "input")
sample_rate = int(dev_info["default_samplerate"])
args = argparse.Namespace(
mode="aubio",
device=device,
sample_rate=sample_rate,
hop_size=256,
win_mult=2,
min_band_hz=45.0,
max_band_hz=180.0,
energy_weight=0.7,
flux_weight=0.3,
threshold_multiplier=1.35,
ema_alpha=0.08,
min_ioi_ms=85.0,
bpm_window=8,
post_url="",
aubio_method="default",
aubio_threshold=0.12,
silence_gate_db=-58.0,
)
runtime = beat_mod.BeatDetectRuntime(args)
runtime.setup(sample_rate=sample_rate)
hop_size = runtime.frame_size
audio_q = queue.Queue(maxsize=64)
def callback(indata, frames, _time_info, status):
_ = frames
if status:
print(f"[audio] status: {status}")
mono = np.asarray(indata[:, 0], dtype=np.float32)
if not audio_q.full():
audio_q.put_nowait(mono)
stream = sd.InputStream(
device=device,
channels=1,
samplerate=sample_rate,
blocksize=hop_size,
callback=callback,
)
with self._lock:
self._stream = stream
stream.start()
try:
while not self._stop_event.is_set():
try:
frame = audio_q.get(timeout=0.1)
except queue.Empty:
continue
if frame.shape[0] != hop_size:
if frame.shape[0] > hop_size:
frame = frame[:hop_size]
else:
frame = np.pad(frame, (0, hop_size - frame.shape[0]))
event = runtime.process_frame(frame, now_s=time.time())
if event is None:
continue
bpm = event.get("bpm")
self._record_beat(
bpm,
beat_type=event.get("beat_type", "unknown"),
beat_type_confidence=event.get("beat_type_confidence", 0.0),
)
finally:
try:
stream.stop()
except Exception:
pass
try:
stream.close()
except Exception:
pass
with self._lock:
if self._stream is stream:
self._stream = None
except Exception as e:
self._set_error(f"detector failed: {e}")
return
finally:
with self._lock:
self._running = False
self._status["running"] = False