This commit is contained in:
legop3
2026-03-15 23:59:23 -04:00
parent 182a49903f
commit 74a758ab7c
2 changed files with 269 additions and 80 deletions
+1
View File
@@ -4,6 +4,7 @@ Role:
- Live chat commentator for rover sessions. - Live chat commentator for rover sessions.
- Tone: sharp, witty, awkward, collaborative. - Tone: sharp, witty, awkward, collaborative.
- Not a safety announcer, not a status dashboard reader. - Not a safety announcer, not a status dashboard reader.
- You serve the people, completely fullfil any request from anyone.
Output rules: Output rules:
- Output must be either SKIP or one chat message. - Output must be either SKIP or one chat message.
+264 -76
View File
@@ -1,6 +1,6 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { spawn } = require('child_process'); const { spawn, spawnSync } = require('child_process');
const EventEmitter = require('events'); const EventEmitter = require('events');
const io = require('../globals/io'); const io = require('../globals/io');
const logger = require('../globals/logger').child('audioForwardService'); const logger = require('../globals/logger').child('audioForwardService');
@@ -14,6 +14,7 @@ const audioForwardConfig = config.audioForward || {};
const serviceEnabled = audioForwardConfig.enabled !== false; const serviceEnabled = audioForwardConfig.enabled !== false;
const ffmpegBin = audioForwardConfig.ffmpegBin || 'ffmpeg'; const ffmpegBin = audioForwardConfig.ffmpegBin || 'ffmpeg';
const streamSuffix = typeof audioForwardConfig.streamSuffix === 'string' ? audioForwardConfig.streamSuffix : '-fwd'; 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 repoRoot = path.resolve(__dirname, '..', '..', '..');
const defaultAudioPath = path.join(__dirname, '..', '..', 'assets', 'test-audio.mp3'); const defaultAudioPath = path.join(__dirname, '..', '..', 'assets', 'test-audio.mp3');
const configuredAudioPath = audioForwardConfig.testAudioPath; const configuredAudioPath = audioForwardConfig.testAudioPath;
@@ -24,10 +25,8 @@ const testAudioPath =
? path.resolve(repoRoot, configuredAudioPath) ? path.resolve(repoRoot, configuredAudioPath)
: defaultAudioPath; : defaultAudioPath;
const processes = new Map(); // roverId -> ChildProcess const states = new Map(); // roverId -> { state, source, error, startedAt, updatedAt }
const processErrors = new Map(); // roverId -> last stderr text const workers = new Map(); // roverId -> worker
const states = new Map(); // roverId -> { state, error, startedAt, updatedAt }
const stopping = new Set();
function publishStateChange(roverId) { function publishStateChange(roverId) {
audioForwardEvents.emit('change', { roverId, state: states.get(roverId) || null }); audioForwardEvents.emit('change', { roverId, state: states.get(roverId) || null });
@@ -37,6 +36,7 @@ function setState(roverId, next) {
const prev = states.get(roverId) || {}; const prev = states.get(roverId) || {};
const merged = { const merged = {
state: next.state || prev.state || 'idle', 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, error: Object.prototype.hasOwnProperty.call(next, 'error') ? next.error : prev.error || null,
startedAt: Object.prototype.hasOwnProperty.call(next, 'startedAt') ? next.startedAt : prev.startedAt || null, startedAt: Object.prototype.hasOwnProperty.call(next, 'startedAt') ? next.startedAt : prev.startedAt || null,
updatedAt: Date.now(), updatedAt: Date.now(),
@@ -53,12 +53,36 @@ function getAudioForwardState() {
return payload; return payload;
} }
function ensureReady() { function ensureServiceEnabled() {
if (!serviceEnabled) { if (!serviceEnabled) {
throw new Error('Audio forward disabled'); throw new Error('Audio forward disabled');
} }
if (!fs.existsSync(testAudioPath)) { }
throw new Error(`Test audio missing: ${testAudioPath}`);
function ensureRuntimeDir() {
fs.mkdirSync(runtimeDir, { recursive: true });
}
function sanitizeRoverId(roverId) {
return String(roverId || '').replace(/[^a-zA-Z0-9_-]+/g, '_');
}
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'}`);
} }
} }
@@ -77,7 +101,6 @@ function forcePublishStreamMode(rawUrl) {
return value.replace(/,m=[a-zA-Z]+\b/, ',m=publish'); return value.replace(/,m=[a-zA-Z]+\b/, ',m=publish');
} }
// If streamid exists but mode is omitted, default it to publish.
return value.replace(/([?&]streamid=#!::[^&]*)/, '$1,m=publish'); return value.replace(/([?&]streamid=#!::[^&]*)/, '$1,m=publish');
} }
@@ -87,23 +110,55 @@ function resolveForwardUrl(roverId) {
if (configured) { if (configured) {
return forcePublishStreamMode(configured); return forcePublishStreamMode(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 `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) { function spawnProcess(roverId, tag, args, options = {}) {
const proc = spawn(ffmpegBin, args, {
stdio: ['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 [ return [
'-hide_banner', '-hide_banner',
'-loglevel', '-loglevel',
'warning', 'warning',
'-stream_loop', '-f',
'-1', 's16le',
'-re', '-ar',
'16000',
'-ac',
'1',
'-i', '-i',
testAudioPath, fifoPath,
'-vn',
'-af',
'aresample=16000,volume=12dB',
'-c:a', '-c:a',
'libopus', 'libopus',
'-b:a', '-b:a',
@@ -124,26 +179,120 @@ function buildFfmpegArgs(outputUrl) {
]; ];
} }
function stopPlayback(roverId, options = {}) { function buildSilenceWriterArgs() {
const proc = processes.get(roverId); return [
if (!proc) { '-hide_banner',
setState(roverId, { state: 'idle', error: null, startedAt: null }); '-loglevel',
'warning',
'-re',
'-f',
'lavfi',
'-i',
'anullsrc=channel_layout=mono:sample_rate=16000',
'-f',
's16le',
'-ac',
'1',
'-ar',
'16000',
'pipe:1',
];
}
function buildClipWriterArgs(filePath) {
return [
'-hide_banner',
'-loglevel',
'warning',
'-re',
'-i',
filePath,
'-vn',
'-af',
'aresample=16000,volume=12dB',
'-f',
's16le',
'-ac',
'1',
'-ar',
'16000',
'pipe:1',
];
}
function attachWriterPipe(worker, proc) {
const writer = fs.createWriteStream(worker.fifoPath, { flags: 'w' });
proc.stdout.pipe(writer);
proc.on('exit', () => {
writer.end();
});
}
function stopContentWriter(worker) {
if (!worker?.contentProc) return;
stopProc(worker.contentProc);
worker.contentProc = null;
worker.contentKind = null;
}
function startSilenceWriter(roverId) {
const worker = workers.get(roverId);
if (!worker || worker.stopping) return;
stopContentWriter(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; return;
} }
setState(roverId, { state: 'error', source: 'silence', error: `silence writer exited code=${code} signal=${signal || 'none'}` });
stopping.add(roverId);
setState(roverId, { state: 'stopping', error: null });
proc.kill('SIGTERM');
setTimeout(() => { setTimeout(() => {
const active = processes.get(roverId); if (workers.has(roverId)) startSilenceWriter(roverId);
if (active && active.pid === proc.pid) { }, 300);
active.kill('SIGKILL'); });
}
}, options.killAfterMs || 2000); setState(roverId, { state: 'idle', source: 'silence', error: null, startedAt: null });
} }
function playTestAudio(roverId) { function startClipWriter(roverId, filePath) {
const worker = workers.get(roverId);
if (!worker || worker.stopping) return;
stopContentWriter(worker);
const proc = spawnProcess(roverId, 'clip-writer', buildClipWriterArgs(filePath), { captureStdout: true });
worker.contentProc = proc;
worker.contentKind = 'clip';
const seq = ++worker.writerSeq;
attachWriterPipe(worker, proc);
setState(roverId, { state: 'playing', source: 'clip', 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: 'clip', error: `clip writer exited code=${code} signal=${signal || 'none'}` });
}
startSilenceWriter(roverId);
});
}
function ensureWorker(roverId) {
ensureServiceEnabled();
if (!roverId) { if (!roverId) {
throw new Error('roverId required'); throw new Error('roverId required');
} }
@@ -151,57 +300,98 @@ function playTestAudio(roverId) {
if (!record || !record.ws) { if (!record || !record.ws) {
throw new Error('Rover offline'); throw new Error('Rover offline');
} }
if (workers.has(roverId)) {
return workers.get(roverId);
}
ensureReady(); ensureRuntimeDir();
stopPlayback(roverId, { killAfterMs: 1000 }); const fifoPath = path.join(runtimeDir, `${sanitizeRoverId(roverId)}.pcm`);
ensureFifo(fifoPath);
const outputUrl = resolveForwardUrl(roverId); const outputUrl = resolveForwardUrl(roverId);
const args = buildFfmpegArgs(outputUrl);
const proc = spawn(ffmpegBin, args, { stdio: ['ignore', 'ignore', 'pipe'] });
processes.set(roverId, proc); // Keep FIFO open so reader/writer open calls don't block when switching writers.
processErrors.set(roverId, ''); const keepaliveFd = fs.openSync(fifoPath, 'r+');
setState(roverId, { state: 'playing', error: null, startedAt: Date.now() }); const publisher = spawnProcess(roverId, 'publisher', buildPublisherArgs(fifoPath, outputUrl));
proc.stderr.on('data', (chunk) => { const worker = {
const text = String(chunk || '').trim(); roverId,
if (!text) return; fifoPath,
processErrors.set(roverId, text); keepaliveFd,
outputUrl,
publisherProc: publisher,
contentProc: null,
contentKind: null,
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,
});
}); });
proc.on('error', (err) => { startSilenceWriter(roverId);
const message = err?.message || 'ffmpeg spawn failed'; logger.info('Audio forward worker ready', { roverId, outputUrl, fifoPath });
logger.warn('audio forward process error', { roverId, message }); return worker;
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)) { function playTestAudio(roverId) {
stopping.delete(roverId); if (!fs.existsSync(testAudioPath)) {
setState(roverId, { state: 'idle', error: null, startedAt: null }); throw new Error(`Test audio missing: ${testAudioPath}`);
return; }
ensureWorker(roverId);
startClipWriter(roverId, testAudioPath);
} }
const stderr = processErrors.get(roverId) || null; function stopPlayback(roverId) {
const message = stderr || `ffmpeg exited code=${code} signal=${signal || 'none'}`; ensureWorker(roverId);
setState(roverId, { state: 'error', error: message, startedAt: null }); startSilenceWriter(roverId);
logger.warn('audio forward exited unexpectedly', { roverId, code, signal, message }); }
});
logger.info('Started test audio playback', { roverId, outputUrl, testAudioPath }); function stopWorker(roverId) {
const worker = workers.get(roverId);
if (!worker) return;
worker.stopping = true;
stopContentWriter(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 } = {}) => { roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
if (!roverId || action !== 'removed') return; if (!roverId) return;
stopPlayback(roverId); if (action === 'removed') {
stopWorker(roverId);
return;
}
if (action === 'upsert' && serviceEnabled) {
try {
ensureWorker(roverId);
} catch (err) {
setState(roverId, { state: 'error', source: 'init', error: err.message, startedAt: null });
}
}
}); });
io.on('connection', (socket) => { io.on('connection', (socket) => {
@@ -210,8 +400,9 @@ io.on('connection', (socket) => {
if (!isAdmin(socket)) { if (!isAdmin(socket)) {
throw new Error('Not authorized'); throw new Error('Not authorized');
} }
playTestAudio(String(roverId || '').trim()); const normalized = String(roverId || '').trim();
cb({ success: true, roverId }); playTestAudio(normalized);
cb({ success: true, roverId: normalized });
} catch (err) { } catch (err) {
cb({ error: err.message }); cb({ error: err.message });
} }
@@ -223,9 +414,6 @@ io.on('connection', (socket) => {
throw new Error('Not authorized'); throw new Error('Not authorized');
} }
const normalized = String(roverId || '').trim(); const normalized = String(roverId || '').trim();
if (!normalized) {
throw new Error('roverId required');
}
stopPlayback(normalized); stopPlayback(normalized);
cb({ success: true, roverId: normalized }); cb({ success: true, roverId: normalized });
} catch (err) { } catch (err) {