6.7 KiB
guestlink - MMO remote guest line (branded "JohnFeed" on both pages since 2026-11-06)
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> (a TLS reverse proxy -> this machine :8790, websockets on) |
| Host console | http://<this machine>:8791 (LAN/VPN only, never proxied) |
| Media | UDP 50090 - one port for all WebRTC traffic; forwarded on the router to this machine |
| 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.
Guest page controls: Mute my mic (with a clear "the show cannot hear you" state), Hear yourself (own mic in their headphones; refused when the headphones box is unticked), and a "What is happening" panel. That panel logs connection events plus stats every 15 s in both directions, with no IP addresses. It stays in the guest's browser until they press Copy and send it to you (chat or email). Ask for it first when a guest sounds wrong.
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.
Tally (On air / Off air), added 2026-11-06: a button on the console sets a big ON AIR / Off air
bar on the guest's page. It is only a sign: it changes no audio, and the guest is heard
whenever their fader is up. Automatic since 2026-11-06 (-tally-auto, on by default): it goes ON AIR when the MMO
Stream app goes LIVE and Off air when it stops. Detection: any darkice process holding an
established TCP connection (ss -tnp), which happens when a stream server accepts it.
Read-only; mmo-stream itself is unchanged. ON needs two 2 s polls; OFF waits 15 s, which rides
out mmo-stream's 5 s reconnect. It acts only when the stream state changes, so a manual
click on the console sticks until the stream next starts or stops. New link / Revoke set it
to match the stream. -tally-auto=false makes it manual only.
Connection warning, added 2026-11-06: a banner on the console when the guest -> show audio is audibly struggling over the last 10 s. "Wobbling" means 3 or more patched gaps (concealed frames) or one buffer dropout. "Bad" (red) means 25 or more gaps, 3 or more dropouts, or the connection dropping. FEC recoveries are inaudible and do not count. When it turns red and the tab is in the background, the tab title flashes (no sound).
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.