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