Two browser apps for dev/testing WebRTC ingest and playback:
- Publisher (
src/publish/) — sends video/audio to a WHIP endpoint - Viewer (
src/view/) — plays video/audio from a WHEP endpoint
Plain JS, no bundler, no deps. Each app is a tiny Node HTTP server (no framework) plus a static HTML/JS/CSS page.
npm start # both: publisher on :8080, viewer on :8081
npm run publish # publisher only
npm run view # viewer onlyOpen http://localhost:8080/ (publisher) and http://localhost:8081/ (viewer). URLs are printed on startup.
Server files use node --watch, so editing server.js auto-restarts. Client files (HTML/JS/CSS) are served off disk per request — just refresh the browser.
Each app has its own config.jsonc (JSONC — // comments and trailing commas are fine; gitignored; see config.example.jsonc for the schema):
src/publish/config.jsonc—port,urls(WHIP endpoints),files(named mp4 paths — absolute,~-expanded, or relative to the repo root),noisePresetssrc/view/config.jsonc—port,urls(WHEP endpoints)
The first URL in urls is preselected on page load. Re-reads on every page load — no restart needed.
- Source: animated test pattern, camera + mic, or any mp4 file from config (looped). Test pattern uses
setInterval(not rAF) so it keeps painting in background tabs. - Codec: H264 / VP8 / VP9. "exclusive" checkbox uses strict
setCodecPreferencesfilter; unchecked = reorder with fallback. - Resolution / bitrate: target res via
scaleResolutionDownBy+maxBitrateviasetParameters. - Noise tuning (test pattern only): live sliders for count / size / intensity, seeded from
config.noisePresets. Forces the encoder to actually consume the bitrate budget. - mute audio (right of Stop): mutes the local preview
<video>(does not affect what's sent over WebRTC). - mute mic (right of Source): default ticked. Toggles
audioTrack.enabledon the camera+mic source — sends silence over the track, keeps connection alive. No effect on test-pattern / mp4 sources.
- Receive-only
<video>+<audio>transceivers.pc.ontrackadds tracks to a single MediaStream (some WHEP servers deliver each track in its own stream, so we can't just useev.streams[0]). - mute audio: default ticked.
Both apps log to a scrollable Log panel with copy / clear buttons. Cleared on each new session. The activity log shows:
- Local ICE candidates as gathered (
hostandsrflx— STUN provides the latter) - ICE / PC state transitions
- SDP offer/answer (collapsible, with their own copy/clear)
- Every 5s: codec, bytes/packets sent or received, frames encoded/decoded, NACK/PLI/retx counts, selected ICE candidate pair (types, addresses, RTT)
- Viewer also logs
<video>element events (playing,waiting,stalled,error, etc.) and per-track mute/unmute/ended
Status line shows: target → actual res/bitrate, fps, codec, quality-limit reason, ICE/PC state.
Both apps configure STUN servers (stun.cloudflare.com:3478, stun.l.google.com:19302). Without STUN the offer only carries host (LAN) candidates, which public WHIP/WHEP servers can't reach.
ICE gathering is capped at 2s (Chrome can spend ~40s otherwise waiting on TCP/IPv6 probes to time out). The log line offer ready (ice-gather: timeout, gathering) is expected and harmless.
Publishing an mp4 file uses HTMLMediaElement.captureStream(), which the WebRTC spec explicitly permits to lose audio/video sync. It can vary run-to-run (audio leads or lags by tens to a few hundred ms) and degrades within a session as the browser's video encoder stalls under load (worse at 1080p). The local preview <video> stays in sync so it looks fine in the publishing browser, but the published RTP is offset.
- For sync-faithful capture, publish with a real encoder (OBS, etc.), not the mp4 source.
- Only the mp4 file source is affected: the test-pattern source makes audio via Web Audio and the camera source uses
getUserMedia, so neither goes through media-elementcaptureStream.
MIT — see LICENSE.

