Files
livekit-cameras/README.md
T
root 736eccb932 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.
2026-08-28 07:14:21 +00:00

175 lines
6.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (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)
Identity is the USB serial, pinned by udev (`/dev/camNN` + `slots.json`).
`--cameras 1,5` means participants `cam-01` and `cam-05`, not discovery order.
## 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
smoke_test.py one-camera LiveKit publish with enhancement ON
scripts/bench_enhance.py RTF bench (1 s window / 200 ms hop)
deploy/livekit-cameras.service optional systemd unit (do not enable blindly)
```
## 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
```
The generator bakes the **current physical wiring** as canonical. Do not
re-run `--write` unless you intend to remap slots.
## 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
```
## Headless displays (LiveKit -> GStreamer kmssink)
This host has no X11/Wayland. `run_displays.py` joins the LiveKit room as
`display-wall` (subscribe-only) and drives three DRM roles with GStreamer
`kmssink`:
1. **grid** — 5x4 mosaic of cam-01..cam-20
2. **speaker** — designated speaker camera (`DISPLAY_SPEAKER_CAMERA`, default
`cam-01`; set to `active` to follow LiveKit active speaker)
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
.venv/bin/python run_displays.py --list
.venv/bin/python run_displays.py --test-pattern
.venv/bin/python run_displays.py
.venv/bin/python run_displays.py --speaker-camera cam-07
sudo systemctl restart livekit-displays
journalctl -u livekit-displays -f
```
`.env`: `DISPLAY_ROLES`, `DISPLAY_CONNECTORS`, `DISPLAY_SPEAKER_CAMERA`,
`DISPLAY_GRID_*`. kmssink takes the DRM master (framebuffer console on
HDMI-A-7 is replaced by the grid).
## Audio cleanup
C920 mics are 16 kHz **stereo**. Each hop (default 200 ms) is kept
stereo through a SpeechBrain preprocessor, then published as mono:
1. Delay-and-Sum beamform (GCC-PHAT TDOA, frozen for
`BEAMFORM_TDOA_EVERY=5` hops) — live path from the SpeechBrain
[multi-microphone beamforming tutorial](https://speechbrain.readthedocs.io/en/latest/tutorials/preprocessing/multi-microphone-beamforming.html)
2. `speechbrain/metricgan-plus-voicebank` neural enhancement on a 1 s
rolling window (`ENHANCE_CHUNK_S=1.0`, `ENHANCE_HOP_S=0.2`)
The 1 s window is **past context**, not +1 s delay: only the latest hop
is published. Algorithmic delay ≈ hop + compute.
Each worker caps Torch/BLAS to `TORCH_NUM_THREADS=1` (default) so 14–20
processes do not oversubscribe the i7-14700. This host has an Intel Arc
B580; the venv Torch is NVIDIA CUDA and **cannot** use it. Do not set
`run_opts={"device":"cuda"}`.
One worker process per camera loads the modules once and reuses them.
`enhance_batch` must be called with `lengths=torch.tensor([1.0])`
(relative, not sample count). `ENHANCE_MODE=off` (or a failed model
load with `auto`) skips MetricGAN; `BEAMFORM_MODE=off` averages L/R
instead of Delay-and-Sum. DSP high-pass + soft-clip is the last-resort
fallback so streams still work.
Until the 1 s window is full, hops use the DSP fallback so LiveKit is
not silent on startup. Workers log `process_ms` / `capture_ms` every 50
hops.
## Appliance boot / power button
This box is meant to behave like an appliance:
- Power button while **off** → BIOS/OS boot → `livekit-cameras` waits for
USB cameras + LiveKit, then publishes.
- Power button while **on** → full `poweroff` (not sleep). Suspend/hibernate
targets are masked. Drop-in:
`/etc/systemd/logind.conf.d/50-power-button.conf`
## systemd (enabled)
- `livekit-server.service` — **local TEST** LiveKit on :7880 (autostart for now)
- `livekit-cameras.service` — publishers. `Wants=` the test server, does not
`Require` it. Destination is `LIVEKIT_URL` in `.env` (currently
`ws://127.0.0.1:7880`).
- `livekit-displays.service` — optional. Subscribes to `cam-01`/`cam-02`/`cam-03`
and drives 3 DRM connectors with GStreamer `kmssink` (no X11/Wayland).
When the real/remote LiveKit exists, change `.env` `LIVEKIT_URL` and:
```bash
systemctl disable --now livekit-server
systemctl restart livekit-cameras
```
```bash
systemctl status livekit-cameras livekit-server
journalctl -u livekit-cameras -f
systemctl restart livekit-cameras # pick up newly plugged cameras
```
## 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 beamforms (Delay-and-Sum) then enhances, and publishes **mono**
to LiveKit.
- Camera identity is USB serial via `slots.json` / udev; USB port is
fallback only for cameras not yet in the map.
- Workers auto-restart (up to 10 times) if a camera drops.
- Local smoke: `.venv/bin/python smoke_test.py` (needs livekit-server on 7880).