Files
TuxDMX-WebUI/backend/app/bpm/service.py
T
thomas 1f110866f5
CI / backend (pull_request) Canceled after 0s
CI / shell (pull_request) Canceled after 0s
CI / frontend (pull_request) Canceled after 0s
CI / arm64-smoke (pull_request) Canceled after 0s
CI / backend (push) Canceled after 0s
CI / shell (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / arm64-smoke (push) Canceled after 0s
Update docs and screenshots
2026-07-25 10:26:29 +02:00

620 lines
24 KiB
Python

from __future__ import annotations
import asyncio
import math
import sys
from array import array
from collections import deque
from dataclasses import dataclass
from datetime import UTC, datetime
from statistics import fmean, median
from time import monotonic
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.core.database import SessionLocal
from app.models.entities import Setting
BPM_AUDIO_SETTING_KEY = "bpm_audio"
AUTO_ALSA_DEVICE = "alsa:auto"
SB_ALIAS_DEVICE = "alsa:plughw:CARD=SB,DEV=0"
SB_FALLBACK_DEVICE = "alsa:plughw:1,0"
DEFAULT_ALSA_DEVICE = "alsa:default"
DEFAULT_SAMPLE_RATE = 44_100
DEFAULT_CHANNELS = 1
DEFAULT_AUDIO_FORMAT = "S16_LE"
@dataclass(slots=True)
class AudioDevice:
id: str
name: str
backend: str
is_default: bool = False
recommended: bool = False
class BeatAnalyzer:
def __init__(self, sample_rate: int = DEFAULT_SAMPLE_RATE, window_size: int = 1024) -> None:
self.sample_rate = sample_rate
self.window_size = window_size
self._buffer = array("h")
self._processed_samples = 0
self._last_trigger_sample = -sample_rate
self._energy_history: deque[float] = deque(maxlen=48)
self._beat_times: deque[float] = deque(maxlen=16)
self.bpm = 120.0
self.confidence = 0.0
self.last_beat_at: float | None = None
self.current_level = 0.0
self.peak_level = 0.0
self.clipping = False
def feed_pcm16(self, chunk: bytes) -> tuple[float, float] | None:
samples = array("h")
samples.frombytes(chunk[: len(chunk) - (len(chunk) % 2)])
if sys.byteorder != "little":
samples.byteswap()
self._buffer.extend(samples)
updated = False
while len(self._buffer) >= self.window_size:
window = self._buffer[: self.window_size]
del self._buffer[: self.window_size]
updated = self._process_window(window) or updated
self._processed_samples += len(window)
if updated:
return self.bpm, self.confidence
return None
def _process_window(self, window: array[int]) -> bool:
if not window:
return False
energy = math.sqrt(sum(sample * sample for sample in window) / len(window)) / 32768.0
peak = max(abs(sample) for sample in window) / 32768.0
self.current_level = round(max(max(0.0, min(1.0, energy)), self.current_level * 0.82), 4)
self.peak_level = round(max(peak, self.peak_level * 0.92), 4)
self.clipping = peak >= 0.985
baseline = fmean(self._energy_history) if self._energy_history else 0.03
threshold = max(0.08, baseline * 2.4)
self._energy_history.append(energy)
min_interval = int(self.sample_rate * 0.2)
if energy < threshold or self._processed_samples - self._last_trigger_sample < min_interval:
return False
timestamp = self._processed_samples / self.sample_rate
self._beat_times.append(timestamp)
self._last_trigger_sample = self._processed_samples
self.last_beat_at = timestamp
if len(self._beat_times) < 2:
return False
intervals = [
self._beat_times[index] - self._beat_times[index - 1]
for index in range(1, len(self._beat_times))
]
beat_seconds = median(intervals)
if beat_seconds <= 0:
return False
bpm = self._normalize_bpm(60.0 / beat_seconds)
jitter = 0.0
if len(intervals) > 1:
average = fmean(intervals)
jitter = fmean(abs(interval - average) for interval in intervals)
self.bpm = round(bpm, 2)
regularity = max(0.0, 1.0 - min(jitter / max(beat_seconds, 0.001), 1.0))
self.confidence = round(min(1.0, regularity * min(1.0, len(intervals) / 6)), 3)
return True
def _normalize_bpm(self, bpm: float) -> float:
while bpm < 70.0:
bpm *= 2.0
while bpm > 180.0:
bpm /= 2.0
return bpm
class BpmService:
def __init__(self, session_factory: async_sessionmaker[AsyncSession] = SessionLocal) -> None:
self._session_factory = session_factory
self.mode = "manual"
self.current_bpm = 120.0
self.confidence = 1.0
self.last_error: str | None = None
self.audio_connected = False
self.current_device = "manual"
self.selected_device = AUTO_ALSA_DEVICE
self.recommended_device = SB_ALIAS_DEVICE
self.beat_counter = 0
self.last_beat_detected_at: float | None = None
self._tap_times: list[float] = []
self._manual_bpm = 120.0
self._audio_task: asyncio.Task[None] | None = None
self._audio_process: asyncio.subprocess.Process | None = None
self._analyzer = BeatAnalyzer()
self._device_cache: list[dict[str, object]] = [
self._serialize_device(
AudioDevice(
id=AUTO_ALSA_DEVICE,
name="Auto (SB -> plughw:CARD=SB,DEV=0 -> plughw:1,0 -> default)",
backend="alsa",
is_default=True,
)
),
self._serialize_device(AudioDevice(id=DEFAULT_ALSA_DEVICE, name="ALSA default", backend="alsa")),
self._serialize_device(
AudioDevice(id="synthetic-click-track", name="Syntetisk click track", backend="synthetic")
),
]
async def startup(self) -> None:
await self._load_config()
await self.list_devices()
async def shutdown(self) -> None:
await self.stop_audio()
def set_manual(self, bpm: float) -> dict[str, object]:
self._manual_bpm = float(bpm)
self.mode = "manual"
self.current_device = "manual"
self.current_bpm = round(float(bpm), 2)
self.confidence = 1.0
self.audio_connected = False
self.last_error = None
self._cancel_audio_runtime()
return self.snapshot()
def tap(self) -> dict[str, object]:
now = monotonic()
self._tap_times = [value for value in self._tap_times if now - value < 4.0]
self._tap_times.append(now)
if len(self._tap_times) >= 2:
intervals = [
self._tap_times[index] - self._tap_times[index - 1]
for index in range(1, len(self._tap_times))
]
beat_seconds = median(intervals)
if beat_seconds > 0:
self.current_bpm = round(60 / beat_seconds, 2)
self._manual_bpm = self.current_bpm
self.mode = "tap"
self.current_device = "tap"
self.confidence = min(1.0, len(intervals) / 4)
self.audio_connected = False
self.last_error = None
self._mark_beat_event()
return self.snapshot()
async def start_audio(self, device: str = AUTO_ALSA_DEVICE) -> dict[str, object]:
await self.stop_audio()
self._analyzer = BeatAnalyzer()
normalized_device = self._normalize_device_id(device)
self.mode = f"audio:{normalized_device}"
self.current_device = "pending"
self.confidence = 0.0
self.audio_connected = False
self.last_error = None
if normalized_device == "synthetic-click-track":
self._audio_task = asyncio.create_task(self._synthetic_click_loop(), name="tuxdmx-bpm-synthetic")
return self.snapshot()
if normalized_device.startswith("alsa:"):
candidates = await self._resolve_alsa_candidates(normalized_device)
self._audio_task = asyncio.create_task(
self._alsa_capture_loop(candidates),
name="tuxdmx-bpm-alsa",
)
return self.snapshot()
raise LookupError(f"Ukendt BPM-device: {normalized_device}")
async def stop_audio(self) -> dict[str, object]:
self._cancel_audio_runtime()
if self._audio_task is not None:
try:
await self._audio_task
except asyncio.CancelledError:
pass
if self._audio_process is not None:
try:
await self._audio_process.wait()
except ProcessLookupError:
pass
self._audio_task = None
self._audio_process = None
self.audio_connected = False
if self.mode.startswith("audio:"):
self.mode = "manual"
self.current_device = "manual"
self.current_bpm = self._manual_bpm
self.confidence = 1.0
return self.snapshot()
async def get_config(self) -> dict[str, object]:
return {
"preferred_device": self.selected_device,
"recommended_device": self.recommended_device,
"sample_rate": DEFAULT_SAMPLE_RATE,
"channels": DEFAULT_CHANNELS,
"format": DEFAULT_AUDIO_FORMAT,
}
async def save_config(self, preferred_device: str) -> dict[str, object]:
self.selected_device = self._normalize_device_id(preferred_device)
await self.list_devices()
payload = await self.get_config()
async with self._session_factory() as session:
setting = await session.get(Setting, BPM_AUDIO_SETTING_KEY)
if setting is None:
setting = Setting(key=BPM_AUDIO_SETTING_KEY, value=payload, updated_at=datetime.now(UTC))
session.add(setting)
else:
setting.value = payload
setting.updated_at = datetime.now(UTC)
await session.commit()
return await self.get_config()
async def list_devices(self) -> list[dict[str, object]]:
devices: list[AudioDevice] = []
self._append_device(
devices,
AudioDevice(
id=AUTO_ALSA_DEVICE,
name="Auto (SB -> plughw:CARD=SB,DEV=0 -> plughw:1,0 -> default)",
backend="alsa",
is_default=True,
),
)
self._append_device(
devices,
AudioDevice(id="synthetic-click-track", name="Syntetisk click track", backend="synthetic"),
)
self._append_device(devices, AudioDevice(id=DEFAULT_ALSA_DEVICE, name="ALSA default", backend="alsa"))
for device in await self._list_alsa_hardware_devices():
self._append_device(devices, device)
try:
process = await asyncio.create_subprocess_exec(
"arecord",
"-L",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except (FileNotFoundError, NotImplementedError):
self.recommended_device = self._pick_recommended_device(devices)
for device in devices:
device.recommended = device.id == self.recommended_device
self._device_cache = [self._serialize_device(device) for device in devices]
return self._device_cache
stdout, _stderr = await process.communicate()
if process.returncode == 0:
for raw_line in stdout.decode("utf-8", errors="ignore").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or raw_line.startswith(" "):
continue
self._append_device(devices, AudioDevice(id=f"alsa:{line}", name=line, backend="alsa"))
self.recommended_device = self._pick_recommended_device(devices)
for device in devices:
device.recommended = device.id == self.recommended_device
self._device_cache = [self._serialize_device(device) for device in devices]
return self._device_cache
def snapshot(self) -> dict[str, object]:
return {
"mode": self.mode,
"bpm": round(self.current_bpm, 2),
"confidence": round(self.confidence, 3),
"devices": [str(device["id"]) for device in self._device_cache],
"device_details": self._device_cache,
"current_device": self.current_device,
"selected_device": self.selected_device,
"recommended_device": self.recommended_device,
"audio_connected": self.audio_connected,
"last_error": self.last_error,
"last_beat_at": self._analyzer.last_beat_at,
"last_beat_detected_at": self.last_beat_detected_at,
"beat_counter": self.beat_counter,
"input_level": round(self._analyzer.current_level, 4),
"peak_level": round(self._analyzer.peak_level, 4),
"clipping": self._analyzer.clipping,
"sample_rate": DEFAULT_SAMPLE_RATE,
"channels": DEFAULT_CHANNELS,
"format": DEFAULT_AUDIO_FORMAT,
}
async def _alsa_capture_loop(self, candidates: list[str]) -> None:
last_error: str | None = None
for candidate in candidates:
alsa_device = candidate.split(":", 1)[1]
try:
process = await asyncio.create_subprocess_exec(
"arecord",
"-D",
alsa_device,
"-q",
"-f",
DEFAULT_AUDIO_FORMAT,
"-c",
str(DEFAULT_CHANNELS),
"-r",
str(DEFAULT_SAMPLE_RATE),
"-t",
"raw",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except FileNotFoundError:
self._fallback_to_manual("arecord blev ikke fundet på systemet.")
return
self._audio_process = process
self.current_device = candidate
self.mode = f"audio:{candidate}"
connected_once = False
stderr_output = b""
try:
if process.stdout is None:
last_error = "ALSA-capture kunne ikke starte."
continue
while True:
chunk = await process.stdout.read(4096)
if not chunk:
break
connected_once = True
self.audio_connected = True
result = self._analyzer.feed_pcm16(chunk)
if result is None:
continue
bpm, confidence = result
self.current_bpm = bpm
self.confidence = confidence
self._mark_beat_event()
except asyncio.CancelledError:
if process.returncode is None:
process.terminate()
await process.wait()
raise
finally:
if process.returncode is None:
process.terminate()
await process.wait()
if process.stderr is not None:
stderr_output = await process.stderr.read()
self._audio_process = None
error = self._humanize_alsa_error(
candidate,
stderr_output.decode("utf-8", errors="ignore").strip(),
)
if connected_once:
if self.mode.startswith("audio:"):
self._fallback_to_manual(error or "Audio-input stoppede.")
return
last_error = error or f"Kunne ikke åbne ALSA-input {alsa_device}."
self.audio_connected = False
if self.mode.startswith("audio:"):
self._fallback_to_manual(last_error or "Audio-input stoppede.")
async def _synthetic_click_loop(self) -> None:
self.audio_connected = True
click_interval = 0.5
sample_rate = self._analyzer.sample_rate
click_width = 2048
silence_width = int(sample_rate * click_interval) - click_width
amplitude = 22000
click = array("h", [amplitude if index < 128 else 0 for index in range(click_width)])
silence = array("h", [0] * max(0, silence_width))
try:
while True:
self._feed_array(click)
self._feed_array(silence)
await asyncio.sleep(click_interval)
except asyncio.CancelledError:
raise
finally:
self.audio_connected = False
def _feed_array(self, values: array[int]) -> None:
if not values:
return
result = self._analyzer.feed_pcm16(values.tobytes())
if result is None:
return
bpm, confidence = result
self.current_bpm = bpm
self.confidence = confidence
self._mark_beat_event()
def _cancel_audio_runtime(self) -> None:
if self._audio_task is not None:
self._audio_task.cancel()
if self._audio_process is not None and self._audio_process.returncode is None:
self._audio_process.terminate()
def _fallback_to_manual(self, error: str) -> None:
self.mode = "manual"
self.current_device = "manual"
self.current_bpm = self._manual_bpm
self.confidence = 1.0
self.audio_connected = False
self.last_error = error
self._analyzer.current_level = 0.0
self._analyzer.peak_level = 0.0
self._analyzer.clipping = False
def _mark_beat_event(self) -> None:
self.beat_counter += 1
self.last_beat_detected_at = monotonic()
def _serialize_device(self, device: AudioDevice) -> dict[str, Any]:
return {
"id": device.id,
"name": device.name,
"backend": device.backend,
"is_default": device.is_default,
"recommended": device.recommended,
}
async def _list_alsa_hardware_devices(self) -> list[AudioDevice]:
try:
process = await asyncio.create_subprocess_exec(
"arecord",
"-l",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except (FileNotFoundError, NotImplementedError):
return []
stdout, _stderr = await process.communicate()
if process.returncode != 0:
return []
devices: list[AudioDevice] = []
for raw_line in stdout.decode("utf-8", errors="ignore").splitlines():
line = raw_line.strip()
if not line or not line.startswith("card "):
continue
card = self._parse_card_line(line)
device = self._parse_device_from_card_line(line)
if card is None or device is None:
continue
card_index, card_key, card_name = card
device_index, _device_key, device_name = device
label = f"{card_name} / {device_name}" if device_name else card_name
self._append_device(
devices,
AudioDevice(id=f"alsa:hw:{card_index},{device_index}", name=label, backend="alsa"),
)
self._append_device(
devices,
AudioDevice(
id=f"alsa:plughw:{card_index},{device_index}",
name=f"{label} (plughw)",
backend="alsa",
),
)
if card_key:
self._append_device(
devices,
AudioDevice(
id=f"alsa:plughw:CARD={card_key},DEV={device_index}",
name=f"{label} (stabil alias)",
backend="alsa",
),
)
return devices
async def _load_config(self) -> None:
async with self._session_factory() as session:
setting = await session.get(Setting, BPM_AUDIO_SETTING_KEY)
if setting is None or not isinstance(setting.value, dict):
return
preferred = setting.value.get("preferred_device")
if isinstance(preferred, str) and preferred.strip():
self.selected_device = self._normalize_device_id(preferred)
async def _resolve_alsa_candidates(self, requested_device: str) -> list[str]:
await self.list_devices()
candidates: list[str] = []
if requested_device == AUTO_ALSA_DEVICE:
if self.selected_device != AUTO_ALSA_DEVICE:
candidates.append(self.selected_device)
candidates.extend([self.recommended_device, SB_ALIAS_DEVICE, SB_FALLBACK_DEVICE, DEFAULT_ALSA_DEVICE])
else:
candidates.append(requested_device)
if requested_device == SB_ALIAS_DEVICE:
candidates.extend([SB_FALLBACK_DEVICE, DEFAULT_ALSA_DEVICE])
elif requested_device == SB_FALLBACK_DEVICE:
candidates.append(DEFAULT_ALSA_DEVICE)
unique_candidates: list[str] = []
for candidate in candidates:
normalized = self._normalize_device_id(candidate)
if not normalized.startswith("alsa:"):
continue
if normalized not in unique_candidates:
unique_candidates.append(normalized)
return unique_candidates or [DEFAULT_ALSA_DEVICE]
def _append_device(self, devices: list[AudioDevice], device: AudioDevice) -> None:
if any(existing.id == device.id for existing in devices):
return
devices.append(device)
def _pick_recommended_device(self, devices: list[AudioDevice]) -> str:
preferred_ids = [SB_ALIAS_DEVICE, SB_FALLBACK_DEVICE, DEFAULT_ALSA_DEVICE]
for preferred_id in preferred_ids:
if any(device.id == preferred_id for device in devices):
return preferred_id
for device in devices:
if "SB" in device.id.upper():
return device.id
return DEFAULT_ALSA_DEVICE
def _normalize_device_id(self, device: str) -> str:
normalized = device.strip() if device else AUTO_ALSA_DEVICE
if not normalized:
return AUTO_ALSA_DEVICE
if normalized == "default":
return DEFAULT_ALSA_DEVICE
if normalized.startswith("alsa:") or normalized == "synthetic-click-track":
return normalized
if normalized.startswith("plughw:") or normalized.startswith("hw:"):
return f"alsa:{normalized}"
return f"alsa:{normalized}"
def _humanize_alsa_error(self, candidate: str, error: str) -> str:
if not error:
return f"Audio-input {candidate.split(':', 1)[1]} stoppede."
normalized = error.lower()
if "device or resource busy" in normalized:
return (
"Mikrofonen er optaget af en anden proces. Luk PipeWire, PulseAudio eller en "
"anden TuxDMX/arecord-proces, eller vælg et andet ALSA-input."
)
if "cannot get card index" in normalized or "no such file or directory" in normalized:
return f"ALSA-input {candidate.split(':', 1)[1]} blev ikke fundet."
if "audio open error" in normalized or "unable to open slave" in normalized:
return f"Kunne ikke åbne ALSA-input {candidate.split(':', 1)[1]}."
return error
def _parse_card_line(self, line: str) -> tuple[str, str, str] | None:
try:
after_prefix = line.split("card ", 1)[1]
card_index = after_prefix.split(":", 1)[0].strip()
after_index = after_prefix.split(":", 1)[1]
card_key = after_index.split("[", 1)[0].split(",", 1)[0].strip()
card_name = after_index.split("[", 1)[1].split("]", 1)[0].strip()
return card_index, card_key, card_name
except IndexError:
return None
def _parse_device_from_card_line(self, line: str) -> tuple[str, str, str] | None:
try:
after_device = line.split("device ", 1)[1]
device_index = after_device.split(":", 1)[0].strip()
after_index = after_device.split(":", 1)[1]
device_key = after_index.split("[", 1)[0].strip().rstrip(",")
device_name = after_index.split("[", 1)[1].split("]", 1)[0].strip()
return device_index, device_key, device_name
except IndexError:
return None