mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
things
This commit is contained in:
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');
|
||||
|
||||
Reference in New Issue
Block a user