This commit is contained in:
legop3
2026-03-17 18:33:27 -04:00
parent c44632ed4d
commit 0e4954fadf
12 changed files with 696 additions and 498 deletions
+4 -2
View File
@@ -23,10 +23,12 @@ 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"
# Max upload payload accepted via VIP forward upload
maxUploadBytes: 8388608
# Maximum playback duration per upload (seconds)
maxUploadSeconds: 45
audioLevels:
# Gains are multipliers (0.0 - 4.0) applied globally to all rovers.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -11,7 +11,7 @@
<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-C4govqUc.js"></script>
<script type="module" crossorigin src="/assets/index-D3RnimBF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-WqYsCIRI.css">
</head>
<body>
+92 -34
View File
@@ -7,7 +7,7 @@ const logger = require('../globals/logger').child('audioForwardService');
const { loadConfig } = require('../helpers/configLoader');
const roverManager = require('./roverManager');
const { isAdmin } = require('./roleService');
const { audioLevelsEvents } = require('./audioLevelsService');
const { isVerified } = require('./verificationService');
const audioForwardEvents = new EventEmitter();
const config = loadConfig();
@@ -16,15 +16,13 @@ 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 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 uploadsDir = path.join(runtimeDir, 'uploads');
const maxUploadBytes = Number.isFinite(audioForwardConfig.maxUploadBytes)
? Math.max(256 * 1024, Math.floor(audioForwardConfig.maxUploadBytes))
: 8 * 1024 * 1024;
const maxUploadSeconds = Number.isFinite(audioForwardConfig.maxUploadSeconds)
? Math.max(1, Math.floor(audioForwardConfig.maxUploadSeconds))
: 45;
const states = new Map(); // roverId -> { state, source, error, startedAt, updatedAt }
const workers = new Map(); // roverId -> worker
@@ -62,12 +60,43 @@ function ensureServiceEnabled() {
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 (!isAdmin(socket) && !roverManager.canDrive(roverId, socket)) {
throw new Error('Not your turn or no control');
}
}
function ensureFifo(fifoPath) {
try {
const stat = fs.statSync(fifoPath);
@@ -208,7 +237,7 @@ function buildSilenceWriterArgs() {
];
}
function buildClipWriterArgs(filePath) {
function buildUploadWriterArgs(filePath) {
return [
'-hide_banner',
'-loglevel',
@@ -219,6 +248,8 @@ function buildClipWriterArgs(filePath) {
'-vn',
'-af',
'aresample=16000',
'-t',
String(maxUploadSeconds),
'-f',
's16le',
'-ac',
@@ -251,6 +282,16 @@ function attachWriterPipe(worker, proc) {
});
}
function cleanupUploadFile(worker) {
if (!worker?.activeUploadPath) return;
try {
fs.unlinkSync(worker.activeUploadPath);
} catch {
// noop
}
worker.activeUploadPath = null;
}
function stopContentWriter(worker) {
if (!worker?.contentProc) return;
stopProc(worker.contentProc);
@@ -263,6 +304,7 @@ function startSilenceWriter(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';
@@ -287,18 +329,20 @@ function startSilenceWriter(roverId) {
setState(roverId, { state: 'idle', source: 'silence', error: null, startedAt: null });
}
function startClipWriter(roverId, filePath) {
function startUploadWriter(roverId, filePath) {
const worker = workers.get(roverId);
if (!worker || worker.stopping) return;
stopContentWriter(worker);
const proc = spawnProcess(roverId, 'clip-writer', buildClipWriterArgs(filePath), { captureStdout: true });
cleanupUploadFile(worker);
worker.activeUploadPath = filePath;
const proc = spawnProcess(roverId, 'upload-writer', buildUploadWriterArgs(filePath), { captureStdout: true });
worker.contentProc = proc;
worker.contentKind = 'clip';
worker.contentKind = 'upload';
const seq = ++worker.writerSeq;
attachWriterPipe(worker, proc);
setState(roverId, { state: 'playing', source: 'clip', error: null, startedAt: Date.now() });
setState(roverId, { state: 'playing', source: 'upload', error: null, startedAt: Date.now() });
proc.on('exit', (code, signal) => {
const current = workers.get(roverId);
@@ -308,7 +352,7 @@ function startClipWriter(roverId, filePath) {
current.contentKind = null;
if (code != null && code !== 0 && signal !== 'SIGTERM') {
setState(roverId, { state: 'error', source: 'clip', error: `clip writer exited code=${code} signal=${signal || 'none'}` });
setState(roverId, { state: 'error', source: 'upload', error: `upload writer exited code=${code} signal=${signal || 'none'}` });
}
startSilenceWriter(roverId);
});
@@ -344,6 +388,7 @@ function ensureWorker(roverId) {
publisherProc: publisher,
contentProc: null,
contentKind: null,
activeUploadPath: null,
writerSeq: 0,
stopping: false,
};
@@ -366,12 +411,31 @@ function ensureWorker(roverId) {
return worker;
}
function playTestAudio(roverId) {
if (!fs.existsSync(testAudioPath)) {
throw new Error(`Test audio missing: ${testAudioPath}`);
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 = {}) {
const uploadPath = writeUploadFile(roverId, payload);
ensureWorker(roverId);
startClipWriter(roverId, testAudioPath);
startUploadWriter(roverId, uploadPath);
}
function stopPlayback(roverId) {
@@ -385,6 +449,7 @@ function stopWorker(roverId) {
worker.stopping = true;
stopContentWriter(worker);
cleanupUploadFile(worker);
stopProc(worker.publisherProc);
try {
@@ -417,30 +482,23 @@ roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
}
});
audioLevelsEvents.on('change', () => {
// Forward gain now applies at rover ALSA mixer (ForwardMaster), so no writer restart is needed.
});
io.on('connection', (socket) => {
socket.on('audio:testPlay', ({ roverId } = {}, cb = () => {}) => {
socket.on('audio:uploadPlay', (payload = {}, cb = () => {}) => {
try {
if (!isAdmin(socket)) {
throw new Error('Not authorized');
}
const roverId = String(payload?.roverId || '').trim();
ensureAudioForwardPermission(socket, roverId);
const normalized = String(roverId || '').trim();
playTestAudio(normalized);
playUploadedAudio(normalized, payload);
cb({ success: true, roverId: normalized });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('audio:testStop', ({ roverId } = {}, cb = () => {}) => {
socket.on('audio:uploadStop', ({ roverId } = {}, cb = () => {}) => {
try {
if (!isAdmin(socket)) {
throw new Error('Not authorized');
}
const normalized = String(roverId || '').trim();
ensureAudioForwardPermission(socket, normalized);
stopPlayback(normalized);
cb({ success: true, roverId: normalized });
} catch (err) {
@@ -452,6 +510,6 @@ io.on('connection', (socket) => {
module.exports = {
getAudioForwardState,
audioForwardEvents,
playTestAudio,
playUploadedAudio,
stopPlayback,
};