190 lines
5.7 KiB
Python
190 lines
5.7 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
CHT Sender: muxed video + audio over TCP/mpegts
|
|
Source: Wayland (kmsgrab) + desktop audio + webcam mic
|
|
Usage: sudo python3 stream_av.py [RECEIVER_IP] [PORT]
|
|
|
|
Requires: sudo for kmsgrab, PulseAudio for audio capture
|
|
Auto-restarts on stall or fatal kmsgrab errors (DRM plane format change).
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import resource
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
|
|
RECEIVER_IP = sys.argv[1] if len(sys.argv) > 1 else "mcrndeb"
|
|
PORT = sys.argv[2] if len(sys.argv) > 2 else "4444"
|
|
STALL_TIMEOUT = 10
|
|
|
|
REAL_UID = os.environ.get("SUDO_UID") or str(os.getuid())
|
|
os.environ["PULSE_SERVER"] = f"unix:/run/user/{REAL_UID}/pulse/native"
|
|
|
|
FATAL_PATTERNS = ["framebuffer format changed", "Error during demuxing"]
|
|
|
|
|
|
def get_monitor_source():
|
|
try:
|
|
out = subprocess.check_output(["pactl", "info"], stderr=subprocess.DEVNULL).decode()
|
|
for line in out.splitlines():
|
|
if "Default Sink:" in line:
|
|
return line.split()[-1] + ".monitor"
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def get_webcam_mic():
|
|
try:
|
|
out = subprocess.check_output(
|
|
["pactl", "list", "short", "sources"], stderr=subprocess.DEVNULL
|
|
).decode()
|
|
for line in out.splitlines():
|
|
if "C922" in line:
|
|
return line.split()[1]
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def build_args(monitor, webcam_mic, progress_file):
|
|
args = [
|
|
"ffmpeg",
|
|
"-init_hw_device", "drm=drm:/dev/dri/card0",
|
|
"-init_hw_device", "vaapi=va@drm",
|
|
"-thread_queue_size", "64",
|
|
"-device", "/dev/dri/card0", "-f", "kmsgrab", "-framerate", "30", "-i", "-",
|
|
"-thread_queue_size", "1024", "-f", "pulse", "-i", monitor,
|
|
]
|
|
if webcam_mic:
|
|
args += [
|
|
"-thread_queue_size", "1024", "-f", "pulse", "-i", webcam_mic,
|
|
"-filter_complex", "[1:a][2:a]amix=inputs=2:duration=longest[aout]",
|
|
"-map", "0:v", "-map", "[aout]",
|
|
]
|
|
args += [
|
|
"-vf", "hwmap=derive_device=vaapi,scale_vaapi=w=1920:h=1080:format=nv12,fps=30",
|
|
"-c:v", "h264_vaapi", "-qp", "20", "-g", "30", "-keyint_min", "30", "-bf", "0",
|
|
"-c:a", "aac", "-b:a", "128k",
|
|
"-max_muxing_queue_size", "64",
|
|
"-flush_packets", "1", "-fflags", "nobuffer", "-muxdelay", "0", "-muxpreload", "0",
|
|
"-f", "mpegts", f"tcp://{RECEIVER_IP}:{PORT}",
|
|
"-hide_banner", "-progress", progress_file,
|
|
]
|
|
return args
|
|
|
|
|
|
def read_progress(progress_file, key):
|
|
try:
|
|
with open(progress_file) as f:
|
|
content = f.read()
|
|
matches = re.findall(rf"^{key}=(\d+)", content, re.MULTILINE)
|
|
return int(matches[-1]) if matches else 0
|
|
except Exception:
|
|
return 0
|
|
|
|
|
|
def run_once(args, progress_file):
|
|
"""Run the pipeline, monitor it. Returns True if should restart."""
|
|
fatal = threading.Event()
|
|
print(f"[cmd] {' '.join(args)}", flush=True)
|
|
|
|
proc = subprocess.Popen(args, stderr=subprocess.PIPE, start_new_session=True)
|
|
|
|
def watch_stderr():
|
|
for raw in proc.stderr:
|
|
line = raw.decode("utf-8", errors="replace").rstrip()
|
|
if line:
|
|
print(f"[ffmpeg] {line}", flush=True)
|
|
if any(p in line for p in FATAL_PATTERNS):
|
|
print("Fatal kmsgrab error — restarting immediately", flush=True)
|
|
fatal.set()
|
|
|
|
threading.Thread(target=watch_stderr, daemon=True).start()
|
|
|
|
last_bytes = last_frame = 0
|
|
stall_since = time.monotonic()
|
|
|
|
def kill_proc():
|
|
try:
|
|
os.killpg(os.getpgid(proc.pid), signal.SIGINT)
|
|
except Exception:
|
|
proc.kill()
|
|
try:
|
|
proc.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
|
|
try:
|
|
while proc.poll() is None:
|
|
time.sleep(1)
|
|
|
|
if fatal.is_set():
|
|
kill_proc()
|
|
break
|
|
|
|
cur_bytes = read_progress(progress_file, "total_size")
|
|
cur_frame = read_progress(progress_file, "frame")
|
|
|
|
if cur_bytes > last_bytes or cur_frame > last_frame:
|
|
last_bytes, last_frame = cur_bytes, cur_frame
|
|
stall_since = time.monotonic()
|
|
|
|
if time.monotonic() - stall_since > STALL_TIMEOUT:
|
|
print(f"Stalled at frame {last_frame} / {last_bytes}B — restarting", flush=True)
|
|
kill_proc()
|
|
break
|
|
except KeyboardInterrupt:
|
|
kill_proc()
|
|
raise
|
|
|
|
rc = proc.wait()
|
|
print(f"ffmpeg exited rc={rc}", flush=True)
|
|
return True # always restart; only KeyboardInterrupt stops the loop
|
|
|
|
|
|
def main():
|
|
try:
|
|
resource.setrlimit(resource.RLIMIT_NOFILE, (65536, 65536))
|
|
except Exception:
|
|
pass
|
|
|
|
monitor = get_monitor_source()
|
|
webcam_mic = get_webcam_mic()
|
|
|
|
print(f"Monitor source : {monitor}")
|
|
print(f"Webcam mic : {webcam_mic or 'not found'}")
|
|
print(f"Streaming to : {RECEIVER_IP}:{PORT}")
|
|
|
|
if not monitor:
|
|
sys.exit("ERROR: could not find monitor source")
|
|
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".progress") as f:
|
|
progress_file = f.name
|
|
|
|
try:
|
|
while True:
|
|
print(f"--- Starting sender {time.strftime('%c')} ---", flush=True)
|
|
open(progress_file, "w").close()
|
|
|
|
args = build_args(monitor, webcam_mic, progress_file)
|
|
run_once(args, progress_file)
|
|
print("Restarting in 2s...", flush=True)
|
|
time.sleep(2)
|
|
except KeyboardInterrupt:
|
|
print("\nStopped.", flush=True)
|
|
finally:
|
|
try:
|
|
os.unlink(progress_file)
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|