#!/usr/bin/env bash # NDI Pi Gateway installer for Raspberry Pi OS Trixie / Debian 13 ARM64. # V17: Adds configurable capture buffering and aspect-preserving HDMI canvas fitting. Integrates validated direct NDI BGRx/BGRA-to-KMS display path without videoconvert. # Restores the manually verified NDI -> ndisrcdemux -> BGRA -> KMS live pipeline. # Standby has explicit HDMI caps and status text; service startup tries live first # and only grabs KMS for standby when the live pipeline is unavailable. # Capture mode selection and NDI latency settings are unchanged; only capture queue buffering is tuned. # Updated: installed NDI 6.x runtimes are detected without requiring the SDK archive. set -Eeuo pipefail # Raspberry Pi 4 / Raspberry Pi OS Trixie 64-bit # NDI Gateway: # USB capture + USB audio -> NDI # NDI video -> HDMI/DRM # NDI audio -> HDMI ALSA # # NDI receive design: # - Prefer a direct NDI sender address (IP:port) when configured/resolved. # - NDI source discovery is still used to find the service and its actual port. # - ndi-name remains a fallback when a direct address cannot be resolved. # # Design: # - GStreamer itself comes from Debian/Raspberry Pi OS. # - The NDI GStreamer plugin is built from the gst-plugins-rs 0.14 branch/tag, # because Debian Trixie provides GStreamer 1.26 while the NDI plugin is not # provided as a matching Debian package. # - NDI SDK is downloaded from the official NDI developer distribution. # # Environment overrides: # NDI_SDK_URL Override the NDI SDK archive URL. # NDI_SDK_TARBALL Use a local SDK archive instead of downloading one. # NDI_GST_TAG Plugin tag, default 0.14.5. # # Existing /etc/ndi-pi.conf is preserved and migrated by adding missing keys. # # The script is intentionally conservative: it never assumes /dev/video0, # never assumes an UGREEN device, and treats audio as optional. set -Eeuo pipefail INSTALL_DIR=/opt/ndi-pi SRC_DIR="${INSTALL_DIR}/src" PLUGIN_DIR="/usr/lib/aarch64-linux-gnu/gstreamer-1.0" CONF="/etc/ndi-pi.conf" # NDI 6.x is the current SDK line. The exact archive URL may change, so the # default is intentionally empty: the installer asks for NDI_SDK_URL or a # locally supplied archive. This avoids hard-coding a vendor download URL. SDK_URL="${NDI_SDK_URL:-}" SDK_TARBALL="${NDI_SDK_TARBALL:-${INSTALL_DIR}/ndi-sdk.tar.gz}" PLUGIN_TAG="${NDI_GST_TAG:-0.14.5}" PLUGIN_REPO="https://gitlab.freedesktop.org/gstreamer/gst-plugins-rs.git" log() { echo "[ndi-pi] $*"; } warn() { echo "[ndi-pi] WARNING: $*" >&2; } die() { echo "[ndi-pi] ERROR: $*" >&2; exit 1; } [[ $EUID -eq 0 ]] || die "Bitte mit sudo/root ausführen." [[ "$(dpkg --print-architecture)" == "arm64" ]] || die "Dieses Skript ist für ARM64." . /etc/os-release [[ "${VERSION_CODENAME:-}" == "trixie" ]] || die "Dieses Skript erwartet Debian/Raspberry Pi OS Trixie." mkdir -p "$INSTALL_DIR" "$SRC_DIR" "$PLUGIN_DIR" log "Installiere System-GStreamer, ALSA, V4L2, DRM und Build-Abhängigkeiten..." apt-get update # gstreamer1.0-plugins-good provides gst-device-monitor-1.0 on Debian Trixie. DEBIAN_FRONTEND=noninteractive apt-get install -y \ ca-certificates curl wget git build-essential pkg-config \ rustc cargo \ libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \ gstreamer1.0-tools gstreamer1.0-plugins-base gstreamer1.0-plugins-good \ gstreamer1.0-plugins-good gstreamer1.0-plugins-bad \ gstreamer1.0-libav gstreamer1.0-alsa \ alsa-utils alsa-ucm-conf \ v4l-utils libdrm-tests libdrm-dev libudev-dev libglib2.0-dev \ avahi-daemon avahi-utils libavahi-client3 libavahi-common3 systemctl enable --now avahi-daemon || true # --------------------------------------------------------------------------- # NDI SDK / already-installed NDI runtime # --------------------------------------------------------------------------- # The SDK archive is NOT required when libndi.so.6 is already installed. # This is important for manual NDI SDK installations on Raspberry Pi. # # Accepted examples: # /usr/local/lib/libndi.so.6 # /usr/local/lib/libndi.so.6.3.2 # /usr/lib/aarch64-linux-gnu/libndi.so.6* # # If libndi.so.6 is a regular file while a versioned libndi.so.6.x.y exists, # normalize it to a symlink. This also removes the ldconfig warning: # "libndi.so.6 is not a symbolic link" NDI_LIB_DIR_INSTALLED="" NDI_LIB_INSTALLED="" find_installed_ndi() { local candidate for candidate in \ /usr/local/lib/libndi.so.6.* \ /usr/local/lib/libndi.so.6 \ /usr/lib/aarch64-linux-gnu/libndi.so.6.* \ /usr/lib/aarch64-linux-gnu/libndi.so.6 do if [[ -f "$candidate" ]]; then NDI_LIB_INSTALLED="$candidate" NDI_LIB_DIR_INSTALLED="$(dirname "$candidate")" return 0 fi done return 1 } normalize_installed_ndi() { local libdir="$1" local major="$2" local major_link="${libdir}/libndi.so.${major}" local unversioned="${libdir}/libndi.so" local versioned="" # Prefer the most specific versioned file, e.g. libndi.so.6.3.2. versioned="$(find "$libdir" -maxdepth 1 -type f \ -name "libndi.so.${major}.*" -print | sort -V | tail -n 1)" if [[ -n "$versioned" ]]; then if [[ -e "$major_link" && ! -L "$major_link" ]]; then log "Korrigiere ${major_link}: reguläre Datei -> Sicherung + Symlink" mv -f "$major_link" "${major_link}.manual-backup.$(date +%Y%m%d-%H%M%S)" fi ln -sfn "$versioned" "$major_link" elif [[ -f "$major_link" && ! -L "$major_link" ]]; then # A standalone libndi.so.6 is still usable. Do not overwrite it. warn "${major_link} ist eine reguläre Datei; kein versioniertes Ziel gefunden." fi # Only replace the unversioned link when it is absent/broken or already a link. if [[ -L "$unversioned" || ! -e "$unversioned" ]]; then ln -sfn "$major_link" "$unversioned" fi echo "$libdir" > /etc/ld.so.conf.d/ndi.conf ldconfig } if find_installed_ndi; then log "Bereits installiertes NDI Runtime gefunden: $NDI_LIB_INSTALLED" # Extract the SONAME major from libndi.so.6 / libndi.so.6.x.y. NDI_MAJOR="$(basename "$NDI_LIB_INSTALLED" | sed -n 's/^libndi\.so\.\([0-9][0-9]*\).*$/\1/p')" [[ "$NDI_MAJOR" == "6" ]] || die "Gefundene NDI Bibliothek ist nicht NDI 6.x: $NDI_LIB_INSTALLED" normalize_installed_ndi "$NDI_LIB_DIR_INSTALLED" "$NDI_MAJOR" # NDI_RUNTIME_DIR_V6 must point to the directory containing libndi.so.6. NDI_RUNTIME_DIR_V6="$NDI_LIB_DIR_INSTALLED" export NDI_RUNTIME_DIR_V6 log "NDI_RUNTIME_DIR_V6=${NDI_RUNTIME_DIR_V6}" else if [[ ! -s "$SDK_TARBALL" ]]; then if [[ -z "$SDK_URL" ]]; then die "Kein NDI 6.x Runtime und kein SDK-Archiv gefunden. Wenn NDI bereits manuell installiert ist, muss libndi.so.6 unter /usr/local/lib oder /usr/lib/aarch64-linux-gnu vorhanden sein. Alternativ: NDI_SDK_URL= ./install.sh oder NDI_SDK_TARBALL=/pfad/zum/SDK.tar.gz ./install.sh" fi log "Lade NDI SDK von der angegebenen NDI-URL..." curl -fL --retry 3 --connect-timeout 15 -o "$SDK_TARBALL" "$SDK_URL" fi TMP_SDK="$(mktemp -d)" trap 'rm -rf "$TMP_SDK"' EXIT tar -xzf "$SDK_TARBALL" -C "$TMP_SDK" SDK_ROOT="$(find "$TMP_SDK" -maxdepth 4 -type d -name 'NDI SDK for Linux' -print -quit)" [[ -n "$SDK_ROOT" ]] || die "NDI SDK Verzeichnis nicht gefunden." NDI_LIB_DIR="$SDK_ROOT/lib/aarch64-rpi4-linux-gnueabi" if [[ ! -d "$NDI_LIB_DIR" ]]; then NDI_LIB_DIR="$(find "$SDK_ROOT/lib" -maxdepth 2 -type d \( -name '*aarch64*rpi4*' -o -name '*aarch64*' \) -print -quit)" fi [[ -n "${NDI_LIB_DIR:-}" && -d "$NDI_LIB_DIR" ]] || die "Keine passende NDI ARM64/RPi4 Bibliothek im SDK gefunden." NDI_LIB="$(find "$NDI_LIB_DIR" -maxdepth 1 -type f -name 'libndi.so.*' -print | sort -V | tail -n 1)" [[ -n "$NDI_LIB" ]] || die "libndi.so.* nicht im SDK gefunden." install -m 0755 "$NDI_LIB" /usr/local/lib/"$(basename "$NDI_LIB")" NDI_BASENAME="$(basename "$NDI_LIB")" NDI_MAJOR="$(printf '%s\n' "$NDI_BASENAME" | sed -n 's/.*\.so\.\([0-9][0-9]*\).*/\1/p')" [[ -n "$NDI_MAJOR" ]] || NDI_MAJOR=6 ln -sfn "/usr/local/lib/$NDI_BASENAME" "/usr/local/lib/libndi.so.${NDI_MAJOR}" ln -sfn "/usr/local/lib/libndi.so.${NDI_MAJOR}" /usr/local/lib/libndi.so echo /usr/local/lib > /etc/ld.so.conf.d/ndi.conf ldconfig NDI_RUNTIME_DIR_V6=/usr/local/lib export NDI_RUNTIME_DIR_V6 fi # --------------------------------------------------------------------------- # Build the NDI GStreamer plugin # --------------------------------------------------------------------------- cd "$SRC_DIR" if [[ ! -d gst-plugins-rs ]]; then git clone --depth 1 --branch "$PLUGIN_TAG" "$PLUGIN_REPO" gst-plugins-rs fi cd gst-plugins-rs git fetch --depth 1 origin "refs/tags/${PLUGIN_TAG}:refs/tags/${PLUGIN_TAG}" || true git checkout -q "$PLUGIN_TAG" if [[ -x /root/.cargo/bin/rustc ]]; then export PATH="/root/.cargo/bin:$PATH" fi log "Baue gst-plugin-ndi ${PLUGIN_TAG} gegen das installierte GStreamer..." cargo build -p gst-plugin-ndi --release PLUGIN_SO="$(find target -type f -name 'libgstndi.so' -not -path '*/deps/*' -print -quit)" [[ -n "$PLUGIN_SO" ]] || die "libgstndi.so wurde nicht erzeugt." install -m 0755 "$PLUGIN_SO" "$PLUGIN_DIR/libgstndi.so" rm -f /root/.cache/gstreamer-1.0/registry.*.bin 2>/dev/null || true # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- HOSTNAME_DEFAULT="$(hostname -s | tr '[:lower:]' '[:upper:]')" if [[ ! -f "$CONF" ]]; then cat > "$CONF" < HDMI audio. If no HDMI ALSA device is present, video continues. ENABLE_HDMI_AUDIO=false KMS_DRIVER=vc4 KMS_CONNECTOR_ID=auto HDMI_AUDIO_DEVICE=auto # NDI receive queue. 0 = minimal latency (validated setting, do not raise # without a documented reason - see NDI-Pi-Gateway-Dokumentation.md, §7). NDI_MAX_QUEUE=0 # Direct NDI receive: when true, resolve the advertised NDI service via mDNS # and feed its actual IP:port to ndisrc url-address. NDI_USE_DIRECT_ADDRESS=true EOF fi chmod 0644 "$CONF" # Migrate an existing configuration without overwriting user settings. ensure_conf_key() { local key="$1" value="$2" if ! grep -qE "^[[:space:]]*${key}=" "$CONF"; then printf '\n%s=%s\n' "$key" "$value" >> "$CONF" log "Konfiguration ergänzt: ${key}=${value}" fi } ensure_conf_key NDI_DISPLAY_ADDRESS auto ensure_conf_key NDI_USE_DIRECT_ADDRESS true ensure_conf_key KMS_DRIVER vc4 ensure_conf_key KMS_CONNECTOR_ID auto ensure_conf_key ENABLE_HDMI_AUDIO false if grep -qE '^NDI_MAX_QUEUE=(4|10)$' "$CONF"; then sed -i -E 's/^NDI_MAX_QUEUE=(4|10)$/NDI_MAX_QUEUE=0/' "$CONF" log "Fehlerhaften Zwischenstand NDI_MAX_QUEUE=4/10 auf 0 zurückgesetzt (validierte Latenz-Einstellung)." fi ensure_conf_key NDI_MAX_QUEUE 0 ensure_conf_key CAPTURE_QUEUE_BUFFERS 2 ensure_conf_key DISPLAY_WIDTH 1920 ensure_conf_key DISPLAY_HEIGHT 1080 ensure_conf_key DISPLAY_FPS 60 ensure_conf_key NDI_USE_DIRECT_ADDRESS true install -d -m 0755 "$INSTALL_DIR/bin" # --------------------------------------------------------------------------- # Capture helper # --------------------------------------------------------------------------- cat > "$INSTALL_DIR/bin/ndi-capture.sh" <<'EOF' #!/usr/bin/env bash set -Eeuo pipefail source /etc/ndi-pi.conf log() { echo "[ndi-capture] $*"; } warn() { echo "[ndi-capture] WARNING: $*" >&2; } is_video_capture() { local d="$1" caps caps="$(v4l2-ctl -d "$d" --all 2>/dev/null || true)" grep -qiE 'Video Capture' <<<"$caps" } fmt_caps() { v4l2-ctl -d "$1" --list-formats-ext 2>/dev/null || true } supports_mode() { local d="$1" fmt="$2" w="$3" h="$4" fps="$5" local caps block caps="$(fmt_caps "$d")" [[ -n "$caps" ]] || return 1 # Extract the complete format block. Trixie's v4l2-ctl output can use # either [N]: 'MJPG' or a Pixel Format heading depending on the driver. block="$(awk -v wanted="$fmt" ' function isfmt(line) { if (wanted == "MJPEG") return line ~ /MJPG|MJPEG|Motion-JPEG/ return line ~ /YUYV|YUY2|YUV 4:2:2/ } isfmt($0) { if (found) exit; found=1; print; next } found { if ($0 ~ /^[[:space:]]*\[[0-9]+\]:/ || $0 ~ /^[[:space:]]*Pixel Format:/) exit print } ' <<<"$caps")" [[ -n "$block" ]] || return 1 grep -qE "${w}[[:space:]]*[x×][[:space:]]*${h}" <<<"$block" || return 1 awk -v wanted="$fps" ' { line=$0 while (match(line, /([0-9]+([.][0-9]+)?)[[:space:]]*([/][[:space:]]*[0-9]+)?[[:space:]]*fps/)) { val=substr(line, RSTART, RLENGTH) gsub(/fps/, "", val); gsub(/[[:space:]]/, "", val) if (index(val, "/")) { split(val,a,"/") if (a[2] != 0 && (a[1]/a[2]) >= wanted-0.01 && (a[1]/a[2]) <= wanted+0.01) ok=1 } else if ((val+0) >= wanted-0.01 && (val+0) <= wanted+0.01) { ok=1 } line=substr(line, RSTART+RLENGTH) } } END { exit(ok ? 0 : 1) } ' <<<"$block" } mode_score() { local fmt="$1" w="$2" h="$3" fps="$4" local score=0 [[ "$fmt" == "YUYV" ]] && score=$((score + 1000000)) score=$((score + w*h)) score=$((score + fps*10000)) echo "$score" } select_mode() { local d="$1" local wanted_fmt="${CAPTURE_FORMAT:-auto}" local desired_w="${CAPTURE_WIDTH:-1920}" local desired_h="${CAPTURE_HEIGHT:-1080}" local desired_fps="${CAPTURE_FPS:-60}" local candidates=( "YUYV|1920|1080|60" "YUYV|1920|1080|50" "YUYV|1920|1080|30" "YUYV|1280|720|60" "YUYV|1280|720|50" "YUYV|1280|720|30" "YUYV|720|480|60" "YUYV|720|480|30" "MJPEG|1920|1080|60" "MJPEG|1920|1080|50" "MJPEG|1920|1080|30" "MJPEG|1280|720|60" "MJPEG|1280|720|50" "MJPEG|1280|720|30" ) # First try the user's exact preferred resolution/fps. local c fmt w h fps for c in "${candidates[@]}"; do IFS='|' read -r fmt w h fps <<<"$c" [[ "$wanted_fmt" != "auto" && "$fmt" != "$wanted_fmt" ]] && continue if supports_mode "$d" "$fmt" "$desired_w" "$desired_h" "$desired_fps"; then echo "$fmt|$desired_w|$desired_h|$desired_fps" return 0 fi done [[ "${CAPTURE_AUTO_FALLBACK:-true}" == "true" ]] || return 1 # Then use the ordered practical fallback list above. for c in "${candidates[@]}"; do IFS='|' read -r fmt w h fps <<<"$c" [[ "$wanted_fmt" != "auto" && "$fmt" != "$wanted_fmt" ]] && continue if supports_mode "$d" "$fmt" "$w" "$h" "$fps"; then echo "$fmt|$w|$h|$fps" return 0 fi done return 1 } usb_key() { local p="$1" x base p="$(readlink -f "$p" 2>/dev/null || true)" [[ -n "$p" ]] || return 1 IFS='/' read -ra parts <<<"${p#/}" for x in "${parts[@]}"; do if [[ "$x" =~ ^[0-9]+-[0-9]+([.][0-9]+)*$ ]]; then base="$x" fi done [[ -n "${base:-}" ]] && echo "$base" } score_device() { local d="$1" mode="$2" score=0 bus fmt w h fps IFS='|' read -r fmt w h fps <<<"$mode" # Keep the generic USB preference, but score the ACTUAL mode selected by # select_mode(). In auto mode YUYV is intentionally preferred over MJPEG. bus="$(udevadm info -q property -n "$d" 2>/dev/null | awk -F= '$1=="ID_BUS"{print $2; exit}')" [[ "$bus" == "usb" ]] && score=$((score + 5000000)) [[ "$fmt" == "YUYV" ]] && score=$((score + 1000000)) [[ "$fmt" == "MJPEG" ]] && score=$((score + 500000)) [[ "$w" == "$CAPTURE_WIDTH" ]] && [[ "$h" == "$CAPTURE_HEIGHT" ]] && score=$((score + 100000)) [[ "$fps" == "$CAPTURE_FPS" ]] && score=$((score + 10000)) score=$((score + w*h)) score=$((score + fps*100)) echo "$score" } pick_capture() { if [[ "${CAPTURE_DEVICE:-auto}" != "auto" ]]; then echo "$CAPTURE_DEVICE" return fi local best="" best_score=-1 d caps mode score shopt -s nullglob for d in /dev/video*; do [[ -e "$d" ]] || continue is_video_capture "$d" || continue caps="$(fmt_caps "$d")" mode="$(select_mode "$d" 2>/dev/null || true)" [[ -n "$mode" ]] || continue score="$(score_device "$d" "$mode")" [[ "$score" -gt "$best_score" ]] && { best="$d"; best_score="$score"; } done shopt -u nullglob [[ -n "$best" ]] && echo "$best" } find_audio_capture() { [[ "${ENABLE_AUDIO:-true}" == "true" ]] || return 1 [[ "${CAPTURE_AUDIO_DEVICE:-auto}" != "auto" ]] && { echo "$CAPTURE_AUDIO_DEVICE"; return 0; } local vkey card idx path skey best="" fallback="" vkey="$(usb_key "$(udevadm info -q path -n "$DEV" 2>/dev/null || true)")" || true # Prefer a USB sound card on the same USB device as the selected video node. while read -r idx _; do [[ "$idx" =~ ^[0-9]+$ ]] || continue path="$(readlink -f "/sys/class/sound/card${idx}/device" 2>/dev/null || true)" [[ -n "$path" ]] || continue skey="$(usb_key "$path" 2>/dev/null || true)" if [[ -n "$vkey" && -n "$skey" && "$vkey" == "$skey" ]]; then best="plughw:${idx},0" break fi if [[ -n "$skey" && -z "$fallback" ]]; then fallback="plughw:${idx},0" fi done < <(grep -E '^[[:space:]]*[0-9]+[[:space:]]+\[' /proc/asound/cards 2>/dev/null | sed -E 's/^[[:space:]]*([0-9]+).*/\1/') [[ -n "$best" ]] && { echo "$best"; return 0; } [[ -n "$fallback" ]] && { echo "$fallback"; return 0; } return 1 } DEV="$(pick_capture)" || { warn "Kein geeignetes V4L2-Capture-Gerät gefunden; systemd startet später erneut." for d in /dev/video*; do [[ -e "$d" ]] || continue warn "V4L2-Info für $d:" v4l2-ctl -d "$d" --all 2>&1 | sed 's/^/[ndi-capture] /' || true v4l2-ctl -d "$d" --list-formats-ext 2>&1 | sed 's/^/[ndi-capture] /' || true done exit 1 } MODE="$(select_mode "$DEV")" || { warn "Kein unterstützter Capture-Modus auf $DEV." fmt_caps "$DEV" | sed 's/^/[ndi-capture] /' >&2 || true exit 1 } IFS='|' read -r FMT WIDTH HEIGHT FPS <<<"$MODE" AUDIO_DEV="$(find_audio_capture || true)" # MJPEG wird mit dem normalen Software-Decoder jpegdec dekodiert. # v4l2jpegdec wird bewusst NICHT verwendet: die Testreihe zeigte dort # wiederholt "slow copy into buffer" und sichtbare Micro-Ruckler. log "Capture: $DEV, $FMT ${WIDTH}x${HEIGHT}@${FPS}" [[ -n "$AUDIO_DEV" ]] && log "Capture-Audio: $AUDIO_DEV" || warn "Kein passendes USB-Audio gefunden; Video-only." if [[ "$FMT" == "MJPEG" ]]; then VIDEO_CAPS="image/jpeg,width=${WIDTH},height=${HEIGHT},framerate=${FPS}/1" else VIDEO_CAPS="video/x-raw,format=YUY2,width=${WIDTH},height=${HEIGHT},framerate=${FPS}/1" fi # MJPEG: v4l2src(io-mode=2) -> jpegdec -> konfigurierbare kurze Queue -> ndisink. # Die Queue puffert kurze Decoder-/Scheduling-Schwankungen; leaky downstream # verhindert, dass bei Überlastung zunehmend alte Frames ausgegeben werden. CAPTURE_QUEUE_BUFFERS="${CAPTURE_QUEUE_BUFFERS:-2}" if ! [[ "$CAPTURE_QUEUE_BUFFERS" =~ ^[1-9][0-9]*$ ]]; then warn "CAPTURE_QUEUE_BUFFERS muss eine positive ganze Zahl sein; verwende 2." CAPTURE_QUEUE_BUFFERS=2 fi CAPTURE_QUEUE=(queue "max-size-buffers=${CAPTURE_QUEUE_BUFFERS}" max-size-time=0 max-size-bytes=0 leaky=downstream) # UYVY ist nur beim YUYV-Fallback nötig, weil ndisink YUY2/YUYV nicht direkt annimmt. if [[ -n "$AUDIO_DEV" ]]; then if [[ "$FMT" == "MJPEG" ]]; then exec gst-launch-1.0 -e \ ndisinkcombiner name=combiner ! \ ndisink ndi-name="${NDI_CAPTURE_NAME}" sync=false qos=true \ v4l2src device="$DEV" io-mode=2 ! \ "$VIDEO_CAPS" ! \ jpegdec ! "${CAPTURE_QUEUE[@]}" ! combiner.video \ alsasrc device="$AUDIO_DEV" do-timestamp=true ! \ queue max-size-time=100000000 max-size-buffers=0 max-size-bytes=0 ! \ audioconvert ! audioresample ! \ audio/x-raw,format=F32LE,rate="${CAPTURE_AUDIO_RATE:-48000}",channels="${CAPTURE_AUDIO_CHANNELS:-2}" ! \ combiner.audio else exec gst-launch-1.0 -e \ ndisinkcombiner name=combiner ! \ ndisink ndi-name="${NDI_CAPTURE_NAME}" sync=false qos=true \ v4l2src device="$DEV" io-mode=2 ! \ "$VIDEO_CAPS" ! \ videoconvert ! video/x-raw,format=UYVY ! "${CAPTURE_QUEUE[@]}" ! combiner.video \ alsasrc device="$AUDIO_DEV" do-timestamp=true ! \ queue max-size-time=100000000 max-size-buffers=0 max-size-bytes=0 ! \ audioconvert ! audioresample ! \ audio/x-raw,format=F32LE,rate="${CAPTURE_AUDIO_RATE:-48000}",channels="${CAPTURE_AUDIO_CHANNELS:-2}" ! \ combiner.audio fi else if [[ "$FMT" == "MJPEG" ]]; then exec gst-launch-1.0 -e \ v4l2src device="$DEV" io-mode=2 ! \ "$VIDEO_CAPS" ! \ jpegdec ! \ "${CAPTURE_QUEUE[@]}" ! \ ndisink ndi-name="${NDI_CAPTURE_NAME}" sync=false qos=true else exec gst-launch-1.0 -e \ v4l2src device="$DEV" io-mode=2 ! \ "$VIDEO_CAPS" ! \ videoconvert ! video/x-raw,format=UYVY ! \ "${CAPTURE_QUEUE[@]}" ! \ ndisink ndi-name="${NDI_CAPTURE_NAME}" sync=false qos=true fi fi EOF chmod 0755 "$INSTALL_DIR/bin/ndi-capture.sh" # --------------------------------------------------------------------------- # Display helper # --------------------------------------------------------------------------- cat > "$INSTALL_DIR/bin/ndi-display.sh" <<'EOF' #!/usr/bin/env bash set -Eeuo pipefail source /etc/ndi-pi.conf log(){ echo "[ndi-display] $*"; } warn(){ echo "[ndi-display] WARN: $*" >&2; } find_kms_connector() { local driver="${KMS_DRIVER:-vc4}" wanted="${KMS_CONNECTOR_ID:-auto}" line if [[ "$wanted" != "auto" && "$wanted" =~ ^[0-9]+$ ]]; then echo "$wanted"; return 0; fi command -v modetest >/dev/null 2>&1 || return 1 line="$(modetest -M "$driver" -c 2>/dev/null | awk '$3 == "connected" {print $1; exit}')" [[ "$line" =~ ^[0-9]+$ ]] || return 1 echo "$line" } KMS_CONNECTOR="$(find_kms_connector || true)" if [[ -z "$KMS_CONNECTOR" ]]; then warn "Kein verbundener ${KMS_DRIVER:-vc4}-HDMI-Connector gefunden." exit 1 fi log "DRM/KMS: driver=${KMS_DRIVER:-vc4}, connector-id=${KMS_CONNECTOR}" resolve_ndi_address() { local source="$1" command -v avahi-browse >/dev/null 2>&1 || return 1 timeout 8 avahi-browse -rtp _ndi._tcp 2>/dev/null | awk -F';' -v wanted="$source" '$1 == "=" && $4 == wanted && $8 ~ /^[0-9]+([.][0-9]+){3}$/ && $9 ~ /^[0-9]+$/ {print $8 ":" $9; exit}' | head -n 1 } NDI_ADDRESS="${NDI_DISPLAY_ADDRESS:-auto}" if [[ "${NDI_USE_DIRECT_ADDRESS:-true}" == "true" && ( -z "$NDI_ADDRESS" || "$NDI_ADDRESS" == "auto" ) ]]; then NDI_ADDRESS="$(resolve_ndi_address "$NDI_DISPLAY_SOURCE" || true)" [[ -n "$NDI_ADDRESS" ]] && log "NDI-Service aufgelöst: ${NDI_DISPLAY_SOURCE} -> ${NDI_ADDRESS}" || true elif [[ "${NDI_USE_DIRECT_ADDRESS:-true}" == "true" && -n "$NDI_ADDRESS" && "$NDI_ADDRESS" != "auto" ]]; then log "Verwende konfigurierte NDI-Adresse: ${NDI_ADDRESS}" fi if [[ "${NDI_USE_DIRECT_ADDRESS:-true}" == "true" && -n "$NDI_ADDRESS" && "$NDI_ADDRESS" != "auto" ]]; then log "Verwende ndisrc url-address=${NDI_ADDRESS}" NDI_SRC_ARGS=(ndisrc "url-address=${NDI_ADDRESS}" "color-format=bgrx-bgra" "max-queue-length=${NDI_MAX_QUEUE:-0}" "connect-timeout=10000" "timeout=5000") else log "Verwende ndisrc ndi-name=${NDI_DISPLAY_SOURCE} (Fallback)" NDI_SRC_ARGS=(ndisrc "ndi-name=${NDI_DISPLAY_SOURCE}" "color-format=bgrx-bgra" "max-queue-length=${NDI_MAX_QUEUE:-0}" "connect-timeout=10000" "timeout=5000") fi WIDTH="${DISPLAY_WIDTH:-1920}" HEIGHT="${DISPLAY_HEIGHT:-1080}" FPS="${DISPLAY_FPS:-60}" STANDBY_PID="" # Keep the standby self-contained and explicitly sized. Without a capsfilter, # videotestsrc negotiates its own small default size and kmssink can display that # image only in the middle of the HDMI output. The explicit caps below make the # standby a real fullscreen framebuffer at the configured HDMI mode. HOST_SHORT="$(hostname -s 2>/dev/null || hostname)" HOST_SHORT="${HOST_SHORT^^}" LOCAL_IP="$(hostname -I 2>/dev/null | awk '{print $1}')" LOCAL_IP="${LOCAL_IP:-unknown}" STANDBY_TEXT="NO SIGNAL ${HOST_SHORT} IP:${LOCAL_IP} HDMI:${WIDTH}x${HEIGHT}@${FPS} NDI:${NDI_DISPLAY_SOURCE}" stop_standby() { if [[ -n "${STANDBY_PID:-}" ]]; then kill "$STANDBY_PID" 2>/dev/null || true wait "$STANDBY_PID" 2>/dev/null || true STANDBY_PID="" fi } start_standby() { stop_standby log "Aktiviere schwarzen HDMI-Standby (Fullscreen ${WIDTH}x${HEIGHT}@${FPS})." env NDI_RUNTIME_DIR_V6="${NDI_RUNTIME_DIR_V6:-/usr/local/lib}" \ gst-launch-1.0 -q \ videotestsrc pattern=black is-live=true ! \ "video/x-raw,format=BGRA,width=${WIDTH},height=${HEIGHT},framerate=${FPS}/1" ! \ textoverlay text="${STANDBY_TEXT}" \ halignment=center valignment=center \ font-desc="Sans Bold, 32" \ shaded-background=true \ ! video/x-raw,format=BGRA ! \ kmssink driver-name="${KMS_DRIVER:-vc4}" connector-id="$KMS_CONNECTOR" sync=false \ >/tmp/ndi-display-standby.log 2>&1 & STANDBY_PID=$! sleep 0.8 if ! kill -0 "$STANDBY_PID" 2>/dev/null; then warn "HDMI-Standby konnte nicht gestartet werden. Ausgabe:" sed 's/^/[ndi-standby] /' /tmp/ndi-display-standby.log >&2 || true STANDBY_PID="" return 1 fi log "HDMI-Standby ist fullscreen aktiv (PID=${STANDBY_PID})." } cleanup() { stop_standby } trap cleanup EXIT INT TERM # Important for service restarts: do not first grab the DRM connector with a # standby kmssink and then immediately release it for the live kmssink. When the # NDI source is available, go directly to the verified live path. Only if that # path exits do we acquire KMS for the standby screen. while true; do log "Starte NDI-Livepipeline für ${NDI_DISPLAY_SOURCE}." set +e env NDI_RUNTIME_DIR_V6="${NDI_RUNTIME_DIR_V6:-/usr/local/lib}" \ gst-launch-1.0 -e \ "${NDI_SRC_ARGS[@]}" ! \ ndisrcdemux name=d \ d.video ! \ queue max-size-buffers=2 max-size-time=0 max-size-bytes=0 leaky=downstream ! \ videoscale add-borders=true ! \ "video/x-raw,width=${WIDTH},height=${HEIGHT},pixel-aspect-ratio=1/1" ! \ kmssink driver-name="${KMS_DRIVER:-vc4}" connector-id="$KMS_CONNECTOR" sync=false \ d.audio ! queue ! fakesink sync=false rc=$? set -e warn "NDI-Livepipeline beendet (rc=$rc). Rückkehr zu schwarzem Standby." start_standby || true # Keep the fullscreen standby active while the source is unavailable. Do not # repeatedly tear down/recreate kmssink: that was the visible flicker reported # during service restarts/source outages. Retry only after the NDI service can # be resolved/reached again. while true; do sleep 2 candidate="$NDI_ADDRESS" if [[ "${NDI_USE_DIRECT_ADDRESS:-true}" == "true" && ( -z "$candidate" || "$candidate" == "auto" ) ]]; then candidate="$(resolve_ndi_address "$NDI_DISPLAY_SOURCE" || true)" fi if [[ -n "$candidate" && "$candidate" != "auto" && "$candidate" == *:* ]]; then host="${candidate%:*}" port="${candidate##*:}" if [[ "$port" =~ ^[0-9]+$ ]] && timeout 1 bash -c "/dev/null; then log "NDI-Service wieder erreichbar (${candidate}); beende Standby und versuche Live erneut." NDI_ADDRESS="$candidate" if [[ "${NDI_USE_DIRECT_ADDRESS:-true}" == "true" ]]; then NDI_SRC_ARGS=(ndisrc "url-address=${NDI_ADDRESS}" "color-format=bgrx-bgra" "max-queue-length=${NDI_MAX_QUEUE:-0}" "connect-timeout=10000" "timeout=5000") fi stop_standby break fi fi done done EOF chmod 0755 "$INSTALL_DIR/bin/ndi-display.sh" # --------------------------------------------------------------------------- # Diagnostic tool # --------------------------------------------------------------------------- cat > "$INSTALL_DIR/bin/ndi-pi-diagnose" <<'EOF' #!/usr/bin/env bash set -u source /etc/ndi-pi.conf 2>/dev/null || true echo "=== NDI Pi Diagnose ===" echo "Kernel: $(uname -a)" echo "OS: $(. /etc/os-release; echo "${PRETTY_NAME:-unknown}")" echo echo "--- GStreamer ---" gst-launch-1.0 --version || true gst-inspect-1.0 ndisink ndisinkcombiner ndisrc ndisrcdemux kmssink 2>&1 | grep -E '^(Factory Details| Rank| Long-name| Description| Version| Author|Pad Templates| SINK| SRC| AUDIO| VIDEO)' || true echo echo "--- V4L2 ---" v4l2-ctl --list-devices || true for d in /dev/video*; do [[ -e "$d" ]] || continue echo "### $d" v4l2-ctl -d "$d" --all 2>/dev/null | grep -E 'Card type|Driver name|Bus info|Capabilities' || true v4l2-ctl -d "$d" --list-formats-ext 2>/dev/null | head -80 || true done echo echo "--- ALSA capture ---" arecord -l || true echo echo "--- ALSA playback ---" aplay -l || true echo echo "--- DRM ---" ls -l /dev/dri 2>/dev/null || true cat /sys/class/drm/*/status 2>/dev/null || true echo echo "--- NDI configuration ---" grep -E '^(NDI_DISPLAY_SOURCE|NDI_DISPLAY_ADDRESS|NDI_USE_DIRECT_ADDRESS|KMS_DRIVER|KMS_CONNECTOR_ID|ENABLE_HDMI_AUDIO|NDI_MAX_QUEUE)=' /etc/ndi-pi.conf 2>/dev/null || true echo echo "--- NDI discovery ---" if command -v gst-device-monitor-1.0 >/dev/null 2>&1; then NDI_RUNTIME_DIR_V6="${NDI_RUNTIME_DIR_V6:-/usr/local/lib}" \ gst-device-monitor-1.0 -f Source/Network:application/x-ndi 2>&1 | head -120 || true else echo "gst-device-monitor-1.0 nicht verfügbar" fi echo echo "--- NDI mDNS services ---" avahi-browse -rtp _ndi._tcp 2>&1 | head -80 || true echo echo "--- Services ---" systemctl --no-pager --full status ndi-capture.service ndi-display.service || true echo echo "--- ndi-capture.service journal (letzte 80 Zeilen) ---" journalctl -u ndi-capture.service -n 80 --no-pager || true echo echo "--- ndi-display.service journal (letzte 80 Zeilen) ---" journalctl -u ndi-display.service -n 80 --no-pager || true EOF chmod 0755 "$INSTALL_DIR/bin/ndi-pi-diagnose" # --------------------------------------------------------------------------- # systemd # --------------------------------------------------------------------------- cat > /etc/systemd/system/ndi-capture.service <<'EOF' [Unit] Description=NDI Pi Gateway - USB Capture + Audio to NDI After=network-online.target sound.target Wants=network-online.target sound.target ConditionPathExists=/etc/ndi-pi.conf [Service] Type=simple Environment=NDI_RUNTIME_DIR_V6=/usr/local/lib ExecStart=/opt/ndi-pi/bin/ndi-capture.sh Restart=always RestartSec=1 Nice=-5 LimitNOFILE=65536 [Install] WantedBy=multi-user.target EOF cat > /etc/systemd/system/ndi-display.service <<'EOF' [Unit] Description=NDI Pi Gateway - NDI Video + Audio to HDMI/DRM After=network-online.target sound.target Wants=network-online.target sound.target ConditionPathExists=/etc/ndi-pi.conf [Service] Type=simple Environment=NDI_RUNTIME_DIR_V6=/usr/local/lib ExecStart=/opt/ndi-pi/bin/ndi-display.sh Restart=always RestartSec=1 Nice=-5 LimitNOFILE=65536 [Install] WantedBy=multi-user.target EOF systemctl daemon-reload systemctl enable ndi-capture.service ndi-display.service log "Prüfe NDI-GStreamer-Plugin..." NDI_RUNTIME_DIR_V6="${NDI_RUNTIME_DIR_V6:-/usr/local/lib}" \ gst-inspect-1.0 ndisink >/dev/null NDI_RUNTIME_DIR_V6="${NDI_RUNTIME_DIR_V6:-/usr/local/lib}" \ gst-inspect-1.0 ndisrc >/dev/null log "NDI Runtime:" ldconfig -p | grep -E 'libndi\.so\.6(\.|$)' || true ls -l "${NDI_RUNTIME_DIR_V6:-/usr/local/lib}"/libndi.so* 2>/dev/null || true systemctl restart ndi-capture.service systemctl restart ndi-display.service echo log "Installation abgeschlossen." echo echo "Konfiguration: $CONF" echo " sudo nano $CONF" echo echo "NDI Display:" echo " NDI_DISPLAY_SOURCE=${NDI_DISPLAY_SOURCE:-}" echo " NDI_DISPLAY_ADDRESS=${NDI_DISPLAY_ADDRESS:-auto}" echo " NDI_USE_DIRECT_ADDRESS=${NDI_USE_DIRECT_ADDRESS:-true}" echo echo "Diagnose:" echo " sudo /opt/ndi-pi/bin/ndi-pi-diagnose" echo echo "Nach Änderungen:" echo " sudo systemctl restart ndi-capture ndi-display" echo echo "Logs:" echo " journalctl -u ndi-capture -f" echo " journalctl -u ndi-display -f" echo echo "NDI-Quellen:" if command -v gst-device-monitor-1.0 >/dev/null 2>&1; then NDI_RUNTIME_DIR_V6="${NDI_RUNTIME_DIR_V6:-/usr/local/lib}" \ gst-device-monitor-1.0 -f Source/Network:application/x-ndi || true else log "gst-device-monitor-1.0 nicht verfügbar; NDI-Quellen werden nicht aufgelistet." fi