feat: point the fleet at wss LiveKit room uwh-telhai

Publish and subscribe over wss://livekit.uni-wh.de:7800 and refuse
cleartext ws://. Conference room is uwh-telhai. Includes the uncommitted
encoded H.264 publish path, Rally hairpin, and KMS wall overlay.
This commit is contained in:
root
2026-10-11 00:48:50 +00:00
parent ef9573c294
commit 95bb7c50ee
89 changed files with 9883 additions and 243 deletions
+454 -54
View File
@@ -14,9 +14,11 @@ from __future__ import annotations
import argparse
import asyncio
import logging
import os
import signal
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from typing import Optional
from urllib.parse import urlparse
@@ -26,19 +28,37 @@ from config import load_config
from display_grid import (
CameraGrid,
ROLES,
active_speaker_ids,
bgra_from_livekit,
camera_ids,
is_camera_identity,
collapse_bleed,
confirm_speakers,
describe_livekit_error,
format_livekit_status,
hold_speakers,
placeholder,
select_grid_speakers,
update_noise_floor,
)
from drm_outputs import DrmOutput, format_outputs, list_outputs, select_outputs
from gst_sink import KmsPipeline
from gst_sink import KmsPipeline, VaGridPipeline
from hand_raise import HandRaiseMonitor
from latency import video_stream_kwargs
from participant_tags import want_display_video
from tokens import make_viewer_token
log = logging.getLogger("cameras.display")
H264_DIR = "/run/livekit-cameras/h264"
def _h264_fifo_paths(identities: list[str]) -> list[str] | None:
# Sidecar H.264 encode removed: grid tiles come from LiveKit I420.
del identities
return None
_PATTERNS = ("smpte", "ball", "snow")
_PLACE_W, _PLACE_H = 1280, 720
_PLACE_W, _PLACE_H = 1920, 1080
def _public_url(cfg) -> str:
@@ -71,6 +91,69 @@ def _bind_roles(roles: list[str], connectors: list[str]) -> list[tuple[str, DrmO
return slots
class HopLevels:
"""Latest hop RMS + idle floor per grid identity (does not mute publish)."""
def __init__(self, shm_dir: str, identities: list[str], rise_db: float = 6.0) -> None:
from hop_shm import HopReader
from audio_gate import hop_mono_int16, rms_dbfs
self._mono = hop_mono_int16
self._rms = rms_dbfs
self.rise_db = float(rise_db)
self._db = {i: -120.0 for i in identities}
self._wave: dict[str, np.ndarray | None] = {i: None for i in identities}
self._floor: dict[str, float | None] = {i: None for i in identities}
self._n = {i: 0 for i in identities}
self._readers = {
i: HopReader(os.path.join(shm_dir, f"{i}.pcm")) for i in identities
}
def poll(self, speaking: set[str] | None = None) -> None:
hot = speaking or set()
for ident, r in self._readers.items():
blob = None
while True:
nxt = r.read()
if nxt is None:
break
blob = nxt
if blob is None:
continue
pcm = np.frombuffer(blob, dtype=np.int16)
if pcm.size % 2 == 0:
pcm = pcm.reshape(-1, 2)
db = self._rms(self._mono(pcm))
self._db[ident] = db
self._wave[ident] = np.asarray(self._mono(pcm), dtype=np.float32)
fl = self._floor[ident]
self._n[ident] += 1
if fl is None:
self._floor[ident] = db
else:
self._floor[ident] = update_noise_floor(
fl, db, speaking=ident in hot, rise_db=self.rise_db)
def levels(self, identities: list[str]) -> list[tuple[str, float]]:
return [
(i, self._db.get(i, -120.0))
for i in identities
if i in self._readers and self._n.get(i, 0) >= 3
]
def waves(self, identities: list[str]) -> dict[str, np.ndarray]:
out: dict[str, np.ndarray] = {}
for i in identities:
w = self._wave.get(i)
if w is not None:
out[i] = w
return out
@property
def floors(self) -> dict[str, float]:
return {i: fl for i, fl in self._floor.items() if fl is not None}
class Wall:
def __init__(self, cfg, slots: list[tuple[str, DrmOutput]]):
self.cfg = cfg
@@ -82,13 +165,23 @@ class Wall:
height=cfg.display_grid_height,
cols=cfg.display_grid_cols,
rows=cfg.display_grid_rows,
identities=camera_ids(n, prefix),
identities=[],
)
self.speaker_camera = (cfg.display_speaker_camera or "cam-01").strip()
self.grid.set_status(format_livekit_status(url=cfg.url, state="connecting"))
self._retry_s = 5.0
# LiveKit FFI retries ~3 times (~12s) on ENETUNREACH; stay above that
# so the overlay can show "host unreachable" instead of a bare timeout.
self._connect_timeout_s = 20.0
self.speaker_camera = (cfg.display_speaker_camera or "rally").strip()
self.active_speaker: Optional[str] = None
self._last_speakers: tuple[str, ...] = ()
self._speaker_frame: Optional[np.ndarray] = None
self._tasks: dict[str, asyncio.Task] = {}
self._stop = asyncio.Event()
self._pump_pool = ThreadPoolExecutor(
max_workers=max(1, int(cfg.display_pump_workers)),
thread_name_prefix="disp-pump",
)
# Screenshare is a stub until production LiveKit is online.
self._share_placeholder = placeholder(_PLACE_W, _PLACE_H, [
"Screen share",
@@ -98,22 +191,162 @@ class Wall:
cam = self.speaker_camera
self._speaker_placeholder = placeholder(_PLACE_W, _PLACE_H, [
"Speaker camera",
"Logitech Rally",
cam if cam.lower() != "active" else "(following active speaker)",
"not connected",
])
self.va_grid: Optional[VaGridPipeline] = None
self.hand_raise = HandRaiseMonitor(
enabled=cfg.display_hand_raise,
hz=cfg.display_hand_raise_hz,
hold_s=cfg.display_hand_raise_hold_s,
on_change=self.grid.set_highlight,
model=cfg.display_hand_raise_model,
)
self._hop_levels = HopLevels(
cfg.audio_shm_dir, self.grid.identities,
rise_db=cfg.display_speak_rise_db)
self._speak_cur: list[str] = []
self._speak_peak = -120.0
self._speak_hold_until = 0.0
self._speak_counts: dict[str, int] = {}
def _refresh_speakers(self) -> None:
"""Hop RMS is the detector; LiveKit is a hint, not a gate."""
tiles = list(self.grid.identities)
ranked = self._hop_levels.levels(tiles)
picked = select_grid_speakers(
ranked,
min_db=self.cfg.display_speak_min_db,
margin_db=self.cfg.display_speak_margin_db,
floors=self._hop_levels.floors,
rise_db=self.cfg.display_speak_rise_db,
)
picked = collapse_bleed(
picked,
self._hop_levels.waves(picked),
dict(ranked),
corr_min=self.cfg.display_speak_corr,
)
picked, self._speak_counts = confirm_speakers(
self._speak_counts, picked,
need=self.cfg.display_speak_confirm,
already=self._speak_cur,
)
new_peak = max((db for i, db in ranked if i in picked), default=-120.0)
now = time.monotonic()
chrome, self._speak_hold_until = hold_speakers(
self._speak_cur, picked,
prev_peak=self._speak_peak, new_peak=new_peak,
now=now, hold_until=self._speak_hold_until,
hold_s=self.cfg.display_speak_hold_s,
)
if chrome == picked:
self._speak_peak = new_peak
self._speak_cur = chrome
self.grid.set_speakers(chrome)
key = tuple(chrome)
if key != self._last_speakers:
self._last_speakers = key
log.info("active-speakers %s", ",".join(chrome) if chrome else "-")
def speaker_identity(self) -> Optional[str]:
if self.speaker_camera.lower() == "active":
return self.active_speaker
return self.speaker_camera
def _push_sink(self, role: str, data: bytes, width: int, height: int, fps: int) -> None:
def _set_lk_status(self, state: str, detail: str = "", retry_s: float | None = None) -> None:
lines = format_livekit_status(
url=self.cfg.url, state=state, detail=detail, retry_s=retry_s,
room=self.cfg.room)
if state == "connected":
if self.grid.identities:
self.grid.set_status(None)
else:
self.grid.set_status(["NO LIVE CAMERAS", f"room {self.cfg.room}"])
return
self.grid.set_status(lines)
def _seat_participant(self, participant) -> None:
ident = getattr(participant, "identity", "") or ""
if not ident or not self._want_video(participant):
return
if ident == self.speaker_identity():
return
if not self.grid.add_identity(ident):
log.warning("grid full, skip %s", ident)
return
if self.grid.identities:
self.grid.set_status(None)
def _unseat_participant(self, identity: str) -> None:
ident = (identity or "").strip()
if not ident:
return
self.grid.remove_identity(ident)
self.grid.clear(ident)
if ident == self.speaker_identity():
self._speaker_frame = None
# Keep mosaic if other tiles remain; otherwise show empty-room text
# only while the SFU session is still up (status overlay owns errors).
if not self.grid.identities and not self.grid._status_lines:
self.grid.set_status(["NO LIVE CAMERAS", f"room {self.cfg.room}"])
def _tile_index(self, identity: str) -> Optional[int]:
try:
return self.grid.identities.index(identity)
except ValueError:
return None
def _start_va_grid(self) -> None:
# vacompositor prerolls one HDMI frame then never flips (crtc CRC unique=1).
# CPU mosaic through appsrc+kmssink is the path that actually presents.
if os.environ.get("DISPLAY_VA_GRID", "0").strip() != "1":
log.info("va-grid skipped; CPU mosaic -> kmssink")
return
sink = self.slots.get("grid")
if sink is None or not sink.out.connected:
return
self.va_grid = VaGridPipeline(sink.out)
self.va_grid.start(
self.grid,
ingest_w=max(16, int(self.cfg.width)),
ingest_h=max(16, int(self.cfg.height)),
fps=max(1, int(self.cfg.fps) or 15),
queue_buffers=self.cfg.display_gst_queue_buffers,
h264_paths=_h264_fifo_paths(self.grid.identities),
)
if not self.va_grid.alive:
log.warning("va-grid failed to start; falling back to CPU mosaic")
self.va_grid = None
def _want_video(self, participant) -> bool:
return want_display_video(
identity=getattr(participant, "identity", "") or "",
attributes=getattr(participant, "attributes", None) or {},
metadata=getattr(participant, "metadata", "") or "",
hide_tags=self.cfg.display_hide_tags,
participant_prefix=self.cfg.participant_prefix or "cam",
display_identity=self.cfg.display_identity,
speaker_identity=self.speaker_identity() or "",
)
def _push_sink(self, role: str, data: bytes, width: int, height: int, fps: int,
pixel_format: str = "bgra") -> None:
sink = self.slots.get(role)
if sink is None or not sink.out.connected:
return
if not sink.alive or sink.width != width or sink.height != height:
sink.start_appsrc(width, height, fps)
if (not sink.alive or sink.width != width or sink.height != height
or getattr(sink, "pixel_format", "bgra") != pixel_format):
sink.start_appsrc(
width, height, fps,
queue_buffers=self.cfg.display_gst_queue_buffers,
pixel_format=pixel_format)
if not sink.push(data):
sink.start_appsrc(width, height, fps)
sink.start_appsrc(
width, height, fps,
queue_buffers=self.cfg.display_gst_queue_buffers,
pixel_format=pixel_format)
sink.push(data)
err = sink.gst_errors()
if err:
@@ -121,26 +354,56 @@ class Wall:
log.warning("%s gst: %s", role, line)
async def _output_loops(self) -> None:
self._start_va_grid()
grid_fps = max(1, int(self.cfg.display_grid_fps))
grid_period = 1.0 / grid_fps
spk_period = 1.0 / max(1, int(self.cfg.fps))
next_grid = time.monotonic()
next_spk = time.monotonic()
next_hop = time.monotonic()
while not self._stop.is_set():
now = time.monotonic()
if now >= next_hop:
self._hop_levels.poll(speaking=set(self._speak_cur))
self._refresh_speakers()
next_hop = now + 0.2
if now >= next_grid:
self._push_sink(
"grid", self.grid.render(),
self.grid.width, self.grid.height, grid_fps,
)
if self.va_grid is not None:
if not self.va_grid.alive:
log.warning("va-grid died; falling back to CPU mosaic")
self.va_grid.stop()
self.va_grid = None
else:
err = self.va_grid.gst_errors()
if err:
for line in err.strip().splitlines()[-6:]:
log.warning("va-grid gst: %s", line)
if self.va_grid is None:
i420 = self.cfg.display_video_format in ("i420", "yuv420p")
if i420:
self._push_sink(
"grid", self.grid.render_i420(),
self.grid.width, self.grid.height, grid_fps,
pixel_format="i420",
)
else:
self._push_sink(
"grid", self.grid.render(),
self.grid.width, self.grid.height, grid_fps,
)
next_grid = now + grid_period
if now >= next_spk:
spk = self._speaker_frame
arrival = self.cfg.display_speaker_push == "arrival"
if spk is None:
img = self._speaker_placeholder
else:
img = spk
self._push_sink("speaker", img.tobytes(), img.shape[1], img.shape[0], self.cfg.fps)
self._push_sink(
"speaker", img.tobytes(), img.shape[1], img.shape[0],
self.cfg.fps)
elif not arrival:
self._push_sink(
"speaker", spk.tobytes(), spk.shape[1], spk.shape[0],
self.cfg.fps)
share = self._share_placeholder
self._push_sink("screenshare", share.tobytes(), share.shape[1], share.shape[0], self.cfg.fps)
next_spk = now + spk_period
@@ -149,23 +412,60 @@ class Wall:
async def _pump_camera(self, track, identity: str) -> None:
import livekit.rtc as rtc
stream = rtc.VideoStream(track)
stream_kw = video_stream_kwargs(
self.cfg.display_video_capacity, self.cfg.display_video_format)
if stream_kw.get("format") == "bgra":
stream_kw["format"] = rtc.VideoBufferType.BGRA
elif stream_kw.get("format") in ("i420", "yuv420p"):
stream_kw["format"] = rtc.VideoBufferType.I420
stream = rtc.VideoStream(track, **stream_kw)
want_bgra = self.cfg.display_video_format == "bgra"
use_i420 = self.cfg.display_video_format in ("i420", "yuv420p")
loop = asyncio.get_running_loop()
try:
async for event in stream:
frame = event.frame
if frame.width <= 0 or frame.height <= 0:
continue
try:
converted = frame.convert(rtc.VideoBufferType.BGRA)
except Exception as exc:
log.warning("%s: convert failed: %s", identity, exc)
if (self.va_grid is not None
and getattr(self.va_grid, "codec", "") == "i420"):
idx = self._tile_index(identity)
if (idx is not None
and frame.width == self.va_grid.ingest_w
and frame.height == self.va_grid.ingest_h):
self.va_grid.push_tile(idx, frame.data)
is_spk = identity == self.speaker_identity()
cpu_grid = self.va_grid is None
if cpu_grid and use_i420 and not is_spk:
blob = bytes(frame.data)
await loop.run_in_executor(
self._pump_pool,
self.grid.set_frame_i420,
identity, blob, frame.width, frame.height)
self.hand_raise.offer(identity, blob, frame.width, frame.height)
continue
bgra = bgra_from_livekit(converted.data, converted.width, converted.height)
self.grid.set_frame(identity, bgra)
if identity == self.speaker_identity():
self._speaker_frame = bgra
if cpu_grid or is_spk:
try:
if frame.type == rtc.VideoBufferType.BGRA:
converted = frame
else:
converted = frame.convert(rtc.VideoBufferType.BGRA)
except Exception as exc:
log.warning("%s: convert failed: %s", identity, exc)
continue
bgra = bgra_from_livekit(
converted.data, converted.width, converted.height)
if cpu_grid:
self.grid.set_frame(identity, bgra)
if is_spk:
self._speaker_frame = bgra
if self.cfg.display_speaker_push == "arrival":
self._push_sink(
"speaker", bgra.tobytes(),
bgra.shape[1], bgra.shape[0], self.cfg.fps)
finally:
await stream.aclose()
self.hand_raise.forget(identity)
self.grid.clear(identity)
if identity == self.speaker_identity():
self._speaker_frame = None
@@ -175,27 +475,29 @@ class Wall:
def attach(self, track, publication, participant, rtc) -> None:
ident = participant.identity
if ident == self.cfg.display_identity:
if not self._want_video(participant):
return
is_spk = ident == self.speaker_identity()
if not is_spk:
self._seat_participant(participant)
if ident not in self.grid.identities:
return
key = self._track_key(ident, track)
old = self._tasks.pop(key, None)
if old:
old.cancel()
# Screenshare subscribe/render is stubbed until production LiveKit.
if is_camera_identity(ident, self.cfg.participant_prefix):
self._tasks[key] = asyncio.create_task(
self._pump_camera(track, ident), name=f"cam-{ident}")
self._tasks[key] = asyncio.create_task(
self._pump_camera(track, ident), name=f"cam-{ident}")
def subscribe_if_wanted(self, participant, rtc) -> None:
ident = participant.identity
if ident == self.cfg.display_identity:
if not self._want_video(participant):
return
cam = is_camera_identity(ident, self.cfg.participant_prefix)
for pub in participant.track_publications.values():
if pub.kind != rtc.TrackKind.KIND_VIDEO:
continue
want = cam
if want and not pub.subscribed:
if not pub.subscribed:
log.info("subscribe %s from %s (source=%s)", pub.sid, ident, pub.source)
pub.set_subscribed(True)
@@ -206,9 +508,69 @@ class Wall:
task.cancel()
async def run(self) -> int:
loops = asyncio.create_task(self._output_loops(), name="output-loops")
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(sig, self._stop.set)
except NotImplementedError:
pass
self.hand_raise.start()
self._set_lk_status("connecting")
log.info(
"wall up (livekit optional): grid=%dx%d speaker_camera=%s "
"video_capacity=%d format=%s gst_queue=%d speaker_push=%s "
"pump_workers=%d hide_tags=%s hand_raise=%s url=%s",
self.cfg.display_grid_cols, self.cfg.display_grid_rows, self.speaker_camera,
self.cfg.display_video_capacity,
self.cfg.display_video_format or "native",
self.cfg.display_gst_queue_buffers,
self.cfg.display_speaker_push,
self.cfg.display_pump_workers,
",".join(self.cfg.display_hide_tags) or "off",
"on" if self.hand_raise.enabled else "off",
self.cfg.url,
)
try:
while not self._stop.is_set():
try:
await self._session()
except asyncio.CancelledError:
raise
except Exception as exc:
detail = describe_livekit_error(exc)
log.error("livekit %s: %s", detail, exc or type(exc).__name__)
self.grid.set_identities([])
self._speaker_frame = None
self._set_lk_status(
"disconnected", detail=detail, retry_s=self._retry_s)
if self._stop.is_set():
break
try:
await asyncio.wait_for(self._stop.wait(), timeout=self._retry_s)
except asyncio.TimeoutError:
pass
if not self._stop.is_set():
self._set_lk_status("connecting")
finally:
loops.cancel()
for t in self._tasks.values():
t.cancel()
self._tasks.clear()
for sink in self.slots.values():
sink.stop()
if self.va_grid is not None:
self.va_grid.stop()
self.va_grid = None
self._pump_pool.shutdown(wait=False, cancel_futures=True)
self.hand_raise.stop()
return 0
async def _session(self) -> None:
import livekit.rtc as rtc
room = rtc.Room()
session_done = asyncio.Event()
@room.on("track_subscribed")
def _on_sub(track, publication, participant):
@@ -227,48 +589,80 @@ class Wall:
@room.on("participant_connected")
def _on_pc(participant):
log.info("participant joined: %s", participant.identity)
self._seat_participant(participant)
self.subscribe_if_wanted(participant, rtc)
@room.on("participant_disconnected")
def _on_pd(participant):
ident = getattr(participant, "identity", "") or ""
log.info("participant left: %s", ident)
self._unseat_participant(ident)
@room.on("disconnected")
def _on_disc(*args):
reason = ""
if args:
reason = str(args[0] or "")
detail = describe_livekit_error(RuntimeError(reason)) if reason else "server closed the session"
if reason:
low = reason.lower()
if "disconnect" in low or "close" in low:
detail = reason[:96]
log.warning("livekit disconnected: %s", reason or "no reason")
self.grid.set_identities([])
self._speaker_frame = None
self._set_lk_status(
"disconnected", detail=detail, retry_s=self._retry_s)
session_done.set()
@room.on("active_speakers_changed")
def _on_spk(speakers):
cams = [
p.identity for p in speakers
if is_camera_identity(p.identity, self.cfg.participant_prefix)
]
cams = active_speaker_ids(speakers, want=self._want_video)
self.active_speaker = cams[0] if cams else None
self._hop_levels.poll(speaking=set(self._speak_cur))
self._refresh_speakers()
token = make_viewer_token(
self.cfg.api_key, self.cfg.api_secret, self.cfg.room, self.cfg.display_identity)
log.info("connecting to %s room=%s as %s", self.cfg.url, self.cfg.room, self.cfg.display_identity)
await room.connect(self.cfg.url, token, rtc.RoomOptions(auto_subscribe=False))
self._set_lk_status("connecting")
try:
await asyncio.wait_for(
room.connect(self.cfg.url, token, rtc.RoomOptions(auto_subscribe=False)),
timeout=self._connect_timeout_s,
)
except Exception:
try:
await room.disconnect()
except Exception:
pass
raise
self._set_lk_status("connected")
for rp in room.remote_participants.values():
self._seat_participant(rp)
self.subscribe_if_wanted(rp, rtc)
for pub in rp.track_publications.values():
if pub.track and pub.track.kind == rtc.TrackKind.KIND_VIDEO:
self.attach(pub.track, pub, rp, rtc)
log.info(
"wall up: grid=%dx%d speaker_camera=%s screenshare=STUB",
self.grid.cols, self.grid.rows, self.speaker_camera,
"livekit connected: tiles=%d speaker_camera=%s",
len(self.grid.identities), self.speaker_camera,
)
loops = asyncio.create_task(self._output_loops(), name="output-loops")
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(sig, self._stop.set)
except NotImplementedError:
pass
stop_task = asyncio.create_task(self._stop.wait())
disc_task = asyncio.create_task(session_done.wait())
try:
await self._stop.wait()
await asyncio.wait({stop_task, disc_task}, return_when=asyncio.FIRST_COMPLETED)
finally:
loops.cancel()
stop_task.cancel()
disc_task.cancel()
for t in self._tasks.values():
t.cancel()
for sink in self.slots.values():
sink.stop()
await room.disconnect()
return 0
self._tasks.clear()
try:
await room.disconnect()
except Exception:
pass
def run_test_pattern(slots: list[tuple[str, DrmOutput]]) -> int:
@@ -326,6 +720,8 @@ def main(argv: Optional[list[str]] = None) -> int:
cfg = load_config()
logging.getLogger().setLevel(getattr(logging, cfg.log_level, logging.INFO))
if cfg.url or cfg.room:
log.info("livekit destination url=%s room=%s", cfg.url, cfg.room)
if args.speaker_camera:
cfg.display_speaker_camera = args.speaker_camera
roles = [p.strip() for p in args.roles.split(",") if p.strip()] or cfg.display_roles
@@ -341,6 +737,10 @@ def main(argv: Optional[list[str]] = None) -> int:
if args.test_pattern:
return run_test_pattern(slots)
if not (cfg.url or "").startswith("wss://"):
log.error("LIVEKIT_URL must be wss:// (refusing %s)", cfg.url)
return 2
log.info("livekit destination url=%s room=%s", cfg.url, cfg.room)
return asyncio.run(Wall(cfg, slots).run())