mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -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 -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+=(
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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` }];
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user