Files
drover-go/internal/checker/voice.go
T
root 11c4eb7f4a
Build / test (push) Failing after 30s
Build / build-windows (push) Has been skipped
Release / release (push) Failing after 3m20s
internal/checker+gui: remove voice-srv test (Discord doesn't expose regional voice servers via public DNS)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 19:02:11 +03:00

277 lines
7.3 KiB
Go

package checker
// voice.go — predictive voice diagnostics.
//
// runVoiceQualityBurst fires a burst of STUN binding requests through
// an open SOCKS5 UDP relay, then derives packet-loss / jitter /
// percentile-RTT from the replies. A single round-trip says the relay
// accepts UDP; a 30-packet burst tells you whether voice will actually
// hold together.
import (
"context"
"encoding/binary"
"errors"
"fmt"
"math"
"net"
"sort"
"sync"
"time"
)
// VoiceQualityResult is the outcome of a UDP burst through a SOCKS5
// relay. All fields are zero on a hard failure (no replies at all).
type VoiceQualityResult struct {
Sent int
Received int
LossPct float64 // 0..100
JitterMS float64 // mean abs of inter-arrival deltas in ms
P50RTTMS float64 // median round-trip in ms
P95RTTMS float64 // 95th percentile (informational, not gated)
}
// runVoiceQualityBurst sends `count` STUN binding requests through the
// already-open SOCKS5 UDP relay (relayAddr) to stunHost:stunPort,
// spaced `interval` apart. It listens on udpConn until
// `time.Now() + max(interval, 200ms)` after the last send, then returns
// the aggregate result.
//
// Each outbound datagram has the SOCKS5 UDP header
// (RSV 00 00, FRAG 00, ATYP 01, DST_IPv4(4), DST_PORT(2)) followed by
// a 20-byte STUN binding request. We track each request by its
// transaction ID. Replies are stripped of their 10-byte SOCKS5 UDP
// header before being handed to ParseBindingResponse.
//
// Returns an error only when ctx is cancelled or stunHost can't be
// resolved to IPv4. A 100% loss is NOT an error — the caller decides
// what status to assign; we just report Sent=count, Received=0.
func runVoiceQualityBurst(
ctx context.Context,
udpConn net.PacketConn,
relayAddr *net.UDPAddr,
stunHost string,
stunPort uint16,
count int,
interval time.Duration,
) (VoiceQualityResult, error) {
if count <= 0 {
return VoiceQualityResult{}, errors.New("voice-quality: burst count must be > 0")
}
// Resolve stunHost to IPv4.
ips, err := net.DefaultResolver.LookupIP(ctx, "ip4", stunHost)
if err != nil {
return VoiceQualityResult{}, fmt.Errorf("voice-quality: lookup %s: %w", stunHost, err)
}
var stunIP4 net.IP
for _, ip := range ips {
if v4 := ip.To4(); v4 != nil {
stunIP4 = v4
break
}
}
if stunIP4 == nil {
return VoiceQualityResult{}, fmt.Errorf("voice-quality: no IPv4 for %s", stunHost)
}
// Per-tx state: send-time + arrival-time.
type entry struct {
sentAt time.Time
arrivedAt time.Time
received bool
}
var (
mu sync.Mutex
entries = make(map[[12]byte]*entry, count)
arrivals = make([]time.Time, 0, count) // for jitter (in arrival order)
rtts = make([]float64, 0, count) // milliseconds
)
// Reader goroutine: loops on ReadFrom until deadline expires.
doneRead := make(chan struct{})
go func() {
defer close(doneRead)
buf := make([]byte, 1500)
for {
n, _, rerr := udpConn.ReadFrom(buf)
if rerr != nil {
// Deadline expired or conn closed — exit.
return
}
if n < 10 {
continue
}
// Validate SOCKS5 UDP wrapper, derive header length.
if buf[0] != 0x00 || buf[1] != 0x00 || buf[2] != 0x00 {
continue
}
var hdrLen int
switch buf[3] {
case 0x01:
hdrLen = 10
case 0x04:
hdrLen = 22
case 0x03:
if n < 5 {
continue
}
hdrLen = 4 + 1 + int(buf[4]) + 2
default:
continue
}
if n < hdrLen+20 {
continue
}
stunReply := buf[hdrLen:n]
// Pull the transaction ID out of the STUN header so we
// can look up the matching send-time. ParseBindingResponse
// rejects mismatched txIDs, so we feed it the *expected*
// id from the entries map.
var txID [12]byte
copy(txID[:], stunReply[8:20])
now := time.Now()
mu.Lock()
ent, ok := entries[txID]
if !ok || ent.received {
mu.Unlock()
continue
}
if _, _, perr := ParseBindingResponse(stunReply, txID); perr != nil {
mu.Unlock()
continue
}
ent.arrivedAt = now
ent.received = true
arrivals = append(arrivals, now)
rtts = append(rtts, float64(now.Sub(ent.sentAt).Microseconds())/1000.0)
mu.Unlock()
}
}()
// Build base SOCKS5 UDP header (RSV+FRAG+ATYP+IP+PORT). STUN body
// is per-packet (fresh tx id each).
hdr := make([]byte, 0, 10)
hdr = append(hdr, 0x00, 0x00, 0x00, 0x01)
hdr = append(hdr, stunIP4...)
var portBuf [2]byte
binary.BigEndian.PutUint16(portBuf[:], stunPort)
hdr = append(hdr, portBuf[:]...)
// Send burst.
ticker := time.NewTicker(interval)
defer ticker.Stop()
sent := 0
sendLoop:
for sent < count {
// Make a fresh tx id and STUN request.
txID, terr := NewTransactionID()
if terr != nil {
break
}
stunReq := EncodeBindingRequest(txID)
dgram := make([]byte, 0, len(hdr)+len(stunReq))
dgram = append(dgram, hdr...)
dgram = append(dgram, stunReq...)
// Record send-time *before* the write. Note: we register the
// entry into the map BEFORE Write so the reader can never get a
// reply for an unknown tx (would happen on a very fast localhost
// echo).
now := time.Now()
mu.Lock()
entries[txID] = &entry{sentAt: now}
mu.Unlock()
if _, werr := udpConn.WriteTo(dgram, relayAddr); werr != nil {
// Write failure aborts the burst — but we still wait for
// any in-flight replies. Treat as "sent so far".
break
}
sent++
if sent >= count {
break sendLoop
}
// Wait for next tick OR ctx cancel.
select {
case <-ticker.C:
case <-ctx.Done():
break sendLoop
}
}
// Wait window for stragglers — at least 200ms past last send.
wait := interval
if wait < 200*time.Millisecond {
wait = 200 * time.Millisecond
}
deadline := time.Now().Add(wait)
_ = udpConn.SetReadDeadline(deadline)
// Wait for reader to exit. ctx cancel still races: bound by deadline.
select {
case <-doneRead:
case <-ctx.Done():
// Force the reader to exit ASAP by setting a past deadline.
_ = udpConn.SetReadDeadline(time.Unix(0, 1))
<-doneRead
}
// Reset deadline so subsequent users of the conn aren't surprised.
_ = udpConn.SetReadDeadline(time.Time{})
// Compute aggregates.
mu.Lock()
defer mu.Unlock()
received := len(rtts)
res := VoiceQualityResult{
Sent: sent,
Received: received,
}
if sent > 0 {
res.LossPct = float64(sent-received) / float64(sent) * 100.0
}
if received >= 2 {
// Sort arrivals to compute inter-arrival jitter in chronological order.
// arrivals is already chronological (appended as packets came in).
var diffs []float64
for i := 1; i < len(arrivals); i++ {
d := float64(arrivals[i].Sub(arrivals[i-1]).Microseconds()) / 1000.0
diffs = append(diffs, d)
}
// mean abs of consecutive deltas of inter-arrival diffs.
if len(diffs) >= 2 {
var sum float64
for i := 1; i < len(diffs); i++ {
sum += math.Abs(diffs[i] - diffs[i-1])
}
res.JitterMS = sum / float64(len(diffs)-1)
} else if len(diffs) == 1 {
// Only two arrivals — single delta, no second-order jitter.
res.JitterMS = 0
}
}
if received > 0 {
// percentile.
sorted := make([]float64, len(rtts))
copy(sorted, rtts)
sort.Float64s(sorted)
p50idx := len(sorted) / 2
if p50idx >= len(sorted) {
p50idx = len(sorted) - 1
}
res.P50RTTMS = sorted[p50idx]
p95idx := int(0.95 * float64(len(sorted)))
if p95idx >= len(sorted) {
p95idx = len(sorted) - 1
}
res.P95RTTMS = sorted[p95idx]
}
return res, nil
}