Merge pull request #11 from legop3/laptoprover

Laptoprover
This commit is contained in:
legop3
2026-06-30 22:08:00 -04:00
committed by GitHub
24 changed files with 520 additions and 200 deletions
BIN
View File
Binary file not shown.
Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+60 -10
View File
@@ -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+=(
+68 -20
View File
@@ -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
View File
@@ -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
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
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
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+36 -15
View File
@@ -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
}
+26 -6
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
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/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>
<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">
</head>
<body>
@@ -37,7 +37,10 @@ function createAudioForwardPolicy(deps) {
function resolveForwardUrl(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);
return `srt://127.0.0.1:9000?streamid=#!::r=${encodeURIComponent(
roverId + streamSuffix,
@@ -25,12 +25,19 @@ function getRoomCameraStream(camera) {
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() {
const sources = [];
for (const rover of roverManager.getRoster()) {
const roverId = String(rover.id);
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`) });
}
}
@@ -40,6 +40,14 @@ function normalizeVideoFilter(value) {
: 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({
roverId = null,
sessionInfo = null,
@@ -58,7 +66,7 @@ export default function RoverMediaPlayer({
? state.session.roster.find((item) => String(item.id) === String(effectiveRoverId)) || null
: null,
);
const hasAudio = Boolean(rosterEntry?.media?.audioPublishUrl);
const hasAudio = hasRoverAudioCapture(rosterEntry);
const autoVideoEnabled = videoMode ? videoMode === 'whep' : true;
const autoAudioEnabled = hasAudio;
const autoEntries = useMemo(() => {
@@ -15,6 +15,12 @@ import InfoColumn from './components/InfoColumn.jsx';
import { ROTATE_MS } from './constants.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() {
const { session } = useSession();
const spectatorReady = useSpectatorMode();
@@ -53,7 +59,7 @@ export default function MiniSummaryContent() {
const audioEntries = useMemo(
() =>
driverRoster.flatMap((rover) => {
if (!rover?.id || !rover.media?.audioPublishUrl) return [];
if (!rover?.id || !hasRoverAudioCapture(rover)) return [];
const id = String(rover.id);
return [{ type: 'rover', id: `${id}-audio`, key: `${id}-audio` }];
}),