chore: snapshot livekit-cameras after serial pin + speechbrain lengths fix
This commit is contained in:
@@ -0,0 +1,32 @@
|
|||||||
|
# LiveKit server connection
|
||||||
|
LIVEKIT_URL=ws://localhost:7880
|
||||||
|
LIVEKIT_API_KEY=APIKey_xxx
|
||||||
|
LIVEKIT_API_SECRET=APIsecret_xxx
|
||||||
|
|
||||||
|
# Room / naming
|
||||||
|
LIVEKIT_ROOM=cameras
|
||||||
|
PARTICIPANT_PREFIX=cam
|
||||||
|
|
||||||
|
# Streams
|
||||||
|
# "all" = every discovered camera, "1,3,5" or "0-19" = subset
|
||||||
|
CAMERAS=all
|
||||||
|
VIDEO_WIDTH=1280
|
||||||
|
VIDEO_HEIGHT=720
|
||||||
|
VIDEO_FPS=30
|
||||||
|
VIDEO_MIN_FPS=5
|
||||||
|
|
||||||
|
# Audio
|
||||||
|
AUDIO_SAMPLE_RATE=16000
|
||||||
|
AUDIO_CHANNELS=1
|
||||||
|
|
||||||
|
# Audio cleanup (speechbrain)
|
||||||
|
# mode: auto (default: enhanced if the model is cached, otherwise passthrough)
|
||||||
|
# force (always enhance), off (always passthrough)
|
||||||
|
ENHANCE_MODE=auto
|
||||||
|
# speechbrain model repo (must be 16 kHz; metricGAN-plus runs ~20-40ms/frame on CPU)
|
||||||
|
ENHANCE_MODEL=speechbrain/metricgan-plus-voicebank
|
||||||
|
ENHANCE_CHUNK_S=0.2
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
PUBLISH_TIMEOUT_S=60
|
||||||
|
LOG_LEVEL=INFO
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
*.wav
|
||||||
|
.hermes/
|
||||||
|
_health_discover.py
|
||||||
|
_test_enhance.py
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# livekit-cameras
|
||||||
|
|
||||||
|
Publishes 20 Logitech C920 webcam streams (video + cleaned audio) to a
|
||||||
|
LiveKit room.
|
||||||
|
|
||||||
|
Each camera becomes one LiveKit participant (`cam-01` .. `cam-20`) that
|
||||||
|
publishes:
|
||||||
|
- a camera video track (1280x720 @ 30 fps by default, MJPG from V4L2)
|
||||||
|
- a microphone audio track (16 kHz mono, cleaned with speechbrain)
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
run.py orchestrator: discovery + per-camera worker processes
|
||||||
|
run_worker.py one camera: ffmpeg capture -> LiveKit track publish
|
||||||
|
discovery.py serial-based camera <-> ALSA card discovery
|
||||||
|
audio_cleanup.py speechbrain SpectralMaskEnhancement (16 kHz) + fallback
|
||||||
|
tokens.py LiveKit JWT generation
|
||||||
|
config.py .env / environment configuration
|
||||||
|
prefetch_model.py one-time speechbrain model download
|
||||||
|
generate_udev_rules.py (re)generate udev pinning + slots.json from live state
|
||||||
|
slots.json stable slot map: cam-NN -> USB serial, /dev/camNN, ALSA card
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv venv --python 3.12
|
||||||
|
uv pip install -p .venv/bin/python -r pyproject.toml # or: uv sync
|
||||||
|
cp .env.example .env # then fill in LIVEKIT_URL / API_KEY / API_SECRET
|
||||||
|
.venv/bin/python prefetch_model.py # download speechbrain model once
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stable camera identity (cam-01 is always cam-01)
|
||||||
|
|
||||||
|
Each C920 has a unique built-in USB serial — that is the camera's UUID.
|
||||||
|
udev rules (`/etc/udev/rules.d/99-c920-pin.rules`, generated from the live
|
||||||
|
state) bind every slot to a serial, so identity survives reboots, hub
|
||||||
|
reordering, and any connect sequence:
|
||||||
|
|
||||||
|
- video: `/dev/cam01` .. `/dev/cam20` (primary UVC node; `.meta` = metadata)
|
||||||
|
- audio: fixed ALSA card per slot, `hw:2,0` .. `hw:21,0` (SOUND_CARD_INDEX)
|
||||||
|
|
||||||
|
`discovery.py` assigns identities from `slots.json` (serial lookup, with a
|
||||||
|
port-based fallback for cameras not yet in the map). To re-map slots (e.g.
|
||||||
|
after physically swapping cameras), regenerate:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python generate_udev_rules.py --write
|
||||||
|
sudo udevadm control --reload-rules
|
||||||
|
sudo udevadm trigger --subsystem-match=video4linux --action=add
|
||||||
|
sudo udevadm trigger --subsystem-match=sound --action=add
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python run.py # all 20 cameras
|
||||||
|
.venv/bin/python run.py --cameras 1,5 # subset
|
||||||
|
.venv/bin/python run.py --once # status report after timeout
|
||||||
|
```
|
||||||
|
|
||||||
|
## Audio cleanup
|
||||||
|
|
||||||
|
The mic audio is cleaned per chunk (default 200 ms) by
|
||||||
|
`speechbrain/metricgan-plus-voicebank` (16 kHz, CPU). One worker process per
|
||||||
|
camera loads the model once and reuses it; on this 28-core box all 20 stay
|
||||||
|
real-time. `ENHANCE_MODE=off` (or a failed model load with `auto`) falls back
|
||||||
|
to a lightweight high-pass + soft-clip DSP path so streams still work.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Video uses MJPG directly from the UVC interface (`ffmpeg -f v4l2
|
||||||
|
-input_format mjpeg`) — cheap, and converts to BGRA for LiveKit.
|
||||||
|
- Audio: ALSA mics on these C920s deliver 16 kHz **stereo** only; the worker
|
||||||
|
downmixes to mono for LiveKit.
|
||||||
|
- Camera <-> card pairing is resolved by matching USB port paths under /sys,
|
||||||
|
so each mic belongs to its own camera.
|
||||||
|
- Workers auto-restart (up to 10 times) if a camera drops.
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"""Audio cleanup for camera mics, using speechbrain.
|
||||||
|
|
||||||
|
Pipeline per audio frame (16 kHz mono int16, ~100 ms chunks from the mic):
|
||||||
|
int16 -> float32 [-1, 1] -> speechbrain enhancement -> int16
|
||||||
|
|
||||||
|
Two implementations, selected by availability and the ENHANCE_MODE config:
|
||||||
|
* "model" - speechbrain SpectralMaskEnhancement (metricGAN-plus). Real
|
||||||
|
ML-based noise suppression. Runs on CPU; ~20-40 ms per 100 ms
|
||||||
|
chunk on a modern core, so latency stays well under a frame
|
||||||
|
budget if workers are spread across cores.
|
||||||
|
* "gain" - lightweight DSP fallback (soft-clip + high-pass) used when the
|
||||||
|
model can't be loaded (ENHANCE_MODE=off / auto + no model).
|
||||||
|
|
||||||
|
The enhancer is created once per worker process and reused across frames.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
log = logging.getLogger("audio.cleanup")
|
||||||
|
|
||||||
|
SAMPLE_RATE = 16000
|
||||||
|
|
||||||
|
|
||||||
|
class AudioCleaner:
|
||||||
|
"""Stateful audio cleaner. Call .process(int16 mono) -> int16 mono."""
|
||||||
|
|
||||||
|
def __init__(self, mode: str = "auto",
|
||||||
|
model_source: str = "speechbrain/metricgan-plus-voicebank",
|
||||||
|
sample_rate: int = SAMPLE_RATE):
|
||||||
|
self.mode = mode
|
||||||
|
self.sample_rate = sample_rate
|
||||||
|
self._enhancer = None
|
||||||
|
self._torch = None
|
||||||
|
self.backend = "off"
|
||||||
|
|
||||||
|
if mode == "force":
|
||||||
|
self._load_model(model_source)
|
||||||
|
if self.backend == "off":
|
||||||
|
raise RuntimeError(
|
||||||
|
f"ENHANCE_MODE=force but failed to load model "
|
||||||
|
f"'{model_source}'")
|
||||||
|
elif mode == "auto":
|
||||||
|
self._load_model(model_source)
|
||||||
|
# mode == "off": passthrough + basic gain shaping
|
||||||
|
|
||||||
|
def _load_model(self, source: str) -> None:
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
from speechbrain.inference.enhancement import (
|
||||||
|
SpectralMaskEnhancement,
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
log.info("speechbrain unavailable (%s); using fallback", e)
|
||||||
|
self._enhancer = None
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
log.info("loading speechbrain enhancement model %s ...", source)
|
||||||
|
self._torch = torch
|
||||||
|
model = SpectralMaskEnhancement.from_hparams(
|
||||||
|
source=source,
|
||||||
|
savedir="/root/.cache/speechbrain-enhancement",
|
||||||
|
)
|
||||||
|
model.mods.enhance_model.eval()
|
||||||
|
self._enhancer = model
|
||||||
|
self.backend = "model"
|
||||||
|
log.info("speechbrain enhancement ready (16 kHz in/out)")
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
log.warning("failed to load enhancement model %s: %s "
|
||||||
|
"(using fallback)", source, e)
|
||||||
|
self._enhancer = None
|
||||||
|
|
||||||
|
# -- DSP fallback ------------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _simple_clean(x: np.ndarray) -> np.ndarray:
|
||||||
|
"""Lightweight cleanup: high-pass + soft knee + gentle gain.
|
||||||
|
|
||||||
|
Good enough to remove DC offset/low rumble and taming clipping
|
||||||
|
without any ML model.
|
||||||
|
"""
|
||||||
|
# first-order high-pass at ~80 Hz
|
||||||
|
if len(x) > 1:
|
||||||
|
alpha = 0.998
|
||||||
|
y = np.empty_like(x)
|
||||||
|
y[0] = x[0]
|
||||||
|
for i in range(1, len(x)):
|
||||||
|
y[i] = alpha * (y[i - 1] + x[i] - x[i - 1])
|
||||||
|
x = y
|
||||||
|
# soft-clip to avoid harsh distortion
|
||||||
|
x = np.tanh(x * 0.9) / np.tanh(0.9)
|
||||||
|
# gentle makeup gain
|
||||||
|
x = x * 1.1
|
||||||
|
return np.clip(x, -1.0, 1.0)
|
||||||
|
|
||||||
|
# -- public API ---------------------------------------------------------
|
||||||
|
|
||||||
|
def process(self, pcm: np.ndarray) -> np.ndarray:
|
||||||
|
"""pcm: int16 mono numpy array. Returns int16 mono, same length.
|
||||||
|
|
||||||
|
If the model changes the length, we trim/pad back to the original
|
||||||
|
length so downstream chunking stays aligned.
|
||||||
|
"""
|
||||||
|
if self._enhancer is None:
|
||||||
|
if len(pcm) == 0:
|
||||||
|
return pcm
|
||||||
|
x = pcm.astype(np.float32) / 32768.0
|
||||||
|
y = self._simple_clean(x)
|
||||||
|
return (y * 32767.0).astype(np.float32).astype(np.int16)
|
||||||
|
|
||||||
|
x = pcm.astype(np.float32) / 32768.0
|
||||||
|
torch = self._torch
|
||||||
|
try:
|
||||||
|
w = torch.from_numpy(x).unsqueeze(0).to(self._enhancer.device)
|
||||||
|
# speechbrain >= 1.1 (e.g. metricGAN-plus) requires `lengths`
|
||||||
|
# on enhance_batch. The value is RELATIVE (multiplied by the
|
||||||
|
# number of frames internally), so 1.0 = "the whole sequence".
|
||||||
|
# Older versions accept the call without it (TypeError fallback).
|
||||||
|
lengths = torch.tensor([1.0]).to(self._enhancer.device)
|
||||||
|
try:
|
||||||
|
with torch.no_grad():
|
||||||
|
out = self._enhancer.enhance_batch(w, lengths=lengths)
|
||||||
|
except TypeError:
|
||||||
|
with torch.no_grad():
|
||||||
|
out = self._enhancer.enhance_batch(w)
|
||||||
|
out = out.squeeze(0).cpu().numpy()
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
log.warning("enhance_batch failed (%s); using fallback", e)
|
||||||
|
y = self._simple_clean(x)
|
||||||
|
return (y * 32767.0).astype(np.float32).astype(np.int16)
|
||||||
|
|
||||||
|
# align length
|
||||||
|
n = len(x)
|
||||||
|
if len(out) >= n:
|
||||||
|
out = out[:n]
|
||||||
|
else:
|
||||||
|
out = np.pad(out, (0, n - len(out)))
|
||||||
|
return (out * 32767.0).astype(np.float32).astype(np.int16)
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""Load configuration from environment / .env file."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def _load_dotenv(path: Path) -> None:
|
||||||
|
if not path.is_file():
|
||||||
|
return
|
||||||
|
for line in path.read_text().splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
os.environ.setdefault(key.strip(), value.strip())
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Config:
|
||||||
|
# connection
|
||||||
|
url: str = ""
|
||||||
|
api_key: str = ""
|
||||||
|
api_secret: str = ""
|
||||||
|
room: str = "cameras"
|
||||||
|
participant_prefix: str = "cam"
|
||||||
|
# video
|
||||||
|
width: int = 1280
|
||||||
|
height: int = 720
|
||||||
|
fps: int = 30
|
||||||
|
min_fps: int = 5
|
||||||
|
# audio
|
||||||
|
audio_rate: int = 16000
|
||||||
|
audio_channels: int = 1
|
||||||
|
# speechbrain cleanup
|
||||||
|
enhance_mode: str = "auto" # auto | force | off
|
||||||
|
enhance_model: str = "speechbrain/metricgan-plus-voicebank"
|
||||||
|
enhance_chunk_s: float = 0.2
|
||||||
|
# misc
|
||||||
|
publish_timeout_s: float = 60.0
|
||||||
|
log_level: str = "INFO"
|
||||||
|
cameras: list[str] = field(default_factory=lambda: ["all"])
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_cameras(spec: str) -> list[str]:
|
||||||
|
"""Expand 'all' / '1,3,5' / '0-19' into a list of indices ('' = all)."""
|
||||||
|
spec = (spec or "all").strip().lower()
|
||||||
|
if spec in ("all", ""):
|
||||||
|
return ["all"]
|
||||||
|
out = []
|
||||||
|
for part in spec.split(","):
|
||||||
|
part = part.strip()
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
if "-" in part:
|
||||||
|
lo, hi = part.split("-", 1)
|
||||||
|
out.extend(str(i) for i in range(int(lo), int(hi) + 1))
|
||||||
|
else:
|
||||||
|
out.append(part)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(env_path: Optional[Path] = None) -> Config:
|
||||||
|
_load_dotenv(Path(env_path or Path(__file__).parent / ".env"))
|
||||||
|
|
||||||
|
cfg = Config(
|
||||||
|
url=os.environ.get("LIVEKIT_URL", ""),
|
||||||
|
api_key=os.environ.get("LIVEKIT_API_KEY", ""),
|
||||||
|
api_secret=os.environ.get("LIVEKIT_API_SECRET", ""),
|
||||||
|
room=os.environ.get("LIVEKIT_ROOM", "cameras"),
|
||||||
|
participant_prefix=os.environ.get("PARTICIPANT_PREFIX", "cam"),
|
||||||
|
width=int(os.environ.get("VIDEO_WIDTH", "1280")),
|
||||||
|
height=int(os.environ.get("VIDEO_HEIGHT", "720")),
|
||||||
|
fps=int(os.environ.get("VIDEO_FPS", "30")),
|
||||||
|
min_fps=int(os.environ.get("VIDEO_MIN_FPS", "5")),
|
||||||
|
audio_rate=int(os.environ.get("AUDIO_SAMPLE_RATE", "16000")),
|
||||||
|
audio_channels=int(os.environ.get("AUDIO_CHANNELS", "1")),
|
||||||
|
enhance_mode=os.environ.get("ENHANCE_MODE", "auto").lower(),
|
||||||
|
enhance_model=os.environ.get(
|
||||||
|
"ENHANCE_MODEL", "speechbrain/metricgan-plus-voicebank"
|
||||||
|
),
|
||||||
|
enhance_chunk_s=float(os.environ.get("ENHANCE_CHUNK_S", "0.2")),
|
||||||
|
publish_timeout_s=float(os.environ.get("PUBLISH_TIMEOUT_S", "60")),
|
||||||
|
log_level=os.environ.get("LOG_LEVEL", "INFO").upper(),
|
||||||
|
cameras=_parse_cameras(os.environ.get("CAMERAS", "all")),
|
||||||
|
)
|
||||||
|
|
||||||
|
if cfg.enhance_mode not in ("auto", "force", "off"):
|
||||||
|
raise ValueError(f"ENHANCE_MODE must be auto|force|off, got {cfg.enhance_mode}")
|
||||||
|
return cfg
|
||||||
+244
@@ -0,0 +1,244 @@
|
|||||||
|
"""Discover C920 cameras: pair each /dev/video* node with its ALSA card.
|
||||||
|
|
||||||
|
Identity is stable across reboots and connect order: each camera is bound
|
||||||
|
to its USB *serial number* by udev rules (see generate_udev_rules.py and
|
||||||
|
slots.json in this directory), which pin
|
||||||
|
- video: /dev/camNN symlinks (primary UVC node per serial)
|
||||||
|
- audio: fixed ALSA card index per serial (SOUND_CARD_INDEX)
|
||||||
|
|
||||||
|
discover_cameras() therefore assigns cam-01..cam-20 by serial, never by
|
||||||
|
enumeration order. If slots.json is missing (first run), it falls back to
|
||||||
|
USB-port-based pairing and enumeration order.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
log = logging.getLogger("cameras.discovery")
|
||||||
|
|
||||||
|
_SLOTS_FILE = Path(__file__).parent / "slots.json"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Camera:
|
||||||
|
index: int
|
||||||
|
identity: str
|
||||||
|
video_device: str
|
||||||
|
audio_card: int | None
|
||||||
|
usb_port: str = ""
|
||||||
|
serial: str = ""
|
||||||
|
extra_video_devices: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def _run(cmd: list[str]) -> str:
|
||||||
|
return subprocess.run(cmd, capture_output=True, text=True, check=False).stdout
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_interface(port: str) -> str:
|
||||||
|
"""Drop the trailing ':config.interface' so the video interface (x:1.0)
|
||||||
|
and audio interface (x:1.2) of the same C920 compare equal."""
|
||||||
|
if ":" in port:
|
||||||
|
port = port.rsplit(":", 1)[0]
|
||||||
|
return port
|
||||||
|
|
||||||
|
|
||||||
|
def _serial_of(sysfs_path: str) -> str:
|
||||||
|
"""Walk up from a sysfs path (interface, function, or card device)
|
||||||
|
until we reach the USB device that carries the 'serial' attribute."""
|
||||||
|
p = Path(sysfs_path)
|
||||||
|
for _ in range(12):
|
||||||
|
cand = p / "serial"
|
||||||
|
if cand.is_file():
|
||||||
|
return cand.read_text().strip()
|
||||||
|
if str(p).startswith("/sys/devices"):
|
||||||
|
p = p.parent
|
||||||
|
else:
|
||||||
|
return ""
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _load_slots() -> dict[str, dict]:
|
||||||
|
"""slots.json: identity -> {serial, video, card}. Empty if absent."""
|
||||||
|
if _SLOTS_FILE.exists():
|
||||||
|
try:
|
||||||
|
return json.loads(_SLOTS_FILE.read_text())
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
log.warning("could not read %s (%s); using port-based fallback",
|
||||||
|
_SLOTS_FILE, e)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _usb_port_of_video_node(video_node: str) -> str:
|
||||||
|
"""Resolve the USB port (e.g. '0c:00.0-1.1.2.1') for a video device node."""
|
||||||
|
link = Path(f"/sys/class/video4linux/{Path(video_node).name}")
|
||||||
|
if not link.exists():
|
||||||
|
return ""
|
||||||
|
dev = link / "device"
|
||||||
|
try:
|
||||||
|
real = dev.resolve()
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
# The USB port is the segment after the last '/' of the USB part, e.g.
|
||||||
|
# /sys/devices/.../usb9/9-1/9-1.1/9-1.1.2/9-1.1.2.1/...
|
||||||
|
parts = real.parts
|
||||||
|
for i, p in enumerate(parts):
|
||||||
|
if p.startswith("usb"):
|
||||||
|
# take everything after the usbX part as the port path
|
||||||
|
port_parts = [q for q in parts[i + 1:] if re.match(r"^\d+-", q)]
|
||||||
|
if port_parts:
|
||||||
|
return "/".join(port_parts)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _usb_port_of_sound_card(card: int) -> str:
|
||||||
|
dev = Path(f"/sys/class/sound/card{card}/device")
|
||||||
|
if not dev.exists():
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
real = dev.resolve()
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
parts = real.parts
|
||||||
|
for i, p in enumerate(parts):
|
||||||
|
if p.startswith("usb"):
|
||||||
|
port_parts = [q for q in parts[i + 1:] if re.match(r"^\d+-", q)]
|
||||||
|
if port_parts:
|
||||||
|
return "/".join(port_parts)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _sound_card_names() -> dict[int, str]:
|
||||||
|
cards: dict[int, str] = {}
|
||||||
|
out = _run(["cat", "/proc/asound/cards"])
|
||||||
|
# format: " 2 [C920 ]: USB-Audio - HD Pro Webcam C920"
|
||||||
|
# (the card name is left-justified in a fixed-width bracket field)
|
||||||
|
for line in out.splitlines():
|
||||||
|
m = re.match(r"\s*(\d+)\s+\[([A-Za-z0-9_]+)\s*\]:\s*(.*)", line)
|
||||||
|
if m:
|
||||||
|
cards[int(m.group(1))] = m.group(3).strip()
|
||||||
|
return cards
|
||||||
|
|
||||||
|
|
||||||
|
def _serial_of_video_node(video_node: str) -> str:
|
||||||
|
link = Path(f"/sys/class/video4linux/{Path(video_node).name}/device")
|
||||||
|
try:
|
||||||
|
real = link.resolve()
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
return _serial_of(str(real))
|
||||||
|
|
||||||
|
|
||||||
|
def _serial_of_sound_card(card: int) -> str:
|
||||||
|
dev = Path(f"/sys/class/sound/card{card}/device")
|
||||||
|
try:
|
||||||
|
real = dev.resolve()
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
return _serial_of(str(real))
|
||||||
|
|
||||||
|
|
||||||
|
def discover_cameras() -> list[Camera]:
|
||||||
|
"""Return cameras with stable serial-based identities (cam-01..cam-20)."""
|
||||||
|
slots = _load_slots()
|
||||||
|
slots_by_serial = {s["serial"]: (ident, s)
|
||||||
|
for ident, s in slots.items() if s.get("serial")}
|
||||||
|
|
||||||
|
v4l_out = _run(["v4l2-ctl", "--list-devices"])
|
||||||
|
if not v4l_out:
|
||||||
|
raise RuntimeError("v4l2-ctl not available; is v4l-utils installed?")
|
||||||
|
|
||||||
|
# group video nodes by device name from v4l2-ctl output
|
||||||
|
# v4l2-ctl output: "NAME:\n\t/dev/videoX\n\t/dev/videoY\n\t/dev/mediaZ"
|
||||||
|
current: str | None = None
|
||||||
|
groups: dict[str, list[str]] = {}
|
||||||
|
for raw in v4l_out.splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
if line.endswith(":") and "\t" not in raw:
|
||||||
|
current = line[:-1]
|
||||||
|
groups.setdefault(current, [])
|
||||||
|
elif current and line.startswith("/dev/video"):
|
||||||
|
groups[current].append(line)
|
||||||
|
|
||||||
|
# sound cards keyed by USB serial (authoritative) with port fallback
|
||||||
|
card_names = _sound_card_names()
|
||||||
|
card_by_serial: dict[str, int] = {}
|
||||||
|
card_by_port: dict[str, int] = {}
|
||||||
|
for card, name in card_names.items():
|
||||||
|
if "C920" not in name and "920" not in name:
|
||||||
|
continue
|
||||||
|
serial = _serial_of_sound_card(card)
|
||||||
|
if serial:
|
||||||
|
card_by_serial[serial] = card
|
||||||
|
else:
|
||||||
|
port = _usb_port_of_sound_card(card)
|
||||||
|
if port:
|
||||||
|
card_by_port[_strip_interface(port)] = card
|
||||||
|
|
||||||
|
cameras: list[Camera] = []
|
||||||
|
for name, nodes in groups.items():
|
||||||
|
if not nodes:
|
||||||
|
continue
|
||||||
|
nodes.sort(key=lambda n: int(Path(n).name.replace("video", "")))
|
||||||
|
primary = nodes[0]
|
||||||
|
serial = _serial_of_video_node(primary)
|
||||||
|
port = _usb_port_of_video_node(primary)
|
||||||
|
|
||||||
|
identity = None
|
||||||
|
video_device = primary
|
||||||
|
audio_card = None
|
||||||
|
slot = slots_by_serial.get(serial)
|
||||||
|
if slot is not None:
|
||||||
|
identity, slot_info = slot
|
||||||
|
# prefer the pinned udev symlink if it exists
|
||||||
|
pinned = slot_info.get("video", "")
|
||||||
|
if pinned and Path(pinned).exists():
|
||||||
|
video_device = pinned
|
||||||
|
if slot_info.get("card") is not None:
|
||||||
|
audio_card = slot_info["card"]
|
||||||
|
if serial in card_by_serial:
|
||||||
|
audio_card = card_by_serial[serial] # live serial match wins
|
||||||
|
else:
|
||||||
|
log.warning("camera %s (%s): serial %s not in %s; "
|
||||||
|
"port-based fallback", name, primary, serial,
|
||||||
|
_SLOTS_FILE.name)
|
||||||
|
if serial:
|
||||||
|
audio_card = card_by_serial.get(serial)
|
||||||
|
if audio_card is None:
|
||||||
|
audio_card = card_by_port.get(_strip_interface(port))
|
||||||
|
if audio_card is None:
|
||||||
|
log.warning("camera %s (%s): no matching sound card "
|
||||||
|
"(port=%s)", name, primary, port)
|
||||||
|
identity = None # assigned below by enumeration order
|
||||||
|
|
||||||
|
cameras.append(Camera(
|
||||||
|
index=len(cameras),
|
||||||
|
identity=identity or f"cam-{len(cameras) + 1:02d}",
|
||||||
|
video_device=video_device,
|
||||||
|
audio_card=audio_card,
|
||||||
|
usb_port=port,
|
||||||
|
serial=serial,
|
||||||
|
extra_video_devices=nodes[1:],
|
||||||
|
))
|
||||||
|
|
||||||
|
# stable order: known slots by slot number, unknowns by video node number
|
||||||
|
def _video_num(cam: Camera) -> int:
|
||||||
|
try:
|
||||||
|
# resolve a pinned /dev/camNN symlink to its real /dev/videoN node
|
||||||
|
real = Path(cam.video_device).resolve().name
|
||||||
|
return int(real.replace("video", ""))
|
||||||
|
except (ValueError, OSError):
|
||||||
|
return 10_000
|
||||||
|
|
||||||
|
def _slot_num(cam: Camera) -> int:
|
||||||
|
m = re.match(r"cam-(\d+)$", cam.identity)
|
||||||
|
return int(m.group(1)) if m else 1000
|
||||||
|
cameras.sort(key=lambda c: (_slot_num(c), _video_num(c)))
|
||||||
|
for i, cam in enumerate(cameras):
|
||||||
|
cam.index = i
|
||||||
|
return cameras
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate udev rules that pin each C920 webcam to a fixed slot.
|
||||||
|
|
||||||
|
Slot N (cam-01..cam-20) is bound to the USB *serial number* of the camera
|
||||||
|
that currently occupies it, giving stable:
|
||||||
|
- video: /dev/camNN (primary capture node), /dev/camNN.meta (UVC metadata)
|
||||||
|
- audio: fixed ALSA card index (hw:INDEX,0)
|
||||||
|
|
||||||
|
Run: python3 generate_udev_rules.py # prints to stdout
|
||||||
|
python3 generate_udev_rules.py --write # also writes /etc/udev/rules.d/99-c920-pin.rules
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
HEADER = """\
|
||||||
|
# Stable slots for 20x Logitech HD Pro Webcam C920 (046d:08e5).
|
||||||
|
# Slot = physical camera, identified by USB serial. Survives reboots,
|
||||||
|
# re-enumeration, and hub reordering. Generated by generate_udev_rules.py.
|
||||||
|
#
|
||||||
|
# /dev/camNN -> primary video capture node (UVC stream 0)
|
||||||
|
# /dev/camNN.meta -> UVC metadata node (stream 1)
|
||||||
|
# hw:N,0 -> mic of cam-NN (N from SOUND_CARD_INDEX below)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def sh(cmd):
|
||||||
|
return subprocess.run(cmd, capture_output=True, text=True).stdout
|
||||||
|
|
||||||
|
|
||||||
|
def _serial_of(sysfs_path):
|
||||||
|
"""Walk up from a sysfs path (interface, function, or card device)
|
||||||
|
until we reach the USB device that carries the 'serial' attribute."""
|
||||||
|
import pathlib
|
||||||
|
p = pathlib.Path(sysfs_path)
|
||||||
|
for _ in range(12):
|
||||||
|
cand = p / "serial"
|
||||||
|
if cand.is_file():
|
||||||
|
return cand.read_text().strip()
|
||||||
|
if str(p).startswith("/sys/devices"):
|
||||||
|
p = p.parent
|
||||||
|
else:
|
||||||
|
return ""
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def current_state():
|
||||||
|
"""Map serial -> (primary_video_node, sound_card_index)."""
|
||||||
|
import pathlib
|
||||||
|
# sound cards: index -> serial
|
||||||
|
cards = {}
|
||||||
|
for line in sh(["cat", "/proc/asound/cards"]).splitlines():
|
||||||
|
m = re.match(r"\s*(\d+)\s+\[([A-Za-z0-9_]+)\s*\]:\s*(.*)", line)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
idx = int(m.group(1))
|
||||||
|
if "C920" not in m.group(3) and "920" not in m.group(2):
|
||||||
|
continue
|
||||||
|
dev = f"/sys/class/sound/card{idx}/device"
|
||||||
|
real = sh(["readlink", "-f", dev]).strip()
|
||||||
|
cards[idx] = _serial_of(real)
|
||||||
|
# video: primary node (index 0) per USB serial, via sysfs
|
||||||
|
primary_by_serial = {}
|
||||||
|
for node in sorted(pathlib.Path("/sys/class/video4linux").glob("video*"),
|
||||||
|
key=lambda p: int(p.name.replace("video", ""))):
|
||||||
|
idx_attr = (node / "index").read_text().strip()
|
||||||
|
if idx_attr != "0":
|
||||||
|
continue
|
||||||
|
real = sh(["readlink", "-f", str(node / "device")]).strip()
|
||||||
|
s = _serial_of(real)
|
||||||
|
primary_by_serial[s] = f"/dev/{node.name}"
|
||||||
|
return primary_by_serial, cards
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
primary_by_serial, cards = current_state()
|
||||||
|
# assign slots: cam-01..cam-20 in current video-node order
|
||||||
|
ordered = []
|
||||||
|
for serial, node in primary_by_serial.items():
|
||||||
|
n = int(node.replace("/dev/video", ""))
|
||||||
|
ordered.append((n, serial, node))
|
||||||
|
ordered.sort()
|
||||||
|
if len(ordered) != 20:
|
||||||
|
print(f"warning: expected 20 cameras, got {len(ordered)}", file=sys.stderr)
|
||||||
|
|
||||||
|
lines = [HEADER]
|
||||||
|
for i, (vnum, serial, node) in enumerate(ordered, start=1):
|
||||||
|
card = next((c for c, s in cards.items() if s == serial), None)
|
||||||
|
slot = f"cam{i:02d}"
|
||||||
|
lines.append(f"# {slot}: serial {serial} (was {node}, card {card})")
|
||||||
|
lines.append(
|
||||||
|
f'SUBSYSTEM=="video4linux", KERNEL=="video*", '
|
||||||
|
f'ENV{{ID_VENDOR_ID}}=="046d", ENV{{ID_MODEL_ID}}=="08e5", '
|
||||||
|
f'ENV{{ID_USB_SERIAL_SHORT}}=="{serial}", '
|
||||||
|
f'ATTR{{index}}=="0", SYMLINK+="{slot}"')
|
||||||
|
lines.append(
|
||||||
|
f'SUBSYSTEM=="video4linux", KERNEL=="video*", '
|
||||||
|
f'ENV{{ID_VENDOR_ID}}=="046d", ENV{{ID_MODEL_ID}}=="08e5", '
|
||||||
|
f'ENV{{ID_USB_SERIAL_SHORT}}=="{serial}", '
|
||||||
|
f'ATTR{{index}}=="1", SYMLINK+="{slot}.meta"')
|
||||||
|
if card is not None:
|
||||||
|
lines.append(
|
||||||
|
f'SUBSYSTEM=="sound", KERNEL=="card*", '
|
||||||
|
f'ENV{{ID_VENDOR_ID}}=="046d", ENV{{ID_MODEL_ID}}=="08e5", '
|
||||||
|
f'ENV{{ID_USB_SERIAL_SHORT}}=="{serial}", '
|
||||||
|
f'ENV{{SOUND_CARD_INDEX}}="{card}"')
|
||||||
|
else:
|
||||||
|
print(f"warning: no sound card for serial {serial}", file=sys.stderr)
|
||||||
|
lines.append("")
|
||||||
|
text = "\n".join(lines)
|
||||||
|
print(text)
|
||||||
|
if "--write" in sys.argv:
|
||||||
|
path = "/etc/udev/rules.d/99-c920-pin.rules"
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.write(text)
|
||||||
|
print(f"# wrote {path}", file=sys.stderr)
|
||||||
|
# durable slot map for the app (discovery.py reads this)
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
slots = {}
|
||||||
|
for i, (vnum, serial, node) in enumerate(ordered, start=1):
|
||||||
|
card = next((c for c, s in cards.items() if s == serial), None)
|
||||||
|
slots[f"cam-{i:02d}"] = {
|
||||||
|
"serial": serial,
|
||||||
|
"video": f"/dev/cam{i:02d}",
|
||||||
|
"card": card,
|
||||||
|
}
|
||||||
|
base = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
slots_path = os.path.join(base, "slots.json")
|
||||||
|
with open(slots_path, "w") as f:
|
||||||
|
json.dump(slots, f, indent=2)
|
||||||
|
print(f"# wrote {slots_path}", file=sys.stderr)
|
||||||
|
# summary table to stderr
|
||||||
|
for i, (vnum, serial, node) in enumerate(ordered, start=1):
|
||||||
|
card = next((c for c, s in cards.items() if s == serial), "?")
|
||||||
|
print(f"# cam-{i:02d} serial={serial} video={node} card={card}",
|
||||||
|
file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""One-time download of the speechbrain enhancement model.
|
||||||
|
|
||||||
|
Each worker loads the model from the local cache, so run this once before
|
||||||
|
starting all 20 workers:
|
||||||
|
|
||||||
|
.venv/bin/python prefetch_model.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
|
log = logging.getLogger("prefetch")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
from config import load_config
|
||||||
|
cfg = load_config()
|
||||||
|
import torch
|
||||||
|
from speechbrain.inference.enhancement import SpectralMaskEnhancement
|
||||||
|
|
||||||
|
log.info("loading %s (first run downloads weights) ...", cfg.enhance_model)
|
||||||
|
model = SpectralMaskEnhancement.from_hparams(
|
||||||
|
source=cfg.enhance_model,
|
||||||
|
savedir="/root/.cache/speechbrain-enhancement",
|
||||||
|
)
|
||||||
|
model.mods.enhance_model.eval()
|
||||||
|
|
||||||
|
# quick self-test: 0.5 s of 16 kHz sine + noise
|
||||||
|
import numpy as np
|
||||||
|
n = int(cfg.audio_rate * 0.5)
|
||||||
|
t = np.arange(n) / cfg.audio_rate
|
||||||
|
x = (0.3 * np.sin(2 * np.pi * 440 * t) + 0.05 * np.random.randn(n))
|
||||||
|
x = x / np.abs(x).max()
|
||||||
|
with torch.no_grad():
|
||||||
|
w = torch.from_numpy(x.astype(np.float32)).unsqueeze(0).to(model.device)
|
||||||
|
# lengths are relative (1.0 = full sequence); older speechbrain
|
||||||
|
# builds don't accept the kwarg at all -> TypeError fallback.
|
||||||
|
try:
|
||||||
|
out = model.enhance_batch(w, lengths=torch.tensor([1.0]))
|
||||||
|
except TypeError:
|
||||||
|
out = model.enhance_batch(w)
|
||||||
|
log.info("self-test OK: in %d samples -> out %d samples",
|
||||||
|
n, out.numel())
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[project]
|
||||||
|
name = "livekit-cameras"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Publish 20 C920 webcam streams (video + speechbrain-cleaned audio) to a LiveKit room"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"livekit>=1.1",
|
||||||
|
"livekit-api>=1.2",
|
||||||
|
"speechbrain",
|
||||||
|
"numpy",
|
||||||
|
"opencv-python-headless",
|
||||||
|
"scipy",
|
||||||
|
]
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
"""Orchestrator: discover cameras and run one publisher worker each.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python run.py # use .env (or environment variables)
|
||||||
|
python run.py --cameras 1,5 # publish only cameras 1 and 5
|
||||||
|
python run.py --once # run, print a status report, exit (for CI)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from config import load_config, _load_dotenv
|
||||||
|
from discovery import discover_cameras
|
||||||
|
from tokens import make_token
|
||||||
|
|
||||||
|
log = logging.getLogger("cameras.main")
|
||||||
|
|
||||||
|
|
||||||
|
def worker_args_for(cfg, cam) -> list[str]:
|
||||||
|
return [
|
||||||
|
"--camera-json", json.dumps(cam.__dict__),
|
||||||
|
"--room", cfg.room,
|
||||||
|
"--url", cfg.url,
|
||||||
|
"--token", make_token(cfg.url, cfg.api_key, cfg.api_secret,
|
||||||
|
cfg.room, cam.identity),
|
||||||
|
"--width", str(cfg.width),
|
||||||
|
"--height", str(cfg.height),
|
||||||
|
"--fps", str(cfg.fps),
|
||||||
|
"--audio-rate", str(cfg.audio_rate),
|
||||||
|
"--enhance-mode", cfg.enhance_mode,
|
||||||
|
"--enhance-model", cfg.enhance_model,
|
||||||
|
"--enhance-chunk-s", str(cfg.enhance_chunk_s),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Supervisor:
|
||||||
|
def __init__(self, cfg, cameras):
|
||||||
|
self.cfg = cfg
|
||||||
|
self.cameras = cameras
|
||||||
|
self.procs: dict[str, subprocess.Popen] = {}
|
||||||
|
self.last_exit: dict[str, int | None] = {}
|
||||||
|
self.restarts: dict[str, int] = {}
|
||||||
|
|
||||||
|
def spawn(self, cam) -> None:
|
||||||
|
args = worker_args_for(self.cfg, cam)
|
||||||
|
env = dict(os.environ)
|
||||||
|
p = subprocess.Popen(
|
||||||
|
[sys.executable, str(Path(__file__).parent / "run_worker.py"),
|
||||||
|
*args],
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||||
|
text=True, bufsize=1, env=env,
|
||||||
|
)
|
||||||
|
self.procs[cam.identity] = p
|
||||||
|
self.restarts[cam.identity] = self.restarts.get(cam.identity, 0) + 1
|
||||||
|
|
||||||
|
def pump(self) -> None:
|
||||||
|
"""Read one line from each running worker; print them."""
|
||||||
|
for ident, p in list(self.procs.items()):
|
||||||
|
line = p.stdout.readline() if p.stdout else ""
|
||||||
|
if line:
|
||||||
|
sys.stdout.write(f"[{ident}] {line}")
|
||||||
|
sys.stdout.flush()
|
||||||
|
if p.poll() is not None and not line:
|
||||||
|
pass # EOF handled by poll below
|
||||||
|
|
||||||
|
def reap(self) -> None:
|
||||||
|
for ident, p in list(self.procs.items()):
|
||||||
|
rc = p.poll()
|
||||||
|
if rc is None:
|
||||||
|
continue
|
||||||
|
self.last_exit[ident] = rc
|
||||||
|
if rc != 0:
|
||||||
|
log.warning("%s exited rc=%s (restart %d/%d)", ident, rc,
|
||||||
|
self.restarts.get(ident, 0), 10)
|
||||||
|
if self.restarts.get(ident, 0) < 10:
|
||||||
|
time.sleep(1.0)
|
||||||
|
# find the camera object again
|
||||||
|
cam = next(c for c in self.cameras
|
||||||
|
if c.identity == ident)
|
||||||
|
self.spawn(cam)
|
||||||
|
else:
|
||||||
|
log.error("%s gave up after 10 restarts", ident)
|
||||||
|
else:
|
||||||
|
log.info("%s exited cleanly", ident)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--cameras", default=None,
|
||||||
|
help="subset spec, e.g. 'all', '1,5', '0-19'")
|
||||||
|
ap.add_argument("--once", action="store_true",
|
||||||
|
help="spawn workers, report status after "
|
||||||
|
"PUBLISH_TIMEOUT_S, then exit")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
cfg = load_config()
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, cfg.log_level, logging.INFO),
|
||||||
|
format="%(asctime)s [main] %(levelname)s %(message)s")
|
||||||
|
|
||||||
|
if args.cameras:
|
||||||
|
from config import _parse_cameras
|
||||||
|
cfg.cameras = _parse_cameras(args.cameras)
|
||||||
|
|
||||||
|
if not cfg.url or not cfg.api_key or not cfg.api_secret:
|
||||||
|
log.error("LIVEKIT_URL / LIVEKIT_API_KEY / LIVEKIT_API_SECRET "
|
||||||
|
"not set (create .env from .env.example)")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
cameras = discover_cameras()
|
||||||
|
log.info("discovered %d cameras:", len(cameras))
|
||||||
|
for c in cameras:
|
||||||
|
log.info(" %s video=%s audio=hw:%s usb=%s",
|
||||||
|
c.identity, c.video_device,
|
||||||
|
c.audio_card if c.audio_card is not None else "-",
|
||||||
|
c.usb_port or "-")
|
||||||
|
|
||||||
|
if cfg.cameras != ["all"]:
|
||||||
|
wanted = set(int(i) - 1 for i in cfg.cameras)
|
||||||
|
cameras = [c for c in cameras if c.index in wanted]
|
||||||
|
if not cameras:
|
||||||
|
log.error("no cameras match --cameras %s", args.cameras)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
sup = Supervisor(cfg, cameras)
|
||||||
|
for cam in cameras:
|
||||||
|
sup.spawn(cam)
|
||||||
|
log.info("spawned %d workers", len(cameras))
|
||||||
|
|
||||||
|
if args.once:
|
||||||
|
deadline = time.monotonic() + cfg.publish_timeout_s
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
sup.pump()
|
||||||
|
sup.reap()
|
||||||
|
time.sleep(0.5)
|
||||||
|
# final report
|
||||||
|
up = [i for i, p in sup.procs.items() if p.poll() is None]
|
||||||
|
log.info("STATUS: %d/%d workers alive", len(up), len(sup.procs))
|
||||||
|
for ident in up:
|
||||||
|
pass
|
||||||
|
# stop everything
|
||||||
|
for p in sup.procs.values():
|
||||||
|
p.send_signal(signal.SIGTERM)
|
||||||
|
for p in sup.procs.values():
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
p.wait(timeout=10)
|
||||||
|
return 0 if len(up) == len(sup.procs) else 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
sup.pump()
|
||||||
|
sup.reap()
|
||||||
|
time.sleep(0.2)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
log.info("shutting down ...")
|
||||||
|
for p in sup.procs.values():
|
||||||
|
if p.poll() is None:
|
||||||
|
p.send_signal(signal.SIGTERM)
|
||||||
|
for p in sup.procs.values():
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
p.wait(timeout=10)
|
||||||
|
for p in sup.procs.values():
|
||||||
|
if p.poll() is None:
|
||||||
|
p.kill()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+225
@@ -0,0 +1,225 @@
|
|||||||
|
"""One camera -> one LiveKit participant.
|
||||||
|
|
||||||
|
Run as a subprocess per camera: python -m cameras.run_worker --index 0 ...
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
log = logging.getLogger("cameras.worker")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--camera-json", required=True,
|
||||||
|
help="JSON of one discovered camera dict")
|
||||||
|
ap.add_argument("--room", required=True)
|
||||||
|
ap.add_argument("--url", required=True)
|
||||||
|
ap.add_argument("--token", required=True)
|
||||||
|
ap.add_argument("--width", type=int, default=1280)
|
||||||
|
ap.add_argument("--height", type=int, default=720)
|
||||||
|
ap.add_argument("--fps", type=int, default=30)
|
||||||
|
ap.add_argument("--audio-rate", type=int, default=16000)
|
||||||
|
ap.add_argument("--enhance-mode", default="auto")
|
||||||
|
ap.add_argument("--enhance-model",
|
||||||
|
default="speechbrain/metricgan-plus-voicebank")
|
||||||
|
ap.add_argument("--enhance-chunk-s", type=float, default=0.2)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
cam = json.loads(args.camera_json)
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format=f"%(asctime)s [{cam['identity']}] %(levelname)s %(message)s",
|
||||||
|
)
|
||||||
|
|
||||||
|
from config import Config # noqa: F401 (ensures .env is loaded)
|
||||||
|
import livekit.rtc as rtc
|
||||||
|
from audio_cleanup import AudioCleaner
|
||||||
|
|
||||||
|
identity = cam["identity"]
|
||||||
|
video_dev = cam["video_device"]
|
||||||
|
audio_card = cam.get("audio_card")
|
||||||
|
|
||||||
|
async def run() -> int:
|
||||||
|
cleaner = AudioCleaner(
|
||||||
|
mode=args.enhance_mode,
|
||||||
|
model_source=args.enhance_model,
|
||||||
|
sample_rate=args.audio_rate,
|
||||||
|
)
|
||||||
|
log.info("audio backend: %s", cleaner.backend)
|
||||||
|
|
||||||
|
room = rtc.Room()
|
||||||
|
await room.connect(args.url, args.token)
|
||||||
|
log.info("connected to room %r as %r", room.name, identity)
|
||||||
|
|
||||||
|
# ---------------- video ----------------
|
||||||
|
video_source = rtc.VideoSource(args.width, args.height)
|
||||||
|
video_track = rtc.LocalVideoTrack.create_video_track(
|
||||||
|
f"{identity}-video", video_source)
|
||||||
|
|
||||||
|
vproc = subprocess.Popen(
|
||||||
|
["ffmpeg", "-hide_banner", "-loglevel", "error",
|
||||||
|
"-f", "v4l2",
|
||||||
|
"-input_format", "mjpeg",
|
||||||
|
"-video_size", f"{args.width}x{args.height}",
|
||||||
|
"-framerate", str(args.fps),
|
||||||
|
"-i", video_dev,
|
||||||
|
"-f", "rawvideo", "-pix_fmt", "bgra",
|
||||||
|
"pipe:1"],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
frame_bytes = args.width * args.height * 4
|
||||||
|
video_frames = 0
|
||||||
|
|
||||||
|
async def video_loop():
|
||||||
|
nonlocal video_frames
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
last = time.monotonic()
|
||||||
|
while vproc.poll() is None:
|
||||||
|
data = await loop.run_in_executor(
|
||||||
|
None, vproc.stdout.read, frame_bytes)
|
||||||
|
if not data or len(data) < frame_bytes:
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
# partial read: accumulate (v4l2/ffmpeg shouldn't do this)
|
||||||
|
keep = b""
|
||||||
|
while len(keep) < frame_bytes:
|
||||||
|
more = await loop.run_in_executor(
|
||||||
|
None, vproc.stdout.read,
|
||||||
|
frame_bytes - len(keep))
|
||||||
|
if not more:
|
||||||
|
return
|
||||||
|
keep += more
|
||||||
|
data = keep
|
||||||
|
now = time.monotonic()
|
||||||
|
ts_us = int((now - last) * 1_000_000)
|
||||||
|
try:
|
||||||
|
video_source.capture_frame(
|
||||||
|
rtc.VideoFrame(
|
||||||
|
args.width, args.height,
|
||||||
|
rtc.VideoBufferType.BGRA, data),
|
||||||
|
timestamp_us=ts_us,
|
||||||
|
)
|
||||||
|
video_frames += 1
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
log.exception("video capture_frame failed")
|
||||||
|
|
||||||
|
# ---------------- audio ----------------
|
||||||
|
aproc: subprocess.Popen | None = None
|
||||||
|
atrack: rtc.LocalAudioTrack | None = None
|
||||||
|
if audio_card is not None:
|
||||||
|
audio_source = rtc.AudioSource(
|
||||||
|
sample_rate=args.audio_rate, num_channels=1)
|
||||||
|
atrack = rtc.LocalAudioTrack.create_audio_track(
|
||||||
|
f"{identity}-audio", audio_source)
|
||||||
|
|
||||||
|
chunk_s = args.enhance_chunk_s
|
||||||
|
chunk = int(chunk_s * args.audio_rate) # samples per channel
|
||||||
|
|
||||||
|
async def audio_loop():
|
||||||
|
assert aproc is not None and atrack is not None
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
# mic delivers stereo s16le: bytes per chunk =
|
||||||
|
# chunk * 2 ch * 2 bytes
|
||||||
|
need = chunk * 2 * 2
|
||||||
|
buf = b""
|
||||||
|
while aproc.poll() is None:
|
||||||
|
data = await loop.run_in_executor(
|
||||||
|
None, aproc.stdout.read, 4096)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
buf += data
|
||||||
|
while len(buf) >= need:
|
||||||
|
piece, buf = buf[:need], buf[need:]
|
||||||
|
pcm_stereo = np.frombuffer(piece, dtype=np.int16)
|
||||||
|
pcm_mono = pcm_stereo.reshape(-1, 2).mean(
|
||||||
|
axis=1).astype(np.int16)
|
||||||
|
# align to full chunks
|
||||||
|
usable = (len(pcm_mono) // chunk) * chunk
|
||||||
|
if usable:
|
||||||
|
cleaned = await loop.run_in_executor(
|
||||||
|
None, cleaner.process,
|
||||||
|
pcm_mono[:usable])
|
||||||
|
await audio_source.capture_frame(
|
||||||
|
rtc.AudioFrame(
|
||||||
|
cleaned.tobytes(),
|
||||||
|
args.audio_rate,
|
||||||
|
1,
|
||||||
|
len(cleaned)))
|
||||||
|
|
||||||
|
aproc = subprocess.Popen(
|
||||||
|
["ffmpeg", "-hide_banner", "-loglevel", "error",
|
||||||
|
"-f", "alsa", "-i", f"hw:{audio_card},0",
|
||||||
|
"-ar", str(args.audio_rate), "-ac", "2",
|
||||||
|
"-f", "s16le", "pipe:1"],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------- publish ----------------
|
||||||
|
from livekit.rtc import TrackSource, TrackPublishOptions
|
||||||
|
pub_opts = TrackPublishOptions(
|
||||||
|
source=TrackSource.SOURCE_CAMERA, simulcast=False)
|
||||||
|
await room.local_participant.publish_track(video_track, pub_opts)
|
||||||
|
if atrack is not None:
|
||||||
|
await room.local_participant.publish_track(
|
||||||
|
atrack,
|
||||||
|
TrackPublishOptions(
|
||||||
|
source=TrackSource.SOURCE_MICROPHONE,
|
||||||
|
dtx=False))
|
||||||
|
log.info("published video+audio (audio backend: %s)",
|
||||||
|
cleaner.backend)
|
||||||
|
else:
|
||||||
|
log.info("published video only (no audio card matched)")
|
||||||
|
|
||||||
|
log.info("status: UP identity=%s video=%s audio=%s",
|
||||||
|
identity, video_dev,
|
||||||
|
f"hw:{audio_card},0" if audio_card is not None else "none")
|
||||||
|
|
||||||
|
# ---------------- run ----------------
|
||||||
|
tasks = [asyncio.create_task(video_loop(), name="video")]
|
||||||
|
if aproc is not None:
|
||||||
|
tasks.append(asyncio.create_task(audio_loop(), name="audio"))
|
||||||
|
|
||||||
|
stop = asyncio.Event()
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||||
|
with contextlib.suppress(NotImplementedError):
|
||||||
|
loop.add_signal_handler(sig, stop.set)
|
||||||
|
|
||||||
|
await stop.wait()
|
||||||
|
|
||||||
|
for t in tasks:
|
||||||
|
t.cancel()
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
for p in (vproc, aproc):
|
||||||
|
if p is not None and p.poll() is None:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
p.terminate()
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
p.wait(timeout=3)
|
||||||
|
log.info("stopped; delivered %d video frames", video_frames)
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await room.disconnect()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
return asyncio.run(run())
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
log.exception("worker fatal")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
{
|
||||||
|
"cam-01": {
|
||||||
|
"serial": "503A123F",
|
||||||
|
"video": "/dev/cam01",
|
||||||
|
"card": 2
|
||||||
|
},
|
||||||
|
"cam-02": {
|
||||||
|
"serial": "4123BF6F",
|
||||||
|
"video": "/dev/cam02",
|
||||||
|
"card": 3
|
||||||
|
},
|
||||||
|
"cam-03": {
|
||||||
|
"serial": "84D270BF",
|
||||||
|
"video": "/dev/cam03",
|
||||||
|
"card": 4
|
||||||
|
},
|
||||||
|
"cam-04": {
|
||||||
|
"serial": "F8F2BF6F",
|
||||||
|
"video": "/dev/cam04",
|
||||||
|
"card": 5
|
||||||
|
},
|
||||||
|
"cam-05": {
|
||||||
|
"serial": "B638DFEF",
|
||||||
|
"video": "/dev/cam05",
|
||||||
|
"card": 6
|
||||||
|
},
|
||||||
|
"cam-06": {
|
||||||
|
"serial": "7972BF6F",
|
||||||
|
"video": "/dev/cam06",
|
||||||
|
"card": 7
|
||||||
|
},
|
||||||
|
"cam-07": {
|
||||||
|
"serial": "6AA1BFAF",
|
||||||
|
"video": "/dev/cam07",
|
||||||
|
"card": 8
|
||||||
|
},
|
||||||
|
"cam-08": {
|
||||||
|
"serial": "FDA67EEF",
|
||||||
|
"video": "/dev/cam08",
|
||||||
|
"card": 9
|
||||||
|
},
|
||||||
|
"cam-09": {
|
||||||
|
"serial": "65B2BF2F",
|
||||||
|
"video": "/dev/cam09",
|
||||||
|
"card": 10
|
||||||
|
},
|
||||||
|
"cam-10": {
|
||||||
|
"serial": "FAA65E2F",
|
||||||
|
"video": "/dev/cam10",
|
||||||
|
"card": 11
|
||||||
|
},
|
||||||
|
"cam-11": {
|
||||||
|
"serial": "33C920FF",
|
||||||
|
"video": "/dev/cam11",
|
||||||
|
"card": 12
|
||||||
|
},
|
||||||
|
"cam-12": {
|
||||||
|
"serial": "5292BF6F",
|
||||||
|
"video": "/dev/cam12",
|
||||||
|
"card": 13
|
||||||
|
},
|
||||||
|
"cam-13": {
|
||||||
|
"serial": "357A8E2F",
|
||||||
|
"video": "/dev/cam13",
|
||||||
|
"card": 14
|
||||||
|
},
|
||||||
|
"cam-14": {
|
||||||
|
"serial": "2EB1BF2F",
|
||||||
|
"video": "/dev/cam14",
|
||||||
|
"card": 15
|
||||||
|
},
|
||||||
|
"cam-15": {
|
||||||
|
"serial": "B478307F",
|
||||||
|
"video": "/dev/cam15",
|
||||||
|
"card": 16
|
||||||
|
},
|
||||||
|
"cam-16": {
|
||||||
|
"serial": "9BF2BF6F",
|
||||||
|
"video": "/dev/cam16",
|
||||||
|
"card": 17
|
||||||
|
},
|
||||||
|
"cam-17": {
|
||||||
|
"serial": "0642BF6F",
|
||||||
|
"video": "/dev/cam17",
|
||||||
|
"card": 18
|
||||||
|
},
|
||||||
|
"cam-18": {
|
||||||
|
"serial": "8CE2BF6F",
|
||||||
|
"video": "/dev/cam18",
|
||||||
|
"card": 19
|
||||||
|
},
|
||||||
|
"cam-19": {
|
||||||
|
"serial": "D41E8E6F",
|
||||||
|
"video": "/dev/cam19",
|
||||||
|
"card": 20
|
||||||
|
},
|
||||||
|
"cam-20": {
|
||||||
|
"serial": "9B91BF6F",
|
||||||
|
"video": "/dev/cam20",
|
||||||
|
"card": 21
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Smoke test: one camera worker against the local LiveKit server, ~20 s."""
|
||||||
|
import json
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HERE = Path(__file__).parent
|
||||||
|
sys.path.insert(0, str(HERE))
|
||||||
|
|
||||||
|
from discovery import discover_cameras
|
||||||
|
from tokens import make_token
|
||||||
|
|
||||||
|
cam = discover_cameras()[0]
|
||||||
|
tok = make_token("ws://localhost:7880", "devkey", "devsecret", "smoke", "smoke-test")
|
||||||
|
|
||||||
|
p = subprocess.Popen(
|
||||||
|
[str(HERE / ".venv/bin/python"), str(HERE / "run_worker.py"),
|
||||||
|
"--camera-json", json.dumps({
|
||||||
|
"index": 0, "identity": "smoke-test",
|
||||||
|
"video_device": cam.video_device, "audio_card": cam.audio_card,
|
||||||
|
"usb_port": cam.usb_port}),
|
||||||
|
"--room", "smoke", "--url", "ws://localhost:7880", "--token", tok,
|
||||||
|
"--width", "640", "--height", "480", "--fps", "30",
|
||||||
|
"--audio-rate", "16000", "--enhance-mode", "off"],
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1,
|
||||||
|
)
|
||||||
|
t0 = time.time()
|
||||||
|
lines = []
|
||||||
|
while time.time() - t0 < 20:
|
||||||
|
line = p.stdout.readline()
|
||||||
|
if line:
|
||||||
|
lines.append(line.rstrip())
|
||||||
|
print(line.rstrip(), flush=True)
|
||||||
|
p.send_signal(signal.SIGTERM)
|
||||||
|
try:
|
||||||
|
rc = p.wait(timeout=15)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
p.kill()
|
||||||
|
rc = "killed"
|
||||||
|
print(f"--- smoke test done: rc={rc}")
|
||||||
|
print("published markers:", [l for l in lines if "published" in l or "status" in l or "UP" in l])
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""Generate LiveKit access tokens for the camera participants."""
|
||||||
|
|
||||||
|
from livekit.api import AccessToken, VideoGrants
|
||||||
|
|
||||||
|
|
||||||
|
def make_token(url: str, api_key: str, api_secret: str,
|
||||||
|
room: str, identity: str) -> str:
|
||||||
|
token = (
|
||||||
|
AccessToken(api_key, api_secret)
|
||||||
|
.with_identity(identity)
|
||||||
|
.with_name(identity)
|
||||||
|
.with_grants(VideoGrants(
|
||||||
|
room_join=True,
|
||||||
|
room=room,
|
||||||
|
can_publish=True,
|
||||||
|
can_subscribe=True,
|
||||||
|
can_publish_data=True,
|
||||||
|
))
|
||||||
|
)
|
||||||
|
return token.to_jwt()
|
||||||
Reference in New Issue
Block a user