Files
guestlink/cmd/glprobe/main.go
T
namandClaude Opus 5.5 b0e3b805b1 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>
2026-11-02 16:14:00 -06:00

174 lines
4.8 KiB
Go

// 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()
}