- 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:
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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, 2500–4000 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, 2500–4000 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()
|
||||
]
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user