internal/checker: voice-quality + voice-srv tests for predictive voice diagnosis
Replaces the single-packet `stun` test with two predictive voice tests:
- voice-quality: 30-packet STUN burst through the SOCKS5 UDP relay.
Computes loss%, jitter (RFC-3550-ish mean abs of inter-arrival
delta), p50/p95 RTT. Three-tier gating: pass (loss≤5%, jitter≤30,
p50≤250), warn (loss≤15%, jitter≤60, p50≤400 — voice glitches but
works), fail (anything worse, including 100% loss).
- voice-srv: parallel-DNS the 16-region <region>.discord.media
hostnames, then SOCKS5 CONNECT to :443 on each through the proxy.
Catches the very common Russian-DPI failure mode where the proxy
passes generic Discord.com TCP but blocks the .discord.media voice
CIDRs — a regression all 5 prior SOCKS5 sanity checks miss.
New StatusWarn = "warn" — soft pass with Hint kept visible. Counted as
passed in summary but flagged in UI.
Config gains VoiceBurstCount (default 30), VoiceBurstInterval (default
20ms), VoiceServerHostnames (default = built-in 16-region list).
Tests cover happy path, warn-tier (10% drop), fail-tier (100% drop),
voice-srv blocked, plus standalone unit tests on
runVoiceQualityBurst and runVoiceServerProbe with a fake UDP relay
and fake SOCKS5 server. Race + cover stays at 82.4%.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+74
-14
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
@@ -35,8 +36,10 @@ func tcpFriendlyName(testID string) string {
|
||||
return "TCP-туннель к Discord"
|
||||
case "udp":
|
||||
return "UDP ASSOCIATE"
|
||||
case "stun":
|
||||
return "STUN round-trip"
|
||||
case "voice-quality":
|
||||
return "качество UDP-канала"
|
||||
case "voice-srv":
|
||||
return "доступность voice-серверов Discord"
|
||||
case "api":
|
||||
return "Discord API"
|
||||
default:
|
||||
@@ -125,25 +128,35 @@ func hintFor(testID string, err error) string {
|
||||
}
|
||||
return genericFallback(testID, err)
|
||||
|
||||
case "stun":
|
||||
case "voice-quality":
|
||||
switch {
|
||||
case errors.Is(err, ErrSTUNNoMappedAddress):
|
||||
return "STUN-ответ без XOR-MAPPED-ADDRESS — UDP-релей не пропускает обратный трафик."
|
||||
case errors.Is(err, ErrSTUNTooShort):
|
||||
return "STUN-ответ короче 20-байтного заголовка — релей возвращает мусор."
|
||||
case errors.Is(err, ErrSTUNBadMagicCookie):
|
||||
return "STUN-ответ без правильного magic cookie — релей возвращает мусор."
|
||||
case errors.Is(err, ErrSTUNNotSuccess):
|
||||
return "STUN-сервер вернул не Binding Success — UDP-релей сломан."
|
||||
case errors.Is(err, ErrSTUNTxIDMismatch):
|
||||
return "STUN-ответ с чужим transaction ID — релей путает пакеты."
|
||||
case errors.Is(err, ErrSTUNUnsupportedFamily):
|
||||
return "STUN-ответ с неподдерживаемым семейством адресов."
|
||||
case errors.Is(err, ErrSTUNTooShort),
|
||||
errors.Is(err, ErrSTUNBadMagicCookie),
|
||||
errors.Is(err, ErrSTUNNotSuccess),
|
||||
errors.Is(err, ErrSTUNTxIDMismatch),
|
||||
errors.Is(err, ErrSTUNUnsupportedFamily):
|
||||
return "STUN-релей возвращает мусор — голос работать не будет."
|
||||
case isTimeout:
|
||||
return "STUN-сервер не ответил вовремя — UDP-релей не работает в обе стороны."
|
||||
return "STUN-релей не отвечает — UDP через прокси сильно теряет пакеты."
|
||||
}
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) {
|
||||
return "Не удалось разрезолвить STUN-сервер — проверь системный DNS."
|
||||
}
|
||||
return genericFallback(testID, err)
|
||||
|
||||
case "voice-srv":
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) {
|
||||
return "DNS не резолвит voice-домены Discord."
|
||||
}
|
||||
if isTimeout {
|
||||
return "Не удалось проверить voice-серверы Discord вовремя — таймаут."
|
||||
}
|
||||
return fmt.Sprintf("Не удалось проверить доступность Discord voice-серверов: %s.", err.Error())
|
||||
|
||||
case "api":
|
||||
switch {
|
||||
case isTimeout:
|
||||
@@ -187,3 +200,50 @@ func socks5ReplyHint(step string, code byte) string {
|
||||
func genericFallback(testID string, err error) string {
|
||||
return fmt.Sprintf("Не удалось выполнить шаг «%s»: %s", tcpFriendlyName(testID), err.Error())
|
||||
}
|
||||
|
||||
// voiceQualityWarnHint composes a warn-tier hint based on which threshold
|
||||
// was violated. Thresholds match runVoiceQuality's warn band: loss>5,
|
||||
// jitter>30, p50>250. Always returns non-empty.
|
||||
func voiceQualityWarnHint(loss, jitter, p50 float64) string {
|
||||
parts := make([]string, 0, 3)
|
||||
if loss > 5.0 {
|
||||
parts = append(parts, fmt.Sprintf("Потери UDP %.0f%% — голос будет с заиканиями", loss))
|
||||
}
|
||||
if jitter > 30.0 {
|
||||
parts = append(parts, fmt.Sprintf("большой джиттер %.1fms — звук будет дёргаться", jitter))
|
||||
}
|
||||
if p50 > 250.0 {
|
||||
parts = append(parts, fmt.Sprintf("высокая задержка %.0fms — заметная рассинхронизация при разговоре", p50))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
// Shouldn't happen — caller only invokes us in the warn band.
|
||||
return "UDP-канал на грани приемлемого — возможны помехи в голосе."
|
||||
}
|
||||
return strings.Join(parts, "; ") + "."
|
||||
}
|
||||
|
||||
// voiceQualityFailHint composes a fail-tier hint. p95 is informational —
|
||||
// included only when notably worse than p50.
|
||||
func voiceQualityFailHint(loss, jitter, p50, p95 float64) string {
|
||||
_ = p95
|
||||
parts := make([]string, 0, 3)
|
||||
if loss > 15.0 {
|
||||
parts = append(parts, fmt.Sprintf("Потери UDP %.0f%% — голос работать не будет", loss))
|
||||
} else if loss > 5.0 {
|
||||
parts = append(parts, fmt.Sprintf("Потери UDP %.0f%%", loss))
|
||||
}
|
||||
if jitter > 60.0 {
|
||||
parts = append(parts, fmt.Sprintf("джиттер %.1fms — звук развалится", jitter))
|
||||
} else if jitter > 30.0 {
|
||||
parts = append(parts, fmt.Sprintf("джиттер %.1fms", jitter))
|
||||
}
|
||||
if p50 > 400.0 {
|
||||
parts = append(parts, fmt.Sprintf("задержка %.0fms — голос идёт со значительной паузой", p50))
|
||||
} else if p50 > 250.0 {
|
||||
parts = append(parts, fmt.Sprintf("задержка %.0fms", p50))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "UDP-канал не пригоден для голоса."
|
||||
}
|
||||
return strings.Join(parts, "; ") + "."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user