Update docs and screenshots
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
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
This commit is contained in:
@@ -0,0 +1,619 @@
|
||||
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
|
||||
Reference in New Issue
Block a user