P6-R1: Audio/video streaming pipeline
Lint & Validate / lint (push) Has been cancelled

- src/streaming/ module: camera detection (USB webcam + Pi Camera Module via
  v4l2/libcamera), GStreamer pipeline builder with V4L2/OMX/x264 h264 encoders,
  platform presets (YouTube, Twitch, Facebook, custom RTMP), streamer lifecycle
  manager with auto-reconnect, scene management, stream statistics
- Keyboard shortcut controller (evdev + pynput fallback) for stream control:
  Ctrl+Shift+S (start/stop), Ctrl+Shift+1-9 (scene switch), Ctrl+Shift+R (reconnect)
- REST API: /api/v1/stream/* endpoints for start/stop/status, scenes, cameras,
  platforms, and hotkey listing — integrated into NetworkServer
- StreamStateProvider bridging layer connects REST routes to Streamer instance
- GStreamer pipeline supports ALSA/JACK/PulseAudio audio capture and
  V4L2/libcamera video capture with H.264 hardware encoding on RPi4B
- Low-latency streaming settings for live performance (zerolatency, no B-frames)
- 89 streaming tests (709 total project tests pass)
This commit is contained in:
2026-05-19 22:13:29 -04:00
parent d6480a35ed
commit cdf04bd8eb
21 changed files with 7119 additions and 34 deletions
+96
View File
@@ -0,0 +1,96 @@
"""Audio/video streaming pipeline for the RPi Audio Mixer.
Streams the mixer master mix + camera video to RTMP platforms
(YouTube, Twitch, custom RTMP servers) using GStreamer with
hardware-accelerated h264 encoding on Raspberry Pi 4B.
Modules:
- camera: camera detection (USB webcam, Pi Camera Module)
- platforms: streaming platform profiles and RTMP URLs
- gst_pipeline: GStreamer pipeline string builder
- streamer: Streamer lifecycle manager (start/stop/status)
- controls: keyboard shortcut handler for stream control
Architecture:
Audio: JACK system output (master mix) → alsasink bridge → GStreamer
Video: v4l2src (USB) or libcamerasrc (Pi Cam) → h264 hardware encoder
Output: RTMP sink → streaming platform
Integration:
- REST API: /api/v1/stream/* endpoints for start/stop/status/scenes
- WebSocket: real-time stream status updates
- Keyboard: global hotkeys for stream control
"""
from __future__ import annotations
from .camera import (
CameraInfo,
CameraType,
detect_cameras,
get_default_camera,
)
from .platforms import (
PlatformProfile,
YOUTUBE_RTMP,
TWITCH_RTMP,
CUSTOM_RTMP,
PRESET_PLATFORMS,
get_platform_profile,
)
from .gst_pipeline import (
GstPipelineBuilder,
VideoSource,
AudioSource,
EncoderType,
build_pipeline_string,
)
from .streamer import (
Streamer,
StreamerConfig,
StreamState,
StreamScene,
StreamStats,
create_streamer,
)
from .controls import (
StreamKeyboardController,
StreamHotkey,
start_keyboard_controller,
)
__all__ = [
# Camera
"CameraInfo",
"CameraType",
"detect_cameras",
"get_default_camera",
# Platforms
"PlatformProfile",
"YOUTUBE_RTMP",
"TWITCH_RTMP",
"CUSTOM_RTMP",
"PRESET_PLATFORMS",
"get_platform_profile",
# Pipeline
"GstPipelineBuilder",
"VideoSource",
"AudioSource",
"EncoderType",
"build_pipeline_string",
# Streamer
"Streamer",
"StreamerConfig",
"StreamState",
"StreamScene",
"StreamStats",
"create_streamer",
# Controls
"StreamKeyboardController",
"StreamHotkey",
"start_keyboard_controller",
]
+398
View File
@@ -0,0 +1,398 @@
"""Camera detection and configuration for the streaming pipeline.
Detects available cameras on Raspberry Pi 4B:
- USB webcams (v4l2, /dev/video*)
- Raspberry Pi Camera Module (libcamera, /dev/video* via bcm2835-unicam)
Returns CameraInfo objects with device paths, supported resolutions,
and frame rates for use in GStreamer pipeline construction.
"""
from __future__ import annotations
import logging
import os
import re
import subprocess
from dataclasses import dataclass, field
from enum import StrEnum
from typing import Optional
logger = logging.getLogger(__name__)
class CameraType(StrEnum):
"""Camera hardware type."""
USB_WEBCAM = "usb_webcam"
PI_CAMERA = "pi_camera"
PI_CAMERA_LEGACY = "pi_camera_legacy" # legacy raspicam stack
UNKNOWN = "unknown"
@dataclass
class CameraResolution:
"""Supported resolution for a camera."""
width: int
height: int
fps: float = 30.0
@dataclass
class CameraInfo:
"""Information about a detected camera."""
device_path: str # e.g. "/dev/video0"
camera_type: CameraType
name: str = ""
driver: str = ""
bus_info: str = ""
resolutions: list[CameraResolution] = field(default_factory=list)
pixel_formats: list[str] = field(default_factory=list)
@property
def supports_h264(self) -> bool:
"""Check if the camera supports hardware h264 encoding."""
return "H264" in self.pixel_formats or "h264" in self.pixel_formats
@property
def supports_mjpeg(self) -> bool:
"""Check if the camera supports MJPEG output."""
return "MJPG" in self.pixel_formats or "mjpeg" in self.pixel_formats
@property
def best_resolution(self) -> Optional[CameraResolution]:
"""Get the highest resolution available."""
if not self.resolutions:
return None
return max(self.resolutions, key=lambda r: r.width * r.height)
def to_dict(self) -> dict:
return {
"device_path": self.device_path,
"camera_type": self.camera_type.value,
"name": self.name,
"driver": self.driver,
"bus_info": self.bus_info,
"resolutions": [
{"width": r.width, "height": r.height, "fps": r.fps}
for r in self.resolutions
],
"pixel_formats": self.pixel_formats,
}
# ── Detection ────────────────────────────────────────────────────────────────
def detect_cameras() -> list[CameraInfo]:
"""Detect all available cameras on the system.
Checks for:
1. Pi Camera Module (via libcamera or bcm2835-unicam driver)
2. USB webcams (via v4l2 /dev/video* devices)
Returns a list of CameraInfo objects, best candidate first.
"""
cameras: list[CameraInfo] = []
# Check for Pi Camera Module
pi_cams = _detect_pi_camera()
cameras.extend(pi_cams)
# Check for USB webcams
usb_cams = _detect_usb_webcams()
cameras.extend(usb_cams)
# Sort: Pi Camera first (better performance on RPi), then by name
def _sort_key(cam: CameraInfo) -> tuple[int, str]:
type_order = {
CameraType.PI_CAMERA: 0,
CameraType.PI_CAMERA_LEGACY: 1,
CameraType.USB_WEBCAM: 2,
CameraType.UNKNOWN: 3,
}
return (type_order.get(cam.camera_type, 99), cam.name)
cameras.sort(key=_sort_key)
return cameras
def get_default_camera() -> Optional[CameraInfo]:
"""Get the best available camera for streaming."""
cameras = detect_cameras()
return cameras[0] if cameras else None
def _detect_pi_camera() -> list[CameraInfo]:
"""Detect Raspberry Pi Camera Module (libcamera or legacy)."""
cameras: list[CameraInfo] = []
# Check for libcamera support (modern Pi camera stack)
try:
result = subprocess.run(
["libcamera-hello", "--list-cameras"],
capture_output=True, text=True, timeout=5,
)
if result.returncode == 0:
# Parse output for camera info
info = _parse_libcamera_output(result.stdout)
if info:
cameras.append(info)
# Also check for libcamera devices in /dev
for dev in sorted(os.listdir("/dev")):
if dev.startswith("video"):
devpath = f"/dev/{dev}"
driver = _get_v4l2_driver(devpath)
if driver and "unicam" in driver.lower():
if not any(c.device_path == devpath for c in cameras):
cam = CameraInfo(
device_path=devpath,
camera_type=CameraType.PI_CAMERA,
name=f"Pi Camera ({dev})",
driver=driver,
)
_enrich_v4l2_info(cam)
cameras.append(cam)
except FileNotFoundError:
logger.debug("libcamera-hello not found (not on a Pi or libcamera not installed)")
# Check for legacy raspicam
try:
if os.path.exists("/dev/vchiq"):
# Legacy Pi camera stack devices
for dev in sorted(os.listdir("/dev")):
if dev.startswith("video"):
devpath = f"/dev/{dev}"
driver = _get_v4l2_driver(devpath)
if driver and "bcm2835" in driver.lower():
cam = CameraInfo(
device_path=devpath,
camera_type=CameraType.PI_CAMERA_LEGACY,
name=f"Pi Camera Legacy ({dev})",
driver=driver,
)
_enrich_v4l2_info(cam)
cameras.append(cam)
except Exception:
pass
return cameras
def _detect_usb_webcams() -> list[CameraInfo]:
"""Detect USB webcams via v4l2 device enumeration."""
cameras: list[CameraInfo] = []
# Try using v4l2-ctl
try:
result = subprocess.run(
["v4l2-ctl", "--list-devices"],
capture_output=True, text=True, timeout=5,
)
if result.returncode == 0:
cameras.extend(_parse_v4l2_list(result.stdout))
return cameras
except FileNotFoundError:
logger.debug("v4l2-ctl not found, falling back to manual detection")
# Fallback: scan /dev/video* devices manually
for dev in sorted(os.listdir("/dev")):
if re.match(r"^video\d+$", dev):
devpath = f"/dev/{dev}"
driver = _get_v4l2_driver(devpath)
if driver and "unicam" not in driver.lower() and "bcm2835" not in driver.lower():
cam = CameraInfo(
device_path=devpath,
camera_type=CameraType.USB_WEBCAM,
name=_get_v4l2_name(devpath) or f"USB Camera ({dev})",
driver=driver,
)
_enrich_v4l2_info(cam)
cameras.append(cam)
return cameras
# ── v4l2 helpers ─────────────────────────────────────────────────────────────
def _get_v4l2_driver(device: str) -> str:
"""Get the driver name for a v4l2 device."""
try:
result = subprocess.run(
["v4l2-ctl", "-d", device, "--info"],
capture_output=True, text=True, timeout=3,
)
for line in result.stdout.splitlines():
if "Driver name" in line:
return line.split(":", 1)[1].strip()
except Exception:
pass
# Try reading from sysfs
try:
devname = os.path.basename(device)
uevent = f"/sys/class/video4linux/{devname}/device/uevent"
if os.path.exists(uevent):
with open(uevent) as f:
for line in f:
if line.startswith("DRIVER="):
return line.split("=", 1)[1].strip()
except Exception:
pass
return ""
def _get_v4l2_name(device: str) -> str:
"""Get the card name for a v4l2 device."""
try:
result = subprocess.run(
["v4l2-ctl", "-d", device, "--info"],
capture_output=True, text=True, timeout=3,
)
for line in result.stdout.splitlines():
if "Card type" in line:
return line.split(":", 1)[1].strip()
except Exception:
pass
return ""
def _enrich_v4l2_info(cam: CameraInfo) -> None:
"""Add resolution and format info to a CameraInfo via v4l2-ctl."""
try:
# Get supported formats
result = subprocess.run(
["v4l2-ctl", "-d", cam.device_path, "--list-formats-ext"],
capture_output=True, text=True, timeout=5,
)
if result.returncode == 0:
_parse_v4l2_formats(result.stdout, cam)
except Exception as exc:
logger.debug("Failed to get v4l2 formats for %s: %s", cam.device_path, exc)
def _parse_v4l2_list(output: str) -> list[CameraInfo]:
"""Parse v4l2-ctl --list-devices output."""
cameras: list[CameraInfo] = []
current_name = ""
current_driver = ""
for line in output.splitlines():
line = line.strip()
if not line:
continue
# Device name line (e.g. "USB Camera: USB Camera (usb-0000:01:00.0-1.2):")
if "(" in line and not line.startswith("/dev/"):
# Extract name and bus info
match = re.match(r"^(.+?)\s*\((.+?)\):", line)
if match:
current_name = match.group(1).strip()
current_driver = match.group(2).strip()
else:
current_name = line.rstrip(":")
elif line.startswith("/dev/video"):
devpath = line.strip()
cam = CameraInfo(
device_path=devpath,
camera_type=_classify_camera(current_name, current_driver),
name=current_name,
driver=current_driver,
)
_enrich_v4l2_info(cam)
cameras.append(cam)
return cameras
def _parse_v4l2_formats(output: str, cam: CameraInfo) -> None:
"""Parse v4l2-ctl --list-formats-ext output."""
resolutions: list[CameraResolution] = []
pixel_formats: list[str] = []
current_format = ""
current_size = ""
for line in output.splitlines():
line = line.strip()
# Format line: "[0]: 'YUYV' (YUYV 4:2:2)"
if line.startswith("[") and "'" in line:
match = re.search(r"'(\w+)'", line)
if match:
current_format = match.group(1)
if current_format not in pixel_formats:
pixel_formats.append(current_format)
# Size line: "Size: Discrete 640x480"
elif line.startswith("Size:"):
match = re.search(r"(\d+)x(\d+)", line)
if match:
current_size = f"{match.group(1)}x{match.group(2)}"
w, h = int(match.group(1)), int(match.group(2))
resolutions.append(CameraResolution(width=w, height=h, fps=30.0))
# FPS line: "Interval: Discrete 0.033s (30.000 fps)"
elif line.startswith("Interval:") and resolutions:
match = re.search(r"\((\d+\.?\d*)\s*fps\)", line)
if match:
fps = float(match.group(1))
resolutions[-1].fps = min(resolutions[-1].fps, fps)
cam.resolutions = resolutions
cam.pixel_formats = pixel_formats
def _parse_libcamera_output(output: str) -> Optional[CameraInfo]:
"""Parse libcamera-hello --list-cameras output."""
# libcamera output format:
# Available cameras
# ----------------
# 0 : imx219 [3280x2464] (/base/soc/i2c0mux/i2c@1/imx219@10)
# Modes: 'SRGGB10_CSI2P' : 640x480, 1640x1232, 1920x1080, 3280x2464
# 'SRGGB8' : 640x480, 1640x1232, 1920x1080, 3280x2464
lines = output.splitlines()
for i, line in enumerate(lines):
match = re.match(r"^\d+\s*:\s*(\w+)\s*\[(\d+)x(\d+)\]", line)
if match:
sensor = match.group(1)
width = int(match.group(2))
height = int(match.group(3))
cam = CameraInfo(
device_path="/dev/video0", # libcamera abstracts device path
camera_type=CameraType.PI_CAMERA,
name=f"Pi Camera ({sensor})",
driver="unicam",
)
cam.resolutions.append(CameraResolution(width=width, height=height, fps=30.0))
# Parse modes from following lines
for mode_line in lines[i+1:]:
mode_line = mode_line.strip()
if mode_line.startswith("Modes:") or mode_line.startswith("'"):
for res_match in re.finditer(r"(\d+)x(\d+)", mode_line):
w, h = int(res_match.group(1)), int(res_match.group(2))
existing = {(r.width, r.height) for r in cam.resolutions}
if (w, h) not in existing:
cam.resolutions.append(CameraResolution(width=w, height=h, fps=30.0))
elif mode_line and not mode_line.startswith("'"):
break
return cam
return None
def _classify_camera(name: str, driver: str) -> CameraType:
"""Classify camera type from name and driver info."""
combined = f"{name} {driver}".lower()
if "pi camera" in combined or "imx" in combined or "ov5647" in combined:
return CameraType.PI_CAMERA
if "bcm2835" in combined or "mmal" in combined:
return CameraType.PI_CAMERA_LEGACY
if "usb" in combined or "webcam" in combined or "uvc" in combined:
return CameraType.USB_WEBCAM
return CameraType.USB_WEBCAM # default assumption
+384
View File
@@ -0,0 +1,384 @@
"""Keyboard shortcut handler for stream control.
Provides global keyboard shortcuts for controlling the streaming
pipeline without needing the web UI or touchscreen:
Ctrl+Shift+S — Start/stop stream
Ctrl+Shift+1..9 — Switch to scene 1-9
Ctrl+Shift+R — Force reconnect
On Linux, uses evdev for direct keyboard input (works without
a window manager, ideal for headless RPi setups).
On macOS/Windows, uses pynput as fallback.
The controller runs a background thread that listens for key
combinations and dispatches them to the Streamer.
"""
from __future__ import annotations
import logging
import threading
import time
from dataclasses import dataclass
from enum import StrEnum
from typing import Optional, Callable
logger = logging.getLogger(__name__)
class StreamHotkey(StrEnum):
"""Stream control hotkey actions."""
START_STOP = "start_stop" # Toggle streaming
SCENE_1 = "scene_1"
SCENE_2 = "scene_2"
SCENE_3 = "scene_3"
SCENE_4 = "scene_4"
SCENE_5 = "scene_5"
SCENE_6 = "scene_6"
SCENE_7 = "scene_7"
SCENE_8 = "scene_8"
SCENE_9 = "scene_9"
RECONNECT = "reconnect" # Force reconnect
MUTE_VIDEO = "mute_video" # Toggle video (audio only)
MUTE_AUDIO = "mute_audio" # Toggle audio (video only)
@dataclass
class HotkeyMapping:
"""Maps a key combination to a stream action."""
hotkey: StreamHotkey
modifiers: set[str] # e.g. {"ctrl", "shift"}
key: str # e.g. "s", "1", "r"
description: str = ""
# Default hotkey mappings for the streaming controller
DEFAULT_HOTKEYS: list[HotkeyMapping] = [
HotkeyMapping(StreamHotkey.START_STOP, {"ctrl", "shift"}, "s", "Start/Stop streaming"),
HotkeyMapping(StreamHotkey.SCENE_1, {"ctrl", "shift"}, "1", "Switch to scene 1"),
HotkeyMapping(StreamHotkey.SCENE_2, {"ctrl", "shift"}, "2", "Switch to scene 2"),
HotkeyMapping(StreamHotkey.SCENE_3, {"ctrl", "shift"}, "3", "Switch to scene 3"),
HotkeyMapping(StreamHotkey.SCENE_4, {"ctrl", "shift"}, "4", "Switch to scene 4"),
HotkeyMapping(StreamHotkey.SCENE_5, {"ctrl", "shift"}, "5", "Switch to scene 5"),
HotkeyMapping(StreamHotkey.SCENE_6, {"ctrl", "shift"}, "6", "Switch to scene 6"),
HotkeyMapping(StreamHotkey.SCENE_7, {"ctrl", "shift"}, "7", "Switch to scene 7"),
HotkeyMapping(StreamHotkey.SCENE_8, {"ctrl", "shift"}, "8", "Switch to scene 8"),
HotkeyMapping(StreamHotkey.SCENE_9, {"ctrl", "shift"}, "9", "Switch to scene 9"),
HotkeyMapping(StreamHotkey.RECONNECT, {"ctrl", "shift"}, "r", "Force reconnect"),
HotkeyMapping(StreamHotkey.MUTE_VIDEO, {"ctrl", "shift"}, "v", "Toggle video mute"),
HotkeyMapping(StreamHotkey.MUTE_AUDIO, {"ctrl", "shift"}, "a", "Toggle audio mute"),
]
class StreamKeyboardController:
"""Global keyboard shortcut handler for stream control.
Runs a background listener thread. On Linux, prefers evdev for
direct keyboard access (works without X11/Wayland, ideal for
headless Raspberry Pi setups).
Usage:
from src.streaming import Streamer, StreamKeyboardController
streamer = Streamer(...)
controller = StreamKeyboardController(streamer)
controller.start()
# ... streamer operations ...
controller.stop()
"""
def __init__(
self,
streamer, # Streamer (avoid circular import by not type-hinting)
hotkeys: list[HotkeyMapping] | None = None,
use_evdev: bool = True,
evdev_device: str | None = None,
):
"""
Args:
streamer: The Streamer instance to control.
hotkeys: Custom hotkey mappings (uses defaults if None).
use_evdev: Prefer evdev on Linux (True) or fallback to pynput.
evdev_device: Specific evdev device path (auto-detected if None).
"""
self._streamer = streamer
self._hotkeys = hotkeys or DEFAULT_HOTKEYS
self._use_evdev = use_evdev
self._evdev_device = evdev_device
self._running = False
self._thread: Optional[threading.Thread] = None
self._lock = threading.Lock()
# Build hotkey lookup: (frozenset(modifiers), key) → action
self._action_map: dict[tuple[frozenset[str], str], StreamHotkey] = {}
self._hotkey_descriptions: dict[StreamHotkey, str] = {}
for hk in self._hotkeys:
key = hk.key.lower()
mods = frozenset(m.lower() for m in hk.modifiers)
self._action_map[(mods, key)] = hk.hotkey
self._hotkey_descriptions[hk.hotkey] = hk.description
def start(self) -> bool:
"""Start the keyboard listener in a background thread.
Returns:
True if the listener started successfully.
"""
with self._lock:
if self._running:
return True
self._running = True
self._thread = threading.Thread(
target=self._listen_loop,
daemon=True,
name="stream-keyboard",
)
self._thread.start()
logger.info("Stream keyboard controller started")
return True
def stop(self) -> None:
"""Stop the keyboard listener."""
with self._lock:
self._running = False
if self._thread and self._thread.is_alive():
self._thread.join(timeout=2.0)
logger.info("Stream keyboard controller stopped")
def get_hotkeys(self) -> list[dict]:
"""List all configured hotkeys (for API display)."""
return [
{
"action": hk.hotkey.value,
"modifiers": sorted(hk.modifiers),
"key": hk.key,
"description": hk.description,
}
for hk in self._hotkeys
]
# ── Internals ──────────────────────────────────────────────────────────
def _listen_loop(self) -> None:
"""Main keyboard listen loop. Tries evdev first, then pynput."""
if self._use_evdev:
success = self._try_evdev_listener()
if success:
return
logger.info("evdev listener failed, trying pynput fallback")
self._try_pynput_listener()
def _try_evdev_listener(self) -> bool:
"""Try to listen for keyboard events via evdev."""
try:
import evdev
from evdev import ecodes, InputDevice
# Find keyboard devices
device = self._find_evdev_keyboard()
if device is None:
logger.warning("No evdev keyboard device found")
return False
logger.info("Listening on keyboard: %s (%s)", device.name, device.path)
# Modifier state tracking
modifiers_active: set[str] = set()
modifier_map = {
ecodes.KEY_LEFTCTRL: "ctrl",
ecodes.KEY_RIGHTCTRL: "ctrl",
ecodes.KEY_LEFTSHIFT: "shift",
ecodes.KEY_RIGHTSHIFT: "shift",
ecodes.KEY_LEFTALT: "alt",
ecodes.KEY_RIGHTALT: "alt",
ecodes.KEY_LEFTMETA: "meta",
ecodes.KEY_RIGHTMETA: "meta",
}
key_name_map = {
ecodes.KEY_S: "s", ecodes.KEY_R: "r", ecodes.KEY_V: "v", ecodes.KEY_A: "a",
ecodes.KEY_1: "1", ecodes.KEY_2: "2", ecodes.KEY_3: "3",
ecodes.KEY_4: "4", ecodes.KEY_5: "5", ecodes.KEY_6: "6",
ecodes.KEY_7: "7", ecodes.KEY_8: "8", ecodes.KEY_9: "9",
ecodes.KEY_0: "0",
}
for event in device.read_loop():
if not self._running:
break
if event.type != ecodes.EV_KEY:
continue
keycode = event.code
keystate = event.value # 0=up, 1=down, 2=hold
# Track modifiers
if keycode in modifier_map:
if keystate == 1: # pressed
modifiers_active.add(modifier_map[keycode])
elif keystate == 0: # released
modifiers_active.discard(modifier_map[keycode])
# Check for hotkey on key-down
if keystate == 1 and keycode in key_name_map:
key_name = key_name_map[keycode]
self._check_hotkey(modifiers_active, key_name)
return True
except ImportError:
logger.debug("evdev not installed (pip install evdev)")
return False
except Exception as exc:
logger.warning("evdev listener error: %s", exc)
return False
def _try_pynput_listener(self) -> bool:
"""Try to listen for keyboard events via pynput."""
try:
from pynput import keyboard
modifiers_active: set[str] = set()
modifier_map = {
keyboard.Key.ctrl_l: "ctrl",
keyboard.Key.ctrl_r: "ctrl",
keyboard.Key.shift_l: "shift",
keyboard.Key.shift_r: "shift",
keyboard.Key.alt_l: "alt",
keyboard.Key.alt_r: "alt",
}
def on_press(key):
if not self._running:
return False
if hasattr(key, "name"): # Special key
mod = modifier_map.get(key)
if mod:
modifiers_active.add(mod)
else: # Character key
try:
char = key.char.lower()
except AttributeError:
return
self._check_hotkey(modifiers_active, char)
def on_release(key):
if hasattr(key, "name"):
mod = modifier_map.get(key)
if mod:
modifiers_active.discard(mod)
with keyboard.Listener(
on_press=on_press,
on_release=on_release,
) as listener:
listener.join()
return True
except ImportError:
logger.debug("pynput not installed (pip install pynput)")
return False
except Exception as exc:
logger.warning("pynput listener error: %s", exc)
return False
def _find_evdev_keyboard(self) -> Optional[object]:
"""Find an evdev keyboard device."""
try:
import evdev
if self._evdev_device:
return evdev.InputDevice(self._evdev_device)
devices = [evdev.InputDevice(path) for path in evdev.list_devices()]
for device in devices:
if "keyboard" in device.name.lower() or "key" in device.name.lower():
# Check if it has keyboard keys
caps = device.capabilities()
if 1 in caps: # EV_KEY
return device
# Fallback: first device with EV_KEY capability
for device in devices:
caps = device.capabilities()
if 1 in caps:
return device
except ImportError:
pass
return None
def _check_hotkey(self, modifiers: set[str], key: str) -> None:
"""Check if a key combination matches a hotkey, and dispatch."""
# Normalize
mods = frozenset(m.lower() for m in modifiers)
key = key.lower()
action = self._action_map.get((mods, key))
if action is None:
return
logger.info("Hotkey triggered: %s", action.value)
self._dispatch_action(action)
def _dispatch_action(self, action: StreamHotkey) -> None:
"""Execute the action on the streamer."""
try:
if action == StreamHotkey.START_STOP:
if self._streamer.is_live:
self._streamer.stop()
else:
self._streamer.start()
elif action == StreamHotkey.RECONNECT:
if self._streamer.is_live:
self._streamer.stop()
time.sleep(1.0)
self._streamer.start()
elif action.value.startswith("scene_"):
scene_num = action.value.split("_")[1]
scenes = self._streamer.get_scenes()
if len(scenes) >= int(scene_num):
scene_name = scenes[int(scene_num) - 1]["name"]
self._streamer.switch_scene(scene_name)
elif action == StreamHotkey.MUTE_VIDEO:
logger.info("Video mute toggle (not yet implemented)")
elif action == StreamHotkey.MUTE_AUDIO:
logger.info("Audio mute toggle (not yet implemented)")
except Exception as exc:
logger.error("Error dispatching hotkey %s: %s", action.value, exc)
def start_keyboard_controller(
streamer, # Streamer
hotkeys: list[HotkeyMapping] | None = None,
) -> StreamKeyboardController:
"""Create and start a keyboard controller for the given streamer.
Convenience factory function.
Args:
streamer: The Streamer to control.
hotkeys: Custom hotkey mappings.
Returns:
Started StreamKeyboardController.
"""
controller = StreamKeyboardController(streamer, hotkeys=hotkeys)
controller.start()
return controller
+366
View File
@@ -0,0 +1,366 @@
"""GStreamer pipeline builder for audio/video streaming.
Constructs GStreamer pipeline strings for the Raspberry Pi 4B
hardware-accelerated encoding path:
Video: camera → h264 encoder → RTMP mux
Audio: JACK/ALSA → AAC encoder → RTMP mux
Supports both Python GObject bindings (preferred) and CLI
gst-launch-1.0 subprocess mode for reliability.
Raspberry Pi 4B hardware encoders:
- v4l2h264enc: V4L2 stateless hardware h264 encoder (Pi 4/5)
- omxh264enc: Legacy Broadcom MMAL hardware encoder (Pi 3/older)
- x264enc: Software fallback (works everywhere, higher CPU)
Audio capture methods:
- alsasrc: Direct ALSA device (e.g., JACK ALSA bridge)
- pulsesrc: PulseAudio source
- jackaudiosrc: JACK audio source (requires gst-plugins-bad)
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from enum import StrEnum
from typing import Optional
logger = logging.getLogger(__name__)
class VideoSource(StrEnum):
"""Video input source type for GStreamer pipeline."""
V4L2 = "v4l2" # USB webcam /dev/video*
LIBCAMERA = "libcamera" # Pi Camera Module (modern libcamera)
RASPICAM = "raspicam" # Legacy Pi Camera (rpicamsrc)
TEST = "test" # Test pattern (videotestsrc)
class AudioSource(StrEnum):
"""Audio input source type for GStreamer pipeline."""
ALSA = "alsa" # Direct ALSA device
JACK = "jack" # JACK audio server
PULSE = "pulse" # PulseAudio
TEST = "test" # Test tone (audiotestsrc)
class EncoderType(StrEnum):
"""Video encoder type."""
V4L2_H264 = "v4l2h264enc" # V4L2 stateless h264 (Pi 4/5)
OMX_H264 = "omxh264enc" # Broadcom OMX h264 (Pi 3/older)
X264 = "x264enc" # Software x264
LIBCAMERA_H264 = "libcamera_h264" # libcamera built-in h264
@dataclass
class GstPipelineConfig:
"""Configuration for building a GStreamer pipeline."""
# Video
video_source: VideoSource = VideoSource.TEST
video_device: str = "/dev/video0"
video_width: int = 1280
video_height: int = 720
video_framerate: int = 30
# Audio
audio_source: AudioSource = AudioSource.TEST
audio_device: str = "hw:0" # ALSA device name
audio_sample_rate: int = 48000
audio_channels: int = 2
audio_buffer_time_us: int = 20000 # 20ms buffer for low latency
# Encoding
video_encoder: EncoderType = EncoderType.V4L2_H264
video_bitrate_kbps: int = 2500
video_bitrate_max_kbps: int = 4000
keyframe_interval: int = 60 # frames between keyframes
h264_profile: str = "main"
h264_level: str = "4.0"
b_frames: int = 0 # 0 for lowest latency
audio_bitrate_kbps: int = 128
audio_encoder: str = "avenc_aac" # or voaacenc, lamemp3enc
# Output
rtmp_url: str = ""
rtmp_stream_key: str = ""
# Latency tuning
low_latency: bool = True
video_queue_max_time_ms: int = 1000
zerolatency_tune: bool = True
# Test mode
test_video_pattern: int = 0 # videotestsrc pattern number
test_audio_freq: float = 440.0 # audiotestsrc frequency
@property
def full_rtmp_url(self) -> str:
"""Construct full RTMP URL with stream key."""
if not self.rtmp_url:
return ""
url = self.rtmp_url.rstrip("/")
if self.rtmp_stream_key:
url = f"{url}/{self.rtmp_stream_key}"
return url
class GstPipelineBuilder:
"""Builds GStreamer pipeline strings for various configurations.
Usage:
builder = GstPipelineBuilder(config)
pipeline_str = builder.build()
# Or for CLI mode:
subprocess.run(["gst-launch-1.0", "-e"] + builder.build_args())
"""
def __init__(self, config: GstPipelineConfig):
self.config = config
def build(self) -> str:
"""Build a complete GStreamer pipeline string."""
elements = self._build_elements()
return " ! ".join(elements)
def build_args(self) -> list[str]:
"""Build GStreamer pipeline as argument list for subprocess."""
return self.build().split(" ! ")
def _build_elements(self) -> list[str]:
"""Build all pipeline element strings."""
elements: list[str] = []
# Video branch
video_elements = self._build_video_branch()
# Audio branch
audio_elements = self._build_audio_branch()
# Combine with flvmux + rtmpsink
elements.append(
f"flvmux name=mux streamable=true "
f"{' ! rtmpsink location=' + self.config.full_rtmp_url + ' sync=false' if self.config.full_rtmp_url else ''}"
)
# Build the full pipeline string with named branches
video_str = " ! ".join(video_elements)
audio_str = " ! ".join(audio_elements)
# Return GStreamer pipeline with named pads
flvmux_elements = ["flvmux", "name=mux", "streamable=true"]
if self.config.full_rtmp_url:
flvmux_elements.append(f"rtmpsink location={self.config.full_rtmp_url} sync=false")
else:
flvmux_elements.append("fakesink sync=false")
mux_str = " ! ".join(flvmux_elements)
full = f"{video_str} ! mux.video {audio_str} ! mux.audio {mux_str}"
return full.split(" ! ")
def _build_video_branch(self) -> list[str]:
"""Build the video source + encoding branch."""
elements: list[str] = []
cfg = self.config
# Video source
source = self._build_video_source()
elements.append(source)
# Video processing
elements.append("videoconvert")
elements.append(f"video/x-raw,format=I420,width={cfg.video_width},height={cfg.video_height},framerate={cfg.video_framerate}/1")
# Queue for buffering
if cfg.low_latency:
elements.append(f"queue max-size-time={cfg.video_queue_max_time_ms * 1000000} max-size-buffers=0 max-size-bytes=0")
else:
elements.append("queue")
# Video encoder
encoder = self._build_video_encoder()
elements.extend(encoder)
# H.264 parsing
elements.append("h264parse")
elements.append(f"video/x-h264,profile={cfg.h264_profile},level={cfg.h264_level}")
return elements
def _build_video_source(self) -> str:
"""Build the video source element string."""
cfg = self.config
if cfg.video_source == VideoSource.V4L2:
return f"v4l2src device={cfg.video_device} do-timestamp=true"
elif cfg.video_source == VideoSource.LIBCAMERA:
# libcamerasrc — modern Pi camera stack
return (
f"libcamerasrc camera-name={cfg.video_device} "
f"! video/x-raw,width={cfg.video_width},height={cfg.video_height},framerate={cfg.video_framerate}/1"
)
elif cfg.video_source == VideoSource.RASPICAM:
# rpicamsrc — legacy Pi camera stack
return (
f"rpicamsrc preview=false bitrate={cfg.video_bitrate_kbps * 1000} "
f"! video/x-h264,width={cfg.video_width},height={cfg.video_height},framerate={cfg.video_framerate}/1,profile={cfg.h264_profile}"
)
elif cfg.video_source == VideoSource.TEST:
return f"videotestsrc pattern={cfg.test_video_pattern} is-live=true"
return f"v4l2src device={cfg.video_device}"
def _build_video_encoder(self) -> list[str]:
"""Build the video encoder element(s)."""
cfg = self.config
if cfg.video_encoder == EncoderType.LIBCAMERA_H264:
# libcamera does its own encoding, skip external encoder
return []
if cfg.video_encoder == EncoderType.V4L2_H264:
return [
f"v4l2h264enc "
f"extra-controls='controls,"
f"video_bitrate={cfg.video_bitrate_kbps * 1000},"
f"repeat_sequence_header=1,"
f"h264_profile=4," # main profile
f"h264_level=10," # level 4.0
f"video_bitrate_mode=0'" # VBR
]
elif cfg.video_encoder == EncoderType.OMX_H264:
return [
f"omxh264enc "
f"target-bitrate={cfg.video_bitrate_kbps * 1000} "
f"control-rate=variable "
f"periodicity-idr={cfg.keyframe_interval} "
f"inline-header=true"
]
elif cfg.video_encoder == EncoderType.X264:
tune = "zerolatency" if cfg.low_latency else "film"
speed_preset = "ultrafast" if cfg.low_latency else "veryfast"
return [
f"x264enc "
f"bitrate={cfg.video_bitrate_kbps} "
f"speed-preset={speed_preset} "
f"tune={tune} "
f"key-int-max={cfg.keyframe_interval} "
f"bframes={cfg.b_frames} "
f"byte-stream=true"
]
return ["x264enc"]
def _build_audio_branch(self) -> list[str]:
"""Build the audio source + encoding branch."""
elements: list[str] = []
cfg = self.config
# Audio source
source = self._build_audio_source()
elements.append(source)
# Audio conversion
elements.append("audioconvert")
if cfg.audio_channels == 1:
elements.append("audio/x-raw,channels=1")
elements.append("audioresample")
elements.append(f"audio/x-raw,rate={cfg.audio_sample_rate}")
# Queue for buffering
if cfg.low_latency:
elements.append(f"queue max-size-time={cfg.video_queue_max_time_ms * 1000000} max-size-buffers=0 max-size-bytes=0")
else:
elements.append("queue")
# Audio encoder
audio_enc = self._build_audio_encoder()
elements.append(audio_enc)
return elements
def _build_audio_source(self) -> str:
"""Build the audio source element string."""
cfg = self.config
if cfg.audio_source == AudioSource.ALSA:
return (
f"alsasrc device={cfg.audio_device} "
f"buffer-time={cfg.audio_buffer_time_us} "
f"do-timestamp=true"
)
elif cfg.audio_source == AudioSource.JACK:
# jackaudiosrc — requires gst-plugins-bad
return "jackaudiosrc connect=0"
elif cfg.audio_source == AudioSource.PULSE:
return "pulsesrc do-timestamp=true"
elif cfg.audio_source == AudioSource.TEST:
return f"audiotestsrc freq={cfg.test_audio_freq} is-live=true"
return "alsasrc"
def _build_audio_encoder(self) -> str:
"""Build the audio encoder element string."""
cfg = self.config
return f"{cfg.audio_encoder} bitrate={cfg.audio_bitrate_kbps * 1000}"
def build_pipeline_string(
video_device: str = "/dev/video0",
video_width: int = 1280,
video_height: int = 720,
video_framerate: int = 30,
video_bitrate_kbps: int = 2500,
audio_device: str = "hw:0",
audio_sample_rate: int = 48000,
audio_bitrate_kbps: int = 128,
rtmp_url: str = "",
low_latency: bool = True,
) -> str:
"""Quick helper to build a streaming pipeline string.
This is a convenience function that creates a default configuration
and returns the GStreamer pipeline string. For full control, use
GstPipelineBuilder with a custom GstPipelineConfig.
Args:
video_device: Path to video device (e.g. /dev/video0).
video_width: Output video width.
video_height: Output video height.
video_framerate: Output framerate.
video_bitrate_kbps: Video encoder bitrate in kbps.
audio_device: ALSA device name (e.g. hw:0).
audio_sample_rate: Audio sample rate in Hz.
audio_bitrate_kbps: Audio encoder bitrate in kbps.
rtmp_url: Full RTMP URL including stream key.
low_latency: Enable zero-latency tuning.
Returns:
GStreamer pipeline string suitable for gst-launch-1.0.
"""
config = GstPipelineConfig(
video_source=VideoSource.V4L2 if video_device != "test" else VideoSource.TEST,
video_device=video_device,
video_width=video_width,
video_height=video_height,
video_framerate=video_framerate,
video_bitrate_kbps=video_bitrate_kbps,
audio_source=AudioSource.ALSA if audio_device != "test" else AudioSource.TEST,
audio_device=audio_device,
audio_sample_rate=audio_sample_rate,
audio_bitrate_kbps=audio_bitrate_kbps,
rtmp_url=rtmp_url,
low_latency=low_latency,
)
builder = GstPipelineBuilder(config)
return builder.build()
+300
View File
@@ -0,0 +1,300 @@
"""Streaming platform configurations and RTMP profiles.
Defines preset configurations for popular streaming platforms
(YouTube, Twitch, Facebook Live, custom RTMP servers) with
recommended encoding settings for Raspberry Pi 4B h264 hardware.
Each platform profile includes:
- RTMP ingest URL template
- Recommended resolution, bitrate, and framerate
- Audio bitrate and sample rate
- Platform-specific encoding notes
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
# ── Preset RTMP URLs ─────────────────────────────────────────────────────────
YOUTUBE_RTMP = "rtmp://a.rtmp.youtube.com/live2"
"""YouTube Live RTMP ingest URL (primary)."""
YOUTUBE_RTMP_BACKUP = "rtmp://b.rtmp.youtube.com/live2"
"""YouTube Live RTMP ingest URL (backup)."""
TWITCH_RTMP = "rtmp://live.twitch.tv/app"
"""Twitch RTMP ingest URL (auto-selects closest server)."""
FACEBOOK_RTMP = "rtmp://live-api-s.facebook.com:80/rtmp"
"""Facebook Live RTMP ingest URL."""
CUSTOM_RTMP = "rtmp://localhost/live"
"""Placeholder for custom RTMP server."""
# ── Platform profile ─────────────────────────────────────────────────────────
@dataclass
class PlatformProfile:
"""Streaming platform configuration for encoding and RTMP.
All values are defaults; users can override via API or UI.
"""
# Identity
name: str
"""Human-readable platform name."""
platform_id: str
"""Short identifier (e.g. 'youtube', 'twitch')."""
# RTMP
rtmp_url: str = ""
"""RTMP ingest URL. Can include {stream_key} placeholder."""
# Video settings
width: int = 1280
"""Output video width in pixels."""
height: int = 720
"""Output video height in pixels."""
framerate: int = 30
"""Output framerate (fps)."""
video_bitrate_kbps: int = 2500
"""Video encoder target bitrate in kbps."""
video_bitrate_max_kbps: int = 4000
"""Video encoder maximum bitrate in kbps."""
keyframe_interval: int = 60
"""Keyframe interval in frames (2s at 30fps)."""
# Audio settings
audio_bitrate_kbps: int = 128
"""Audio encoder bitrate in kbps."""
audio_sample_rate: int = 48000
"""Audio sample rate in Hz."""
audio_channels: int = 2
"""Audio channels (1=mono, 2=stereo)."""
# Encoder
encoder: str = "h264"
"""Video encoder type: 'h264' for RPi hardware, 'x264' for software."""
h264_profile: str = "main"
"""H.264 profile: 'baseline', 'main', or 'high'."""
h264_level: str = "4.0"
"""H.264 level for compatibility."""
# Control
low_latency: bool = True
"""Enable low-latency tuning (for live performance)."""
b_frames: int = 0
"""Number of B-frames (0 for lowest latency)."""
# Experimental
description: str = ""
"""Human-readable description of the platform preset."""
def to_dict(self) -> dict:
"""Serialize to dictionary for API responses."""
return {
"name": self.name,
"platform_id": self.platform_id,
"rtmp_url": self.rtmp_url,
"width": self.width,
"height": self.height,
"framerate": self.framerate,
"video_bitrate_kbps": self.video_bitrate_kbps,
"video_bitrate_max_kbps": self.video_bitrate_max_kbps,
"keyframe_interval": self.keyframe_interval,
"audio_bitrate_kbps": self.audio_bitrate_kbps,
"audio_sample_rate": self.audio_sample_rate,
"audio_channels": self.audio_channels,
"encoder": self.encoder,
"low_latency": self.low_latency,
"description": self.description,
}
@classmethod
def from_dict(cls, data: dict) -> PlatformProfile:
"""Create from dictionary (for API deserialization)."""
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
# ── Preset platforms ─────────────────────────────────────────────────────────
PRESET_PLATFORMS: dict[str, PlatformProfile] = {
"youtube": PlatformProfile(
name="YouTube Live",
platform_id="youtube",
rtmp_url=YOUTUBE_RTMP,
width=1280,
height=720,
framerate=30,
video_bitrate_kbps=2500,
video_bitrate_max_kbps=4000,
keyframe_interval=60,
audio_bitrate_kbps=128,
audio_sample_rate=48000,
audio_channels=2,
encoder="h264",
h264_profile="main",
h264_level="4.0",
low_latency=True,
b_frames=0,
description="YouTube Live RTMP ingest. Recommended: 720p@30fps, 25004000 kbps.",
),
"youtube_1080p": PlatformProfile(
name="YouTube Live 1080p",
platform_id="youtube_1080p",
rtmp_url=YOUTUBE_RTMP,
width=1920,
height=1080,
framerate=30,
video_bitrate_kbps=4500,
video_bitrate_max_kbps=6000,
keyframe_interval=60,
audio_bitrate_kbps=128,
audio_sample_rate=48000,
audio_channels=2,
encoder="h264",
h264_profile="main",
h264_level="4.1",
low_latency=True,
b_frames=0,
description="YouTube Live 1080p@30fps. Requires strong network upload.",
),
"twitch": PlatformProfile(
name="Twitch",
platform_id="twitch",
rtmp_url=TWITCH_RTMP,
width=1280,
height=720,
framerate=30,
video_bitrate_kbps=2500,
video_bitrate_max_kbps=4000,
keyframe_interval=60,
audio_bitrate_kbps=128,
audio_sample_rate=48000,
audio_channels=2,
encoder="h264",
h264_profile="main",
h264_level="4.0",
low_latency=True,
b_frames=0,
description="Twitch RTMP ingest. Recommended: 720p@30fps, 25004000 kbps.",
),
"twitch_1080p": PlatformProfile(
name="Twitch 1080p",
platform_id="twitch_1080p",
rtmp_url=TWITCH_RTMP,
width=1920,
height=1080,
framerate=30,
video_bitrate_kbps=4500,
video_bitrate_max_kbps=6000,
keyframe_interval=60,
audio_bitrate_kbps=128,
audio_sample_rate=48000,
audio_channels=2,
encoder="h264",
h264_profile="main",
h264_level="4.1",
low_latency=True,
b_frames=0,
description="Twitch 1080p@30fps. Affiliate/Partner recommended only.",
),
"facebook": PlatformProfile(
name="Facebook Live",
platform_id="facebook",
rtmp_url=FACEBOOK_RTMP,
width=1280,
height=720,
framerate=30,
video_bitrate_kbps=2500,
video_bitrate_max_kbps=4000,
keyframe_interval=60,
audio_bitrate_kbps=128,
audio_sample_rate=48000,
audio_channels=2,
encoder="h264",
h264_profile="main",
h264_level="4.0",
low_latency=False,
b_frames=0,
description="Facebook Live RTMP ingest. 720p@30fps.",
),
"custom": PlatformProfile(
name="Custom RTMP",
platform_id="custom",
rtmp_url=CUSTOM_RTMP,
width=1280,
height=720,
framerate=30,
video_bitrate_kbps=2500,
video_bitrate_max_kbps=4000,
keyframe_interval=60,
audio_bitrate_kbps=128,
audio_sample_rate=48000,
audio_channels=2,
encoder="h264",
h264_profile="main",
h264_level="4.0",
low_latency=True,
b_frames=0,
description="Custom RTMP server. Edit the URL and stream key in settings.",
),
"audio_only": PlatformProfile(
name="Audio Only",
platform_id="audio_only",
rtmp_url="",
width=320,
height=240,
framerate=5,
video_bitrate_kbps=100,
video_bitrate_max_kbps=200,
keyframe_interval=300,
audio_bitrate_kbps=192,
audio_sample_rate=48000,
audio_channels=2,
encoder="h264",
h264_profile="baseline",
h264_level="3.0",
low_latency=True,
b_frames=0,
description="Audio-only stream with minimal video placeholder. High quality audio at 192kbps.",
),
}
def get_platform_profile(platform_id: str) -> Optional[PlatformProfile]:
"""Get a preset platform profile by ID.
Args:
platform_id: One of 'youtube', 'youtube_1080p', 'twitch',
'twitch_1080p', 'facebook', 'custom', 'audio_only'.
Returns:
PlatformProfile or None if not found.
"""
return PRESET_PLATFORMS.get(platform_id)
def list_platforms() -> list[dict]:
"""List all available platform presets (for API display)."""
return [
{"id": pid, "name": p.name, "description": p.description}
for pid, p in PRESET_PLATFORMS.items()
]
+728
View File
@@ -0,0 +1,728 @@
"""Streamer — manages the GStreamer streaming pipeline lifecycle.
The Streamer class is the top-level controller for audio/video streaming.
It manages:
- GStreamer subprocess lifecycle (start, stop, restart)
- Pipeline monitoring (bitrate, uptime, dropped frames)
- Scene management (camera sources, overlays)
- Stream statistics collection
- Error recovery and reconnection
Architecture:
Streamer
├── GStreamer subprocess (gst-launch-1.0 -e)
├── Statistics collector (stderr parsing)
├── Scene manager (named presets)
└── State machine (idle → starting → live → stopping → idle)
"""
from __future__ import annotations
import asyncio
import logging
import os
import shutil
import signal
import subprocess
import threading
import time
from dataclasses import dataclass, field
from enum import StrEnum
from typing import Optional, Callable
from .gst_pipeline import (
GstPipelineBuilder,
GstPipelineConfig,
VideoSource,
AudioSource,
EncoderType,
build_pipeline_string,
)
from .platforms import PlatformProfile, PRESET_PLATFORMS, get_platform_profile
from .camera import CameraInfo, detect_cameras, get_default_camera
logger = logging.getLogger(__name__)
class StreamState(StrEnum):
"""Streamer lifecycle states."""
IDLE = "idle" # Not streaming, not configured
CONFIGURED = "configured" # Ready to stream
STARTING = "starting" # Pipeline launching
LIVE = "live" # Streaming to platform
RECONNECTING = "reconnecting" # Lost connection, retrying
STOPPING = "stopping" # Shutting down
ERROR = "error" # Fatal error, requires reset
@dataclass
class StreamScene:
"""A named streaming scene (camera + settings preset)."""
name: str
camera_device: str = "/dev/video0"
video_width: int = 1280
video_height: int = 720
video_framerate: int = 30
audio_device: str = "hw:0"
overlay_text: str = ""
description: str = ""
def to_dict(self) -> dict:
return {
"name": self.name,
"camera_device": self.camera_device,
"video_width": self.video_width,
"video_height": self.video_height,
"video_framerate": self.video_framerate,
"audio_device": self.audio_device,
"overlay_text": self.overlay_text,
"description": self.description,
}
@dataclass
class StreamStats:
"""Real-time stream statistics."""
state: StreamState = StreamState.IDLE
uptime_seconds: float = 0.0
video_bitrate_kbps: float = 0.0
audio_bitrate_kbps: float = 0.0
total_bytes_sent: int = 0
dropped_frames: int = 0
fps: float = 0.0
cpu_usage_percent: float = 0.0
reconnect_count: int = 0
last_error: str = ""
camera_name: str = ""
platform_name: str = ""
def to_dict(self) -> dict:
return {
"state": self.state.value,
"uptime_seconds": round(self.uptime_seconds, 1),
"video_bitrate_kbps": round(self.video_bitrate_kbps, 1),
"audio_bitrate_kbps": round(self.audio_bitrate_kbps, 1),
"total_bytes_sent": self.total_bytes_sent,
"dropped_frames": self.dropped_frames,
"fps": round(self.fps, 1),
"cpu_usage_percent": round(self.cpu_usage_percent, 1),
"reconnect_count": self.reconnect_count,
"last_error": self.last_error,
"camera_name": self.camera_name,
"platform_name": self.platform_name,
}
@dataclass
class StreamerConfig:
"""Configuration for the Streamer."""
# Platform
platform_id: str = "custom"
rtmp_url: str = ""
stream_key: str = ""
# Video
video_device: str = "/dev/video0"
video_source: VideoSource = VideoSource.V4L2
video_width: int = 1280
video_height: int = 720
video_framerate: int = 30
video_bitrate_kbps: int = 2500
# Audio
audio_device: str = "hw:0"
audio_source: AudioSource = AudioSource.ALSA
audio_sample_rate: int = 48000
audio_bitrate_kbps: int = 128
# Encoder
encoder_type: EncoderType = EncoderType.V4L2_H264
h264_profile: str = "main"
h264_level: str = "4.0"
low_latency: bool = True
# Recovery
auto_reconnect: bool = True
max_reconnect_attempts: int = 5
reconnect_delay_seconds: float = 3.0
# Scenes
scenes: list[StreamScene] = field(default_factory=list)
active_scene: str = "default"
@property
def full_rtmp_url(self) -> str:
"""Construct full RTMP URL with stream key."""
if not self.rtmp_url:
return ""
url = self.rtmp_url.rstrip("/")
if self.stream_key:
url = f"{url}/{self.stream_key}"
return url
@classmethod
def from_platform(cls, platform_id: str, stream_key: str = "") -> StreamerConfig:
"""Create config from a platform preset."""
profile = get_platform_profile(platform_id)
if profile is None:
profile = PRESET_PLATFORMS.get("custom", PlatformProfile(
name="Custom", platform_id="custom",
))
return cls(
platform_id=profile.platform_id,
rtmp_url=profile.rtmp_url,
stream_key=stream_key,
video_width=profile.width,
video_height=profile.height,
video_framerate=profile.framerate,
video_bitrate_kbps=profile.video_bitrate_kbps,
audio_bitrate_kbps=profile.audio_bitrate_kbps,
audio_sample_rate=profile.audio_sample_rate,
h264_profile=profile.h264_profile,
h264_level=profile.h264_level,
low_latency=profile.low_latency,
)
class Streamer:
"""Manages the GStreamer streaming pipeline.
Usage:
streamer = Streamer(StreamerConfig(platform_id="youtube", stream_key="xxxx"))
streamer.configure()
streamer.start()
# ... stream is live ...
streamer.stop()
Thread-safe: all public methods use a lock for state transitions.
"""
def __init__(self, config: StreamerConfig | None = None):
self._config = config or StreamerConfig()
self._state = StreamState.IDLE
self._state_lock = threading.Lock()
self._process: Optional[subprocess.Popen] = None
self._stderr_thread: Optional[threading.Thread] = None
self._stats = StreamStats()
self._stats_lock = threading.Lock()
self._start_time: float = 0.0
self._reconnect_count: int = 0
self._stop_event = threading.Event()
self._on_state_change: Optional[Callable[[StreamState, StreamStats], None]] = None
self._camera_info: Optional[CameraInfo] = None
self._scenes: dict[str, StreamScene] = {}
self._active_scene: str = "default"
# Initialize with defaults
self._init_default_scenes()
# ── Configuration ──────────────────────────────────────────────────────
def configure(
self,
platform_id: str | None = None,
stream_key: str | None = None,
video_device: str | None = None,
audio_device: str | None = None,
) -> None:
"""Configure or reconfigure the streamer.
Args:
platform_id: Platform preset ID (youtube, twitch, etc.).
stream_key: RTMP stream key for authentication.
video_device: Path to video device (e.g. /dev/video0).
audio_device: ALSA device name (e.g. hw:0).
"""
with self._state_lock:
if self._state == StreamState.LIVE:
raise RuntimeError("Cannot reconfigure while streaming. Stop first.")
# Apply platform preset if specified
if platform_id:
profile = get_platform_profile(platform_id)
if profile:
self._config.platform_id = platform_id
self._config.rtmp_url = profile.rtmp_url
self._config.video_width = profile.width
self._config.video_height = profile.height
self._config.video_framerate = profile.framerate
self._config.video_bitrate_kbps = profile.video_bitrate_kbps
self._config.audio_bitrate_kbps = profile.audio_bitrate_kbps
self._config.audio_sample_rate = profile.audio_sample_rate
self._config.h264_profile = profile.h264_profile
self._config.h264_level = profile.h264_level
self._config.low_latency = profile.low_latency
if stream_key is not None:
self._config.stream_key = stream_key
if video_device is not None:
self._config.video_device = video_device
self._detect_camera()
if audio_device is not None:
self._config.audio_device = audio_device
self._state = StreamState.CONFIGURED
logger.info(
"Streamer configured: platform=%s, video=%s, audio=%s",
self._config.platform_id,
self._config.video_device,
self._config.audio_device,
)
self._notify_state_change()
def _detect_camera(self) -> None:
"""Auto-detect camera capabilities."""
cameras = detect_cameras()
if cameras:
self._camera_info = cameras[0]
self._stats.camera_name = self._camera_info.name
logger.info("Detected camera: %s (%s)", self._camera_info.name, self._camera_info.device_path)
# ── Lifecycle ──────────────────────────────────────────────────────────
def start(self, block: bool = False, timeout: float = 10.0) -> bool:
"""Start the streaming pipeline.
Args:
block: If True, wait until stream is LIVE or timeout.
timeout: Maximum seconds to wait when blocking.
Returns:
True if streaming started successfully.
"""
with self._state_lock:
if self._state == StreamState.LIVE:
logger.warning("Stream already live")
return True
if self._state == StreamState.STARTING:
logger.warning("Stream already starting")
return False
# Validate configuration
if not self._config.full_rtmp_url:
self._state = StreamState.ERROR
self._stats.last_error = "No RTMP URL configured. Set stream_key and platform."
self._notify_state_change()
return False
self._state = StreamState.STARTING
self._reconnect_count = 0
self._stop_event.clear()
self._notify_state_change()
# Launch pipeline in background
success = self._launch_pipeline()
if success and block:
# Wait for LIVE state
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
with self._stats_lock:
if self._stats.state == StreamState.LIVE:
return True
if self._stats.state == StreamState.ERROR:
return False
time.sleep(0.1)
return success
def stop(self, timeout: float = 5.0) -> None:
"""Stop the streaming pipeline gracefully.
Args:
timeout: Maximum seconds to wait for process to exit.
"""
with self._state_lock:
if self._state not in (StreamState.LIVE, StreamState.STARTING, StreamState.RECONNECTING):
logger.debug("Stream not running (state=%s)", self._state.value)
self._state = StreamState.IDLE
return
self._state = StreamState.STOPPING
self._notify_state_change()
self._stop_event.set()
self._terminate_process(timeout)
with self._state_lock:
self._state = StreamState.IDLE
self._stats.state = StreamState.IDLE
self._notify_state_change()
logger.info("Stream stopped")
def switch_scene(self, scene_name: str) -> bool:
"""Switch to a named streaming scene.
Scenes allow changing camera source and video settings
without restarting the entire pipeline (if supported by
the GStreamer pipeline).
Args:
scene_name: Name of the scene to activate.
Returns:
True if the scene was found and activated.
"""
scene = self._scenes.get(scene_name)
if scene is None:
logger.warning("Scene not found: %s", scene_name)
return False
self._active_scene = scene_name
self._config.video_device = scene.camera_device
self._config.video_width = scene.video_width
self._config.video_height = scene.video_height
self._config.video_framerate = scene.video_framerate
self._config.audio_device = scene.audio_device
# If currently streaming, we need to restart for the new camera
was_live = self._state == StreamState.LIVE
if was_live:
logger.info("Restarting stream for scene switch: %s", scene_name)
self.stop()
time.sleep(1.0)
self.start()
logger.info("Scene activated: %s", scene_name)
return True
def get_scenes(self) -> list[dict]:
"""List all configured scenes."""
return [s.to_dict() for s in self._scenes.values()]
def add_scene(self, scene: StreamScene) -> None:
"""Add a new scene."""
self._scenes[scene.name] = scene
logger.info("Scene added: %s", scene.name)
def remove_scene(self, name: str) -> bool:
"""Remove a scene by name. Cannot remove 'default'."""
if name == "default":
return False
if name in self._scenes:
del self._scenes[name]
return True
return False
# ── Stats ──────────────────────────────────────────────────────────────
@property
def state(self) -> StreamState:
"""Current streamer state."""
with self._state_lock:
return self._state
@property
def stats(self) -> StreamStats:
"""Current stream statistics (copy)."""
with self._stats_lock:
return StreamStats(
state=self._stats.state,
uptime_seconds=self._stats.uptime_seconds,
video_bitrate_kbps=self._stats.video_bitrate_kbps,
audio_bitrate_kbps=self._stats.audio_bitrate_kbps,
total_bytes_sent=self._stats.total_bytes_sent,
dropped_frames=self._stats.dropped_frames,
fps=self._stats.fps,
cpu_usage_percent=self._stats.cpu_usage_percent,
reconnect_count=self._stats.reconnect_count,
last_error=self._stats.last_error,
camera_name=self._stats.camera_name,
platform_name=self._stats.platform_name,
)
def get_stats_dict(self) -> dict:
"""Get statistics as a dictionary."""
return self.stats.to_dict()
# ── Callbacks ──────────────────────────────────────────────────────────
def on_state_change(self, callback: Callable[[StreamState, StreamStats], None]) -> None:
"""Register a callback for state changes.
The callback receives (new_state, current_stats).
"""
self._on_state_change = callback
# ── Internals ──────────────────────────────────────────────────────────
def _init_default_scenes(self) -> None:
"""Initialize default scenes."""
self._scenes["default"] = StreamScene(
name="default",
camera_device=self._config.video_device,
video_width=self._config.video_width,
video_height=self._config.video_height,
video_framerate=self._config.video_framerate,
audio_device=self._config.audio_device,
description="Default streaming scene",
)
def _build_pipeline_config(self) -> GstPipelineConfig:
"""Build a GstPipelineConfig from current StreamerConfig."""
cfg = self._config
return GstPipelineConfig(
video_source=cfg.video_source,
video_device=cfg.video_device,
video_width=cfg.video_width,
video_height=cfg.video_height,
video_framerate=cfg.video_framerate,
audio_source=cfg.audio_source,
audio_device=cfg.audio_device,
audio_sample_rate=cfg.audio_sample_rate,
audio_channels=2,
video_encoder=cfg.encoder_type,
video_bitrate_kbps=cfg.video_bitrate_kbps,
video_bitrate_max_kbps=cfg.video_bitrate_kbps * 2,
keyframe_interval=60,
h264_profile=cfg.h264_profile,
h264_level=cfg.h264_level,
b_frames=0,
audio_bitrate_kbps=cfg.audio_bitrate_kbps,
rtmp_url=cfg.rtmp_url,
rtmp_stream_key=cfg.stream_key,
low_latency=cfg.low_latency,
)
def _launch_pipeline(self) -> bool:
"""Launch the GStreamer pipeline as a subprocess."""
try:
pipeline_config = self._build_pipeline_config()
builder = GstPipelineBuilder(pipeline_config)
pipeline_str = builder.build()
logger.info("Launching GStreamer pipeline")
logger.debug("Pipeline: %s", pipeline_str)
# Check gst-launch-1.0 availability
gst_launch = shutil.which("gst-launch-1.0")
if not gst_launch:
self._set_error("gst-launch-1.0 not found. Install gstreamer-tools.")
return False
self._start_time = time.monotonic()
self._reconnect_count = 0
# Launch subprocess
self._process = subprocess.Popen(
[gst_launch, "-e", pipeline_str],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
preexec_fn=os.setsid if hasattr(os, "setsid") else None,
)
# Start stderr reader thread
self._stderr_thread = threading.Thread(
target=self._read_stderr,
daemon=True,
)
self._stderr_thread.start()
# Start process monitor thread
monitor_thread = threading.Thread(
target=self._monitor_process,
daemon=True,
)
monitor_thread.start()
return True
except Exception as exc:
logger.error("Failed to launch pipeline: %s", exc)
self._set_error(str(exc))
return False
def _read_stderr(self) -> None:
"""Read and parse GStreamer stderr for statistics."""
if not self._process or not self._process.stderr:
return
try:
for line in self._process.stderr:
line = line.strip()
if not line:
continue
# Detect live state from GStreamer output
if "Redistribute latency" in line or "Pipeline is live" in line:
self._set_live()
# Parse bitrate information (GStreamer doesn't always output this)
# Most RTMP stats come from rtmpsink messages
# Detect errors
if "ERROR" in line or "error:" in line.lower():
logger.error("GStreamer error: %s", line)
with self._stats_lock:
self._stats.last_error = line[:200]
# Log at debug level
logger.debug("GST: %s", line)
except (ValueError, IOError) as exc:
logger.debug("Stderr reader stopped: %s", exc)
def _monitor_process(self) -> None:
"""Monitor the GStreamer process for exit."""
if not self._process:
return
while not self._stop_event.is_set():
# Update uptime
if self._start_time > 0:
with self._stats_lock:
self._stats.uptime_seconds = time.monotonic() - self._start_time
# Check if process exited
if self._process.poll() is not None:
exit_code = self._process.returncode
if self._stop_event.is_set():
# Expected shutdown
break
logger.warning("GStreamer process exited with code %d", exit_code)
# Try reconnect
if self._config.auto_reconnect and self._reconnect_count < self._config.max_reconnect_attempts:
self._reconnect_count += 1
with self._stats_lock:
self._stats.reconnect_count = self._reconnect_count
self._stats.state = StreamState.RECONNECTING
logger.info(
"Reconnecting (attempt %d/%d) in %.1fs...",
self._reconnect_count,
self._config.max_reconnect_attempts,
self._config.reconnect_delay_seconds,
)
time.sleep(self._config.reconnect_delay_seconds)
if not self._stop_event.is_set():
self._launch_pipeline()
else:
error_msg = f"Process exited with code {exit_code}"
if self._reconnect_count >= self._config.max_reconnect_attempts:
error_msg = f"Max reconnects ({self._config.max_reconnect_attempts}) reached. {error_msg}"
self._set_error(error_msg)
break
time.sleep(0.5)
def _set_live(self) -> None:
"""Transition to LIVE state."""
with self._state_lock:
self._state = StreamState.LIVE
with self._stats_lock:
self._stats.state = StreamState.LIVE
self._stats.platform_name = self._config.platform_id
self._notify_state_change()
logger.info("Stream is LIVE")
def _set_error(self, message: str) -> None:
"""Transition to ERROR state."""
with self._state_lock:
self._state = StreamState.ERROR
with self._stats_lock:
self._stats.state = StreamState.ERROR
self._stats.last_error = message
self._notify_state_change()
logger.error("Stream error: %s", message)
def _terminate_process(self, timeout: float = 5.0) -> None:
"""Terminate the GStreamer subprocess gracefully."""
if not self._process:
return
try:
# Send SIGINT for graceful shutdown (gst-launch-1.0 -e handles this)
if hasattr(os, "killpg"):
os.killpg(os.getpgid(self._process.pid), signal.SIGINT)
else:
self._process.send_signal(signal.SIGINT)
try:
self._process.wait(timeout=timeout)
except subprocess.TimeoutExpired:
logger.warning("GStreamer did not exit gracefully, sending SIGKILL")
if hasattr(os, "killpg"):
os.killpg(os.getpgid(self._process.pid), signal.SIGKILL)
else:
self._process.kill()
self._process.wait(timeout=2.0)
except ProcessLookupError:
pass # Already exited
self._process = None
self._stderr_thread = None
def _notify_state_change(self) -> None:
"""Notify state change callback."""
if self._on_state_change:
try:
stats = self.stats
self._on_state_change(self._state, stats)
except Exception as exc:
logger.error("State change callback error: %s", exc)
# ── Properties ─────────────────────────────────────────────────────────
@property
def config(self) -> StreamerConfig:
return self._config
@property
def is_live(self) -> bool:
return self._state == StreamState.LIVE
@property
def camera_info(self) -> Optional[CameraInfo]:
return self._camera_info
def create_streamer(
platform_id: str = "custom",
stream_key: str = "",
video_device: str | None = None,
audio_device: str = "hw:0",
) -> Streamer:
"""Create a configured Streamer instance.
Convenience factory that creates a Streamer with sensible defaults,
auto-detects cameras, and applies a platform preset.
Args:
platform_id: Platform preset ('youtube', 'twitch', 'custom', etc.).
stream_key: RTMP stream key.
video_device: Camera device path (auto-detected if None).
audio_device: ALSA device name.
Returns:
Configured Streamer instance.
"""
config = StreamerConfig(
platform_id=platform_id,
stream_key=stream_key,
audio_device=audio_device,
)
# Auto-detect camera
if video_device:
config.video_device = video_device
else:
cam = get_default_camera()
if cam:
config.video_device = cam.device_path
if "pi camera" in cam.name.lower():
config.video_source = VideoSource.LIBCAMERA
streamer = Streamer(config)
streamer.configure(platform_id=platform_id, stream_key=stream_key)
return streamer