mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 17:40:46 -04:00
config reshape
This commit is contained in:
@@ -2,20 +2,70 @@
|
||||
set -euo pipefail
|
||||
set +H
|
||||
|
||||
ENV_FILE="${VIDEO_ENV_FILE:-/var/lib/roverd/video.env}"
|
||||
ENV_FILE="${ROVERD_MEDIA_ENV_FILE:-/var/lib/roverd/media.env}"
|
||||
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
echo "Environment file ${ENV_FILE} missing; cannot start audio forward listener" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
# Load KEY=VALUE pairs from media.env without evaluating shell syntax. The
|
||||
# forward URL is data produced by roverd, and treating it as shell code would
|
||||
# break on normal SRT query-string characters such as '&'.
|
||||
load_env_file() {
|
||||
local content=""
|
||||
|
||||
: "${AUDIO_FORWARD_URL:?AUDIO_FORWARD_URL not set in ${ENV_FILE}}"
|
||||
PLAYBACK_DEVICE="${AUDIO_PLAYBACK_DEVICE:-forward}"
|
||||
AUDIO_NORMALIZE_ENABLE="${AUDIO_NORMALIZE_ENABLE:-1}"
|
||||
AUDIO_NORMALIZE_FILTER="${AUDIO_NORMALIZE_FILTER:-dynaudnorm=f=75:g=15:m=10:p=0.9,alimiter=limit=0.85:level=disabled}"
|
||||
if [[ -r "$ENV_FILE" ]]; then
|
||||
content="$(cat "$ENV_FILE")"
|
||||
elif command -v sudo >/dev/null 2>&1; then
|
||||
content="$(sudo -n cat "$ENV_FILE" 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
if [[ -z "$content" ]]; then
|
||||
echo "Cannot read ${ENV_FILE} (permission denied). Run as a user that can read it, or allow sudo -n for cat." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local line key val
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
|
||||
[[ "$line" =~ ^[[:space:]]*# ]] && continue
|
||||
|
||||
if [[ "$line" =~ ^[[:space:]]*export[[:space:]]+([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
|
||||
key="${BASH_REMATCH[1]}"
|
||||
val="${BASH_REMATCH[2]}"
|
||||
elif [[ "$line" =~ ^[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
|
||||
key="${BASH_REMATCH[1]}"
|
||||
val="${BASH_REMATCH[2]}"
|
||||
else
|
||||
continue
|
||||
fi
|
||||
|
||||
val="${val#${val%%[![:space:]]*}}"
|
||||
val="${val%${val##*[![:space:]]}}"
|
||||
if [[ "$val" =~ ^\".*\"$ ]]; then
|
||||
val="${val:1:${#val}-2}"
|
||||
elif [[ "$val" =~ ^\'.*\'$ ]]; then
|
||||
val="${val:1:${#val}-2}"
|
||||
fi
|
||||
|
||||
printf -v "$key" '%s' "$val"
|
||||
export "$key"
|
||||
done <<< "$content"
|
||||
}
|
||||
|
||||
load_env_file
|
||||
|
||||
: "${ROVERD_AUDIO_PLAYBACK_ENABLE:?ROVERD_AUDIO_PLAYBACK_ENABLE not set in ${ENV_FILE}}"
|
||||
if [[ "${ROVERD_AUDIO_PLAYBACK_ENABLE}" -ne 1 ]]; then
|
||||
echo "Audio playback disabled by roverd media config; skipping audio forward listener" >&2
|
||||
exit 0
|
||||
fi
|
||||
: "${ROVERD_AUDIO_PLAYBACK_FORWARD_URL:?ROVERD_AUDIO_PLAYBACK_FORWARD_URL not set in ${ENV_FILE}}"
|
||||
: "${ROVERD_AUDIO_PLAYBACK_DEVICE:?ROVERD_AUDIO_PLAYBACK_DEVICE not set in ${ENV_FILE}}"
|
||||
: "${ROVERD_AUDIO_PLAYBACK_NORMALIZE:?ROVERD_AUDIO_PLAYBACK_NORMALIZE not set in ${ENV_FILE}}"
|
||||
: "${ROVERD_AUDIO_PLAYBACK_NORMALIZE_FILTER:?ROVERD_AUDIO_PLAYBACK_NORMALIZE_FILTER not set in ${ENV_FILE}}"
|
||||
PLAYBACK_DEVICE="${ROVERD_AUDIO_PLAYBACK_DEVICE}"
|
||||
|
||||
if [[ -n "${FFMPEG_BIN:-}" ]]; then
|
||||
FFMPEG_BIN_PATH="$FFMPEG_BIN"
|
||||
@@ -45,12 +95,12 @@ run_pipeline() {
|
||||
-flags low_delay
|
||||
-analyzeduration 200k
|
||||
-probesize 32k
|
||||
-i "${AUDIO_FORWARD_URL}"
|
||||
-i "${ROVERD_AUDIO_PLAYBACK_FORWARD_URL}"
|
||||
-vn
|
||||
)
|
||||
|
||||
if [[ "${AUDIO_NORMALIZE_ENABLE}" -ne 0 ]]; then
|
||||
ffmpeg_args+=(-af "${AUDIO_NORMALIZE_FILTER}")
|
||||
if [[ "${ROVERD_AUDIO_PLAYBACK_NORMALIZE}" -ne 0 ]]; then
|
||||
ffmpeg_args+=(-af "${ROVERD_AUDIO_PLAYBACK_NORMALIZE_FILTER}")
|
||||
fi
|
||||
|
||||
ffmpeg_args+=(
|
||||
|
||||
@@ -2,26 +2,74 @@
|
||||
set -euo pipefail
|
||||
set +H
|
||||
|
||||
ENV_FILE="${VIDEO_ENV_FILE:-/var/lib/roverd/video.env}"
|
||||
ENV_FILE="${ROVERD_MEDIA_ENV_FILE:-/var/lib/roverd/media.env}"
|
||||
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
echo "Environment file ${ENV_FILE} missing; cannot publish audio" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
AUDIO_ENABLE="${AUDIO_ENABLE:-0}"
|
||||
if [[ "${AUDIO_ENABLE}" -ne 1 ]]; then
|
||||
# Load KEY=VALUE pairs from media.env without evaluating shell syntax. SRT URLs
|
||||
# contain characters such as '&' and '#!', so sourcing this file would treat a
|
||||
# data file as code and can split a valid URL into shell control operators.
|
||||
load_env_file() {
|
||||
local content=""
|
||||
|
||||
if [[ -r "$ENV_FILE" ]]; then
|
||||
content="$(cat "$ENV_FILE")"
|
||||
elif command -v sudo >/dev/null 2>&1; then
|
||||
content="$(sudo -n cat "$ENV_FILE" 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
if [[ -z "$content" ]]; then
|
||||
echo "Cannot read ${ENV_FILE} (permission denied). Run as a user that can read it, or allow sudo -n for cat." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local line key val
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
|
||||
[[ "$line" =~ ^[[:space:]]*# ]] && continue
|
||||
|
||||
if [[ "$line" =~ ^[[:space:]]*export[[:space:]]+([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
|
||||
key="${BASH_REMATCH[1]}"
|
||||
val="${BASH_REMATCH[2]}"
|
||||
elif [[ "$line" =~ ^[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
|
||||
key="${BASH_REMATCH[1]}"
|
||||
val="${BASH_REMATCH[2]}"
|
||||
else
|
||||
continue
|
||||
fi
|
||||
|
||||
val="${val#${val%%[![:space:]]*}}"
|
||||
val="${val%${val##*[![:space:]]}}"
|
||||
if [[ "$val" =~ ^\".*\"$ ]]; then
|
||||
val="${val:1:${#val}-2}"
|
||||
elif [[ "$val" =~ ^\'.*\'$ ]]; then
|
||||
val="${val:1:${#val}-2}"
|
||||
fi
|
||||
|
||||
printf -v "$key" '%s' "$val"
|
||||
export "$key"
|
||||
done <<< "$content"
|
||||
}
|
||||
|
||||
load_env_file
|
||||
: "${ROVERD_AUDIO_CAPTURE_ENABLE:?ROVERD_AUDIO_CAPTURE_ENABLE not set in ${ENV_FILE}}"
|
||||
if [[ "${ROVERD_AUDIO_CAPTURE_ENABLE}" -ne 1 ]]; then
|
||||
echo "Audio capture disabled; skipping audio-only publisher" >&2
|
||||
exit 0
|
||||
fi
|
||||
: "${AUDIO_PUBLISH_URL:?AUDIO_PUBLISH_URL not set in ${ENV_FILE}}"
|
||||
: "${ROVERD_AUDIO_CAPTURE_PUBLISH_URL:?ROVERD_AUDIO_CAPTURE_PUBLISH_URL not set in ${ENV_FILE}}"
|
||||
: "${ROVERD_AUDIO_CAPTURE_DEVICE:?ROVERD_AUDIO_CAPTURE_DEVICE not set in ${ENV_FILE}}"
|
||||
: "${ROVERD_AUDIO_CAPTURE_SAMPLE_RATE:?ROVERD_AUDIO_CAPTURE_SAMPLE_RATE not set in ${ENV_FILE}}"
|
||||
: "${ROVERD_AUDIO_CAPTURE_CHANNELS:?ROVERD_AUDIO_CAPTURE_CHANNELS not set in ${ENV_FILE}}"
|
||||
: "${ROVERD_AUDIO_CAPTURE_BITRATE:?ROVERD_AUDIO_CAPTURE_BITRATE not set in ${ENV_FILE}}"
|
||||
|
||||
AUDIO_DEVICE="${AUDIO_DEVICE:-hw:0,0}"
|
||||
CAPTURE_DEVICE="${ROVERD_AUDIO_CAPTURE_DEVICE}"
|
||||
|
||||
# The old 65,536-byte ALSA buffer represented about 171 ms before ffmpeg could
|
||||
# even publish the microphone audio:
|
||||
# At the default 48 kHz stereo S32_LE capture format, the old 65,536-byte ALSA
|
||||
# buffer represented about 171 ms before ffmpeg could publish microphone audio:
|
||||
# 65,536 / (48,000 samples * 2 channels * 4 bytes) = 0.1707 seconds
|
||||
# Keep the defaults much smaller because this publisher feeds an interactive
|
||||
# rover stream, where late-but-smooth audio is less useful than fresher audio.
|
||||
@@ -51,8 +99,8 @@ run_pipeline() {
|
||||
# format and so the published audio stays full-band stereo before the
|
||||
# Opus encoder sees it.
|
||||
-f s32le
|
||||
-ar 48000
|
||||
-ac 2
|
||||
-ar "${ROVERD_AUDIO_CAPTURE_SAMPLE_RATE}"
|
||||
-ac "${ROVERD_AUDIO_CAPTURE_CHANNELS}"
|
||||
-i pipe:0
|
||||
|
||||
# Keep the known-required microphone boost, but remove the old
|
||||
@@ -64,9 +112,9 @@ run_pipeline() {
|
||||
# the highest practical quality browsers and MediaMTX can carry without
|
||||
# trying to push raw PCM through the live path.
|
||||
-c:a libopus
|
||||
-b:a 510000
|
||||
-ar:a 48000
|
||||
-ac:a 2
|
||||
-b:a "${ROVERD_AUDIO_CAPTURE_BITRATE}"
|
||||
-ar:a "${ROVERD_AUDIO_CAPTURE_SAMPLE_RATE}"
|
||||
-ac:a "${ROVERD_AUDIO_CAPTURE_CHANNELS}"
|
||||
|
||||
# Use the audio profile for quality. Latency is still controlled by the
|
||||
# 20 ms Opus frame size and the low-buffering capture/publish options
|
||||
@@ -82,13 +130,13 @@ run_pipeline() {
|
||||
-muxdelay 0
|
||||
-muxpreload 0
|
||||
-f mpegts
|
||||
"${AUDIO_PUBLISH_URL}"
|
||||
"${ROVERD_AUDIO_CAPTURE_PUBLISH_URL}"
|
||||
)
|
||||
|
||||
# The Google Voice HAT microphone path is intentionally fixed instead of
|
||||
# configurable. The previous env-driven sample rate/channel knobs made it
|
||||
# easy for the rover config and the actual ffmpeg pipeline to drift apart,
|
||||
# while the hardware path we install is always 48 kHz stereo capture.
|
||||
# The capture format comes from roverd's normalized media config. Keeping
|
||||
# arecord and ffmpeg on the same env-backed values prevents the publisher
|
||||
# script from quietly disagreeing with roverd.yaml about sample rate or
|
||||
# channel count.
|
||||
#
|
||||
# The buffer and period are byte counts because arecord interprets -B/-F in
|
||||
# microseconds only when the value has an explicit time suffix. Keeping them
|
||||
@@ -96,7 +144,7 @@ run_pipeline() {
|
||||
# defaults are roughly 43 ms total buffer and 2.7 ms wakeup periods at
|
||||
# 48 kHz stereo S32_LE, which removes about 128 ms of avoidable capture
|
||||
# latency compared with the old 65,536-byte buffer.
|
||||
arecord -D "${AUDIO_DEVICE}" -f S32_LE -c 2 -r 48000 -B "${AUDIO_ALSA_BUFFER_BYTES}" -F "${AUDIO_ALSA_PERIOD_BYTES}" -q -t raw \
|
||||
arecord -D "${CAPTURE_DEVICE}" -f S32_LE -c "${ROVERD_AUDIO_CAPTURE_CHANNELS}" -r "${ROVERD_AUDIO_CAPTURE_SAMPLE_RATE}" -B "${AUDIO_ALSA_BUFFER_BYTES}" -F "${AUDIO_ALSA_PERIOD_BYTES}" -q -t raw \
|
||||
| "${FFMPEG_BIN_PATH}" "${ffmpeg_args[@]}"
|
||||
}
|
||||
|
||||
|
||||
+22
-18
@@ -4,7 +4,7 @@ set -euo pipefail
|
||||
# Keep history expansion off so values containing "!" are safe.
|
||||
set +H
|
||||
|
||||
ENV_FILE="${VIDEO_ENV_FILE:-/var/lib/roverd/video.env}"
|
||||
ENV_FILE="${ROVERD_MEDIA_ENV_FILE:-/var/lib/roverd/media.env}"
|
||||
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
echo "Environment file ${ENV_FILE} missing; cannot publish" >&2
|
||||
@@ -64,25 +64,29 @@ load_env_file() {
|
||||
}
|
||||
|
||||
load_env_file
|
||||
: "${PUBLISH_URL:?PUBLISH_URL not set in ${ENV_FILE}}"
|
||||
: "${ROVERD_VIDEO_ENABLE:?ROVERD_VIDEO_ENABLE not set in ${ENV_FILE}}"
|
||||
: "${ROVERD_VIDEO_PUBLISH_URL:?ROVERD_VIDEO_PUBLISH_URL not set in ${ENV_FILE}}"
|
||||
: "${ROVERD_VIDEO_WIDTH:?ROVERD_VIDEO_WIDTH not set in ${ENV_FILE}}"
|
||||
: "${ROVERD_VIDEO_HEIGHT:?ROVERD_VIDEO_HEIGHT not set in ${ENV_FILE}}"
|
||||
: "${ROVERD_VIDEO_FPS:?ROVERD_VIDEO_FPS not set in ${ENV_FILE}}"
|
||||
: "${ROVERD_VIDEO_BITRATE:?ROVERD_VIDEO_BITRATE not set in ${ENV_FILE}}"
|
||||
: "${ROVERD_VIDEO_INVERT:?ROVERD_VIDEO_INVERT not set in ${ENV_FILE}}"
|
||||
|
||||
# Defaults tuned for OV5647: use 4:3 output and force the common 2x2 binned full-FOV mode.
|
||||
VIDEO_WIDTH="640"
|
||||
VIDEO_HEIGHT="480"
|
||||
VIDEO_FPS="30"
|
||||
VIDEO_BITRATE="${VIDEO_BITRATE:-3000000}"
|
||||
VIDEO_INVERT="${VIDEO_INVERT:-1}"
|
||||
VIDEO_SENSOR_MODE="${VIDEO_SENSOR_MODE:-1296:972}"
|
||||
if [[ "${ROVERD_VIDEO_ENABLE}" -ne 1 ]]; then
|
||||
echo "Video publisher disabled by roverd media config; skipping" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Flip the camera 180deg by default; allow upright camera mounts via VIDEO_INVERT=0.
|
||||
# The inversion decision is made in roverd.yaml and written into media.env, so
|
||||
# this script only converts the configured logical value into libcamera flags.
|
||||
FLIP_ARGS=()
|
||||
if [[ "${VIDEO_INVERT}" -ne 0 ]]; then
|
||||
if [[ "${ROVERD_VIDEO_INVERT}" -ne 0 ]]; then
|
||||
FLIP_ARGS=(--rotation 180)
|
||||
fi
|
||||
|
||||
MODE_ARGS=()
|
||||
if [[ -n "${VIDEO_SENSOR_MODE}" ]]; then
|
||||
MODE_ARGS=(--mode "${VIDEO_SENSOR_MODE}")
|
||||
if [[ -n "${ROVERD_VIDEO_SENSOR_MODE:-}" ]]; then
|
||||
MODE_ARGS=(--mode "${ROVERD_VIDEO_SENSOR_MODE}")
|
||||
fi
|
||||
|
||||
if [[ -n "${LIBCAMERA_BIN:-}" ]]; then
|
||||
@@ -110,11 +114,11 @@ run_pipeline() {
|
||||
--inline \
|
||||
--timeout 0 \
|
||||
"${MODE_ARGS[@]}" \
|
||||
--width "${VIDEO_WIDTH}" \
|
||||
--height "${VIDEO_HEIGHT}" \
|
||||
--width "${ROVERD_VIDEO_WIDTH}" \
|
||||
--height "${ROVERD_VIDEO_HEIGHT}" \
|
||||
"${FLIP_ARGS[@]}" \
|
||||
--framerate "${VIDEO_FPS}" \
|
||||
--bitrate "${VIDEO_BITRATE}" \
|
||||
--framerate "${ROVERD_VIDEO_FPS}" \
|
||||
--bitrate "${ROVERD_VIDEO_BITRATE}" \
|
||||
--codec h264 \
|
||||
--profile baseline \
|
||||
--denoise auto \
|
||||
@@ -138,7 +142,7 @@ run_pipeline() {
|
||||
-muxdelay 0 \
|
||||
-muxpreload 0 \
|
||||
-f mpegts \
|
||||
"${PUBLISH_URL}"
|
||||
"${ROVERD_VIDEO_PUBLISH_URL}"
|
||||
}
|
||||
|
||||
while true; do
|
||||
|
||||
+24
-10
@@ -312,18 +312,32 @@ install -D -o root -g root -m 0755 pi/bin/audio-forward-listener.sh /usr/local/b
|
||||
install -m 0644 pi/systemd/audio-forward-listener.service /etc/systemd/system/audio-forward-listener.service
|
||||
log "Installed audio-forward listener helper + systemd unit"
|
||||
install -d -o roverd -g roverd /var/lib/roverd
|
||||
cat > /var/lib/roverd/video.env <<'ENV'
|
||||
cat > /var/lib/roverd/media.env <<'ENV'
|
||||
# Managed by roverd; placeholder values will be overwritten at runtime.
|
||||
PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||
AUDIO_PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-audio,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||
AUDIO_FORWARD_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-fwd,m=request&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||
VIDEO_BITRATE=2000000
|
||||
AUDIO_ENABLE=0
|
||||
AUDIO_DEVICE=hw:0,0
|
||||
AUDIO_PLAYBACK_DEVICE=forward
|
||||
ROVERD_VIDEO_ENABLE=1
|
||||
ROVERD_VIDEO_PUBLISHER=pi-libcamera
|
||||
ROVERD_VIDEO_PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||
ROVERD_VIDEO_DEVICE=
|
||||
ROVERD_VIDEO_WIDTH=640
|
||||
ROVERD_VIDEO_HEIGHT=480
|
||||
ROVERD_VIDEO_FPS=30
|
||||
ROVERD_VIDEO_BITRATE=2000000
|
||||
ROVERD_VIDEO_INVERT=1
|
||||
ROVERD_VIDEO_SENSOR_MODE=1296:972
|
||||
ROVERD_AUDIO_CAPTURE_ENABLE=0
|
||||
ROVERD_AUDIO_CAPTURE_PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-audio,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||
ROVERD_AUDIO_CAPTURE_DEVICE=hw:0,0
|
||||
ROVERD_AUDIO_CAPTURE_SAMPLE_RATE=48000
|
||||
ROVERD_AUDIO_CAPTURE_CHANNELS=2
|
||||
ROVERD_AUDIO_CAPTURE_BITRATE=510000
|
||||
ROVERD_AUDIO_PLAYBACK_ENABLE=1
|
||||
ROVERD_AUDIO_PLAYBACK_FORWARD_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-fwd,m=request&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||
ROVERD_AUDIO_PLAYBACK_DEVICE=forward
|
||||
ROVERD_AUDIO_PLAYBACK_NORMALIZE=1
|
||||
ROVERD_AUDIO_PLAYBACK_NORMALIZE_FILTER=dynaudnorm=f=75:g=15:m=10:p=0.9,alimiter=limit=0.85:level=disabled
|
||||
ENV
|
||||
chown roverd:roverd /var/lib/roverd/video.env
|
||||
chmod 0640 /var/lib/roverd/video.env
|
||||
chown roverd:roverd /var/lib/roverd/media.env
|
||||
chmod 0640 /var/lib/roverd/media.env
|
||||
# Create persistent audio FIFO for capture -> publisher
|
||||
FIFO_PATH="/var/lib/roverd/audio.pcm"
|
||||
if [[ -p "$FIFO_PATH" ]]; then
|
||||
|
||||
@@ -21,7 +21,7 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalf("load config: %v", err)
|
||||
}
|
||||
if err := roverd.UpdatePublisherEnv(cfg.Media, cfg.Audio); err != nil {
|
||||
if err := roverd.UpdatePublisherEnv(cfg.Media); err != nil {
|
||||
log.Fatalf("prepare media env: %v", err)
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ func main() {
|
||||
|
||||
adapter := roverd.NewSerialAdapter(serialPort, logger)
|
||||
|
||||
mediaSupervisor := roverd.NewMediaSupervisor(cfg.Media, cfg.Audio, logger)
|
||||
mediaSupervisor := roverd.NewMediaSupervisor(cfg.Media, logger)
|
||||
if mediaSupervisor != nil {
|
||||
mediaSupervisor.Start(ctx)
|
||||
}
|
||||
|
||||
+195
-66
@@ -56,13 +56,13 @@ type BatteryConfig struct {
|
||||
}
|
||||
|
||||
type AudioConfig struct {
|
||||
CaptureEnabled bool `yaml:"captureEnabled" json:"captureEnabled"`
|
||||
CaptureDevice string `yaml:"captureDevice" json:"captureDevice,omitempty"`
|
||||
PlaybackDevice string `yaml:"playbackDevice" json:"playbackDevice,omitempty"`
|
||||
TTSEnabled bool `yaml:"ttsEnabled" json:"ttsEnabled"`
|
||||
DefaultEngine string `yaml:"defaultEngine" json:"defaultEngine,omitempty"`
|
||||
DefaultVoice string `yaml:"defaultVoice" json:"defaultVoice,omitempty"`
|
||||
DefaultPitch int `yaml:"defaultPitch" json:"defaultPitch,omitempty"`
|
||||
// AudioConfig is only for sounds generated by roverd itself. Live capture
|
||||
// and playback are media-publisher concerns, so those settings live under
|
||||
// MediaConfig where they can be written directly into media.env.
|
||||
TTSEnabled bool `yaml:"ttsEnabled" json:"ttsEnabled"`
|
||||
DefaultEngine string `yaml:"defaultEngine" json:"defaultEngine,omitempty"`
|
||||
DefaultVoice string `yaml:"defaultVoice" json:"defaultVoice,omitempty"`
|
||||
DefaultPitch int `yaml:"defaultPitch" json:"defaultPitch,omitempty"`
|
||||
}
|
||||
|
||||
type HornConfig struct {
|
||||
@@ -77,21 +77,59 @@ type HornConfig struct {
|
||||
}
|
||||
|
||||
type MediaConfig struct {
|
||||
PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"`
|
||||
AudioPublishURL string `yaml:"audioPublishUrl" json:"audioPublishUrl,omitempty"`
|
||||
AudioForwardURL string `yaml:"audioForwardUrl" json:"audioForwardUrl,omitempty"`
|
||||
PublishPort int `yaml:"publishPort" json:"-"`
|
||||
CameraInverted bool `yaml:"cameraInverted" json:"-"`
|
||||
Manage bool `yaml:"manage"`
|
||||
ManageAudio bool `yaml:"manageAudio"`
|
||||
Service string `yaml:"service"`
|
||||
AudioService string `yaml:"audioService"`
|
||||
HealthURL string `yaml:"healthUrl"`
|
||||
HealthInterval Duration `yaml:"healthInterval"`
|
||||
VideoWidth int `yaml:"videoWidth" json:"-"`
|
||||
VideoHeight int `yaml:"videoHeight" json:"-"`
|
||||
VideoFPS int `yaml:"videoFps" json:"-"`
|
||||
VideoBitrate int `yaml:"videoBitrate" json:"-"`
|
||||
// PublishPort is shared by the derived video, microphone, and forwarded-audio
|
||||
// SRT URLs. Keeping it at this level prevents each nested block from needing
|
||||
// to repeat the same server port when the common MediaMTX listener is used.
|
||||
PublishPort int `yaml:"publishPort" json:"-"`
|
||||
Manage bool `yaml:"manage" json:"manage"`
|
||||
HealthURL string `yaml:"healthUrl" json:"healthUrl,omitempty"`
|
||||
HealthInterval Duration `yaml:"healthInterval" json:"-"`
|
||||
Video VideoMediaConfig `yaml:"video" json:"video"`
|
||||
AudioCapture AudioCaptureConfig `yaml:"audioCapture" json:"audioCapture"`
|
||||
AudioPlayback AudioPlaybackConfig `yaml:"audioPlayback" json:"audioPlayback"`
|
||||
}
|
||||
|
||||
type VideoMediaConfig struct {
|
||||
// Publisher selects the installed publisher script/pipeline family. The
|
||||
// first pass uses pi-libcamera for current rovers; laptop-v4l2 can be added
|
||||
// without changing the server-facing media shape again.
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
Service string `yaml:"service" json:"service,omitempty"`
|
||||
Publisher string `yaml:"publisher" json:"publisher,omitempty"`
|
||||
PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"`
|
||||
Device string `yaml:"device" json:"device,omitempty"`
|
||||
Width int `yaml:"width" json:"-"`
|
||||
Height int `yaml:"height" json:"-"`
|
||||
FPS int `yaml:"fps" json:"-"`
|
||||
Bitrate int `yaml:"bitrate" json:"-"`
|
||||
Inverted bool `yaml:"inverted" json:"-"`
|
||||
SensorMode string `yaml:"sensorMode" json:"-"`
|
||||
}
|
||||
|
||||
type AudioCaptureConfig struct {
|
||||
// AudioCapture describes the rover microphone stream that browsers can
|
||||
// subscribe to as "<rover>-audio". A disabled capture block still has
|
||||
// normalized defaults so enabling it only requires flipping enabled: true.
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
Service string `yaml:"service" json:"service,omitempty"`
|
||||
PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"`
|
||||
Device string `yaml:"device" json:"device,omitempty"`
|
||||
SampleRate int `yaml:"sampleRate" json:"-"`
|
||||
Channels int `yaml:"channels" json:"-"`
|
||||
Bitrate int `yaml:"bitrate" json:"-"`
|
||||
}
|
||||
|
||||
type AudioPlaybackConfig struct {
|
||||
// AudioPlayback describes the reverse stream that VIP users publish into
|
||||
// MediaMTX for playback on the rover speaker. The URL is a request/read URL
|
||||
// for the rover listener, while the server converts it to publish mode when
|
||||
// it needs to inject audio.
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
Service string `yaml:"service" json:"service,omitempty"`
|
||||
ForwardURL string `yaml:"forwardUrl" json:"forwardUrl,omitempty"`
|
||||
Device string `yaml:"device" json:"device,omitempty"`
|
||||
Normalize bool `yaml:"normalize" json:"-"`
|
||||
NormalizeFilter string `yaml:"normalizeFilter" json:"-"`
|
||||
}
|
||||
|
||||
type CameraServoConfig struct {
|
||||
@@ -189,9 +227,33 @@ func LoadConfig(path string) (*Config, error) {
|
||||
},
|
||||
Media: MediaConfig{
|
||||
PublishPort: 9000,
|
||||
CameraInverted: true,
|
||||
HealthInterval: Duration{Duration: 30 * time.Second},
|
||||
VideoBitrate: 2000000,
|
||||
Video: VideoMediaConfig{
|
||||
Enabled: true,
|
||||
Service: "video-publisher.service",
|
||||
Publisher: "pi-libcamera",
|
||||
Width: 640,
|
||||
Height: 480,
|
||||
FPS: 30,
|
||||
Bitrate: 2000000,
|
||||
Inverted: true,
|
||||
SensorMode: "1296:972",
|
||||
},
|
||||
AudioCapture: AudioCaptureConfig{
|
||||
Enabled: false,
|
||||
Service: "audio-only-publisher.service",
|
||||
Device: "hw:0,0",
|
||||
SampleRate: 48000,
|
||||
Channels: 2,
|
||||
Bitrate: 510000,
|
||||
},
|
||||
AudioPlayback: AudioPlaybackConfig{
|
||||
Enabled: true,
|
||||
Service: "audio-forward-listener.service",
|
||||
Device: "forward",
|
||||
Normalize: true,
|
||||
NormalizeFilter: "dynaudnorm=f=75:g=15:m=10:p=0.9,alimiter=limit=0.85:level=disabled",
|
||||
},
|
||||
},
|
||||
CameraServo: CameraServoConfig{
|
||||
Pin: 12,
|
||||
@@ -205,13 +267,10 @@ func LoadConfig(path string) (*Config, error) {
|
||||
NudgeDegrees: 2,
|
||||
},
|
||||
Audio: AudioConfig{
|
||||
CaptureEnabled: false,
|
||||
CaptureDevice: "rovermic",
|
||||
PlaybackDevice: "forward",
|
||||
TTSEnabled: false,
|
||||
DefaultEngine: "flite",
|
||||
DefaultVoice: "rms",
|
||||
DefaultPitch: 50,
|
||||
TTSEnabled: false,
|
||||
DefaultEngine: "flite",
|
||||
DefaultVoice: "rms",
|
||||
DefaultPitch: 50,
|
||||
},
|
||||
Horn: HornConfig{
|
||||
Enabled: false,
|
||||
@@ -283,38 +342,11 @@ func LoadConfig(path string) (*Config, error) {
|
||||
if cfg.BRC.GPIOChip == "" {
|
||||
cfg.BRC.GPIOChip = "gpiochip0"
|
||||
}
|
||||
if cfg.Media.Manage && cfg.Media.Service == "" {
|
||||
return nil, errors.New("media.manage requires media.service")
|
||||
}
|
||||
if cfg.Media.Manage && cfg.Media.HealthInterval.Duration <= 0 {
|
||||
cfg.Media.HealthInterval = Duration{Duration: 30 * time.Second}
|
||||
}
|
||||
if cfg.Media.VideoBitrate <= 0 {
|
||||
cfg.Media.VideoBitrate = 3000000
|
||||
}
|
||||
if cfg.Media.PublishPort <= 0 {
|
||||
cfg.Media.PublishPort = 9000
|
||||
}
|
||||
if cfg.Media.PublishURL == "" {
|
||||
derived, err := derivePublishURL(cfg.ServerURL, cfg.Name, cfg.Media.PublishPort)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("derive publishUrl: %w", err)
|
||||
}
|
||||
cfg.Media.PublishURL = derived
|
||||
}
|
||||
if cfg.Media.AudioPublishURL == "" {
|
||||
derived, err := derivePublishURL(cfg.ServerURL, cfg.Name+"-audio", cfg.Media.PublishPort)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("derive audioPublishUrl: %w", err)
|
||||
}
|
||||
cfg.Media.AudioPublishURL = derived
|
||||
}
|
||||
if cfg.Media.AudioForwardURL == "" {
|
||||
derived, err := deriveReadURL(cfg.ServerURL, cfg.Name+"-fwd", cfg.Media.PublishPort)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("derive audioForwardUrl: %w", err)
|
||||
}
|
||||
cfg.Media.AudioForwardURL = derived
|
||||
if err := validateMediaConfig(&cfg.Media, cfg.ServerURL, cfg.Name); err != nil {
|
||||
return nil, fmt.Errorf("media: %w", err)
|
||||
}
|
||||
if err := validateServoConfig(&cfg.CameraServo); err != nil {
|
||||
return nil, fmt.Errorf("cameraServo: %w", err)
|
||||
@@ -375,12 +407,6 @@ func clampFloat(value, min, max float64) float64 {
|
||||
}
|
||||
|
||||
func validateAudioConfig(cfg *AudioConfig) {
|
||||
if cfg.CaptureEnabled && cfg.CaptureDevice == "" {
|
||||
cfg.CaptureDevice = "hw:0,0"
|
||||
}
|
||||
if cfg.PlaybackDevice == "" || cfg.PlaybackDevice == "default" {
|
||||
cfg.PlaybackDevice = "forward"
|
||||
}
|
||||
if cfg.DefaultEngine == "" {
|
||||
cfg.DefaultEngine = "flite"
|
||||
}
|
||||
@@ -392,6 +418,109 @@ func validateAudioConfig(cfg *AudioConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
func validateMediaConfig(cfg *MediaConfig, serverURL string, roverName string) error {
|
||||
/*
|
||||
The nested media config is the source of truth for the publisher scripts,
|
||||
so defaults that used to live in shell are normalized here before any env
|
||||
file is written. This keeps the Pi behavior stable while making laptop
|
||||
and future publisher variants explicit configuration choices.
|
||||
*/
|
||||
if cfg.PublishPort <= 0 {
|
||||
cfg.PublishPort = 9000
|
||||
}
|
||||
if cfg.HealthInterval.Duration <= 0 {
|
||||
cfg.HealthInterval = Duration{Duration: 30 * time.Second}
|
||||
}
|
||||
if err := validateVideoMediaConfig(&cfg.Video, serverURL, roverName, cfg.PublishPort); err != nil {
|
||||
return fmt.Errorf("video: %w", err)
|
||||
}
|
||||
if err := validateAudioCaptureConfig(&cfg.AudioCapture, serverURL, roverName, cfg.PublishPort); err != nil {
|
||||
return fmt.Errorf("audioCapture: %w", err)
|
||||
}
|
||||
if err := validateAudioPlaybackConfig(&cfg.AudioPlayback, serverURL, roverName, cfg.PublishPort); err != nil {
|
||||
return fmt.Errorf("audioPlayback: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateVideoMediaConfig(cfg *VideoMediaConfig, serverURL string, roverName string, publishPort int) error {
|
||||
if cfg.Service == "" {
|
||||
cfg.Service = "video-publisher.service"
|
||||
}
|
||||
if cfg.Publisher == "" {
|
||||
cfg.Publisher = "pi-libcamera"
|
||||
}
|
||||
if cfg.Width <= 0 {
|
||||
cfg.Width = 640
|
||||
}
|
||||
if cfg.Height <= 0 {
|
||||
cfg.Height = 480
|
||||
}
|
||||
if cfg.FPS <= 0 {
|
||||
cfg.FPS = 30
|
||||
}
|
||||
if cfg.Bitrate <= 0 {
|
||||
cfg.Bitrate = 2000000
|
||||
}
|
||||
if cfg.SensorMode == "" && cfg.Publisher == "pi-libcamera" {
|
||||
cfg.SensorMode = "1296:972"
|
||||
}
|
||||
if cfg.PublishURL == "" {
|
||||
derived, err := derivePublishURL(serverURL, roverName, publishPort)
|
||||
if err != nil {
|
||||
return fmt.Errorf("derive publishUrl: %w", err)
|
||||
}
|
||||
cfg.PublishURL = derived
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAudioCaptureConfig(cfg *AudioCaptureConfig, serverURL string, roverName string, publishPort int) error {
|
||||
if cfg.Service == "" {
|
||||
cfg.Service = "audio-only-publisher.service"
|
||||
}
|
||||
if cfg.Device == "" {
|
||||
cfg.Device = "hw:0,0"
|
||||
}
|
||||
if cfg.SampleRate <= 0 {
|
||||
cfg.SampleRate = 48000
|
||||
}
|
||||
if cfg.Channels <= 0 {
|
||||
cfg.Channels = 2
|
||||
}
|
||||
if cfg.Bitrate <= 0 {
|
||||
cfg.Bitrate = 510000
|
||||
}
|
||||
if cfg.PublishURL == "" {
|
||||
derived, err := derivePublishURL(serverURL, roverName+"-audio", publishPort)
|
||||
if err != nil {
|
||||
return fmt.Errorf("derive publishUrl: %w", err)
|
||||
}
|
||||
cfg.PublishURL = derived
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAudioPlaybackConfig(cfg *AudioPlaybackConfig, serverURL string, roverName string, publishPort int) error {
|
||||
if cfg.Service == "" {
|
||||
cfg.Service = "audio-forward-listener.service"
|
||||
}
|
||||
if cfg.Device == "" {
|
||||
cfg.Device = "forward"
|
||||
}
|
||||
if cfg.NormalizeFilter == "" {
|
||||
cfg.NormalizeFilter = "dynaudnorm=f=75:g=15:m=10:p=0.9,alimiter=limit=0.85:level=disabled"
|
||||
}
|
||||
if cfg.ForwardURL == "" {
|
||||
derived, err := deriveReadURL(serverURL, roverName+"-fwd", publishPort)
|
||||
if err != nil {
|
||||
return fmt.Errorf("derive forwardUrl: %w", err)
|
||||
}
|
||||
cfg.ForwardURL = derived
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateHornConfig(cfg *HornConfig) {
|
||||
if cfg.Volume <= 0 {
|
||||
cfg.Volume = 0.25
|
||||
|
||||
+38
-37
@@ -7,51 +7,52 @@ import (
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const publisherEnvPath = "/var/lib/roverd/video.env"
|
||||
const publisherEnvPath = "/var/lib/roverd/media.env"
|
||||
|
||||
func UpdatePublisherEnv(media MediaConfig, audio AudioConfig) error {
|
||||
if media.PublishURL == "" {
|
||||
return fmt.Errorf("media publishUrl missing")
|
||||
func UpdatePublisherEnv(media MediaConfig) error {
|
||||
if media.Video.Enabled && media.Video.PublishURL == "" {
|
||||
return fmt.Errorf("media video publishUrl missing")
|
||||
}
|
||||
if media.AudioPublishURL == "" && audio.CaptureEnabled {
|
||||
return fmt.Errorf("audio publishUrl missing")
|
||||
if media.AudioCapture.Enabled && media.AudioCapture.PublishURL == "" {
|
||||
return fmt.Errorf("media audioCapture publishUrl missing")
|
||||
}
|
||||
if media.VideoWidth < 0 || media.VideoHeight < 0 || media.VideoFPS < 0 || media.VideoBitrate <= 0 {
|
||||
return fmt.Errorf("invalid media dimensions/bitrate")
|
||||
if media.AudioPlayback.Enabled && media.AudioPlayback.ForwardURL == "" {
|
||||
return fmt.Errorf("media audioPlayback forwardUrl missing")
|
||||
}
|
||||
if media.Video.Width <= 0 || media.Video.Height <= 0 || media.Video.FPS <= 0 || media.Video.Bitrate <= 0 {
|
||||
return fmt.Errorf("invalid media video dimensions/bitrate")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(publisherEnvPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
fmt.Fprintf(&buf, "PUBLISH_URL=%s\n", media.PublishURL)
|
||||
if audio.CaptureEnabled && media.AudioPublishURL != "" {
|
||||
fmt.Fprintf(&buf, "AUDIO_PUBLISH_URL=%s\n", media.AudioPublishURL)
|
||||
}
|
||||
if media.AudioForwardURL != "" {
|
||||
fmt.Fprintf(&buf, "AUDIO_FORWARD_URL=%s\n", media.AudioForwardURL)
|
||||
}
|
||||
if media.VideoWidth > 0 {
|
||||
fmt.Fprintf(&buf, "VIDEO_WIDTH=%d\n", media.VideoWidth)
|
||||
}
|
||||
if media.VideoHeight > 0 {
|
||||
fmt.Fprintf(&buf, "VIDEO_HEIGHT=%d\n", media.VideoHeight)
|
||||
}
|
||||
if media.VideoFPS > 0 {
|
||||
fmt.Fprintf(&buf, "VIDEO_FPS=%d\n", media.VideoFPS)
|
||||
}
|
||||
fmt.Fprintf(&buf, "VIDEO_BITRATE=%d\n", media.VideoBitrate)
|
||||
fmt.Fprintf(&buf, "VIDEO_INVERT=%d\n", boolToInt(media.CameraInverted))
|
||||
audioDevice := audio.CaptureDevice
|
||||
if audioDevice == "" || audioDevice == "rovermic" {
|
||||
audioDevice = "hw:0,0"
|
||||
}
|
||||
fmt.Fprintf(&buf, "AUDIO_ENABLE=%d\n", boolToInt(audio.CaptureEnabled))
|
||||
fmt.Fprintf(&buf, "AUDIO_DEVICE=%s\n", audioDevice)
|
||||
playbackDevice := audio.PlaybackDevice
|
||||
if playbackDevice == "" {
|
||||
playbackDevice = "forward"
|
||||
}
|
||||
fmt.Fprintf(&buf, "AUDIO_PLAYBACK_DEVICE=%s\n", playbackDevice)
|
||||
/*
|
||||
The env file is intentionally verbose: every publisher receives concrete
|
||||
values instead of silently falling back to script-local defaults. That
|
||||
makes roverd.yaml the owner of media behavior while keeping the shell
|
||||
scripts as small pipeline launchers.
|
||||
*/
|
||||
fmt.Fprintf(&buf, "ROVERD_VIDEO_ENABLE=%d\n", boolToInt(media.Video.Enabled))
|
||||
fmt.Fprintf(&buf, "ROVERD_VIDEO_PUBLISHER=%s\n", media.Video.Publisher)
|
||||
fmt.Fprintf(&buf, "ROVERD_VIDEO_PUBLISH_URL=%s\n", media.Video.PublishURL)
|
||||
fmt.Fprintf(&buf, "ROVERD_VIDEO_DEVICE=%s\n", media.Video.Device)
|
||||
fmt.Fprintf(&buf, "ROVERD_VIDEO_WIDTH=%d\n", media.Video.Width)
|
||||
fmt.Fprintf(&buf, "ROVERD_VIDEO_HEIGHT=%d\n", media.Video.Height)
|
||||
fmt.Fprintf(&buf, "ROVERD_VIDEO_FPS=%d\n", media.Video.FPS)
|
||||
fmt.Fprintf(&buf, "ROVERD_VIDEO_BITRATE=%d\n", media.Video.Bitrate)
|
||||
fmt.Fprintf(&buf, "ROVERD_VIDEO_INVERT=%d\n", boolToInt(media.Video.Inverted))
|
||||
fmt.Fprintf(&buf, "ROVERD_VIDEO_SENSOR_MODE=%s\n", media.Video.SensorMode)
|
||||
fmt.Fprintf(&buf, "ROVERD_AUDIO_CAPTURE_ENABLE=%d\n", boolToInt(media.AudioCapture.Enabled))
|
||||
fmt.Fprintf(&buf, "ROVERD_AUDIO_CAPTURE_PUBLISH_URL=%s\n", media.AudioCapture.PublishURL)
|
||||
fmt.Fprintf(&buf, "ROVERD_AUDIO_CAPTURE_DEVICE=%s\n", media.AudioCapture.Device)
|
||||
fmt.Fprintf(&buf, "ROVERD_AUDIO_CAPTURE_SAMPLE_RATE=%d\n", media.AudioCapture.SampleRate)
|
||||
fmt.Fprintf(&buf, "ROVERD_AUDIO_CAPTURE_CHANNELS=%d\n", media.AudioCapture.Channels)
|
||||
fmt.Fprintf(&buf, "ROVERD_AUDIO_CAPTURE_BITRATE=%d\n", media.AudioCapture.Bitrate)
|
||||
fmt.Fprintf(&buf, "ROVERD_AUDIO_PLAYBACK_ENABLE=%d\n", boolToInt(media.AudioPlayback.Enabled))
|
||||
fmt.Fprintf(&buf, "ROVERD_AUDIO_PLAYBACK_FORWARD_URL=%s\n", media.AudioPlayback.ForwardURL)
|
||||
fmt.Fprintf(&buf, "ROVERD_AUDIO_PLAYBACK_DEVICE=%s\n", media.AudioPlayback.Device)
|
||||
fmt.Fprintf(&buf, "ROVERD_AUDIO_PLAYBACK_NORMALIZE=%d\n", boolToInt(media.AudioPlayback.Normalize))
|
||||
fmt.Fprintf(&buf, "ROVERD_AUDIO_PLAYBACK_NORMALIZE_FILTER=%s\n", media.AudioPlayback.NormalizeFilter)
|
||||
if err := os.WriteFile(publisherEnvPath, buf.Bytes(), 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -13,17 +13,16 @@ import (
|
||||
|
||||
type MediaSupervisor struct {
|
||||
cfg MediaConfig
|
||||
audio AudioConfig
|
||||
logger *log.Logger
|
||||
client *http.Client
|
||||
checkInterval time.Duration
|
||||
}
|
||||
|
||||
func NewMediaSupervisor(cfg MediaConfig, audio AudioConfig, logger *log.Logger) *MediaSupervisor {
|
||||
if err := UpdatePublisherEnv(cfg, audio); err != nil {
|
||||
func NewMediaSupervisor(cfg MediaConfig, logger *log.Logger) *MediaSupervisor {
|
||||
if err := UpdatePublisherEnv(cfg); err != nil {
|
||||
logger.Printf("media supervisor: update env failed: %v", err)
|
||||
}
|
||||
if !cfg.Manage || cfg.Service == "" {
|
||||
if !cfg.Manage {
|
||||
return nil
|
||||
}
|
||||
interval := cfg.HealthInterval.Duration
|
||||
@@ -36,7 +35,6 @@ func NewMediaSupervisor(cfg MediaConfig, audio AudioConfig, logger *log.Logger)
|
||||
}
|
||||
return &MediaSupervisor{
|
||||
cfg: cfg,
|
||||
audio: audio,
|
||||
logger: logger,
|
||||
client: client,
|
||||
checkInterval: interval,
|
||||
@@ -47,7 +45,7 @@ func (m *MediaSupervisor) Start(ctx context.Context) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
if err := UpdatePublisherEnv(m.cfg, m.audio); err != nil {
|
||||
if err := UpdatePublisherEnv(m.cfg); err != nil {
|
||||
m.logger.Printf("media supervisor: update env failed: %v", err)
|
||||
}
|
||||
if m.cfg.HealthURL == "" || m.client == nil {
|
||||
@@ -78,7 +76,7 @@ func (m *MediaSupervisor) HandleAction(ctx context.Context, action string) error
|
||||
if m == nil {
|
||||
return errors.New("media supervisor disabled")
|
||||
}
|
||||
if err := UpdatePublisherEnv(m.cfg, m.audio); err != nil {
|
||||
if err := UpdatePublisherEnv(m.cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
switch action {
|
||||
@@ -96,9 +94,9 @@ func (m *MediaSupervisor) checkAndRepair() error {
|
||||
if m.checkHealth(ctx) {
|
||||
return nil
|
||||
}
|
||||
m.logger.Printf("media supervisor: health check failed, restarting %s", m.cfg.Service)
|
||||
m.logger.Printf("media supervisor: health check failed, restarting configured media services")
|
||||
if err := m.runSystemctl(ctx, "restart"); err != nil {
|
||||
return fmt.Errorf("restart mediamtx: %w", err)
|
||||
return fmt.Errorf("restart media services: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -127,16 +125,39 @@ func (m *MediaSupervisor) checkHealth(ctx context.Context) bool {
|
||||
}
|
||||
|
||||
func (m *MediaSupervisor) runSystemctl(ctx context.Context, action string) error {
|
||||
if m.cfg.Service == "" {
|
||||
return errors.New("no media service configured")
|
||||
services := m.managedServices()
|
||||
if len(services) == 0 {
|
||||
return errors.New("no media services configured")
|
||||
}
|
||||
runCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(runCtx, "systemctl", action, m.cfg.Service)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("systemctl %s %s: %w (%s)", action, m.cfg.Service, err, string(output))
|
||||
for _, service := range services {
|
||||
cmd := exec.CommandContext(runCtx, "systemctl", action, service)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("systemctl %s %s: %w (%s)", action, service, err, string(output))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MediaSupervisor) managedServices() []string {
|
||||
/*
|
||||
Only enabled streams are service-managed. This matters for audio capture:
|
||||
the service may be installed everywhere, but a rover with capture disabled
|
||||
should not have remote media commands starting a publisher that the config
|
||||
says should stay off.
|
||||
*/
|
||||
services := []string{}
|
||||
if m.cfg.Video.Enabled && m.cfg.Video.Service != "" {
|
||||
services = append(services, m.cfg.Video.Service)
|
||||
}
|
||||
if m.cfg.AudioCapture.Enabled && m.cfg.AudioCapture.Service != "" {
|
||||
services = append(services, m.cfg.AudioCapture.Service)
|
||||
}
|
||||
if m.cfg.AudioPlayback.Enabled && m.cfg.AudioPlayback.Service != "" {
|
||||
services = append(services, m.cfg.AudioPlayback.Service)
|
||||
}
|
||||
return services
|
||||
}
|
||||
|
||||
@@ -17,16 +17,36 @@ battery:
|
||||
urgent: 1650
|
||||
maxWheelSpeed: 350
|
||||
media:
|
||||
publishUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||
audioForwardUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha-fwd,m=request&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||
publishPort: 9000
|
||||
# Default assumes camera is mounted upside down; set false for upright mounts.
|
||||
cameraInverted: true
|
||||
videoBitrate: 2000000
|
||||
manage: true
|
||||
service: video-publisher.service
|
||||
healthUrl: ""
|
||||
healthInterval: 30s
|
||||
video:
|
||||
enabled: true
|
||||
service: video-publisher.service
|
||||
publisher: pi-libcamera
|
||||
publishUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||
width: 640
|
||||
height: 480
|
||||
fps: 30
|
||||
bitrate: 2000000
|
||||
# Default assumes camera is mounted upside down; set false for upright mounts.
|
||||
inverted: true
|
||||
sensorMode: 1296:972
|
||||
audioCapture:
|
||||
enabled: false
|
||||
service: audio-only-publisher.service
|
||||
publishUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha-audio,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||
device: hw:0,0
|
||||
sampleRate: 48000
|
||||
channels: 2
|
||||
bitrate: 510000
|
||||
audioPlayback:
|
||||
enabled: true
|
||||
service: audio-forward-listener.service
|
||||
forwardUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha-fwd,m=request&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||
device: forward
|
||||
normalize: true
|
||||
cameraServo:
|
||||
enabled: false
|
||||
pin: 12
|
||||
|
||||
+10
-1
@@ -17,9 +17,18 @@ battery:
|
||||
maxWheelSpeed: 350
|
||||
media:
|
||||
manage: false
|
||||
service: mediamtx.service
|
||||
healthUrl: http://127.0.0.1:9997/v3/paths/list
|
||||
healthInterval: 30s
|
||||
video:
|
||||
enabled: true
|
||||
service: video-publisher.service
|
||||
publisher: pi-libcamera
|
||||
audioCapture:
|
||||
enabled: false
|
||||
service: audio-only-publisher.service
|
||||
audioPlayback:
|
||||
enabled: true
|
||||
service: audio-forward-listener.service
|
||||
cameraServo:
|
||||
enabled: false
|
||||
pin: 19
|
||||
|
||||
@@ -7,7 +7,7 @@ Wants=network-online.target
|
||||
Type=simple
|
||||
User=roverd
|
||||
Group=roverd
|
||||
EnvironmentFile=/var/lib/roverd/video.env
|
||||
EnvironmentFile=/var/lib/roverd/media.env
|
||||
ExecStart=/usr/local/bin/audio-forward-listener
|
||||
KillMode=control-group
|
||||
TimeoutStopSec=5
|
||||
|
||||
@@ -7,7 +7,7 @@ Wants=network-online.target
|
||||
Type=simple
|
||||
User=roverd
|
||||
Group=roverd
|
||||
EnvironmentFile=/var/lib/roverd/video.env
|
||||
EnvironmentFile=/var/lib/roverd/media.env
|
||||
ExecStart=/usr/local/bin/audio-only-publisher
|
||||
KillMode=control-group
|
||||
TimeoutStopSec=5
|
||||
|
||||
@@ -8,7 +8,7 @@ Type=simple
|
||||
User=roverd
|
||||
Group=roverd
|
||||
WorkingDirectory=/var/lib/roverd
|
||||
EnvironmentFile=/var/lib/roverd/video.env
|
||||
EnvironmentFile=/var/lib/roverd/media.env
|
||||
ExecStart=/usr/local/bin/video-publisher
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
|
||||
Reference in New Issue
Block a user