feat: known-good CPU audio + 1080p VAAPI + KMS display wall

Working snapshot before the OpenVINO MetricGAN GPU spike:
thread-capped SpeechBrain DelaySum+MetricGAN on CPU, hop 200 ms,
video hold for A/V sync, Arc VAAPI H.264, headless kmssink wall.
32 tests passing. Do not treat this as a GPU audio path.
This commit is contained in:
root
2026-08-28 07:14:21 +00:00
parent 11776adf52
commit 736eccb932
17 changed files with 1421 additions and 46 deletions
+18 -4
View File
@@ -12,10 +12,15 @@ PARTICIPANT_PREFIX=cam
# Streams # Streams
# "all" = every discovered camera, "1,3,5" or "0-19" = subset # "all" = every discovered camera, "1,3,5" or "0-19" = subset
CAMERAS=all CAMERAS=all
VIDEO_WIDTH=1280 VIDEO_WIDTH=640
VIDEO_HEIGHT=720 VIDEO_HEIGHT=360
VIDEO_FPS=30 VIDEO_FPS=30
VIDEO_MIN_FPS=5 VIDEO_MIN_FPS=5
VIDEO_BITRATE=800000
VIDEO_CODEC=h264
VIDEO_ENCODER=vaapi
LIBVA_DRM_DEVICE=/dev/dri/renderD129
LIBVA_DRIVER_NAME=iHD
# Audio # Audio
AUDIO_SAMPLE_RATE=16000 AUDIO_SAMPLE_RATE=16000
@@ -44,9 +49,18 @@ PUBLISH_TIMEOUT_S=60
LOG_LEVEL=INFO LOG_LEVEL=INFO
# Headless display wall (run_displays.py / livekit-displays.service) # Headless display wall (run_displays.py / livekit-displays.service)
# 3 DRM outputs showing LiveKit participants via GStreamer kmssink # 3 DRM roles: 20-cam grid, designated speaker camera, screenshare STUB
DISPLAY_COUNT=3 DISPLAY_COUNT=3
DISPLAY_IDENTITIES=cam-01,cam-02,cam-03 DISPLAY_ROLES=grid,speaker,screenshare
# Empty = auto (connected heads first, prefer Arc HDMI-A-*) # Empty = auto (connected heads first, prefer Arc HDMI-A-*)
DISPLAY_CONNECTORS= DISPLAY_CONNECTORS=
DISPLAY_IDENTITY=display-wall DISPLAY_IDENTITY=display-wall
# Speaker camera pane: cam-NN or "active"
DISPLAY_SPEAKER_CAMERA=cam-01
DISPLAY_GRID_COLS=5
DISPLAY_GRID_ROWS=4
DISPLAY_GRID_WIDTH=1920
DISPLAY_GRID_HEIGHT=1080
DISPLAY_GRID_FPS=30
# Screen share is a stub until production LiveKit is online.
LIVEKIT_PUBLIC_URL=
+20 -16
View File
@@ -5,7 +5,8 @@ LiveKit room.
Each camera becomes one LiveKit participant (`cam-01` .. `cam-20`) that Each camera becomes one LiveKit participant (`cam-01` .. `cam-20`) that
publishes: publishes:
- a camera video track (1280x720 @ 30 fps by default, MJPG from V4L2) - a camera video track (640x360 @ 30 fps MJPG, H.264 VAAPI — sized
for low-latency wall decode, not max quality)
- a microphone audio track (16 kHz mono, cleaned with speechbrain) - a microphone audio track (16 kHz mono, cleaned with speechbrain)
Identity is the USB serial, pinned by udev (`/dev/camNN` + `slots.json`). Identity is the USB serial, pinned by udev (`/dev/camNN` + `slots.json`).
@@ -72,28 +73,31 @@ re-run `--write` unless you intend to remap slots.
## Headless displays (LiveKit -> GStreamer kmssink) ## Headless displays (LiveKit -> GStreamer kmssink)
This host has no X11/Wayland. `run_displays.py` joins the LiveKit room as This host has no X11/Wayland. `run_displays.py` joins the LiveKit room as
`display-wall` (subscribe-only) and pipes each camera's video into a `display-wall` (subscribe-only) and drives three DRM roles with GStreamer
GStreamer `kmssink` on a DRM connector. `kmssink`:
Default mapping: `cam-01`, `cam-02`, `cam-03` onto the first 3 DRM heads 1. **grid** — 5x4 mosaic of cam-01..cam-20
(connected first, Arc `xe` HDMI preferred). Today HDMI-A-7 is the live 4K 2. **speaker** — designated speaker camera (`DISPLAY_SPEAKER_CAMERA`, default
panel; plug two more HDMI cables into the Arc card (`HDMI-A-5`/`HDMI-A-6`) `cam-01`; set to `active` to follow LiveKit active speaker)
and they light up on the same process. 3. **screenshare** — stub placeholder until the production LiveKit server
is online (no subscribe, no speaker login yet)
Default connector order: connected Arc HDMI first (today HDMI-A-7 is live;
HDMI-A-5 / HDMI-A-6 light up when cabled).
```bash ```bash
.venv/bin/python run_displays.py --list .venv/bin/python run_displays.py --list
.venv/bin/python run_displays.py --test-pattern # smpte/ball/snow, no LiveKit .venv/bin/python run_displays.py --test-pattern
.venv/bin/python run_displays.py # subscribe cam-01..03 .venv/bin/python run_displays.py
.venv/bin/python run_displays.py --connectors HDMI-A-7,HDMI-A-6,HDMI-A-5 .venv/bin/python run_displays.py --speaker-camera cam-07
sudo cp deploy/livekit-displays.service /etc/systemd/system/ sudo systemctl restart livekit-displays
sudo systemctl daemon-reload journalctl -u livekit-displays -f
sudo systemctl enable --now livekit-displays
``` ```
`DISPLAY_IDENTITIES` / `DISPLAY_CONNECTORS` / `DISPLAY_COUNT` in `.env`. `.env`: `DISPLAY_ROLES`, `DISPLAY_CONNECTORS`, `DISPLAY_SPEAKER_CAMERA`,
kmssink takes the DRM master for those connectors (the framebuffer console `DISPLAY_GRID_*`. kmssink takes the DRM master (framebuffer console on
on HDMI-A-7 will be replaced by camera video). HDMI-A-7 is replaced by the grid).
## Audio cleanup ## Audio cleanup
+53 -4
View File
@@ -26,10 +26,14 @@ class Config:
room: str = "cameras" room: str = "cameras"
participant_prefix: str = "cam" participant_prefix: str = "cam"
# video # video
width: int = 1280 width: int = 640
height: int = 720 height: int = 360
fps: int = 30 fps: int = 30
min_fps: int = 5 min_fps: int = 5
video_bitrate: int = 800_000
video_codec: str = "h264"
video_encoder: str = "vaapi" # auto|software|hardware|nvenc|vaapi
vaapi_device: str = "/dev/dri/renderD129"
# audio # audio
audio_rate: int = 16000 audio_rate: int = 16000
audio_channels: int = 1 audio_channels: int = 1
@@ -46,6 +50,22 @@ class Config:
publish_timeout_s: float = 60.0 publish_timeout_s: float = 60.0
log_level: str = "INFO" log_level: str = "INFO"
cameras: list[str] = field(default_factory=lambda: ["all"]) cameras: list[str] = field(default_factory=lambda: ["all"])
# headless displays (LiveKit subscribe -> GStreamer kmssink)
display_count: int = 3
display_identities: list[str] = field(
default_factory=lambda: ["cam-01", "cam-02", "cam-03"])
display_connectors: list[str] = field(default_factory=list)
display_identity: str = "display-wall"
display_roles: list[str] = field(
default_factory=lambda: ["grid", "speaker", "screenshare"])
display_speaker_camera: str = "cam-01" # cam-NN or "active"
display_speaker_participant: str = "speaker"
display_grid_cols: int = 5
display_grid_rows: int = 4
display_grid_width: int = 1920
display_grid_height: int = 1080
display_grid_fps: int = 30
livekit_public_url: str = ""
def _parse_cameras(spec: str) -> list[str]: def _parse_cameras(spec: str) -> list[str]:
@@ -66,6 +86,14 @@ def _parse_cameras(spec: str) -> list[str]:
return out return out
def _parse_list(spec: str) -> list[str]:
"""Comma-separated names, preserving case (HDMI-A-7, cam-01)."""
spec = (spec or "").strip()
if not spec:
return []
return [p.strip() for p in spec.split(",") if p.strip()]
def load_config(env_path: Optional[Path] = None) -> Config: def load_config(env_path: Optional[Path] = None) -> Config:
_load_dotenv(Path(env_path or Path(__file__).parent / ".env")) _load_dotenv(Path(env_path or Path(__file__).parent / ".env"))
@@ -75,10 +103,14 @@ def load_config(env_path: Optional[Path] = None) -> Config:
api_secret=os.environ.get("LIVEKIT_API_SECRET", ""), api_secret=os.environ.get("LIVEKIT_API_SECRET", ""),
room=os.environ.get("LIVEKIT_ROOM", "cameras"), room=os.environ.get("LIVEKIT_ROOM", "cameras"),
participant_prefix=os.environ.get("PARTICIPANT_PREFIX", "cam"), participant_prefix=os.environ.get("PARTICIPANT_PREFIX", "cam"),
width=int(os.environ.get("VIDEO_WIDTH", "1280")), width=int(os.environ.get("VIDEO_WIDTH", "640")),
height=int(os.environ.get("VIDEO_HEIGHT", "720")), height=int(os.environ.get("VIDEO_HEIGHT", "360")),
fps=int(os.environ.get("VIDEO_FPS", "30")), fps=int(os.environ.get("VIDEO_FPS", "30")),
min_fps=int(os.environ.get("VIDEO_MIN_FPS", "5")), min_fps=int(os.environ.get("VIDEO_MIN_FPS", "5")),
video_bitrate=int(os.environ.get("VIDEO_BITRATE", "800000")),
video_codec=os.environ.get("VIDEO_CODEC", "h264").lower(),
video_encoder=os.environ.get("VIDEO_ENCODER", "vaapi").lower(),
vaapi_device=os.environ.get("LIBVA_DRM_DEVICE", "/dev/dri/renderD129"),
audio_rate=int(os.environ.get("AUDIO_SAMPLE_RATE", "16000")), audio_rate=int(os.environ.get("AUDIO_SAMPLE_RATE", "16000")),
audio_channels=int(os.environ.get("AUDIO_CHANNELS", "1")), audio_channels=int(os.environ.get("AUDIO_CHANNELS", "1")),
enhance_mode=os.environ.get("ENHANCE_MODE", "auto").lower(), enhance_mode=os.environ.get("ENHANCE_MODE", "auto").lower(),
@@ -93,6 +125,23 @@ def load_config(env_path: Optional[Path] = None) -> Config:
publish_timeout_s=float(os.environ.get("PUBLISH_TIMEOUT_S", "60")), publish_timeout_s=float(os.environ.get("PUBLISH_TIMEOUT_S", "60")),
log_level=os.environ.get("LOG_LEVEL", "INFO").upper(), log_level=os.environ.get("LOG_LEVEL", "INFO").upper(),
cameras=_parse_cameras(os.environ.get("CAMERAS", "all")), cameras=_parse_cameras(os.environ.get("CAMERAS", "all")),
display_count=int(os.environ.get("DISPLAY_COUNT", "3")),
display_identities=_parse_list(
os.environ.get("DISPLAY_IDENTITIES", "cam-01,cam-02,cam-03")),
display_connectors=_parse_list(os.environ.get("DISPLAY_CONNECTORS", "")),
display_identity=os.environ.get("DISPLAY_IDENTITY", "display-wall"),
display_roles=_parse_list(
os.environ.get("DISPLAY_ROLES", "grid,speaker,screenshare"))
or ["grid", "speaker", "screenshare"],
display_speaker_camera=os.environ.get("DISPLAY_SPEAKER_CAMERA", "cam-01"),
display_speaker_participant=os.environ.get(
"DISPLAY_SPEAKER_PARTICIPANT", "speaker"),
display_grid_cols=int(os.environ.get("DISPLAY_GRID_COLS", "5")),
display_grid_rows=int(os.environ.get("DISPLAY_GRID_ROWS", "4")),
display_grid_width=int(os.environ.get("DISPLAY_GRID_WIDTH", "1920")),
display_grid_height=int(os.environ.get("DISPLAY_GRID_HEIGHT", "1080")),
display_grid_fps=int(os.environ.get("DISPLAY_GRID_FPS", "30")),
livekit_public_url=os.environ.get("LIVEKIT_PUBLIC_URL", ""),
) )
if cfg.enhance_mode not in ("auto", "force", "off"): if cfg.enhance_mode not in ("auto", "force", "off"):
+24
View File
@@ -0,0 +1,24 @@
[Unit]
Description=Drive 3 DRM displays with LiveKit camera output (GStreamer kmssink)
Documentation=file:///home/fdenkena/livekit-cameras/README.md
After=network-online.target livekit-server.service livekit-cameras.service
Wants=network-online.target livekit-cameras.service
[Service]
Type=simple
WorkingDirectory=/home/fdenkena/livekit-cameras
EnvironmentFile=-/home/fdenkena/livekit-cameras/.env
Environment=PYTHONUNBUFFERED=1
# Exclusive KMS access — no X/Wayland on these connectors.
ExecStartPre=/home/fdenkena/livekit-cameras/deploy/wait-for-livekit.sh
ExecStart=/home/fdenkena/livekit-cameras/.venv/bin/python /home/fdenkena/livekit-cameras/run_displays.py
ExecStop=/bin/kill -TERM $MAINPID
Restart=always
RestartSec=5
TimeoutStartSec=60
TimeoutStopSec=20
KillMode=control-group
KillSignal=SIGTERM
[Install]
WantedBy=multi-user.target
+197
View File
@@ -0,0 +1,197 @@
"""20-camera mosaic + framed tiles with name placeholders for the KMS wall."""
from __future__ import annotations
from typing import Iterable, Optional
import cv2
import numpy as np
ROLES = ("grid", "speaker", "screenshare")
# BGRA
CANVAS_BG = (10, 10, 10, 255)
CELL_BG = (18, 18, 18, 255)
EMPTY_FILL = (36, 36, 36, 255)
FRAME_COLOR = (196, 196, 196, 255)
FRAME_LIVE = (168, 168, 168, 255)
LABEL_BG = (12, 12, 12, 255)
LABEL_FG = (245, 245, 245, 255)
NAME_FG = (220, 220, 220, 255)
def camera_ids(n: int = 20, prefix: str = "cam") -> list[str]:
return [f"{prefix}-{i:02d}" for i in range(1, n + 1)]
def is_camera_identity(identity: str, prefix: str = "cam") -> bool:
ident = (identity or "").strip()
p = prefix.rstrip("-")
return ident.startswith(p + "-") or ident.startswith(p)
def display_name(identity: str) -> str:
"""Human label for a camera identity (cam-07 -> CAM-07)."""
return (identity or "").strip().upper() or "CAMERA"
def letterbox_bgra(src: np.ndarray, tw: int, th: int) -> np.ndarray:
"""Scale BGRA `src` into tw x th, preserving aspect (black bars)."""
if tw < 1 or th < 1:
raise ValueError("tile size must be positive")
if src.ndim != 3 or src.shape[2] not in (3, 4):
raise ValueError(f"expected HxWx3/4, got {src.shape}")
h, w = src.shape[:2]
if src.shape[2] == 3:
bgra = cv2.cvtColor(src, cv2.COLOR_BGR2BGRA)
else:
bgra = src
scale = min(tw / w, th / h)
nw = max(1, int(round(w * scale)))
nh = max(1, int(round(h * scale)))
resized = cv2.resize(bgra, (nw, nh), interpolation=cv2.INTER_AREA)
out = np.zeros((th, tw, 4), dtype=np.uint8)
y = (th - nh) // 2
x = (tw - nw) // 2
out[y : y + nh, x : x + nw] = resized
return out
def bgra_from_livekit(data: memoryview | bytes, width: int, height: int) -> np.ndarray:
arr = np.frombuffer(data, dtype=np.uint8)
return arr.reshape((height, width, 4)).copy()
def placeholder(
width: int,
height: int,
lines: list[str],
*,
color: tuple[int, int, int, int] = (24, 24, 24, 255),
) -> np.ndarray:
img = np.zeros((height, width, 4), dtype=np.uint8)
img[:] = color
if not lines:
return img
font = cv2.FONT_HERSHEY_SIMPLEX
scale = max(0.5, min(width / 960.0, height / 540.0) * 1.2)
thick = 2 if scale >= 0.8 else 1
sizes = [cv2.getTextSize(t, font, scale, thick)[0] for t in lines]
total_h = sum(s[1] for s in sizes) + 12 * (len(lines) - 1)
y = (height - total_h) // 2 + sizes[0][1]
for text, (tw, th) in zip(lines, sizes):
x = max(8, (width - tw) // 2)
cv2.putText(img, text, (x, y), font, scale, (240, 240, 240, 255), thick, cv2.LINE_AA)
y += th + 12
return img
def _fit_text(text: str, max_w: int, max_h: int) -> tuple[float, int, tuple[int, int]]:
font = cv2.FONT_HERSHEY_SIMPLEX
scale = max(0.35, min(max_w / 220.0, max_h / 36.0))
thick = 2 if scale >= 0.7 else 1
for _ in range(8):
(tw, th), _ = cv2.getTextSize(text, font, scale, thick)
if tw <= max_w and th <= max_h:
return scale, thick, (tw, th)
scale *= 0.85
if scale < 0.3:
break
(tw, th), _ = cv2.getTextSize(text, font, scale, thick)
return scale, thick, (tw, th)
def _draw_centered(img: np.ndarray, text: str, box: tuple[int, int, int, int],
color=NAME_FG) -> None:
x0, y0, x1, y1 = box
w, h = max(1, x1 - x0), max(1, y1 - y0)
scale, thick, (tw, th) = _fit_text(text, w - 8, h - 6)
x = x0 + max(2, (w - tw) // 2)
y = y0 + max(th + 2, (h + th) // 2)
cv2.putText(img, text, (x, y), cv2.FONT_HERSHEY_SIMPLEX, scale, color, thick, cv2.LINE_AA)
class CameraGrid:
"""5x4 (default) mosaic of framed camera tiles with name placeholders."""
def __init__(
self,
width: int = 1920,
height: int = 1080,
cols: int = 5,
rows: int = 4,
identities: Optional[Iterable[str]] = None,
gap: int = 8,
border: int = 3,
label_h: int = 28,
) -> None:
if cols < 1 or rows < 1:
raise ValueError("cols/rows must be >= 1")
self.width = int(width)
self.height = int(height)
self.cols = int(cols)
self.rows = int(rows)
self.gap = max(0, int(gap))
self.border = max(1, int(border))
self.label_h = max(16, int(label_h))
self.identities = list(identities) if identities is not None else camera_ids(cols * rows)
self.tile_w = max(32, (self.width - self.gap * (self.cols + 1)) // self.cols)
self.tile_h = max(32, (self.height - self.gap * (self.rows + 1)) // self.rows)
self.inner_w = max(1, self.tile_w - 2 * self.border)
self.inner_h = max(1, self.tile_h - 2 * self.border - self.label_h)
self._live: set[str] = set()
self._tiles: dict[str, np.ndarray] = {}
for ident in self.identities:
self._tiles[ident] = self._compose_cell(ident, None)
self._canvas = np.zeros((self.height, self.width, 4), dtype=np.uint8)
def _compose_cell(self, identity: str, inner: Optional[np.ndarray]) -> np.ndarray:
tw, th = self.tile_w, self.tile_h
b = self.border
cell = np.empty((th, tw, 4), dtype=np.uint8)
cell[:] = CELL_BG
frame = FRAME_LIVE if inner is not None else FRAME_COLOR
cv2.rectangle(cell, (0, 0), (tw - 1, th - 1), frame, b)
# video / placeholder region
iy0, ix0 = b, b
iy1, ix1 = b + self.inner_h, b + self.inner_w
if inner is not None:
fitted = letterbox_bgra(inner, self.inner_w, self.inner_h)
cell[iy0:iy1, ix0:ix1] = fitted
else:
cell[iy0:iy1, ix0:ix1] = EMPTY_FILL
_draw_centered(cell, display_name(identity), (ix0, iy0, ix1, iy1))
# name bar under the feed, inside the frame
ly0 = th - b - self.label_h
cell[ly0 : th - b, b : tw - b] = LABEL_BG
_draw_centered(
cell, display_name(identity),
(b + 2, ly0, tw - b - 2, th - b),
color=LABEL_FG,
)
return cell
def set_frame(self, identity: str, bgra: np.ndarray) -> None:
if identity not in self._tiles:
return
self._live.add(identity)
self._tiles[identity] = self._compose_cell(identity, bgra)
def clear(self, identity: str) -> None:
if identity not in self._tiles:
return
self._live.discard(identity)
self._tiles[identity] = self._compose_cell(identity, None)
def render(self) -> bytes:
canvas = self._canvas
canvas[:] = CANVAS_BG
g = self.gap
for i, ident in enumerate(self.identities):
r, c = divmod(i, self.cols)
if r >= self.rows:
break
y = g + r * (self.tile_h + g)
x = g + c * (self.tile_w + g)
tile = self._tiles[ident]
canvas[y : y + self.tile_h, x : x + self.tile_w] = tile
return canvas.tobytes()
+229
View File
@@ -0,0 +1,229 @@
"""Discover DRM/KMS connectors for headless kmssink output."""
from __future__ import annotations
import json
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
DRM_CLASS = Path("/sys/class/drm")
# xf86drmMode.h DRM_MODE_CONNECTOR_*
_TYPE_SYSFS = {
10: "DP",
11: "HDMI-A",
12: "HDMI-B",
14: "eDP",
15: "Virtual",
}
_STATUS = {1: "connected", 2: "disconnected", 3: "unknown"}
@dataclass
class DrmOutput:
name: str # e.g. HDMI-A-7
sysfs: str # card0-HDMI-A-7
node: str # /dev/dri/card0
driver: str # xe / i915
connector_id: int
status: str
width: int = 0
height: int = 0
crtc_id: int = 0
plane_id: int = 0
bus_id: str = "" # pci:0000:03:00.0 style for kmssink
@property
def connected(self) -> bool:
return self.status == "connected"
def _pci_bus_id(node: str) -> str:
link = Path(f"/sys/class/drm/{Path(node).name}/device")
try:
uevent = (link / "uevent").read_text()
except OSError:
return ""
for line in uevent.splitlines():
if line.startswith("PCI_SLOT_NAME="):
slot = line.split("=", 1)[1].strip()
return f"pci:{slot}"
return ""
def _sysfs_connectors() -> dict[str, dict]:
out: dict[str, dict] = {}
if not DRM_CLASS.is_dir():
return out
for p in sorted(DRM_CLASS.iterdir()):
name = p.name
if not name.startswith("card") or "-" not in name:
continue
card, conn = name.split("-", 1)
status = (p / "status").read_text().strip() if (p / "status").is_file() else "unknown"
modes: list[str] = []
if (p / "modes").is_file():
modes = [m.strip() for m in (p / "modes").read_text().splitlines() if m.strip()]
w = h = 0
if modes and "x" in modes[0]:
try:
wh, *_ = modes[0].split("@")
w, h = (int(x) for x in wh.split("x")[:2])
except ValueError:
pass
out[name] = {
"card": card,
"conn": conn,
"status": status,
"modes": modes,
"width": w,
"height": h,
}
return out
def _drm_info() -> dict:
try:
raw = subprocess.check_output(["drm_info", "-j"], text=True, timeout=10)
except (FileNotFoundError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
return {}
return json.loads(raw)
def _plane_type(plane: dict) -> int:
props = plane.get("properties") or {}
t = props.get("type") or {}
return int(t.get("value") or t.get("raw_value") or 0)
def _primary_planes(info: dict) -> list[dict]:
planes = info.get("planes") or []
return [p for p in planes if _plane_type(p) == 1]
def _pair_sysfs(card: str, typ_name: str, drm_conns: list[dict], sysfs: dict) -> list[tuple[dict, str]]:
names = []
for key, meta in sysfs.items():
if meta["card"] == card and meta["conn"].startswith(typ_name + "-"):
names.append(key)
names.sort(key=lambda k: int(sysfs[k]["conn"].rsplit("-", 1)[-1]))
drm_conns = sorted(drm_conns, key=lambda c: int(c["id"]))
return list(zip(drm_conns, names))
def list_outputs() -> list[DrmOutput]:
sysfs = _sysfs_connectors()
info = _drm_info()
outputs: list[DrmOutput] = []
for node, ninfo in info.items():
driver = ((ninfo.get("driver") or {}).get("name")) or ""
card = Path(node).name # card0
bus_id = _pci_bus_id(node)
crtcs = ninfo.get("crtcs") or []
primaries = _primary_planes(ninfo)
by_type: dict[int, list[dict]] = {}
for c in ninfo.get("connectors") or []:
by_type.setdefault(int(c.get("type") or 0), []).append(c)
assigned_crtc: set[int] = set()
for typ, conns in by_type.items():
typ_name = _TYPE_SYSFS.get(typ)
if not typ_name:
continue
for c, sys_key in _pair_sysfs(card, typ_name, conns, sysfs):
meta = sysfs[sys_key]
status = _STATUS.get(int(c.get("status") or 0), meta["status"]) or "unknown"
modes = c.get("modes") or []
w = h = 0
if modes:
m0 = modes[0]
w = int(m0.get("hdisplay") or 0)
h = int(m0.get("vdisplay") or 0)
if not w:
w, h = meta["width"], meta["height"]
crtc_id = 0
plane_id = 0
props = c.get("properties") or {}
crtc_prop = props.get("CRTC_ID") or {}
already = int(crtc_prop.get("value") or 0)
if already:
crtc_id = already
assigned_crtc.add(already)
outputs.append(
DrmOutput(
name=meta["conn"],
sysfs=sys_key,
node=node,
driver=driver,
connector_id=int(c["id"]),
status=status,
width=w,
height=h,
crtc_id=crtc_id,
plane_id=plane_id,
bus_id=bus_id,
)
)
# assign unused CRTCs + primary planes for disconnected heads
crtc_ids = [int(cr["id"]) for cr in crtcs]
unused = [i for i in crtc_ids if i not in assigned_crtc]
card_outs = [o for o in outputs if Path(o.node).name == card]
def crtc_rank(o: DrmOutput) -> tuple:
return (0 if o.connected else 1, 0 if "HDMI" in o.name else 1, o.name)
for out in sorted(card_outs, key=crtc_rank):
if not out.crtc_id and unused:
out.crtc_id = unused.pop(0)
if out.crtc_id:
try:
idx = crtc_ids.index(out.crtc_id)
except ValueError:
idx = 0
mask = 1 << idx
for p in primaries:
if int(p.get("possible_crtcs") or 0) & mask:
out.plane_id = int(p["id"])
break
outputs.sort(key=lambda o: (o.node, o.name))
return outputs
def select_outputs(
n: int = 3,
names: Optional[list[str]] = None,
connected_only: bool = False,
) -> list[DrmOutput]:
all_outs = list_outputs()
if names:
by_name = {o.name: o for o in all_outs}
picked = []
for name in names:
if name not in by_name:
raise KeyError(f"DRM connector {name!r} not found. Have: {sorted(by_name)}")
picked.append(by_name[name])
return picked
connected = [o for o in all_outs if o.connected]
rest = [o for o in all_outs if not o.connected]
# Prefer the discrete GPU (xe / Arc) HDMI heads for a 3-display wall.
def rank(o: DrmOutput) -> tuple:
return (
0 if o.driver == "xe" else 1,
0 if o.name.startswith("HDMI-A") else 1,
o.name,
)
connected.sort(key=rank)
rest.sort(key=rank)
ordered = connected + ([] if connected_only else rest)
return ordered[:n]
def format_outputs(outs: list[DrmOutput]) -> str:
lines = []
for o in outs:
mode = f"{o.width}x{o.height}" if o.width else "?"
lines.append(
f"{o.name:12} {o.status:12} id={o.connector_id:<4} "
f"{o.driver:4} {o.node} crtc={o.crtc_id} plane={o.plane_id} {mode}"
)
return "\n".join(lines)
+161
View File
@@ -0,0 +1,161 @@
"""GStreamer kmssink pipelines for one DRM connector."""
from __future__ import annotations
import logging
import os
import shutil
import subprocess
from typing import Optional
from drm_outputs import DrmOutput
log = logging.getLogger("cameras.display")
GST_LAUNCH = shutil.which("gst-launch-1.0") or "gst-launch-1.0"
def _kms_props(out: DrmOutput) -> list[str]:
props = [
"kmssink",
f"connector-id={out.connector_id}",
"force-modesetting=true",
"can-scale=true",
"sync=false",
"restore-crtc=true",
]
if out.driver:
props.append(f"driver-name={out.driver}")
return props
def test_pattern_cmd(out: DrmOutput, pattern: str = "smpte", width: int = 1280, height: int = 720) -> list[str]:
return [
GST_LAUNCH, "-e",
"videotestsrc", "is-live=true", f"pattern={pattern}",
"!", f"video/x-raw,width={width},height={height},framerate=30/1",
"!", "videoconvert",
"!", "videoscale",
"!", *_kms_props(out),
]
def appsrc_cmd(out: DrmOutput, width: int, height: int, fps: int = 30) -> list[str]:
"""Raw BGRA frames on stdin -> kmssink."""
block = width * height * 4
return [
GST_LAUNCH, "-e",
"fdsrc", "fd=0", f"blocksize={block}",
"!", "videoparse",
f"width={width}", f"height={height}", "format=bgra", f"framerate={fps}/1",
"!", "queue", "max-size-buffers=2", "leaky=downstream",
"!", "videoconvert",
"!", "videoscale", "method=0",
"!", *_kms_props(out),
]
class KmsPipeline:
"""One gst-launch kmssink process, fed raw BGRA on stdin (or self-running testsrc)."""
def __init__(self, out: DrmOutput, identity: str):
self.out = out
self.identity = identity
self.proc: Optional[subprocess.Popen] = None
self.width = 0
self.height = 0
self.mode = "idle"
@property
def alive(self) -> bool:
return self.proc is not None and self.proc.poll() is None
def stop(self) -> None:
if self.proc is None:
return
if self.proc.stdin:
try:
self.proc.stdin.close()
except OSError:
pass
if self.proc.poll() is None:
self.proc.terminate()
try:
self.proc.wait(timeout=3)
except subprocess.TimeoutExpired:
self.proc.kill()
self.proc.wait(timeout=2)
self.proc = None
self.mode = "idle"
def start_test_pattern(self, pattern: str = "smpte") -> None:
self.stop()
if not self.out.connected:
log.warning("%s: %s not connected, skip test pattern", self.identity, self.out.name)
return
cmd = test_pattern_cmd(self.out, pattern=pattern)
log.info("%s: gst test pattern %s on %s id=%s", self.identity, pattern, self.out.name, self.out.connector_id)
self.proc = subprocess.Popen(
cmd,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
env=_gst_env(),
)
self.mode = "pattern"
def start_appsrc(self, width: int, height: int, fps: int = 30) -> None:
if self.alive and self.mode == "appsrc" and self.width == width and self.height == height:
return
self.stop()
if not self.out.connected:
log.warning("%s: %s not connected, cannot start kmssink", self.identity, self.out.name)
return
cmd = appsrc_cmd(self.out, width, height, fps)
log.info(
"%s: gst appsrc %dx%d -> %s (connector %s %s)",
self.identity, width, height, self.out.name, self.out.connector_id, self.out.driver,
)
self.proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
env=_gst_env(),
bufsize=0,
)
self.width = width
self.height = height
self.mode = "appsrc"
def push(self, frame: bytes) -> bool:
if not self.alive or self.proc is None or self.proc.stdin is None:
return False
try:
self.proc.stdin.write(frame)
return True
except BrokenPipeError:
log.warning("%s: gst stdin closed (rc=%s)", self.identity, self.proc.poll())
self.proc = None
return False
def gst_errors(self) -> str:
if self.proc is None or self.proc.stderr is None:
return ""
try:
os.set_blocking(self.proc.stderr.fileno(), False)
except OSError:
pass
try:
chunk = self.proc.stderr.read() or b""
except OSError:
return ""
return chunk.decode("utf-8", "replace")
def _gst_env() -> dict:
env = os.environ.copy()
# Never try X11/Wayland; kmssink is the point.
env.pop("DISPLAY", None)
env.pop("WAYLAND_DISPLAY", None)
env.setdefault("GST_GL_PLATFORM", "egl")
return env
+6
View File
@@ -37,6 +37,10 @@ def worker_args_for(cfg, cam) -> list[str]:
"--width", str(cfg.width), "--width", str(cfg.width),
"--height", str(cfg.height), "--height", str(cfg.height),
"--fps", str(cfg.fps), "--fps", str(cfg.fps),
"--bitrate", str(cfg.video_bitrate),
"--video-codec", cfg.video_codec,
"--video-encoder", cfg.video_encoder,
"--vaapi-device", cfg.vaapi_device,
"--audio-rate", str(cfg.audio_rate), "--audio-rate", str(cfg.audio_rate),
"--enhance-mode", cfg.enhance_mode, "--enhance-mode", cfg.enhance_mode,
"--enhance-model", cfg.enhance_model, "--enhance-model", cfg.enhance_model,
@@ -59,6 +63,8 @@ class Supervisor:
def spawn(self, cam) -> None: def spawn(self, cam) -> None:
args = worker_args_for(self.cfg, cam) args = worker_args_for(self.cfg, cam)
env = dict(os.environ) env = dict(os.environ)
env.setdefault("LIBVA_DRIVER_NAME", "iHD")
env.setdefault("LIBVA_DRM_DEVICE", self.cfg.vaapi_device)
p = subprocess.Popen( p = subprocess.Popen(
[sys.executable, str(Path(__file__).parent / "run_worker.py"), [sys.executable, str(Path(__file__).parent / "run_worker.py"),
*args], *args],
+348
View File
@@ -0,0 +1,348 @@
#!/usr/bin/env python3
"""Headless 3-display LiveKit wall via GStreamer kmssink.
1. grid — 5x4 mosaic of cam-01..cam-20
2. speaker — designated speaker camera (DISPLAY_SPEAKER_CAMERA)
3. screenshare — STUB until the production LiveKit server is online
.venv/bin/python run_displays.py --list
.venv/bin/python run_displays.py --test-pattern
.venv/bin/python run_displays.py
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import signal
import sys
import time
from typing import Optional
from urllib.parse import urlparse
import numpy as np
from config import load_config
from display_grid import (
CameraGrid,
ROLES,
bgra_from_livekit,
camera_ids,
is_camera_identity,
placeholder,
)
from drm_outputs import DrmOutput, format_outputs, list_outputs, select_outputs
from gst_sink import KmsPipeline
from tokens import make_viewer_token
log = logging.getLogger("cameras.display")
_PATTERNS = ("smpte", "ball", "snow")
_PLACE_W, _PLACE_H = 1280, 720
def _public_url(cfg) -> str:
if cfg.livekit_public_url:
return cfg.livekit_public_url.rstrip("/")
parsed = urlparse(cfg.url)
host = parsed.hostname or "127.0.0.1"
if host in ("127.0.0.1", "localhost"):
host = "10.200.0.20"
scheme = parsed.scheme or "ws"
port = parsed.port
if port:
return f"{scheme}://{host}:{port}"
return f"{scheme}://{host}"
def _bind_roles(roles: list[str], connectors: list[str]) -> list[tuple[str, DrmOutput]]:
roles = [r.strip().lower() for r in roles if r.strip()]
for r in roles:
if r not in ROLES:
raise KeyError(f"unknown display role {r!r}; expected {ROLES}")
names = connectors if connectors else None
outs = select_outputs(n=len(roles), names=names)
if len(outs) < len(roles):
log.warning("wanted %d displays, found %d DRM connectors", len(roles), len(outs))
slots = list(zip(roles, outs))
for role, out in slots:
log.info("role %s -> %s (%s connector-id=%s %s)",
role, out.name, out.status, out.connector_id, out.driver)
return slots
class Wall:
def __init__(self, cfg, slots: list[tuple[str, DrmOutput]]):
self.cfg = cfg
self.slots = {role: KmsPipeline(out, role) for role, out in slots}
prefix = cfg.participant_prefix or "cam"
n = cfg.display_grid_cols * cfg.display_grid_rows
self.grid = CameraGrid(
width=cfg.display_grid_width,
height=cfg.display_grid_height,
cols=cfg.display_grid_cols,
rows=cfg.display_grid_rows,
identities=camera_ids(n, prefix),
)
self.speaker_camera = (cfg.display_speaker_camera or "cam-01").strip()
self.active_speaker: Optional[str] = None
self._speaker_frame: Optional[np.ndarray] = None
self._tasks: dict[str, asyncio.Task] = {}
self._stop = asyncio.Event()
# Screenshare is a stub until production LiveKit is online.
self._share_placeholder = placeholder(_PLACE_W, _PLACE_H, [
"Screen share",
"stub — waiting for production LiveKit",
_public_url(cfg),
])
cam = self.speaker_camera
self._speaker_placeholder = placeholder(_PLACE_W, _PLACE_H, [
"Speaker camera",
cam if cam.lower() != "active" else "(following active speaker)",
])
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:
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.push(data):
sink.start_appsrc(width, height, fps)
sink.push(data)
err = sink.gst_errors()
if err:
for line in err.strip().splitlines()[-6:]:
log.warning("%s gst: %s", role, line)
async def _output_loops(self) -> None:
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()
while not self._stop.is_set():
now = time.monotonic()
if now >= next_grid:
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
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)
share = self._share_placeholder
self._push_sink("screenshare", share.tobytes(), share.shape[1], share.shape[0], self.cfg.fps)
next_spk = now + spk_period
await asyncio.sleep(0.005)
async def _pump_camera(self, track, identity: str) -> None:
import livekit.rtc as rtc
stream = rtc.VideoStream(track)
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)
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
finally:
await stream.aclose()
self.grid.clear(identity)
if identity == self.speaker_identity():
self._speaker_frame = None
def _track_key(self, participant_identity: str, track) -> str:
return f"{participant_identity}:{getattr(track, 'sid', id(track))}"
def attach(self, track, publication, participant, rtc) -> None:
ident = participant.identity
if ident == self.cfg.display_identity:
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}")
def subscribe_if_wanted(self, participant, rtc) -> None:
ident = participant.identity
if ident == self.cfg.display_identity:
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:
log.info("subscribe %s from %s (source=%s)", pub.sid, ident, pub.source)
pub.set_subscribed(True)
def detach(self, track, participant) -> None:
key = self._track_key(participant.identity, track)
task = self._tasks.pop(key, None)
if task:
task.cancel()
async def run(self) -> int:
import livekit.rtc as rtc
room = rtc.Room()
@room.on("track_subscribed")
def _on_sub(track, publication, participant):
if track.kind == rtc.TrackKind.KIND_VIDEO:
log.info("subscribed %s from %s", publication.sid, participant.identity)
self.attach(track, publication, participant, rtc)
@room.on("track_unsubscribed")
def _on_unsub(track, publication, participant):
self.detach(track, participant)
@room.on("track_published")
def _on_pub(publication, participant):
self.subscribe_if_wanted(participant, rtc)
@room.on("participant_connected")
def _on_pc(participant):
log.info("participant joined: %s", participant.identity)
self.subscribe_if_wanted(participant, rtc)
@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)
]
self.active_speaker = cams[0] if cams else None
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))
for rp in room.remote_participants.values():
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,
)
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
try:
await self._stop.wait()
finally:
loops.cancel()
for t in self._tasks.values():
t.cancel()
for sink in self.slots.values():
sink.stop()
await room.disconnect()
return 0
def run_test_pattern(slots: list[tuple[str, DrmOutput]]) -> int:
sinks = []
for i, (role, out) in enumerate(slots):
sink = KmsPipeline(out, role)
sink.start_test_pattern(_PATTERNS[i % len(_PATTERNS)])
sinks.append(sink)
log.info("test patterns on %d sink(s); Ctrl-C to stop", sum(1 for s in sinks if s.alive))
stop = False
def _stop(*_):
nonlocal stop
stop = True
signal.signal(signal.SIGINT, _stop)
signal.signal(signal.SIGTERM, _stop)
try:
while not stop:
for s in sinks:
err = s.gst_errors()
if err:
for line in err.strip().splitlines()[-8:]:
log.warning("%s gst: %s", s.identity, line)
if any(s.out.connected and not s.alive and s.mode == "dead" for s in sinks):
return 1
time.sleep(0.5)
finally:
for s in sinks:
s.stop()
return 0
def main(argv: Optional[list[str]] = None) -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--list", action="store_true", help="list DRM connectors and exit")
ap.add_argument("--test-pattern", action="store_true",
help="drive displays with GStreamer videotestsrc (no LiveKit)")
ap.add_argument("--connectors", default="",
help="comma-separated DRM names, e.g. HDMI-A-7,HDMI-A-5,HDMI-A-6")
ap.add_argument("--roles", default="",
help="comma-separated roles (default grid,speaker,screenshare)")
ap.add_argument("--speaker-camera", default="",
help="cam-NN or 'active' (overrides DISPLAY_SPEAKER_CAMERA)")
args = ap.parse_args(argv)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [display] %(levelname)s %(message)s",
)
if args.list:
print(format_outputs(list_outputs()) or "(no DRM connectors)")
return 0
cfg = load_config()
logging.getLogger().setLevel(getattr(logging, cfg.log_level, logging.INFO))
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
connectors = [p.strip() for p in args.connectors.split(",") if p.strip()] or cfg.display_connectors
try:
slots = _bind_roles(roles, connectors)
except KeyError as exc:
log.error("%s", exc)
return 2
if not slots:
log.error("no DRM outputs to drive")
return 2
if args.test_pattern:
return run_test_pattern(slots)
return asyncio.run(Wall(cfg, slots).run())
if __name__ == "__main__":
sys.exit(main())
+80 -13
View File
@@ -15,12 +15,19 @@ import signal
import subprocess import subprocess
import sys import sys
import time import time
from collections import deque
import numpy as np import numpy as np
log = logging.getLogger("cameras.worker") log = logging.getLogger("cameras.worker")
def drain_held_frames(pending: deque, now: float, hold_s: float):
"""Yield frame payloads captured at least hold_s seconds ago."""
while pending and (now - pending[0][0]) >= hold_s:
yield pending.popleft()[1]
def main() -> int: def main() -> int:
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("--camera-json", required=True, ap.add_argument("--camera-json", required=True,
@@ -28,9 +35,13 @@ def main() -> int:
ap.add_argument("--room", required=True) ap.add_argument("--room", required=True)
ap.add_argument("--url", required=True) ap.add_argument("--url", required=True)
ap.add_argument("--token", required=True) ap.add_argument("--token", required=True)
ap.add_argument("--width", type=int, default=1280) ap.add_argument("--width", type=int, default=640)
ap.add_argument("--height", type=int, default=720) ap.add_argument("--height", type=int, default=360)
ap.add_argument("--fps", type=int, default=30) ap.add_argument("--fps", type=int, default=30)
ap.add_argument("--bitrate", type=int, default=800_000)
ap.add_argument("--video-codec", default="h264")
ap.add_argument("--video-encoder", default="vaapi")
ap.add_argument("--vaapi-device", default="/dev/dri/renderD129")
ap.add_argument("--audio-rate", type=int, default=16000) ap.add_argument("--audio-rate", type=int, default=16000)
ap.add_argument("--enhance-mode", default="auto") ap.add_argument("--enhance-mode", default="auto")
ap.add_argument("--enhance-model", ap.add_argument("--enhance-model",
@@ -56,6 +67,9 @@ def main() -> int:
identity = cam["identity"] identity = cam["identity"]
video_dev = cam["video_device"] video_dev = cam["video_device"]
audio_card = cam.get("audio_card") audio_card = cam.get("audio_card")
if args.vaapi_device:
os.environ.setdefault("LIBVA_DRM_DEVICE", args.vaapi_device)
os.environ.setdefault("LIBVA_DRIVER_NAME", "iHD")
async def run() -> int: async def run() -> int:
cleaner = AudioCleaner( cleaner = AudioCleaner(
@@ -81,29 +95,38 @@ def main() -> int:
vproc = subprocess.Popen( vproc = subprocess.Popen(
["ffmpeg", "-hide_banner", "-loglevel", "error", ["ffmpeg", "-hide_banner", "-loglevel", "error",
"-fflags", "nobuffer", "-flags", "low_delay",
"-thread_queue_size", "4",
"-f", "v4l2", "-f", "v4l2",
"-input_format", "mjpeg", "-input_format", "mjpeg",
"-video_size", f"{args.width}x{args.height}", "-video_size", f"{args.width}x{args.height}",
"-framerate", str(args.fps), "-framerate", str(args.fps),
"-i", video_dev, "-i", video_dev,
"-f", "rawvideo", "-pix_fmt", "bgra", "-f", "rawvideo", "-pix_fmt", "yuv420p",
"pipe:1"], "pipe:1"],
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
) )
frame_bytes = args.width * args.height * 4 frame_bytes = args.width * args.height * 3 // 2
video_frames = 0 video_frames = 0
# Audio is published in enhance hops (default 200 ms). Hold video by
# that same amount so lipsync at the subscriber: a frame captured at
# T is sent when the audio hop covering T is sent.
video_hold_s = float(args.enhance_hop_s) if audio_card is not None else 0.0
pending: deque[tuple[float, bytes]] = deque()
max_pending = max(8, int(args.fps * (video_hold_s + 0.15)) + 4)
if video_hold_s > 0:
log.info("av sync: holding video %.0f ms to match audio hop",
video_hold_s * 1000.0)
async def video_loop(): async def video_loop():
nonlocal video_frames nonlocal video_frames
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
last = time.monotonic()
while vproc.poll() is None: while vproc.poll() is None:
data = await loop.run_in_executor( data = await loop.run_in_executor(
None, vproc.stdout.read, frame_bytes) None, vproc.stdout.read, frame_bytes)
if not data or len(data) < frame_bytes: if not data or len(data) < frame_bytes:
if not data: if not data:
break break
# partial read: accumulate (v4l2/ffmpeg shouldn't do this)
keep = b"" keep = b""
while len(keep) < frame_bytes: while len(keep) < frame_bytes:
more = await loop.run_in_executor( more = await loop.run_in_executor(
@@ -114,13 +137,16 @@ def main() -> int:
keep += more keep += more
data = keep data = keep
now = time.monotonic() now = time.monotonic()
ts_us = int((now - last) * 1_000_000) pending.append((now, data))
while len(pending) > max_pending:
pending.popleft()
for payload in drain_held_frames(pending, now, video_hold_s):
try: try:
video_source.capture_frame( video_source.capture_frame(
rtc.VideoFrame( rtc.VideoFrame(
args.width, args.height, args.width, args.height,
rtc.VideoBufferType.BGRA, data), rtc.VideoBufferType.I420, payload),
timestamp_us=ts_us, timestamp_us=int(time.monotonic() * 1_000_000),
) )
video_frames += 1 video_frames += 1
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
@@ -131,7 +157,8 @@ def main() -> int:
atrack: rtc.LocalAudioTrack | None = None atrack: rtc.LocalAudioTrack | None = None
if audio_card is not None: if audio_card is not None:
audio_source = rtc.AudioSource( audio_source = rtc.AudioSource(
sample_rate=args.audio_rate, num_channels=1) sample_rate=args.audio_rate, num_channels=1,
queue_size_ms=max(250, int(args.enhance_hop_s * 1000) + 50))
atrack = rtc.LocalAudioTrack.create_audio_track( atrack = rtc.LocalAudioTrack.create_audio_track(
f"{identity}-audio", audio_source) f"{identity}-audio", audio_source)
@@ -180,7 +207,7 @@ def main() -> int:
aproc = subprocess.Popen( aproc = subprocess.Popen(
["ffmpeg", "-hide_banner", "-loglevel", "error", ["ffmpeg", "-hide_banner", "-loglevel", "error",
"-fflags", "nobuffer", "-flags", "low_delay", "-fflags", "nobuffer", "-flags", "low_delay",
"-thread_queue_size", "64", "-thread_queue_size", "8",
"-f", "alsa", "-i", f"hw:{audio_card},0", "-f", "alsa", "-i", f"hw:{audio_card},0",
"-ar", str(args.audio_rate), "-ac", "2", "-ar", str(args.audio_rate), "-ac", "2",
"-f", "s16le", "pipe:1"], "-f", "s16le", "pipe:1"],
@@ -188,9 +215,49 @@ def main() -> int:
) )
# ---------------- publish ---------------- # ---------------- publish ----------------
from livekit.rtc import TrackSource, TrackPublishOptions from livekit.rtc import TrackSource, TrackPublishOptions, VideoEncoding, VideoCodec
from livekit.rtc._proto.room_pb2 import (
ENCODER_BACKEND_AUTO,
ENCODER_BACKEND_SOFTWARE,
ENCODER_BACKEND_HARDWARE,
ENCODER_BACKEND_NVENC,
ENCODER_BACKEND_VAAPI,
ENCODER_BACKEND_VIDEOTOOLBOX,
DEGRADATION_PREFERENCE_MAINTAIN_RESOLUTION,
)
encoder_map = {
"auto": ENCODER_BACKEND_AUTO,
"software": ENCODER_BACKEND_SOFTWARE,
"hardware": ENCODER_BACKEND_HARDWARE,
"nvenc": ENCODER_BACKEND_NVENC,
"vaapi": ENCODER_BACKEND_VAAPI,
"videotoolbox": ENCODER_BACKEND_VIDEOTOOLBOX,
}
codec_map = {
"vp8": VideoCodec.VP8,
"h264": VideoCodec.H264,
"av1": VideoCodec.AV1,
"vp9": VideoCodec.VP9,
"h265": VideoCodec.H265,
}
encoder = encoder_map.get(args.video_encoder, ENCODER_BACKEND_VAAPI)
codec = codec_map.get(args.video_codec, VideoCodec.H264)
pub_opts = TrackPublishOptions( pub_opts = TrackPublishOptions(
source=TrackSource.SOURCE_CAMERA, simulcast=False) source=TrackSource.SOURCE_CAMERA,
simulcast=False,
video_codec=codec,
video_encoding=VideoEncoding(
max_bitrate=int(args.bitrate),
max_framerate=int(args.fps),
),
video_encoder=encoder,
degradation_preference=DEGRADATION_PREFERENCE_MAINTAIN_RESOLUTION,
)
log.info(
"publish video %dx%d@%d %s encoder=%s bitrate=%d vaapi=%s",
args.width, args.height, args.fps, args.video_codec,
args.video_encoder, args.bitrate, args.vaapi_device,
)
await room.local_participant.publish_track(video_track, pub_opts) await room.local_participant.publish_track(video_track, pub_opts)
if atrack is not None: if atrack is not None:
await room.local_participant.publish_track( await room.local_participant.publish_track(
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""Print a compact DRM connector/CRTC/plane map from drm_info -j."""
from __future__ import annotations
import json
import subprocess
import sys
def main() -> int:
raw = subprocess.check_output(["drm_info", "-j"], text=True)
data = json.loads(raw)
for node, info in data.items():
driver = info.get("driver", {}).get("name") or info.get("driver_name")
device = info.get("device", {})
print(f"NODE {node} driver={driver} device={device}")
conns = info.get("connectors") or info.get("Connector") or []
if isinstance(conns, dict):
conns = list(conns.values())
crtcs = info.get("crtcs") or []
planes = info.get("planes") or []
print(f" crtcs={len(crtcs)} planes={len(planes)} connectors={len(conns)}")
if conns and isinstance(conns[0], dict):
print(" connector keys:", sorted(conns[0].keys())[:20])
for c in conns:
if not isinstance(c, dict):
print(" conn", c)
continue
cid = c.get("id") or c.get("object_id")
typ = c.get("type") or c.get("connector_type")
name = c.get("name")
st = c.get("status")
modes = c.get("modes") or []
mode0 = None
if modes:
m = modes[0]
if isinstance(m, dict):
mode0 = f"{m.get('name') or m.get('clock')} {m.get('hdisplay')}x{m.get('vdisplay')}"
else:
mode0 = str(m)[:80]
print(f" connector id={cid} type={typ} name={name} status={st} mode0={mode0}")
if planes and isinstance(planes[0], dict):
print(" plane keys:", sorted(planes[0].keys())[:20])
for p in planes[:8]:
print(" plane", {k: p.get(k) for k in list(p)[:12]})
if crtcs and isinstance(crtcs[0], dict):
print(" crtc keys:", sorted(crtcs[0].keys())[:20])
for cr in crtcs:
print(" crtc", {k: cr.get(k) for k in list(cr)[:12]})
return 0
if __name__ == "__main__":
sys.exit(main())
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Print a LiveKit Meet URL + token so a speaker can join and share a screen.
STUB / future: do not use until the production LiveKit server is online.
The third display currently shows a placeholder only.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from urllib.parse import quote, urlparse
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from config import load_config
from tokens import make_speaker_token
def public_url(cfg) -> str:
if cfg.livekit_public_url:
return cfg.livekit_public_url.rstrip("/")
parsed = urlparse(cfg.url)
host = parsed.hostname or "127.0.0.1"
if host in ("127.0.0.1", "localhost"):
host = "10.200.0.20"
scheme = parsed.scheme or "ws"
port = parsed.port
if port:
return f"{scheme}://{host}:{port}"
return f"{scheme}://{host}"
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--identity", default="", help="default: DISPLAY_SPEAKER_PARTICIPANT")
ap.add_argument("--name", default="Speaker")
args = ap.parse_args()
cfg = load_config()
identity = args.identity or cfg.display_speaker_participant or "speaker"
token = make_speaker_token(cfg.api_key, cfg.api_secret, cfg.room, identity, args.name)
url = public_url(cfg)
meet = (
"https://meet.livekit.io/custom"
f"?liveKitUrl={quote(url, safe='')}&token={quote(token, safe='')}"
)
print(f"room: {cfg.room}")
print(f"identity: {identity}")
print(f"url: {url}")
print(f"token: {token}")
print()
print("Open this on the speaker laptop, then Share screen:")
print(meet)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+23
View File
@@ -0,0 +1,23 @@
"""A/V hold queue: video is delayed to match the audio hop."""
from collections import deque
from run_worker import drain_held_frames
def test_drain_nothing_until_hold():
q = deque([(0.0, b"a"), (0.05, b"b")])
assert list(drain_held_frames(q, now=0.1, hold_s=0.2)) == []
assert len(q) == 2
def test_drain_ready_frames():
q = deque([(0.0, b"a"), (0.05, b"b"), (0.25, b"c")])
got = list(drain_held_frames(q, now=0.26, hold_s=0.2))
assert got == [b"a", b"b"]
assert list(q) == [(0.25, b"c")]
def test_no_hold_drains_all():
q = deque([(1.0, b"x")])
assert list(drain_held_frames(q, now=1.0, hold_s=0.0)) == [b"x"]
assert not q
+81
View File
@@ -0,0 +1,81 @@
"""5x4 camera mosaic helpers."""
import numpy as np
from display_grid import (
CameraGrid,
camera_ids,
display_name,
is_camera_identity,
letterbox_bgra,
placeholder,
)
def test_camera_ids():
ids = camera_ids(20)
assert ids[0] == "cam-01" and ids[-1] == "cam-20"
assert len(ids) == 20
def test_is_camera_identity():
assert is_camera_identity("cam-07")
assert is_camera_identity("cam-07", "cam")
assert not is_camera_identity("speaker")
assert not is_camera_identity("display-wall")
def test_display_name():
assert display_name("cam-07") == "CAM-07"
assert display_name("cam-01") == "CAM-01"
def test_letterbox_preserves_canvas_size():
src = np.zeros((720, 1280, 4), dtype=np.uint8)
src[:] = (0, 0, 255, 255)
out = letterbox_bgra(src, 384, 270)
assert out.shape == (270, 384, 4)
assert out.dtype == np.uint8
def test_grid_render_size_and_slots():
g = CameraGrid(width=1920, height=1080, cols=5, rows=4)
assert g.tile_w == (1920 - g.gap * 6) // 5
assert g.tile_h == (1080 - g.gap * 5) // 4
assert g.inner_w < g.tile_w
assert g.inner_h < g.tile_h
assert len(g.identities) == 20
buf = g.render()
assert len(buf) == 1920 * 1080 * 4
frame = np.zeros((720, 1280, 4), dtype=np.uint8)
frame[:] = (255, 0, 0, 255)
g.set_frame("cam-07", frame)
buf2 = g.render()
assert buf2 != buf
g.clear("cam-07")
assert len(g.render()) == len(buf)
def test_empty_tiles_have_name_placeholder():
g = CameraGrid(width=1920, height=1080, cols=5, rows=4)
# Name is drawn in the inner region — not a flat fill.
tile = g._tiles["cam-01"]
assert tile.shape == (g.tile_h, g.tile_w, 4)
assert tile.sum() > 0
inner = tile[g.border : g.border + g.inner_h, g.border : g.border + g.inner_w]
# Center of an empty tile is not uniform (text glyphs).
assert inner.std() > 1.0
def test_gap_separates_tiles():
g = CameraGrid(width=1920, height=1080, cols=5, rows=4, gap=8)
canvas = np.frombuffer(g.render(), dtype=np.uint8).reshape(1080, 1920, 4)
# Pixel in the gutter between col 0 and col 1 is canvas background, not a tile.
x = g.gap + g.tile_w + g.gap // 2
y = g.gap + 4
assert tuple(canvas[y, x]) == (10, 10, 10, 255)
def test_placeholder_draws_text():
img = placeholder(640, 360, ["Screen share", "stub"])
assert img.shape == (360, 640, 4)
assert img.sum() > 0
+21
View File
@@ -0,0 +1,21 @@
"""DRM connector discovery for the 3-display LiveKit wall."""
from drm_outputs import list_outputs, select_outputs
def test_lists_arc_hdmi():
outs = list_outputs()
assert outs, "expected at least one DRM connector"
names = {o.name for o in outs}
assert "HDMI-A-7" in names
hdmi7 = next(o for o in outs if o.name == "HDMI-A-7")
assert hdmi7.connector_id > 0
assert hdmi7.driver in ("xe", "i915")
assert hdmi7.node.startswith("/dev/dri/card")
def test_select_three_prefers_xe_hdmi():
three = select_outputs(n=3)
assert len(three) == 3
assert three[0].name == "HDMI-A-7" or three[0].connected
ids = [o.connector_id for o in three]
assert len(set(ids)) == 3
+1 -1
View File
@@ -7,4 +7,4 @@ def test_alsa_ffmpeg_uses_low_delay_flags():
src = inspect.getsource(run_worker) src = inspect.getsource(run_worker)
assert '"-fflags", "nobuffer"' in src assert '"-fflags", "nobuffer"' in src
assert '"-flags", "low_delay"' in src assert '"-flags", "low_delay"' in src
assert '"-thread_queue_size", "64"' in src assert '"-thread_queue_size"' in src
+37
View File
@@ -18,3 +18,40 @@ def make_token(url: str, api_key: str, api_secret: str,
)) ))
) )
return token.to_jwt() return token.to_jwt()
def make_speaker_token(api_key: str, api_secret: str,
room: str, identity: str = "speaker",
name: str = "") -> str:
"""Publisher token so a speaker can join and share their screen."""
token = (
AccessToken(api_key, api_secret)
.with_identity(identity)
.with_name(name or identity)
.with_grants(VideoGrants(
room_join=True,
room=room,
can_publish=True,
can_subscribe=True,
can_publish_data=True,
))
)
return token.to_jwt()
def make_viewer_token(api_key: str, api_secret: str,
room: str, identity: str) -> str:
"""Subscribe-only token for headless display clients."""
token = (
AccessToken(api_key, api_secret)
.with_identity(identity)
.with_name(identity)
.with_grants(VideoGrants(
room_join=True,
room=room,
can_publish=False,
can_subscribe=True,
can_publish_data=False,
))
)
return token.to_jwt()