diff --git a/pi/bin/video-publisher.sh b/pi/bin/video-publisher.sh index d5a64f74..6d3f4b2e 100644 --- a/pi/bin/video-publisher.sh +++ b/pi/bin/video-publisher.sh @@ -23,13 +23,21 @@ AUDIO_DEVICE="${AUDIO_DEVICE:-plughw:0,0}" AUDIO_RATE="${AUDIO_RATE:-48000}" AUDIO_CHANNELS="${AUDIO_CHANNELS:-1}" AUDIO_BITRATE="${AUDIO_BITRATE:-24000}" +AUDIO_CODEC="${AUDIO_CODEC:-pcm_mulaw}" # Normalize device/rate to match the HAT capture; plughw handles any minor conversions. -# Keep audio encode light for the Pi Zero by using 16 kHz mono Opus at a low complexity. AUDIO_DEVICE="plughw:0,0" -AUDIO_RATE=16000 AUDIO_CHANNELS=1 -AUDIO_BITRATE=16000 +# Defaults chosen to keep Pi Zero CPU low. Opus can be enabled by setting AUDIO_CODEC=libopus. +if [[ "${AUDIO_CODEC}" == "libopus" ]]; then + AUDIO_RATE=16000 + AUDIO_BITRATE=16000 +else + # G.711 mu-law (or A-law if AUDIO_CODEC=pcm_alaw): very low CPU, 8 kHz mono, ~64 kbps. + AUDIO_CODEC="pcm_mulaw" + AUDIO_RATE=8000 + AUDIO_BITRATE=64000 +fi # Flip the camera 180deg (supported by rpicam-vid/libcamera-vid) FLIP_ARGS=(--rotation 180) @@ -89,14 +97,12 @@ run_pipeline() { -ar "${AUDIO_RATE}" \ -i "${AUDIO_DEVICE}" \ -c:v copy \ - -c:a libopus \ + -c:a "${AUDIO_CODEC}" \ -b:a "${AUDIO_BITRATE}" \ - -compression_level 0 \ - -application voip \ - -frame_duration 60 \ + $( [[ "${AUDIO_CODEC}" == "libopus" ]] && echo "-compression_level 0 -application voip -frame_duration 60" ) \ -ac:a "${AUDIO_CHANNELS}" \ -ar:a "${AUDIO_RATE}" \ - -af "pan=1c|c0=c0,volume=20dB" \ + -af "$( [[ "${AUDIO_CODEC}" == "libopus" ]] && echo "pan=1c|c0=c0," )volume=20dB" \ -flush_packets 1 \ -f mpegts \ "${PUBLISH_URL}" diff --git a/server/config.example.yaml b/server/config.example.yaml index 14977741..1f9939aa 100644 --- a/server/config.example.yaml +++ b/server/config.example.yaml @@ -12,6 +12,8 @@ media: # http:////whep # Example: http://192.168.0.86:8889/video whepBaseUrl: "http://192.168.0.86:8889/video" + # Set audioBridge to false to disable server-side audio transcoding from -raw -> + # audioBridge: true homeAssistant: url: "http://homeassistant.local:8123" diff --git a/server/index.js b/server/index.js index f2be3274..439902bc 100644 --- a/server/index.js +++ b/server/index.js @@ -26,3 +26,4 @@ require('./src/services/sessionService'); require('./src/services/batteryManager'); require('./src/services/discordBotService'); require('./src/services/httpServer'); +require('./src/services/audioBridgeService'); diff --git a/server/src/services/audioBridgeService.js b/server/src/services/audioBridgeService.js new file mode 100644 index 00000000..792378dc --- /dev/null +++ b/server/src/services/audioBridgeService.js @@ -0,0 +1,144 @@ +const { spawn } = require('child_process'); +const logger = require('../globals/logger').child('audioBridge'); +const { loadConfig } = require('../helpers/configLoader'); + +const config = loadConfig(); + +const MEDIAMTX_API = + process.env.MEDIAMTX_API || 'http://127.0.0.1:9997/v3/paths/list'; +const SRT_HOST = process.env.MEDIAMTX_SRT_HOST || '127.0.0.1'; +const POLL_MS = 5000; + +// Track active ffmpeg bridges keyed by raw path name (e.g., rover-raw). +const bridges = new Map(); + +function stopBridge(rawName) { + const proc = bridges.get(rawName); + if (!proc) return; + bridges.delete(rawName); + proc.kill('SIGTERM'); + logger.info('stopped bridge', { rawName }); +} + +function startBridge(rawName, baseName) { + if (bridges.has(rawName)) return; + + const inputUrl = `srt://${SRT_HOST}:9000?streamid=#!::r=${rawName},m=request&mode=caller&transtype=live&latency=20`; + const outputUrl = `srt://${SRT_HOST}:9000?streamid=#!::r=${baseName},m=publish&mode=caller&transtype=live&pkt_size=1316`; + + const args = [ + '-hide_banner', + '-loglevel', + 'warning', + '-fflags', + 'nobuffer', + '-thread_queue_size', + '512', + '-i', + inputUrl, + '-map', + '0:v', + '-map', + '0:a?', + '-c:v', + 'copy', + '-c:a', + 'libopus', + '-b:a', + '24000', + '-ac', + '1', + '-ar', + '16000', + '-application', + 'voip', + '-frame_duration', + '60', + '-af', + 'pan=1c|c0=c0,volume=12dB', + '-flush_packets', + '1', + '-f', + 'mpegts', + outputUrl, + ]; + + const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'pipe', 'pipe'] }); + bridges.set(rawName, proc); + logger.info('started bridge', { rawName, baseName }); + + proc.stdout.on('data', (data) => { + logger.debug(data.toString().trim()); + }); + proc.stderr.on('data', (data) => { + logger.debug(data.toString().trim()); + }); + proc.on('exit', (code, signal) => { + bridges.delete(rawName); + logger.info('bridge exited', { rawName, code, signal }); + }); +} + +async function fetchPaths() { + try { + const res = await fetch(MEDIAMTX_API, { signal: AbortSignal.timeout(3000) }); + if (!res.ok) { + throw new Error(`mediamtx api ${res.status}`); + } + return res.json(); + } catch (err) { + logger.warn('path fetch failed: %s', err.message); + return null; + } +} + +function pickRawPairs(items) { + const names = new Set(items.map((i) => i.name)); + return items + .filter((i) => i.name.endsWith('-raw')) + .map((raw) => { + const baseName = raw.name.slice(0, -4); + return { rawName: raw.name, baseName }; + }) + .filter(({ baseName }) => !names.has(baseName)); +} + +async function reconcile() { + const data = await fetchPaths(); + if (!data?.items) { + // On failure, stop all bridges so we don't run blind. + Array.from(bridges.keys()).forEach(stopBridge); + return; + } + + const activeRaw = new Set(); + pickRawPairs(data.items).forEach(({ rawName, baseName }) => { + activeRaw.add(rawName); + startBridge(rawName, baseName); + }); + + // Stop bridges whose raw path disappeared. + Array.from(bridges.keys()).forEach((rawName) => { + if (!activeRaw.has(rawName)) { + stopBridge(rawName); + } + }); +} + +function start() { + // Only enable when explicit media config allows it, or by default. + const enabled = config.media?.audioBridge !== false; + if (!enabled) { + logger.info('audio bridge disabled by config'); + return; + } + logger.info('audio bridge enabled; watching for *-raw streams'); + reconcile(); + setInterval(reconcile, POLL_MS); +} + +start(); + +module.exports = { + start, +};