feat(bridge): add wifi/serial bridge runtime and UI

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-28 00:38:21 +12:00
parent 2cf019079e
commit 78dc8ffc77
92 changed files with 5679 additions and 1790 deletions

View File

@@ -10,6 +10,9 @@ from typing import Any
_HOLDOVER_BPM_MIN = 30.0
_HOLDOVER_BPM_MAX = 300.0
_HOLDOVER_MAX_S = 300.0
# After this many seconds without a detected beat, re-prime aubio and start BPM holdover
# (same window as status() uses to hide stale BPM).
_SILENCE_GAP_S = 4.0
class AudioBeatDetector:
@@ -24,6 +27,8 @@ class AudioBeatDetector:
self._holdover_thread: threading.Thread | None = None
self._holdover_stop = threading.Event()
self._holdover_active = False
self._last_real_beat_ts: float | None = None
self._last_gap_tempo_reset_ts: float = 0.0
self._status = {
"running": False,
"bpm": None,
@@ -38,9 +43,36 @@ class AudioBeatDetector:
"bar_phase_readout": "1/4",
"error": None,
"device": None,
"input_level": 0.0,
}
def list_input_devices(self):
try:
from util.pulse_audio_devices import list_pulse_matched_input_devices
pulse = list_pulse_matched_input_devices()
if pulse:
return pulse
except Exception as e:
print(f"[audio] pulse device list skipped: {e!r}")
sd_list = self._list_sounddevice_input_devices()
if sd_list:
print("[audio] device list: sounddevice fallback (install/use pactl for Pulse names)")
return sd_list
@staticmethod
def _skip_sounddevice_virtual(name: str, hostapi_name: str) -> bool:
"""Hide PortAudio/Pulse aggregate devices (pipewire, pulse, default)."""
n = name.strip().lower()
if n in ("pipewire", "pulse", "default", "sysdefault"):
return True
ha = hostapi_name.strip().lower()
if ha in ("pulse", "pipewire") and n in ("default", "pipewire", "pulse"):
return True
return False
def _list_sounddevice_input_devices(self):
import sounddevice as sd
devices = sd.query_devices()
@@ -55,15 +87,17 @@ class AudioBeatDetector:
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"
)
if self._skip_sounddevice_virtual(name, hostapi_name):
continue
if chans <= 0 and not is_monitor_named:
continue
sr = int(dev.get("default_samplerate", 44100))
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})"
@@ -71,10 +105,14 @@ class AudioBeatDetector:
label = f"{label} [default]"
if is_monitor_named:
label = f"{label} [monitor]"
display_name = name
if is_default:
display_name = f"{display_name} (default)"
out.append(
{
"id": idx,
"name": name,
"display_name": display_name,
"label": label,
"max_input_channels": chans,
"default_samplerate": sr,
@@ -101,6 +139,13 @@ class AudioBeatDetector:
}
def start(self, device=None):
try:
from util.pulse_audio_devices import resolve_capture_device
device = resolve_capture_device(device)
except Exception as e:
self._set_error(str(e))
raise
should_restart = False
with self._lock:
should_restart = self._running
@@ -108,6 +153,8 @@ class AudioBeatDetector:
self.stop()
with self._lock:
self._stop_event.clear()
self._last_real_beat_ts = None
self._last_gap_tempo_reset_ts = 0.0
self._status.update(
{
"running": True,
@@ -162,7 +209,42 @@ class AudioBeatDetector:
self._thread = None
self._stream = None
self._pending_reset = False
self._last_real_beat_ts = None
self._last_gap_tempo_reset_ts = 0.0
self._status["running"] = False
self._status["input_level"] = 0.0
def _update_input_level(self, mono) -> None:
import numpy as np
arr = np.asarray(mono, dtype=np.float32)
if arr.size == 0:
inst = 0.0
else:
peak = float(np.max(np.abs(arr)))
rms = float(np.sqrt(np.mean(arr * arr)))
inst = min(1.0, max(peak, rms * 2.0))
with self._lock:
prev = float(self._status.get("input_level") or 0.0)
if inst >= prev:
self._status["input_level"] = inst
else:
self._status["input_level"] = max(inst, prev * 0.82)
def _decay_input_level(self) -> None:
with self._lock:
prev = float(self._status.get("input_level") or 0.0)
self._status["input_level"] = prev * 0.82
def _input_gain(self) -> float:
try:
from settings import get_settings
vol = int(get_settings().get("audio_input_volume") or 100)
except (TypeError, ValueError, ImportError):
vol = 100
vol = max(0, min(200, vol))
return vol / 100.0
def status(self):
with self._lock:
@@ -342,10 +424,47 @@ class AudioBeatDetector:
print(f"[audio] anchor_bar_phase: {e}")
return False
def _maybe_recover_after_silence_gap(self, runtime) -> None:
"""After a quiet spell, reset tempo tracking and run holdover until real beats return."""
now = time.time()
with self._lock:
if not self._running:
return
last_real = self._last_real_beat_ts
bpm = self._clamp_holdover_bpm(self._status.get("bpm"))
holdover = self._holdover_active
last_reset = self._last_gap_tempo_reset_ts
if last_real is None or bpm is None:
return
try:
gap = now - float(last_real)
except (TypeError, ValueError):
return
if gap < _SILENCE_GAP_S:
return
if not holdover:
self._start_bpm_holdover(bpm)
try:
since_reset = (
now - float(last_reset) if last_reset else _SILENCE_GAP_S
)
except (TypeError, ValueError):
since_reset = _SILENCE_GAP_S
if since_reset >= _SILENCE_GAP_S:
try:
runtime.reset_tempo_state()
except Exception as e:
print(f"[audio] silence gap tempo reset: {e}")
else:
with self._lock:
self._last_gap_tempo_reset_ts = now
def _record_beat(self, bpm, beat_type="unknown", beat_type_confidence=0.0, **phase_fields):
self._stop_bpm_holdover()
now = time.time()
self._last_real_beat_ts = now
with self._lock:
self._last_gap_tempo_reset_ts = 0.0
self._status["last_beat_ts"] = now
self._status["bpm"] = bpm
self._status["beat_type"] = beat_type
@@ -386,6 +505,9 @@ class AudioBeatDetector:
beat_mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(beat_mod)
from util.pulse_audio_devices import resolve_capture_device
device = resolve_capture_device(device)
if device is None:
try:
device = int(sd.default.device[0])
@@ -395,6 +517,10 @@ class AudioBeatDetector:
raise RuntimeError(
"no default input device; open Audio, pick an input, then Start"
)
if not isinstance(device, int):
raise RuntimeError(
f"internal error: unresolved capture device {device!r}"
)
dev_info = sd.query_devices(device, "input")
sample_rate = int(dev_info["default_samplerate"])
@@ -450,6 +576,8 @@ class AudioBeatDetector:
try:
frame = audio_q.get(timeout=0.1)
except queue.Empty:
self._decay_input_level()
self._maybe_recover_after_silence_gap(runtime)
continue
self._process_pending_reset(runtime)
if frame.shape[0] != hop_size:
@@ -457,8 +585,13 @@ class AudioBeatDetector:
frame = frame[:hop_size]
else:
frame = np.pad(frame, (0, hop_size - frame.shape[0]))
gain = self._input_gain()
if gain != 1.0:
frame = frame * gain
self._update_input_level(frame)
event = runtime.process_frame(frame, now_s=time.time())
if event is None:
self._maybe_recover_after_silence_gap(runtime)
continue
bpm = event.get("bpm")
self._record_beat(