- 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,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()
|
||||
Reference in New Issue
Block a user