48eb62bddc
Web UI checkbox в Audio source карточке: «вывод аудио в стрим». Off →
TV получает silence + downstream audio chain isolated от live-audio RTSP
(нестабильный sidecar поток больше не блокирует video pipeline).
Изменения:
state.InstanceState: + audio_output_enabled (default True)
config.InstanceCfg:
+ audio_output_volume_target (default volume@output_audio)
+ audio_input_select_target (default astreamselect@audio_input)
dispatch.set_audio_output_enabled(enabled):
enabled=False → astreamselect map=1 (anullsrc) + volume=0
enabled=True → astreamselect map=0 (live-audio) + volume=1
Двойная команда: select decouples upstream, volume гарантирует тишину
на случай если в anullsrc что-то не так.
http_api: + GET /audio-output/{instance}, POST /audio-output/{instance}
static/index.html: + checkbox в Audio source header + loadAudioOut/toggleAudioOut
ZMQ smoke test OK. HTTP roundtrip OK.
Сопутствующий pipeline change: docker-compose.phase7.yml — amix заменён
на astreamselect@audio_input (см. localhost-infra commit).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
91 lines
3.2 KiB
Python
91 lines
3.2 KiB
Python
"""In-memory state controller'а."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
from dataclasses import dataclass, field
|
||
|
||
from .overlays import Overlay
|
||
|
||
|
||
@dataclass
|
||
class InstanceState:
|
||
name: str
|
||
active_layout: str
|
||
overlays: dict[str, Overlay] = field(default_factory=dict)
|
||
audio_output_enabled: bool = True
|
||
# Future: fps_out, dropped_frames, motion_cameras, last_event_ts
|
||
|
||
|
||
@dataclass
|
||
class ControllerState:
|
||
instances: dict[str, InstanceState] = field(default_factory=dict)
|
||
_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||
|
||
async def set_layout(self, instance: str, layout: str) -> None:
|
||
async with self._lock:
|
||
st = self.instances.get(instance)
|
||
if st is None:
|
||
st = InstanceState(name=instance, active_layout=layout)
|
||
self.instances[instance] = st
|
||
else:
|
||
st.active_layout = layout
|
||
|
||
async def get_layout(self, instance: str) -> str | None:
|
||
async with self._lock:
|
||
st = self.instances.get(instance)
|
||
return st.active_layout if st else None
|
||
|
||
# ─── Overlay state ──────────────────────────────────────────────
|
||
|
||
async def add_overlay(self, instance: str, overlay: Overlay) -> str:
|
||
async with self._lock:
|
||
st = self.instances.get(instance)
|
||
if st is None:
|
||
raise KeyError(f"unknown instance '{instance}'")
|
||
st.overlays[overlay.id] = overlay
|
||
return overlay.id
|
||
|
||
async def remove_overlay(self, instance: str, overlay_id: str) -> bool:
|
||
async with self._lock:
|
||
st = self.instances.get(instance)
|
||
if st is None or overlay_id not in st.overlays:
|
||
return False
|
||
del st.overlays[overlay_id]
|
||
return True
|
||
|
||
async def update_overlay(self, instance: str, overlay: Overlay) -> bool:
|
||
async with self._lock:
|
||
st = self.instances.get(instance)
|
||
if st is None or overlay.id not in st.overlays:
|
||
return False
|
||
st.overlays[overlay.id] = overlay
|
||
return True
|
||
|
||
async def get_overlays(self, instance: str) -> list[Overlay]:
|
||
async with self._lock:
|
||
st = self.instances.get(instance)
|
||
return list(st.overlays.values()) if st else []
|
||
|
||
# ─── Audio output mute ──────────────────────────────────────────
|
||
|
||
async def set_audio_output_enabled(self, instance: str, enabled: bool) -> None:
|
||
async with self._lock:
|
||
st = self.instances.get(instance)
|
||
if st is not None:
|
||
st.audio_output_enabled = enabled
|
||
|
||
async def get_audio_output_enabled(self, instance: str) -> bool:
|
||
async with self._lock:
|
||
st = self.instances.get(instance)
|
||
return st.audio_output_enabled if st else True
|
||
|
||
async def clear_overlays(self, instance: str) -> int:
|
||
async with self._lock:
|
||
st = self.instances.get(instance)
|
||
if st is None:
|
||
return 0
|
||
n = len(st.overlays)
|
||
st.overlays.clear()
|
||
return n
|