Files
livekit-cameras/README.md
T
root 11776adf52 chore: Arc B580 offload spike script
Read-only probe: CUDA unusable, OpenCL sees the B580, OpenVINO not
installed. Per-worker GPU still out of scope.
2026-08-28 06:26:02 +00:00

6.8 KiB
Raw Blame History

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)

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

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:

.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

.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 pipes each camera's video into a GStreamer kmssink on a DRM connector.

Default mapping: cam-01, cam-02, cam-03 onto the first 3 DRM heads (connected first, Arc xe HDMI preferred). Today HDMI-A-7 is the live 4K panel; plug two more HDMI cables into the Arc card (HDMI-A-5/HDMI-A-6) and they light up on the same process.

.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                  # subscribe cam-01..03
.venv/bin/python run_displays.py --connectors HDMI-A-7,HDMI-A-6,HDMI-A-5

sudo cp deploy/livekit-displays.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now livekit-displays

DISPLAY_IDENTITIES / DISPLAY_CONNECTORS / DISPLAY_COUNT in .env. kmssink takes the DRM master for those connectors (the framebuffer console on HDMI-A-7 will be replaced by camera video).

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
  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:

systemctl disable --now livekit-server
systemctl restart livekit-cameras
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).