This commit is contained in:
legop3
2026-03-15 22:43:05 -04:00
parent 2ea222355f
commit 95972af558
20 changed files with 552 additions and 134 deletions
Binary file not shown.
+9
View File
@@ -19,6 +19,15 @@ media:
# Example: http://192.168.0.86:8889/video
whepBaseUrl: "http://192.168.0.86:8889/video"
audioForward:
enabled: true
# Optional override; defaults to "ffmpeg"
ffmpegBin: "ffmpeg"
# Optional test file path for admin-only first-pass playback
testAudioPath: "server/assets/test-audio.mp3"
# Optional stream suffix for fallback URL generation
streamSuffix: "-fwd"
homeAssistant:
url: "http://homeassistant.local:8123"
token: "REPLACE_WITH_LONG_LIVED_TOKEN"
+1
View File
@@ -30,6 +30,7 @@ require('./src/services/embedHttpService');
require('./src/services/logStreamService');
require('./src/services/adminLogService');
require('./src/services/homeAssistantService');
require('./src/services/audioForwardService');
require('./src/services/sessionService');
require('./src/services/batteryManager');
require('./src/services/replaySocketService');
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -11,8 +11,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-DlEkWSx3.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dx4QsRNa.css">
<script type="module" crossorigin src="/assets/index-5J3deGq0.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-WqYsCIRI.css">
</head>
<body>
<div id="root"></div>
+223
View File
@@ -0,0 +1,223 @@
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
const EventEmitter = require('events');
const io = require('../globals/io');
const logger = require('../globals/logger').child('audioForwardService');
const { loadConfig } = require('../helpers/configLoader');
const roverManager = require('./roverManager');
const { isAdmin } = require('./roleService');
const audioForwardEvents = new EventEmitter();
const config = loadConfig();
const audioForwardConfig = config.audioForward || {};
const serviceEnabled = audioForwardConfig.enabled !== false;
const ffmpegBin = audioForwardConfig.ffmpegBin || 'ffmpeg';
const streamSuffix = typeof audioForwardConfig.streamSuffix === 'string' ? audioForwardConfig.streamSuffix : '-fwd';
const repoRoot = path.resolve(__dirname, '..', '..', '..');
const defaultAudioPath = path.join(__dirname, '..', '..', 'assets', 'test-audio.mp3');
const configuredAudioPath = audioForwardConfig.testAudioPath;
const testAudioPath =
configuredAudioPath && path.isAbsolute(configuredAudioPath)
? configuredAudioPath
: configuredAudioPath
? path.resolve(repoRoot, configuredAudioPath)
: defaultAudioPath;
const processes = new Map(); // roverId -> ChildProcess
const processErrors = new Map(); // roverId -> last stderr text
const states = new Map(); // roverId -> { state, error, startedAt, updatedAt }
const stopping = new Set();
function publishStateChange(roverId) {
audioForwardEvents.emit('change', { roverId, state: states.get(roverId) || null });
}
function setState(roverId, next) {
const prev = states.get(roverId) || {};
const merged = {
state: next.state || prev.state || 'idle',
error: Object.prototype.hasOwnProperty.call(next, 'error') ? next.error : prev.error || null,
startedAt: Object.prototype.hasOwnProperty.call(next, 'startedAt') ? next.startedAt : prev.startedAt || null,
updatedAt: Date.now(),
};
states.set(roverId, merged);
publishStateChange(roverId);
}
function getAudioForwardState() {
const payload = {};
states.forEach((entry, roverId) => {
payload[roverId] = { ...entry };
});
return payload;
}
function ensureReady() {
if (!serviceEnabled) {
throw new Error('Audio forward disabled');
}
if (!fs.existsSync(testAudioPath)) {
throw new Error(`Test audio missing: ${testAudioPath}`);
}
}
function resolveForwardUrl(roverId) {
const record = roverManager.rovers.get(roverId);
const configured = record?.meta?.media?.audioForwardUrl;
if (configured) {
return configured;
}
const fallback = `srt://127.0.0.1:9000?streamid=#!::r=${encodeURIComponent(roverId + streamSuffix)},m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316`;
return fallback;
}
function buildFfmpegArgs(outputUrl) {
return [
'-hide_banner',
'-loglevel',
'warning',
'-stream_loop',
'-1',
'-re',
'-i',
testAudioPath,
'-vn',
'-af',
'aresample=16000,pan=mono|c0=0.5*FL+0.5*FR,volume=12dB',
'-c:a',
'libopus',
'-b:a',
'24000',
'-ar:a',
'16000',
'-ac:a',
'1',
'-application',
'lowdelay',
'-frame_duration',
'20',
'-compression_level',
'0',
'-f',
'mpegts',
outputUrl,
];
}
function stopPlayback(roverId, options = {}) {
const proc = processes.get(roverId);
if (!proc) {
setState(roverId, { state: 'idle', error: null, startedAt: null });
return;
}
stopping.add(roverId);
setState(roverId, { state: 'stopping', error: null });
proc.kill('SIGTERM');
setTimeout(() => {
const active = processes.get(roverId);
if (active && active.pid === proc.pid) {
active.kill('SIGKILL');
}
}, options.killAfterMs || 2000);
}
function playTestAudio(roverId) {
if (!roverId) {
throw new Error('roverId required');
}
const record = roverManager.rovers.get(roverId);
if (!record || !record.ws) {
throw new Error('Rover offline');
}
ensureReady();
stopPlayback(roverId, { killAfterMs: 1000 });
const outputUrl = resolveForwardUrl(roverId);
const args = buildFfmpegArgs(outputUrl);
const proc = spawn(ffmpegBin, args, { stdio: ['ignore', 'ignore', 'pipe'] });
processes.set(roverId, proc);
processErrors.set(roverId, '');
setState(roverId, { state: 'playing', error: null, startedAt: Date.now() });
proc.stderr.on('data', (chunk) => {
const text = String(chunk || '').trim();
if (!text) return;
processErrors.set(roverId, text);
});
proc.on('error', (err) => {
const message = err?.message || 'ffmpeg spawn failed';
logger.warn('audio forward process error', { roverId, message });
if (processes.get(roverId)?.pid === proc.pid) {
processes.delete(roverId);
stopping.delete(roverId);
setState(roverId, { state: 'error', error: message, startedAt: null });
}
});
proc.on('exit', (code, signal) => {
if (processes.get(roverId)?.pid === proc.pid) {
processes.delete(roverId);
}
if (stopping.has(roverId)) {
stopping.delete(roverId);
setState(roverId, { state: 'idle', error: null, startedAt: null });
return;
}
const stderr = processErrors.get(roverId) || null;
const message = stderr || `ffmpeg exited code=${code} signal=${signal || 'none'}`;
setState(roverId, { state: 'error', error: message, startedAt: null });
logger.warn('audio forward exited unexpectedly', { roverId, code, signal, message });
});
logger.info('Started test audio playback', { roverId, outputUrl, testAudioPath });
}
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
if (!roverId || action !== 'removed') return;
stopPlayback(roverId);
});
io.on('connection', (socket) => {
socket.on('audio:testPlay', ({ roverId } = {}, cb = () => {}) => {
try {
if (!isAdmin(socket)) {
throw new Error('Not authorized');
}
playTestAudio(String(roverId || '').trim());
cb({ success: true, roverId });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('audio:testStop', ({ roverId } = {}, cb = () => {}) => {
try {
if (!isAdmin(socket)) {
throw new Error('Not authorized');
}
const normalized = String(roverId || '').trim();
if (!normalized) {
throw new Error('roverId required');
}
stopPlayback(normalized);
cb({ success: true, roverId: normalized });
} catch (err) {
cb({ error: err.message });
}
});
});
module.exports = {
getAudioForwardState,
audioForwardEvents,
playTestAudio,
stopPlayback,
};
+6
View File
@@ -22,6 +22,7 @@ const { getCommunityGoal } = require('./communityGoalService');
const { getAdminReason } = require('./adminReasonService');
const { subscribe } = require('./eventBus');
const { getSocketIp, isLocalNetwork } = require('../helpers/ipResolver');
const { getAudioForwardState, audioForwardEvents } = require('./audioForwardService');
const config = loadConfig();
const discordInvite = config.discord?.invite || null;
@@ -91,6 +92,7 @@ function buildSession(socket) {
identity: getIdentitySummary(socket),
verification: getVerificationStateForSocket(socket),
isVerified: Boolean(socket?.data?.isVerified),
audioForward: getAudioForwardState(),
};
}
@@ -258,6 +260,10 @@ subscribe('adminReason.updated', () => {
syncAll();
});
audioForwardEvents.on('change', () => {
syncAll();
});
// sync all sockets 20 seconds
setInterval(() => {
logger.info('Periodic session sync for all clients');