Padding/probe packets use a sequence number but carry no audio. They were treated as lost, so each one played an invented 20 ms frame (a glitch) and pushed the buffer a frame deeper for drift correction to remove again. Signature in the first live test: lost ~= drift-drops (74 vs 72 per minute). They are now skipped and counted as 'padding'. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4.9 KiB
guestlink - MMO remote guest line
Self-hosted replacement for Cleanfeed: one remote guest, live audio both ways, plus an anonymous text chat between the guest and the host. Built 2026-11-02.
| What | Where |
|---|---|
| Guest page | https://guest.mmo.show/j/<token> (NPM on .158 -> 192.168.50.204:8790) |
| Host console | http://192.168.50.204:8791 (LAN/VPN only, never proxied) |
| Media | UDP 50090 - one port for all WebRTC traffic; forwarded on the router to .204 |
| Service | systemctl --user {status,restart} guestlink · logs: journalctl --user -u guestlink -f |
| Invite token | ~/.local/state/guestlink/token (0600) - survives restarts |
Audio path (same as motu-cleanfeed)
- guest voice ->
pacat-> sinkmotu_guest-> From Computer 1-2 -> guest strip motu_mixminus.monitor(To Computer 3-4, show minus guest) ->parec-> guest
Both are ordinary PipeWire client streams that exist only during a call. They never reopen
the MOTU. They carry node.dont-fallback/dont-reconnect/dont-move, so if motu_guest
disappears playback fails instead of landing on the default output (tested).
Restarting guestlink is safe for the rest of the rig. It only drops the guest's call.
Opus 48 kHz stereo, 128 kb/s, in-band FEC. The guest page disables browser echo cancellation, noise suppression and AGC. The "I'm wearing headphones" box is ticked by default; unticking it turns echo cancellation back on for guests on speakers.
The receive side has an adaptive jitter buffer (v2, 2026-11-04): 80 ms start, max 300 ms.
On an underrun it holds its place and refills to a larger target, so delayed packets are
played rather than thrown away. It corrects guest/MOTU clock drift in both directions by
dropping (guest fast) or adding (guest slow) a frame during a quiet moment. "Quiet" is
relative to the guest's own noise floor. The target rises on underruns and late packets
and relaxes after two calm minutes. Host console: buffer x/y ms, lost/FEC/concealed,
late, drift-drops, inserted, underruns.
Logs for diagnosing a bad night: one rx 1m: line a minute per call (buffer, losses,
underruns, drift corrections), the network route: when it is chosen, and call totals:
when it ends. journalctl --user -u guestlink --since ...
Why v2: v1 skipped ahead on an underrun and kept playing from an empty buffer, so after the
first hiccup every later wobble was another glitch. It also never corrected for a slow guest
clock. In simulation that was ~12 glitches/min with a guest clock 30 ppm slow, against ~0
for v2. The 3 Nov 2026 cohost "jitter" was probably this, but that night was not measured.
The simulation (and a padding regression test, test/pad_test.go.txt) is kept in test/jb_sim_test.go.txt (rename to _test.go in the package dir to run).
Padding fix (v2b, same day): browsers send RTP padding/probe packets that use a sequence
number but carry no audio. v1/v2 treated each one as a lost packet and played an invented
frame. That was an audible glitch, plus a frame the drift correction later had to remove
(signature in the logs: lost ~= drift-drops). They are now skipped and counted as padding.
The guest websocket is pinged every 25 s. Without that, the proxy closed it every 90 s (signalling only, audio unaffected, but the guest saw "reconnecting...").
Host console
New link (ends the session, clears chat), Revoke, Copy, End audio, Kick, Clear chat. It shows live meters for both directions and the selected network route. The page never plays sound (it is on the rig): a new guest message flashes the tab title.
Chat
Anonymous: no accounts, and the guest's name is optional (default "Guest"). The guest sees
the host as -host-label (default "Host"). Messages are kept in memory only (last 300),
are never written to disk or logs, and are cleared by New link / Clear / restart. Guest
messages are rate limited and rendered as plain text.
Public IP
Learned per call via STUN (google, then cloudflare), falling back to the A record of
guest.mmo.show. Override with -public-ip.
Build / test
cd ~/apps/guestlink && go build -tags nolibopusfile -o guestlink . && go build -tags nolibopusfile -o glprobe ./cmd/glprobe
systemctl --user restart guestlink # drops a live guest - not during a show
glprobe is a headless test guest. It creates a new link (ending the current session)
unless given -token. Safe offline test on spare ports, with no PipeWire playback:
./guestlink -public 127.0.0.1:18790 -host 127.0.0.1:18791 -udp 50091 \
-sink none -test-playout-file /tmp/rx.raw -state /tmp/glstate &
./glprobe -hostws ws://127.0.0.1:18791/ws -guest ws://127.0.0.1:18790 -tone -20
Fallback
motu-cleanfeed is untouched and still works. Don't run both at once: both would feed motu_guest.
Not done (v1)
One guest only. No TURN relay: a guest on a network that blocks all outbound UDP (some corporate/hotel Wi-Fi) cannot connect; they can use a phone hotspot. No recording.