guestlink v1: self-hosted guest line (Cleanfeed replacement)

Go + Pion + libopus. One guest, live audio both ways, anonymous chat.
README for this version was not preserved; see the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
namandClaude Opus 5.5 committed 2026-11-02 16:14:00 -06:00
commit b0e3b805b1
13 files changed
+1839

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
/guestlink
/guestlink.new
/glprobe
/backup-*/
*.raw
+653
View File
@@ -0,0 +1,653 @@
package main
import (
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"log"
"math"
"net"
"os"
"os/exec"
"sync"
"sync/atomic"
"time"
"github.com/pion/interceptor"
"github.com/pion/rtp"
"github.com/pion/stun/v4"
"github.com/pion/webrtc/v4"
"github.com/pion/webrtc/v4/pkg/media"
"golang.org/x/sys/unix"
"gopkg.in/hraban/opus.v2"
)
const (
rate = 48000
channels = 2
frameSamps = 960 // 20 ms per channel
opusFmtp = "minptime=10;useinbandfec=1;stereo=1;sprop-stereo=1;maxaveragebitrate=128000"
)
type Call struct {
pc *webrtc.PeerConnection
track *webrtc.TrackLocalStaticSample
ctx context.Context
cancel context.CancelFunc
once sync.Once
started time.Time
pcState atomic.Value // string
iceState atomic.Value // string
route atomic.Value // string: selected candidate pair
outPeak atomic.Uint32 // mix-minus to guest, peak*1e4 since last status
inPeak atomic.Uint32 // guest to show
jb *jitterBuffer
jbMu sync.Mutex
sendOn atomic.Bool
playOn atomic.Bool
}
var (
pubIPMu sync.Mutex
pubIPCache string
pubIPAt time.Time
)
// publicIP returns the rig's public IPv4: -public-ip if set, else a STUN query (works no
// matter how DNS / Cloudflare proxying is set up), else the A record of -public-host.
func publicIP() (string, error) {
if *publicIPFlag != "" {
return *publicIPFlag, nil
}
pubIPMu.Lock()
defer pubIPMu.Unlock()
if pubIPCache != "" && time.Since(pubIPAt) < 10*time.Minute {
return pubIPCache, nil
}
for _, srv := range []string{"stun.l.google.com:19302", "stun.cloudflare.com:3478"} {
if ip, err := stunIP(srv); err == nil {
pubIPCache, pubIPAt = ip, time.Now()
return ip, nil
} else {
log.Printf("stun %s: %v", srv, err)
}
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
ips, err := net.DefaultResolver.LookupIP(ctx, "ip4", *publicHost)
if err != nil || len(ips) == 0 {
return "", fmt.Errorf("no public IP (stun failed; resolve %s: %v)", *publicHost, err)
}
return ips[0].String(), nil
}
func stunIP(server string) (string, error) {
conn, err := net.DialTimeout("udp4", server, 2*time.Second)
if err != nil {
return "", err
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(2 * time.Second))
msg := stun.MustBuild(stun.TransactionID, stun.BindingRequest)
if _, err := conn.Write(msg.Raw); err != nil {
return "", err
}
buf := make([]byte, 1500)
n, err := conn.Read(buf)
if err != nil {
return "", err
}
res := &stun.Message{Raw: buf[:n]}
if err := res.Decode(); err != nil {
return "", err
}
var xor stun.XORMappedAddress
if err := xor.GetFrom(res); err != nil {
return "", err
}
if xor.IP.To4() == nil {
return "", errors.New("not ipv4")
}
return xor.IP.String(), nil
}
func newAPI() (*webrtc.API, error) {
m := &webrtc.MediaEngine{}
if err := m.RegisterCodec(webrtc.RTPCodecParameters{
RTPCodecCapability: webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus, ClockRate: rate, Channels: 2, SDPFmtpLine: opusFmtp},
PayloadType: 111,
}, webrtc.RTPCodecTypeAudio); err != nil {
return nil, err
}
ir := &interceptor.Registry{}
if err := webrtc.RegisterDefaultInterceptors(m, ir); err != nil {
return nil, err
}
_, lan, err := net.ParseCIDR(*lanCIDR)
if err != nil {
return nil, err
}
se := webrtc.SettingEngine{}
se.SetICEUDPMux(hub.mux)
se.SetNetworkTypes([]webrtc.NetworkType{webrtc.NetworkTypeUDP4})
se.SetIPFilter(func(ip net.IP) bool { return lan.Contains(ip) })
se.SetICETimeouts(8*time.Second, 25*time.Second, 2*time.Second)
if pip, err := publicIP(); err == nil {
// Keep the LAN host candidate and ADD one with the public IP on the same port.
// Needs UDP <udp> forwarded to this machine on the router.
if err := se.SetICEAddressRewriteRules(webrtc.ICEAddressRewriteRule{
External: []string{pip}, AsCandidateType: webrtc.ICECandidateTypeHost, Mode: webrtc.ICEAddressRewriteAppend,
}); err != nil {
return nil, err
}
} else {
log.Printf("warning: %v - offering LAN candidate only", err)
}
return webrtc.NewAPI(webrtc.WithMediaEngine(m), webrtc.WithInterceptorRegistry(ir), webrtc.WithSettingEngine(se)), nil
}
func newCall(offerSDP string) (*Call, string, error) {
api, err := newAPI()
if err != nil {
return nil, "", err
}
pc, err := api.NewPeerConnection(webrtc.Configuration{})
if err != nil {
return nil, "", err
}
ctx, cancel := context.WithCancel(context.Background())
c := &Call{pc: pc, ctx: ctx, cancel: cancel, started: time.Now()}
c.pcState.Store("new")
c.iceState.Store("new")
c.route.Store("")
fail := func(err error) (*Call, string, error) { c.Close(); return nil, "", err }
if err := pc.SetRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: offerSDP}); err != nil {
return fail(err)
}
track, err := webrtc.NewTrackLocalStaticSample(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus, ClockRate: rate, Channels: 2, SDPFmtpLine: opusFmtp},
"audio", "mmo-mixminus")
if err != nil {
return fail(err)
}
c.track = track
sender, err := pc.AddTrack(track)
if err != nil {
return fail(err)
}
go func() { // drain RTCP so interceptors work
buf := make([]byte, 1500)
for {
if _, _, err := sender.Read(buf); err != nil {
return
}
}
}()
pc.OnICEConnectionStateChange(func(s webrtc.ICEConnectionState) {
c.iceState.Store(s.String())
log.Printf("ice: %s", s)
if s == webrtc.ICEConnectionStateConnected {
c.updateRoute()
}
})
pc.OnConnectionStateChange(func(s webrtc.PeerConnectionState) {
c.pcState.Store(s.String())
log.Printf("peer connection: %s", s)
switch s {
case webrtc.PeerConnectionStateConnected:
c.updateRoute()
c.startSend()
case webrtc.PeerConnectionStateFailed, webrtc.PeerConnectionStateClosed:
hub.mu.Lock()
mine := hub.call == c
hub.mu.Unlock()
if mine {
go hub.endCall()
}
}
go hub.pushState()
})
pc.OnTrack(func(t *webrtc.TrackRemote, _ *webrtc.RTPReceiver) {
if t.Kind() != webrtc.RTPCodecTypeAudio {
return
}
log.Printf("guest track: %s %dHz ch=%d", t.Codec().MimeType, t.Codec().ClockRate, t.Codec().Channels)
c.startPlay(t)
})
answer, err := pc.CreateAnswer(nil)
if err != nil {
return fail(err)
}
gather := webrtc.GatheringCompletePromise(pc)
if err := pc.SetLocalDescription(answer); err != nil {
return fail(err)
}
select {
case <-gather:
case <-time.After(3 * time.Second):
}
return c, pc.LocalDescription().SDP, nil
}
func (c *Call) updateRoute() {
for _, tr := range c.pc.GetTransceivers() {
if s := tr.Sender(); s != nil && s.Transport() != nil {
ice := s.Transport().ICETransport()
if ice == nil {
continue
}
if pair, err := ice.GetSelectedCandidatePair(); err == nil && pair != nil {
c.route.Store(fmt.Sprintf("%s:%d <-> %s:%d (%s)", pair.Local.Address, pair.Local.Port, pair.Remote.Address, pair.Remote.Port, pair.Remote.Typ))
return
}
}
}
}
func (c *Call) Close() {
c.once.Do(func() {
c.cancel()
c.pc.Close()
log.Printf("call closed after %s", time.Since(c.started).Round(time.Second))
})
}
func (c *Call) status() map[string]any {
st := map[string]any{
"pc": c.pcState.Load(),
"ice": c.iceState.Load(),
"route": c.route.Load(),
"secs": int(time.Since(c.started).Seconds()),
"sending": c.sendOn.Load(),
"playing": c.playOn.Load(),
"outPeak": float64(c.outPeak.Swap(0)) / 1e4,
"inPeak": float64(c.inPeak.Swap(0)) / 1e4,
}
c.jbMu.Lock()
if c.jb != nil {
st["jb"] = c.jb.stats()
}
c.jbMu.Unlock()
return st
}
func storeMax(a *atomic.Uint32, v uint32) {
for {
o := a.Load()
if v <= o || a.CompareAndSwap(o, v) {
return
}
}
}
func peakOf(pcm []int16) uint32 {
var p int32
for _, s := range pcm {
v := int32(s)
if v < 0 {
v = -v
}
if v > p {
p = v
}
}
return uint32(float64(p) / 32768 * 1e4)
}
// ---------------------------------------------------------------- mix-minus -> guest
func (c *Call) startSend() {
if c.sendOn.Swap(true) {
return
}
go func() {
defer c.sendOn.Store(false)
for c.ctx.Err() == nil {
if err := c.sendLoop(); err != nil && c.ctx.Err() == nil {
log.Printf("send: %v - retrying in 1s", err)
select {
case <-c.ctx.Done():
case <-time.After(time.Second):
}
}
}
}()
}
func (c *Call) sendLoop() error {
cmd := exec.CommandContext(c.ctx, "parec",
"-d", *sourceName, "--raw", "--format=s16le", fmt.Sprintf("--rate=%d", rate), fmt.Sprintf("--channels=%d", channels),
"--latency-msec=20", "--property=node.dont-fallback=true", "--property=node.dont-reconnect=true", "--property=node.dont-move=true", "--client-name=guestlink", "--stream-name=mix-minus to guest")
out, err := cmd.StdoutPipe()
if err != nil {
return err
}
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
return err
}
defer cmd.Wait()
defer cmd.Process.Kill()
enc, err := opus.NewEncoder(rate, channels, opus.AppAudio)
if err != nil {
return err
}
enc.SetBitrate(128000)
enc.SetInBandFEC(true)
enc.SetPacketLossPerc(5)
enc.SetComplexity(9)
raw := make([]byte, frameSamps*channels*2)
pcm := make([]int16, frameSamps*channels)
pkt := make([]byte, 4000)
for {
if _, err := io.ReadFull(out, raw); err != nil {
if c.ctx.Err() != nil {
return nil
}
return fmt.Errorf("parec read: %w", err)
}
for i := range pcm {
pcm[i] = int16(binary.LittleEndian.Uint16(raw[i*2:]))
}
storeMax(&c.outPeak, peakOf(pcm))
n, err := enc.Encode(pcm, pkt)
if err != nil {
return fmt.Errorf("opus encode: %w", err)
}
data := make([]byte, n)
copy(data, pkt[:n])
if err := c.track.WriteSample(media.Sample{Data: data, Duration: 20 * time.Millisecond}); err != nil && !errors.Is(err, io.ErrClosedPipe) {
return fmt.Errorf("write sample: %w", err)
}
}
}
// ---------------------------------------------------------------- guest -> show
func (c *Call) startPlay(t *webrtc.TrackRemote) {
dec, err := opus.NewDecoder(rate, channels)
if err != nil {
log.Printf("decoder: %v", err)
return
}
jb := newJitterBuffer(dec)
c.jbMu.Lock()
c.jb = jb
c.jbMu.Unlock()
go func() { // network -> jitter buffer
for {
p, _, err := t.ReadRTP()
if err != nil {
return
}
jb.push(p)
}
}()
go func() { // jitter buffer -> pacat, paced by the MOTU clock via a tiny pipe
c.playOn.Store(true)
defer c.playOn.Store(false)
for c.ctx.Err() == nil {
if err := c.playLoop(jb); err != nil && c.ctx.Err() == nil {
log.Printf("play: %v - retrying in 1s", err)
select {
case <-c.ctx.Done():
case <-time.After(time.Second):
}
}
}
}()
}
func (c *Call) playLoop(jb *jitterBuffer) error {
if *testPlayoutFile != "" {
return c.playToFile(jb)
}
r, w, err := os.Pipe()
if err != nil {
return err
}
// Small pipe => writes block on the sink's consumption => the MOTU clock paces us,
// and the jitter buffer depth tells us about clock drift.
unix.FcntlInt(w.Fd(), unix.F_SETPIPE_SZ, 4096)
cmd := exec.CommandContext(c.ctx, "pacat", "--playback",
"-d", *sinkName, "--raw", "--format=s16le", fmt.Sprintf("--rate=%d", rate), fmt.Sprintf("--channels=%d", channels),
"--latency-msec=30", "--property=node.dont-fallback=true", "--property=node.dont-reconnect=true", "--property=node.dont-move=true", "--client-name=guestlink", "--stream-name=guest voice")
cmd.Stdin = r
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
r.Close()
w.Close()
return err
}
r.Close()
defer cmd.Wait()
defer cmd.Process.Kill()
defer w.Close()
buf := make([]byte, 0, 5760*channels*2)
for c.ctx.Err() == nil {
pcm := jb.next()
storeMax(&c.inPeak, peakOf(pcm))
buf = buf[:0]
for _, s := range pcm {
buf = binary.LittleEndian.AppendUint16(buf, uint16(s))
}
if _, err := w.Write(buf); err != nil {
if c.ctx.Err() != nil {
return nil
}
return fmt.Errorf("pacat write: %w", err)
}
}
return nil
}
// playToFile is a test path: playout to a raw PCM file paced by a 20 ms ticker instead of
// the sound card, so the receive chain can be verified without touching PipeWire.
func (c *Call) playToFile(jb *jitterBuffer) error {
f, err := os.Create(*testPlayoutFile)
if err != nil {
return err
}
defer f.Close()
t := time.NewTicker(20 * time.Millisecond)
defer t.Stop()
buf := make([]byte, 0, 5760*channels*2)
for {
select {
case <-c.ctx.Done():
return nil
case <-t.C:
}
pcm := jb.next()
storeMax(&c.inPeak, peakOf(pcm))
buf = buf[:0]
for _, s := range pcm {
buf = binary.LittleEndian.AppendUint16(buf, uint16(s))
}
f.Write(buf)
}
}
// ---------------------------------------------------------------- jitter buffer
// jitterBuffer reorders Opus packets, conceals losses (FEC, then PLC), adapts its depth to
// network jitter, and corrects clock drift between the guest's sound card and the MOTU by
// discarding quiet frames when it runs too deep.
type jitterBuffer struct {
mu sync.Mutex
dec *opus.Decoder
pkts map[uint16][]byte
nextSeq uint16
started bool
target int // frames to hold before starting / after an underrun
misses int
lastUnd time.Time
pcm []int16
lost int
fec int
plc int
dropped int
underrun int
late int
}
func newJitterBuffer(dec *opus.Decoder) *jitterBuffer {
return &jitterBuffer{dec: dec, pkts: map[uint16][]byte{}, target: 3, pcm: make([]int16, 5760*channels), lastUnd: time.Now()}
}
func seqLess(a, b uint16) bool { return int16(a-b) < 0 }
func (j *jitterBuffer) push(p *rtp.Packet) {
j.mu.Lock()
defer j.mu.Unlock()
if len(p.Payload) == 0 {
return
}
if j.started {
if seqLess(p.SequenceNumber, j.nextSeq) {
j.late++
return
}
if d := uint16(p.SequenceNumber - j.nextSeq); d > 500 { // stream restarted
j.pkts = map[uint16][]byte{}
j.started = false
}
}
if len(j.pkts) > 100 { // hard cap - should never happen
j.pkts = map[uint16][]byte{}
j.started = false
}
j.pkts[p.SequenceNumber] = append([]byte(nil), p.Payload...)
}
func (j *jitterBuffer) oldest() uint16 {
first := true
var m uint16
for s := range j.pkts {
if first || seqLess(s, m) {
m, first = s, false
}
}
return m
}
func (j *jitterBuffer) silence() []int16 { return make([]int16, frameSamps*channels) }
func (j *jitterBuffer) decode(p []byte) []int16 {
n, err := j.dec.Decode(p, j.pcm)
if err != nil || n <= 0 {
return j.conceal()
}
return append([]int16(nil), j.pcm[:n*channels]...)
}
func (j *jitterBuffer) conceal() []int16 {
out := make([]int16, frameSamps*channels)
if err := j.dec.DecodePLC(out); err != nil {
return j.silence()
}
j.plc++
return out
}
func rms(pcm []int16) float64 {
if len(pcm) == 0 {
return 0
}
var s float64
for _, v := range pcm {
f := float64(v) / 32768
s += f * f
}
return math.Sqrt(s / float64(len(pcm)))
}
// next returns the next block of interleaved PCM to play (normally 20 ms).
func (j *jitterBuffer) next() []int16 {
j.mu.Lock()
defer j.mu.Unlock()
// Relax the target after a quiet minute.
if j.target > 3 && time.Since(j.lastUnd) > 60*time.Second {
j.target--
j.lastUnd = time.Now()
}
if !j.started {
if len(j.pkts) < j.target {
return j.silence()
}
j.nextSeq = j.oldest()
j.started = true
j.misses = 0
}
for {
p, ok := j.pkts[j.nextSeq]
if ok {
delete(j.pkts, j.nextSeq)
j.nextSeq++
j.misses = 0
pcm := j.decode(p)
depth := len(j.pkts)
// Drift / jitter-spike correction: too deep -> throw away quiet frames,
// or any frame if far too deep.
if (depth > j.target+3 && rms(pcm) < 0.003) || depth > j.target+12 {
j.dropped++
continue
}
return pcm
}
if len(j.pkts) == 0 {
// Underrun: nothing to play.
j.misses++
if j.misses == 1 {
j.underrun++
if j.target < 10 {
j.target++
}
j.lastUnd = time.Now()
}
if j.misses > 5 {
j.started = false // rebuffer to target, play silence meanwhile
return j.silence()
}
j.nextSeq++
return j.conceal()
}
// Gap with later packets present: the packet is lost.
j.lost++
if fp, ok := j.pkts[j.nextSeq+1]; ok {
out := make([]int16, frameSamps*channels)
if err := j.dec.DecodeFEC(fp, out); err == nil {
j.fec++
j.nextSeq++
return out
}
}
j.nextSeq++
return j.conceal()
}
}
func (j *jitterBuffer) stats() map[string]any {
j.mu.Lock()
defer j.mu.Unlock()
return map[string]any{
"depthMs": len(j.pkts) * 20, "targetMs": j.target * 20,
"lost": j.lost, "fec": j.fec, "plc": j.plc, "dropped": j.dropped, "underruns": j.underrun, "late": j.late,
}
}
+173
View File
@@ -0,0 +1,173 @@
// glprobe: headless test guest for guestlink. Creates (or uses) an invite via the host
// console, joins as a guest, sends a quiet tone, chats, and reports what it receives.
package main
import (
"encoding/json"
"flag"
"fmt"
"log"
"math"
"net/http"
"net/url"
"strings"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
"github.com/pion/webrtc/v4"
"github.com/pion/webrtc/v4/pkg/media"
"gopkg.in/hraban/opus.v2"
)
func main() {
hostWS := flag.String("hostws", "ws://127.0.0.1:8791/ws", "host console websocket (to create a link)")
guestBase := flag.String("guest", "ws://127.0.0.1:8790", "guest base (ws:// or wss://)")
token := flag.String("token", "", "use this token instead of creating a new link")
secs := flag.Int("secs", 8, "seconds to stay connected")
toneDB := flag.Float64("tone", -40, "test tone level dBFS")
flag.Parse()
if *token == "" {
h, _, err := websocket.DefaultDialer.Dial(*hostWS, nil)
if err != nil {
log.Fatalf("host ws: %v", err)
}
h.WriteJSON(map[string]string{"t": "newlink"})
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
var m map[string]any
h.SetReadDeadline(deadline)
if err := h.ReadJSON(&m); err != nil {
break
}
if m["t"] == "state" {
if l, _ := m["link"].(string); l != "" {
*token = l[strings.LastIndex(l, "/")+1:]
}
}
}
h.Close()
if *token == "" {
log.Fatal("no link created")
}
fmt.Println("token created")
}
hdr := http.Header{}
u, _ := url.Parse(*guestBase)
scheme := "http"
if u.Scheme == "wss" {
scheme = "https"
}
hdr.Set("Origin", scheme+"://"+u.Host)
ws, resp, err := websocket.DefaultDialer.Dial(*guestBase+"/ws?t="+url.QueryEscape(*token), hdr)
if err != nil {
code := 0
if resp != nil {
code = resp.StatusCode
}
log.Fatalf("guest ws: %v (HTTP %d)", err, code)
}
ws.WriteJSON(map[string]string{"t": "name", "name": "glprobe"})
ws.WriteJSON(map[string]string{"t": "chat", "text": "hello from glprobe <b>not bold</b>"})
pc, _ := webrtc.NewPeerConnection(webrtc.Configuration{})
track, _ := webrtc.NewTrackLocalStaticSample(webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus, ClockRate: 48000, Channels: 2}, "a", "probe")
pc.AddTransceiverFromTrack(track, webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionSendrecv})
var rxPeak atomic.Uint64
var rxPkts atomic.Int64
pc.OnTrack(func(t *webrtc.TrackRemote, _ *webrtc.RTPReceiver) {
dec, _ := opus.NewDecoder(48000, 2)
pcm := make([]int16, 5760*2)
for {
p, _, err := t.ReadRTP()
if err != nil {
return
}
rxPkts.Add(1)
n, err := dec.Decode(p.Payload, pcm)
if err != nil {
continue
}
for _, s := range pcm[:n*2] {
v := uint64(math.Abs(float64(s)))
if v > rxPeak.Load() {
rxPeak.Store(v)
}
}
}
})
connected := make(chan struct{}, 1)
pc.OnConnectionStateChange(func(s webrtc.PeerConnectionState) {
fmt.Println("pc:", s)
if s == webrtc.PeerConnectionStateConnected {
connected <- struct{}{}
}
})
pc.OnICECandidate(func(c *webrtc.ICECandidate) {
if c != nil {
ci := c.ToJSON()
ws.WriteJSON(map[string]any{"t": "candidate", "candidate": ci})
}
})
offer, _ := pc.CreateOffer(nil)
pc.SetLocalDescription(offer)
ws.WriteJSON(map[string]string{"t": "offer", "sdp": offer.SDP})
go func() {
for {
var m map[string]any
if err := ws.ReadJSON(&m); err != nil {
return
}
switch m["t"] {
case "answer":
sdp := m["sdp"].(string)
for _, l := range strings.Split(sdp, "\n") {
if strings.HasPrefix(l, "a=candidate") || strings.HasPrefix(l, "a=fmtp") {
fmt.Println(" answer:", strings.TrimSpace(l))
}
}
pc.SetRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeAnswer, SDP: sdp})
case "chat":
b, _ := json.Marshal(m["msg"])
fmt.Println("chat:", string(b))
default:
b, _ := json.Marshal(m)
if len(b) > 200 {
b = b[:200]
}
fmt.Println("ws:", string(b))
}
}
}()
select {
case <-connected:
case <-time.After(10 * time.Second):
log.Fatal("did not connect")
}
enc, _ := opus.NewEncoder(48000, 2, opus.AppAudio)
amp := math.Pow(10, *toneDB/20) * 32767
pcm := make([]int16, 960*2)
pkt := make([]byte, 4000)
ph := 0.0
tick := time.NewTicker(20 * time.Millisecond)
end := time.Now().Add(time.Duration(*secs) * time.Second)
for time.Now().Before(end) {
<-tick.C
for i := 0; i < 960; i++ {
v := int16(amp * math.Sin(ph))
ph += 2 * math.Pi * 440 / 48000
pcm[2*i], pcm[2*i+1] = v, v
}
n, _ := enc.Encode(pcm, pkt)
track.WriteSample(media.Sample{Data: append([]byte(nil), pkt[:n]...), Duration: 20 * time.Millisecond})
}
pk := float64(rxPeak.Load()) / 32768
fmt.Printf("received %d packets from server, peak %.1f dBFS\n", rxPkts.Load(), 20*math.Log10(pk+1e-9))
ws.WriteJSON(map[string]string{"t": "hangup"})
time.Sleep(300 * time.Millisecond)
pc.Close()
}
+33
View File
@@ -0,0 +1,33 @@
module guestlink
go 1.26.8
require (
github.com/gorilla/websocket v1.5.3
github.com/pion/ice/v4 v4.4.8
github.com/pion/interceptor v0.1.51
github.com/pion/rtp v1.10.6
github.com/pion/stun/v4 v4.0.1
github.com/pion/webrtc/v4 v4.2.26
golang.org/x/sys v0.41.0
gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302
)
require (
github.com/google/uuid v1.6.0 // indirect
github.com/pion/datachannel v1.6.3 // indirect
github.com/pion/dtls/v3 v3.1.15 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/mdns/v2 v2.2.2 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.20 // indirect
github.com/pion/sctp v1.12.0 // indirect
github.com/pion/sdp/v3 v3.0.20 // indirect
github.com/pion/srtp/v3 v3.1.3 // indirect
github.com/pion/transport/v5 v5.1.4 // indirect
github.com/pion/turn/v5 v5.1.3 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/time v0.14.0 // indirect
)
+52
View File
@@ -0,0 +1,52 @@
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/pion/datachannel v1.6.3 h1:HZAYLpunVI0xi057ZiANl558d0uSpomcWw4UEPz3qXs=
github.com/pion/datachannel v1.6.3/go.mod h1:oRmkXVVXxkUbqa8MatgdNHW29kvtQY3HUdjJQsfv2GE=
github.com/pion/dtls/v3 v3.1.15 h1:okWk5LNwU21DfhvG8z4wm45knHVSPEdkihwp6BxfQcc=
github.com/pion/dtls/v3 v3.1.15/go.mod h1:iKFQNYrjsN2TiA2YKKMqB9MOZaFpjFULBI/A4sW0eyc=
github.com/pion/ice/v4 v4.4.8 h1:NB22Ze5WrgMjcf9L+O+1B29bl6anq+sbyvddImDLdhc=
github.com/pion/ice/v4 v4.4.8/go.mod h1:zCRRg4Mkpdbx9B+75fd4z2APvIXeIraeUOB7IMPt8mU=
github.com/pion/interceptor v0.1.51 h1:PU09y6RghjWXR4AYJ+AVNyDX7EmgIZuDFMGDl6smKQA=
github.com/pion/interceptor v0.1.51/go.mod h1:nVgAA8pNNhaglm6NSe9lMuFhmi8kwWbf7Whw1ukt9VU=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
github.com/pion/mdns/v2 v2.2.2 h1:qq9Kj3PnjF0mfSwd8Icu0CXy5aoYdQKnNUIsGPeUpgk=
github.com/pion/mdns/v2 v2.2.2/go.mod h1:ZX5f0AAH1D6TOjdjvcBORZZHaZuG0t9+br78lHEwiJ4=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.20 h1:ZxQpb1Z3st9W5Rts4PjGoC3ynDhUtQ9B/itKJho8h2I=
github.com/pion/rtcp v1.2.20/go.mod h1:vX7Es6skWYm4oVvOSC5+4WHLYUTZHcbGNmk5wjzMGH4=
github.com/pion/rtp v1.10.6 h1:1qs356bmniSc89gTF1KFdI0kej7qTdIUSnHOXu3P2EY=
github.com/pion/rtp v1.10.6/go.mod h1:qvV617FVkp5DBUfoHGxP8uOnTI4UaNDJK0z7Sildylk=
github.com/pion/sctp v1.12.0 h1:AzUdVkVBNyGY1W4crWO1/aOWzMaW3zsVHGrNuAfFQiI=
github.com/pion/sctp v1.12.0/go.mod h1:6k1zrpkrxSwbisitn/AlzDQaa15DR49MFsrMochG/yc=
github.com/pion/sdp/v3 v3.0.20 h1:TS6DViqcmp+49f0+mjw9anbr9xY3vJtsZewxAvlMCRQ=
github.com/pion/sdp/v3 v3.0.20/go.mod h1:slIMXDK5OKj0nhISwjfeN18AzTBCt2LYZq9uPw0cU5Q=
github.com/pion/srtp/v3 v3.1.3 h1:O3sfbfWfq/OyEaXRbX02Z0SNQRijS9nDdc/DEb8jxlU=
github.com/pion/srtp/v3 v3.1.3/go.mod h1:v3uQtrCz9LH3aahau0jtzRDsWGwKkh4a0BiBwzzxhtQ=
github.com/pion/stun/v4 v4.0.1 h1:S2ggK4hsUJJKoZfpL90uIqIRTj6xJ0NOIEkXgcpQif0=
github.com/pion/stun/v4 v4.0.1/go.mod h1:byktbPA8U7HjJ2H8w3moP7nzq0Q+kYNulvMLPaubQL4=
github.com/pion/transport/v5 v5.1.4 h1:j0E5QIPyv28YnWxa8yE9AyDmrxVnwhZ109c7Dpt48K4=
github.com/pion/transport/v5 v5.1.4/go.mod h1:Qxw6fCEjFWQkRDZOhS4Vf+neJBcihauvA3uyEa1J1F0=
github.com/pion/turn/v5 v5.1.3 h1:dxBq+M6o5DCxIjtiBQNvtHkKshS+xlJbXwgc1MKzBH8=
github.com/pion/turn/v5 v5.1.3/go.mod h1:ocKg/4W5d4N9hvpADSwoc5HdU53/4i966FIU1qJL0tY=
github.com/pion/webrtc/v4 v4.2.26 h1:RX3FUQVHhr488sgPFJWthbbtxpuYNm3XAgYDEj4RDyQ=
github.com/pion/webrtc/v4 v4.2.26/go.mod h1:vH5Fn0zOZ8LuLhIPEtsLkoYkwnvubV4TX+ZtjV5Jqgk=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302 h1:xeVptzkP8BuJhoIjNizd2bRHfq9KB9HfOLZu90T04XM=
gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302/go.mod h1:/L5E7a21VWl8DeuCPKxQBdVG5cy+L0MRZ08B1wnqt7g=
+547
View File
@@ -0,0 +1,547 @@
// guestlink - self-hosted replacement for Cleanfeed on the MMO rig.
//
// One remote guest joins from a browser at https://guest.mmo.show/j/<token>.
//
// guest's voice -> pacat -> PipeWire sink "motu_guest" (From Computer 1-2, the guest strip)
// mix-minus <- parec <- "motu_mixminus.monitor" (To Computer 3-4, show minus guest)
//
// Plus an anonymous text chat between the guest and the host console.
//
// Two HTTP listeners:
//
// -public (8790): guest page + guest websocket. This is what NPM on .158 proxies.
// -host (8791): host console. LAN only, never proxied.
//
// Media: one fixed UDP port (-udp 50090) shared by all ICE traffic.
//
// Audio safety: the parec/pacat streams are ordinary PipeWire client streams (exactly what
// Chromium did for Cleanfeed). They never reopen the MOTU. node.dont-fallback/dont-reconnect stop PipeWire from
// falling back to the default sink if motu_guest disappears.
package main
import (
"crypto/rand"
"embed"
"encoding/base64"
"encoding/json"
"flag"
"io/fs"
"log"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/gorilla/websocket"
"github.com/pion/ice/v4"
"github.com/pion/webrtc/v4"
)
//go:embed web
var webFS embed.FS
var (
publicAddr = flag.String("public", "0.0.0.0:8790", "guest-facing listener (proxied by NPM)")
hostAddr = flag.String("host", "0.0.0.0:8791", "host console listener (LAN only)")
udpPort = flag.Int("udp", 50090, "single UDP port for all WebRTC media")
publicHost = flag.String("public-host", "guest.mmo.show", "hostname; its A record is advertised as our public IP")
publicIPFlag = flag.String("public-ip", "", "override the public IP instead of resolving -public-host")
lanCIDR = flag.String("lan", "192.168.50.0/24", "LAN whose interface address is offered as a host candidate")
sinkName = flag.String("sink", "motu_guest", "PipeWire sink the guest's voice is played into")
sourceName = flag.String("source", "motu_mixminus.monitor", "PipeWire source sent to the guest")
hostLabel = flag.String("host-label", "Host", "name the guest sees on host chat messages")
testPlayoutFile = flag.String("test-playout-file", "", "TEST ONLY: write guest audio to this raw file instead of the sink")
stateDir = flag.String("state", filepath.Join(os.Getenv("HOME"), ".local/state/guestlink"), "state directory (invite token)")
)
// ---------------------------------------------------------------- hub
type chatMsg struct {
From string `json:"from"`
Who string `json:"who"` // "host" | "guest" | "system"
Text string `json:"text"`
TS int64 `json:"ts"`
}
type wsConn struct {
c *websocket.Conn
mu sync.Mutex
}
func (w *wsConn) send(v any) {
w.mu.Lock()
defer w.mu.Unlock()
w.c.SetWriteDeadline(time.Now().Add(5 * time.Second))
if err := w.c.WriteJSON(v); err != nil {
w.c.Close()
}
}
type Hub struct {
mu sync.Mutex
token string
guest *wsConn
guestName string
call *Call
hosts map[*wsConn]bool
chat []chatMsg
udp *net.UDPConn
mux ice.UDPMux
}
var hub = &Hub{hosts: map[*wsConn]bool{}}
func randToken() string {
b := make([]byte, 15)
rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
func tokenPath() string { return filepath.Join(*stateDir, "token") }
func (h *Hub) loadToken() {
b, err := os.ReadFile(tokenPath())
if err == nil {
h.token = strings.TrimSpace(string(b))
}
}
func (h *Hub) saveToken() {
os.MkdirAll(*stateDir, 0o700)
os.WriteFile(tokenPath(), []byte(h.token), 0o600)
}
func (h *Hub) link() string {
if h.token == "" {
return ""
}
return "https://" + *publicHost + "/j/" + h.token
}
// stateLocked builds the host-console status message. Caller holds h.mu.
func (h *Hub) stateLocked() map[string]any {
st := map[string]any{
"t": "state",
"link": h.link(),
"guestOnline": h.guest != nil,
"guestName": h.guestName,
"call": nil,
}
if h.call != nil {
st["call"] = h.call.status()
}
return st
}
func (h *Hub) broadcastHosts(v any) {
h.mu.Lock()
hs := make([]*wsConn, 0, len(h.hosts))
for c := range h.hosts {
hs = append(hs, c)
}
h.mu.Unlock()
for _, c := range hs {
c.send(v)
}
}
func (h *Hub) pushState() {
h.mu.Lock()
st := h.stateLocked()
h.mu.Unlock()
h.broadcastHosts(st)
}
func (h *Hub) addChat(m chatMsg) {
h.mu.Lock()
h.chat = append(h.chat, m)
if len(h.chat) > 300 {
h.chat = h.chat[len(h.chat)-300:]
}
g := h.guest
h.mu.Unlock()
out := map[string]any{"t": "chat", "msg": m}
h.broadcastHosts(out)
if g != nil {
g.send(out)
}
}
func (h *Hub) system(text string) {
h.addChat(chatMsg{From: "", Who: "system", Text: text, TS: time.Now().UnixMilli()})
}
// endCall tears down the current call, if any.
func (h *Hub) endCall() {
h.mu.Lock()
c := h.call
h.call = nil
h.mu.Unlock()
if c != nil {
c.Close()
}
h.pushState()
}
// kickGuest drops the guest's websocket and call.
func (h *Hub) kickGuest(reason string) {
h.mu.Lock()
g := h.guest
h.guest = nil
h.mu.Unlock()
if g != nil {
g.send(map[string]any{"t": "ended", "reason": reason})
g.c.Close()
}
h.endCall()
}
// ---------------------------------------------------------------- helpers
func cleanText(s string, max int) string {
s = strings.TrimSpace(strings.ToValidUTF8(s, ""))
s = strings.Map(func(r rune) rune {
if r == '\n' || r == '\t' {
return r
}
if r < 0x20 || r == 0x7f {
return -1
}
return r
}, s)
if utf8.RuneCountInString(s) > max {
s = string([]rune(s)[:max])
}
return s
}
func isPrivate(addr string) bool {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return false
}
ip := net.ParseIP(host)
if ip == nil {
return false
}
_, cg, _ := net.ParseCIDR("100.64.0.0/10") // tailscale / CGNAT VPN
return ip.IsLoopback() || ip.IsPrivate() || cg.Contains(ip)
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hd := w.Header()
hd.Set("Content-Security-Policy", "default-src 'self'; connect-src 'self' wss: ws:; img-src 'self' data:; style-src 'self'; script-src 'self'; frame-ancestors 'none'; base-uri 'none'")
hd.Set("Permissions-Policy", "microphone=(self), camera=(), geolocation=()")
hd.Set("Referrer-Policy", "no-referrer")
hd.Set("X-Content-Type-Options", "nosniff")
hd.Set("Cache-Control", "no-store")
next.ServeHTTP(w, r)
})
}
func serveFile(name string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
b, err := webFS.ReadFile("web/" + name)
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(b)
}
}
var upgrader = websocket.Upgrader{ReadBufferSize: 8192, WriteBufferSize: 8192}
// ---------------------------------------------------------------- guest side
type rateLimiter struct {
times []time.Time
n int
per time.Duration
}
func (r *rateLimiter) allow() bool {
now := time.Now()
keep := r.times[:0]
for _, t := range r.times {
if now.Sub(t) < r.per {
keep = append(keep, t)
}
}
r.times = keep
if len(r.times) >= r.n {
return false
}
r.times = append(r.times, now)
return true
}
func guestWS(w http.ResponseWriter, r *http.Request) {
tok := r.URL.Query().Get("t")
hub.mu.Lock()
valid := hub.token != "" && tok == hub.token
hub.mu.Unlock()
if !valid {
http.Error(w, "invite not active", http.StatusForbidden)
return
}
c, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
c.SetReadLimit(64 << 10)
me := &wsConn{c: c}
hub.mu.Lock()
old := hub.guest
hub.guest = me
hist := append([]chatMsg(nil), hub.chat...)
hub.mu.Unlock()
if old != nil {
old.send(map[string]any{"t": "ended", "reason": "This link was opened in another window."})
old.c.Close()
}
log.Printf("guest connected from %s", r.Header.Get("X-Real-IP"))
me.send(map[string]any{"t": "welcome", "hostLabel": *hostLabel, "history": hist})
hub.pushState()
chatRL := &rateLimiter{n: 8, per: 10 * time.Second}
sigRL := &rateLimiter{n: 200, per: 10 * time.Second}
defer func() {
hub.mu.Lock()
still := hub.guest == me
if still {
hub.guest = nil
}
hub.mu.Unlock()
c.Close()
if still {
log.Printf("guest websocket closed")
hub.pushState()
}
}()
for {
var m struct {
T string `json:"t"`
Text string `json:"text"`
Name string `json:"name"`
SDP string `json:"sdp"`
Candidate *webrtc.ICECandidateInit `json:"candidate"`
}
if err := c.ReadJSON(&m); err != nil {
return
}
hub.mu.Lock()
current := hub.guest == me && hub.token == tok
hub.mu.Unlock()
if !current {
return
}
if !sigRL.allow() {
continue
}
switch m.T {
case "name":
n := cleanText(strings.ReplaceAll(m.Name, "\n", " "), 40)
hub.mu.Lock()
hub.guestName = n
hub.mu.Unlock()
hub.pushState()
case "chat":
t := cleanText(m.Text, 1000)
if t == "" {
continue
}
if !chatRL.allow() {
me.send(map[string]any{"t": "notice", "text": "Slow down a little - message not sent."})
continue
}
hub.mu.Lock()
name := hub.guestName
hub.mu.Unlock()
if name == "" {
name = "Guest"
}
hub.addChat(chatMsg{From: name, Who: "guest", Text: t, TS: time.Now().UnixMilli()})
case "offer":
hub.endCall() // one call at a time; a new offer replaces the old one
call, answer, err := newCall(m.SDP)
if err != nil {
log.Printf("offer failed: %v", err)
me.send(map[string]any{"t": "error", "text": "Could not start audio: " + err.Error()})
continue
}
hub.mu.Lock()
hub.call = call
hub.mu.Unlock()
me.send(map[string]any{"t": "answer", "sdp": answer})
hub.pushState()
case "candidate":
hub.mu.Lock()
call := hub.call
hub.mu.Unlock()
if call != nil && m.Candidate != nil {
call.pc.AddICECandidate(*m.Candidate)
}
case "hangup":
hub.endCall()
}
}
}
// ---------------------------------------------------------------- host side
func hostWS(w http.ResponseWriter, r *http.Request) {
if !isPrivate(r.RemoteAddr) {
http.Error(w, "LAN only", http.StatusForbidden)
return
}
c, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
me := &wsConn{c: c}
hub.mu.Lock()
hub.hosts[me] = true
hist := append([]chatMsg(nil), hub.chat...)
st := hub.stateLocked()
hub.mu.Unlock()
me.send(map[string]any{"t": "history", "history": hist, "hostLabel": *hostLabel})
me.send(st)
defer func() {
hub.mu.Lock()
delete(hub.hosts, me)
hub.mu.Unlock()
c.Close()
}()
for {
var m struct {
T string `json:"t"`
Text string `json:"text"`
}
if err := c.ReadJSON(&m); err != nil {
return
}
switch m.T {
case "chat":
if t := cleanText(m.Text, 2000); t != "" {
hub.addChat(chatMsg{From: *hostLabel, Who: "host", Text: t, TS: time.Now().UnixMilli()})
}
case "newlink":
hub.kickGuest("The host started a new session.")
hub.mu.Lock()
hub.token = randToken()
hub.chat = nil
hub.guestName = ""
hub.saveToken()
hub.mu.Unlock()
log.Printf("new invite link created")
hub.broadcastHosts(map[string]any{"t": "history", "history": []chatMsg{}, "hostLabel": *hostLabel})
hub.pushState()
case "revoke":
hub.kickGuest("The host closed this session.")
hub.mu.Lock()
hub.token = ""
hub.saveToken()
hub.mu.Unlock()
log.Printf("invite link revoked")
hub.pushState()
case "kick":
hub.kickGuest("The host ended the call.")
case "endcall":
hub.mu.Lock()
g := hub.guest
hub.mu.Unlock()
hub.endCall()
if g != nil {
g.send(map[string]any{"t": "callended"})
}
case "clearchat":
hub.mu.Lock()
hub.chat = nil
g := hub.guest
hub.mu.Unlock()
clr := map[string]any{"t": "history", "history": []chatMsg{}, "hostLabel": *hostLabel}
hub.broadcastHosts(clr)
if g != nil {
g.send(clr)
}
}
}
}
// ---------------------------------------------------------------- main
func main() {
flag.Parse()
log.SetFlags(log.LstdFlags)
hub.loadToken()
udp, err := net.ListenUDP("udp4", &net.UDPAddr{Port: *udpPort})
if err != nil {
log.Fatalf("udp listen: %v", err)
}
hub.udp = udp
hub.mux = webrtc.NewICEUDPMux(nil, udp)
static, _ := fs.Sub(webFS, "web/static")
staticH := http.StripPrefix("/static/", http.FileServer(http.FS(static)))
pub := http.NewServeMux()
pub.Handle("/static/", staticH)
pub.HandleFunc("/j/", serveFile("guest.html"))
pub.HandleFunc("/ws", guestWS)
pub.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok\n")) })
pub.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
serveFile("index.html")(w, r)
})
host := http.NewServeMux()
host.Handle("/static/", staticH)
host.HandleFunc("/ws", hostWS)
host.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if !isPrivate(r.RemoteAddr) {
http.Error(w, "LAN only", http.StatusForbidden)
return
}
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
serveFile("host.html")(w, r)
})
// Periodic status for the host console (levels, jitter buffer).
go func() {
for range time.Tick(150 * time.Millisecond) {
hub.mu.Lock()
active := hub.call != nil && len(hub.hosts) > 0
hub.mu.Unlock()
if active {
hub.pushState()
}
}
}()
go func() {
log.Printf("host console on http://%s", *hostAddr)
log.Fatal(http.ListenAndServe(*hostAddr, securityHeaders(host)))
}()
log.Printf("guest listener on %s, media on udp/%d", *publicAddr, *udpPort)
log.Fatal(http.ListenAndServe(*publicAddr, securityHeaders(pub)))
}
// jsonString is used by the call code for compact logging.
func jsonString(v any) string { b, _ := json.Marshal(v); return string(b) }
+34
View File
@@ -0,0 +1,34 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex">
<title>MMO Guest Line</title><link rel="stylesheet" href="/static/style.css"></head>
<body>
<header><h1>MMO Guest Line</h1><span id="conn" class="pill">connecting…</span></header>
<div id="fatal" class="banner hidden"></div>
<main class="grid">
<section class="card" id="audio">
<h2>Audio</h2>
<label class="field">Your name in chat <span class="muted">(optional - leave blank to stay anonymous)</span>
<input id="name" maxlength="40" placeholder="Guest" autocomplete="off"></label>
<label class="field">Microphone <select id="mic"><option value="">Default microphone</option></select></label>
<label class="field" id="outwrap">Headphones / output <select id="out"><option value="">Default output</option></select></label>
<label class="check"><input type="checkbox" id="headphones" checked> I'm wearing headphones <span class="muted">(best quality; untick only if you must use speakers)</span></label>
<div class="row">
<button id="join" class="primary">Join the show</button>
<button id="mute" class="hidden">Mute mic</button>
<button id="leave" class="hidden danger">Leave</button>
</div>
<div id="callstate" class="muted">Not connected to audio.</div>
<div class="meter"><span>Your mic</span><div class="bar"><i id="mMic"></i></div></div>
<div class="meter"><span>The show</span><div class="bar"><i id="mShow"></i></div></div>
<audio id="player" autoplay></audio>
</section>
<section class="card chat">
<h2>Chat with the host</h2>
<div id="log" class="log" aria-live="polite"></div>
<form id="chatform" class="row"><textarea id="msg" rows="2" maxlength="1000" placeholder="Type a message… (Enter to send)"></textarea><button class="primary">Send</button></form>
</section>
</main>
<script src="/static/common.js"></script>
<script src="/static/guest.js"></script>
</body></html>
+34
View File
@@ -0,0 +1,34 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Guest Line Console</title><link rel="stylesheet" href="/static/style.css"></head>
<body>
<header><h1>Guest Line - host console</h1><span id="conn" class="pill">connecting…</span></header>
<main class="grid">
<section class="card">
<h2>Invite</h2>
<div class="row"><input id="link" readonly placeholder="No active link"><button id="copy">Copy</button></div>
<div class="row">
<button id="newlink">New link</button>
<button id="revoke" class="danger">Revoke link</button>
</div>
<p class="muted small">A new link ends the current session and clears chat. Old links stop working.</p>
<h2>Guest</h2>
<div id="guest" class="big">No guest connected</div>
<div id="callinfo" class="muted small"></div>
<div class="meter"><span>Guest &rarr; show</span><div class="bar"><i id="mIn"></i></div><b id="dIn"></b></div>
<div class="meter"><span>Mix-minus &rarr; guest</span><div class="bar"><i id="mOut"></i></div><b id="dOut"></b></div>
<div id="jb" class="muted small mono"></div>
<div class="row">
<button id="endcall">End audio</button>
<button id="kick" class="danger">Kick guest</button>
</div>
</section>
<section class="card chat">
<div class="row between"><h2>Chat</h2><button id="clear" class="small">Clear</button></div>
<div id="log" class="log" aria-live="polite"></div>
<form id="chatform" class="row"><textarea id="msg" rows="2" maxlength="2000" placeholder="Message the guest… (Enter to send)"></textarea><button class="primary">Send</button></form>
</section>
</main>
<script src="/static/common.js"></script>
<script src="/static/host.js"></script>
</body></html>
+5
View File
@@ -0,0 +1,5 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>MMO Guest Line</title><link rel="stylesheet" href="/static/style.css"></head>
<body><main class="narrow"><h1>MMO Guest Line</h1>
<p class="muted">You need a personal invite link from the host to join a show.</p></main></body></html>
+30
View File
@@ -0,0 +1,30 @@
'use strict';
const $ = s => document.querySelector(s);
function wsURL(path){ return (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + path; }
function fmtTime(ts){ const d = new Date(ts); return d.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'}); }
// Render a chat message. `mine` = which side this page is ('host' | 'guest'). Text only - never innerHTML.
function renderMsg(log, m, mine){
const el = document.createElement('div');
el.className = 'msg ' + (m.who === 'system' ? 'system' : (m.who === mine ? 'me' : 'them'));
if (m.who !== 'system') {
const w = document.createElement('span'); w.className = 'who';
w.textContent = (m.who === mine ? 'You' : m.from) + ' · ' + fmtTime(m.ts);
el.appendChild(w);
}
el.appendChild(document.createTextNode(m.text));
const atBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 40;
log.appendChild(el);
if (atBottom || m.who === mine) log.scrollTop = log.scrollHeight;
}
function setMeter(el, peak){ // peak 0..1 linear -> bar on a -60..0 dBFS scale
const db = peak > 0 ? 20*Math.log10(peak) : -100;
const pct = Math.max(0, Math.min(100, (db + 60) / 60 * 100));
el.style.width = pct + '%';
el.className = db > -1 ? 'clip' : (db > -9 ? 'hot' : '');
return db;
}
function chatForm(form, ta, send){
form.addEventListener('submit', e => { e.preventDefault(); const t = ta.value.trim(); if (t) { send(t); ta.value=''; } ta.focus(); });
ta.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); form.requestSubmit(); } });
}
function store(k, v){ try { if (v === undefined) return localStorage.getItem(k); localStorage.setItem(k, v); } catch(e) { return null; } }
+168
View File
@@ -0,0 +1,168 @@
'use strict';
const token = location.pathname.split('/').filter(Boolean)[1] || '';
const log = $('#log');
let ws = null, everOpen = false, finished = false;
let pc = null, stream = null, actx = null, micAn = null, showAn = null, muted = false;
function fatal(msg){
finished = true;
$('#fatal').textContent = msg; $('#fatal').classList.remove('hidden');
$('#conn').textContent = 'ended'; $('#conn').className = 'pill bad';
hangup(false);
for (const el of document.querySelectorAll('button, textarea, input, select')) el.disabled = true;
}
function send(o){ if (ws && ws.readyState === 1) ws.send(JSON.stringify(o)); }
function connect(){
ws = new WebSocket(wsURL('/ws?t=' + encodeURIComponent(token)));
ws.onopen = () => {
everOpen = true;
$('#conn').textContent = 'connected'; $('#conn').className = 'pill ok';
const n = $('#name').value.trim(); if (n) send({t:'name', name:n});
};
ws.onclose = () => {
if (finished) return;
if (!everOpen) { fatal('This invite link is not active. Ask the host for a new one.'); return; }
$('#conn').textContent = 'reconnecting…'; $('#conn').className = 'pill bad';
setTimeout(connect, 2000);
};
ws.onmessage = async ev => {
const m = JSON.parse(ev.data);
switch (m.t) {
case 'welcome': case 'history':
log.textContent = ''; (m.history || []).forEach(x => renderMsg(log, x, 'guest')); break;
case 'chat': renderMsg(log, m.msg, 'guest'); if (document.hidden && m.msg.who === 'host') flashTitle(); break;
case 'notice': renderMsg(log, {who:'system', text:m.text, ts:Date.now()}, 'guest'); break;
case 'answer': if (pc) await pc.setRemoteDescription({type:'answer', sdp:m.sdp}); break;
case 'error': $('#callstate').textContent = m.text; hangup(false); break;
case 'callended': hangup(false); $('#callstate').textContent = 'The host ended the audio. You can join again.'; break;
case 'ended': fatal(m.reason || 'The session has ended.'); break;
}
};
}
let flashT = null;
function flashTitle(){
if (flashT) return; const base = document.title; let on = false;
flashT = setInterval(() => { document.title = (on = !on) ? '💬 New message' : base; }, 1000);
const stop = () => { clearInterval(flashT); flashT = null; document.title = base; document.removeEventListener('visibilitychange', stop); };
document.addEventListener('visibilitychange', stop);
}
// ---- devices
async function listDevices(){
const devs = await navigator.mediaDevices.enumerateDevices();
const fill = (sel, kind, saved) => {
const cur = sel.value || saved || '';
sel.length = 1;
devs.filter(d => d.kind === kind && d.deviceId !== 'default' && d.deviceId !== 'communications')
.forEach((d, i) => { const o = new Option(d.label || (kind + ' ' + (i+1)), d.deviceId); sel.add(o); });
if ([...sel.options].some(o => o.value === cur)) sel.value = cur;
};
fill($('#mic'), 'audioinput', store('gl_mic'));
if ('setSinkId' in HTMLMediaElement.prototype) fill($('#out'), 'audiooutput', store('gl_out'));
else $('#outwrap').classList.add('hidden');
}
navigator.mediaDevices?.addEventListener?.('devicechange', listDevices);
$('#mic').onchange = () => store('gl_mic', $('#mic').value);
$('#out').onchange = async () => {
store('gl_out', $('#out').value);
try { await $('#player').setSinkId($('#out').value); } catch(e) {}
};
// ---- call
function munge(sdp){
// Ask for stereo, full-band, high-bitrate Opus in both directions.
const m = sdp.match(/a=rtpmap:(\d+) opus\/48000/i); if (!m) return sdp;
const pt = m[1];
return sdp.replace(new RegExp('a=fmtp:' + pt + ' (.*)'), (line, p) =>
'a=fmtp:' + pt + ' ' + p.split(';').filter(x => !/^(stereo|sprop-stereo|maxaveragebitrate|cbr)=/.test(x)).join(';') +
';stereo=1;sprop-stereo=1;maxaveragebitrate=128000');
}
async function join(){
if (!window.RTCPeerConnection || !navigator.mediaDevices) { $('#callstate').textContent = 'This browser cannot do live audio. Please use Chrome, Edge, Firefox or Safari.'; return; }
$('#join').disabled = true; $('#callstate').textContent = 'Asking for your microphone…';
const hp = $('#headphones').checked;
try {
stream = await navigator.mediaDevices.getUserMedia({audio: {
deviceId: $('#mic').value ? {exact: $('#mic').value} : undefined,
echoCancellation: !hp, noiseSuppression: false, autoGainControl: false,
channelCount: {ideal: 1}, sampleRate: {ideal: 48000}
}});
} catch (e) {
$('#join').disabled = false;
$('#callstate').textContent = 'Microphone not available: ' + (e.message || e.name) + '. Check your browser\'s permission and try again.';
return;
}
listDevices().catch(()=>{});
actx = actx || new AudioContext();
if (actx.state === 'suspended') await actx.resume();
micAn = actx.createAnalyser(); micAn.fftSize = 1024;
actx.createMediaStreamSource(stream).connect(micAn);
pc = new RTCPeerConnection({iceServers: [{urls: 'stun:stun.l.google.com:19302'}]});
const tr = pc.addTransceiver(stream.getAudioTracks()[0], {direction: 'sendrecv', streams: [stream]});
pc.onicecandidate = e => { if (e.candidate) send({t:'candidate', candidate: e.candidate.toJSON()}); };
pc.ontrack = e => {
const s = e.streams[0] || new MediaStream([e.track]);
const p = $('#player'); p.srcObject = s;
if ($('#out').value && p.setSinkId) p.setSinkId($('#out').value).catch(()=>{});
p.play().catch(()=>{});
showAn = actx.createAnalyser(); showAn.fftSize = 1024;
actx.createMediaStreamSource(s).connect(showAn);
};
pc.onconnectionstatechange = () => {
const s = pc ? pc.connectionState : 'closed';
const label = {connecting:'Connecting audio…', connected:'You are live with the host. 🎙', disconnected:'Audio interrupted - trying to recover…', failed:'Audio connection failed.'}[s];
if (label) $('#callstate').textContent = label;
if (s === 'connected') {
const p = tr.sender.getParameters(); p.encodings = p.encodings && p.encodings.length ? p.encodings : [{}];
p.encodings[0].maxBitrate = 128000; tr.sender.setParameters(p).catch(()=>{});
}
if (s === 'failed') { hangup(false); $('#callstate').textContent = 'Audio connection failed. Press Join to try again - if it keeps failing, tell the host in chat.'; }
};
const offer = await pc.createOffer();
offer.sdp = munge(offer.sdp);
await pc.setLocalDescription(offer);
send({t:'offer', sdp: pc.localDescription.sdp});
$('#callstate').textContent = 'Connecting audio…';
$('#join').classList.add('hidden'); $('#join').disabled = false;
$('#mute').classList.remove('hidden'); $('#leave').classList.remove('hidden');
for (const s of ['#mic', '#headphones']) $(s).disabled = true;
}
function hangup(tell){
if (tell) send({t:'hangup'});
if (pc) { pc.close(); pc = null; }
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }
micAn = showAn = null; muted = false;
$('#player').srcObject = null;
$('#mute').textContent = 'Mute mic';
$('#join').classList.remove('hidden'); $('#mute').classList.add('hidden'); $('#leave').classList.add('hidden');
for (const s of ['#mic', '#headphones']) $(s).disabled = false;
$('#callstate').textContent = 'Not connected to audio.';
}
$('#join').onclick = join;
$('#leave').onclick = () => hangup(true);
$('#mute').onclick = () => {
if (!stream) return; muted = !muted;
stream.getAudioTracks().forEach(t => t.enabled = !muted);
$('#mute').textContent = muted ? 'Unmute mic' : 'Mute mic';
$('#mute').classList.toggle('danger', muted);
};
// ---- meters
const buf = new Float32Array(1024);
function peak(an){ if (!an) return 0; an.getFloatTimeDomainData(buf); let p = 0; for (const v of buf) { const a = Math.abs(v); if (a > p) p = a; } return p; }
(function loop(){ setMeter($('#mMic'), muted ? 0 : peak(micAn)); setMeter($('#mShow'), peak(showAn)); requestAnimationFrame(loop); })();
// ---- name + chat
$('#name').value = store('gl_name') || '';
$('#name').addEventListener('change', () => { const n = $('#name').value.trim(); store('gl_name', n); send({t:'name', name:n}); });
chatForm($('#chatform'), $('#msg'), t => send({t:'chat', text:t}));
window.addEventListener('beforeunload', () => { if (pc) send({t:'hangup'}); });
if (!token) fatal('This link is incomplete. Ask the host for your invite link.');
else { connect(); if (navigator.mediaDevices) listDevices().catch(()=>{}); }
+68
View File
@@ -0,0 +1,68 @@
'use strict';
const log = $('#log');
let ws = null, lastGuestOnline = null;
function send(o){ if (ws && ws.readyState === 1) ws.send(JSON.stringify(o)); }
function connect(){
ws = new WebSocket(wsURL('/ws'));
ws.onopen = () => { $('#conn').textContent = 'server connected'; $('#conn').className = 'pill ok'; };
ws.onclose = () => { $('#conn').textContent = 'server offline - retrying'; $('#conn').className = 'pill bad'; setTimeout(connect, 2000); };
ws.onmessage = ev => {
const m = JSON.parse(ev.data);
if (m.t === 'history') { log.textContent = ''; m.history.forEach(x => renderMsg(log, x, 'host')); }
else if (m.t === 'chat') { renderMsg(log, m.msg, 'host'); if (m.msg.who === 'guest' && document.hidden) flashTitle(); }
else if (m.t === 'state') render(m);
};
}
let flashT = null;
function flashTitle(){ // visual only - the host page must never make sound on the rig
if (flashT) return; const base = document.title; let on = false;
flashT = setInterval(() => { document.title = (on = !on) ? '💬 Guest message' : base; }, 1000);
const stop = () => { clearInterval(flashT); flashT = null; document.title = base; document.removeEventListener('visibilitychange', stop); };
document.addEventListener('visibilitychange', stop);
}
function fmtDur(s){ const m = Math.floor(s/60); return m + ':' + String(s%60).padStart(2,'0'); }
function render(s){
$('#link').value = s.link || '';
$('#copy').disabled = $('#revoke').disabled = !s.link;
const name = s.guestName || 'Guest (anonymous)';
const c = s.call;
let line = s.guestOnline ? name + ' - page open' : 'No guest connected';
if (c && c.pc === 'connected') line = name + ' - LIVE ' + fmtDur(c.secs);
else if (c) line = name + ' - audio ' + c.pc;
$('#guest').textContent = line;
$('#callinfo').textContent = c ? ('route: ' + (c.route || '…') + (c.sending ? '' : ' · send stopped') + (c.playing ? '' : ' · playout stopped')) : '';
if (c) {
$('#dIn').textContent = Math.max(-60, setMeter($('#mIn'), c.inPeak)).toFixed(0) + ' dB';
$('#dOut').textContent = Math.max(-60, setMeter($('#mOut'), c.outPeak)).toFixed(0) + ' dB';
const j = c.jb;
$('#jb').textContent = j ? `buffer ${j.depthMs}/${j.targetMs} ms · lost ${j.lost} (fec ${j.fec}) · concealed ${j.plc} · drift-drops ${j.dropped} · underruns ${j.underruns}` : '';
} else {
setMeter($('#mIn'), 0); setMeter($('#mOut'), 0); $('#dIn').textContent = $('#dOut').textContent = ''; $('#jb').textContent = '';
}
$('#endcall').disabled = !c; $('#kick').disabled = !s.guestOnline && !c;
}
// Two-click confirm for destructive buttons (no browser dialogs).
function confirmBtn(btn, action){
const label = btn.textContent; let armed = null;
btn.onclick = () => {
if (armed) { clearTimeout(armed); armed = null; btn.textContent = label; action(); return; }
btn.textContent = 'Click again to confirm'; armed = setTimeout(() => { armed = null; btn.textContent = label; }, 3000);
};
}
confirmBtn($('#newlink'), () => send({t:'newlink'}));
confirmBtn($('#revoke'), () => send({t:'revoke'}));
confirmBtn($('#kick'), () => send({t:'kick'}));
confirmBtn($('#endcall'), () => send({t:'endcall'}));
confirmBtn($('#clear'), () => send({t:'clearchat'}));
$('#copy').onclick = async () => {
const el = $('#link'); el.select();
try { await navigator.clipboard.writeText(el.value); } catch(e) { document.execCommand('copy'); }
$('#copy').textContent = 'Copied'; setTimeout(() => $('#copy').textContent = 'Copy', 1500);
};
chatForm($('#chatform'), $('#msg'), t => send({t:'chat', text:t}));
connect();
+37
View File
@@ -0,0 +1,37 @@
:root{--bg:#f4f5f7;--card:#fff;--fg:#1b1d22;--muted:#6a7180;--line:#d9dce3;--accent:#2563eb;--accent-fg:#fff;--danger:#c0392b;--ok:#15803d;--warn:#b45309;--me:#e7efff;--them:#f0f1f4;--sys:#fff7e0}
@media (prefers-color-scheme:dark){:root{--bg:#121418;--card:#1c1f26;--fg:#e8eaee;--muted:#9aa1ad;--line:#2e333d;--accent:#5b8cff;--accent-fg:#0b0d12;--danger:#ff6b5b;--ok:#4ade80;--warn:#fbbf24;--me:#1f2d4d;--them:#262a33;--sys:#3a3220}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.45 system-ui,-apple-system,"Segoe UI",sans-serif}
header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 16px;border-bottom:1px solid var(--line)}
h1{font-size:18px;margin:0}h2{font-size:15px;margin:0 0 10px}
main.grid{display:grid;grid-template-columns:minmax(280px,420px) 1fr;gap:16px;padding:16px;max-width:1200px;margin:0 auto}
main.narrow{max-width:520px;margin:60px auto;padding:0 16px}
@media (max-width:760px){main.grid{grid-template-columns:1fr}}
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:16px;min-width:0}
.chat{display:flex;flex-direction:column;min-height:420px}
.log{flex:1;overflow-y:auto;min-height:240px;max-height:65vh;display:flex;flex-direction:column;gap:6px;padding:4px 0 10px}
.msg{max-width:85%;padding:7px 10px;border-radius:10px;background:var(--them);white-space:pre-wrap;overflow-wrap:anywhere}
.msg.me{align-self:flex-end;background:var(--me)}
.msg.system{align-self:center;background:var(--sys);font-size:13px;color:var(--muted)}
.msg .who{display:block;font-size:12px;color:var(--muted);margin-bottom:2px}
.row{display:flex;gap:8px;align-items:center;margin:8px 0;flex-wrap:wrap}.row.between{justify-content:space-between}
.row>input,.row>textarea{flex:1;min-width:0}
input,select,textarea{font:inherit;color:inherit;background:var(--bg);border:1px solid var(--line);border-radius:7px;padding:7px 9px;width:100%}
textarea{resize:vertical}
button{font:inherit;cursor:pointer;border:1px solid var(--line);background:var(--bg);color:var(--fg);border-radius:7px;padding:7px 14px}
button.primary{background:var(--accent);border-color:var(--accent);color:var(--accent-fg);font-weight:600}
button.danger{color:var(--danger)}
button:disabled{opacity:.5;cursor:default}
button.small{padding:3px 10px;font-size:13px}
.field{display:block;margin:0 0 10px;font-size:14px}.field select,.field input{margin-top:4px}
.check{display:flex;gap:8px;align-items:flex-start;font-size:14px;margin:6px 0 10px}.check input{width:auto;margin-top:3px}
.muted{color:var(--muted)}.small{font-size:13px}.mono{font-family:ui-monospace,monospace}.big{font-size:17px;font-weight:600;margin-bottom:4px}
.hidden{display:none!important}
.pill{font-size:13px;padding:3px 10px;border-radius:99px;border:1px solid var(--line);color:var(--muted)}
.pill.ok{color:var(--ok);border-color:var(--ok)}.pill.bad{color:var(--danger);border-color:var(--danger)}
.banner{margin:16px;padding:12px 16px;border-radius:8px;background:var(--sys);border:1px solid var(--warn)}
.meter{display:grid;grid-template-columns:130px 1fr 52px;align-items:center;gap:8px;margin:8px 0;font-size:13px}
.bar{height:10px;background:var(--bg);border:1px solid var(--line);border-radius:5px;overflow:hidden}
.bar i{display:block;height:100%;width:0;background:var(--ok);transition:width .08s linear}
.bar i.hot{background:var(--warn)}.bar i.clip{background:var(--danger)}
.meter b{font-weight:400;color:var(--muted);text-align:right;font-variant-numeric:tabular-nums}