config reshape

This commit is contained in:
legop3
2026-06-30 20:17:43 -04:00
parent 2eb6c00f9c
commit 31eacea7bc
21 changed files with 520 additions and 200 deletions
+60 -10
View File
@@ -2,20 +2,70 @@
set -euo pipefail set -euo pipefail
set +H 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 if [[ ! -f "$ENV_FILE" ]]; then
echo "Environment file ${ENV_FILE} missing; cannot start audio forward listener" >&2 echo "Environment file ${ENV_FILE} missing; cannot start audio forward listener" >&2
exit 1 exit 1
fi fi
# shellcheck disable=SC1090 # Load KEY=VALUE pairs from media.env without evaluating shell syntax. The
source "$ENV_FILE" # 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}}" if [[ -r "$ENV_FILE" ]]; then
PLAYBACK_DEVICE="${AUDIO_PLAYBACK_DEVICE:-forward}" content="$(cat "$ENV_FILE")"
AUDIO_NORMALIZE_ENABLE="${AUDIO_NORMALIZE_ENABLE:-1}" elif command -v sudo >/dev/null 2>&1; then
AUDIO_NORMALIZE_FILTER="${AUDIO_NORMALIZE_FILTER:-dynaudnorm=f=75:g=15:m=10:p=0.9,alimiter=limit=0.85:level=disabled}" 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 if [[ -n "${FFMPEG_BIN:-}" ]]; then
FFMPEG_BIN_PATH="$FFMPEG_BIN" FFMPEG_BIN_PATH="$FFMPEG_BIN"
@@ -45,12 +95,12 @@ run_pipeline() {
-flags low_delay -flags low_delay
-analyzeduration 200k -analyzeduration 200k
-probesize 32k -probesize 32k
-i "${AUDIO_FORWARD_URL}" -i "${ROVERD_AUDIO_PLAYBACK_FORWARD_URL}"
-vn -vn
) )
if [[ "${AUDIO_NORMALIZE_ENABLE}" -ne 0 ]]; then if [[ "${ROVERD_AUDIO_PLAYBACK_NORMALIZE}" -ne 0 ]]; then
ffmpeg_args+=(-af "${AUDIO_NORMALIZE_FILTER}") ffmpeg_args+=(-af "${ROVERD_AUDIO_PLAYBACK_NORMALIZE_FILTER}")
fi fi
ffmpeg_args+=( ffmpeg_args+=(
+68 -20
View File
@@ -2,26 +2,74 @@
set -euo pipefail set -euo pipefail
set +H 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 if [[ ! -f "$ENV_FILE" ]]; then
echo "Environment file ${ENV_FILE} missing; cannot publish audio" >&2 echo "Environment file ${ENV_FILE} missing; cannot publish audio" >&2
exit 1 exit 1
fi fi
# shellcheck disable=SC1090 # Load KEY=VALUE pairs from media.env without evaluating shell syntax. SRT URLs
source "$ENV_FILE" # contain characters such as '&' and '#!', so sourcing this file would treat a
AUDIO_ENABLE="${AUDIO_ENABLE:-0}" # data file as code and can split a valid URL into shell control operators.
if [[ "${AUDIO_ENABLE}" -ne 1 ]]; then 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 echo "Audio capture disabled; skipping audio-only publisher" >&2
exit 0 exit 0
fi 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 # At the default 48 kHz stereo S32_LE capture format, the old 65,536-byte ALSA
# even publish the microphone audio: # buffer represented about 171 ms before ffmpeg could publish microphone audio:
# 65,536 / (48,000 samples * 2 channels * 4 bytes) = 0.1707 seconds # 65,536 / (48,000 samples * 2 channels * 4 bytes) = 0.1707 seconds
# Keep the defaults much smaller because this publisher feeds an interactive # Keep the defaults much smaller because this publisher feeds an interactive
# rover stream, where late-but-smooth audio is less useful than fresher audio. # 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 # format and so the published audio stays full-band stereo before the
# Opus encoder sees it. # Opus encoder sees it.
-f s32le -f s32le
-ar 48000 -ar "${ROVERD_AUDIO_CAPTURE_SAMPLE_RATE}"
-ac 2 -ac "${ROVERD_AUDIO_CAPTURE_CHANNELS}"
-i pipe:0 -i pipe:0
# Keep the known-required microphone boost, but remove the old # 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 # the highest practical quality browsers and MediaMTX can carry without
# trying to push raw PCM through the live path. # trying to push raw PCM through the live path.
-c:a libopus -c:a libopus
-b:a 510000 -b:a "${ROVERD_AUDIO_CAPTURE_BITRATE}"
-ar:a 48000 -ar:a "${ROVERD_AUDIO_CAPTURE_SAMPLE_RATE}"
-ac:a 2 -ac:a "${ROVERD_AUDIO_CAPTURE_CHANNELS}"
# Use the audio profile for quality. Latency is still controlled by the # Use the audio profile for quality. Latency is still controlled by the
# 20 ms Opus frame size and the low-buffering capture/publish options # 20 ms Opus frame size and the low-buffering capture/publish options
@@ -82,13 +130,13 @@ run_pipeline() {
-muxdelay 0 -muxdelay 0
-muxpreload 0 -muxpreload 0
-f mpegts -f mpegts
"${AUDIO_PUBLISH_URL}" "${ROVERD_AUDIO_CAPTURE_PUBLISH_URL}"
) )
# The Google Voice HAT microphone path is intentionally fixed instead of # The capture format comes from roverd's normalized media config. Keeping
# configurable. The previous env-driven sample rate/channel knobs made it # arecord and ffmpeg on the same env-backed values prevents the publisher
# easy for the rover config and the actual ffmpeg pipeline to drift apart, # script from quietly disagreeing with roverd.yaml about sample rate or
# while the hardware path we install is always 48 kHz stereo capture. # channel count.
# #
# The buffer and period are byte counts because arecord interprets -B/-F in # 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 # 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 # 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 # 48 kHz stereo S32_LE, which removes about 128 ms of avoidable capture
# latency compared with the old 65,536-byte buffer. # 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[@]}" | "${FFMPEG_BIN_PATH}" "${ffmpeg_args[@]}"
} }
+22 -18
View File
@@ -4,7 +4,7 @@ set -euo pipefail
# Keep history expansion off so values containing "!" are safe. # Keep history expansion off so values containing "!" are safe.
set +H 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 if [[ ! -f "$ENV_FILE" ]]; then
echo "Environment file ${ENV_FILE} missing; cannot publish" >&2 echo "Environment file ${ENV_FILE} missing; cannot publish" >&2
@@ -64,25 +64,29 @@ load_env_file() {
} }
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. if [[ "${ROVERD_VIDEO_ENABLE}" -ne 1 ]]; then
VIDEO_WIDTH="640" echo "Video publisher disabled by roverd media config; skipping" >&2
VIDEO_HEIGHT="480" exit 0
VIDEO_FPS="30" fi
VIDEO_BITRATE="${VIDEO_BITRATE:-3000000}"
VIDEO_INVERT="${VIDEO_INVERT:-1}"
VIDEO_SENSOR_MODE="${VIDEO_SENSOR_MODE:-1296:972}"
# 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=() FLIP_ARGS=()
if [[ "${VIDEO_INVERT}" -ne 0 ]]; then if [[ "${ROVERD_VIDEO_INVERT}" -ne 0 ]]; then
FLIP_ARGS=(--rotation 180) FLIP_ARGS=(--rotation 180)
fi fi
MODE_ARGS=() MODE_ARGS=()
if [[ -n "${VIDEO_SENSOR_MODE}" ]]; then if [[ -n "${ROVERD_VIDEO_SENSOR_MODE:-}" ]]; then
MODE_ARGS=(--mode "${VIDEO_SENSOR_MODE}") MODE_ARGS=(--mode "${ROVERD_VIDEO_SENSOR_MODE}")
fi fi
if [[ -n "${LIBCAMERA_BIN:-}" ]]; then if [[ -n "${LIBCAMERA_BIN:-}" ]]; then
@@ -110,11 +114,11 @@ run_pipeline() {
--inline \ --inline \
--timeout 0 \ --timeout 0 \
"${MODE_ARGS[@]}" \ "${MODE_ARGS[@]}" \
--width "${VIDEO_WIDTH}" \ --width "${ROVERD_VIDEO_WIDTH}" \
--height "${VIDEO_HEIGHT}" \ --height "${ROVERD_VIDEO_HEIGHT}" \
"${FLIP_ARGS[@]}" \ "${FLIP_ARGS[@]}" \
--framerate "${VIDEO_FPS}" \ --framerate "${ROVERD_VIDEO_FPS}" \
--bitrate "${VIDEO_BITRATE}" \ --bitrate "${ROVERD_VIDEO_BITRATE}" \
--codec h264 \ --codec h264 \
--profile baseline \ --profile baseline \
--denoise auto \ --denoise auto \
@@ -138,7 +142,7 @@ run_pipeline() {
-muxdelay 0 \ -muxdelay 0 \
-muxpreload 0 \ -muxpreload 0 \
-f mpegts \ -f mpegts \
"${PUBLISH_URL}" "${ROVERD_VIDEO_PUBLISH_URL}"
} }
while true; do while true; do
+24 -10
View File
@@ -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 install -m 0644 pi/systemd/audio-forward-listener.service /etc/systemd/system/audio-forward-listener.service
log "Installed audio-forward listener helper + systemd unit" log "Installed audio-forward listener helper + systemd unit"
install -d -o roverd -g roverd /var/lib/roverd 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. # 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 ROVERD_VIDEO_ENABLE=1
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 ROVERD_VIDEO_PUBLISHER=pi-libcamera
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 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
VIDEO_BITRATE=2000000 ROVERD_VIDEO_DEVICE=
AUDIO_ENABLE=0 ROVERD_VIDEO_WIDTH=640
AUDIO_DEVICE=hw:0,0 ROVERD_VIDEO_HEIGHT=480
AUDIO_PLAYBACK_DEVICE=forward 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 ENV
chown roverd:roverd /var/lib/roverd/video.env chown roverd:roverd /var/lib/roverd/media.env
chmod 0640 /var/lib/roverd/video.env chmod 0640 /var/lib/roverd/media.env
# Create persistent audio FIFO for capture -> publisher # Create persistent audio FIFO for capture -> publisher
FIFO_PATH="/var/lib/roverd/audio.pcm" FIFO_PATH="/var/lib/roverd/audio.pcm"
if [[ -p "$FIFO_PATH" ]]; then if [[ -p "$FIFO_PATH" ]]; then
+2 -2
View File
@@ -21,7 +21,7 @@ func main() {
if err != nil { if err != nil {
log.Fatalf("load config: %v", err) 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) log.Fatalf("prepare media env: %v", err)
} }
@@ -55,7 +55,7 @@ func main() {
adapter := roverd.NewSerialAdapter(serialPort, logger) adapter := roverd.NewSerialAdapter(serialPort, logger)
mediaSupervisor := roverd.NewMediaSupervisor(cfg.Media, cfg.Audio, logger) mediaSupervisor := roverd.NewMediaSupervisor(cfg.Media, logger)
if mediaSupervisor != nil { if mediaSupervisor != nil {
mediaSupervisor.Start(ctx) mediaSupervisor.Start(ctx)
} }
+195 -66
View File
@@ -56,13 +56,13 @@ type BatteryConfig struct {
} }
type AudioConfig struct { type AudioConfig struct {
CaptureEnabled bool `yaml:"captureEnabled" json:"captureEnabled"` // AudioConfig is only for sounds generated by roverd itself. Live capture
CaptureDevice string `yaml:"captureDevice" json:"captureDevice,omitempty"` // and playback are media-publisher concerns, so those settings live under
PlaybackDevice string `yaml:"playbackDevice" json:"playbackDevice,omitempty"` // MediaConfig where they can be written directly into media.env.
TTSEnabled bool `yaml:"ttsEnabled" json:"ttsEnabled"` TTSEnabled bool `yaml:"ttsEnabled" json:"ttsEnabled"`
DefaultEngine string `yaml:"defaultEngine" json:"defaultEngine,omitempty"` DefaultEngine string `yaml:"defaultEngine" json:"defaultEngine,omitempty"`
DefaultVoice string `yaml:"defaultVoice" json:"defaultVoice,omitempty"` DefaultVoice string `yaml:"defaultVoice" json:"defaultVoice,omitempty"`
DefaultPitch int `yaml:"defaultPitch" json:"defaultPitch,omitempty"` DefaultPitch int `yaml:"defaultPitch" json:"defaultPitch,omitempty"`
} }
type HornConfig struct { type HornConfig struct {
@@ -77,21 +77,59 @@ type HornConfig struct {
} }
type MediaConfig struct { type MediaConfig struct {
PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"` // PublishPort is shared by the derived video, microphone, and forwarded-audio
AudioPublishURL string `yaml:"audioPublishUrl" json:"audioPublishUrl,omitempty"` // SRT URLs. Keeping it at this level prevents each nested block from needing
AudioForwardURL string `yaml:"audioForwardUrl" json:"audioForwardUrl,omitempty"` // to repeat the same server port when the common MediaMTX listener is used.
PublishPort int `yaml:"publishPort" json:"-"` PublishPort int `yaml:"publishPort" json:"-"`
CameraInverted bool `yaml:"cameraInverted" json:"-"` Manage bool `yaml:"manage" json:"manage"`
Manage bool `yaml:"manage"` HealthURL string `yaml:"healthUrl" json:"healthUrl,omitempty"`
ManageAudio bool `yaml:"manageAudio"` HealthInterval Duration `yaml:"healthInterval" json:"-"`
Service string `yaml:"service"` Video VideoMediaConfig `yaml:"video" json:"video"`
AudioService string `yaml:"audioService"` AudioCapture AudioCaptureConfig `yaml:"audioCapture" json:"audioCapture"`
HealthURL string `yaml:"healthUrl"` AudioPlayback AudioPlaybackConfig `yaml:"audioPlayback" json:"audioPlayback"`
HealthInterval Duration `yaml:"healthInterval"` }
VideoWidth int `yaml:"videoWidth" json:"-"`
VideoHeight int `yaml:"videoHeight" json:"-"` type VideoMediaConfig struct {
VideoFPS int `yaml:"videoFps" json:"-"` // Publisher selects the installed publisher script/pipeline family. The
VideoBitrate int `yaml:"videoBitrate" json:"-"` // 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 { type CameraServoConfig struct {
@@ -189,9 +227,33 @@ func LoadConfig(path string) (*Config, error) {
}, },
Media: MediaConfig{ Media: MediaConfig{
PublishPort: 9000, PublishPort: 9000,
CameraInverted: true,
HealthInterval: Duration{Duration: 30 * time.Second}, 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{ CameraServo: CameraServoConfig{
Pin: 12, Pin: 12,
@@ -205,13 +267,10 @@ func LoadConfig(path string) (*Config, error) {
NudgeDegrees: 2, NudgeDegrees: 2,
}, },
Audio: AudioConfig{ Audio: AudioConfig{
CaptureEnabled: false, TTSEnabled: false,
CaptureDevice: "rovermic", DefaultEngine: "flite",
PlaybackDevice: "forward", DefaultVoice: "rms",
TTSEnabled: false, DefaultPitch: 50,
DefaultEngine: "flite",
DefaultVoice: "rms",
DefaultPitch: 50,
}, },
Horn: HornConfig{ Horn: HornConfig{
Enabled: false, Enabled: false,
@@ -283,38 +342,11 @@ func LoadConfig(path string) (*Config, error) {
if cfg.BRC.GPIOChip == "" { if cfg.BRC.GPIOChip == "" {
cfg.BRC.GPIOChip = "gpiochip0" 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 { if cfg.Media.PublishPort <= 0 {
cfg.Media.PublishPort = 9000 cfg.Media.PublishPort = 9000
} }
if cfg.Media.PublishURL == "" { if err := validateMediaConfig(&cfg.Media, cfg.ServerURL, cfg.Name); err != nil {
derived, err := derivePublishURL(cfg.ServerURL, cfg.Name, cfg.Media.PublishPort) return nil, fmt.Errorf("media: %w", err)
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 := validateServoConfig(&cfg.CameraServo); err != nil { if err := validateServoConfig(&cfg.CameraServo); err != nil {
return nil, fmt.Errorf("cameraServo: %w", err) return nil, fmt.Errorf("cameraServo: %w", err)
@@ -375,12 +407,6 @@ func clampFloat(value, min, max float64) float64 {
} }
func validateAudioConfig(cfg *AudioConfig) { 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 == "" { if cfg.DefaultEngine == "" {
cfg.DefaultEngine = "flite" 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) { func validateHornConfig(cfg *HornConfig) {
if cfg.Volume <= 0 { if cfg.Volume <= 0 {
cfg.Volume = 0.25 cfg.Volume = 0.25
+38 -37
View File
@@ -7,51 +7,52 @@ import (
"path/filepath" "path/filepath"
) )
const publisherEnvPath = "/var/lib/roverd/video.env" const publisherEnvPath = "/var/lib/roverd/media.env"
func UpdatePublisherEnv(media MediaConfig, audio AudioConfig) error { func UpdatePublisherEnv(media MediaConfig) error {
if media.PublishURL == "" { if media.Video.Enabled && media.Video.PublishURL == "" {
return fmt.Errorf("media publishUrl missing") return fmt.Errorf("media video publishUrl missing")
} }
if media.AudioPublishURL == "" && audio.CaptureEnabled { if media.AudioCapture.Enabled && media.AudioCapture.PublishURL == "" {
return fmt.Errorf("audio publishUrl missing") return fmt.Errorf("media audioCapture publishUrl missing")
} }
if media.VideoWidth < 0 || media.VideoHeight < 0 || media.VideoFPS < 0 || media.VideoBitrate <= 0 { if media.AudioPlayback.Enabled && media.AudioPlayback.ForwardURL == "" {
return fmt.Errorf("invalid media dimensions/bitrate") 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 { if err := os.MkdirAll(filepath.Dir(publisherEnvPath), 0o755); err != nil {
return err return err
} }
var buf bytes.Buffer var buf bytes.Buffer
fmt.Fprintf(&buf, "PUBLISH_URL=%s\n", media.PublishURL) /*
if audio.CaptureEnabled && media.AudioPublishURL != "" { The env file is intentionally verbose: every publisher receives concrete
fmt.Fprintf(&buf, "AUDIO_PUBLISH_URL=%s\n", media.AudioPublishURL) values instead of silently falling back to script-local defaults. That
} makes roverd.yaml the owner of media behavior while keeping the shell
if media.AudioForwardURL != "" { scripts as small pipeline launchers.
fmt.Fprintf(&buf, "AUDIO_FORWARD_URL=%s\n", media.AudioForwardURL) */
} fmt.Fprintf(&buf, "ROVERD_VIDEO_ENABLE=%d\n", boolToInt(media.Video.Enabled))
if media.VideoWidth > 0 { fmt.Fprintf(&buf, "ROVERD_VIDEO_PUBLISHER=%s\n", media.Video.Publisher)
fmt.Fprintf(&buf, "VIDEO_WIDTH=%d\n", media.VideoWidth) fmt.Fprintf(&buf, "ROVERD_VIDEO_PUBLISH_URL=%s\n", media.Video.PublishURL)
} fmt.Fprintf(&buf, "ROVERD_VIDEO_DEVICE=%s\n", media.Video.Device)
if media.VideoHeight > 0 { fmt.Fprintf(&buf, "ROVERD_VIDEO_WIDTH=%d\n", media.Video.Width)
fmt.Fprintf(&buf, "VIDEO_HEIGHT=%d\n", media.VideoHeight) fmt.Fprintf(&buf, "ROVERD_VIDEO_HEIGHT=%d\n", media.Video.Height)
} fmt.Fprintf(&buf, "ROVERD_VIDEO_FPS=%d\n", media.Video.FPS)
if media.VideoFPS > 0 { fmt.Fprintf(&buf, "ROVERD_VIDEO_BITRATE=%d\n", media.Video.Bitrate)
fmt.Fprintf(&buf, "VIDEO_FPS=%d\n", media.VideoFPS) 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, "VIDEO_BITRATE=%d\n", media.VideoBitrate) fmt.Fprintf(&buf, "ROVERD_AUDIO_CAPTURE_ENABLE=%d\n", boolToInt(media.AudioCapture.Enabled))
fmt.Fprintf(&buf, "VIDEO_INVERT=%d\n", boolToInt(media.CameraInverted)) fmt.Fprintf(&buf, "ROVERD_AUDIO_CAPTURE_PUBLISH_URL=%s\n", media.AudioCapture.PublishURL)
audioDevice := audio.CaptureDevice fmt.Fprintf(&buf, "ROVERD_AUDIO_CAPTURE_DEVICE=%s\n", media.AudioCapture.Device)
if audioDevice == "" || audioDevice == "rovermic" { fmt.Fprintf(&buf, "ROVERD_AUDIO_CAPTURE_SAMPLE_RATE=%d\n", media.AudioCapture.SampleRate)
audioDevice = "hw:0,0" 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, "AUDIO_ENABLE=%d\n", boolToInt(audio.CaptureEnabled)) fmt.Fprintf(&buf, "ROVERD_AUDIO_PLAYBACK_ENABLE=%d\n", boolToInt(media.AudioPlayback.Enabled))
fmt.Fprintf(&buf, "AUDIO_DEVICE=%s\n", audioDevice) fmt.Fprintf(&buf, "ROVERD_AUDIO_PLAYBACK_FORWARD_URL=%s\n", media.AudioPlayback.ForwardURL)
playbackDevice := audio.PlaybackDevice fmt.Fprintf(&buf, "ROVERD_AUDIO_PLAYBACK_DEVICE=%s\n", media.AudioPlayback.Device)
if playbackDevice == "" { fmt.Fprintf(&buf, "ROVERD_AUDIO_PLAYBACK_NORMALIZE=%d\n", boolToInt(media.AudioPlayback.Normalize))
playbackDevice = "forward" fmt.Fprintf(&buf, "ROVERD_AUDIO_PLAYBACK_NORMALIZE_FILTER=%s\n", media.AudioPlayback.NormalizeFilter)
}
fmt.Fprintf(&buf, "AUDIO_PLAYBACK_DEVICE=%s\n", playbackDevice)
if err := os.WriteFile(publisherEnvPath, buf.Bytes(), 0o640); err != nil { if err := os.WriteFile(publisherEnvPath, buf.Bytes(), 0o640); err != nil {
return err return err
} }
+36 -15
View File
@@ -13,17 +13,16 @@ import (
type MediaSupervisor struct { type MediaSupervisor struct {
cfg MediaConfig cfg MediaConfig
audio AudioConfig
logger *log.Logger logger *log.Logger
client *http.Client client *http.Client
checkInterval time.Duration checkInterval time.Duration
} }
func NewMediaSupervisor(cfg MediaConfig, audio AudioConfig, logger *log.Logger) *MediaSupervisor { func NewMediaSupervisor(cfg MediaConfig, logger *log.Logger) *MediaSupervisor {
if err := UpdatePublisherEnv(cfg, audio); err != nil { if err := UpdatePublisherEnv(cfg); err != nil {
logger.Printf("media supervisor: update env failed: %v", err) logger.Printf("media supervisor: update env failed: %v", err)
} }
if !cfg.Manage || cfg.Service == "" { if !cfg.Manage {
return nil return nil
} }
interval := cfg.HealthInterval.Duration interval := cfg.HealthInterval.Duration
@@ -36,7 +35,6 @@ func NewMediaSupervisor(cfg MediaConfig, audio AudioConfig, logger *log.Logger)
} }
return &MediaSupervisor{ return &MediaSupervisor{
cfg: cfg, cfg: cfg,
audio: audio,
logger: logger, logger: logger,
client: client, client: client,
checkInterval: interval, checkInterval: interval,
@@ -47,7 +45,7 @@ func (m *MediaSupervisor) Start(ctx context.Context) {
if m == nil { if m == nil {
return 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) m.logger.Printf("media supervisor: update env failed: %v", err)
} }
if m.cfg.HealthURL == "" || m.client == nil { if m.cfg.HealthURL == "" || m.client == nil {
@@ -78,7 +76,7 @@ func (m *MediaSupervisor) HandleAction(ctx context.Context, action string) error
if m == nil { if m == nil {
return errors.New("media supervisor disabled") return errors.New("media supervisor disabled")
} }
if err := UpdatePublisherEnv(m.cfg, m.audio); err != nil { if err := UpdatePublisherEnv(m.cfg); err != nil {
return err return err
} }
switch action { switch action {
@@ -96,9 +94,9 @@ func (m *MediaSupervisor) checkAndRepair() error {
if m.checkHealth(ctx) { if m.checkHealth(ctx) {
return nil 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 { 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 return nil
} }
@@ -127,16 +125,39 @@ func (m *MediaSupervisor) checkHealth(ctx context.Context) bool {
} }
func (m *MediaSupervisor) runSystemctl(ctx context.Context, action string) error { func (m *MediaSupervisor) runSystemctl(ctx context.Context, action string) error {
if m.cfg.Service == "" { services := m.managedServices()
return errors.New("no media service configured") if len(services) == 0 {
return errors.New("no media services configured")
} }
runCtx, cancel := context.WithTimeout(ctx, 15*time.Second) runCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel() defer cancel()
cmd := exec.CommandContext(runCtx, "systemctl", action, m.cfg.Service) for _, service := range services {
output, err := cmd.CombinedOutput() cmd := exec.CommandContext(runCtx, "systemctl", action, service)
if err != nil { output, err := cmd.CombinedOutput()
return fmt.Errorf("systemctl %s %s: %w (%s)", action, m.cfg.Service, err, string(output)) if err != nil {
return fmt.Errorf("systemctl %s %s: %w (%s)", action, service, err, string(output))
}
} }
return nil 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
}
+26 -6
View File
@@ -17,16 +17,36 @@ battery:
urgent: 1650 urgent: 1650
maxWheelSpeed: 350 maxWheelSpeed: 350
media: 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 publishPort: 9000
# Default assumes camera is mounted upside down; set false for upright mounts.
cameraInverted: true
videoBitrate: 2000000
manage: true manage: true
service: video-publisher.service
healthUrl: "" healthUrl: ""
healthInterval: 30s 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: cameraServo:
enabled: false enabled: false
pin: 12 pin: 12
+10 -1
View File
@@ -17,9 +17,18 @@ battery:
maxWheelSpeed: 350 maxWheelSpeed: 350
media: media:
manage: false manage: false
service: mediamtx.service
healthUrl: http://127.0.0.1:9997/v3/paths/list healthUrl: http://127.0.0.1:9997/v3/paths/list
healthInterval: 30s 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: cameraServo:
enabled: false enabled: false
pin: 19 pin: 19
+1 -1
View File
@@ -7,7 +7,7 @@ Wants=network-online.target
Type=simple Type=simple
User=roverd User=roverd
Group=roverd Group=roverd
EnvironmentFile=/var/lib/roverd/video.env EnvironmentFile=/var/lib/roverd/media.env
ExecStart=/usr/local/bin/audio-forward-listener ExecStart=/usr/local/bin/audio-forward-listener
KillMode=control-group KillMode=control-group
TimeoutStopSec=5 TimeoutStopSec=5
+1 -1
View File
@@ -7,7 +7,7 @@ Wants=network-online.target
Type=simple Type=simple
User=roverd User=roverd
Group=roverd Group=roverd
EnvironmentFile=/var/lib/roverd/video.env EnvironmentFile=/var/lib/roverd/media.env
ExecStart=/usr/local/bin/audio-only-publisher ExecStart=/usr/local/bin/audio-only-publisher
KillMode=control-group KillMode=control-group
TimeoutStopSec=5 TimeoutStopSec=5
+1 -1
View File
@@ -8,7 +8,7 @@ Type=simple
User=roverd User=roverd
Group=roverd Group=roverd
WorkingDirectory=/var/lib/roverd WorkingDirectory=/var/lib/roverd
EnvironmentFile=/var/lib/roverd/video.env EnvironmentFile=/var/lib/roverd/media.env
ExecStart=/usr/local/bin/video-publisher ExecStart=/usr/local/bin/video-publisher
Restart=always Restart=always
RestartSec=2 RestartSec=2
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -78,7 +78,7 @@
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script> <script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script> <script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
<title>Roomba Rover</title> <title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-yGP4eLx4.js"></script> <script type="module" crossorigin src="/assets/index-DRqsCa1W.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-JqEX_oga.css"> <link rel="stylesheet" crossorigin href="/assets/index-JqEX_oga.css">
</head> </head>
<body> <body>
@@ -37,7 +37,10 @@ function createAudioForwardPolicy(deps) {
function resolveForwardUrl(roverId) { function resolveForwardUrl(roverId) {
const record = roverManager.rovers.get(roverId); const record = roverManager.rovers.get(roverId);
const configured = record?.meta?.media?.audioForwardUrl; // Rovers listen to the playback stream with a request/read URL. The VIP
// upload path needs to publish into that same stream, so the configured
// nested playback URL is converted to publish mode below.
const configured = record?.meta?.media?.audioPlayback?.forwardUrl;
if (configured) return forcePublishStreamMode(configured); if (configured) return forcePublishStreamMode(configured);
return `srt://127.0.0.1:9000?streamid=#!::r=${encodeURIComponent( return `srt://127.0.0.1:9000?streamid=#!::r=${encodeURIComponent(
roverId + streamSuffix, roverId + streamSuffix,
@@ -25,12 +25,19 @@ function getRoomCameraStream(camera) {
return null; return null;
} }
function hasRoverAudioCapture(rover) {
// The replay worker only records a rover microphone stream when roverd says
// capture is both enabled and publishable. That mirrors the media publisher
// contract instead of guessing from stream naming conventions alone.
return Boolean(rover?.media?.audioCapture?.enabled && rover?.media?.audioCapture?.publishUrl);
}
function listDesiredSources() { function listDesiredSources() {
const sources = []; const sources = [];
for (const rover of roverManager.getRoster()) { for (const rover of roverManager.getRoster()) {
const roverId = String(rover.id); const roverId = String(rover.id);
sources.push({ id: roverId, sourceType: 'rover', kind: 'video', label: rover.name || roverId, inputUrl: toSrtReadPath(roverId) }); sources.push({ id: roverId, sourceType: 'rover', kind: 'video', label: rover.name || roverId, inputUrl: toSrtReadPath(roverId) });
if (rover?.media?.audioPublishUrl) { if (hasRoverAudioCapture(rover)) {
sources.push({ id: `${roverId}-audio`, sourceType: 'rover', roverId, kind: 'audio', label: `${rover.name || roverId} audio`, inputUrl: toSrtReadPath(`${roverId}-audio`) }); sources.push({ id: `${roverId}-audio`, sourceType: 'rover', roverId, kind: 'audio', label: `${rover.name || roverId} audio`, inputUrl: toSrtReadPath(`${roverId}-audio`) });
} }
} }
@@ -40,6 +40,14 @@ function normalizeVideoFilter(value) {
: VIDEO_SETTINGS_DEFAULTS.colorFilter; : VIDEO_SETTINGS_DEFAULTS.colorFilter;
} }
function hasRoverAudioCapture(rover) {
// The browser asks for the separate "<rover>-audio" WHEP source only when
// roverd's media config says the microphone publisher is enabled and has a
// concrete publish URL. That keeps UI media setup aligned with the daemon
// config instead of probing for streams that should not exist.
return Boolean(rover?.media?.audioCapture?.enabled && rover?.media?.audioCapture?.publishUrl);
}
export default function RoverMediaPlayer({ export default function RoverMediaPlayer({
roverId = null, roverId = null,
sessionInfo = null, sessionInfo = null,
@@ -58,7 +66,7 @@ export default function RoverMediaPlayer({
? state.session.roster.find((item) => String(item.id) === String(effectiveRoverId)) || null ? state.session.roster.find((item) => String(item.id) === String(effectiveRoverId)) || null
: null, : null,
); );
const hasAudio = Boolean(rosterEntry?.media?.audioPublishUrl); const hasAudio = hasRoverAudioCapture(rosterEntry);
const autoVideoEnabled = videoMode ? videoMode === 'whep' : true; const autoVideoEnabled = videoMode ? videoMode === 'whep' : true;
const autoAudioEnabled = hasAudio; const autoAudioEnabled = hasAudio;
const autoEntries = useMemo(() => { const autoEntries = useMemo(() => {
@@ -15,6 +15,12 @@ import InfoColumn from './components/InfoColumn.jsx';
import { ROTATE_MS } from './constants.js'; import { ROTATE_MS } from './constants.js';
import { formatDriverLabel } from './utils.js'; import { formatDriverLabel } from './utils.js';
function hasRoverAudioCapture(rover) {
// Mini uses the same media contract as the full rover player: audio exists
// only when the nested microphone publisher block is enabled and publishable.
return Boolean(rover?.media?.audioCapture?.enabled && rover?.media?.audioCapture?.publishUrl);
}
export default function MiniSummaryContent() { export default function MiniSummaryContent() {
const { session } = useSession(); const { session } = useSession();
const spectatorReady = useSpectatorMode(); const spectatorReady = useSpectatorMode();
@@ -53,7 +59,7 @@ export default function MiniSummaryContent() {
const audioEntries = useMemo( const audioEntries = useMemo(
() => () =>
driverRoster.flatMap((rover) => { driverRoster.flatMap((rover) => {
if (!rover?.id || !rover.media?.audioPublishUrl) return []; if (!rover?.id || !hasRoverAudioCapture(rover)) return [];
const id = String(rover.id); const id = String(rover.id);
return [{ type: 'rover', id: `${id}-audio`, key: `${id}-audio` }]; return [{ type: 'rover', id: `${id}-audio`, key: `${id}-audio` }];
}), }),