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
620 lines
24 KiB
Python
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
|