cdf04bd8eb
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)
367 lines
12 KiB
Python
367 lines
12 KiB
Python
"""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()
|