Files
raspberry-pi-mixer/src/streaming/gst_pipeline.py
T
shawn cdf04bd8eb
Lint & Validate / lint (push) Has been cancelled
P6-R1: Audio/video streaming pipeline
- 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)
2026-05-19 22:13:51 -04:00

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()