From b0e3b805b1e34045eb4ddf9a5dd71435d409a30a Mon Sep 17 00:00:00 2001 From: nam Date: Mon, 2 Nov 2026 16:14:00 -0600 Subject: [PATCH] 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 --- .gitignore | 5 + call.go | 653 +++++++++++++++++++++++++++++++++++++++++++ cmd/glprobe/main.go | 173 ++++++++++++ go.mod | 33 +++ go.sum | 52 ++++ main.go | 547 ++++++++++++++++++++++++++++++++++++ web/guest.html | 34 +++ web/host.html | 34 +++ web/index.html | 5 + web/static/common.js | 30 ++ web/static/guest.js | 168 +++++++++++ web/static/host.js | 68 +++++ web/static/style.css | 37 +++ 13 files changed, 1839 insertions(+) create mode 100644 .gitignore create mode 100644 call.go create mode 100644 cmd/glprobe/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 web/guest.html create mode 100644 web/host.html create mode 100644 web/index.html create mode 100644 web/static/common.js create mode 100644 web/static/guest.js create mode 100644 web/static/host.js create mode 100644 web/static/style.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a4d09e --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/guestlink +/guestlink.new +/glprobe +/backup-*/ +*.raw diff --git a/call.go b/call.go new file mode 100644 index 0000000..bc09aa9 --- /dev/null +++ b/call.go @@ -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 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, + } +} diff --git a/cmd/glprobe/main.go b/cmd/glprobe/main.go new file mode 100644 index 0000000..af88f9e --- /dev/null +++ b/cmd/glprobe/main.go @@ -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 not bold"}) + + 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() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..353c559 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..938808a --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..8d4bdce --- /dev/null +++ b/main.go @@ -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/. +// +// 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) } diff --git a/web/guest.html b/web/guest.html new file mode 100644 index 0000000..0d2ce1d --- /dev/null +++ b/web/guest.html @@ -0,0 +1,34 @@ + + + +MMO Guest Line + +

MMO Guest Line

connecting…
+ +
+
+

Audio

+ + + + +
+ + + +
+
Not connected to audio.
+
Your mic
+
The show
+ +
+
+

Chat with the host

+
+
+
+
+ + + diff --git a/web/host.html b/web/host.html new file mode 100644 index 0000000..0cbf2c7 --- /dev/null +++ b/web/host.html @@ -0,0 +1,34 @@ + + +Guest Line Console + +

Guest Line - host console

connecting…
+
+
+

Invite

+
+
+ + +
+

A new link ends the current session and clears chat. Old links stop working.

+

Guest

+
No guest connected
+
+
Guest → show
+
Mix-minus → guest
+
+
+ + +
+
+
+

Chat

+
+
+
+
+ + + diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..b3ac95a --- /dev/null +++ b/web/index.html @@ -0,0 +1,5 @@ + + +MMO Guest Line +

MMO Guest Line

+

You need a personal invite link from the host to join a show.

diff --git a/web/static/common.js b/web/static/common.js new file mode 100644 index 0000000..343e6a6 --- /dev/null +++ b/web/static/common.js @@ -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; } } diff --git a/web/static/guest.js b/web/static/guest.js new file mode 100644 index 0000000..42d93dd --- /dev/null +++ b/web/static/guest.js @@ -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(()=>{}); } diff --git a/web/static/host.js b/web/static/host.js new file mode 100644 index 0000000..dc421bd --- /dev/null +++ b/web/static/host.js @@ -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(); diff --git a/web/static/style.css b/web/static/style.css new file mode 100644 index 0000000..504eb07 --- /dev/null +++ b/web/static/style.css @@ -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}