mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
the removal...
This commit is contained in:
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -1,75 +0,0 @@
|
||||
#!/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:-forward}"
|
||||
|
||||
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
|
||||
|
||||
LAST_FFMPEG_STATUS="unknown"
|
||||
LAST_APLAY_STATUS="unknown"
|
||||
|
||||
run_pipeline() {
|
||||
set +e
|
||||
"${FFMPEG_BIN_PATH}" \
|
||||
-hide_banner \
|
||||
-loglevel warning \
|
||||
-fflags nobuffer \
|
||||
-flags low_delay \
|
||||
-analyzeduration 200k \
|
||||
-probesize 32k \
|
||||
-i "${AUDIO_FORWARD_URL}" \
|
||||
-vn \
|
||||
-ac 1 \
|
||||
-ar 16000 \
|
||||
-f s16le \
|
||||
pipe:1 \
|
||||
| "${APLAY_BIN_PATH}" \
|
||||
-q \
|
||||
-D "${PLAYBACK_DEVICE}" \
|
||||
-t raw \
|
||||
-f S16_LE \
|
||||
-r 16000 \
|
||||
-c 1
|
||||
local rc=$?
|
||||
local -a statuses=("${PIPESTATUS[@]}")
|
||||
LAST_FFMPEG_STATUS="${statuses[0]:-unknown}"
|
||||
LAST_APLAY_STATUS="${statuses[1]:-unknown}"
|
||||
set -e
|
||||
return "${rc}"
|
||||
}
|
||||
|
||||
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=${LAST_FFMPEG_STATUS:-unknown} aplay=${LAST_APLAY_STATUS:-unknown}, restarting in 2s..." >&2
|
||||
sleep 2
|
||||
done
|
||||
@@ -210,16 +210,11 @@ 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=request&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||
VIDEO_BITRATE=2000000
|
||||
AUDIO_ENABLE=0
|
||||
AUDIO_DEVICE=hw:0,0
|
||||
@@ -250,15 +245,13 @@ 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
|
||||
systemctl restart audio-forward-listener.service
|
||||
log "Restarted roverd + media publishers/listener"
|
||||
log "Restarted roverd + media publishers"
|
||||
else
|
||||
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"
|
||||
log "Skipped auto-start because config is the sample; edit $CONFIG_DEST then run: sudo systemctl restart roverd video-publisher audio-only-publisher"
|
||||
fi
|
||||
|
||||
log "Install complete"
|
||||
|
||||
@@ -80,7 +80,6 @@ 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"`
|
||||
@@ -248,13 +247,6 @@ 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)
|
||||
}
|
||||
@@ -385,10 +377,6 @@ 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, "request")
|
||||
}
|
||||
|
||||
func deriveSRTURL(serverURL, streamName string, port int, mode string) (string, error) {
|
||||
if streamName == "" {
|
||||
return "", errors.New("missing stream name for publishUrl")
|
||||
|
||||
@@ -27,9 +27,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ 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=request&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||
publishPort: 9000
|
||||
videoBitrate: 2000000
|
||||
manage: true
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
[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
|
||||
@@ -19,20 +19,6 @@ 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 stream suffix for fallback URL generation
|
||||
streamSuffix: "-fwd"
|
||||
# Browser mic ingress path suffix (WHIP publish target)
|
||||
micSuffix: "-mic"
|
||||
# Client should prefer WHIP and fall back to socket chunk streaming
|
||||
micDefaultTransport: "whip"
|
||||
micSocketFallback: true
|
||||
# Max upload payload accepted via VIP forward upload
|
||||
maxUploadBytes: 8388608
|
||||
|
||||
audioLevels:
|
||||
# Gains are multipliers (0.0 - 4.0) applied globally to all rovers.
|
||||
hornGain: 1.0
|
||||
|
||||
@@ -31,7 +31,6 @@ require('./src/services/logStreamService');
|
||||
require('./src/services/adminLogService');
|
||||
require('./src/services/homeAssistantService');
|
||||
require('./src/services/audioLevelsService');
|
||||
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-E6gJe5Xr.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-WqYsCIRI.css">
|
||||
<script type="module" crossorigin src="/assets/index-BYvRyw7e.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Dx4QsRNa.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,861 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawn, spawnSync } = 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 { isVerified } = require('./verificationService');
|
||||
const turnService = require('./turnService');
|
||||
const videoSessions = require('./videoSessions');
|
||||
|
||||
const audioForwardEvents = new EventEmitter();
|
||||
const config = loadConfig();
|
||||
const audioForwardConfig = config.audioForward || {};
|
||||
const mediaConfig = config.media || {};
|
||||
const serviceEnabled = audioForwardConfig.enabled !== false;
|
||||
const ffmpegBin = audioForwardConfig.ffmpegBin || 'ffmpeg';
|
||||
const streamSuffix = typeof audioForwardConfig.streamSuffix === 'string' ? audioForwardConfig.streamSuffix : '-fwd';
|
||||
const runtimeDir = path.resolve(audioForwardConfig.runtimeDir || '/tmp/mrr-audio-forward');
|
||||
const uploadsDir = path.join(runtimeDir, 'uploads');
|
||||
const maxUploadBytes = Number.isFinite(audioForwardConfig.maxUploadBytes)
|
||||
? Math.max(256 * 1024, Math.floor(audioForwardConfig.maxUploadBytes))
|
||||
: 8 * 1024 * 1024;
|
||||
|
||||
const states = new Map(); // roverId -> { state, source, error, startedAt, updatedAt }
|
||||
const workers = new Map(); // roverId -> worker
|
||||
const whipOwners = new Map(); // roverId -> socketId
|
||||
|
||||
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',
|
||||
source: Object.prototype.hasOwnProperty.call(next, 'source') ? next.source : prev.source || 'silence',
|
||||
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 ensureServiceEnabled() {
|
||||
if (!serviceEnabled) {
|
||||
throw new Error('Audio forward disabled');
|
||||
}
|
||||
}
|
||||
|
||||
function ensureRuntimeDir() {
|
||||
fs.mkdirSync(runtimeDir, { recursive: true });
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
|
||||
function sanitizeRoverId(roverId) {
|
||||
return String(roverId || '').replace(/[^a-zA-Z0-9_-]+/g, '_');
|
||||
}
|
||||
|
||||
function sanitizeFileStem(name) {
|
||||
return String(name || 'upload')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
.slice(0, 64);
|
||||
}
|
||||
|
||||
function extFromUpload(name, mime) {
|
||||
const lowerName = String(name || '').toLowerCase();
|
||||
const lowerMime = String(mime || '').toLowerCase();
|
||||
if (lowerName.endsWith('.mp3') || lowerMime === 'audio/mpeg' || lowerMime === 'audio/mp3') return '.mp3';
|
||||
if (lowerName.endsWith('.wav') || lowerMime === 'audio/wav' || lowerMime === 'audio/x-wav') return '.wav';
|
||||
if (lowerName.endsWith('.ogg') || lowerMime === 'audio/ogg') return '.ogg';
|
||||
throw new Error('Unsupported upload format (allowed: mp3, wav, ogg)');
|
||||
}
|
||||
|
||||
function ensureVipVerified(socket) {
|
||||
if (!isVerified(socket)) {
|
||||
throw new Error('VIP verification required');
|
||||
}
|
||||
}
|
||||
|
||||
function ensureAudioForwardPermission(socket, roverId) {
|
||||
ensureVipVerified(socket);
|
||||
if (!roverManager.isDriver(roverId, socket)) {
|
||||
throw new Error('Audio forwarding is only allowed on your own rover');
|
||||
}
|
||||
if (!turnService.canDrive(roverId, socket)) {
|
||||
throw new Error('Only the current driver can play audio');
|
||||
}
|
||||
}
|
||||
|
||||
function ensureFifo(fifoPath) {
|
||||
try {
|
||||
const stat = fs.statSync(fifoPath);
|
||||
if (stat.isFIFO()) {
|
||||
return;
|
||||
}
|
||||
fs.unlinkSync(fifoPath);
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const result = spawnSync('mkfifo', [fifoPath], { encoding: 'utf8' });
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`mkfifo failed: ${result.stderr || result.stdout || 'unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
function forcePublishStreamMode(rawUrl) {
|
||||
const value = String(rawUrl || '').trim();
|
||||
if (!value) return '';
|
||||
if (!/[?&]streamid=#!::/.test(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (/,m=publish\b/.test(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (/,m=[a-zA-Z]+\b/.test(value)) {
|
||||
return value.replace(/,m=[a-zA-Z]+\b/, ',m=publish');
|
||||
}
|
||||
|
||||
return value.replace(/([?&]streamid=#!::[^&]*)/, '$1,m=publish');
|
||||
}
|
||||
|
||||
function resolveForwardUrl(roverId) {
|
||||
const record = roverManager.rovers.get(roverId);
|
||||
const configured = record?.meta?.media?.audioForwardUrl;
|
||||
if (configured) {
|
||||
return forcePublishStreamMode(configured);
|
||||
}
|
||||
return `srt://127.0.0.1:9000?streamid=#!::r=${encodeURIComponent(roverId + streamSuffix)},m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316`;
|
||||
}
|
||||
|
||||
function resolveForwardPathId(roverId) {
|
||||
return `${roverId}${streamSuffix}`;
|
||||
}
|
||||
|
||||
function getMediaPrefix() {
|
||||
const base = mediaConfig.whepBaseUrl;
|
||||
if (!base) return '';
|
||||
try {
|
||||
const parsed = new URL(base);
|
||||
return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, '');
|
||||
} catch {
|
||||
return String(base).replace(/\/+$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
function buildWhipUrl(pathId) {
|
||||
const prefix = getMediaPrefix();
|
||||
if (!prefix) {
|
||||
throw new Error('Server media base URL missing');
|
||||
}
|
||||
return `${prefix}/${encodeURIComponent(pathId)}/whip`;
|
||||
}
|
||||
|
||||
function spawnProcess(roverId, tag, args, options = {}) {
|
||||
const proc = spawn(ffmpegBin, args, {
|
||||
stdio: [options.captureStdin ? 'pipe' : 'ignore', options.captureStdout ? 'pipe' : 'ignore', 'pipe'],
|
||||
});
|
||||
proc.stderr?.on('data', (chunk) => {
|
||||
const text = String(chunk || '').trim();
|
||||
if (!text) return;
|
||||
logger.warn(`${tag} stderr`, { roverId, text });
|
||||
});
|
||||
proc.on('error', (err) => {
|
||||
logger.warn(`${tag} spawn error`, { roverId, message: err?.message || String(err) });
|
||||
});
|
||||
return proc;
|
||||
}
|
||||
|
||||
function stopProc(proc, graceMs = 1200) {
|
||||
if (!proc || proc.killed) return;
|
||||
try {
|
||||
proc.kill('SIGTERM');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (!proc.killed) {
|
||||
try {
|
||||
proc.kill('SIGKILL');
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
}, graceMs);
|
||||
}
|
||||
|
||||
function buildPublisherArgs(fifoPath, outputUrl) {
|
||||
return [
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'warning',
|
||||
'-f',
|
||||
's16le',
|
||||
'-ar',
|
||||
'16000',
|
||||
'-ac',
|
||||
'1',
|
||||
'-i',
|
||||
fifoPath,
|
||||
'-c:a',
|
||||
'libopus',
|
||||
'-b:a',
|
||||
'24000',
|
||||
'-ar:a',
|
||||
'16000',
|
||||
'-ac:a',
|
||||
'1',
|
||||
'-application',
|
||||
'lowdelay',
|
||||
'-frame_duration',
|
||||
'10',
|
||||
'-compression_level',
|
||||
'0',
|
||||
'-fflags',
|
||||
'nobuffer',
|
||||
'-flush_packets',
|
||||
'1',
|
||||
'-muxdelay',
|
||||
'0',
|
||||
'-muxpreload',
|
||||
'0',
|
||||
'-f',
|
||||
'mpegts',
|
||||
outputUrl,
|
||||
];
|
||||
}
|
||||
|
||||
function buildSilenceWriterArgs() {
|
||||
return [
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'warning',
|
||||
'-re',
|
||||
'-f',
|
||||
'lavfi',
|
||||
'-i',
|
||||
'anullsrc=channel_layout=mono:sample_rate=16000',
|
||||
'-f',
|
||||
's16le',
|
||||
'-ac',
|
||||
'1',
|
||||
'-ar',
|
||||
'16000',
|
||||
'pipe:1',
|
||||
];
|
||||
}
|
||||
|
||||
function buildUploadWriterArgs(filePath) {
|
||||
return [
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'warning',
|
||||
'-re',
|
||||
'-i',
|
||||
filePath,
|
||||
'-vn',
|
||||
'-af',
|
||||
'aresample=16000',
|
||||
'-f',
|
||||
's16le',
|
||||
'-ac',
|
||||
'1',
|
||||
'-ar',
|
||||
'16000',
|
||||
'pipe:1',
|
||||
];
|
||||
}
|
||||
|
||||
function attachWriterPipe(worker, proc) {
|
||||
const writer = fs.createWriteStream(worker.fifoPath, { flags: 'w' });
|
||||
writer.on('error', (err) => {
|
||||
const code = err?.code || 'unknown';
|
||||
// Broken pipe is expected when FIFO reader (publisher) restarts/exits.
|
||||
if (code !== 'EPIPE') {
|
||||
logger.warn('writer pipe error', { roverId: worker?.roverId, code, message: err?.message || String(err) });
|
||||
}
|
||||
});
|
||||
proc.stdout.on('error', (err) => {
|
||||
logger.warn('writer stdout error', {
|
||||
roverId: worker?.roverId,
|
||||
code: err?.code || 'unknown',
|
||||
message: err?.message || String(err),
|
||||
});
|
||||
});
|
||||
proc.stdout.pipe(writer);
|
||||
proc.on('exit', () => {
|
||||
writer.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
function cleanupUploadFile(worker) {
|
||||
if (!worker?.activeUploadPath) return;
|
||||
try {
|
||||
fs.unlinkSync(worker.activeUploadPath);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
worker.activeUploadPath = null;
|
||||
}
|
||||
|
||||
function stopContentWriter(worker) {
|
||||
if (!worker) return;
|
||||
if (worker.micIdleTimer) {
|
||||
clearTimeout(worker.micIdleTimer);
|
||||
worker.micIdleTimer = null;
|
||||
}
|
||||
worker.micLastChunkAt = 0;
|
||||
worker.micBackpressured = false;
|
||||
if (worker.micWriter && !worker.micWriter.destroyed) {
|
||||
try {
|
||||
worker.micWriter.end();
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
try {
|
||||
worker.micWriter.destroy();
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
worker.micWriter = null;
|
||||
if (worker.contentProc && worker.contentProc.stdin && !worker.contentProc.stdin.destroyed) {
|
||||
try {
|
||||
worker.contentProc.stdin.destroy();
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
if (worker.contentProc) {
|
||||
stopProc(worker.contentProc);
|
||||
}
|
||||
worker.contentProc = null;
|
||||
worker.contentKind = null;
|
||||
worker.activeOwnerSocketId = null;
|
||||
}
|
||||
|
||||
function startSilenceWriter(roverId) {
|
||||
const worker = workers.get(roverId);
|
||||
if (!worker || worker.stopping) return;
|
||||
|
||||
stopContentWriter(worker);
|
||||
cleanupUploadFile(worker);
|
||||
const proc = spawnProcess(roverId, 'silence-writer', buildSilenceWriterArgs(), { captureStdout: true });
|
||||
worker.contentProc = proc;
|
||||
worker.contentKind = 'silence';
|
||||
const seq = ++worker.writerSeq;
|
||||
attachWriterPipe(worker, proc);
|
||||
|
||||
proc.on('exit', (code, signal) => {
|
||||
const current = workers.get(roverId);
|
||||
if (!current || current.stopping) return;
|
||||
if (current.writerSeq !== seq || current.contentProc !== proc) return;
|
||||
current.contentProc = null;
|
||||
current.contentKind = null;
|
||||
if (code === 0 || signal === 'SIGTERM') {
|
||||
return;
|
||||
}
|
||||
setState(roverId, { state: 'error', source: 'silence', error: `silence writer exited code=${code} signal=${signal || 'none'}` });
|
||||
setTimeout(() => {
|
||||
if (workers.has(roverId)) startSilenceWriter(roverId);
|
||||
}, 300);
|
||||
});
|
||||
|
||||
setState(roverId, { state: 'idle', source: 'silence', error: null, startedAt: null });
|
||||
}
|
||||
|
||||
function startUploadWriter(roverId, filePath) {
|
||||
const worker = workers.get(roverId);
|
||||
if (!worker || worker.stopping) return;
|
||||
|
||||
stopContentWriter(worker);
|
||||
cleanupUploadFile(worker);
|
||||
worker.activeUploadPath = filePath;
|
||||
worker.activeOwnerSocketId = null;
|
||||
const proc = spawnProcess(roverId, 'upload-writer', buildUploadWriterArgs(filePath), { captureStdout: true });
|
||||
worker.contentProc = proc;
|
||||
worker.contentKind = 'upload';
|
||||
const seq = ++worker.writerSeq;
|
||||
attachWriterPipe(worker, proc);
|
||||
|
||||
setState(roverId, { state: 'playing', source: 'upload', error: null, startedAt: Date.now() });
|
||||
|
||||
proc.on('exit', (code, signal) => {
|
||||
const current = workers.get(roverId);
|
||||
if (!current || current.stopping) return;
|
||||
if (current.writerSeq !== seq || current.contentProc !== proc) return;
|
||||
current.contentProc = null;
|
||||
current.contentKind = null;
|
||||
|
||||
if (code != null && code !== 0 && signal !== 'SIGTERM') {
|
||||
setState(roverId, { state: 'error', source: 'upload', error: `upload writer exited code=${code} signal=${signal || 'none'}` });
|
||||
}
|
||||
startSilenceWriter(roverId);
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleMicIdleTimeout(roverId) {
|
||||
const worker = workers.get(roverId);
|
||||
if (!worker || worker.contentKind !== 'mic') return;
|
||||
if (worker.micIdleTimer) {
|
||||
clearTimeout(worker.micIdleTimer);
|
||||
}
|
||||
worker.micIdleTimer = setTimeout(() => {
|
||||
const current = workers.get(roverId);
|
||||
if (!current || current.contentKind !== 'mic') return;
|
||||
const staleForMs = Date.now() - (current.micLastChunkAt || 0);
|
||||
if (staleForMs < 2500) return;
|
||||
logger.info('Stopping mic writer due to idle chunk timeout', { roverId, staleForMs });
|
||||
startSilenceWriter(roverId);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function startMicWriter(roverId, ownerSocketId = null) {
|
||||
stopWhipForRover(roverId, 'socket_mic_override');
|
||||
const worker = workers.get(roverId);
|
||||
if (!worker || worker.stopping) return;
|
||||
if (
|
||||
worker.contentKind === 'mic' &&
|
||||
worker.micWriter &&
|
||||
worker.activeOwnerSocketId === ownerSocketId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
stopContentWriter(worker);
|
||||
cleanupUploadFile(worker);
|
||||
const writer = fs.createWriteStream(worker.fifoPath, { flags: 'w' });
|
||||
writer.on('error', (err) => {
|
||||
const code = err?.code || 'unknown';
|
||||
if (code !== 'EPIPE') {
|
||||
logger.warn('mic fifo writer error', { roverId, code, message: err?.message || String(err) });
|
||||
}
|
||||
});
|
||||
writer.on('drain', () => {
|
||||
const current = workers.get(roverId);
|
||||
if (!current || current.contentKind !== 'mic') return;
|
||||
current.micBackpressured = false;
|
||||
});
|
||||
writer.on('close', () => {
|
||||
const current = workers.get(roverId);
|
||||
if (!current || current.contentKind !== 'mic') return;
|
||||
current.micWriter = null;
|
||||
current.micBackpressured = false;
|
||||
});
|
||||
|
||||
worker.micWriter = writer;
|
||||
worker.micBackpressured = false;
|
||||
worker.contentProc = null;
|
||||
worker.contentKind = 'mic';
|
||||
worker.activeOwnerSocketId = ownerSocketId;
|
||||
worker.micLastChunkAt = Date.now();
|
||||
scheduleMicIdleTimeout(roverId);
|
||||
|
||||
setState(roverId, { state: 'playing', source: 'mic', error: null, startedAt: Date.now() });
|
||||
}
|
||||
|
||||
function decodeMicChunk(payload = {}) {
|
||||
const binary = payload?.data;
|
||||
if (Buffer.isBuffer(binary)) {
|
||||
return binary;
|
||||
}
|
||||
if (binary && typeof binary === 'object' && binary.type === 'Buffer' && Array.isArray(binary.data)) {
|
||||
return Buffer.from(binary.data);
|
||||
}
|
||||
if (binary instanceof Uint8Array) {
|
||||
return Buffer.from(binary.buffer, binary.byteOffset, binary.byteLength);
|
||||
}
|
||||
if (binary instanceof ArrayBuffer) {
|
||||
return Buffer.from(binary);
|
||||
}
|
||||
if (typeof payload?.dataBase64 === 'string' && payload.dataBase64.trim()) {
|
||||
return Buffer.from(payload.dataBase64.trim(), 'base64');
|
||||
}
|
||||
return Buffer.alloc(0);
|
||||
}
|
||||
|
||||
function pushMicChunk(roverId, ownerSocketId, payload = {}) {
|
||||
const worker = workers.get(roverId);
|
||||
if (!worker) {
|
||||
throw new Error('Audio forward worker unavailable');
|
||||
}
|
||||
if (worker.contentKind !== 'mic' || !worker.micWriter || worker.activeOwnerSocketId !== ownerSocketId) {
|
||||
throw new Error('Mic forwarding is not active');
|
||||
}
|
||||
const bytes = decodeMicChunk(payload);
|
||||
if (!bytes.length) {
|
||||
throw new Error('Mic chunk missing');
|
||||
}
|
||||
if (bytes.length % 2 !== 0) {
|
||||
throw new Error('Mic chunk has invalid PCM byte length');
|
||||
}
|
||||
if (bytes.length > 64 * 1024) {
|
||||
throw new Error('Mic chunk too large');
|
||||
}
|
||||
if (worker.micWriter.writable !== true) {
|
||||
throw new Error('Mic writer input is not writable');
|
||||
}
|
||||
if (worker.micBackpressured || worker.micWriter.writableNeedDrain) {
|
||||
// Preserve low latency by dropping stale mic packets instead of queueing.
|
||||
return;
|
||||
}
|
||||
worker.micLastChunkAt = Date.now();
|
||||
const wrote = worker.micWriter.write(bytes);
|
||||
if (!wrote) {
|
||||
worker.micBackpressured = true;
|
||||
}
|
||||
scheduleMicIdleTimeout(roverId);
|
||||
}
|
||||
|
||||
function ensureWorker(roverId) {
|
||||
ensureServiceEnabled();
|
||||
if (!roverId) {
|
||||
throw new Error('roverId required');
|
||||
}
|
||||
const record = roverManager.rovers.get(roverId);
|
||||
if (!record || !record.ws) {
|
||||
throw new Error('Rover offline');
|
||||
}
|
||||
if (workers.has(roverId)) {
|
||||
return workers.get(roverId);
|
||||
}
|
||||
|
||||
ensureRuntimeDir();
|
||||
const fifoPath = path.join(runtimeDir, `${sanitizeRoverId(roverId)}.pcm`);
|
||||
ensureFifo(fifoPath);
|
||||
const outputUrl = resolveForwardUrl(roverId);
|
||||
|
||||
// Keep FIFO open so reader/writer open calls don't block when switching writers.
|
||||
const keepaliveFd = fs.openSync(fifoPath, 'r+');
|
||||
const publisher = spawnProcess(roverId, 'publisher', buildPublisherArgs(fifoPath, outputUrl));
|
||||
|
||||
const worker = {
|
||||
roverId,
|
||||
fifoPath,
|
||||
keepaliveFd,
|
||||
outputUrl,
|
||||
publisherProc: publisher,
|
||||
contentProc: null,
|
||||
contentKind: null,
|
||||
activeOwnerSocketId: null,
|
||||
activeUploadPath: null,
|
||||
micWriter: null,
|
||||
micLastChunkAt: 0,
|
||||
micIdleTimer: null,
|
||||
micBackpressured: false,
|
||||
writerSeq: 0,
|
||||
stopping: false,
|
||||
};
|
||||
workers.set(roverId, worker);
|
||||
|
||||
publisher.on('exit', (code, signal) => {
|
||||
const current = workers.get(roverId);
|
||||
if (!current || current.publisherProc !== publisher) return;
|
||||
if (current.stopping) return;
|
||||
setState(roverId, {
|
||||
state: 'error',
|
||||
source: current.contentKind || 'silence',
|
||||
error: `publisher exited code=${code} signal=${signal || 'none'}`,
|
||||
startedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
startSilenceWriter(roverId);
|
||||
logger.info('Audio forward worker ready', { roverId, outputUrl, fifoPath });
|
||||
return worker;
|
||||
}
|
||||
|
||||
function writeUploadFile(roverId, payload = {}) {
|
||||
const { name, mime, dataBase64 } = payload || {};
|
||||
const ext = extFromUpload(name, mime);
|
||||
const encoded = typeof dataBase64 === 'string' ? dataBase64.trim() : '';
|
||||
if (!encoded) {
|
||||
throw new Error('Upload payload missing');
|
||||
}
|
||||
const bytes = Buffer.from(encoded, 'base64');
|
||||
if (!bytes.length) {
|
||||
throw new Error('Upload decode failed');
|
||||
}
|
||||
if (bytes.length > maxUploadBytes) {
|
||||
throw new Error(`Upload too large (max ${maxUploadBytes} bytes)`);
|
||||
}
|
||||
ensureRuntimeDir();
|
||||
const stem = sanitizeFileStem(name || `upload-${Date.now()}`);
|
||||
const filePath = path.join(uploadsDir, `${sanitizeRoverId(roverId)}-${Date.now()}-${stem}${ext}`);
|
||||
fs.writeFileSync(filePath, bytes);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function playUploadedAudio(roverId, payload = {}) {
|
||||
stopWhipForRover(roverId, 'upload_override');
|
||||
const ownerSocketId = typeof payload?.ownerSocketId === 'string' ? payload.ownerSocketId : null;
|
||||
const uploadPath = writeUploadFile(roverId, payload);
|
||||
ensureWorker(roverId);
|
||||
const worker = workers.get(roverId);
|
||||
if (worker) {
|
||||
worker.activeOwnerSocketId = ownerSocketId;
|
||||
}
|
||||
startUploadWriter(roverId, uploadPath);
|
||||
if (worker) {
|
||||
worker.activeOwnerSocketId = ownerSocketId;
|
||||
}
|
||||
}
|
||||
|
||||
function stopPlayback(roverId) {
|
||||
stopWhipForRover(roverId, 'stop_playback');
|
||||
ensureWorker(roverId);
|
||||
startSilenceWriter(roverId);
|
||||
}
|
||||
|
||||
function revokeWhipSessionForRover(roverId, ownerSocketId) {
|
||||
if (!roverId || !ownerSocketId) return;
|
||||
const pathId = resolveForwardPathId(roverId);
|
||||
videoSessions.revokeWhere(
|
||||
(info) => info?.socketId === ownerSocketId && info?.sourceType === 'roverMic' && info?.sourceId === pathId,
|
||||
);
|
||||
}
|
||||
|
||||
function stopWhipForRover(roverId, reason = 'unknown') {
|
||||
const ownerSocketId = whipOwners.get(roverId);
|
||||
if (!ownerSocketId) return;
|
||||
whipOwners.delete(roverId);
|
||||
revokeWhipSessionForRover(roverId, ownerSocketId);
|
||||
logger.info('Stopping WHIP mic session', { roverId, ownerSocketId, reason });
|
||||
try {
|
||||
ensureWorker(roverId);
|
||||
startSilenceWriter(roverId);
|
||||
} catch (err) {
|
||||
setState(roverId, { state: 'error', source: 'whip', error: err?.message || String(err), startedAt: null });
|
||||
}
|
||||
}
|
||||
|
||||
function stopWorker(roverId) {
|
||||
stopWhipForRover(roverId, 'worker_stop');
|
||||
const worker = workers.get(roverId);
|
||||
if (!worker) return;
|
||||
worker.stopping = true;
|
||||
|
||||
stopContentWriter(worker);
|
||||
cleanupUploadFile(worker);
|
||||
stopProc(worker.publisherProc);
|
||||
|
||||
try {
|
||||
fs.closeSync(worker.keepaliveFd);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
try {
|
||||
fs.unlinkSync(worker.fifoPath);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
|
||||
workers.delete(roverId);
|
||||
setState(roverId, { state: 'offline', source: 'none', error: null, startedAt: null });
|
||||
}
|
||||
|
||||
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
|
||||
if (!roverId) return;
|
||||
if (action === 'removed') {
|
||||
stopWorker(roverId);
|
||||
return;
|
||||
}
|
||||
if (action === 'upsert' && serviceEnabled) {
|
||||
if (whipOwners.has(roverId)) {
|
||||
// WHIP publishes directly to the forward path; avoid recreating local publisher mid-session.
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ensureWorker(roverId);
|
||||
} catch (err) {
|
||||
setState(roverId, { state: 'error', source: 'init', error: err.message, startedAt: null });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function stopOwnedAudioIfUnauthorized(roverId, ownerSocketId, reason = 'driver_change') {
|
||||
if (!roverId || !ownerSocketId) return;
|
||||
if (whipOwners.get(roverId) === ownerSocketId) {
|
||||
const ownerSocket = io.sockets.sockets.get(ownerSocketId);
|
||||
const ownerIsDriver = ownerSocket ? roverManager.isDriver(roverId, ownerSocket) : false;
|
||||
const ownerCanDrive = ownerSocket ? turnService.canDrive(roverId, ownerSocket) : false;
|
||||
if (!ownerIsDriver || !ownerCanDrive) {
|
||||
stopWhipForRover(roverId, reason);
|
||||
}
|
||||
}
|
||||
const worker = workers.get(roverId);
|
||||
if (!worker) return;
|
||||
if (worker.contentKind !== 'upload' && worker.contentKind !== 'mic') return;
|
||||
if (worker.activeOwnerSocketId !== ownerSocketId) return;
|
||||
const ownerSocket = io.sockets.sockets.get(ownerSocketId);
|
||||
const ownerIsDriver = ownerSocket ? roverManager.isDriver(roverId, ownerSocket) : false;
|
||||
const ownerCanDrive = ownerSocket ? turnService.canDrive(roverId, ownerSocket) : false;
|
||||
if (ownerIsDriver && ownerCanDrive) return;
|
||||
logger.info('Stopping audio forward due to ownership/driver change', { roverId, ownerSocketId, reason, source: worker.contentKind });
|
||||
startSilenceWriter(roverId);
|
||||
}
|
||||
|
||||
roverManager.managerEvents.on('driver', ({ socketId, roverId, action } = {}) => {
|
||||
if (!socketId || !roverId) return;
|
||||
if (action === 'remove' || action === 'add') {
|
||||
stopOwnedAudioIfUnauthorized(roverId, socketId, action);
|
||||
}
|
||||
});
|
||||
|
||||
turnService.turnEvents.on('activeDriver', ({ roverId } = {}) => {
|
||||
if (!roverId) return;
|
||||
const whipOwner = whipOwners.get(roverId);
|
||||
if (whipOwner) {
|
||||
stopOwnedAudioIfUnauthorized(roverId, whipOwner, 'turn_change');
|
||||
}
|
||||
const worker = workers.get(roverId);
|
||||
if (!worker || (worker.contentKind !== 'upload' && worker.contentKind !== 'mic')) return;
|
||||
stopOwnedAudioIfUnauthorized(roverId, worker.activeOwnerSocketId, 'turn_change');
|
||||
});
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('audio:uploadPlay', (payload = {}, cb = () => {}) => {
|
||||
try {
|
||||
const roverId = String(payload?.roverId || '').trim();
|
||||
ensureAudioForwardPermission(socket, roverId);
|
||||
const normalized = String(roverId || '').trim();
|
||||
playUploadedAudio(normalized, { ...(payload || {}), ownerSocketId: socket.id });
|
||||
cb({ success: true, roverId: normalized });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('audio:uploadStop', ({ roverId } = {}, cb = () => {}) => {
|
||||
try {
|
||||
const normalized = String(roverId || '').trim();
|
||||
ensureAudioForwardPermission(socket, normalized);
|
||||
stopPlayback(normalized);
|
||||
cb({ success: true, roverId: normalized });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('audio:micStart', ({ roverId } = {}, cb = () => {}) => {
|
||||
try {
|
||||
const normalized = String(roverId || '').trim();
|
||||
ensureAudioForwardPermission(socket, normalized);
|
||||
ensureWorker(normalized);
|
||||
startMicWriter(normalized, socket.id);
|
||||
cb({ success: true, roverId: normalized });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('audio:micChunk', (payload = {}, cb) => {
|
||||
try {
|
||||
const normalized = String(payload?.roverId || '').trim();
|
||||
ensureAudioForwardPermission(socket, normalized);
|
||||
pushMicChunk(normalized, socket.id, payload);
|
||||
if (typeof cb === 'function') cb({ success: true });
|
||||
} catch (err) {
|
||||
if (typeof cb === 'function') cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('audio:micStop', ({ roverId } = {}, cb = () => {}) => {
|
||||
try {
|
||||
const normalized = String(roverId || '').trim();
|
||||
ensureAudioForwardPermission(socket, normalized);
|
||||
const worker = workers.get(normalized);
|
||||
if (worker && worker.contentKind === 'mic' && worker.activeOwnerSocketId !== socket.id) {
|
||||
throw new Error('Mic forwarding is owned by another session');
|
||||
}
|
||||
stopPlayback(normalized);
|
||||
cb({ success: true, roverId: normalized });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('audio:micWhipStart', ({ roverId } = {}, cb = () => {}) => {
|
||||
try {
|
||||
const normalized = String(roverId || '').trim();
|
||||
ensureAudioForwardPermission(socket, normalized);
|
||||
// WHIP publishes directly to the same forward path; stop local publisher to avoid path conflicts.
|
||||
stopWorker(normalized);
|
||||
whipOwners.set(normalized, socket.id);
|
||||
const pathId = resolveForwardPathId(normalized);
|
||||
revokeWhipSessionForRover(normalized, socket.id);
|
||||
const token = videoSessions.createSession(socket, { type: 'roverMic', id: pathId });
|
||||
const whipUrl = buildWhipUrl(pathId);
|
||||
setState(normalized, { state: 'starting', source: 'mic-whip', error: null, startedAt: Date.now() });
|
||||
cb({ success: true, roverId: normalized, pathId, token, whipUrl });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('audio:micWhipReady', ({ roverId } = {}, cb = () => {}) => {
|
||||
try {
|
||||
const normalized = String(roverId || '').trim();
|
||||
ensureAudioForwardPermission(socket, normalized);
|
||||
if (whipOwners.get(normalized) !== socket.id) {
|
||||
throw new Error('WHIP session not owned by this client');
|
||||
}
|
||||
setState(normalized, { state: 'playing', source: 'mic-whip', error: null, startedAt: Date.now() });
|
||||
cb({ success: true, roverId: normalized });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('audio:micWhipStop', ({ roverId } = {}, cb = () => {}) => {
|
||||
try {
|
||||
const normalized = String(roverId || '').trim();
|
||||
ensureAudioForwardPermission(socket, normalized);
|
||||
if (whipOwners.get(normalized) && whipOwners.get(normalized) !== socket.id) {
|
||||
throw new Error('Mic forwarding is owned by another session');
|
||||
}
|
||||
stopWhipForRover(normalized, 'client_stop');
|
||||
cb({ success: true, roverId: normalized });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
workers.forEach((worker, roverId) => {
|
||||
if (!worker || worker.activeOwnerSocketId !== socket.id) return;
|
||||
if (worker.contentKind !== 'upload' && worker.contentKind !== 'mic') return;
|
||||
logger.info('Stopping owned audio forward due to socket disconnect', { roverId, socketId: socket.id, source: worker.contentKind });
|
||||
startSilenceWriter(roverId);
|
||||
});
|
||||
for (const [roverId, ownerSocketId] of whipOwners.entries()) {
|
||||
if (ownerSocketId !== socket.id) continue;
|
||||
stopWhipForRover(roverId, 'socket_disconnect');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
getAudioForwardState,
|
||||
audioForwardEvents,
|
||||
playUploadedAudio,
|
||||
stopPlayback,
|
||||
};
|
||||
@@ -22,7 +22,6 @@ const { getCommunityGoal } = require('./communityGoalService');
|
||||
const { getAdminReason } = require('./adminReasonService');
|
||||
const { subscribe } = require('./eventBus');
|
||||
const { getSocketIp, isLocalNetwork } = require('../helpers/ipResolver');
|
||||
const { getAudioForwardState, audioForwardEvents } = require('./audioForwardService');
|
||||
const { getAudioLevels, audioLevelsEvents } = require('./audioLevelsService');
|
||||
|
||||
const config = loadConfig();
|
||||
@@ -93,7 +92,6 @@ function buildSession(socket) {
|
||||
identity: getIdentitySummary(socket),
|
||||
verification: getVerificationStateForSocket(socket),
|
||||
isVerified: Boolean(socket?.data?.isVerified),
|
||||
audioForward: getAudioForwardState(),
|
||||
audioLevels: getAudioLevels(),
|
||||
};
|
||||
}
|
||||
@@ -262,10 +260,6 @@ subscribe('adminReason.updated', () => {
|
||||
syncAll();
|
||||
});
|
||||
|
||||
audioForwardEvents.on('change', () => {
|
||||
syncAll();
|
||||
});
|
||||
|
||||
audioLevelsEvents.on('change', () => {
|
||||
syncAll();
|
||||
});
|
||||
|
||||
@@ -4,8 +4,6 @@ const logger = require('../globals/logger').child('videoAuth');
|
||||
const videoSessions = require('./videoSessions');
|
||||
const { getMode, MODES } = require('./modeManager');
|
||||
const { isAdmin, isLockdownAdmin, getRole } = require('./roleService');
|
||||
const { isVerified } = require('./verificationService');
|
||||
const turnService = require('./turnService');
|
||||
const roverManager = require('./roverManager');
|
||||
const { loadConfig } = require('../helpers/configLoader');
|
||||
const { getRequestIp, getSocketIp, isLocalNetwork } = require('../helpers/ipResolver');
|
||||
@@ -53,9 +51,6 @@ function extractStreamInfo(path) {
|
||||
if (rawId.endsWith('-fwd')) {
|
||||
return { type: 'rover', id: rawId, baseId: rawId.slice(0, -4) };
|
||||
}
|
||||
if (rawId.endsWith('-mic')) {
|
||||
return { type: 'roverMic', id: rawId, baseId: rawId.slice(0, -4) };
|
||||
}
|
||||
const baseId = rawId.endsWith('-audio') ? rawId.slice(0, -6) : rawId;
|
||||
return { type: 'rover', id: rawId, baseId };
|
||||
}
|
||||
@@ -92,9 +87,6 @@ function extractStreamInfoFromBody(body = {}) {
|
||||
extractSrtStreamId(body.query);
|
||||
if (!srtId) return null;
|
||||
|
||||
if (srtId.endsWith('-mic')) {
|
||||
return { type: 'roverMic', id: srtId, baseId: srtId.slice(0, -4) };
|
||||
}
|
||||
if (srtId.endsWith('-fwd')) {
|
||||
return { type: 'rover', id: srtId, baseId: srtId.slice(0, -4) };
|
||||
}
|
||||
@@ -153,9 +145,7 @@ app.post('/mediamtx/auth', (req, res) => {
|
||||
}
|
||||
|
||||
const info = videoSessions.getSession(sessionId);
|
||||
const streamTypeMatches =
|
||||
info &&
|
||||
(info.sourceType === streamInfo.type || (info.sourceType === 'roverMic' && streamInfo.type === 'rover'));
|
||||
const streamTypeMatches = info && info.sourceType === streamInfo.type;
|
||||
if (!info || !streamTypeMatches || info.sourceId !== streamInfo.id) {
|
||||
logger.warn('invalid session %s for stream %s:%s', sessionId, streamInfo.type, streamInfo.id);
|
||||
return res.status(401).end();
|
||||
@@ -168,19 +158,6 @@ app.post('/mediamtx/auth', (req, res) => {
|
||||
if (!canView(socket)) {
|
||||
return res.status(401).end();
|
||||
}
|
||||
if (info.sourceType === 'roverMic' && action === 'publish') {
|
||||
const roverId = streamInfo.baseId || streamInfo.id;
|
||||
if (!isVerified(socket)) {
|
||||
return res.status(401).end();
|
||||
}
|
||||
if (!roverManager.isDriver(roverId, socket)) {
|
||||
return res.status(401).end();
|
||||
}
|
||||
if (!turnService.canDrive(roverId, socket)) {
|
||||
return res.status(401).end();
|
||||
}
|
||||
return res.status(200).end();
|
||||
}
|
||||
const role = getRole(socket);
|
||||
const isAudio = streamInfo.id?.endsWith('-audio');
|
||||
if (role === 'spectator' && !isAdmin(socket) && !isAudio) {
|
||||
|
||||
@@ -337,14 +337,6 @@ export default function AdminPanel() {
|
||||
>
|
||||
{rebootStates[rover.id] ? 'Rebooting...' : 'Reboot'}
|
||||
</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>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -24,7 +24,6 @@ const KEY_ACTIONS = [
|
||||
{ id: 'cameraDown', label: 'Camera Down', group: 'Camera' },
|
||||
{ id: 'nightVisionToggle', label: 'Toggle Night Vision', group: 'Camera' },
|
||||
{ id: 'hornHonk', label: 'Horn (Hold)', group: 'Audio' },
|
||||
{ id: 'micPtt', label: 'Mic Push To Talk', group: 'Audio' },
|
||||
{ id: 'driveMacro', label: 'Drive Macro', group: 'Macros' },
|
||||
{ id: 'dockMacro', label: 'Dock Macro', group: 'Macros' },
|
||||
{ id: 'chatFocus', label: 'Toggle Chat', group: 'Chat' },
|
||||
|
||||
@@ -2,24 +2,11 @@ import { useMemo, useState } from 'react';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../settings/index.js';
|
||||
import { COOKIE_KEY_REGEX, flowWrapClass } from './vip/constants.js';
|
||||
import VipAudioForwardingCard from './vip/VipAudioForwardingCard.jsx';
|
||||
import VipVerificationCard from './vip/VipVerificationCard.jsx';
|
||||
import VipIdentityCard from './vip/VipIdentityCard.jsx';
|
||||
|
||||
export default function VipPanel() {
|
||||
const {
|
||||
session,
|
||||
identifySession,
|
||||
requestVerification,
|
||||
playUploadedAudio,
|
||||
stopUploadedAudio,
|
||||
startMicForward,
|
||||
stopMicForward,
|
||||
sendMicChunk,
|
||||
startMicWhip,
|
||||
readyMicWhip,
|
||||
stopMicWhip,
|
||||
} = useSession();
|
||||
const { session, identifySession, requestVerification } = useSession();
|
||||
const { value: identity, save: saveIdentity } = useSettingsNamespace('identity', { cookieUserId: '' });
|
||||
const { value: profile } = useSettingsNamespace('profile', { nickname: '' });
|
||||
|
||||
@@ -27,12 +14,6 @@ export default function VipPanel() {
|
||||
const nickname = (profile?.nickname || '').trim();
|
||||
const isVerified = Boolean(session?.isVerified);
|
||||
const pendingRequestId = session?.verification?.pendingRequestId || null;
|
||||
const roster = useMemo(() => session?.roster ?? [], [session?.roster]);
|
||||
const ownRoverId = String(session?.assignment?.roverId || '').trim();
|
||||
const ownRoverRoster = useMemo(
|
||||
() => (ownRoverId ? roster.filter((rover) => rover.id === ownRoverId) : []),
|
||||
[ownRoverId, roster],
|
||||
);
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const applyIdentityKey = async (nextRaw) => {
|
||||
@@ -51,19 +32,12 @@ export default function VipPanel() {
|
||||
return (
|
||||
<section className="panel-section space-y-0.5 text-base">
|
||||
{isVerified ? (
|
||||
<VipAudioForwardingCard
|
||||
roster={ownRoverRoster}
|
||||
ownRoverId={ownRoverId}
|
||||
audioForwardByRover={session?.audioForward || {}}
|
||||
playUploadedAudio={playUploadedAudio}
|
||||
stopUploadedAudio={stopUploadedAudio}
|
||||
startMicForward={startMicForward}
|
||||
stopMicForward={stopMicForward}
|
||||
sendMicChunk={sendMicChunk}
|
||||
startMicWhip={startMicWhip}
|
||||
readyMicWhip={readyMicWhip}
|
||||
stopMicWhip={stopMicWhip}
|
||||
/>
|
||||
<div className={`surface ${flowWrapClass}`}>
|
||||
<div className="mx-auto flex w-full max-w-md flex-col items-center space-y-0.5 text-center">
|
||||
<p className="text-sm text-slate-300">VIP verified.</p>
|
||||
<p className="text-xs text-slate-500">Audio forwarding is disabled while this stack is being rebuilt.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<VipVerificationCard
|
||||
pendingRequestId={pendingRequestId}
|
||||
|
||||
@@ -1,631 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
MAX_UPLOAD_BYTES,
|
||||
bytesToBase64,
|
||||
fieldClass,
|
||||
flowWrapClass,
|
||||
innerFlowClass,
|
||||
} from './constants.js';
|
||||
import { useControlSystem } from '../../controls/index.js';
|
||||
|
||||
const TARGET_SAMPLE_RATE = 16000;
|
||||
const MIC_PACKET_MS = 40;
|
||||
const MIC_PACKET_BYTES = (TARGET_SAMPLE_RATE * 2 * MIC_PACKET_MS) / 1000; // s16le mono
|
||||
const RTC_CONFIG = {
|
||||
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
|
||||
bundlePolicy: 'max-bundle',
|
||||
rtcpMuxPolicy: 'require',
|
||||
};
|
||||
|
||||
function encodeBase64(value) {
|
||||
if (typeof btoa === 'function') return btoa(value);
|
||||
return '';
|
||||
}
|
||||
|
||||
function buildAuthHeader(token) {
|
||||
if (!token) return {};
|
||||
const encoded = encodeBase64(`${token}:${token}`);
|
||||
return encoded ? { Authorization: `Basic ${encoded}` } : {};
|
||||
}
|
||||
|
||||
function waitForIceGatheringComplete(pc, timeoutMs = 1500) {
|
||||
return new Promise((resolve) => {
|
||||
if (!pc || pc.iceGatheringState === 'complete') {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
pc.removeEventListener('icegatheringstatechange', onChange);
|
||||
resolve();
|
||||
}, timeoutMs);
|
||||
function onChange() {
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
clearTimeout(timer);
|
||||
pc.removeEventListener('icegatheringstatechange', onChange);
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
pc.addEventListener('icegatheringstatechange', onChange);
|
||||
});
|
||||
}
|
||||
|
||||
function isPeerTransportReady(pc) {
|
||||
if (!pc) return false;
|
||||
const conn = pc.connectionState;
|
||||
const ice = pc.iceConnectionState;
|
||||
if (conn === 'connected') return true;
|
||||
if (ice === 'connected' || ice === 'completed') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function waitForPeerConnected(pc, timeoutMs = 10000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!pc) {
|
||||
reject(new Error('Peer connection missing'));
|
||||
return;
|
||||
}
|
||||
if (isPeerTransportReady(pc)) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
pc.connectionState === 'failed' ||
|
||||
pc.connectionState === 'closed' ||
|
||||
pc.iceConnectionState === 'failed'
|
||||
) {
|
||||
reject(new Error(`Peer connection ${pc.connectionState || pc.iceConnectionState}`));
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error('Peer connection timeout'));
|
||||
}, timeoutMs);
|
||||
const onState = () => {
|
||||
if (isPeerTransportReady(pc)) {
|
||||
cleanup();
|
||||
resolve();
|
||||
} else if (
|
||||
pc.connectionState === 'failed' ||
|
||||
pc.connectionState === 'closed' ||
|
||||
pc.iceConnectionState === 'failed'
|
||||
) {
|
||||
cleanup();
|
||||
reject(new Error(`Peer connection ${pc.connectionState || pc.iceConnectionState}`));
|
||||
}
|
||||
};
|
||||
function cleanup() {
|
||||
clearTimeout(timer);
|
||||
pc.removeEventListener('connectionstatechange', onState);
|
||||
pc.removeEventListener('iceconnectionstatechange', onState);
|
||||
}
|
||||
pc.addEventListener('connectionstatechange', onState);
|
||||
pc.addEventListener('iceconnectionstatechange', onState);
|
||||
});
|
||||
}
|
||||
|
||||
async function configureSenderForLowLatency(sender) {
|
||||
if (!sender?.getParameters || !sender?.setParameters) return;
|
||||
const params = sender.getParameters() || {};
|
||||
const first = (params.encodings && params.encodings[0]) || {};
|
||||
params.encodings = [
|
||||
{
|
||||
...first,
|
||||
maxBitrate: 64000,
|
||||
dtx: 'disabled',
|
||||
},
|
||||
];
|
||||
try {
|
||||
await sender.setParameters(params);
|
||||
} catch {
|
||||
// Some browsers reject unsupported combinations; keep defaults.
|
||||
}
|
||||
}
|
||||
|
||||
function waitForOutboundAudioFlow(pc, timeoutMs = 6000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!pc) {
|
||||
reject(new Error('Peer connection missing'));
|
||||
return;
|
||||
}
|
||||
const start = Date.now();
|
||||
let baseline = -1;
|
||||
const timer = setInterval(async () => {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
clearInterval(timer);
|
||||
reject(new Error('WHIP connected but no outbound audio flow'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const senders = pc.getSenders().filter((s) => s.track?.kind === 'audio');
|
||||
for (const sender of senders) {
|
||||
const stats = await sender.getStats();
|
||||
for (const report of stats.values()) {
|
||||
if (report.type !== 'outbound-rtp' || report.kind !== 'audio') continue;
|
||||
const sent = Number(report.bytesSent || 0);
|
||||
const packets = Number(report.packetsSent || 0);
|
||||
if (baseline < 0) {
|
||||
baseline = sent;
|
||||
} else if (sent > baseline + 200 || packets > 5) {
|
||||
clearInterval(timer);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Keep polling until timeout.
|
||||
}
|
||||
}, 250);
|
||||
});
|
||||
}
|
||||
|
||||
function resampleTo16k(input, sampleRate) {
|
||||
if (!input || !input.length) return new Float32Array(0);
|
||||
if (sampleRate === TARGET_SAMPLE_RATE) return input;
|
||||
if (!Number.isFinite(sampleRate) || sampleRate <= 0) return input;
|
||||
const ratio = sampleRate / TARGET_SAMPLE_RATE;
|
||||
const outputLength = Math.max(1, Math.round(input.length / ratio));
|
||||
const output = new Float32Array(outputLength);
|
||||
for (let outIdx = 0; outIdx < outputLength; outIdx += 1) {
|
||||
const src = outIdx * ratio;
|
||||
const srcFloor = Math.floor(src);
|
||||
const srcCeil = Math.min(input.length - 1, srcFloor + 1);
|
||||
const frac = src - srcFloor;
|
||||
const a = input[srcFloor] ?? 0;
|
||||
const b = input[srcCeil] ?? a;
|
||||
output[outIdx] = a + (b - a) * frac;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function floatToInt16Bytes(floatSamples) {
|
||||
const bytes = new Uint8Array(floatSamples.length * 2);
|
||||
const view = new DataView(bytes.buffer);
|
||||
for (let i = 0; i < floatSamples.length; i += 1) {
|
||||
const sample = Math.max(-1, Math.min(1, floatSamples[i]));
|
||||
const int16 = sample < 0 ? Math.round(sample * 0x8000) : Math.round(sample * 0x7fff);
|
||||
view.setInt16(i * 2, int16, true);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function concatUint8(chunks = [], totalLength = 0) {
|
||||
const out = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
out.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export default function VipAudioForwardingCard({
|
||||
roster = [],
|
||||
ownRoverId = '',
|
||||
audioForwardByRover = {},
|
||||
playUploadedAudio,
|
||||
stopUploadedAudio,
|
||||
startMicForward,
|
||||
stopMicForward,
|
||||
sendMicChunk,
|
||||
startMicWhip,
|
||||
readyMicWhip,
|
||||
stopMicWhip,
|
||||
}) {
|
||||
const { state: controlState } = useControlSystem();
|
||||
const [selectedUpload, setSelectedUpload] = useState(null);
|
||||
const [working, setWorking] = useState(false);
|
||||
const [openMicEnabled, setOpenMicEnabled] = useState(false);
|
||||
const [micState, setMicState] = useState('idle');
|
||||
const [message, setMessage] = useState('');
|
||||
const streamRef = useRef(null);
|
||||
const audioContextRef = useRef(null);
|
||||
const mediaSourceRef = useRef(null);
|
||||
const processorRef = useRef(null);
|
||||
const sinkRef = useRef(null);
|
||||
const pendingPcmChunksRef = useRef([]);
|
||||
const pendingPcmBytesRef = useRef(0);
|
||||
const whipPcRef = useRef(null);
|
||||
const micTransportRef = useRef('none');
|
||||
const whipFailoverRef = useRef(false);
|
||||
const micActiveRef = useRef(false);
|
||||
const activeRoverRef = useRef('');
|
||||
const singleRoverId = roster.length === 1 ? roster[0].id : '';
|
||||
const targetRoverId = String(singleRoverId || ownRoverId || '').trim();
|
||||
const pttActive = Boolean(controlState?.mic?.pttActive);
|
||||
const selectedForwardState = useMemo(
|
||||
() => (targetRoverId ? audioForwardByRover?.[targetRoverId] || null : null),
|
||||
[audioForwardByRover, targetRoverId],
|
||||
);
|
||||
|
||||
const handleUploadPlay = async () => {
|
||||
const roverId = targetRoverId;
|
||||
if (!roverId) {
|
||||
setMessage('Take control of a rover first.');
|
||||
return;
|
||||
}
|
||||
if (!selectedUpload) {
|
||||
setMessage('Select an audio file first.');
|
||||
return;
|
||||
}
|
||||
if (selectedUpload.size > MAX_UPLOAD_BYTES) {
|
||||
setMessage(`File too large (max ${MAX_UPLOAD_BYTES} bytes).`);
|
||||
return;
|
||||
}
|
||||
setWorking(true);
|
||||
setMessage('');
|
||||
try {
|
||||
const buffer = await selectedUpload.arrayBuffer();
|
||||
const base64 = bytesToBase64(new Uint8Array(buffer));
|
||||
await playUploadedAudio?.({
|
||||
roverId,
|
||||
name: selectedUpload.name,
|
||||
mime: selectedUpload.type || '',
|
||||
dataBase64: base64,
|
||||
});
|
||||
setMessage(`Playing upload on ${roverId}.`);
|
||||
} catch (err) {
|
||||
setMessage(err.message || 'Failed to play upload.');
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadStop = async () => {
|
||||
const roverId = targetRoverId;
|
||||
if (!roverId) {
|
||||
setMessage('Take control of a rover first.');
|
||||
return;
|
||||
}
|
||||
setWorking(true);
|
||||
setMessage('');
|
||||
try {
|
||||
await stopUploadedAudio?.(roverId);
|
||||
setMessage(`Stopped upload on ${roverId}.`);
|
||||
} catch (err) {
|
||||
setMessage(err.message || 'Failed to stop upload.');
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stopMicCapture = useCallback(
|
||||
async (roverId) => {
|
||||
const target = String(roverId || activeRoverRef.current || '').trim();
|
||||
micActiveRef.current = false;
|
||||
setMicState('idle');
|
||||
try {
|
||||
if (processorRef.current && mediaSourceRef.current) {
|
||||
mediaSourceRef.current.disconnect(processorRef.current);
|
||||
}
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
try {
|
||||
if (processorRef.current && sinkRef.current) {
|
||||
processorRef.current.disconnect(sinkRef.current);
|
||||
}
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
try {
|
||||
if (sinkRef.current && audioContextRef.current?.destination) {
|
||||
sinkRef.current.disconnect(audioContextRef.current.destination);
|
||||
}
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
if (audioContextRef.current) {
|
||||
try {
|
||||
await audioContextRef.current.close();
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
if (whipPcRef.current) {
|
||||
try {
|
||||
whipPcRef.current.getSenders().forEach((sender) => sender.track?.stop());
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
try {
|
||||
whipPcRef.current.close();
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
whipPcRef.current = null;
|
||||
processorRef.current = null;
|
||||
mediaSourceRef.current = null;
|
||||
sinkRef.current = null;
|
||||
audioContextRef.current = null;
|
||||
pendingPcmChunksRef.current = [];
|
||||
pendingPcmBytesRef.current = 0;
|
||||
whipFailoverRef.current = false;
|
||||
micTransportRef.current = 'none';
|
||||
if (streamRef.current) {
|
||||
try {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
streamRef.current = null;
|
||||
if (target) {
|
||||
try {
|
||||
await stopMicWhip?.(target);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
try {
|
||||
await stopMicForward?.(target);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
activeRoverRef.current = '';
|
||||
},
|
||||
[stopMicForward, stopMicWhip],
|
||||
);
|
||||
|
||||
const startSocketBridge = useCallback(
|
||||
async (target, stream) => {
|
||||
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AudioContextCtor) {
|
||||
throw new Error('Web Audio API is not supported in this browser.');
|
||||
}
|
||||
await startMicForward?.(target);
|
||||
const audioContext = new AudioContextCtor({ latencyHint: 'interactive' });
|
||||
audioContextRef.current = audioContext;
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
mediaSourceRef.current = source;
|
||||
const processor = audioContext.createScriptProcessor(1024, 1, 1);
|
||||
processorRef.current = processor;
|
||||
const sink = audioContext.createGain();
|
||||
sink.gain.value = 0;
|
||||
sinkRef.current = sink;
|
||||
pendingPcmChunksRef.current = [];
|
||||
pendingPcmBytesRef.current = 0;
|
||||
micTransportRef.current = 'socket';
|
||||
|
||||
processor.onaudioprocess = (event) => {
|
||||
if (!micActiveRef.current || micTransportRef.current !== 'socket') return;
|
||||
const input = event.inputBuffer?.getChannelData(0);
|
||||
if (!input || input.length === 0) return;
|
||||
const resampled = resampleTo16k(input, audioContext.sampleRate);
|
||||
if (!resampled.length) return;
|
||||
const pcmBytes = floatToInt16Bytes(resampled);
|
||||
pendingPcmChunksRef.current.push(pcmBytes);
|
||||
pendingPcmBytesRef.current += pcmBytes.length;
|
||||
|
||||
while (pendingPcmBytesRef.current >= MIC_PACKET_BYTES) {
|
||||
const merged = concatUint8(pendingPcmChunksRef.current, pendingPcmBytesRef.current);
|
||||
const packet = merged.slice(0, MIC_PACKET_BYTES);
|
||||
const rest = merged.slice(MIC_PACKET_BYTES);
|
||||
pendingPcmChunksRef.current = rest.length ? [rest] : [];
|
||||
pendingPcmBytesRef.current = rest.length;
|
||||
sendMicChunk?.({ roverId: target, data: packet });
|
||||
}
|
||||
};
|
||||
|
||||
source.connect(processor);
|
||||
processor.connect(sink);
|
||||
sink.connect(audioContext.destination);
|
||||
if (audioContext.state === 'suspended') {
|
||||
await audioContext.resume();
|
||||
}
|
||||
setMicState('live');
|
||||
},
|
||||
[sendMicChunk, startMicForward],
|
||||
);
|
||||
|
||||
const startWhipBridge = useCallback(
|
||||
async (target, stream) => {
|
||||
const startPayload = await startMicWhip?.(target);
|
||||
const whipUrl = String(startPayload?.whipUrl || '').trim();
|
||||
const token = String(startPayload?.token || '').trim();
|
||||
if (!whipUrl || !token) {
|
||||
throw new Error('WHIP endpoint unavailable');
|
||||
}
|
||||
const pc = new RTCPeerConnection(RTC_CONFIG);
|
||||
whipPcRef.current = pc;
|
||||
micTransportRef.current = 'whip';
|
||||
try {
|
||||
const senders = stream.getAudioTracks().map((track) => pc.addTrack(track, stream));
|
||||
await Promise.all(senders.map((sender) => configureSenderForLowLatency(sender)));
|
||||
pc.onconnectionstatechange = () => {
|
||||
const state = pc.connectionState;
|
||||
if (!micActiveRef.current) return;
|
||||
if (state === 'connected') {
|
||||
setMicState('live');
|
||||
return;
|
||||
}
|
||||
if (state === 'failed' || state === 'disconnected') {
|
||||
setMicState('error');
|
||||
setMessage(`WHIP transport ${state}.`);
|
||||
}
|
||||
};
|
||||
|
||||
const offer = await pc.createOffer({ offerToReceiveAudio: false, offerToReceiveVideo: false });
|
||||
await pc.setLocalDescription(offer);
|
||||
await waitForIceGatheringComplete(pc, 1800);
|
||||
|
||||
const response = await fetch(whipUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/sdp',
|
||||
...buildAuthHeader(token),
|
||||
},
|
||||
body: pc.localDescription?.sdp || offer.sdp,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`WHIP request failed: ${response.status}`);
|
||||
}
|
||||
const answerSdp = await response.text();
|
||||
await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
|
||||
await waitForPeerConnected(pc, 10000);
|
||||
await waitForOutboundAudioFlow(pc, 6000);
|
||||
await readyMicWhip?.(target);
|
||||
setMicState('live');
|
||||
} catch (err) {
|
||||
try {
|
||||
pc.close();
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
if (whipPcRef.current === pc) {
|
||||
whipPcRef.current = null;
|
||||
}
|
||||
micTransportRef.current = 'none';
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[readyMicWhip, startMicWhip],
|
||||
);
|
||||
|
||||
const startMicCapture = useCallback(
|
||||
async (roverId) => {
|
||||
const target = String(roverId || '').trim();
|
||||
if (!target) {
|
||||
throw new Error('Take control of a rover first.');
|
||||
}
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
throw new Error('Microphone capture is not supported in this browser.');
|
||||
}
|
||||
await stopMicCapture(target);
|
||||
setMicState('starting');
|
||||
let stream = null;
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
channelCount: 1,
|
||||
sampleRate: TARGET_SAMPLE_RATE,
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
autoGainControl: false,
|
||||
},
|
||||
});
|
||||
const [track] = stream.getAudioTracks();
|
||||
if (track?.applyConstraints) {
|
||||
try {
|
||||
await track.applyConstraints({
|
||||
channelCount: 1,
|
||||
sampleRate: TARGET_SAMPLE_RATE,
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
autoGainControl: false,
|
||||
});
|
||||
} catch {
|
||||
// Constraint support varies by browser; use acquired track as-is.
|
||||
}
|
||||
}
|
||||
streamRef.current = stream;
|
||||
micActiveRef.current = true;
|
||||
activeRoverRef.current = target;
|
||||
await startWhipBridge(target, stream);
|
||||
} catch (err) {
|
||||
if (stream) {
|
||||
try {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
streamRef.current = null;
|
||||
audioContextRef.current = null;
|
||||
mediaSourceRef.current = null;
|
||||
processorRef.current = null;
|
||||
sinkRef.current = null;
|
||||
whipPcRef.current = null;
|
||||
micTransportRef.current = 'none';
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[startWhipBridge, stopMicCapture],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const desiredActive = Boolean(openMicEnabled || pttActive);
|
||||
const roverId = targetRoverId;
|
||||
let cancelled = false;
|
||||
async function syncMicState() {
|
||||
if (!roverId || !desiredActive) {
|
||||
await stopMicCapture(activeRoverRef.current || roverId);
|
||||
return;
|
||||
}
|
||||
if (micActiveRef.current && activeRoverRef.current === roverId) return;
|
||||
try {
|
||||
await startMicCapture(roverId);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setMicState('error');
|
||||
setMessage(err?.message || 'Failed to start microphone forwarding.');
|
||||
}
|
||||
}
|
||||
}
|
||||
syncMicState();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [openMicEnabled, pttActive, startMicCapture, stopMicCapture, targetRoverId]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
stopMicCapture(activeRoverRef.current);
|
||||
},
|
||||
[stopMicCapture],
|
||||
);
|
||||
|
||||
return (
|
||||
<section className={`surface ${flowWrapClass}`}>
|
||||
<div className={innerFlowClass}>
|
||||
<p className="text-sm text-slate-300">VIP Audio Forwarding</p>
|
||||
<label className="grid w-full gap-0.5 text-xs text-slate-300">
|
||||
<span>Audio file (mp3 / wav / ogg)</span>
|
||||
<input
|
||||
className={fieldClass}
|
||||
type="file"
|
||||
accept=".mp3,.wav,.ogg,audio/mpeg,audio/wav,audio/ogg"
|
||||
disabled={working || !targetRoverId}
|
||||
onChange={(event) => setSelectedUpload(event.target.files?.[0] || null)}
|
||||
/>
|
||||
</label>
|
||||
{selectedUpload ? (
|
||||
<div className="surface-muted mx-auto w-full max-w-sm text-xs text-slate-300 text-center">
|
||||
{selectedUpload.name} ({selectedUpload.size} bytes)
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex justify-center gap-0.5">
|
||||
<button type="button" className="button-dark text-sm" disabled={working || !targetRoverId} onClick={handleUploadPlay}>
|
||||
{working ? 'Working...' : 'Play Upload'}
|
||||
</button>
|
||||
<button type="button" className="button-dark text-sm" disabled={working || !targetRoverId} onClick={handleUploadStop}>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
<div className="surface-muted mx-auto flex w-full max-w-sm flex-col gap-0.5 p-0.5 text-xs text-slate-300 text-center">
|
||||
<p className="text-slate-200">Microphone Forwarding</p>
|
||||
<label className="flex items-center justify-center gap-0.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={openMicEnabled}
|
||||
disabled={!targetRoverId}
|
||||
onChange={(event) => setOpenMicEnabled(Boolean(event.target.checked))}
|
||||
/>
|
||||
<span>Open mic</span>
|
||||
</label>
|
||||
<p className="text-slate-400">PTT key: {controlState?.keymap?.micPtt?.[0] || 'v'} (hold)</p>
|
||||
<p className="text-slate-400">mic: {micState}</p>
|
||||
<p className="text-slate-500">transport: {micTransportRef.current === 'none' ? 'idle' : micTransportRef.current}</p>
|
||||
</div>
|
||||
{selectedForwardState ? (
|
||||
<div className="surface-muted mx-auto w-full max-w-sm text-xs text-slate-300 text-center">
|
||||
state: {selectedForwardState.state || 'idle'}
|
||||
{selectedForwardState.error ? ` | error: ${selectedForwardState.error}` : ''}
|
||||
</div>
|
||||
) : null}
|
||||
{message ? <div className="text-xs text-slate-400 text-center">{message}</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
export const COOKIE_KEY_REGEX = /^cu_[a-f0-9]{32}$/;
|
||||
export const MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
export const fieldClass = 'field-input w-full max-w-sm text-left focus:ring-emerald-500';
|
||||
export const flowWrapClass = 'mx-auto w-full max-w-xl flex justify-center';
|
||||
@@ -11,13 +10,3 @@ export function maskKey(value) {
|
||||
if (key.length <= 10) return `${key.slice(0, 2)}***${key.slice(-2)}`;
|
||||
return `${key.slice(0, 6)}...${key.slice(-6)}`;
|
||||
}
|
||||
|
||||
export function bytesToBase64(bytes) {
|
||||
let binary = '';
|
||||
const chunkSize = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||
const chunk = bytes.subarray(i, i + chunkSize);
|
||||
binary += String.fromCharCode(...chunk);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
@@ -26,14 +26,6 @@ const SessionContext = createContext({
|
||||
setAdminReason: async () => {},
|
||||
rebootRover: async () => {},
|
||||
rebootServer: async () => {},
|
||||
playUploadedAudio: async () => {},
|
||||
stopUploadedAudio: async () => {},
|
||||
startMicForward: async () => {},
|
||||
stopMicForward: async () => {},
|
||||
sendMicChunk: async () => {},
|
||||
startMicWhip: async () => {},
|
||||
readyMicWhip: async () => {},
|
||||
stopMicWhip: async () => {},
|
||||
setAudioLevels: async () => {},
|
||||
llmControl: async () => {},
|
||||
});
|
||||
@@ -150,23 +142,6 @@ export function SessionProvider({ children }) {
|
||||
rebootRover: (roverId) =>
|
||||
emitWithAck('command', { roverId, type: 'reboot', data: { reboot: {} } }),
|
||||
rebootServer: () => emitWithAck('server:reboot'),
|
||||
playUploadedAudio: ({ roverId, name, mime, dataBase64 }) =>
|
||||
emitWithAck('audio:uploadPlay', { roverId, name, mime, dataBase64 }),
|
||||
stopUploadedAudio: (roverId) => emitWithAck('audio:uploadStop', { roverId }),
|
||||
startMicForward: (roverId) => emitWithAck('audio:micStart', { roverId }),
|
||||
stopMicForward: (roverId) => emitWithAck('audio:micStop', { roverId }),
|
||||
sendMicChunk: ({ roverId, dataBase64, data }) => {
|
||||
if (!socket.connected) return false;
|
||||
const ws = socket.io?.engine?.transport?.ws;
|
||||
if (ws && typeof ws.bufferedAmount === 'number' && ws.bufferedAmount > 256 * 1024) {
|
||||
return false;
|
||||
}
|
||||
socket.emit('audio:micChunk', { roverId, dataBase64, data });
|
||||
return true;
|
||||
},
|
||||
startMicWhip: (roverId) => emitWithAck('audio:micWhipStart', { roverId }),
|
||||
readyMicWhip: (roverId) => emitWithAck('audio:micWhipReady', { roverId }),
|
||||
stopMicWhip: (roverId) => emitWithAck('audio:micWhipStop', { roverId }),
|
||||
setAudioLevels: (levels = {}) => emitWithAck('audioLevels:set', levels),
|
||||
llmControl: (action, controls = {}) =>
|
||||
emitWithAck('llm:control', { controls: { action, ...controls } }),
|
||||
@@ -176,7 +151,7 @@ export function SessionProvider({ children }) {
|
||||
{ ...alert, receivedAt: Date.now(), id: alert.id || Math.random().toString(36).slice(2) },
|
||||
]),
|
||||
}),
|
||||
[emitWithAck, socket],
|
||||
[emitWithAck],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
|
||||
@@ -452,10 +452,6 @@ export function ControlSystemProvider({ children }) {
|
||||
dispatch({ type: 'control/register-input-state', payload: { source, state: data } });
|
||||
}, []);
|
||||
|
||||
const setMicPttActive = useCallback((active) => {
|
||||
dispatch({ type: 'control/set-mic-ptt', payload: Boolean(active) });
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
state,
|
||||
@@ -482,7 +478,6 @@ export function ControlSystemProvider({ children }) {
|
||||
sendSong,
|
||||
startHorn,
|
||||
stopHorn,
|
||||
setMicPttActive,
|
||||
},
|
||||
}),
|
||||
[
|
||||
@@ -508,7 +503,6 @@ export function ControlSystemProvider({ children }) {
|
||||
sendSong,
|
||||
startHorn,
|
||||
stopHorn,
|
||||
setMicPttActive,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -48,7 +48,6 @@ export const DEFAULT_KEYMAP = {
|
||||
cameraDown: ['j'],
|
||||
nightVisionToggle: ['e'],
|
||||
hornHonk: ['h'],
|
||||
micPtt: ['m'],
|
||||
driveMacro: ['f'],
|
||||
dockMacro: ['g'],
|
||||
chatFocus: ['enter'],
|
||||
|
||||
@@ -35,12 +35,6 @@ function createHornState() {
|
||||
};
|
||||
}
|
||||
|
||||
function createMicState() {
|
||||
return {
|
||||
pttActive: false,
|
||||
};
|
||||
}
|
||||
|
||||
export const initialControlState = {
|
||||
roverId: null,
|
||||
mode: 'drive',
|
||||
@@ -49,7 +43,6 @@ export const initialControlState = {
|
||||
camera: createCameraState(),
|
||||
song: createSongState(),
|
||||
horn: createHornState(),
|
||||
mic: createMicState(),
|
||||
lastControlIntentAt: 0,
|
||||
macros: DEFAULT_MACROS,
|
||||
keymap: DEFAULT_KEYMAP,
|
||||
@@ -66,7 +59,6 @@ export function controlReducer(state, action) {
|
||||
aux: action.payload ? state.aux : createAuxState(),
|
||||
song: action.payload ? state.song : createSongState(),
|
||||
horn: action.payload ? state.horn : createHornState(),
|
||||
mic: action.payload ? state.mic : createMicState(),
|
||||
lastControlIntentAt: action.payload ? state.lastControlIntentAt : 0,
|
||||
};
|
||||
case 'control/set-mode':
|
||||
@@ -144,7 +136,6 @@ export function controlReducer(state, action) {
|
||||
aux: createAuxState(),
|
||||
song: createSongState(),
|
||||
horn: createHornState(),
|
||||
mic: createMicState(),
|
||||
lastControlIntentAt: 0,
|
||||
};
|
||||
case 'control/set-horn-active':
|
||||
@@ -177,14 +168,6 @@ export function controlReducer(state, action) {
|
||||
note: action.payload ?? SONG_DEFAULT_NOTE,
|
||||
},
|
||||
};
|
||||
case 'control/set-mic-ptt':
|
||||
return {
|
||||
...state,
|
||||
mic: {
|
||||
...(state.mic || createMicState()),
|
||||
pttActive: Boolean(action.payload),
|
||||
},
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -129,7 +129,6 @@ export default function KeyboardInputManager() {
|
||||
toggleNightVision,
|
||||
startHorn,
|
||||
stopHorn,
|
||||
setMicPttActive,
|
||||
setSongNote,
|
||||
sendSong,
|
||||
},
|
||||
@@ -308,10 +307,9 @@ export default function KeyboardInputManager() {
|
||||
hornActiveRef.current = false;
|
||||
stopHorn();
|
||||
}
|
||||
setMicPttActive(false);
|
||||
stopAllMotion();
|
||||
registerInputState(SOURCE, { keys: [], vector: ZERO_VECTOR, aux: ZERO_AUX });
|
||||
}, [registerInputState, setMicPttActive, stopAllMotion, stopHorn, stopServoLoop, stopSongLoop]);
|
||||
}, [registerInputState, stopAllMotion, stopHorn, stopServoLoop, stopSongLoop]);
|
||||
|
||||
const triggerHomeAssistantCycle = useCallback(
|
||||
(targetState) => {
|
||||
@@ -380,8 +378,6 @@ export default function KeyboardInputManager() {
|
||||
const started = startHorn();
|
||||
hornActiveRef.current = Boolean(started);
|
||||
}
|
||||
} else if (newlyPressed.some((token) => keymap.micPtt?.has(token))) {
|
||||
setMicPttActive(true);
|
||||
} else if (newlyPressed.some((token) => keymap.homeAssistantOn?.has(token))) {
|
||||
triggerHomeAssistantCycle('on');
|
||||
} else if (newlyPressed.some((token) => keymap.homeAssistantOff?.has(token))) {
|
||||
@@ -402,9 +398,6 @@ export default function KeyboardInputManager() {
|
||||
hornActiveRef.current = false;
|
||||
stopHorn();
|
||||
}
|
||||
if (!bindingActive(keymap.micPtt, activeTokensRef.current)) {
|
||||
setMicPttActive(false);
|
||||
}
|
||||
ensureServoLoop();
|
||||
ensureSongLoop();
|
||||
driveFromKeys();
|
||||
@@ -434,10 +427,8 @@ export default function KeyboardInputManager() {
|
||||
keymap.dockMacro,
|
||||
keymap.driveMacro,
|
||||
keymap.hornHonk,
|
||||
keymap.micPtt,
|
||||
resetAll,
|
||||
runMacro,
|
||||
setMicPttActive,
|
||||
setMode,
|
||||
stopAllMotion,
|
||||
stopSongLoop,
|
||||
|
||||
Reference in New Issue
Block a user