mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -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
|
||||
|
||||
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
@@ -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` }];
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user