This commit is contained in:
legop3
2026-03-15 22:43:05 -04:00
parent 2ea222355f
commit 95972af558
20 changed files with 552 additions and 134 deletions
BIN
View File
Binary file not shown.
Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
set -euo pipefail
set +H
ENV_FILE="${VIDEO_ENV_FILE:-/var/lib/roverd/video.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"
: "${AUDIO_FORWARD_URL:?AUDIO_FORWARD_URL not set in ${ENV_FILE}}"
PLAYBACK_DEVICE="${AUDIO_PLAYBACK_DEVICE:-default}"
if [[ -n "${FFMPEG_BIN:-}" ]]; then
FFMPEG_BIN_PATH="$FFMPEG_BIN"
elif command -v ffmpeg >/dev/null 2>&1; then
FFMPEG_BIN_PATH="$(command -v ffmpeg)"
else
echo "ffmpeg not found; install it via apt install ffmpeg." >&2
exit 1
fi
if command -v aplay >/dev/null 2>&1; then
APLAY_BIN_PATH="$(command -v aplay)"
else
echo "aplay not found; install it via apt install alsa-utils." >&2
exit 1
fi
run_pipeline() {
"${FFMPEG_BIN_PATH}" \
-hide_banner \
-loglevel warning \
-fflags nobuffer \
-flags low_delay \
-analyzeduration 0 \
-probesize 32 \
-i "${AUDIO_FORWARD_URL}" \
-vn \
-ac 1 \
-ar 16000 \
-f s16le \
pipe:1 \
| "${APLAY_BIN_PATH}" \
-q \
-D "${PLAYBACK_DEVICE}" \
-f S16_LE \
-r 16000 \
-c 1
}
trap 'kill 0 2>/dev/null' EXIT INT TERM
while true; do
if run_pipeline; then
exit 0
fi
echo "Audio forward listener exited ffmpeg=${PIPESTATUS[0]} aplay=${PIPESTATUS[1]}, restarting in 2s..." >&2
sleep 2
done
+12 -4
View File
@@ -20,8 +20,8 @@ Options:
The script must run from the repository root and as root (sudo). It will:
* create system users/groups if needed
* install /usr/local/bin/roverd and /etc/roverd.yaml
* install /usr/local/bin/video-publisher and its systemd unit
* enable roverd.service and video-publisher.service
* install /usr/local/bin/video/audio helpers and systemd units
* enable roverd.service and media publisher/listener services
USAGE
}
@@ -210,14 +210,20 @@ log "Installed video-publisher systemd unit"
install -D -o root -g root -m 0755 pi/bin/audio-only-publisher.sh /usr/local/bin/audio-only-publisher
install -m 0644 pi/systemd/audio-only-publisher.service /etc/systemd/system/audio-only-publisher.service
log "Installed audio-only publisher helper + systemd unit"
# Install audio-forward listener assets
install -D -o root -g root -m 0755 pi/bin/audio-forward-listener.sh /usr/local/bin/audio-forward-listener
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'
# 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=read&latency=10&mode=caller&transtype=live&pkt_size=1316
VIDEO_BITRATE=2000000
AUDIO_ENABLE=0
AUDIO_DEVICE=hw:0,0
AUDIO_PLAYBACK_DEVICE=default
AUDIO_RATE=48000
AUDIO_CHANNELS=2
ENV
@@ -244,13 +250,15 @@ systemctl daemon-reload
systemctl enable roverd.service
systemctl enable video-publisher.service
systemctl enable audio-only-publisher.service
systemctl enable audio-forward-listener.service
if [[ $CONFIG_EXISTS -eq 1 ]]; then
systemctl restart roverd.service
systemctl restart video-publisher.service
systemctl restart audio-only-publisher.service
log "Restarted roverd + video/audio publishers"
systemctl restart audio-forward-listener.service
log "Restarted roverd + media publishers/listener"
else
log "Skipped auto-start because config is the sample; edit $CONFIG_DEST then run: sudo systemctl restart roverd video-publisher audio-only-publisher"
log "Skipped auto-start because config is the sample; edit $CONFIG_DEST then run: sudo systemctl restart roverd video-publisher audio-only-publisher audio-forward-listener"
fi
log "Install complete"
+25 -1
View File
@@ -56,6 +56,7 @@ 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"`
SampleRate int `yaml:"sampleRate" json:"sampleRate,omitempty"`
Channels int `yaml:"channels" json:"channels,omitempty"`
Bitrate int `yaml:"bitrate" json:"bitrate,omitempty"`
@@ -79,6 +80,7 @@ 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:"-"`
Manage bool `yaml:"manage"`
ManageAudio bool `yaml:"manageAudio"`
@@ -170,6 +172,7 @@ func LoadConfig(path string) (*Config, error) {
Audio: AudioConfig{
CaptureEnabled: false,
CaptureDevice: "rovermic",
PlaybackDevice: "default",
SampleRate: 48000,
Channels: 2,
Bitrate: 24000,
@@ -245,6 +248,13 @@ func LoadConfig(path string) (*Config, error) {
}
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 {
return nil, fmt.Errorf("cameraServo: %w", err)
}
@@ -304,6 +314,9 @@ func validateAudioConfig(cfg *AudioConfig) {
if cfg.CaptureEnabled && cfg.CaptureDevice == "" {
cfg.CaptureDevice = "hw:0,0"
}
if cfg.PlaybackDevice == "" {
cfg.PlaybackDevice = "default"
}
if cfg.SampleRate <= 0 {
cfg.SampleRate = 48000
}
@@ -369,9 +382,20 @@ func validateAutoSideBrushConfig(cfg *AutoSideBrushConfig) {
}
func derivePublishURL(serverURL, streamName string, port int) (string, error) {
return deriveSRTURL(serverURL, streamName, port, "publish")
}
func deriveReadURL(serverURL, streamName string, port int) (string, error) {
return deriveSRTURL(serverURL, streamName, port, "read")
}
func deriveSRTURL(serverURL, streamName string, port int, mode string) (string, error) {
if streamName == "" {
return "", errors.New("missing stream name for publishUrl")
}
if mode == "" {
mode = "publish"
}
parsed, err := url.Parse(serverURL)
if err != nil {
return "", err
@@ -384,5 +408,5 @@ func derivePublishURL(serverURL, streamName string, port int) (string, error) {
port = 9000
}
escaped := url.PathEscape(streamName)
return fmt.Sprintf("srt://%s:%d?streamid=#!::r=%s,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316", host, port, escaped), nil
return fmt.Sprintf("srt://%s:%d?streamid=#!::r=%s,m=%s&latency=10&mode=caller&transtype=live&pkt_size=1316", host, port, escaped, mode), nil
}
+8
View File
@@ -27,6 +27,9 @@ func UpdatePublisherEnv(media MediaConfig, audio AudioConfig) error {
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)
}
@@ -49,6 +52,11 @@ func UpdatePublisherEnv(media MediaConfig, audio AudioConfig) error {
}
fmt.Fprintf(&buf, "AUDIO_ENABLE=%d\n", boolToInt(audio.CaptureEnabled))
fmt.Fprintf(&buf, "AUDIO_DEVICE=%s\n", audioDevice)
playbackDevice := audio.PlaybackDevice
if playbackDevice == "" {
playbackDevice = "default"
}
fmt.Fprintf(&buf, "AUDIO_PLAYBACK_DEVICE=%s\n", playbackDevice)
fmt.Fprintf(&buf, "AUDIO_RATE=%d\n", audio.SampleRate)
fmt.Fprintf(&buf, "AUDIO_CHANNELS=%d\n", audio.Channels)
if err := os.WriteFile(publisherEnvPath, buf.Bytes(), 0o640); err != nil {
+2
View File
@@ -16,6 +16,7 @@ battery:
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=read&latency=10&mode=caller&transtype=live&pkt_size=1316
publishPort: 9000
videoBitrate: 2000000
manage: true
@@ -38,6 +39,7 @@ cameraServo:
audio:
captureEnabled: false
captureDevice: hw:0,0
playbackDevice: default
sampleRate: 48000
channels: 2
bitrate: 24000
+18
View File
@@ -0,0 +1,18 @@
[Unit]
Description=Rover Audio Forward Listener (SRT -> ALSA)
After=network-online.target roverd.service
Wants=network-online.target
[Service]
Type=simple
User=roverd
Group=audio
EnvironmentFile=/var/lib/roverd/video.env
ExecStart=/usr/local/bin/audio-forward-listener
KillMode=control-group
TimeoutStopSec=5
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
Binary file not shown.
+9
View File
@@ -19,6 +19,15 @@ media:
# Example: http://192.168.0.86:8889/video
whepBaseUrl: "http://192.168.0.86:8889/video"
audioForward:
enabled: true
# Optional override; defaults to "ffmpeg"
ffmpegBin: "ffmpeg"
# Optional test file path for admin-only first-pass playback
testAudioPath: "server/assets/test-audio.mp3"
# Optional stream suffix for fallback URL generation
streamSuffix: "-fwd"
homeAssistant:
url: "http://homeassistant.local:8123"
token: "REPLACE_WITH_LONG_LIVED_TOKEN"
+1
View File
@@ -30,6 +30,7 @@ require('./src/services/embedHttpService');
require('./src/services/logStreamService');
require('./src/services/adminLogService');
require('./src/services/homeAssistantService');
require('./src/services/audioForwardService');
require('./src/services/sessionService');
require('./src/services/batteryManager');
require('./src/services/replaySocketService');
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
+2 -2
View File
@@ -11,8 +11,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-DlEkWSx3.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dx4QsRNa.css">
<script type="module" crossorigin src="/assets/index-5J3deGq0.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-WqYsCIRI.css">
</head>
<body>
<div id="root"></div>
+223
View File
@@ -0,0 +1,223 @@
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
const EventEmitter = require('events');
const io = require('../globals/io');
const logger = require('../globals/logger').child('audioForwardService');
const { loadConfig } = require('../helpers/configLoader');
const roverManager = require('./roverManager');
const { isAdmin } = require('./roleService');
const audioForwardEvents = new EventEmitter();
const config = loadConfig();
const audioForwardConfig = config.audioForward || {};
const serviceEnabled = audioForwardConfig.enabled !== false;
const ffmpegBin = audioForwardConfig.ffmpegBin || 'ffmpeg';
const streamSuffix = typeof audioForwardConfig.streamSuffix === 'string' ? audioForwardConfig.streamSuffix : '-fwd';
const repoRoot = path.resolve(__dirname, '..', '..', '..');
const defaultAudioPath = path.join(__dirname, '..', '..', 'assets', 'test-audio.mp3');
const configuredAudioPath = audioForwardConfig.testAudioPath;
const testAudioPath =
configuredAudioPath && path.isAbsolute(configuredAudioPath)
? configuredAudioPath
: configuredAudioPath
? path.resolve(repoRoot, configuredAudioPath)
: defaultAudioPath;
const processes = new Map(); // roverId -> ChildProcess
const processErrors = new Map(); // roverId -> last stderr text
const states = new Map(); // roverId -> { state, error, startedAt, updatedAt }
const stopping = new Set();
function publishStateChange(roverId) {
audioForwardEvents.emit('change', { roverId, state: states.get(roverId) || null });
}
function setState(roverId, next) {
const prev = states.get(roverId) || {};
const merged = {
state: next.state || prev.state || 'idle',
error: Object.prototype.hasOwnProperty.call(next, 'error') ? next.error : prev.error || null,
startedAt: Object.prototype.hasOwnProperty.call(next, 'startedAt') ? next.startedAt : prev.startedAt || null,
updatedAt: Date.now(),
};
states.set(roverId, merged);
publishStateChange(roverId);
}
function getAudioForwardState() {
const payload = {};
states.forEach((entry, roverId) => {
payload[roverId] = { ...entry };
});
return payload;
}
function ensureReady() {
if (!serviceEnabled) {
throw new Error('Audio forward disabled');
}
if (!fs.existsSync(testAudioPath)) {
throw new Error(`Test audio missing: ${testAudioPath}`);
}
}
function resolveForwardUrl(roverId) {
const record = roverManager.rovers.get(roverId);
const configured = record?.meta?.media?.audioForwardUrl;
if (configured) {
return configured;
}
const fallback = `srt://127.0.0.1:9000?streamid=#!::r=${encodeURIComponent(roverId + streamSuffix)},m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316`;
return fallback;
}
function buildFfmpegArgs(outputUrl) {
return [
'-hide_banner',
'-loglevel',
'warning',
'-stream_loop',
'-1',
'-re',
'-i',
testAudioPath,
'-vn',
'-af',
'aresample=16000,pan=mono|c0=0.5*FL+0.5*FR,volume=12dB',
'-c:a',
'libopus',
'-b:a',
'24000',
'-ar:a',
'16000',
'-ac:a',
'1',
'-application',
'lowdelay',
'-frame_duration',
'20',
'-compression_level',
'0',
'-f',
'mpegts',
outputUrl,
];
}
function stopPlayback(roverId, options = {}) {
const proc = processes.get(roverId);
if (!proc) {
setState(roverId, { state: 'idle', error: null, startedAt: null });
return;
}
stopping.add(roverId);
setState(roverId, { state: 'stopping', error: null });
proc.kill('SIGTERM');
setTimeout(() => {
const active = processes.get(roverId);
if (active && active.pid === proc.pid) {
active.kill('SIGKILL');
}
}, options.killAfterMs || 2000);
}
function playTestAudio(roverId) {
if (!roverId) {
throw new Error('roverId required');
}
const record = roverManager.rovers.get(roverId);
if (!record || !record.ws) {
throw new Error('Rover offline');
}
ensureReady();
stopPlayback(roverId, { killAfterMs: 1000 });
const outputUrl = resolveForwardUrl(roverId);
const args = buildFfmpegArgs(outputUrl);
const proc = spawn(ffmpegBin, args, { stdio: ['ignore', 'ignore', 'pipe'] });
processes.set(roverId, proc);
processErrors.set(roverId, '');
setState(roverId, { state: 'playing', error: null, startedAt: Date.now() });
proc.stderr.on('data', (chunk) => {
const text = String(chunk || '').trim();
if (!text) return;
processErrors.set(roverId, text);
});
proc.on('error', (err) => {
const message = err?.message || 'ffmpeg spawn failed';
logger.warn('audio forward process error', { roverId, message });
if (processes.get(roverId)?.pid === proc.pid) {
processes.delete(roverId);
stopping.delete(roverId);
setState(roverId, { state: 'error', error: message, startedAt: null });
}
});
proc.on('exit', (code, signal) => {
if (processes.get(roverId)?.pid === proc.pid) {
processes.delete(roverId);
}
if (stopping.has(roverId)) {
stopping.delete(roverId);
setState(roverId, { state: 'idle', error: null, startedAt: null });
return;
}
const stderr = processErrors.get(roverId) || null;
const message = stderr || `ffmpeg exited code=${code} signal=${signal || 'none'}`;
setState(roverId, { state: 'error', error: message, startedAt: null });
logger.warn('audio forward exited unexpectedly', { roverId, code, signal, message });
});
logger.info('Started test audio playback', { roverId, outputUrl, testAudioPath });
}
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
if (!roverId || action !== 'removed') return;
stopPlayback(roverId);
});
io.on('connection', (socket) => {
socket.on('audio:testPlay', ({ roverId } = {}, cb = () => {}) => {
try {
if (!isAdmin(socket)) {
throw new Error('Not authorized');
}
playTestAudio(String(roverId || '').trim());
cb({ success: true, roverId });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('audio:testStop', ({ roverId } = {}, cb = () => {}) => {
try {
if (!isAdmin(socket)) {
throw new Error('Not authorized');
}
const normalized = String(roverId || '').trim();
if (!normalized) {
throw new Error('roverId required');
}
stopPlayback(normalized);
cb({ success: true, roverId: normalized });
} catch (err) {
cb({ error: err.message });
}
});
});
module.exports = {
getAudioForwardState,
audioForwardEvents,
playTestAudio,
stopPlayback,
};
+6
View File
@@ -22,6 +22,7 @@ const { getCommunityGoal } = require('./communityGoalService');
const { getAdminReason } = require('./adminReasonService');
const { subscribe } = require('./eventBus');
const { getSocketIp, isLocalNetwork } = require('../helpers/ipResolver');
const { getAudioForwardState, audioForwardEvents } = require('./audioForwardService');
const config = loadConfig();
const discordInvite = config.discord?.invite || null;
@@ -91,6 +92,7 @@ function buildSession(socket) {
identity: getIdentitySummary(socket),
verification: getVerificationStateForSocket(socket),
isVerified: Boolean(socket?.data?.isVerified),
audioForward: getAudioForwardState(),
};
}
@@ -258,6 +260,10 @@ subscribe('adminReason.updated', () => {
syncAll();
});
audioForwardEvents.on('change', () => {
syncAll();
});
// sync all sockets 20 seconds
setInterval(() => {
logger.info('Periodic session sync for all clients');
+52 -1
View File
@@ -20,6 +20,8 @@ export default function AdminPanel() {
setAdminReason,
rebootRover,
rebootServer,
playTestAudio,
stopTestAudio,
llmControl,
adminLogs,
llmCommentaryState,
@@ -27,6 +29,7 @@ export default function AdminPanel() {
const roster = useMemo(() => session?.roster ?? [], [session?.roster]);
const [lockStates, setLockStates] = useState({});
const [rebootStates, setRebootStates] = useState({});
const [audioStates, setAudioStates] = useState({});
const [serverRebooting, setServerRebooting] = useState(false);
const [clearingLlmHistory, setClearingLlmHistory] = useState(false);
const health = session?.health || null;
@@ -84,6 +87,30 @@ export default function AdminPanel() {
}
};
const handlePlayTestAudio = async (roverId) => {
if (!roverId) return;
setAudioStates((prev) => ({ ...prev, [roverId]: true }));
try {
await playTestAudio(roverId);
} catch (err) {
alert(err.message);
} finally {
setAudioStates((prev) => ({ ...prev, [roverId]: false }));
}
};
const handleStopTestAudio = async (roverId) => {
if (!roverId) return;
setAudioStates((prev) => ({ ...prev, [roverId]: true }));
try {
await stopTestAudio(roverId);
} catch (err) {
alert(err.message);
} finally {
setAudioStates((prev) => ({ ...prev, [roverId]: false }));
}
};
const handleServerReboot = async () => {
const ok = window.confirm('Reboot the server host now? This will disconnect all users.');
if (!ok) return;
@@ -232,7 +259,7 @@ export default function AdminPanel() {
<RoverRoster
roster={roster}
renderActions={(rover) => (
<div className="flex flex-wrap gap-0.5 text-xs">
<div className="flex flex-wrap items-center gap-0.5 text-xs">
<button
type="button"
onClick={() => handleLockToggle(rover.id, !lockMap[rover.id])}
@@ -251,6 +278,30 @@ export default function AdminPanel() {
>
{rebootStates[rover.id] ? 'Rebooting...' : 'Reboot'}
</button>
<button
type="button"
onClick={() => handlePlayTestAudio(rover.id)}
disabled={Boolean(audioStates[rover.id])}
className="button-dark disabled:cursor-not-allowed disabled:opacity-60"
>
Play Test Audio
</button>
<button
type="button"
onClick={() => handleStopTestAudio(rover.id)}
disabled={Boolean(audioStates[rover.id])}
className="button-dark disabled:cursor-not-allowed disabled:opacity-60"
>
Stop Test Audio
</button>
<span className="surface-muted">
audio: {session?.audioForward?.[rover.id]?.state || 'idle'}
</span>
{session?.audioForward?.[rover.id]?.error ? (
<span className="surface-muted text-rose-300">
{String(session.audioForward[rover.id].error)}
</span>
) : null}
</div>
)}
/>
+4
View File
@@ -26,6 +26,8 @@ const SessionContext = createContext({
setAdminReason: async () => {},
rebootRover: async () => {},
rebootServer: async () => {},
playTestAudio: async () => {},
stopTestAudio: async () => {},
llmControl: async () => {},
});
@@ -141,6 +143,8 @@ export function SessionProvider({ children }) {
rebootRover: (roverId) =>
emitWithAck('command', { roverId, type: 'reboot', data: { reboot: {} } }),
rebootServer: () => emitWithAck('server:reboot'),
playTestAudio: (roverId) => emitWithAck('audio:testPlay', { roverId }),
stopTestAudio: (roverId) => emitWithAck('audio:testStop', { roverId }),
llmControl: (action, controls = {}) =>
emitWithAck('llm:control', { controls: { action, ...controls } }),
pushAlert: (alert) =>