mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
things
This commit is contained in:
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Executable
+64
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
@@ -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"
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user