Introduces an engine+template abstraction for audio capture, mirroring the existing screen capture engine pattern. This enables multiple audio backends (WASAPI for Windows, sounddevice for cross-platform) with per-source engine configuration via reusable templates. Backend: - AudioCaptureEngine ABC with WasapiEngine and SounddeviceEngine implementations - AudioEngineRegistry for engine discovery and factory creation - AudioAnalyzer class decouples FFT/RMS/beat analysis from engine-specific capture - ManagedAudioStream wraps engine stream + analyzer in background thread - AudioCaptureTemplate model and AudioTemplateStore with JSON CRUD - AudioCaptureManager keyed by (engine_type, device_index, is_loopback) - Auto-migration: default template created on startup, assigned to existing sources - Full REST API: CRUD for audio templates + engine listing with availability flags - audio_template_id added to MultichannelAudioSource model and API schemas Frontend: - Audio template cards in Streams > Audio tab with engine badge and config details - Audio template editor modal with engine selector and dynamic config fields - Audio template dropdown in multichannel audio source editor - Template name crosslink badge on multichannel audio source cards - Confirm modal z-index fix (always stacks above editor modals) - i18n keys for EN and RU Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
118 lines
4.1 KiB
Python
118 lines
4.1 KiB
Python
"""Audio source data model with inheritance-based source types.
|
|
|
|
An AudioSource represents a reusable audio input configuration:
|
|
MultichannelAudioSource — wraps a physical audio device (index + loopback flag)
|
|
MonoAudioSource — extracts a single channel from a multichannel source
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from typing import Optional
|
|
|
|
|
|
@dataclass
|
|
class AudioSource:
|
|
"""Base class for audio source configurations."""
|
|
|
|
id: str
|
|
name: str
|
|
source_type: str # "multichannel" | "mono"
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
description: Optional[str] = None
|
|
|
|
def to_dict(self) -> dict:
|
|
"""Convert source to dictionary. Subclasses extend this."""
|
|
return {
|
|
"id": self.id,
|
|
"name": self.name,
|
|
"source_type": self.source_type,
|
|
"created_at": self.created_at.isoformat(),
|
|
"updated_at": self.updated_at.isoformat(),
|
|
"description": self.description,
|
|
# Subclass fields default to None for forward compat
|
|
"device_index": None,
|
|
"is_loopback": None,
|
|
"audio_template_id": None,
|
|
"audio_source_id": None,
|
|
"channel": None,
|
|
}
|
|
|
|
@staticmethod
|
|
def from_dict(data: dict) -> "AudioSource":
|
|
"""Factory: dispatch to the correct subclass based on source_type."""
|
|
source_type: str = data.get("source_type", "multichannel") or "multichannel"
|
|
sid: str = data["id"]
|
|
name: str = data["name"]
|
|
description: str | None = data.get("description")
|
|
|
|
raw_created = data.get("created_at")
|
|
created_at: datetime = (
|
|
datetime.fromisoformat(raw_created)
|
|
if isinstance(raw_created, str)
|
|
else raw_created if isinstance(raw_created, datetime)
|
|
else datetime.utcnow()
|
|
)
|
|
raw_updated = data.get("updated_at")
|
|
updated_at: datetime = (
|
|
datetime.fromisoformat(raw_updated)
|
|
if isinstance(raw_updated, str)
|
|
else raw_updated if isinstance(raw_updated, datetime)
|
|
else datetime.utcnow()
|
|
)
|
|
|
|
if source_type == "mono":
|
|
return MonoAudioSource(
|
|
id=sid, name=name, source_type="mono",
|
|
created_at=created_at, updated_at=updated_at, description=description,
|
|
audio_source_id=data.get("audio_source_id") or "",
|
|
channel=data.get("channel") or "mono",
|
|
)
|
|
|
|
# Default: multichannel
|
|
return MultichannelAudioSource(
|
|
id=sid, name=name, source_type="multichannel",
|
|
created_at=created_at, updated_at=updated_at, description=description,
|
|
device_index=int(data.get("device_index", -1)),
|
|
is_loopback=bool(data.get("is_loopback", True)),
|
|
audio_template_id=data.get("audio_template_id"),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class MultichannelAudioSource(AudioSource):
|
|
"""Audio source wrapping a physical audio device.
|
|
|
|
Captures all channels from the device. For WASAPI loopback devices
|
|
(system audio output), set is_loopback=True.
|
|
"""
|
|
|
|
device_index: int = -1 # -1 = default device
|
|
is_loopback: bool = True # True = WASAPI loopback (system audio)
|
|
audio_template_id: Optional[str] = None # references AudioCaptureTemplate
|
|
|
|
def to_dict(self) -> dict:
|
|
d = super().to_dict()
|
|
d["device_index"] = self.device_index
|
|
d["is_loopback"] = self.is_loopback
|
|
d["audio_template_id"] = self.audio_template_id
|
|
return d
|
|
|
|
|
|
@dataclass
|
|
class MonoAudioSource(AudioSource):
|
|
"""Audio source that extracts a single channel from a multichannel source.
|
|
|
|
References a MultichannelAudioSource and selects which channel to use:
|
|
mono (L+R mix), left, or right.
|
|
"""
|
|
|
|
audio_source_id: str = "" # references a MultichannelAudioSource
|
|
channel: str = "mono" # mono | left | right
|
|
|
|
def to_dict(self) -> dict:
|
|
d = super().to_dict()
|
|
d["audio_source_id"] = self.audio_source_id
|
|
d["channel"] = self.channel
|
|
return d
|