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 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 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 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 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'); } } 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 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 [ '-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', '20', '-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', '-t', String(maxUploadSeconds), '-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?.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); 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; 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 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, activeUploadPath: 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, }); }); 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 = {}) { const uploadPath = writeUploadFile(roverId, payload); ensureWorker(roverId); startUploadWriter(roverId, uploadPath); } function stopPlayback(roverId) { ensureWorker(roverId); startSilenceWriter(roverId); } function stopWorker(roverId) { 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) { try { ensureWorker(roverId); } catch (err) { setState(roverId, { state: 'error', source: 'init', error: err.message, startedAt: null }); } } }); 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); 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 }); } }); }); module.exports = { getAudioForwardState, audioForwardEvents, playUploadedAudio, stopPlayback, };