"""Shared C920 mic capture: small gst-launch pool, PCM hops in mmap rings.""" from __future__ import annotations import argparse import json import logging import os import select import signal import subprocess import sys import time from audio_gst import GST_GROUP_SIZE, grouped_audio, shared_audio_cmd from hop_shm import HopWriter, hop_bytes log = logging.getLogger("cameras.audio_daemon") READY_NAME = "ready" def _open_pipes(n: int) -> tuple[list[int], list[int]]: reads: list[int] = [] writes: list[int] = [] for _ in range(n): r, w = os.pipe() try: import fcntl fcntl.fcntl(r, fcntl.F_SETPIPE_SZ, 65536) fcntl.fcntl(w, fcntl.F_SETPIPE_SZ, 65536) except OSError: pass os.set_inheritable(w, True) os.set_inheritable(r, False) reads.append(r) writes.append(w) return reads, writes def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description="Shared ALSA capture daemon") ap.add_argument("--cameras-json", required=True) ap.add_argument("--rate", type=int, default=16000) ap.add_argument("--hop-s", type=float, default=0.1) ap.add_argument("--shm-dir", default="/run/livekit-cameras/pcm") ap.add_argument("--group-size", type=int, default=GST_GROUP_SIZE) args = ap.parse_args(argv) logging.basicConfig( level=logging.INFO, format="%(asctime)s [audio-daemon] %(levelname)s %(message)s") cams = json.loads(args.cameras_json) pairs = [(c["identity"], int(c["audio_card"])) for c in cams if c.get("audio_card") is not None] if not pairs: log.error("no audio cards") return 2 need = hop_bytes(args.rate, 2, args.hop_s) os.makedirs(args.shm_dir, exist_ok=True) writers = [ HopWriter(os.path.join(args.shm_dir, f"{ident}.pcm"), need) for ident, _card in pairs ] reads, writes = _open_pipes(len(pairs)) env = dict(os.environ) groups = grouped_audio(pairs, writes, args.group_size) log.info("starting %d gst-launch for %d mics rate=%d hop=%.3f (group=%d)", len(groups), len(pairs), args.rate, args.hop_s, args.group_size) procs: list[subprocess.Popen] = [] for g_cams, g_fds in groups: cmd = shared_audio_cmd(g_cams, args.rate, g_fds) procs.append(subprocess.Popen( cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=None, pass_fds=tuple(g_fds), env=env, close_fds=True, )) for w in writes: os.close(w) for r in reads: os.set_blocking(r, False) bufs = [bytearray() for _ in pairs] seqs = [0] * len(pairs) stop = False def _stop(_signum=None, _frame=None): nonlocal stop stop = True signal.signal(signal.SIGTERM, _stop) signal.signal(signal.SIGINT, _stop) ready_path = os.path.join(args.shm_dir, READY_NAME) if os.path.exists(ready_path): os.unlink(ready_path) marked = False last_log = time.monotonic() fd_index = {r: i for i, r in enumerate(reads)} try: while not stop: dead = [p for p in procs if p.poll() is not None] if dead: log.error("gst exited rc=%s", [p.returncode for p in dead]) break ready, _, _ = select.select(reads, [], [], 0.2) for r in ready: i = fd_index[r] while True: try: chunk = os.read(r, max(need, need - len(bufs[i]))) except BlockingIOError: break if not chunk: break bufs[i].extend(chunk) while len(bufs[i]) >= need: hop = bytes(bufs[i][:need]) del bufs[i][:need] seqs[i] = writers[i].write(hop) if not marked and any(s > 0 for s in seqs): open(ready_path, "w").write("ok\n") marked = True log.info("ready first hops %s", ",".join(f"{pairs[i][0]}={seqs[i]}" for i in range(len(pairs)))) now = time.monotonic() if now - last_log >= 5.0: last_log = now log.info("seqs %s", " ".join(f"{pairs[i][0]}:{seqs[i]}" for i in range(len(pairs)))) finally: for proc in procs: if proc.poll() is None: proc.terminate() try: proc.wait(timeout=5) except subprocess.TimeoutExpired: proc.kill() for r in reads: try: os.close(r) except OSError: pass for w in writers: w.close() if os.path.exists(ready_path): os.unlink(ready_path) return 0 if __name__ == "__main__": sys.exit(main())