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,
+};