WebRTC audio mirror: AAC cannot cross WebRTC, so transcode audio to Opus into <path>.webrtc

bin/mtx-on-ready.sh (runOnReady inside the mediamtx container) posts the is-live notice and, for sources whose
audio is not Opus, runs ffmpeg: video copy, audio -> Opus, published over MediaMTX's loopback RTSP to
<user>/<Title>.webrtc (record: no). dumpd accepts .webrtc publishes only from 127.0.0.1. The portal hides
mirror paths, plays the mirror over WHEP (HLS stays on the source), holds an AAC source for 15 s while its
mirror comes up, and corrects the viewer count. Verified headless with a mocked path list.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 13:58:15 -06:00
parent f060277167
commit 9bdf9f1947
8 changed files with 99 additions and 26 deletions

View File

@@ -6,6 +6,7 @@ import { audio, effectiveMuted } from './useAudio.js'
// WebRTC (WHEP) first, HLS fallback. Mute/volume come from the shared audio store.
export default function Player({ params, api }) {
const { id, user, title } = params
const whep = params.whep || id // Opus audio mirror path when the source audio is AAC
const videoRef = useRef(null)
const [status, setStatus] = useState('connecting…')
const [viewers, setViewers] = useState(params.viewers ?? 0)
@@ -78,7 +79,7 @@ export default function Player({ params, api }) {
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
await new Promise(res => { if (pc.iceGatheringState === 'complete') return res(); const t = setTimeout(res, 1500); pc.onicegatheringstatechange = () => { if (pc.iceGatheringState === 'complete') { clearTimeout(t); res() } } })
const r = await fetch(whepUrl(id), { method: 'POST', headers: { 'Content-Type': 'application/sdp' }, body: pc.localDescription.sdp, credentials: 'same-origin' })
const r = await fetch(whepUrl(whep), { method: 'POST', headers: { 'Content-Type': 'application/sdp' }, body: pc.localDescription.sdp, credentials: 'same-origin' })
if (!r.ok) throw new Error('whep ' + r.status)
await pc.setRemoteDescription({ type: 'answer', sdp: await r.text() })
setStatus('webrtc')
@@ -90,7 +91,7 @@ export default function Player({ params, api }) {
}
startWhep()
return () => { cancelled = true; try { pc?.close() } catch {}; try { hls?.destroy() } catch {}; v.srcObject = null; v.removeAttribute('src') }
}, [id])
}, [id, whep])
const icon = muted ? '🔇' : volume === 0 ? '🔈' : volume < 0.5 ? '🔉' : '🔊'
return (

View File

@@ -1,22 +1,37 @@
// MediaMTX control API, proxied by Caddy at /api (-> 127.0.0.1:9997). Returns active streams.
const VIDEO = new Set(['H264', 'H265', 'VP8', 'VP9', 'AV1', 'MJPEG', 'M-JPEG', 'MPEG-4 Video', 'MPEG-1 Video'])
const firstSeen = new Map() // path -> first time we saw it ready (mirror grace period)
const MIRROR_WAIT_MS = 15000
export async function fetchStreams() {
const r = await fetch('/api/v3/paths/list?itemsPerPage=200', { credentials: 'same-origin' })
if (!r.ok) throw new Error(`api ${r.status}`)
const data = await r.json()
return (data.items || [])
.filter(p => p.ready && (p.tracks || []).length > 0)
.map(p => {
const [user, ...rest] = p.name.split('/')
return {
id: p.name, // e.g. techy/VanguardTraining
user,
title: rest.join('/') || p.name,
viewers: (p.readers || []).length,
since: p.readyTime,
tracks: p.tracks || [],
}
const ready = (data.items || []).filter(p => p.ready && (p.tracks || []).length > 0)
const byName = new Map(ready.map(p => [p.name, p]))
const now = Date.now()
for (const p of ready) if (!firstSeen.has(p.name)) firstSeen.set(p.name, now)
for (const k of [...firstSeen.keys()]) if (!byName.has(k)) firstSeen.delete(k)
const out = []
for (const p of ready) {
if (p.name.endsWith('.webrtc')) continue // audio mirror: played through its source's panel, never its own
const tracks = p.tracks || []
const audio = tracks.filter(t => !VIDEO.has(t))
const needsMirror = audio.length > 0 && !audio.includes('Opus') // AAC etc. cannot cross WebRTC
const mirror = byName.get(p.name + '.webrtc')
if (needsMirror && !mirror && now - firstSeen.get(p.name) < MIRROR_WAIT_MS) continue // give the mirror a moment to come up
const [user, ...rest] = p.name.split('/')
out.push({
id: p.name, // e.g. techy/VanguardTraining
user,
title: rest.join('/') || p.name,
whep: mirror ? mirror.name : p.name, // WebRTC plays the Opus mirror when there is one; HLS always the source
viewers: (p.readers || []).length + (mirror ? (mirror.readers || []).length - 1 : 0), // the mirror's ffmpeg is one reader
since: p.readyTime,
tracks,
})
.sort((a, b) => (a.since || '').localeCompare(b.since || ''))
}
return out.sort((a, b) => (a.since || '').localeCompare(b.since || ''))
}
export const whepUrl = id => `/webrtc/${id}/whep`