mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
feat: rewrite server voice handlers for LiveKit (Phase 1)
Replace custom Pion WebRTC SFU with LiveKit token-based flow. Server changes: - client.go: remove PeerConnection, voiceDone, negoMu; add setVoiceChID/clearVoiceChID - voice_handlers.go: 961 -> ~295 lines; voice_join now generates LiveKit token, voice_leave calls RemoveParticipant; delete SDP/ICE/RTP/soundboard handlers - hub.go: replace SFU + VoiceRooms with LiveKitClient + LiveKitProcess; remove speaker broadcast goroutine - messages.go: add buildVoiceToken, remove buildVoiceOffer/ Answer/ICE; simplify buildVoiceConfig - handlers.go: remove voice_offer/answer/ice/soundboard dispatch - router.go: replace NewSFU with NewLiveKitClient, add optional LiveKit process auto-start Deleted files (14): - sfu.go, voice_room.go, speaker_detector.go, rtp_audio_level.go, speaker_broadcast.go, api/voice_handler.go - All corresponding test files All server tests pass (go test ./...).
This commit is contained in:
+15
-8
@@ -63,18 +63,25 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri
|
||||
MountUploadRoutes(r, database, store)
|
||||
}
|
||||
|
||||
// Voice credentials REST route.
|
||||
MountVoiceRoutes(r, cfg, database)
|
||||
|
||||
// WebSocket hub — WS does its own in-band auth, so no AuthMiddleware here.
|
||||
hub := ws.NewHub(database, limiter)
|
||||
|
||||
// Create SFU if voice config is present; voice is disabled on failure.
|
||||
sfu, sfuErr := ws.NewSFU(&cfg.Voice)
|
||||
if sfuErr != nil {
|
||||
slog.Warn("failed to create SFU, voice disabled", "error", sfuErr)
|
||||
// Create LiveKit client if voice config is present; voice is disabled on failure.
|
||||
lk, lkErr := ws.NewLiveKitClient(&cfg.Voice)
|
||||
if lkErr != nil {
|
||||
slog.Warn("failed to create LiveKit client, voice disabled", "error", lkErr)
|
||||
} else {
|
||||
hub.SetSFU(sfu)
|
||||
hub.SetLiveKit(lk)
|
||||
|
||||
// Optionally start a companion LiveKit process.
|
||||
if cfg.Voice.LiveKitBinaryPath != "" {
|
||||
proc := ws.NewLiveKitProcess(&cfg.Voice, cfg.Server.DataDir)
|
||||
if startErr := proc.Start(); startErr != nil {
|
||||
slog.Error("failed to start LiveKit process", "error", startErr)
|
||||
} else {
|
||||
hub.SetLiveKitProcess(proc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
go hub.Run()
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/owncord/server/config"
|
||||
"github.com/owncord/server/db"
|
||||
)
|
||||
|
||||
const voiceCredentialTTL = 24 * time.Hour
|
||||
|
||||
// iceServer describes a single ICE server entry for WebRTC peer connections.
|
||||
type iceServer struct {
|
||||
URLs string `json:"urls"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Credential string `json:"credential,omitempty"`
|
||||
}
|
||||
|
||||
// voiceCredentialsResponse is the JSON body for GET /api/v1/voice/credentials.
|
||||
type voiceCredentialsResponse struct {
|
||||
ICEServers []iceServer `json:"ice_servers"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
// turnCredentials holds the generated TURN username and HMAC credential.
|
||||
type turnCredentials struct {
|
||||
Username string
|
||||
Credential string
|
||||
}
|
||||
|
||||
// MountVoiceRoutes registers the voice REST endpoints on r.
|
||||
func MountVoiceRoutes(r chi.Router, cfg *config.Config, database *db.DB) {
|
||||
r.Route("/api/v1/voice", func(r chi.Router) {
|
||||
r.Use(AuthMiddleware(database))
|
||||
r.Get("/credentials", handleVoiceCredentials(cfg, database))
|
||||
})
|
||||
}
|
||||
|
||||
// handleVoiceCredentials returns ICE server credentials for WebRTC.
|
||||
// Requires a valid session (AuthMiddleware). Generates time-limited TURN
|
||||
// credentials using HMAC-SHA1 as per the coturn REST API spec.
|
||||
func handleVoiceCredentials(cfg *config.Config, _ *db.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := r.Context().Value(UserKey).(*db.User)
|
||||
if !ok || user == nil {
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||
Error: "UNAUTHORIZED",
|
||||
Message: "authentication required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
host := serverHost(r)
|
||||
servers := buildICEServers(user.ID, cfg, host)
|
||||
|
||||
urls := make([]string, 0, len(servers))
|
||||
for _, s := range servers {
|
||||
urls = append(urls, s.URLs)
|
||||
}
|
||||
slog.Info("voice credentials issued",
|
||||
"user_id", user.ID,
|
||||
"host", host,
|
||||
"ice_servers", urls,
|
||||
"external_ip", cfg.Voice.ExternalIP)
|
||||
|
||||
writeJSON(w, http.StatusOK, voiceCredentialsResponse{
|
||||
ICEServers: servers,
|
||||
ExpiresIn: int(voiceCredentialTTL.Seconds()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// buildICEServers constructs the ICE server list for the given user.
|
||||
// Always includes a public STUN server so clients behind NAT can discover
|
||||
// their server-reflexive address. Adds the self-hosted STUN and optional
|
||||
// TURN server if configured.
|
||||
func buildICEServers(userID int64, cfg *config.Config, host string) []iceServer {
|
||||
servers := []iceServer{
|
||||
// Public STUN — reliable fallback for NAT traversal even if the
|
||||
// self-hosted STUN port isn't reachable.
|
||||
{URLs: "stun:stun.l.google.com:19302"},
|
||||
{URLs: fmt.Sprintf("stun:%s:%d", host, cfg.Voice.STUNPort)},
|
||||
}
|
||||
|
||||
if cfg.Voice.TURNEnabled && cfg.Voice.TURNSecret != "" {
|
||||
creds := generateTURNCredentials(userID, cfg.Voice.TURNSecret)
|
||||
servers = append(servers, iceServer{
|
||||
URLs: fmt.Sprintf("turn:%s:%d", host, cfg.Voice.TURNPort),
|
||||
Username: creds.Username,
|
||||
Credential: creds.Credential,
|
||||
})
|
||||
}
|
||||
|
||||
return servers
|
||||
}
|
||||
|
||||
// generateTURNCredentials produces time-limited TURN credentials using HMAC-SHA1.
|
||||
// Username format: "<expiry_unix_timestamp>:<userID>"
|
||||
// Credential: base64(HMAC-SHA1(secret, username))
|
||||
func generateTURNCredentials(userID int64, secret string) turnCredentials {
|
||||
expiry := time.Now().Add(voiceCredentialTTL).Unix()
|
||||
username := fmt.Sprintf("%d:%d", expiry, userID)
|
||||
|
||||
mac := hmac.New(sha1.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(username))
|
||||
credential := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
|
||||
return turnCredentials{
|
||||
Username: username,
|
||||
Credential: credential,
|
||||
}
|
||||
}
|
||||
|
||||
// serverHost extracts the host (without port) for ICE server URLs from the
|
||||
// request, or falls back to "localhost". Uses net.SplitHostPort for correct
|
||||
// handling of IPv6 addresses with ports (e.g. "[::1]:8443").
|
||||
func serverHost(r *http.Request) string {
|
||||
host := r.Host
|
||||
if host == "" {
|
||||
return "localhost"
|
||||
}
|
||||
h, _, err := net.SplitHostPort(host)
|
||||
if err != nil {
|
||||
// No port present — return as-is.
|
||||
return host
|
||||
}
|
||||
return h
|
||||
}
|
||||
@@ -1,344 +0,0 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/owncord/server/api"
|
||||
"github.com/owncord/server/auth"
|
||||
"github.com/owncord/server/config"
|
||||
"github.com/owncord/server/db"
|
||||
)
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// newVoiceAPITestDB opens an in-memory DB for voice API tests.
|
||||
func newVoiceAPITestDB(t *testing.T) *db.DB {
|
||||
t.Helper()
|
||||
database, err := db.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("db.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
|
||||
migrFS := fstest.MapFS{
|
||||
"001_schema.sql": {Data: apiTestSchema},
|
||||
}
|
||||
if err := db.MigrateFS(database, migrFS); err != nil {
|
||||
t.Fatalf("MigrateFS: %v", err)
|
||||
}
|
||||
return database
|
||||
}
|
||||
|
||||
// buildVoiceRouter returns a chi router with voice routes mounted.
|
||||
func buildVoiceRouter(database *db.DB, cfg *config.Config) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
api.MountVoiceRoutes(r, cfg, database)
|
||||
return r
|
||||
}
|
||||
|
||||
// seedAPIUser creates a user+session and returns a valid bearer token.
|
||||
func seedVoiceAPIUser(t *testing.T, database *db.DB, username string) string {
|
||||
t.Helper()
|
||||
_, err := database.CreateUser(username, "hash", 4)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
user, err := database.GetUserByUsername(username)
|
||||
if err != nil || user == nil {
|
||||
t.Fatalf("GetUserByUsername: %v", err)
|
||||
}
|
||||
token := "test-token-" + username
|
||||
hash := auth.HashToken(token)
|
||||
future := time.Now().Add(24 * time.Hour).UTC().Format("2006-01-02 15:04:05")
|
||||
_, err = database.Exec(
|
||||
`INSERT INTO sessions (user_id, token, device, ip_address, expires_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
user.ID, hash, "test", "127.0.0.1", future,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert session: %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
// voiceGetWithToken performs a GET with Authorization: Bearer header.
|
||||
func voiceGetWithToken(t *testing.T, router http.Handler, path, token string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.RemoteAddr = "127.0.0.1:9999"
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
// defaultVoiceCfg returns a Config with a known TURN secret for testing.
|
||||
func defaultVoiceCfg() *config.Config {
|
||||
return &config.Config{
|
||||
Server: config.ServerConfig{Name: "Test"},
|
||||
Voice: config.VoiceConfig{
|
||||
TURNSecret: "test-secret-key-12345",
|
||||
STUNPort: 3478,
|
||||
TURNPort: 3478,
|
||||
TURNEnabled: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ─── GET /api/v1/voice/credentials ───────────────────────────────────────────
|
||||
|
||||
func TestVoiceCredentials_Authenticated_Returns200(t *testing.T) {
|
||||
database := newVoiceAPITestDB(t)
|
||||
token := seedVoiceAPIUser(t, database, "alice")
|
||||
cfg := defaultVoiceCfg()
|
||||
|
||||
router := buildVoiceRouter(database, cfg)
|
||||
rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceCredentials_Unauthenticated_Returns401(t *testing.T) {
|
||||
database := newVoiceAPITestDB(t)
|
||||
cfg := defaultVoiceCfg()
|
||||
|
||||
router := buildVoiceRouter(database, cfg)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/voice/credentials", nil)
|
||||
req.RemoteAddr = "127.0.0.1:9999"
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceCredentials_ResponseContainsIceServers(t *testing.T) {
|
||||
database := newVoiceAPITestDB(t)
|
||||
token := seedVoiceAPIUser(t, database, "bob")
|
||||
cfg := defaultVoiceCfg()
|
||||
|
||||
router := buildVoiceRouter(database, cfg)
|
||||
rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token)
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
iceServers, ok := resp["ice_servers"]
|
||||
if !ok {
|
||||
t.Fatal("response missing ice_servers field")
|
||||
}
|
||||
servers, ok := iceServers.([]any)
|
||||
if !ok || len(servers) == 0 {
|
||||
t.Error("ice_servers is empty or wrong type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceCredentials_ContainsSTUNEntry(t *testing.T) {
|
||||
database := newVoiceAPITestDB(t)
|
||||
token := seedVoiceAPIUser(t, database, "carol")
|
||||
cfg := defaultVoiceCfg()
|
||||
|
||||
router := buildVoiceRouter(database, cfg)
|
||||
rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token)
|
||||
|
||||
var resp map[string]any
|
||||
_ = json.NewDecoder(rr.Body).Decode(&resp)
|
||||
|
||||
servers := resp["ice_servers"].([]any)
|
||||
foundSTUN := false
|
||||
for _, s := range servers {
|
||||
entry := s.(map[string]any)
|
||||
if urls, ok := entry["urls"].(string); ok {
|
||||
if len(urls) > 5 && urls[:5] == "stun:" {
|
||||
foundSTUN = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundSTUN {
|
||||
t.Error("ice_servers does not contain a STUN entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceCredentials_ContainsTURNEntry(t *testing.T) {
|
||||
database := newVoiceAPITestDB(t)
|
||||
token := seedVoiceAPIUser(t, database, "dave")
|
||||
cfg := defaultVoiceCfg()
|
||||
|
||||
router := buildVoiceRouter(database, cfg)
|
||||
rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token)
|
||||
|
||||
var resp map[string]any
|
||||
_ = json.NewDecoder(rr.Body).Decode(&resp)
|
||||
|
||||
servers := resp["ice_servers"].([]any)
|
||||
foundTURN := false
|
||||
for _, s := range servers {
|
||||
entry := s.(map[string]any)
|
||||
if urls, ok := entry["urls"].(string); ok {
|
||||
if len(urls) > 5 && urls[:5] == "turn:" {
|
||||
foundTURN = true
|
||||
// TURN entries must have username and credential.
|
||||
if _, hasUser := entry["username"]; !hasUser {
|
||||
t.Error("TURN entry missing username")
|
||||
}
|
||||
if _, hasCred := entry["credential"]; !hasCred {
|
||||
t.Error("TURN entry missing credential")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundTURN {
|
||||
t.Error("ice_servers does not contain a TURN entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceCredentials_TURNCredentialIsValidHMAC(t *testing.T) {
|
||||
database := newVoiceAPITestDB(t)
|
||||
token := seedVoiceAPIUser(t, database, "eve")
|
||||
secret := "test-secret-key-12345"
|
||||
cfg := &config.Config{
|
||||
Voice: config.VoiceConfig{
|
||||
TURNSecret: secret,
|
||||
STUNPort: 3478,
|
||||
TURNPort: 3478,
|
||||
TURNEnabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
router := buildVoiceRouter(database, cfg)
|
||||
rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token)
|
||||
|
||||
var resp map[string]any
|
||||
_ = json.NewDecoder(rr.Body).Decode(&resp)
|
||||
|
||||
servers := resp["ice_servers"].([]any)
|
||||
for _, s := range servers {
|
||||
entry := s.(map[string]any)
|
||||
urls, _ := entry["urls"].(string)
|
||||
if len(urls) < 5 || urls[:5] != "turn:" {
|
||||
continue
|
||||
}
|
||||
username, _ := entry["username"].(string)
|
||||
credential, _ := entry["credential"].(string)
|
||||
|
||||
if username == "" || credential == "" {
|
||||
t.Fatal("TURN entry has empty username or credential")
|
||||
}
|
||||
|
||||
// Verify HMAC-SHA1: credential should be base64(HMAC-SHA1(secret, username)).
|
||||
mac := hmac.New(sha1.New, []byte(secret))
|
||||
mac.Write([]byte(username))
|
||||
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
|
||||
if credential != expected {
|
||||
t.Errorf("TURN credential HMAC mismatch\n got: %s\n want: %s", credential, expected)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Error("no TURN entry found to validate HMAC")
|
||||
}
|
||||
|
||||
func TestVoiceCredentials_UsernameContainsTimestampAndUserID(t *testing.T) {
|
||||
database := newVoiceAPITestDB(t)
|
||||
token := seedVoiceAPIUser(t, database, "frank")
|
||||
cfg := defaultVoiceCfg()
|
||||
|
||||
router := buildVoiceRouter(database, cfg)
|
||||
rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token)
|
||||
|
||||
var resp map[string]any
|
||||
_ = json.NewDecoder(rr.Body).Decode(&resp)
|
||||
|
||||
servers := resp["ice_servers"].([]any)
|
||||
for _, s := range servers {
|
||||
entry := s.(map[string]any)
|
||||
urls, _ := entry["urls"].(string)
|
||||
if len(urls) < 5 || urls[:5] != "turn:" {
|
||||
continue
|
||||
}
|
||||
username, _ := entry["username"].(string)
|
||||
|
||||
// Username format: "<unix_timestamp>:<userID>".
|
||||
var ts, uid int64
|
||||
if _, err := fmt.Sscanf(username, "%d:%d", &ts, &uid); err != nil {
|
||||
t.Errorf("TURN username %q is not in format <timestamp>:<userID>: %v", username, err)
|
||||
}
|
||||
if ts <= time.Now().Unix() {
|
||||
t.Errorf("TURN username timestamp %d is in the past, want future", ts)
|
||||
}
|
||||
if uid <= 0 {
|
||||
t.Errorf("TURN username userID %d must be positive", uid)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Error("no TURN entry found to validate username format")
|
||||
}
|
||||
|
||||
func TestVoiceCredentials_ResponseContainsExpiresIn(t *testing.T) {
|
||||
database := newVoiceAPITestDB(t)
|
||||
token := seedVoiceAPIUser(t, database, "grace")
|
||||
cfg := defaultVoiceCfg()
|
||||
|
||||
router := buildVoiceRouter(database, cfg)
|
||||
rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token)
|
||||
|
||||
var resp map[string]any
|
||||
_ = json.NewDecoder(rr.Body).Decode(&resp)
|
||||
|
||||
expiresIn, ok := resp["expires_in"]
|
||||
if !ok {
|
||||
t.Fatal("response missing expires_in field")
|
||||
}
|
||||
// expires_in should be 86400 (24 hours in seconds).
|
||||
val, ok := expiresIn.(float64)
|
||||
if !ok || val != 86400 {
|
||||
t.Errorf("expires_in = %v, want 86400", expiresIn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceCredentials_TURNDisabled_NoTURNEntry(t *testing.T) {
|
||||
database := newVoiceAPITestDB(t)
|
||||
token := seedVoiceAPIUser(t, database, "henry")
|
||||
cfg := &config.Config{
|
||||
Voice: config.VoiceConfig{
|
||||
TURNSecret: "secret",
|
||||
STUNPort: 3478,
|
||||
TURNPort: 3478,
|
||||
TURNEnabled: false, // TURN disabled
|
||||
},
|
||||
}
|
||||
|
||||
router := buildVoiceRouter(database, cfg)
|
||||
rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
_ = json.NewDecoder(rr.Body).Decode(&resp)
|
||||
|
||||
servers := resp["ice_servers"].([]any)
|
||||
for _, s := range servers {
|
||||
entry := s.(map[string]any)
|
||||
if urls, _ := entry["urls"].(string); len(urls) >= 5 && urls[:5] == "turn:" {
|
||||
t.Error("TURN entry present when TURNEnabled=false")
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -13,8 +13,6 @@ require (
|
||||
github.com/livekit/protocol v1.45.1
|
||||
github.com/livekit/server-sdk-go/v2 v2.16.0
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/pion/interceptor v0.1.44
|
||||
github.com/pion/webrtc/v4 v4.2.9
|
||||
go.yaml.in/yaml/v3 v3.0.4
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/mod v0.34.0
|
||||
@@ -66,6 +64,7 @@ require (
|
||||
github.com/pion/datachannel v1.6.0 // indirect
|
||||
github.com/pion/dtls/v3 v3.1.2 // indirect
|
||||
github.com/pion/ice/v4 v4.2.1 // indirect
|
||||
github.com/pion/interceptor v0.1.44 // indirect
|
||||
github.com/pion/logging v0.2.4 // indirect
|
||||
github.com/pion/mdns/v2 v2.1.0 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
@@ -77,6 +76,7 @@ require (
|
||||
github.com/pion/stun/v3 v3.1.1 // indirect
|
||||
github.com/pion/transport/v4 v4.0.1 // indirect
|
||||
github.com/pion/turn/v4 v4.1.4 // indirect
|
||||
github.com/pion/webrtc/v4 v4.2.9 // indirect
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
|
||||
github.com/redis/go-redis/v9 v9.17.2 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
|
||||
+7
-37
@@ -3,8 +3,6 @@ package ws
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
|
||||
"github.com/owncord/server/db"
|
||||
)
|
||||
|
||||
@@ -24,16 +22,13 @@ type Client struct {
|
||||
user *db.User
|
||||
channelID int64 // currently viewed channel for channel-scoped broadcasts
|
||||
voiceChID int64 // voice channel the user is in (0 = not in voice); guarded by voiceMu
|
||||
pc *webrtc.PeerConnection // SFU peer connection; nil when not in voice; guarded by voiceMu
|
||||
voiceDone chan struct{} // closed by clearVoice to signal RTP goroutines to exit; guarded by voiceMu
|
||||
roleName string // cached role name for chat_message broadcasts
|
||||
tokenHash string // SHA-256 hex of the session token; used for periodic revalidation
|
||||
msgCount int // count of messages processed; resets after session check
|
||||
sendClosed bool // true after the send channel has been closed
|
||||
send chan []byte
|
||||
mu sync.Mutex // guards sendClosed, msgCount, channelID
|
||||
voiceMu sync.Mutex // guards voiceChID and pc
|
||||
negoMu sync.Mutex // serialises SDP signalling (renegotiate / handleOffer / handleAnswer) per client
|
||||
voiceMu sync.Mutex // guards voiceChID
|
||||
}
|
||||
|
||||
// wsConn is the subset of nhooyr.io/websocket.Conn used by writePump/readPump.
|
||||
@@ -120,45 +115,20 @@ func (c *Client) getVoiceChID() int64 {
|
||||
return c.voiceChID
|
||||
}
|
||||
|
||||
// getPC returns the PeerConnection under voiceMu.
|
||||
func (c *Client) getPC() *webrtc.PeerConnection {
|
||||
c.voiceMu.Lock()
|
||||
defer c.voiceMu.Unlock()
|
||||
return c.pc
|
||||
}
|
||||
|
||||
// setVoice sets the voice channel and PeerConnection atomically.
|
||||
// It also creates a done channel that RTP goroutines can select on.
|
||||
func (c *Client) setVoice(chID int64, pc *webrtc.PeerConnection) {
|
||||
// setVoiceChID sets the voice channel ID atomically.
|
||||
func (c *Client) setVoiceChID(chID int64) {
|
||||
c.voiceMu.Lock()
|
||||
defer c.voiceMu.Unlock()
|
||||
c.voiceChID = chID
|
||||
c.pc = pc
|
||||
c.voiceDone = make(chan struct{})
|
||||
}
|
||||
|
||||
// getVoiceDone returns the done channel for the current voice session.
|
||||
func (c *Client) getVoiceDone() <-chan struct{} {
|
||||
// clearVoiceChID clears the voice channel ID and returns the old value.
|
||||
func (c *Client) clearVoiceChID() int64 {
|
||||
c.voiceMu.Lock()
|
||||
defer c.voiceMu.Unlock()
|
||||
return c.voiceDone
|
||||
}
|
||||
|
||||
// clearVoice clears voice state and returns the old values for cleanup.
|
||||
// The caller is responsible for closing the returned PeerConnection.
|
||||
// Closes the voiceDone channel to signal any RTP goroutines to exit.
|
||||
func (c *Client) clearVoice() (oldChID int64, oldPC *webrtc.PeerConnection) {
|
||||
c.voiceMu.Lock()
|
||||
defer c.voiceMu.Unlock()
|
||||
oldChID = c.voiceChID
|
||||
oldPC = c.pc
|
||||
if c.voiceDone != nil {
|
||||
close(c.voiceDone)
|
||||
c.voiceDone = nil
|
||||
}
|
||||
oldChID := c.voiceChID
|
||||
c.voiceChID = 0
|
||||
c.pc = nil
|
||||
return
|
||||
return oldChID
|
||||
}
|
||||
|
||||
// sendMsg queues a message to this client's send buffer without blocking.
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/owncord/server/auth"
|
||||
"github.com/owncord/server/config"
|
||||
"github.com/owncord/server/db"
|
||||
"github.com/owncord/server/ws"
|
||||
)
|
||||
@@ -136,151 +135,6 @@ func TestSetClientVoiceChID_ConcurrentAccess(t *testing.T) {
|
||||
<-done
|
||||
}
|
||||
|
||||
// ─── setupICEMonitor — nil PC guard path (voice_handlers.go:30) ───────────────
|
||||
|
||||
func TestSetupICEMonitor_NilPC_NoPanic(t *testing.T) {
|
||||
hub, _ := newCoverageHub(t)
|
||||
send := make(chan []byte, 4)
|
||||
c := ws.NewTestClient(hub, 1, send)
|
||||
|
||||
// Client has no PeerConnection (pc == nil).
|
||||
// setupICEMonitor should return early without panic.
|
||||
hub.SetupICEMonitorForTest(c, 42)
|
||||
}
|
||||
|
||||
// ─── setupICECallback — nil PC guard path (voice_handlers.go:67) ──────────────
|
||||
|
||||
func TestSetupICECallback_NilPC_NoPanic(t *testing.T) {
|
||||
hub, _ := newCoverageHub(t)
|
||||
send := make(chan []byte, 4)
|
||||
c := ws.NewTestClient(hub, 1, send)
|
||||
|
||||
// Client has no PeerConnection (pc == nil).
|
||||
// setupICECallback should return early without panic.
|
||||
hub.SetupICECallbackForTest(c, 42)
|
||||
}
|
||||
|
||||
// ─── renegotiateParticipant — nil PC guard path (voice_handlers.go:83) ────────
|
||||
|
||||
func TestRenegotiateParticipant_NilPC_NoPanic(t *testing.T) {
|
||||
hub, _ := newCoverageHub(t)
|
||||
send := make(chan []byte, 4)
|
||||
c := ws.NewTestClient(hub, 1, send)
|
||||
|
||||
// Client has no PeerConnection (pc == nil).
|
||||
// renegotiateParticipant should return early without panic.
|
||||
hub.RenegotiateParticipantForTest(c)
|
||||
}
|
||||
|
||||
// ─── SFU.Close (sfu.go:97 — 0% coverage) ─────────────────────────────────────
|
||||
|
||||
func TestSFU_Close_DoubleClose_NoPanic(t *testing.T) {
|
||||
cfg := &config.VoiceConfig{
|
||||
Quality: "medium",
|
||||
MediaPortMin: 50000,
|
||||
MediaPortMax: 50100,
|
||||
}
|
||||
sfu, err := ws.NewSFU(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSFU: %v", err)
|
||||
}
|
||||
sfu.Close()
|
||||
// Double close must not panic.
|
||||
sfu.Close()
|
||||
}
|
||||
|
||||
// ─── NewSFU with STUN port (sfu.go:73 — 66.7% coverage) ─────────────────────
|
||||
|
||||
func TestNewPeerConnection_WithSTUNPort(t *testing.T) {
|
||||
cfg := &config.VoiceConfig{
|
||||
Quality: "medium",
|
||||
MediaPortMin: 50000,
|
||||
MediaPortMax: 50100,
|
||||
STUNPort: 3478,
|
||||
}
|
||||
sfu, err := ws.NewSFU(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSFU: %v", err)
|
||||
}
|
||||
defer sfu.Close()
|
||||
|
||||
pc, err := sfu.NewPeerConnection()
|
||||
if err != nil {
|
||||
t.Fatalf("NewPeerConnection: %v", err)
|
||||
}
|
||||
if pc == nil {
|
||||
t.Fatal("NewPeerConnection returned nil")
|
||||
}
|
||||
_ = pc.Close()
|
||||
}
|
||||
|
||||
func TestNewPeerConnection_WithTURN(t *testing.T) {
|
||||
cfg := &config.VoiceConfig{
|
||||
Quality: "high",
|
||||
MediaPortMin: 50000,
|
||||
MediaPortMax: 50100,
|
||||
STUNPort: 3478,
|
||||
TURNEnabled: true,
|
||||
TURNPort: 3479,
|
||||
TURNSecret: "test-secret",
|
||||
}
|
||||
sfu, err := ws.NewSFU(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSFU: %v", err)
|
||||
}
|
||||
defer sfu.Close()
|
||||
|
||||
pc, err := sfu.NewPeerConnection()
|
||||
if err != nil {
|
||||
t.Fatalf("NewPeerConnection: %v", err)
|
||||
}
|
||||
if pc == nil {
|
||||
t.Fatal("NewPeerConnection returned nil")
|
||||
}
|
||||
_ = pc.Close()
|
||||
}
|
||||
|
||||
func TestNewPeerConnection_WithTURNDisabled(t *testing.T) {
|
||||
cfg := &config.VoiceConfig{
|
||||
Quality: "low",
|
||||
MediaPortMin: 50000,
|
||||
MediaPortMax: 50100,
|
||||
TURNEnabled: false,
|
||||
TURNPort: 3479,
|
||||
TURNSecret: "test-secret",
|
||||
}
|
||||
sfu, err := ws.NewSFU(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSFU: %v", err)
|
||||
}
|
||||
defer sfu.Close()
|
||||
|
||||
pc, err := sfu.NewPeerConnection()
|
||||
if err != nil {
|
||||
t.Fatalf("NewPeerConnection: %v", err)
|
||||
}
|
||||
_ = pc.Close()
|
||||
}
|
||||
|
||||
func TestNewPeerConnection_NoSTUNPort(t *testing.T) {
|
||||
cfg := &config.VoiceConfig{
|
||||
Quality: "medium",
|
||||
MediaPortMin: 50000,
|
||||
MediaPortMax: 50100,
|
||||
STUNPort: 0,
|
||||
}
|
||||
sfu, err := ws.NewSFU(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSFU: %v", err)
|
||||
}
|
||||
defer sfu.Close()
|
||||
|
||||
pc, err := sfu.NewPeerConnection()
|
||||
if err != nil {
|
||||
t.Fatalf("NewPeerConnection: %v", err)
|
||||
}
|
||||
_ = pc.Close()
|
||||
}
|
||||
|
||||
// ─── buildJSON error fallback (messages.go:18 — 75% coverage) ────────────────
|
||||
|
||||
@@ -324,16 +178,9 @@ func TestGracefulStop_WithClientsHavingVoiceState(t *testing.T) {
|
||||
// Set voice channel ID on the client to simulate voice state.
|
||||
ws.SetClientVoiceChID(c, 42)
|
||||
|
||||
// Create a voice room so GracefulStop has rooms to clean up.
|
||||
hub.GetOrCreateVoiceRoom(42, ws.VoiceRoomConfig{ChannelID: 42, MaxUsers: 10, Quality: "medium"})
|
||||
|
||||
hub.GracefulStop()
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
// Voice rooms should be cleaned up.
|
||||
if hub.GetVoiceRoom(42) != nil {
|
||||
t.Error("expected voice room to be nil after GracefulStop")
|
||||
}
|
||||
// Should not panic.
|
||||
}
|
||||
|
||||
func TestGracefulStop_MultipleClients(t *testing.T) {
|
||||
@@ -779,170 +626,6 @@ func TestHandleVoiceDeafen_InvalidPayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleVoiceOffer_NoPC(t *testing.T) {
|
||||
hub, database := newCoverageHub(t)
|
||||
user := seedCoverageOwner(t, database, "vo-no-pc")
|
||||
send := make(chan []byte, 16)
|
||||
c := ws.NewTestClientWithUser(hub, user, 0, send)
|
||||
hub.Register(c)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"type": "voice_offer",
|
||||
"payload": map[string]any{
|
||||
"channel_id": 1,
|
||||
"sdp": "v=0\r\n",
|
||||
},
|
||||
})
|
||||
hub.HandleMessageForTest(c, raw)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
code := drainForErrorCode(send, 200*time.Millisecond)
|
||||
if code != "VOICE_ERROR" {
|
||||
t.Errorf("error code = %q, want VOICE_ERROR for no PC", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleVoiceAnswer_NoPC(t *testing.T) {
|
||||
hub, database := newCoverageHub(t)
|
||||
user := seedCoverageOwner(t, database, "va-no-pc")
|
||||
send := make(chan []byte, 16)
|
||||
c := ws.NewTestClientWithUser(hub, user, 0, send)
|
||||
hub.Register(c)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"type": "voice_answer",
|
||||
"payload": map[string]any{
|
||||
"channel_id": 1,
|
||||
"sdp": "v=0\r\n",
|
||||
},
|
||||
})
|
||||
hub.HandleMessageForTest(c, raw)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
code := drainForErrorCode(send, 200*time.Millisecond)
|
||||
if code != "VOICE_ERROR" {
|
||||
t.Errorf("error code = %q, want VOICE_ERROR for no PC", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleVoiceICE_NoPC(t *testing.T) {
|
||||
hub, database := newCoverageHub(t)
|
||||
user := seedCoverageOwner(t, database, "vi-no-pc")
|
||||
send := make(chan []byte, 16)
|
||||
c := ws.NewTestClientWithUser(hub, user, 0, send)
|
||||
hub.Register(c)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"type": "voice_ice",
|
||||
"payload": map[string]any{
|
||||
"channel_id": 1,
|
||||
"candidate": map[string]any{"candidate": ""},
|
||||
},
|
||||
})
|
||||
hub.HandleMessageForTest(c, raw)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
code := drainForErrorCode(send, 200*time.Millisecond)
|
||||
if code != "VOICE_ERROR" {
|
||||
t.Errorf("error code = %q, want VOICE_ERROR for no PC", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleVoiceOffer_InvalidPayload(t *testing.T) {
|
||||
hub, database := newCoverageHub(t)
|
||||
user := seedCoverageOwner(t, database, "vo-bad-payload")
|
||||
send := make(chan []byte, 16)
|
||||
c := ws.NewTestClientWithUser(hub, user, 0, send)
|
||||
hub.Register(c)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
// Client needs a PC for the payload to be parsed.
|
||||
// Without a PC, we get VOICE_ERROR before parsing.
|
||||
// Test the payload parse path requires a PC, so test that path
|
||||
// via the no-PC early return above.
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"type": "voice_offer",
|
||||
"payload": "bad",
|
||||
})
|
||||
hub.HandleMessageForTest(c, raw)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
code := drainForErrorCode(send, 200*time.Millisecond)
|
||||
if code == "" {
|
||||
t.Error("expected an error for invalid voice_offer payload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleVoiceOffer_EmptySDP(t *testing.T) {
|
||||
hub, database := newCoverageHub(t)
|
||||
user := seedCoverageOwner(t, database, "vo-empty-sdp")
|
||||
send := make(chan []byte, 16)
|
||||
c := ws.NewTestClientWithUser(hub, user, 0, send)
|
||||
hub.Register(c)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
// Without PC, gets VOICE_ERROR before SDP check. That's fine — it covers
|
||||
// the rate limiter and early-return path.
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"type": "voice_offer",
|
||||
"payload": map[string]any{
|
||||
"channel_id": 1,
|
||||
"sdp": "",
|
||||
},
|
||||
})
|
||||
hub.HandleMessageForTest(c, raw)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
code := drainForErrorCode(send, 200*time.Millisecond)
|
||||
if code == "" {
|
||||
t.Error("expected an error for empty SDP")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleVoiceAnswer_InvalidPayload(t *testing.T) {
|
||||
hub, database := newCoverageHub(t)
|
||||
user := seedCoverageOwner(t, database, "va-bad-payload")
|
||||
send := make(chan []byte, 16)
|
||||
c := ws.NewTestClientWithUser(hub, user, 0, send)
|
||||
hub.Register(c)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"type": "voice_answer",
|
||||
"payload": "bad",
|
||||
})
|
||||
hub.HandleMessageForTest(c, raw)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
code := drainForErrorCode(send, 200*time.Millisecond)
|
||||
if code == "" {
|
||||
t.Error("expected error for invalid voice_answer payload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleVoiceICE_InvalidPayload(t *testing.T) {
|
||||
hub, database := newCoverageHub(t)
|
||||
user := seedCoverageOwner(t, database, "vi-bad-payload")
|
||||
send := make(chan []byte, 16)
|
||||
c := ws.NewTestClientWithUser(hub, user, 0, send)
|
||||
hub.Register(c)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"type": "voice_ice",
|
||||
"payload": "bad",
|
||||
})
|
||||
hub.HandleMessageForTest(c, raw)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
code := drainForErrorCode(send, 200*time.Millisecond)
|
||||
if code == "" {
|
||||
t.Error("expected error for invalid voice_ice payload")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── voice camera and screenshare error paths ────────────────────────────────
|
||||
|
||||
@@ -1047,50 +730,6 @@ func TestHandleVoiceScreenshare_InvalidPayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── soundboard handler error paths ──────────────────────────────────────────
|
||||
|
||||
func TestHandleSoundboard_MissingSoundID(t *testing.T) {
|
||||
hub, database := newCoverageHub(t)
|
||||
user := seedCoverageOwner(t, database, "sb-missing-id")
|
||||
send := make(chan []byte, 16)
|
||||
c := ws.NewTestClientWithUser(hub, user, 0, send)
|
||||
hub.Register(c)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"type": "soundboard_play",
|
||||
"payload": map[string]any{},
|
||||
})
|
||||
hub.HandleMessageForTest(c, raw)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
code := drainForErrorCode(send, 200*time.Millisecond)
|
||||
if code != "BAD_REQUEST" {
|
||||
t.Errorf("error code = %q, want BAD_REQUEST for missing sound_id", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSoundboard_InvalidPayload(t *testing.T) {
|
||||
hub, database := newCoverageHub(t)
|
||||
user := seedCoverageOwner(t, database, "sb-bad-payload")
|
||||
send := make(chan []byte, 16)
|
||||
c := ws.NewTestClientWithUser(hub, user, 0, send)
|
||||
hub.Register(c)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"type": "soundboard_play",
|
||||
"payload": "not-an-object",
|
||||
})
|
||||
hub.HandleMessageForTest(c, raw)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
code := drainForErrorCode(send, 200*time.Millisecond)
|
||||
if code != "BAD_REQUEST" {
|
||||
t.Errorf("error code = %q, want BAD_REQUEST for invalid soundboard payload", code)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── channel_focus handler ───────────────────────────────────────────────────
|
||||
|
||||
func TestHandleChannelFocus_InvalidChannelID(t *testing.T) {
|
||||
@@ -1913,10 +1552,6 @@ func TestHandleVoiceLeave_ExplicitLeave(t *testing.T) {
|
||||
if !foundLeave {
|
||||
t.Error("expected voice_leave broadcast after explicit leave")
|
||||
}
|
||||
|
||||
if hub.GetVoiceRoom(vcID) != nil {
|
||||
t.Error("expected voice room to be removed after last participant leaves")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleVoiceLeave_NotInVoice(t *testing.T) {
|
||||
@@ -2079,47 +1714,6 @@ func TestHandleVoiceJoin_WithQualityOverride(t *testing.T) {
|
||||
t.Error("expected voice_config with quality override")
|
||||
}
|
||||
|
||||
func TestHandleVoiceJoin_WithMixingThresholdOverride(t *testing.T) {
|
||||
hub, database := newCoverageHub(t)
|
||||
user := seedCoverageOwner(t, database, "vj-thresh-user")
|
||||
|
||||
vcID, err := database.CreateChannel("thresh-vc", "voice", "", "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel: %v", err)
|
||||
}
|
||||
_, err = database.Exec("UPDATE channels SET mixing_threshold = 5 WHERE id = ?", vcID)
|
||||
if err != nil {
|
||||
t.Fatalf("UPDATE: %v", err)
|
||||
}
|
||||
|
||||
send := make(chan []byte, 64)
|
||||
c := ws.NewTestClientWithUser(hub, user, 0, send)
|
||||
hub.Register(c)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"type": "voice_join",
|
||||
"payload": map[string]any{
|
||||
"channel_id": vcID,
|
||||
},
|
||||
})
|
||||
hub.HandleMessageForTest(c, raw)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
msgs := drainChanTimeout(send, 300*time.Millisecond)
|
||||
for _, msg := range msgs {
|
||||
var env map[string]any
|
||||
if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_config" {
|
||||
p := env["payload"].(map[string]any)
|
||||
if p["mixing_threshold"] != float64(5) {
|
||||
t.Errorf("voice_config mixing_threshold = %v, want 5", p["mixing_threshold"])
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Error("expected voice_config with mixing_threshold override")
|
||||
}
|
||||
|
||||
func TestHandleVoiceJoin_MultipleParticipants(t *testing.T) {
|
||||
hub, database := newCoverageHub(t)
|
||||
vcID := seedVoiceChannel(t, database, "vj-multi-vc")
|
||||
|
||||
@@ -41,23 +41,3 @@ func ParseChannelIDForTest(payload json.RawMessage) (int64, error) {
|
||||
func BuildJSONForTest(v any) []byte {
|
||||
return buildJSON(v)
|
||||
}
|
||||
|
||||
// BuildVoiceOfferForTest exposes buildVoiceOffer for external tests.
|
||||
func BuildVoiceOfferForTest(channelID int64, sdp string) []byte {
|
||||
return buildVoiceOffer(channelID, sdp)
|
||||
}
|
||||
|
||||
// BuildVoiceICEForTest exposes buildVoiceICE for external tests.
|
||||
func BuildVoiceICEForTest(channelID int64, candidate any) []byte {
|
||||
return buildVoiceICE(channelID, candidate)
|
||||
}
|
||||
|
||||
// SetupICECallbackForTest exposes setupICECallback for external tests.
|
||||
func (h *Hub) SetupICECallbackForTest(c *Client, channelID int64) {
|
||||
h.setupICECallback(c, channelID)
|
||||
}
|
||||
|
||||
// RenegotiateParticipantForTest exposes renegotiateParticipant for external tests.
|
||||
func (h *Hub) RenegotiateParticipantForTest(c *Client) {
|
||||
h.renegotiateParticipant(c)
|
||||
}
|
||||
|
||||
@@ -107,14 +107,6 @@ func (h *Hub) handleMessage(c *Client, raw []byte) {
|
||||
h.handleVoiceCamera(c, env.Payload)
|
||||
case "voice_screenshare":
|
||||
h.handleVoiceScreenshare(c, env.Payload)
|
||||
case "voice_offer":
|
||||
h.handleVoiceOffer(c, env.Payload)
|
||||
case "voice_answer":
|
||||
h.handleVoiceAnswer(c, env.Payload)
|
||||
case "voice_ice":
|
||||
h.handleVoiceICE(c, env.Payload)
|
||||
case "soundboard_play":
|
||||
h.handleSoundboard(c, env.Payload)
|
||||
case "ping":
|
||||
c.sendMsg(buildJSON(map[string]any{"type": "pong"}))
|
||||
default:
|
||||
|
||||
+34
-89
@@ -28,9 +28,8 @@ type Hub struct {
|
||||
unregister chan *Client
|
||||
stop chan struct{}
|
||||
stopOnce sync.Once
|
||||
sfu *SFU
|
||||
voiceRooms map[int64]*VoiceRoom
|
||||
voiceRoomsMu sync.RWMutex
|
||||
livekit *LiveKitClient
|
||||
lkProcess *LiveKitProcess
|
||||
|
||||
// Settings cache — avoids per-connection DB queries for server_name/motd.
|
||||
settingsMu sync.RWMutex
|
||||
@@ -50,7 +49,6 @@ func NewHub(database *db.DB, limiter *auth.RateLimiter) *Hub {
|
||||
register: make(chan *Client, 32),
|
||||
unregister: make(chan *Client, 32),
|
||||
stop: make(chan struct{}),
|
||||
voiceRooms: make(map[int64]*VoiceRoom),
|
||||
settingsName: "OwnCord Server",
|
||||
settingsMotd: "Welcome!",
|
||||
}
|
||||
@@ -94,67 +92,19 @@ func (h *Hub) refreshSettingsLocked() {
|
||||
h.settingsLastUpdate = time.Now()
|
||||
}
|
||||
|
||||
// SetSFU sets the SFU engine on the hub. Must be called before Run.
|
||||
func (h *Hub) SetSFU(sfu *SFU) {
|
||||
h.sfu = sfu
|
||||
// SetLiveKit sets the LiveKit client on the hub. Must be called before Run.
|
||||
func (h *Hub) SetLiveKit(lk *LiveKitClient) {
|
||||
h.livekit = lk
|
||||
}
|
||||
|
||||
// GetOrCreateVoiceRoom returns the existing room for channelID or creates one.
|
||||
// cfg provides the room config (from channel settings and server defaults).
|
||||
func (h *Hub) GetOrCreateVoiceRoom(channelID int64, cfg VoiceRoomConfig) *VoiceRoom {
|
||||
h.voiceRoomsMu.Lock()
|
||||
defer h.voiceRoomsMu.Unlock()
|
||||
|
||||
if room, ok := h.voiceRooms[channelID]; ok {
|
||||
return room
|
||||
}
|
||||
room := NewVoiceRoom(cfg)
|
||||
h.voiceRooms[channelID] = room
|
||||
return room
|
||||
}
|
||||
|
||||
// GetVoiceRoom returns the room for channelID, or nil if none exists.
|
||||
func (h *Hub) GetVoiceRoom(channelID int64) *VoiceRoom {
|
||||
h.voiceRoomsMu.RLock()
|
||||
defer h.voiceRoomsMu.RUnlock()
|
||||
return h.voiceRooms[channelID]
|
||||
}
|
||||
|
||||
// RemoveVoiceRoom removes and closes the room for channelID. No-op if absent.
|
||||
func (h *Hub) RemoveVoiceRoom(channelID int64) {
|
||||
h.voiceRoomsMu.Lock()
|
||||
room, ok := h.voiceRooms[channelID]
|
||||
if ok {
|
||||
delete(h.voiceRooms, channelID)
|
||||
}
|
||||
h.voiceRoomsMu.Unlock()
|
||||
|
||||
if ok {
|
||||
slog.Info("voice room destroyed", "channel_id", channelID)
|
||||
room.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// CloseAllVoiceRooms closes all voice rooms. Called during shutdown.
|
||||
func (h *Hub) CloseAllVoiceRooms() {
|
||||
h.voiceRoomsMu.Lock()
|
||||
rooms := make([]*VoiceRoom, 0, len(h.voiceRooms))
|
||||
for _, room := range h.voiceRooms {
|
||||
rooms = append(rooms, room)
|
||||
}
|
||||
h.voiceRooms = make(map[int64]*VoiceRoom)
|
||||
h.voiceRoomsMu.Unlock()
|
||||
|
||||
for _, room := range rooms {
|
||||
room.Close()
|
||||
}
|
||||
// SetLiveKitProcess sets the LiveKit process manager on the hub.
|
||||
func (h *Hub) SetLiveKitProcess(p *LiveKitProcess) {
|
||||
h.lkProcess = p
|
||||
}
|
||||
|
||||
// Run starts the hub's dispatch loop. It blocks until Stop is called.
|
||||
// Must be called in its own goroutine.
|
||||
func (h *Hub) Run() {
|
||||
go h.runSpeakerBroadcast(h.stop)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-h.stop:
|
||||
@@ -185,52 +135,47 @@ func (h *Hub) Stop() {
|
||||
h.stopOnce.Do(func() { close(h.stop) })
|
||||
}
|
||||
|
||||
// GracefulStop closes all PeerConnections, voice rooms, and then stops the hub.
|
||||
// GracefulStop stops the LiveKit process (if managed) and then stops the hub.
|
||||
func (h *Hub) GracefulStop() {
|
||||
// Close all client PeerConnections first (CRIT-2 fix).
|
||||
h.mu.RLock()
|
||||
for _, c := range h.clients {
|
||||
if _, oldPC := c.clearVoice(); oldPC != nil {
|
||||
_ = oldPC.Close()
|
||||
}
|
||||
if h.lkProcess != nil {
|
||||
h.lkProcess.Stop()
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
|
||||
h.CloseAllVoiceRooms()
|
||||
h.stopOnce.Do(func() { close(h.stop) })
|
||||
}
|
||||
|
||||
// CleanupVoiceForChannel removes the voice room for the given channel and
|
||||
// closes PeerConnections for all participants. Called when a channel is deleted.
|
||||
// CleanupVoiceForChannel removes all voice participants from the given channel.
|
||||
// Called when a channel is deleted.
|
||||
func (h *Hub) CleanupVoiceForChannel(channelID int64) {
|
||||
room := h.GetVoiceRoom(channelID)
|
||||
if room == nil {
|
||||
// Get all users in the channel's voice state from DB.
|
||||
states, err := h.db.GetChannelVoiceStates(channelID)
|
||||
if err != nil {
|
||||
slog.Error("CleanupVoiceForChannel GetChannelVoiceStates", "err", err, "channel_id", channelID)
|
||||
return
|
||||
}
|
||||
if len(states) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Get participant IDs before removing the room.
|
||||
participantIDs := room.ParticipantIDs()
|
||||
// Clean up DB state and LiveKit for each participant.
|
||||
for _, vs := range states {
|
||||
_ = h.db.LeaveVoiceChannel(vs.UserID)
|
||||
|
||||
// Remove the room (this also calls room.Close() which clears participants).
|
||||
h.RemoveVoiceRoom(channelID)
|
||||
// Clear client voice state.
|
||||
h.mu.RLock()
|
||||
if client, ok := h.clients[vs.UserID]; ok {
|
||||
client.clearVoiceChID()
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
|
||||
// Close PeerConnections and clean up DB state for all participants.
|
||||
// Use RLock for client map read; voice fields are guarded by voiceMu (HIGH-3 fix).
|
||||
h.mu.RLock()
|
||||
for _, userID := range participantIDs {
|
||||
if client, ok := h.clients[userID]; ok {
|
||||
if _, oldPC := client.clearVoice(); oldPC != nil {
|
||||
_ = oldPC.Close()
|
||||
}
|
||||
// Remove from LiveKit (best-effort).
|
||||
if h.livekit != nil {
|
||||
_ = h.livekit.RemoveParticipant(channelID, vs.UserID)
|
||||
}
|
||||
// Clean up DB voice state (best-effort; ignore error).
|
||||
_ = h.db.LeaveVoiceChannel(userID)
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
|
||||
// Broadcast voice_leave for each participant.
|
||||
for _, userID := range participantIDs {
|
||||
h.BroadcastToAll(buildVoiceLeave(channelID, userID))
|
||||
for _, vs := range states {
|
||||
h.BroadcastToAll(buildVoiceLeave(channelID, vs.UserID))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-190
@@ -447,120 +447,12 @@ func assertNotReceived(t *testing.T, ch <-chan []byte, label string) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Voice room lifecycle ─────────────────────────────────────────────────────
|
||||
// ─── LiveKit lifecycle ────────────────────────────────────────────────────────
|
||||
|
||||
func TestHub_SetSFU_NilSafe(t *testing.T) {
|
||||
func TestHub_SetLiveKit_NilSafe(t *testing.T) {
|
||||
hub, _ := newTestHub(t)
|
||||
// Setting a nil SFU must not panic.
|
||||
hub.SetSFU(nil)
|
||||
}
|
||||
|
||||
func TestHub_GetOrCreateVoiceRoom_CreatesNew(t *testing.T) {
|
||||
hub, _ := newTestHub(t)
|
||||
cfg := ws.VoiceRoomConfig{ChannelID: 42, MaxUsers: 10, Quality: "medium"}
|
||||
|
||||
room := hub.GetOrCreateVoiceRoom(42, cfg)
|
||||
if room == nil {
|
||||
t.Fatal("GetOrCreateVoiceRoom returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_GetOrCreateVoiceRoom_ReturnsSameRoom(t *testing.T) {
|
||||
hub, _ := newTestHub(t)
|
||||
cfg := ws.VoiceRoomConfig{ChannelID: 99, MaxUsers: 5, Quality: "low"}
|
||||
|
||||
r1 := hub.GetOrCreateVoiceRoom(99, cfg)
|
||||
r2 := hub.GetOrCreateVoiceRoom(99, cfg)
|
||||
if r1 != r2 {
|
||||
t.Error("GetOrCreateVoiceRoom should return the same room on subsequent calls")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_GetOrCreateVoiceRoom_DifferentChannels(t *testing.T) {
|
||||
hub, _ := newTestHub(t)
|
||||
cfg1 := ws.VoiceRoomConfig{ChannelID: 1, Quality: "low"}
|
||||
cfg2 := ws.VoiceRoomConfig{ChannelID: 2, Quality: "high"}
|
||||
|
||||
r1 := hub.GetOrCreateVoiceRoom(1, cfg1)
|
||||
r2 := hub.GetOrCreateVoiceRoom(2, cfg2)
|
||||
if r1 == r2 {
|
||||
t.Error("different channel IDs must produce distinct rooms")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_GetVoiceRoom_ReturnsNilWhenAbsent(t *testing.T) {
|
||||
hub, _ := newTestHub(t)
|
||||
room := hub.GetVoiceRoom(404)
|
||||
if room != nil {
|
||||
t.Errorf("GetVoiceRoom: want nil for absent channel, got %v", room)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_GetVoiceRoom_ReturnsRoomAfterCreate(t *testing.T) {
|
||||
hub, _ := newTestHub(t)
|
||||
cfg := ws.VoiceRoomConfig{ChannelID: 7, Quality: "medium"}
|
||||
hub.GetOrCreateVoiceRoom(7, cfg)
|
||||
|
||||
room := hub.GetVoiceRoom(7)
|
||||
if room == nil {
|
||||
t.Fatal("GetVoiceRoom: want non-nil after GetOrCreateVoiceRoom, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_RemoveVoiceRoom_NoopWhenAbsent(t *testing.T) {
|
||||
hub, _ := newTestHub(t)
|
||||
// Must not panic on removal of non-existent room.
|
||||
hub.RemoveVoiceRoom(999)
|
||||
}
|
||||
|
||||
func TestHub_RemoveVoiceRoom_RemovesRoom(t *testing.T) {
|
||||
hub, _ := newTestHub(t)
|
||||
cfg := ws.VoiceRoomConfig{ChannelID: 55, Quality: "low"}
|
||||
hub.GetOrCreateVoiceRoom(55, cfg)
|
||||
|
||||
hub.RemoveVoiceRoom(55)
|
||||
if hub.GetVoiceRoom(55) != nil {
|
||||
t.Error("GetVoiceRoom: want nil after RemoveVoiceRoom")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_CloseAllVoiceRooms_ClearsAll(t *testing.T) {
|
||||
hub, _ := newTestHub(t)
|
||||
for _, id := range []int64{10, 20, 30} {
|
||||
hub.GetOrCreateVoiceRoom(id, ws.VoiceRoomConfig{ChannelID: id, Quality: "medium"})
|
||||
}
|
||||
|
||||
hub.CloseAllVoiceRooms()
|
||||
|
||||
for _, id := range []int64{10, 20, 30} {
|
||||
if hub.GetVoiceRoom(id) != nil {
|
||||
t.Errorf("GetVoiceRoom(%d): want nil after CloseAllVoiceRooms", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_CloseAllVoiceRooms_EmptyIsNoop(t *testing.T) {
|
||||
hub, _ := newTestHub(t)
|
||||
// Must not panic when no rooms exist.
|
||||
hub.CloseAllVoiceRooms()
|
||||
}
|
||||
|
||||
func TestHub_VoiceRooms_ConcurrentAccess(t *testing.T) {
|
||||
hub, _ := newTestHub(t)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Concurrent creates and reads must not race.
|
||||
for i := range int64(20) {
|
||||
wg.Add(1)
|
||||
go func(id int64) {
|
||||
defer wg.Done()
|
||||
cfg := ws.VoiceRoomConfig{ChannelID: id, Quality: "medium"}
|
||||
hub.GetOrCreateVoiceRoom(id, cfg)
|
||||
hub.GetVoiceRoom(id)
|
||||
hub.RemoveVoiceRoom(id)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
// Setting a nil LiveKit client must not panic.
|
||||
hub.SetLiveKit(nil)
|
||||
}
|
||||
|
||||
// ─── GracefulStop ─────────────────────────────────────────────────────────────
|
||||
@@ -584,95 +476,21 @@ func TestHub_GracefulStop_StopsHub(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_GracefulStop_ClosesAllVoiceRooms(t *testing.T) {
|
||||
hub, _ := newTestHub(t)
|
||||
for _, id := range []int64{100, 200, 300} {
|
||||
hub.GetOrCreateVoiceRoom(id, ws.VoiceRoomConfig{ChannelID: id, Quality: "low"})
|
||||
}
|
||||
go hub.Run()
|
||||
|
||||
hub.GracefulStop()
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
for _, id := range []int64{100, 200, 300} {
|
||||
if hub.GetVoiceRoom(id) != nil {
|
||||
t.Errorf("GetVoiceRoom(%d): expected nil after GracefulStop", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_GracefulStop_NoRooms_NoPanic(t *testing.T) {
|
||||
func TestHub_GracefulStop_NoPanic(t *testing.T) {
|
||||
hub, _ := newTestHub(t)
|
||||
go hub.Run()
|
||||
// Must not panic with zero voice rooms.
|
||||
// Must not panic with no LiveKit process.
|
||||
hub.GracefulStop()
|
||||
}
|
||||
|
||||
// ─── CleanupVoiceForChannel ───────────────────────────────────────────────────
|
||||
|
||||
func TestHub_CleanupVoiceForChannel_RemovesRoom(t *testing.T) {
|
||||
func TestHub_CleanupVoiceForChannel_NoVoiceState_NoPanic(t *testing.T) {
|
||||
hub, _ := newTestHub(t)
|
||||
chID := int64(55)
|
||||
hub.GetOrCreateVoiceRoom(chID, ws.VoiceRoomConfig{ChannelID: chID, Quality: "medium"})
|
||||
|
||||
hub.CleanupVoiceForChannel(chID)
|
||||
|
||||
if hub.GetVoiceRoom(chID) != nil {
|
||||
t.Error("expected room to be nil after CleanupVoiceForChannel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_CleanupVoiceForChannel_NoRoom_NoPanic(t *testing.T) {
|
||||
hub, _ := newTestHub(t)
|
||||
// Must not panic when channel has no voice room.
|
||||
// Must not panic when channel has no voice state in DB.
|
||||
hub.CleanupVoiceForChannel(9999)
|
||||
}
|
||||
|
||||
func TestHub_CleanupVoiceForChannel_BroadcastsVoiceLeave(t *testing.T) {
|
||||
hub, database := newTestHub(t)
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
|
||||
chID := seedTestChannel(t, database, "cleanup-vc")
|
||||
u1 := seedTestUser(t, database, "cleanup-user1")
|
||||
u2 := seedTestUser(t, database, "cleanup-user2")
|
||||
|
||||
send1 := make(chan []byte, 16)
|
||||
send2 := make(chan []byte, 16)
|
||||
c1 := ws.NewTestClientWithChannel(hub, u1, chID, send1)
|
||||
c2 := ws.NewTestClientWithChannel(hub, u2, chID, send2)
|
||||
hub.Register(c1)
|
||||
hub.Register(c2)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
room := hub.GetOrCreateVoiceRoom(chID, ws.VoiceRoomConfig{ChannelID: chID, Quality: "medium"})
|
||||
if err := room.AddParticipant(u1); err != nil {
|
||||
t.Fatalf("AddParticipant u1: %v", err)
|
||||
}
|
||||
if err := room.AddParticipant(u2); err != nil {
|
||||
t.Fatalf("AddParticipant u2: %v", err)
|
||||
}
|
||||
|
||||
hub.CleanupVoiceForChannel(chID)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// At least one of the clients must receive a voice_leave.
|
||||
allMsgs := append(drainChan(send1), drainChan(send2)...)
|
||||
found := false
|
||||
for _, msg := range allMsgs {
|
||||
var env map[string]any
|
||||
if err := json.Unmarshal(msg, &env); err == nil {
|
||||
if env["type"] == "voice_leave" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected voice_leave broadcast after CleanupVoiceForChannel")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHub_Register_CleansUpOldVoiceState was removed because duplicate
|
||||
// logins are now rejected at the WebSocket handshake level (commit 00bbb46)
|
||||
// before hub.Register is called. The hub's register case simply overwrites
|
||||
|
||||
+17
-51
@@ -219,17 +219,26 @@ func buildVoiceState(state db.VoiceState) []byte {
|
||||
}
|
||||
|
||||
// buildVoiceConfig constructs a voice_config message sent after voice_join acceptance.
|
||||
func buildVoiceConfig(channelID int64, quality string, bitrate int, mode string, threshold, topSpeakers, maxUsers int) []byte {
|
||||
func buildVoiceConfig(channelID int64, quality string, bitrate int, maxUsers int) []byte {
|
||||
return buildJSON(map[string]any{
|
||||
"type": "voice_config",
|
||||
"payload": map[string]any{
|
||||
"channel_id": channelID,
|
||||
"quality": quality,
|
||||
"bitrate": bitrate,
|
||||
"threshold_mode": mode,
|
||||
"mixing_threshold": threshold,
|
||||
"top_speakers": topSpeakers,
|
||||
"max_users": maxUsers,
|
||||
"channel_id": channelID,
|
||||
"quality": quality,
|
||||
"bitrate": bitrate,
|
||||
"max_users": maxUsers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// buildVoiceToken constructs a voice_token message with a LiveKit token and URL.
|
||||
func buildVoiceToken(channelID int64, token string, livekitURL string) []byte {
|
||||
return buildJSON(map[string]any{
|
||||
"type": "voice_token",
|
||||
"payload": map[string]any{
|
||||
"channel_id": channelID,
|
||||
"token": token,
|
||||
"url": livekitURL,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -257,49 +266,6 @@ func buildVoiceLeave(channelID, userID int64) []byte {
|
||||
})
|
||||
}
|
||||
|
||||
// buildVoiceAnswer constructs a voice_answer message sent from server to client.
|
||||
func buildVoiceAnswer(channelID int64, sdp string) []byte {
|
||||
return buildJSON(map[string]any{
|
||||
"type": "voice_answer",
|
||||
"payload": map[string]any{
|
||||
"channel_id": channelID,
|
||||
"sdp": sdp,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// buildVoiceOffer constructs a voice_offer message sent from server to client.
|
||||
func buildVoiceOffer(channelID int64, sdp string) []byte {
|
||||
return buildJSON(map[string]any{
|
||||
"type": "voice_offer",
|
||||
"payload": map[string]any{
|
||||
"channel_id": channelID,
|
||||
"sdp": sdp,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// buildVoiceICE constructs a voice_ice message sent from server to client.
|
||||
func buildVoiceICE(channelID int64, candidate any) []byte {
|
||||
return buildJSON(map[string]any{
|
||||
"type": "voice_ice",
|
||||
"payload": map[string]any{
|
||||
"channel_id": channelID,
|
||||
"candidate": candidate,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// buildSoundboardPlay constructs a soundboard_play broadcast.
|
||||
func buildSoundboardPlay(soundID string, userID int64) []byte {
|
||||
return buildJSON(map[string]any{
|
||||
"type": "soundboard_play",
|
||||
"payload": map[string]any{
|
||||
"sound_id": soundID,
|
||||
"user_id": userID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// buildChannelCreate constructs a channel_create broadcast.
|
||||
func buildChannelCreate(ch *db.Channel) []byte {
|
||||
|
||||
+17
-14
@@ -548,28 +548,28 @@ func TestBuildTypingMsg_ValidJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── buildVoiceAnswer ─────────────────────────────────────────────────────────
|
||||
// ─── buildVoiceToken ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestBuildVoiceAnswer_Type(t *testing.T) {
|
||||
msg := buildVoiceAnswer(99, "v=0\r\n")
|
||||
func TestBuildVoiceToken_Type(t *testing.T) {
|
||||
msg := buildVoiceToken(99, "jwt-token", "ws://localhost:7880")
|
||||
var env struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if err := json.Unmarshal(msg, &env); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if env.Type != "voice_answer" {
|
||||
t.Errorf("type = %q, want voice_answer", env.Type)
|
||||
if env.Type != "voice_token" {
|
||||
t.Errorf("type = %q, want voice_token", env.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVoiceAnswer_Payload(t *testing.T) {
|
||||
sdp := "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\n"
|
||||
msg := buildVoiceAnswer(99, sdp)
|
||||
func TestBuildVoiceToken_Payload(t *testing.T) {
|
||||
msg := buildVoiceToken(99, "jwt-token", "ws://localhost:7880")
|
||||
var env struct {
|
||||
Payload struct {
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
SDP string `json:"sdp"`
|
||||
Token string `json:"token"`
|
||||
URL string `json:"url"`
|
||||
} `json:"payload"`
|
||||
}
|
||||
if err := json.Unmarshal(msg, &env); err != nil {
|
||||
@@ -578,13 +578,16 @@ func TestBuildVoiceAnswer_Payload(t *testing.T) {
|
||||
if env.Payload.ChannelID != 99 {
|
||||
t.Errorf("payload.channel_id = %d, want 99", env.Payload.ChannelID)
|
||||
}
|
||||
if env.Payload.SDP != sdp {
|
||||
t.Errorf("payload.sdp = %q, want %q", env.Payload.SDP, sdp)
|
||||
if env.Payload.Token != "jwt-token" {
|
||||
t.Errorf("payload.token = %q, want jwt-token", env.Payload.Token)
|
||||
}
|
||||
if env.Payload.URL != "ws://localhost:7880" {
|
||||
t.Errorf("payload.url = %q, want ws://localhost:7880", env.Payload.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVoiceAnswer_ValidJSON(t *testing.T) {
|
||||
if !json.Valid(buildVoiceAnswer(1, "sdp-data")) {
|
||||
t.Error("buildVoiceAnswer output is not valid JSON")
|
||||
func TestBuildVoiceToken_ValidJSON(t *testing.T) {
|
||||
if !json.Valid(buildVoiceToken(1, "t", "ws://a")) {
|
||||
t.Error("buildVoiceToken output is not valid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
package ws_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
ws "github.com/owncord/server/ws"
|
||||
)
|
||||
|
||||
func TestBuildVoiceOffer(t *testing.T) {
|
||||
msg := ws.BuildVoiceOfferForTest(10, "v=0\r\noffer-sdp")
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(msg, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m["type"] != "voice_offer" {
|
||||
t.Errorf("type = %v, want voice_offer", m["type"])
|
||||
}
|
||||
p := m["payload"].(map[string]any)
|
||||
if p["channel_id"] != float64(10) {
|
||||
t.Errorf("channel_id = %v, want 10", p["channel_id"])
|
||||
}
|
||||
if p["sdp"] != "v=0\r\noffer-sdp" {
|
||||
t.Errorf("sdp = %v", p["sdp"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVoiceICE(t *testing.T) {
|
||||
candidate := map[string]any{
|
||||
"candidate": "candidate:1 1 UDP 2130706431 ...",
|
||||
"sdpMid": "0",
|
||||
"sdpMLineIndex": float64(0),
|
||||
}
|
||||
msg := ws.BuildVoiceICEForTest(10, candidate)
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(msg, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m["type"] != "voice_ice" {
|
||||
t.Errorf("type = %v, want voice_ice", m["type"])
|
||||
}
|
||||
p := m["payload"].(map[string]any)
|
||||
if p["channel_id"] != float64(10) {
|
||||
t.Errorf("channel_id = %v, want 10", p["channel_id"])
|
||||
}
|
||||
c := p["candidate"].(map[string]any)
|
||||
if c["sdpMid"] != "0" {
|
||||
t.Errorf("candidate.sdpMid = %v, want 0", c["sdpMid"])
|
||||
}
|
||||
}
|
||||
@@ -1,455 +0,0 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
|
||||
"github.com/owncord/server/auth"
|
||||
"github.com/owncord/server/config"
|
||||
"github.com/owncord/server/db"
|
||||
)
|
||||
|
||||
// renegTestSchema is a minimal schema for renegotiation tests.
|
||||
var renegTestSchema = []byte(`
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
color TEXT,
|
||||
permissions INTEGER NOT NULL DEFAULT 0,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
is_default INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO roles (id, name, color, permissions, position, is_default) VALUES
|
||||
(1, 'Owner', '#E74C3C', 2147483647, 100, 0),
|
||||
(4, 'Member', NULL, 1635, 40, 1);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
password TEXT NOT NULL,
|
||||
avatar TEXT,
|
||||
role_id INTEGER NOT NULL DEFAULT 4 REFERENCES roles(id),
|
||||
totp_secret TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'offline',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_seen TEXT,
|
||||
banned INTEGER NOT NULL DEFAULT 0,
|
||||
ban_reason TEXT,
|
||||
ban_expires TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
device TEXT,
|
||||
ip_address TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_used TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'text',
|
||||
category TEXT,
|
||||
topic TEXT,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
slow_mode INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
voice_max_users INTEGER NOT NULL DEFAULT 0,
|
||||
voice_quality TEXT,
|
||||
mixing_threshold INTEGER,
|
||||
voice_max_video INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_overrides (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||
allow INTEGER NOT NULL DEFAULT 0,
|
||||
deny INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(channel_id, role_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
content TEXT NOT NULL,
|
||||
reply_to INTEGER REFERENCES messages(id) ON DELETE SET NULL,
|
||||
edited_at TEXT,
|
||||
deleted INTEGER NOT NULL DEFAULT 0,
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS voice_states (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
muted INTEGER NOT NULL DEFAULT 0,
|
||||
deafened INTEGER NOT NULL DEFAULT 0,
|
||||
speaking INTEGER NOT NULL DEFAULT 0,
|
||||
camera INTEGER NOT NULL DEFAULT 0,
|
||||
screenshare INTEGER NOT NULL DEFAULT 0,
|
||||
joined_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_voice_states_channel ON voice_states(channel_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
INSERT OR IGNORE INTO settings (key, value) VALUES ('server_name', 'Test Server');
|
||||
INSERT OR IGNORE INTO settings (key, value) VALUES ('motd', 'Welcome');
|
||||
`)
|
||||
|
||||
// newRenegTestDB opens an in-memory DB with the renegotiation test schema.
|
||||
func newRenegTestDB(t *testing.T) *db.DB {
|
||||
t.Helper()
|
||||
database, err := db.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("db.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
|
||||
migrFS := fstest.MapFS{
|
||||
"001_schema.sql": {Data: renegTestSchema},
|
||||
}
|
||||
if err := db.MigrateFS(database, migrFS); err != nil {
|
||||
t.Fatalf("MigrateFS: %v", err)
|
||||
}
|
||||
return database
|
||||
}
|
||||
|
||||
// newRenegHub creates a hub suitable for renegotiation tests.
|
||||
func newRenegHub(t *testing.T) (*Hub, *db.DB) {
|
||||
t.Helper()
|
||||
database := newRenegTestDB(t)
|
||||
limiter := auth.NewRateLimiter()
|
||||
hub := NewHub(database, limiter)
|
||||
go hub.Run()
|
||||
t.Cleanup(func() { hub.Stop() })
|
||||
return hub, database
|
||||
}
|
||||
|
||||
// newTestSFU creates an SFU for tests with a small port range.
|
||||
func newTestSFU(t *testing.T) *SFU {
|
||||
t.Helper()
|
||||
cfg := &config.VoiceConfig{
|
||||
Quality: "medium",
|
||||
MediaPortMin: 50000,
|
||||
MediaPortMax: 50100,
|
||||
}
|
||||
sfu, err := NewSFU(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSFU: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { sfu.Close() })
|
||||
return sfu
|
||||
}
|
||||
|
||||
// seedRenegUser inserts an Owner-role user for renegotiation tests.
|
||||
func seedRenegUser(t *testing.T, database *db.DB, username string) *db.User {
|
||||
t.Helper()
|
||||
_, err := database.CreateUser(username, "hash", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
user, err := database.GetUserByUsername(username)
|
||||
if err != nil || user == nil {
|
||||
t.Fatalf("GetUserByUsername: %v", err)
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
// TestRenegotiateParticipant_SkipsHaveRemoteOffer verifies that when the
|
||||
// PeerConnection is in have-remote-offer state, renegotiateParticipant
|
||||
// returns early without creating a new offer.
|
||||
func TestRenegotiateParticipant_SkipsHaveRemoteOffer(t *testing.T) {
|
||||
hub, database := newRenegHub(t)
|
||||
sfu := newTestSFU(t)
|
||||
user := seedRenegUser(t, database, "skip-remote-offer")
|
||||
|
||||
send := make(chan []byte, 32)
|
||||
c := NewTestClientWithUser(hub, user, 0, send)
|
||||
hub.Register(c)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
// Create a server-side PC via the SFU.
|
||||
serverPC, err := sfu.NewPeerConnection()
|
||||
if err != nil {
|
||||
t.Fatalf("NewPeerConnection: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = serverPC.Close() })
|
||||
|
||||
// Create a client-side PC to generate a valid offer.
|
||||
clientPC, err := webrtc.NewPeerConnection(webrtc.Configuration{})
|
||||
if err != nil {
|
||||
t.Fatalf("NewPeerConnection (client): %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = clientPC.Close() })
|
||||
|
||||
// Add a transceiver on the client so the offer has media.
|
||||
_, err = clientPC.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{
|
||||
Direction: webrtc.RTPTransceiverDirectionSendrecv,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddTransceiverFromKind: %v", err)
|
||||
}
|
||||
|
||||
clientOffer, err := clientPC.CreateOffer(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateOffer (client): %v", err)
|
||||
}
|
||||
if err := clientPC.SetLocalDescription(clientOffer); err != nil {
|
||||
t.Fatalf("SetLocalDescription (client): %v", err)
|
||||
}
|
||||
|
||||
// Set the client's offer as the server PC's remote description,
|
||||
// putting it into have-remote-offer state.
|
||||
if err := serverPC.SetRemoteDescription(clientOffer); err != nil {
|
||||
t.Fatalf("SetRemoteDescription (server): %v", err)
|
||||
}
|
||||
|
||||
if serverPC.SignalingState() != webrtc.SignalingStateHaveRemoteOffer {
|
||||
t.Fatalf("expected have-remote-offer, got %s", serverPC.SignalingState())
|
||||
}
|
||||
|
||||
// Attach the server PC to the client.
|
||||
c.setVoice(1, serverPC)
|
||||
|
||||
// Drain any messages that were sent during setup.
|
||||
drainSend(send)
|
||||
|
||||
// Call renegotiateParticipant — it should skip (no offer sent).
|
||||
hub.renegotiateParticipant(c)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Verify no voice_offer was sent.
|
||||
msgs := drainSend(send)
|
||||
for _, msg := range msgs {
|
||||
typ := extractMsgType(t, msg)
|
||||
if typ == "voice_offer" {
|
||||
t.Error("renegotiateParticipant should skip in have-remote-offer state, but sent a voice_offer")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenegotiateParticipant_RollsBackHaveLocalOffer verifies that when the
|
||||
// PeerConnection already has a pending local offer, renegotiateParticipant
|
||||
// attempts a rollback. Pion v4 does not support SDPTypeRollback, so the
|
||||
// rollback fails and the function returns early without sending a new offer.
|
||||
// This test documents the current behavior and ensures graceful handling.
|
||||
func TestRenegotiateParticipant_RollsBackHaveLocalOffer(t *testing.T) {
|
||||
hub, database := newRenegHub(t)
|
||||
sfu := newTestSFU(t)
|
||||
user := seedRenegUser(t, database, "rollback-local-offer")
|
||||
|
||||
send := make(chan []byte, 32)
|
||||
c := NewTestClientWithUser(hub, user, 0, send)
|
||||
hub.Register(c)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
// Create a server-side PC.
|
||||
serverPC, err := sfu.NewPeerConnection()
|
||||
if err != nil {
|
||||
t.Fatalf("NewPeerConnection: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = serverPC.Close() })
|
||||
|
||||
// Add a transceiver so offers contain media.
|
||||
_, err = serverPC.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{
|
||||
Direction: webrtc.RTPTransceiverDirectionSendrecv,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddTransceiverFromKind: %v", err)
|
||||
}
|
||||
|
||||
// Put the server PC into have-local-offer state by creating and setting
|
||||
// an offer manually.
|
||||
initialOffer, err := serverPC.CreateOffer(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateOffer: %v", err)
|
||||
}
|
||||
if err := serverPC.SetLocalDescription(initialOffer); err != nil {
|
||||
t.Fatalf("SetLocalDescription: %v", err)
|
||||
}
|
||||
if serverPC.SignalingState() != webrtc.SignalingStateHaveLocalOffer {
|
||||
t.Fatalf("expected have-local-offer, got %s", serverPC.SignalingState())
|
||||
}
|
||||
|
||||
// Attach the server PC to the client with a voice channel ID.
|
||||
c.setVoice(1, serverPC)
|
||||
|
||||
// Drain setup messages.
|
||||
drainSend(send)
|
||||
|
||||
// Call renegotiateParticipant — it attempts rollback which fails in Pion v4,
|
||||
// so it returns early without sending a new offer.
|
||||
hub.renegotiateParticipant(c)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Verify no voice_offer was sent (rollback failed, function returned early).
|
||||
msgs := drainSend(send)
|
||||
for _, msg := range msgs {
|
||||
typ := extractMsgType(t, msg)
|
||||
if typ == "voice_offer" {
|
||||
t.Error("renegotiateParticipant should return early when rollback fails, but sent a voice_offer")
|
||||
}
|
||||
}
|
||||
|
||||
// The PC remains in have-local-offer since rollback failed.
|
||||
if serverPC.SignalingState() != webrtc.SignalingStateHaveLocalOffer {
|
||||
t.Errorf("expected have-local-offer (unchanged after failed rollback), got %s", serverPC.SignalingState())
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleVoiceOffer_RollsBackOnGlare verifies glare condition handling:
|
||||
// when the server has a pending local offer and the client sends an offer
|
||||
// simultaneously. The code attempts to rollback the server's offer before
|
||||
// accepting the client's. Since Pion v4 does not support SDPTypeRollback,
|
||||
// the rollback fails and handleVoiceOffer sends a VOICE_ERROR to the client.
|
||||
// This test documents the current behavior and ensures graceful error handling.
|
||||
func TestHandleVoiceOffer_RollsBackOnGlare(t *testing.T) {
|
||||
hub, database := newRenegHub(t)
|
||||
sfu := newTestSFU(t)
|
||||
user := seedRenegUser(t, database, "glare-rollback")
|
||||
|
||||
send := make(chan []byte, 32)
|
||||
c := NewTestClientWithUser(hub, user, 0, send)
|
||||
hub.Register(c)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
// Create a server-side PC via the SFU.
|
||||
serverPC, err := sfu.NewPeerConnection()
|
||||
if err != nil {
|
||||
t.Fatalf("NewPeerConnection (server): %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = serverPC.Close() })
|
||||
|
||||
// Create a client-side PC to generate a valid offer.
|
||||
clientPC, err := webrtc.NewPeerConnection(webrtc.Configuration{})
|
||||
if err != nil {
|
||||
t.Fatalf("NewPeerConnection (client): %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = clientPC.Close() })
|
||||
|
||||
// Add audio transceivers on both sides.
|
||||
_, err = serverPC.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{
|
||||
Direction: webrtc.RTPTransceiverDirectionSendrecv,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddTransceiverFromKind (server): %v", err)
|
||||
}
|
||||
|
||||
_, err = clientPC.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{
|
||||
Direction: webrtc.RTPTransceiverDirectionSendrecv,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddTransceiverFromKind (client): %v", err)
|
||||
}
|
||||
|
||||
// Put the server PC into have-local-offer state (server sent an offer).
|
||||
serverOffer, err := serverPC.CreateOffer(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateOffer (server): %v", err)
|
||||
}
|
||||
if err := serverPC.SetLocalDescription(serverOffer); err != nil {
|
||||
t.Fatalf("SetLocalDescription (server): %v", err)
|
||||
}
|
||||
if serverPC.SignalingState() != webrtc.SignalingStateHaveLocalOffer {
|
||||
t.Fatalf("expected server in have-local-offer, got %s", serverPC.SignalingState())
|
||||
}
|
||||
|
||||
// Attach the server PC to the client.
|
||||
chanID := int64(42)
|
||||
c.setVoice(chanID, serverPC)
|
||||
|
||||
// Generate a client offer (simulating the client also sending an offer).
|
||||
clientOffer, err := clientPC.CreateOffer(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateOffer (client): %v", err)
|
||||
}
|
||||
if err := clientPC.SetLocalDescription(clientOffer); err != nil {
|
||||
t.Fatalf("SetLocalDescription (client): %v", err)
|
||||
}
|
||||
|
||||
// Drain any messages from setup.
|
||||
drainSend(send)
|
||||
|
||||
// Build and dispatch the voice_offer payload as handleVoiceOffer expects.
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"channel_id": chanID,
|
||||
"sdp": clientOffer.SDP,
|
||||
})
|
||||
|
||||
hub.handleVoiceOffer(c, payload)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Since Pion v4 does not support rollback, the glare path sends a
|
||||
// VOICE_ERROR back to the client indicating the conflict could not
|
||||
// be resolved.
|
||||
msgs := drainSend(send)
|
||||
foundError := false
|
||||
for _, msg := range msgs {
|
||||
typ := extractMsgType(t, msg)
|
||||
if typ == "error" {
|
||||
foundError = true
|
||||
// Verify the error code is VOICE_ERROR (signaling conflict).
|
||||
var env struct {
|
||||
Payload struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"payload"`
|
||||
}
|
||||
if err := json.Unmarshal(msg, &env); err != nil {
|
||||
t.Fatalf("failed to parse error message: %v", err)
|
||||
}
|
||||
if env.Payload.Code != "VOICE_ERROR" {
|
||||
t.Errorf("expected error code VOICE_ERROR, got %q", env.Payload.Code)
|
||||
}
|
||||
}
|
||||
if typ == "voice_answer" {
|
||||
t.Error("should not produce a voice_answer when rollback fails")
|
||||
}
|
||||
}
|
||||
if !foundError {
|
||||
t.Error("handleVoiceOffer should send a VOICE_ERROR when glare rollback fails, but no error was sent")
|
||||
}
|
||||
|
||||
// The server PC remains in have-local-offer since rollback failed.
|
||||
if serverPC.SignalingState() != webrtc.SignalingStateHaveLocalOffer {
|
||||
t.Errorf("expected have-local-offer (unchanged after failed rollback), got %s", serverPC.SignalingState())
|
||||
}
|
||||
}
|
||||
|
||||
// drainSend reads all pending messages from a channel.
|
||||
func drainSend(ch chan []byte) [][]byte {
|
||||
var msgs [][]byte
|
||||
for {
|
||||
select {
|
||||
case m := <-ch:
|
||||
msgs = append(msgs, m)
|
||||
default:
|
||||
return msgs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extractMsgType parses a JSON message and returns the "type" field.
|
||||
func extractMsgType(t *testing.T, msg []byte) string {
|
||||
t.Helper()
|
||||
var env map[string]any
|
||||
if err := json.Unmarshal(msg, &env); err != nil {
|
||||
t.Fatalf("extractMsgType unmarshal: %v", err)
|
||||
}
|
||||
typ, _ := env["type"].(string)
|
||||
return typ
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
package ws
|
||||
|
||||
import "encoding/binary"
|
||||
|
||||
// audioLevelExtID is the RTP header extension ID for RFC 6464 audio level.
|
||||
const audioLevelExtID = 1
|
||||
|
||||
// extractAudioLevel parses raw RTP bytes to extract the audio level from
|
||||
// a one-byte header extension (profile 0xBEDE) with ID == audioLevelExtID.
|
||||
// Returns the 7-bit level (0=loudest, 127=silence) and true if found.
|
||||
//
|
||||
// This avoids a full rtp.Packet.Unmarshal on every packet (~50 pps/user).
|
||||
func extractAudioLevel(buf []byte, n int) (level byte, ok bool) {
|
||||
if n < 12 {
|
||||
return 0, false // too short for RTP fixed header
|
||||
}
|
||||
|
||||
// Check X bit (extension present) at byte 0, bit 4.
|
||||
if buf[0]&0x10 == 0 {
|
||||
return 0, false // no header extension
|
||||
}
|
||||
|
||||
// CC = CSRC count (lower 4 bits of byte 0).
|
||||
cc := int(buf[0] & 0x0F)
|
||||
extOffset := 12 + 4*cc // skip fixed header + CSRCs
|
||||
|
||||
// Need at least 4 bytes for extension header (profile + length).
|
||||
if n < extOffset+4 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// Extension profile must be 0xBEDE (one-byte header format).
|
||||
profile := binary.BigEndian.Uint16(buf[extOffset:])
|
||||
if profile != 0xBEDE {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// Extension length in 32-bit words.
|
||||
extWords := int(binary.BigEndian.Uint16(buf[extOffset+2:]))
|
||||
extDataStart := extOffset + 4
|
||||
extDataEnd := extDataStart + extWords*4
|
||||
|
||||
if n < extDataEnd {
|
||||
return 0, false // extension data extends past packet
|
||||
}
|
||||
|
||||
// Walk one-byte header extension elements.
|
||||
// Format: ID (4 bits) | L (4 bits) | data[L+1 bytes]
|
||||
// ID=0 is padding, ID=15 terminates.
|
||||
pos := extDataStart
|
||||
for pos < extDataEnd {
|
||||
b := buf[pos]
|
||||
|
||||
// Padding byte.
|
||||
if b == 0 {
|
||||
pos++
|
||||
continue
|
||||
}
|
||||
|
||||
id := b >> 4
|
||||
dataLen := int(b&0x0F) + 1
|
||||
|
||||
// ID=15 means end of extensions.
|
||||
if id == 15 {
|
||||
break
|
||||
}
|
||||
|
||||
pos++ // advance past the ID|L byte
|
||||
|
||||
if pos+dataLen > extDataEnd {
|
||||
break // malformed: data extends past extension block
|
||||
}
|
||||
|
||||
if id == audioLevelExtID && dataLen >= 1 {
|
||||
// RFC 6464: V(1 bit) + level(7 bits)
|
||||
return buf[pos] & 0x7F, true
|
||||
}
|
||||
|
||||
pos += dataLen
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// buildRTPPacket constructs a minimal RTP packet with optional one-byte
|
||||
// header extensions. csrcCount specifies the number of dummy CSRCs.
|
||||
func buildRTPPacket(csrcCount int, extensions []struct{ id, value byte }) []byte {
|
||||
// Fixed header: V=2, P=0, X=(1 if extensions), CC=csrcCount
|
||||
header := make([]byte, 12+4*csrcCount)
|
||||
header[0] = 0x80 | byte(csrcCount) // V=2, CC
|
||||
header[1] = 111 // PT (opus)
|
||||
binary.BigEndian.PutUint16(header[2:], 1) // seq
|
||||
binary.BigEndian.PutUint32(header[4:], 1000) // timestamp
|
||||
binary.BigEndian.PutUint32(header[8:], 0xDEADBEEF) // SSRC
|
||||
|
||||
// Fill dummy CSRCs.
|
||||
for i := 0; i < csrcCount; i++ {
|
||||
binary.BigEndian.PutUint32(header[12+4*i:], uint32(i+1))
|
||||
}
|
||||
|
||||
if len(extensions) == 0 {
|
||||
return header
|
||||
}
|
||||
|
||||
// Set X bit.
|
||||
header[0] |= 0x10
|
||||
|
||||
// Build one-byte extension block.
|
||||
// Each element: 1 byte (ID<<4 | L) + (L+1) data bytes.
|
||||
// For simplicity each extension here has 1 byte of data (L=0).
|
||||
var extData []byte
|
||||
for _, ext := range extensions {
|
||||
extData = append(extData, ext.id<<4) // ID | L=0 (1 byte data)
|
||||
extData = append(extData, ext.value)
|
||||
}
|
||||
|
||||
// Pad to 32-bit boundary.
|
||||
for len(extData)%4 != 0 {
|
||||
extData = append(extData, 0x00)
|
||||
}
|
||||
|
||||
extWords := len(extData) / 4
|
||||
extHeader := make([]byte, 4)
|
||||
binary.BigEndian.PutUint16(extHeader[0:], 0xBEDE)
|
||||
binary.BigEndian.PutUint16(extHeader[2:], uint16(extWords))
|
||||
|
||||
pkt := make([]byte, 0, len(header)+len(extHeader)+len(extData))
|
||||
pkt = append(pkt, header...)
|
||||
pkt = append(pkt, extHeader...)
|
||||
pkt = append(pkt, extData...)
|
||||
return pkt
|
||||
}
|
||||
|
||||
func TestExtractAudioLevel_Valid(t *testing.T) {
|
||||
// Audio level 42 with V bit set (0x80 | 42 = 0xAA).
|
||||
pkt := buildRTPPacket(0, []struct{ id, value byte }{{1, 0x80 | 42}})
|
||||
level, ok := extractAudioLevel(pkt, len(pkt))
|
||||
if !ok {
|
||||
t.Fatal("expected ok=true for valid audio level extension")
|
||||
}
|
||||
if level != 42 {
|
||||
t.Fatalf("expected level=42, got %d", level)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractAudioLevel_NoExtension(t *testing.T) {
|
||||
// Packet without X bit (no extensions).
|
||||
pkt := buildRTPPacket(0, nil)
|
||||
_, ok := extractAudioLevel(pkt, len(pkt))
|
||||
if ok {
|
||||
t.Fatal("expected ok=false for packet without extension")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractAudioLevel_WrongExtensionID(t *testing.T) {
|
||||
// Extension with ID=5 instead of ID=1.
|
||||
pkt := buildRTPPacket(0, []struct{ id, value byte }{{5, 0x80 | 10}})
|
||||
_, ok := extractAudioLevel(pkt, len(pkt))
|
||||
if ok {
|
||||
t.Fatal("expected ok=false when extension ID does not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractAudioLevel_Truncated(t *testing.T) {
|
||||
// Too short to even contain the fixed header.
|
||||
_, ok := extractAudioLevel([]byte{0x90, 0x6F}, 2)
|
||||
if ok {
|
||||
t.Fatal("expected ok=false for truncated packet")
|
||||
}
|
||||
|
||||
// Has X bit but truncated before extension header.
|
||||
pkt := buildRTPPacket(0, []struct{ id, value byte }{{1, 50}})
|
||||
_, ok = extractAudioLevel(pkt, 14) // cut off inside extension header
|
||||
if ok {
|
||||
t.Fatal("expected ok=false for packet truncated in extension header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractAudioLevel_MultipleCSRCs(t *testing.T) {
|
||||
// 3 CSRCs, valid audio level extension.
|
||||
pkt := buildRTPPacket(3, []struct{ id, value byte }{{1, 0x80 | 99}})
|
||||
level, ok := extractAudioLevel(pkt, len(pkt))
|
||||
if !ok {
|
||||
t.Fatal("expected ok=true with CSRCs present")
|
||||
}
|
||||
if level != 99 {
|
||||
t.Fatalf("expected level=99, got %d", level)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractAudioLevel_MultipleExtensions(t *testing.T) {
|
||||
// Extension ID=3 first, then ID=1 (audio level).
|
||||
pkt := buildRTPPacket(0, []struct{ id, value byte }{
|
||||
{3, 0xFF},
|
||||
{1, 0x80 | 17},
|
||||
})
|
||||
level, ok := extractAudioLevel(pkt, len(pkt))
|
||||
if !ok {
|
||||
t.Fatal("expected ok=true when audio level is second extension")
|
||||
}
|
||||
if level != 17 {
|
||||
t.Fatalf("expected level=17, got %d", level)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractAudioLevel_VBitStripped(t *testing.T) {
|
||||
// Audio level 0 with V bit set — should return 0.
|
||||
pkt := buildRTPPacket(0, []struct{ id, value byte }{{1, 0x80}})
|
||||
level, ok := extractAudioLevel(pkt, len(pkt))
|
||||
if !ok {
|
||||
t.Fatal("expected ok=true")
|
||||
}
|
||||
if level != 0 {
|
||||
t.Fatalf("expected level=0, got %d", level)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractAudioLevel_NonBEDEProfile(t *testing.T) {
|
||||
// Manually construct a packet with X bit but non-0xBEDE profile.
|
||||
pkt := buildRTPPacket(0, []struct{ id, value byte }{{1, 50}})
|
||||
// Overwrite the profile field (bytes 12-13) with something else.
|
||||
binary.BigEndian.PutUint16(pkt[12:], 0x1000)
|
||||
_, ok := extractAudioLevel(pkt, len(pkt))
|
||||
if ok {
|
||||
t.Fatal("expected ok=false for non-BEDE profile")
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/pion/interceptor"
|
||||
"github.com/pion/webrtc/v4"
|
||||
|
||||
"github.com/owncord/server/config"
|
||||
)
|
||||
|
||||
// SFU wraps Pion's WebRTC API with pre-configured MediaEngine,
|
||||
// InterceptorRegistry, and SettingEngine.
|
||||
type SFU struct {
|
||||
api *webrtc.API
|
||||
config *config.VoiceConfig
|
||||
}
|
||||
|
||||
// NewSFU creates a new SFU with the given voice configuration. It sets up
|
||||
// the Pion MediaEngine with default codecs, registers the ssrc-audio-level
|
||||
// RTP header extension, configures interceptors, and applies NAT/port settings.
|
||||
func NewSFU(cfg *config.VoiceConfig) (*SFU, error) {
|
||||
var me webrtc.MediaEngine
|
||||
if err := me.RegisterDefaultCodecs(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Register ssrc-audio-level header extension for active speaker detection.
|
||||
const audioLevelURI = "urn:ietf:params:rtp-hdrext:ssrc-audio-level"
|
||||
for _, dir := range []webrtc.RTPTransceiverDirection{
|
||||
webrtc.RTPTransceiverDirectionSendonly,
|
||||
webrtc.RTPTransceiverDirectionRecvonly,
|
||||
} {
|
||||
if err := me.RegisterHeaderExtension(
|
||||
webrtc.RTPHeaderExtensionCapability{URI: audioLevelURI},
|
||||
webrtc.RTPCodecTypeAudio,
|
||||
dir,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var ir interceptor.Registry
|
||||
if err := webrtc.RegisterDefaultInterceptors(&me, &ir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var se webrtc.SettingEngine
|
||||
_ = se.SetEphemeralUDPPortRange(uint16(cfg.MediaPortMin), uint16(cfg.MediaPortMax))
|
||||
|
||||
if cfg.ExternalIP != "" {
|
||||
if err := se.SetICEAddressRewriteRules(webrtc.ICEAddressRewriteRule{
|
||||
External: []string{cfg.ExternalIP},
|
||||
AsCandidateType: webrtc.ICECandidateTypeHost,
|
||||
Mode: webrtc.ICEAddressRewriteReplace,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("setting ICE address rewrite rules: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
api := webrtc.NewAPI(
|
||||
webrtc.WithMediaEngine(&me),
|
||||
webrtc.WithInterceptorRegistry(&ir),
|
||||
webrtc.WithSettingEngine(se),
|
||||
)
|
||||
|
||||
slog.Info("SFU initialized",
|
||||
"quality", cfg.Quality,
|
||||
"media_port_range", fmt.Sprintf("%d-%d", cfg.MediaPortMin, cfg.MediaPortMax),
|
||||
"external_ip", cfg.ExternalIP)
|
||||
return &SFU{api: api, config: cfg}, nil
|
||||
}
|
||||
|
||||
// NewPeerConnection creates a new PeerConnection using the SFU's pre-configured
|
||||
// WebRTC API. The SFU is the media server itself — it does not need STUN/TURN
|
||||
// to discover its own address. NAT traversal is handled by ExternalIP config
|
||||
// which rewrites ICE candidates via SetICEAddressRewriteRules in the
|
||||
// SettingEngine.
|
||||
func (s *SFU) NewPeerConnection() (*webrtc.PeerConnection, error) {
|
||||
return s.api.NewPeerConnection(webrtc.Configuration{})
|
||||
}
|
||||
|
||||
// Close is a placeholder for SFU cleanup. Future implementations may close
|
||||
// active peer connections or release resources.
|
||||
func (s *SFU) Close() {
|
||||
// Placeholder for cleanup.
|
||||
}
|
||||
|
||||
// QualityBitrate returns the target audio bitrate in bits/s based on the
|
||||
// configured quality preset.
|
||||
func (s *SFU) QualityBitrate() int {
|
||||
switch s.config.Quality {
|
||||
case "low":
|
||||
return 32000
|
||||
case "high":
|
||||
return 128000
|
||||
default:
|
||||
return 64000
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
package ws_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/owncord/server/config"
|
||||
"github.com/owncord/server/ws"
|
||||
)
|
||||
|
||||
func testVoiceConfig() *config.VoiceConfig {
|
||||
return &config.VoiceConfig{
|
||||
Quality: "medium",
|
||||
MediaPortMin: 50000,
|
||||
MediaPortMax: 50100,
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSFU_Success(t *testing.T) {
|
||||
sfu, err := ws.NewSFU(testVoiceConfig())
|
||||
if err != nil {
|
||||
t.Fatalf("NewSFU() returned error: %v", err)
|
||||
}
|
||||
if sfu == nil {
|
||||
t.Fatal("NewSFU() returned nil SFU")
|
||||
}
|
||||
defer sfu.Close()
|
||||
}
|
||||
|
||||
func TestNewSFU_CreatesValidPeerConnection(t *testing.T) {
|
||||
sfu, err := ws.NewSFU(testVoiceConfig())
|
||||
if err != nil {
|
||||
t.Fatalf("NewSFU() returned error: %v", err)
|
||||
}
|
||||
defer sfu.Close()
|
||||
|
||||
pc, err := sfu.NewPeerConnection()
|
||||
if err != nil {
|
||||
t.Fatalf("NewPeerConnection() returned error: %v", err)
|
||||
}
|
||||
if pc == nil {
|
||||
t.Fatal("NewPeerConnection() returned nil PeerConnection")
|
||||
}
|
||||
if err := pc.Close(); err != nil {
|
||||
t.Fatalf("PeerConnection.Close() returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSFU_QualityBitrate_Presets(t *testing.T) {
|
||||
tests := []struct {
|
||||
quality string
|
||||
want int
|
||||
}{
|
||||
{"low", 32000},
|
||||
{"medium", 64000},
|
||||
{"high", 128000},
|
||||
{"unknown", 64000},
|
||||
{"", 64000},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.quality, func(t *testing.T) {
|
||||
cfg := testVoiceConfig()
|
||||
cfg.Quality = tt.quality
|
||||
|
||||
sfu, err := ws.NewSFU(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSFU() returned error: %v", err)
|
||||
}
|
||||
defer sfu.Close()
|
||||
|
||||
got := sfu.QualityBitrate()
|
||||
if got != tt.want {
|
||||
t.Errorf("QualityBitrate() = %d, want %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSFU_Close(t *testing.T) {
|
||||
sfu, err := ws.NewSFU(testVoiceConfig())
|
||||
if err != nil {
|
||||
t.Fatalf("NewSFU() returned error: %v", err)
|
||||
}
|
||||
|
||||
// Close should not panic.
|
||||
sfu.Close()
|
||||
}
|
||||
|
||||
func TestNewSFU_WithExternalIP(t *testing.T) {
|
||||
cfg := testVoiceConfig()
|
||||
cfg.ExternalIP = "203.0.113.1"
|
||||
|
||||
sfu, err := ws.NewSFU(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSFU() returned error: %v", err)
|
||||
}
|
||||
defer sfu.Close()
|
||||
|
||||
pc, err := sfu.NewPeerConnection()
|
||||
if err != nil {
|
||||
t.Fatalf("NewPeerConnection() returned error: %v", err)
|
||||
}
|
||||
if err := pc.Close(); err != nil {
|
||||
t.Fatalf("PeerConnection.Close() returned error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
const speakerBroadcastInterval = 200 * time.Millisecond
|
||||
|
||||
// runSpeakerBroadcast periodically checks all voice rooms for speaker changes
|
||||
// and broadcasts voice_speakers to the channel. Runs until stop is closed.
|
||||
func (h *Hub) runSpeakerBroadcast(stop <-chan struct{}) {
|
||||
ticker := time.NewTicker(speakerBroadcastInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Track previous speaker lists to avoid redundant broadcasts.
|
||||
prevSpeakers := make(map[int64]string) // channelID → comma-joined speaker IDs
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-ticker.C:
|
||||
h.voiceRoomsMu.RLock()
|
||||
rooms := make(map[int64]*VoiceRoom, len(h.voiceRooms))
|
||||
for id, room := range h.voiceRooms {
|
||||
rooms[id] = room
|
||||
}
|
||||
h.voiceRoomsMu.RUnlock()
|
||||
|
||||
for channelID, room := range rooms {
|
||||
speakers := room.TopSpeakers()
|
||||
mode := room.Mode()
|
||||
|
||||
// Build a simple key to detect changes.
|
||||
key := speakerKey(speakers)
|
||||
if prev, ok := prevSpeakers[channelID]; ok && prev == key {
|
||||
continue // no change
|
||||
}
|
||||
prevSpeakers[channelID] = key
|
||||
|
||||
msg := buildVoiceSpeakers(channelID, speakers, mode)
|
||||
slog.Debug("speaker broadcast", "channel_id", channelID, "speakers", speakers, "mode", mode)
|
||||
h.BroadcastToChannel(channelID, msg)
|
||||
}
|
||||
|
||||
// Clean up stale entries for rooms that no longer exist.
|
||||
for id := range prevSpeakers {
|
||||
if _, exists := rooms[id]; !exists {
|
||||
delete(prevSpeakers, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// speakerKey builds a simple string key from speaker IDs for change detection.
|
||||
// Order matters: [1,2,3] and [3,2,1] produce different keys.
|
||||
func speakerKey(speakers []int64) string {
|
||||
if len(speakers) == 0 {
|
||||
return ""
|
||||
}
|
||||
// Simple concatenation — order matters for change detection.
|
||||
b := make([]byte, 0, len(speakers)*4)
|
||||
for i, id := range speakers {
|
||||
if i > 0 {
|
||||
b = append(b, ',')
|
||||
}
|
||||
b = append(b, []byte(strconv.FormatInt(id, 10))...)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// SpeakerKeyForTest exposes speakerKey for use in external test packages.
|
||||
// Only call from *_test.go files.
|
||||
func SpeakerKeyForTest(speakers []int64) string {
|
||||
return speakerKey(speakers)
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultHoldoff = 500 * time.Millisecond
|
||||
|
||||
// speakerLevel tracks the running audio level average for one user.
|
||||
type speakerLevel struct {
|
||||
userID int64
|
||||
levels [10]uint8 // ring buffer, 10 samples = 200ms at 20ms frames
|
||||
pos int
|
||||
count int // how many samples collected (up to 10)
|
||||
average float64
|
||||
lastActive time.Time // last time this speaker was in top-N
|
||||
}
|
||||
|
||||
// SpeakerDetector selects the top-N loudest speakers by RFC 6464 audio level.
|
||||
type SpeakerDetector struct {
|
||||
speakers map[int64]*speakerLevel
|
||||
topN int
|
||||
holdoff time.Duration // how long a speaker stays in top-N after going quiet
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewSpeakerDetector creates a detector with the default 500ms holdoff.
|
||||
func NewSpeakerDetector(topN int) *SpeakerDetector {
|
||||
return NewSpeakerDetectorWithHoldoff(topN, defaultHoldoff)
|
||||
}
|
||||
|
||||
// NewSpeakerDetectorWithHoldoff creates a detector with a custom holdoff duration.
|
||||
func NewSpeakerDetectorWithHoldoff(topN int, holdoff time.Duration) *SpeakerDetector {
|
||||
return &SpeakerDetector{
|
||||
speakers: make(map[int64]*speakerLevel),
|
||||
topN: topN,
|
||||
holdoff: holdoff,
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateLevel adds an audio level sample to the ring buffer for the given user
|
||||
// and recalculates the running average. Level is RFC 6464 dBov: 0 = loudest,
|
||||
// 127 = silence.
|
||||
func (d *SpeakerDetector) UpdateLevel(userID int64, level uint8) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
sl, ok := d.speakers[userID]
|
||||
if !ok {
|
||||
sl = &speakerLevel{userID: userID}
|
||||
d.speakers[userID] = sl
|
||||
}
|
||||
|
||||
sl.levels[sl.pos] = level
|
||||
sl.pos = (sl.pos + 1) % len(sl.levels)
|
||||
if sl.count < len(sl.levels) {
|
||||
sl.count++
|
||||
}
|
||||
|
||||
// Recalculate average over collected samples.
|
||||
var sum int
|
||||
for i := range sl.count {
|
||||
sum += int(sl.levels[i])
|
||||
}
|
||||
sl.average = float64(sum) / float64(sl.count)
|
||||
|
||||
// Mark as active if not silent.
|
||||
if sl.average < 127 {
|
||||
sl.lastActive = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
// TopSpeakers returns up to top-N user IDs sorted by lowest average level
|
||||
// (loudest first). Silent speakers (average == 127) are excluded unless they
|
||||
// are within the holdoff window.
|
||||
func (d *SpeakerDetector) TopSpeakers() []int64 {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// Collect candidates: not silent, or within holdoff.
|
||||
candidates := make([]*speakerLevel, 0, len(d.speakers))
|
||||
for _, sl := range d.speakers {
|
||||
if sl.average < 127 {
|
||||
candidates = append(candidates, sl)
|
||||
} else if !sl.lastActive.IsZero() && now.Sub(sl.lastActive) <= d.holdoff {
|
||||
candidates = append(candidates, sl)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by average level ascending (loudest first).
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
return candidates[i].average < candidates[j].average
|
||||
})
|
||||
|
||||
n := d.topN
|
||||
if len(candidates) < n {
|
||||
n = len(candidates)
|
||||
}
|
||||
|
||||
result := make([]int64, n)
|
||||
for i := range n {
|
||||
result[i] = candidates[i].userID
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// RemoveSpeaker removes a speaker from the detector (e.g., when they leave).
|
||||
func (d *SpeakerDetector) RemoveSpeaker(userID int64) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
delete(d.speakers, userID)
|
||||
}
|
||||
|
||||
// ParseAudioLevel parses an RFC 6464 one-byte header extension from raw RTP
|
||||
// extension data (RFC 5285 one-byte header format). It scans for the given
|
||||
// extensionID and extracts the voice activity bit and 7-bit level.
|
||||
//
|
||||
// Returns ok=false if the extension is not found.
|
||||
func ParseAudioLevel(buf []byte, extensionID uint8) (level uint8, voice bool, ok bool) {
|
||||
if len(buf) == 0 {
|
||||
return 0, false, false
|
||||
}
|
||||
|
||||
// Walk RFC 5285 one-byte header extensions.
|
||||
// Each element: 4-bit ID | 4-bit (length-1), followed by (length) data bytes.
|
||||
// ID=0 is padding, ID=15 terminates.
|
||||
i := 0
|
||||
for i < len(buf) {
|
||||
id := buf[i] >> 4
|
||||
dataLen := int(buf[i]&0x0F) + 1
|
||||
|
||||
if id == 0 {
|
||||
// Padding byte — skip.
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if id == 15 {
|
||||
// Terminator.
|
||||
break
|
||||
}
|
||||
|
||||
i++ // move past header byte
|
||||
|
||||
if i+dataLen > len(buf) {
|
||||
break
|
||||
}
|
||||
|
||||
if id == extensionID && dataLen >= 1 {
|
||||
b := buf[i]
|
||||
voice = (b & 0x80) != 0
|
||||
level = b & 0x7F
|
||||
return level, voice, true
|
||||
}
|
||||
|
||||
i += dataLen
|
||||
}
|
||||
|
||||
return 0, false, false
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
package ws_test
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/owncord/server/ws"
|
||||
)
|
||||
|
||||
func TestNewSpeakerDetector(t *testing.T) {
|
||||
t.Parallel()
|
||||
sd := ws.NewSpeakerDetector(3)
|
||||
if sd == nil {
|
||||
t.Fatal("NewSpeakerDetector returned nil")
|
||||
}
|
||||
top := sd.TopSpeakers()
|
||||
if len(top) != 0 {
|
||||
t.Fatalf("expected empty top speakers, got %v", top)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeakerDetector_UpdateLevel(t *testing.T) {
|
||||
t.Parallel()
|
||||
sd := ws.NewSpeakerDetector(3)
|
||||
|
||||
// Feed several level samples for a single user.
|
||||
for range 5 {
|
||||
sd.UpdateLevel(1, 30) // relatively loud
|
||||
}
|
||||
|
||||
top := sd.TopSpeakers()
|
||||
if len(top) != 1 {
|
||||
t.Fatalf("expected 1 speaker, got %d", len(top))
|
||||
}
|
||||
if top[0] != int64(1) {
|
||||
t.Fatalf("expected userID 1, got %d", top[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeakerDetector_TopSpeakers_RankedByLoudest(t *testing.T) {
|
||||
t.Parallel()
|
||||
sd := ws.NewSpeakerDetector(3)
|
||||
|
||||
// 5 users with different average levels (lower = louder in dBov).
|
||||
// User 10: level 10 (loudest)
|
||||
// User 20: level 30
|
||||
// User 30: level 50
|
||||
// User 40: level 80
|
||||
// User 50: level 100 (quietest)
|
||||
users := []struct {
|
||||
id int64
|
||||
level uint8
|
||||
}{
|
||||
{10, 10},
|
||||
{20, 30},
|
||||
{30, 50},
|
||||
{40, 80},
|
||||
{50, 100},
|
||||
}
|
||||
for _, u := range users {
|
||||
for range 5 {
|
||||
sd.UpdateLevel(u.id, u.level)
|
||||
}
|
||||
}
|
||||
|
||||
top := sd.TopSpeakers()
|
||||
if len(top) != 3 {
|
||||
t.Fatalf("expected 3 top speakers, got %d: %v", len(top), top)
|
||||
}
|
||||
// Should be sorted loudest first: 10, 20, 30
|
||||
expected := []int64{10, 20, 30}
|
||||
for i, want := range expected {
|
||||
if top[i] != want {
|
||||
t.Errorf("top[%d] = %d, want %d", i, top[i], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeakerDetector_TopSpeakers_SilentExcluded(t *testing.T) {
|
||||
t.Parallel()
|
||||
sd := ws.NewSpeakerDetector(3)
|
||||
|
||||
// User 1: loud
|
||||
for range 5 {
|
||||
sd.UpdateLevel(1, 20)
|
||||
}
|
||||
// User 2: completely silent (127 = digital silence in RFC 6464)
|
||||
for range 5 {
|
||||
sd.UpdateLevel(2, 127)
|
||||
}
|
||||
|
||||
top := sd.TopSpeakers()
|
||||
if len(top) != 1 {
|
||||
t.Fatalf("expected 1 speaker (silent excluded), got %d: %v", len(top), top)
|
||||
}
|
||||
if top[0] != int64(1) {
|
||||
t.Fatalf("expected userID 1, got %d", top[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeakerDetector_TopSpeakers_HoldoffKeepsSpeaker(t *testing.T) {
|
||||
t.Parallel()
|
||||
sd := ws.NewSpeakerDetectorWithHoldoff(3, 50*time.Millisecond)
|
||||
|
||||
// User 1 speaks loudly.
|
||||
for range 5 {
|
||||
sd.UpdateLevel(1, 20)
|
||||
}
|
||||
|
||||
// User 1 goes silent.
|
||||
for range 10 {
|
||||
sd.UpdateLevel(1, 127)
|
||||
}
|
||||
|
||||
// Immediately check — holdoff should keep user 1 in top speakers.
|
||||
top := sd.TopSpeakers()
|
||||
if !slices.Contains(top, int64(1)) {
|
||||
t.Fatalf("expected user 1 to remain in top speakers during holdoff, got %v", top)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeakerDetector_TopSpeakers_HoldoffExpires(t *testing.T) {
|
||||
t.Parallel()
|
||||
sd := ws.NewSpeakerDetectorWithHoldoff(3, 50*time.Millisecond)
|
||||
|
||||
// User 1 speaks loudly.
|
||||
for range 5 {
|
||||
sd.UpdateLevel(1, 20)
|
||||
}
|
||||
|
||||
// User 1 goes silent — fill ring buffer with silence.
|
||||
for range 10 {
|
||||
sd.UpdateLevel(1, 127)
|
||||
}
|
||||
|
||||
// Wait longer than holdoff.
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
|
||||
top := sd.TopSpeakers()
|
||||
for _, id := range top {
|
||||
if id == int64(1) {
|
||||
t.Fatalf("expected user 1 to be evicted after holdoff expired, got %v", top)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeakerDetector_RemoveSpeaker(t *testing.T) {
|
||||
t.Parallel()
|
||||
sd := ws.NewSpeakerDetector(3)
|
||||
|
||||
for range 5 {
|
||||
sd.UpdateLevel(1, 20)
|
||||
sd.UpdateLevel(2, 30)
|
||||
}
|
||||
|
||||
sd.RemoveSpeaker(1)
|
||||
|
||||
top := sd.TopSpeakers()
|
||||
for _, id := range top {
|
||||
if id == int64(1) {
|
||||
t.Fatalf("removed speaker should not appear in TopSpeakers, got %v", top)
|
||||
}
|
||||
}
|
||||
if len(top) != 1 || top[0] != int64(2) {
|
||||
t.Fatalf("expected [2], got %v", top)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAudioLevel_Valid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Construct a one-byte header extension value:
|
||||
// V=1, Level=42 → binary: 1_0101010 → 0xAA
|
||||
extByte := byte(0x80 | 42) // voice=1, level=42
|
||||
// RFC 5285 one-byte header format: 4-bit ID | 4-bit length-1
|
||||
// For extensionID=1, length=1 byte: header = 0x10
|
||||
extensionID := uint8(1)
|
||||
buf := []byte{extensionID << 4, extByte} // ID=1, L=0 (meaning 1 byte), then the data byte
|
||||
|
||||
level, voice, ok := ws.ParseAudioLevel(buf, extensionID)
|
||||
if !ok {
|
||||
t.Fatal("expected ok=true for valid extension")
|
||||
}
|
||||
if level != 42 {
|
||||
t.Errorf("level = %d, want 42", level)
|
||||
}
|
||||
if !voice {
|
||||
t.Error("expected voice=true")
|
||||
}
|
||||
|
||||
// Test with voice=false, level=10 → binary: 0_0001010 → 0x0A
|
||||
extByte2 := byte(10) // voice=0, level=10
|
||||
buf2 := []byte{extensionID << 4, extByte2}
|
||||
|
||||
level2, voice2, ok2 := ws.ParseAudioLevel(buf2, extensionID)
|
||||
if !ok2 {
|
||||
t.Fatal("expected ok=true")
|
||||
}
|
||||
if level2 != 10 {
|
||||
t.Errorf("level = %d, want 10", level2)
|
||||
}
|
||||
if voice2 {
|
||||
t.Error("expected voice=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAudioLevel_NotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Empty buffer.
|
||||
_, _, ok := ws.ParseAudioLevel(nil, 1)
|
||||
if ok {
|
||||
t.Error("expected ok=false for nil buffer")
|
||||
}
|
||||
|
||||
_, _, ok = ws.ParseAudioLevel([]byte{}, 1)
|
||||
if ok {
|
||||
t.Error("expected ok=false for empty buffer")
|
||||
}
|
||||
|
||||
// Wrong extension ID — buffer has ID=2 but we ask for ID=1.
|
||||
buf := []byte{2 << 4, 0x80}
|
||||
_, _, ok = ws.ParseAudioLevel(buf, 1)
|
||||
if ok {
|
||||
t.Error("expected ok=false for wrong extension ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAudioLevel_PaddingByte(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Padding byte (ID=0), then actual extension ID=1.
|
||||
// Padding: byte 0x00 (id=0 means skip)
|
||||
// Extension: ID=1, L=0 (1 byte), data=0x8A (voice=1, level=10)
|
||||
buf := []byte{0x00, 1 << 4, 0x8A}
|
||||
|
||||
level, voice, ok := ws.ParseAudioLevel(buf, 1)
|
||||
if !ok {
|
||||
t.Fatal("expected ok=true after padding byte")
|
||||
}
|
||||
if level != 10 {
|
||||
t.Errorf("level = %d, want 10", level)
|
||||
}
|
||||
if !voice {
|
||||
t.Error("expected voice=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAudioLevel_Terminator(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Terminator byte (ID=15) before any matching extension.
|
||||
buf := []byte{0xF0} // ID=15, terminates
|
||||
|
||||
_, _, ok := ws.ParseAudioLevel(buf, 1)
|
||||
if ok {
|
||||
t.Error("expected ok=false when terminator encountered before matching ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAudioLevel_TruncatedData(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Extension header says 1 byte of data, but buffer ends before data.
|
||||
// ID=1, L=0 (meaning 1 byte of data needed), but no data follows.
|
||||
buf := []byte{1 << 4}
|
||||
|
||||
_, _, ok := ws.ParseAudioLevel(buf, 1)
|
||||
if ok {
|
||||
t.Error("expected ok=false when data is truncated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAudioLevel_SkipOtherExtension(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Extension ID=2 with 2 bytes of data, followed by ID=1 with actual data.
|
||||
// ID=2, L=1 (2 bytes data): header 0x21, data 0x00 0x00
|
||||
// ID=1, L=0 (1 byte data): header 0x10, data 0x85 (voice=1, level=5)
|
||||
buf := []byte{0x21, 0x00, 0x00, 0x10, 0x85}
|
||||
|
||||
level, voice, ok := ws.ParseAudioLevel(buf, 1)
|
||||
if !ok {
|
||||
t.Fatal("expected ok=true after skipping other extension")
|
||||
}
|
||||
if level != 5 {
|
||||
t.Errorf("level = %d, want 5", level)
|
||||
}
|
||||
if !voice {
|
||||
t.Error("expected voice=true")
|
||||
}
|
||||
}
|
||||
@@ -1,373 +0,0 @@
|
||||
package ws_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/owncord/server/auth"
|
||||
"github.com/owncord/server/ws"
|
||||
)
|
||||
|
||||
// ─── VoiceRoom speaker detection ─────────────────────────────────────────────
|
||||
|
||||
func TestVoiceRoom_UpdateSpeakerLevel(t *testing.T) {
|
||||
cfg := ws.VoiceRoomConfig{
|
||||
ChannelID: 1,
|
||||
TopSpeakers: 3,
|
||||
}
|
||||
room := ws.NewVoiceRoom(cfg)
|
||||
|
||||
// Add participants first.
|
||||
_ = room.AddParticipant(10)
|
||||
_ = room.AddParticipant(20)
|
||||
_ = room.AddParticipant(30)
|
||||
|
||||
// User 10 is loudest (lowest dBov = 10), user 30 quietest (90).
|
||||
for range 5 {
|
||||
room.UpdateSpeakerLevel(10, 10)
|
||||
room.UpdateSpeakerLevel(20, 50)
|
||||
room.UpdateSpeakerLevel(30, 90)
|
||||
}
|
||||
|
||||
top := room.TopSpeakers()
|
||||
if len(top) == 0 {
|
||||
t.Fatal("TopSpeakers returned empty; expected at least one active speaker")
|
||||
}
|
||||
if top[0] != int64(10) {
|
||||
t.Errorf("top speaker = %d, want 10 (loudest)", top[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceRoom_TopSpeakers_EmptyRoom(t *testing.T) {
|
||||
cfg := ws.VoiceRoomConfig{
|
||||
ChannelID: 2,
|
||||
TopSpeakers: 3,
|
||||
}
|
||||
room := ws.NewVoiceRoom(cfg)
|
||||
|
||||
top := room.TopSpeakers()
|
||||
if len(top) != 0 {
|
||||
t.Errorf("TopSpeakers on empty room = %v, want empty slice", top)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceRoom_RemoveParticipant_RemovesFromDetector(t *testing.T) {
|
||||
cfg := ws.VoiceRoomConfig{
|
||||
ChannelID: 3,
|
||||
TopSpeakers: 3,
|
||||
}
|
||||
room := ws.NewVoiceRoom(cfg)
|
||||
_ = room.AddParticipant(100)
|
||||
_ = room.AddParticipant(200)
|
||||
|
||||
// Feed audio so both appear in top speakers.
|
||||
for range 5 {
|
||||
room.UpdateSpeakerLevel(100, 20)
|
||||
room.UpdateSpeakerLevel(200, 30)
|
||||
}
|
||||
|
||||
// Verify both appear before removal.
|
||||
topBefore := room.TopSpeakers()
|
||||
if len(topBefore) < 2 {
|
||||
t.Fatalf("expected 2 speakers before removal, got %v", topBefore)
|
||||
}
|
||||
|
||||
// Remove user 100 from the room.
|
||||
room.RemoveParticipant(100)
|
||||
|
||||
// After removal, user 100 must not appear in TopSpeakers.
|
||||
top := room.TopSpeakers()
|
||||
for _, id := range top {
|
||||
if id == int64(100) {
|
||||
t.Errorf("removed user 100 still appears in TopSpeakers: %v", top)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceRoom_Config(t *testing.T) {
|
||||
cfg := ws.VoiceRoomConfig{
|
||||
ChannelID: 42,
|
||||
MaxUsers: 10,
|
||||
Quality: "high",
|
||||
MixingThreshold: 8,
|
||||
TopSpeakers: 5,
|
||||
MaxVideo: 2,
|
||||
}
|
||||
room := ws.NewVoiceRoom(cfg)
|
||||
|
||||
got := room.Config()
|
||||
if got.ChannelID != 42 {
|
||||
t.Errorf("Config().ChannelID = %d, want 42", got.ChannelID)
|
||||
}
|
||||
if got.MaxUsers != 10 {
|
||||
t.Errorf("Config().MaxUsers = %d, want 10", got.MaxUsers)
|
||||
}
|
||||
if got.Quality != "high" {
|
||||
t.Errorf("Config().Quality = %q, want %q", got.Quality, "high")
|
||||
}
|
||||
if got.MixingThreshold != 8 {
|
||||
t.Errorf("Config().MixingThreshold = %d, want 8", got.MixingThreshold)
|
||||
}
|
||||
if got.TopSpeakers != 5 {
|
||||
t.Errorf("Config().TopSpeakers = %d, want 5", got.TopSpeakers)
|
||||
}
|
||||
if got.MaxVideo != 2 {
|
||||
t.Errorf("Config().MaxVideo = %d, want 2", got.MaxVideo)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── speakerKey helper ────────────────────────────────────────────────────────
|
||||
|
||||
func TestSpeakerKey_Empty(t *testing.T) {
|
||||
key := ws.SpeakerKeyForTest(nil)
|
||||
if key != "" {
|
||||
t.Errorf("SpeakerKeyForTest(nil) = %q, want empty string", key)
|
||||
}
|
||||
|
||||
key2 := ws.SpeakerKeyForTest([]int64{})
|
||||
if key2 != "" {
|
||||
t.Errorf("SpeakerKeyForTest([]) = %q, want empty string", key2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeakerKey_SingleSpeaker(t *testing.T) {
|
||||
key := ws.SpeakerKeyForTest([]int64{42})
|
||||
if key == "" {
|
||||
t.Error("SpeakerKeyForTest([42]) returned empty string")
|
||||
}
|
||||
// Key must contain the speaker ID in some form.
|
||||
if key != "42" {
|
||||
t.Errorf("SpeakerKeyForTest([42]) = %q, want %q", key, "42")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeakerKey_MultipleSpeakers(t *testing.T) {
|
||||
key1 := ws.SpeakerKeyForTest([]int64{1, 2, 3})
|
||||
key2 := ws.SpeakerKeyForTest([]int64{1, 2, 3})
|
||||
key3 := ws.SpeakerKeyForTest([]int64{3, 2, 1})
|
||||
|
||||
// Same order → same key.
|
||||
if key1 != key2 {
|
||||
t.Errorf("same speaker lists produced different keys: %q vs %q", key1, key2)
|
||||
}
|
||||
// Different order → different key (order matters for change detection).
|
||||
if key1 == key3 {
|
||||
t.Errorf("different speaker order should produce different keys but got %q for both", key1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeakerKey_DistinctFromDifferentSpeakers(t *testing.T) {
|
||||
key1 := ws.SpeakerKeyForTest([]int64{1, 2})
|
||||
key2 := ws.SpeakerKeyForTest([]int64{1, 3})
|
||||
if key1 == key2 {
|
||||
t.Errorf("different speaker sets should produce different keys, both got %q", key1)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Speaker broadcast integration ───────────────────────────────────────────
|
||||
|
||||
// TestSpeakerBroadcast_Integration creates a hub with a voice room, feeds
|
||||
// speaker levels, and verifies a voice_speakers broadcast is sent within the
|
||||
// ticker interval.
|
||||
func TestSpeakerBroadcast_Integration(t *testing.T) {
|
||||
database := openTestDB(t)
|
||||
limiter := auth.NewRateLimiter()
|
||||
hub := ws.NewHub(database, limiter)
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
|
||||
// Create a voice room for channel 99.
|
||||
cfg := ws.VoiceRoomConfig{
|
||||
ChannelID: 99,
|
||||
TopSpeakers: 3,
|
||||
}
|
||||
room := hub.GetOrCreateVoiceRoom(99, cfg)
|
||||
|
||||
// Register a client subscribed to channel 99 to receive the broadcast.
|
||||
send := make(chan []byte, 16)
|
||||
c := ws.NewTestClientWithChannel(hub, 1, 99, send)
|
||||
hub.Register(c)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
// Feed audio levels into the room — make user 1 a speaker.
|
||||
for range 5 {
|
||||
room.UpdateSpeakerLevel(1, 20) // level=20 (dBov), well below silence threshold
|
||||
}
|
||||
|
||||
// Wait for at least two ticker intervals (200ms each) so the broadcast fires.
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Drain and look for a voice_speakers message.
|
||||
var found bool
|
||||
drainLoop:
|
||||
for {
|
||||
select {
|
||||
case msg := <-send:
|
||||
var env map[string]json.RawMessage
|
||||
if err := json.Unmarshal(msg, &env); err != nil {
|
||||
continue
|
||||
}
|
||||
msgType, ok := env["type"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var t2 string
|
||||
if err := json.Unmarshal(msgType, &t2); err != nil {
|
||||
continue
|
||||
}
|
||||
if t2 == "voice_speakers" {
|
||||
found = true
|
||||
break drainLoop
|
||||
}
|
||||
default:
|
||||
break drainLoop
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Error("expected voice_speakers broadcast within ticker interval, none received")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpeakerBroadcast_NoBroadcastWhenNoChange verifies that the ticker does
|
||||
// not repeatedly broadcast when the speaker list has not changed.
|
||||
func TestSpeakerBroadcast_NoBroadcastWhenNoChange(t *testing.T) {
|
||||
database := openTestDB(t)
|
||||
limiter := auth.NewRateLimiter()
|
||||
hub := ws.NewHub(database, limiter)
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
|
||||
cfg := ws.VoiceRoomConfig{
|
||||
ChannelID: 100,
|
||||
TopSpeakers: 3,
|
||||
}
|
||||
room := hub.GetOrCreateVoiceRoom(100, cfg)
|
||||
|
||||
send := make(chan []byte, 64)
|
||||
c := ws.NewTestClientWithChannel(hub, 2, 100, send)
|
||||
hub.Register(c)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
// Feed levels to produce a stable speaker list.
|
||||
for range 5 {
|
||||
room.UpdateSpeakerLevel(2, 20)
|
||||
}
|
||||
|
||||
// Wait for first broadcast.
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
// Count how many voice_speakers messages arrived after the initial one.
|
||||
// In a change-detection implementation, subsequent ticks with the same
|
||||
// speaker list should NOT send more broadcasts.
|
||||
count := 0
|
||||
for {
|
||||
select {
|
||||
case msg := <-send:
|
||||
var env map[string]json.RawMessage
|
||||
if err := json.Unmarshal(msg, &env); err != nil {
|
||||
continue
|
||||
}
|
||||
var msgType string
|
||||
if raw, ok := env["type"]; ok {
|
||||
_ = json.Unmarshal(raw, &msgType)
|
||||
}
|
||||
if msgType == "voice_speakers" {
|
||||
count++
|
||||
}
|
||||
default:
|
||||
goto done
|
||||
}
|
||||
}
|
||||
done:
|
||||
// We allow 1 broadcast (initial detection), but not many repeated ones.
|
||||
// If every tick sent a message, we'd see ~2-4 in 300ms. We cap at 2.
|
||||
if count > 2 {
|
||||
t.Errorf("expected at most 2 voice_speakers broadcasts (dedup), got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpeakerBroadcast_RoomCleanup verifies that when a voice room is removed,
|
||||
// the ticker cleans up its stale prevSpeakers entry so that re-creating the
|
||||
// room with an active speaker triggers a new broadcast.
|
||||
func TestSpeakerBroadcast_RoomCleanup(t *testing.T) {
|
||||
database := openTestDB(t)
|
||||
limiter := auth.NewRateLimiter()
|
||||
hub := ws.NewHub(database, limiter)
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
|
||||
const chanID = int64(101)
|
||||
cfg := ws.VoiceRoomConfig{
|
||||
ChannelID: chanID,
|
||||
TopSpeakers: 3,
|
||||
}
|
||||
room := hub.GetOrCreateVoiceRoom(chanID, cfg)
|
||||
|
||||
// Use a large buffer to avoid missing messages due to timing.
|
||||
send := make(chan []byte, 64)
|
||||
c := ws.NewTestClientWithChannel(hub, 3, chanID, send)
|
||||
hub.Register(c)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
// Feed levels so the ticker broadcasts at least once.
|
||||
for range 5 {
|
||||
room.UpdateSpeakerLevel(3, 20)
|
||||
}
|
||||
|
||||
// Wait for two ticker intervals to ensure at least one broadcast fires.
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Remove the room; this should cause the ticker to clean up prevSpeakers.
|
||||
hub.RemoveVoiceRoom(chanID)
|
||||
|
||||
// Wait one more tick to let the cleanup run.
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
|
||||
// Drain all pending messages.
|
||||
draining:
|
||||
for {
|
||||
select {
|
||||
case <-send:
|
||||
default:
|
||||
break draining
|
||||
}
|
||||
}
|
||||
|
||||
// Re-create the room and feed a new speaker — the ticker should broadcast
|
||||
// again because prevSpeakers[chanID] was deleted when the room was removed.
|
||||
newRoom := hub.GetOrCreateVoiceRoom(chanID, cfg)
|
||||
for range 5 {
|
||||
newRoom.UpdateSpeakerLevel(3, 20)
|
||||
}
|
||||
|
||||
// Wait for the ticker to detect the new room and broadcast.
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
var found bool
|
||||
collectLoop:
|
||||
for {
|
||||
select {
|
||||
case msg := <-send:
|
||||
var env map[string]json.RawMessage
|
||||
if err := json.Unmarshal(msg, &env); err != nil {
|
||||
continue
|
||||
}
|
||||
var msgType string
|
||||
if raw, ok := env["type"]; ok {
|
||||
_ = json.Unmarshal(raw, &msgType)
|
||||
}
|
||||
if msgType == "voice_speakers" {
|
||||
found = true
|
||||
break collectLoop
|
||||
}
|
||||
default:
|
||||
break collectLoop
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Error("expected voice_speakers broadcast after room re-creation, none received")
|
||||
}
|
||||
}
|
||||
+77
-710
@@ -2,167 +2,43 @@ package ws
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
|
||||
"github.com/owncord/server/db"
|
||||
"github.com/owncord/server/permissions"
|
||||
)
|
||||
|
||||
// Voice rate limit settings.
|
||||
const (
|
||||
voiceSignalRateLimit = 20
|
||||
voiceSignalWindow = time.Second
|
||||
voiceICERateLimit = 50 // ICE candidates arrive in bursts during connection setup
|
||||
voiceICEWindow = time.Second
|
||||
soundboardRateLimit = 1
|
||||
soundboardWindow = 3 * time.Second
|
||||
voiceCameraRateLimit = 2
|
||||
voiceCameraWindow = time.Second
|
||||
voiceScreenshareRateLimit = 2
|
||||
voiceScreenshareWindow = time.Second
|
||||
)
|
||||
|
||||
// setupICEMonitor monitors ICE connection state changes on the client's
|
||||
// PeerConnection. On failure/disconnect, it cleans up voice state.
|
||||
func (h *Hub) setupICEMonitor(c *Client, channelID int64) {
|
||||
pc := c.getPC()
|
||||
if pc == nil {
|
||||
return
|
||||
// qualityBitrate returns the target audio bitrate in bits/s based on a quality preset.
|
||||
func qualityBitrate(quality string) int {
|
||||
switch quality {
|
||||
case "low":
|
||||
return 32000
|
||||
case "high":
|
||||
return 128000
|
||||
default:
|
||||
return 64000
|
||||
}
|
||||
|
||||
pc.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
|
||||
// Guard: ignore stale events from old PeerConnections after channel switch
|
||||
if c.getPC() != pc {
|
||||
slog.Debug("ignoring stale ICE event from old PC", "user_id", c.userID, "channel_id", channelID, "state", state.String())
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("ICE state change", "user_id", c.userID, "channel_id", channelID, "state", state.String())
|
||||
|
||||
switch state {
|
||||
case webrtc.ICEConnectionStateFailed:
|
||||
slog.Warn("ICE connection failed, cleaning up voice", "user_id", c.userID, "channel_id", channelID)
|
||||
if c.getVoiceChID() != 0 {
|
||||
h.handleVoiceLeave(c)
|
||||
}
|
||||
case webrtc.ICEConnectionStateClosed:
|
||||
// Closed means the PC was shut down (client destroyed it).
|
||||
// Safety net: only clean up if voice_leave hasn't already done it.
|
||||
if c.getVoiceChID() != 0 {
|
||||
slog.Info("ICE connection closed, cleaning up voice", "user_id", c.userID, "channel_id", channelID)
|
||||
h.handleVoiceLeave(c)
|
||||
}
|
||||
case webrtc.ICEConnectionStateDisconnected:
|
||||
// Disconnected is transient — ICE may recover.
|
||||
// Log but don't clean up immediately.
|
||||
slog.Info("ICE disconnected (may recover)", "user_id", c.userID, "channel_id", channelID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// SetupICEMonitorForTest exposes setupICEMonitor for tests.
|
||||
func (h *Hub) SetupICEMonitorForTest(c *Client, channelID int64) {
|
||||
h.setupICEMonitor(c, channelID)
|
||||
}
|
||||
|
||||
// setupICECallback registers an OnICECandidate handler on the client's
|
||||
// PeerConnection to send server-generated ICE candidates to the client.
|
||||
func (h *Hub) setupICECallback(c *Client, channelID int64) {
|
||||
pc := c.getPC()
|
||||
if pc == nil {
|
||||
return
|
||||
}
|
||||
pc.OnICECandidate(func(candidate *webrtc.ICECandidate) {
|
||||
if candidate == nil {
|
||||
slog.Debug("ICE gathering complete", "user_id", c.userID, "channel_id", channelID)
|
||||
return
|
||||
}
|
||||
slog.Debug("SFU ICE candidate generated",
|
||||
"user_id", c.userID,
|
||||
"type", candidate.Typ.String(),
|
||||
"address", candidate.Address,
|
||||
"port", candidate.Port,
|
||||
"protocol", candidate.Protocol.String())
|
||||
c.sendMsg(buildVoiceICE(channelID, candidate.ToJSON()))
|
||||
})
|
||||
}
|
||||
|
||||
// renegotiateParticipant creates a new SDP offer for the given client
|
||||
// and sends it as voice_offer. Implements the "impolite" side of
|
||||
// Perfect Negotiation — skips if PC is in have-remote-offer state.
|
||||
func (h *Hub) renegotiateParticipant(c *Client) {
|
||||
// Serialise all SDP signalling for this client so concurrent OnTrack
|
||||
// goroutines don't race through state-check → rollback → createOffer.
|
||||
c.negoMu.Lock()
|
||||
defer c.negoMu.Unlock()
|
||||
|
||||
pc := c.getPC()
|
||||
if pc == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Perfect Negotiation: server is impolite — skip if we're already
|
||||
// mid-negotiation (client sent us an offer, or we sent one and are
|
||||
// waiting for an answer).
|
||||
state := pc.SignalingState()
|
||||
slog.Debug("renegotiateParticipant enter",
|
||||
"user_id", c.userID, "signaling_state", state.String())
|
||||
if state == webrtc.SignalingStateHaveRemoteOffer {
|
||||
slog.Info("renegotiate skipped: have-remote-offer",
|
||||
"user_id", c.userID)
|
||||
return
|
||||
}
|
||||
if state == webrtc.SignalingStateHaveLocalOffer {
|
||||
// Roll back our pending offer so we can create a fresh one
|
||||
// that includes all current tracks.
|
||||
if err := pc.SetLocalDescription(webrtc.SessionDescription{
|
||||
Type: webrtc.SDPTypeRollback,
|
||||
}); err != nil {
|
||||
slog.Error("renegotiateParticipant rollback failed",
|
||||
"err", err, "user_id", c.userID)
|
||||
return
|
||||
}
|
||||
slog.Debug("renegotiateParticipant rollback OK",
|
||||
"user_id", c.userID)
|
||||
}
|
||||
|
||||
offer, err := pc.CreateOffer(nil)
|
||||
if err != nil {
|
||||
slog.Error("renegotiateParticipant CreateOffer",
|
||||
"err", err, "user_id", c.userID)
|
||||
return
|
||||
}
|
||||
|
||||
if err := pc.SetLocalDescription(offer); err != nil {
|
||||
slog.Error("renegotiateParticipant SetLocalDescription",
|
||||
"err", err, "user_id", c.userID)
|
||||
return
|
||||
}
|
||||
|
||||
channelID := c.getVoiceChID()
|
||||
slog.Debug("renegotiateParticipant offer sent",
|
||||
"user_id", c.userID, "channel_id", channelID,
|
||||
"signaling_state", pc.SignalingState().String())
|
||||
c.sendMsg(buildVoiceOffer(channelID, offer.SDP))
|
||||
}
|
||||
|
||||
// handleVoiceJoin processes a voice_join message.
|
||||
// 1. Parses channel_id.
|
||||
// 2. Checks CONNECT_VOICE permission.
|
||||
// 3. If already in a different voice channel, leaves it first.
|
||||
// 4. Gets or creates VoiceRoom with config from channel settings.
|
||||
// 5. Adds participant to VoiceRoom (checks capacity).
|
||||
// 6. Persists join in DB.
|
||||
// 7. Creates PeerConnection if SFU is available.
|
||||
// 8. Broadcasts voice_state to channel.
|
||||
// 9. Sends existing voice states to joiner.
|
||||
// 10. Sends voice_config to joiner.
|
||||
// 4. Checks channel capacity (voice_max_users).
|
||||
// 5. Persists join in DB.
|
||||
// 6. Generates LiveKit token and sends voice_token to the client.
|
||||
// 7. Sends existing voice states to the joiner.
|
||||
// 8. Broadcasts voice_state to all clients.
|
||||
// 9. Sends voice_config to the joiner.
|
||||
func (h *Hub) handleVoiceJoin(c *Client, payload json.RawMessage) {
|
||||
channelID, err := parseChannelID(payload)
|
||||
if err != nil || channelID <= 0 {
|
||||
@@ -176,7 +52,7 @@ func (h *Hub) handleVoiceJoin(c *Client, payload json.RawMessage) {
|
||||
|
||||
currentChID := c.getVoiceChID()
|
||||
|
||||
// HIGH-2: If user is already in the same voice channel, no-op.
|
||||
// If user is already in the same voice channel, no-op.
|
||||
if currentChID == channelID {
|
||||
c.sendMsg(buildErrorMsg("ALREADY_JOINED", "already in this voice channel"))
|
||||
return
|
||||
@@ -193,76 +69,52 @@ func (h *Hub) handleVoiceJoin(c *Client, payload json.RawMessage) {
|
||||
return
|
||||
}
|
||||
|
||||
roomCfg := h.buildVoiceRoomConfig(ch)
|
||||
room := h.GetOrCreateVoiceRoom(channelID, roomCfg)
|
||||
|
||||
if addErr := room.AddParticipant(c.userID); addErr != nil {
|
||||
if errors.Is(addErr, ErrRoomFull) {
|
||||
// Check channel capacity.
|
||||
maxUsers := ch.VoiceMaxUsers
|
||||
if maxUsers > 0 {
|
||||
existing, qErr := h.db.GetChannelVoiceStates(channelID)
|
||||
if qErr != nil {
|
||||
slog.Error("ws handleVoiceJoin GetChannelVoiceStates", "err", qErr, "channel_id", channelID)
|
||||
c.sendMsg(buildErrorMsg("INTERNAL", "failed to check channel capacity"))
|
||||
return
|
||||
}
|
||||
if len(existing) >= maxUsers {
|
||||
c.sendMsg(buildErrorMsg("CHANNEL_FULL", "voice channel is full"))
|
||||
} else {
|
||||
c.sendMsg(buildErrorMsg("VOICE_ERROR", "failed to join voice channel"))
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Persist to DB.
|
||||
if err := h.db.JoinVoiceChannel(c.userID, channelID); err != nil {
|
||||
room.RemoveParticipant(c.userID)
|
||||
slog.Error("ws handleVoiceJoin JoinVoiceChannel", "err", err, "user_id", c.userID)
|
||||
c.sendMsg(buildErrorMsg("INTERNAL", "failed to join voice channel"))
|
||||
return
|
||||
}
|
||||
|
||||
// Create PeerConnection if SFU is available. Non-fatal on failure.
|
||||
var pc *webrtc.PeerConnection
|
||||
if h.sfu != nil {
|
||||
var pcErr error
|
||||
pc, pcErr = h.sfu.NewPeerConnection()
|
||||
if pcErr != nil {
|
||||
slog.Error("ws handleVoiceJoin NewPeerConnection", "err", pcErr, "user_id", c.userID)
|
||||
// Set voice channel on the client.
|
||||
c.setVoiceChID(channelID)
|
||||
|
||||
// Generate LiveKit token if LiveKit client is available.
|
||||
if h.livekit != nil {
|
||||
canPublish := true
|
||||
canSubscribe := true
|
||||
token, tokenErr := h.livekit.GenerateToken(c.userID, c.user.Username, channelID, canPublish, canSubscribe)
|
||||
if tokenErr != nil {
|
||||
slog.Error("ws handleVoiceJoin GenerateToken", "err", tokenErr, "user_id", c.userID)
|
||||
// Non-fatal: voice join still succeeds at the DB/state level.
|
||||
} else {
|
||||
c.sendMsg(buildVoiceToken(channelID, token, h.livekit.URL()))
|
||||
}
|
||||
}
|
||||
|
||||
// Track the voice channel and PC on the client atomically (CRIT-1 fix).
|
||||
c.setVoice(channelID, pc)
|
||||
|
||||
// Add existing tracks to the new joiner's PC so they hear
|
||||
// participants who joined before them.
|
||||
if pc != nil {
|
||||
existingTracks := room.GetTracks()
|
||||
addedExisting := 0
|
||||
for _, vt := range existingTracks {
|
||||
if vt.Local == nil || vt.UserID == c.userID {
|
||||
continue
|
||||
}
|
||||
sender, addErr := pc.AddTrack(vt.Local)
|
||||
if addErr != nil {
|
||||
slog.Error("handleVoiceJoin AddTrack existing",
|
||||
"err", addErr,
|
||||
"from", vt.UserID, "to", c.userID)
|
||||
continue
|
||||
}
|
||||
vt.AddSender(c.userID, sender)
|
||||
addedExisting++
|
||||
}
|
||||
slog.Info("existing tracks added to new joiner",
|
||||
"user_id", c.userID,
|
||||
"existing_tracks_total", len(existingTracks),
|
||||
"tracks_added", addedExisting)
|
||||
}
|
||||
|
||||
if pc != nil {
|
||||
h.setupOnTrack(c, channelID)
|
||||
h.setupICEMonitor(c, channelID)
|
||||
h.setupICECallback(c, channelID)
|
||||
}
|
||||
|
||||
// Get and broadcast the joiner's state.
|
||||
state, err := h.db.GetVoiceState(c.userID)
|
||||
if err != nil || state == nil {
|
||||
slog.Error("ws handleVoiceJoin GetVoiceState", "err", err, "user_id", c.userID)
|
||||
return
|
||||
}
|
||||
|
||||
// Broadcast the joiner's state to all connected clients so every sidebar updates.
|
||||
// Broadcast the joiner's state to all connected clients.
|
||||
h.BroadcastToAll(buildVoiceState(*state))
|
||||
|
||||
// Send existing channel voice states to the joiner.
|
||||
@@ -278,114 +130,41 @@ func (h *Hub) handleVoiceJoin(c *Client, payload json.RawMessage) {
|
||||
c.sendMsg(buildVoiceState(vs))
|
||||
}
|
||||
|
||||
// Send voice_config to the joiner with room settings.
|
||||
quality := roomCfg.Quality
|
||||
bitrate := 64000 // default medium
|
||||
if h.sfu != nil {
|
||||
bitrate = h.sfu.QualityBitrate()
|
||||
}
|
||||
c.sendMsg(buildVoiceConfig(channelID, quality, bitrate, room.Mode(), roomCfg.MixingThreshold, roomCfg.TopSpeakers, roomCfg.MaxUsers))
|
||||
|
||||
slog.Info("voice join", "user_id", c.userID, "channel_id", channelID, "participants", room.ParticipantCount(), "mode", room.Mode())
|
||||
}
|
||||
|
||||
// buildVoiceRoomConfig constructs a VoiceRoomConfig from channel settings and server defaults.
|
||||
func (h *Hub) buildVoiceRoomConfig(ch *db.Channel) VoiceRoomConfig {
|
||||
cfg := VoiceRoomConfig{
|
||||
ChannelID: ch.ID,
|
||||
MaxUsers: ch.VoiceMaxUsers,
|
||||
Quality: "medium",
|
||||
MixingThreshold: 10,
|
||||
TopSpeakers: 3,
|
||||
MaxVideo: ch.VoiceMaxVideo,
|
||||
}
|
||||
// Send voice_config to the joiner.
|
||||
quality := "medium"
|
||||
if ch.VoiceQuality != nil && *ch.VoiceQuality != "" {
|
||||
cfg.Quality = *ch.VoiceQuality
|
||||
quality = *ch.VoiceQuality
|
||||
}
|
||||
if ch.MixingThreshold != nil {
|
||||
cfg.MixingThreshold = *ch.MixingThreshold
|
||||
}
|
||||
return cfg
|
||||
bitrate := qualityBitrate(quality)
|
||||
c.sendMsg(buildVoiceConfig(channelID, quality, bitrate, maxUsers))
|
||||
|
||||
slog.Info("voice join", "user_id", c.userID, "channel_id", channelID)
|
||||
}
|
||||
|
||||
// handleVoiceLeave processes an explicit voice_leave message or a disconnect.
|
||||
// 1. Reads current voice state (for broadcast).
|
||||
// 2. Closes PeerConnection if active.
|
||||
// 3. Removes participant from VoiceRoom; removes room if empty.
|
||||
// 4. Removes voice state from DB.
|
||||
// 5. Broadcasts voice_leave to the channel the user was in.
|
||||
// 1. Gets old voiceChID from clearVoiceChID().
|
||||
// 2. If was in voice: remove from DB, broadcast voice_leave.
|
||||
// 3. Call livekit.RemoveParticipant (ignore errors — participant may already be gone).
|
||||
func (h *Hub) handleVoiceLeave(c *Client) {
|
||||
// Atomically clear voice state and get old values for cleanup (CRIT-1 fix).
|
||||
// Only the first caller gets real values; concurrent calls (e.g. ICE
|
||||
// callbacks racing with an explicit voice_leave) get oldChID=0 and
|
||||
// become no-ops.
|
||||
oldChID, oldPC := c.clearVoice()
|
||||
if oldChID == 0 && oldPC == nil {
|
||||
oldChID := c.clearVoiceChID()
|
||||
if oldChID == 0 {
|
||||
slog.Debug("handleVoiceLeave no-op (already cleared)", "user_id", c.userID)
|
||||
return
|
||||
}
|
||||
|
||||
// Close PeerConnection if active.
|
||||
// This also causes any setupOnTrack goroutine to exit via track.Read error (HIGH-1).
|
||||
if oldPC != nil {
|
||||
if closeErr := oldPC.Close(); closeErr != nil {
|
||||
slog.Error("ws handleVoiceLeave pc.Close", "err", closeErr, "user_id", c.userID)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove this user's track from all subscribers' PCs.
|
||||
// Done AFTER oldPC.Close() so the RTP goroutine has exited.
|
||||
if oldChID > 0 {
|
||||
if room := h.GetVoiceRoom(oldChID); room != nil {
|
||||
needsRenego := make(map[int64]*Client)
|
||||
for _, kind := range []string{"audio", "video"} {
|
||||
vt := room.RemoveTrack(c.userID, kind)
|
||||
if vt == nil {
|
||||
continue
|
||||
}
|
||||
senders := vt.CopySenders()
|
||||
for subID, sender := range senders {
|
||||
sub := h.GetClient(subID)
|
||||
if sub == nil {
|
||||
continue
|
||||
}
|
||||
subPC := sub.getPC()
|
||||
if subPC == nil {
|
||||
continue
|
||||
}
|
||||
if rmErr := subPC.RemoveTrack(sender); rmErr != nil {
|
||||
slog.Error("handleVoiceLeave RemoveTrack",
|
||||
"err", rmErr, "user_id", subID, "kind", kind)
|
||||
} else {
|
||||
slog.Debug("handleVoiceLeave track removed from subscriber",
|
||||
"leaving_user", c.userID, "subscriber", subID, "kind", kind)
|
||||
}
|
||||
needsRenego[subID] = sub
|
||||
}
|
||||
}
|
||||
for _, sub := range needsRenego {
|
||||
h.renegotiateParticipant(sub)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from VoiceRoom and clean up empty rooms.
|
||||
if oldChID > 0 {
|
||||
if room := h.GetVoiceRoom(oldChID); room != nil {
|
||||
room.RemoveParticipant(c.userID)
|
||||
if room.IsEmpty() {
|
||||
h.RemoveVoiceRoom(oldChID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("voice leave", "user_id", c.userID, "channel_id", oldChID)
|
||||
|
||||
if oldChID > 0 {
|
||||
if leaveErr := h.db.LeaveVoiceChannel(c.userID); leaveErr != nil {
|
||||
slog.Error("ws handleVoiceLeave LeaveVoiceChannel", "err", leaveErr, "user_id", c.userID)
|
||||
if leaveErr := h.db.LeaveVoiceChannel(c.userID); leaveErr != nil {
|
||||
slog.Error("ws handleVoiceLeave LeaveVoiceChannel", "err", leaveErr, "user_id", c.userID)
|
||||
}
|
||||
h.BroadcastToAll(buildVoiceLeave(oldChID, c.userID))
|
||||
|
||||
// Remove from LiveKit (best-effort).
|
||||
if h.livekit != nil {
|
||||
if err := h.livekit.RemoveParticipant(oldChID, c.userID); err != nil {
|
||||
slog.Debug("handleVoiceLeave RemoveParticipant (may already be gone)",
|
||||
"err", err, "user_id", c.userID, "channel_id", oldChID)
|
||||
}
|
||||
h.BroadcastToAll(buildVoiceLeave(oldChID, c.userID))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,8 +218,9 @@ func (h *Hub) handleVoiceDeafen(c *Client, payload json.RawMessage) {
|
||||
// 1. Rate limits at 2/sec per user.
|
||||
// 2. Checks USE_VIDEO permission.
|
||||
// 3. Parses enabled bool.
|
||||
// 4. Updates DB.
|
||||
// 5. Broadcasts voice_state update to channel.
|
||||
// 4. Enforces MaxVideo limit via LiveKit.
|
||||
// 5. Updates DB.
|
||||
// 6. Broadcasts voice_state update to channel.
|
||||
func (h *Hub) handleVoiceCamera(c *Client, payload json.RawMessage) {
|
||||
ratKey := fmt.Sprintf("voice_camera:%d", c.userID)
|
||||
if !h.limiter.Allow(ratKey, voiceCameraRateLimit, voiceCameraWindow) {
|
||||
@@ -468,22 +248,15 @@ func (h *Hub) handleVoiceCamera(c *Client, payload json.RawMessage) {
|
||||
|
||||
// Enforce MaxVideo limit when enabling camera.
|
||||
if p.Enabled {
|
||||
room := h.GetVoiceRoom(voiceChID)
|
||||
if room != nil {
|
||||
cfg := room.Config()
|
||||
if cfg.MaxVideo > 0 {
|
||||
allTracks := room.GetTracks()
|
||||
videoCount := 0
|
||||
for _, vt := range allTracks {
|
||||
if vt.Local != nil && strings.HasPrefix(vt.Local.ID(), "video-") {
|
||||
videoCount++
|
||||
}
|
||||
}
|
||||
if videoCount >= cfg.MaxVideo {
|
||||
c.sendMsg(buildErrorMsg("VIDEO_LIMIT",
|
||||
fmt.Sprintf("maximum %d video streams reached", cfg.MaxVideo)))
|
||||
return
|
||||
}
|
||||
ch, chErr := h.db.GetChannel(voiceChID)
|
||||
if chErr == nil && ch != nil && ch.VoiceMaxVideo > 0 && h.livekit != nil {
|
||||
videoCount, countErr := h.livekit.CountVideoTracks(voiceChID)
|
||||
if countErr != nil {
|
||||
slog.Error("handleVoiceCamera CountVideoTracks", "err", countErr, "channel_id", voiceChID)
|
||||
} else if videoCount >= ch.VoiceMaxVideo {
|
||||
c.sendMsg(buildErrorMsg("VIDEO_LIMIT",
|
||||
fmt.Sprintf("maximum %d video streams reached", ch.VoiceMaxVideo)))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -539,412 +312,6 @@ func (h *Hub) handleVoiceScreenshare(c *Client, payload json.RawMessage) {
|
||||
h.broadcastVoiceStateUpdate(c)
|
||||
}
|
||||
|
||||
// handleVoiceOffer processes a voice_offer from the client.
|
||||
// The client sends an SDP offer; the server sets it as remote description
|
||||
// on the client's PeerConnection, creates an answer, and sends it back.
|
||||
func (h *Hub) handleVoiceOffer(c *Client, payload json.RawMessage) {
|
||||
ratKey := fmt.Sprintf("voice_signal:%d", c.userID)
|
||||
if !h.limiter.Allow(ratKey, voiceSignalRateLimit, voiceSignalWindow) {
|
||||
c.sendMsg(buildRateLimitError("too many signaling messages", voiceSignalWindow.Seconds()))
|
||||
return
|
||||
}
|
||||
|
||||
pc := c.getPC()
|
||||
if pc == nil {
|
||||
c.sendMsg(buildErrorMsg("VOICE_ERROR", "not in a voice channel"))
|
||||
return
|
||||
}
|
||||
|
||||
var p struct {
|
||||
ChannelID json.Number `json:"channel_id"`
|
||||
SDP string `json:"sdp"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &p); err != nil {
|
||||
c.sendMsg(buildErrorMsg("BAD_REQUEST", "invalid voice_offer payload"))
|
||||
return
|
||||
}
|
||||
if p.SDP == "" {
|
||||
c.sendMsg(buildErrorMsg("INVALID_SDP", "SDP is required"))
|
||||
return
|
||||
}
|
||||
|
||||
offer := webrtc.SessionDescription{
|
||||
Type: webrtc.SDPTypeOffer,
|
||||
SDP: p.SDP,
|
||||
}
|
||||
|
||||
// Serialise SDP signalling so a concurrent renegotiateParticipant
|
||||
// cannot race with this offer/answer exchange.
|
||||
c.negoMu.Lock()
|
||||
defer c.negoMu.Unlock()
|
||||
|
||||
stateBefore := pc.SignalingState()
|
||||
slog.Debug("handleVoiceOffer enter",
|
||||
"user_id", c.userID, "signaling_state", stateBefore.String())
|
||||
|
||||
// Perfect Negotiation: if we already have a pending local offer (glare
|
||||
// condition — server and client sent offers simultaneously), roll back
|
||||
// ours so we can accept the client's offer.
|
||||
if stateBefore == webrtc.SignalingStateHaveLocalOffer {
|
||||
if err := pc.SetLocalDescription(webrtc.SessionDescription{
|
||||
Type: webrtc.SDPTypeRollback,
|
||||
}); err != nil {
|
||||
slog.Error("ws handleVoiceOffer rollback failed", "err", err, "user_id", c.userID)
|
||||
c.sendMsg(buildErrorMsg("VOICE_ERROR", "failed to resolve signaling conflict"))
|
||||
return
|
||||
}
|
||||
slog.Info("handleVoiceOffer rolled back local offer (glare)", "user_id", c.userID)
|
||||
}
|
||||
|
||||
if err := pc.SetRemoteDescription(offer); err != nil {
|
||||
slog.Error("ws handleVoiceOffer SetRemoteDescription", "err", err, "user_id", c.userID)
|
||||
c.sendMsg(buildErrorMsg("INVALID_SDP", "failed to set remote description"))
|
||||
return
|
||||
}
|
||||
|
||||
answer, err := pc.CreateAnswer(nil)
|
||||
if err != nil {
|
||||
slog.Error("ws handleVoiceOffer CreateAnswer", "err", err, "user_id", c.userID)
|
||||
c.sendMsg(buildErrorMsg("VOICE_ERROR", "failed to create answer"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := pc.SetLocalDescription(answer); err != nil {
|
||||
slog.Error("ws handleVoiceOffer SetLocalDescription", "err", err, "user_id", c.userID)
|
||||
c.sendMsg(buildErrorMsg("VOICE_ERROR", "failed to set local description"))
|
||||
return
|
||||
}
|
||||
|
||||
slog.Debug("handleVoiceOffer answer sent",
|
||||
"user_id", c.userID,
|
||||
"signaling_state", pc.SignalingState().String())
|
||||
// Send the answer back to the client.
|
||||
c.sendMsg(buildVoiceAnswer(c.getVoiceChID(), answer.SDP))
|
||||
}
|
||||
|
||||
// handleVoiceAnswer processes a voice_answer from the client.
|
||||
// This handles the case where the server sent an offer (e.g., renegotiation)
|
||||
// and the client responds with an answer.
|
||||
func (h *Hub) handleVoiceAnswer(c *Client, payload json.RawMessage) {
|
||||
ratKey := fmt.Sprintf("voice_signal:%d", c.userID)
|
||||
if !h.limiter.Allow(ratKey, voiceSignalRateLimit, voiceSignalWindow) {
|
||||
c.sendMsg(buildRateLimitError("too many signaling messages", voiceSignalWindow.Seconds()))
|
||||
return
|
||||
}
|
||||
|
||||
pc := c.getPC()
|
||||
if pc == nil {
|
||||
c.sendMsg(buildErrorMsg("VOICE_ERROR", "not in a voice channel"))
|
||||
return
|
||||
}
|
||||
|
||||
var p struct {
|
||||
ChannelID json.Number `json:"channel_id"`
|
||||
SDP string `json:"sdp"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &p); err != nil {
|
||||
c.sendMsg(buildErrorMsg("BAD_REQUEST", "invalid voice_answer payload"))
|
||||
return
|
||||
}
|
||||
if p.SDP == "" {
|
||||
c.sendMsg(buildErrorMsg("INVALID_SDP", "SDP is required"))
|
||||
return
|
||||
}
|
||||
|
||||
answer := webrtc.SessionDescription{
|
||||
Type: webrtc.SDPTypeAnswer,
|
||||
SDP: p.SDP,
|
||||
}
|
||||
|
||||
// Serialise with renegotiateParticipant — SetRemoteDescription(answer)
|
||||
// transitions from have-local-offer → stable and must not race with a
|
||||
// concurrent rollback + new offer.
|
||||
c.negoMu.Lock()
|
||||
defer c.negoMu.Unlock()
|
||||
|
||||
stateBefore := pc.SignalingState()
|
||||
if err := pc.SetRemoteDescription(answer); err != nil {
|
||||
slog.Error("ws handleVoiceAnswer SetRemoteDescription", "err", err, "user_id", c.userID)
|
||||
c.sendMsg(buildErrorMsg("INVALID_SDP", "failed to set remote description"))
|
||||
return
|
||||
}
|
||||
slog.Debug("handleVoiceAnswer applied",
|
||||
"user_id", c.userID,
|
||||
"state_before", stateBefore.String(),
|
||||
"state_after", pc.SignalingState().String())
|
||||
}
|
||||
|
||||
// handleVoiceICE processes a voice_ice (ICE candidate) from the client.
|
||||
func (h *Hub) handleVoiceICE(c *Client, payload json.RawMessage) {
|
||||
// ICE candidates use a separate, higher rate limit — they arrive in bursts
|
||||
// during connection setup and are mandatory for connectivity.
|
||||
ratKey := fmt.Sprintf("voice_ice:%d", c.userID)
|
||||
if !h.limiter.Allow(ratKey, voiceICERateLimit, voiceICEWindow) {
|
||||
c.sendMsg(buildRateLimitError("too many ICE candidates", voiceICEWindow.Seconds()))
|
||||
return
|
||||
}
|
||||
|
||||
pc := c.getPC()
|
||||
if pc == nil {
|
||||
c.sendMsg(buildErrorMsg("VOICE_ERROR", "not in a voice channel"))
|
||||
return
|
||||
}
|
||||
|
||||
var p struct {
|
||||
ChannelID json.Number `json:"channel_id"`
|
||||
Candidate webrtc.ICECandidateInit `json:"candidate"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &p); err != nil {
|
||||
c.sendMsg(buildErrorMsg("BAD_REQUEST", "invalid voice_ice payload"))
|
||||
return
|
||||
}
|
||||
|
||||
slog.Debug("client ICE candidate received",
|
||||
"user_id", c.userID,
|
||||
"candidate", p.Candidate.Candidate)
|
||||
if err := pc.AddICECandidate(p.Candidate); err != nil {
|
||||
slog.Error("ws handleVoiceICE AddICECandidate", "err", err, "user_id", c.userID)
|
||||
c.sendMsg(buildErrorMsg("VOICE_ERROR", "failed to add ICE candidate"))
|
||||
return
|
||||
}
|
||||
slog.Debug("client ICE candidate added", "user_id", c.userID)
|
||||
}
|
||||
|
||||
// handleSoundboard processes a soundboard_play message.
|
||||
// 1. Rate limits at 1 per 3 seconds.
|
||||
// 2. Checks USE_SOUNDBOARD permission.
|
||||
// 3. Broadcasts soundboard_play (with user_id) to all connected clients.
|
||||
func (h *Hub) handleSoundboard(c *Client, payload json.RawMessage) {
|
||||
ratKey := fmt.Sprintf("soundboard:%d", c.userID)
|
||||
if !h.limiter.Allow(ratKey, soundboardRateLimit, soundboardWindow) {
|
||||
c.sendMsg(buildErrorMsg("RATE_LIMITED", "soundboard is on cooldown"))
|
||||
return
|
||||
}
|
||||
|
||||
// channelID=0: soundboard is a server-wide permission with no per-channel
|
||||
// override. The client does not send a channel_id in the payload.
|
||||
if !h.requireChannelPerm(c, 0, permissions.UseSoundboard, "USE_SOUNDBOARD") {
|
||||
return
|
||||
}
|
||||
|
||||
var p struct {
|
||||
SoundID string `json:"sound_id"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &p); err != nil || p.SoundID == "" {
|
||||
c.sendMsg(buildErrorMsg("BAD_REQUEST", "sound_id is required"))
|
||||
return
|
||||
}
|
||||
|
||||
h.BroadcastToAll(buildSoundboardPlay(p.SoundID, c.userID))
|
||||
}
|
||||
|
||||
// setupOnTrack configures the PeerConnection's OnTrack handler to:
|
||||
// 1. Create a TrackLocalStaticRTP for SFU fan-out.
|
||||
// 2. Store it on the VoiceRoom as a VoiceTrack.
|
||||
// 3. Add the local track to all other participants' PCs and renegotiate.
|
||||
// 4. Forward RTP packets while parsing audio levels for speaker detection.
|
||||
//
|
||||
// Must be called after c.pc is set and before SDP negotiation completes.
|
||||
func (h *Hub) setupOnTrack(c *Client, channelID int64) {
|
||||
pc := c.getPC()
|
||||
if pc == nil {
|
||||
return
|
||||
}
|
||||
|
||||
pc.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) {
|
||||
// Determine kind from the remote track.
|
||||
var kind string
|
||||
switch track.Kind() {
|
||||
case webrtc.RTPCodecTypeAudio:
|
||||
kind = "audio"
|
||||
case webrtc.RTPCodecTypeVideo:
|
||||
kind = "video"
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("SFU OnTrack",
|
||||
"user_id", c.userID,
|
||||
"channel_id", channelID,
|
||||
"kind", kind,
|
||||
"codec", track.Codec().MimeType,
|
||||
)
|
||||
|
||||
// Create local track for fan-out using the remote track's codec.
|
||||
local, err := webrtc.NewTrackLocalStaticRTP(
|
||||
track.Codec().RTPCodecCapability,
|
||||
fmt.Sprintf("%s-%d", kind, c.userID),
|
||||
fmt.Sprintf("user-%d-%s", c.userID, kind),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("setupOnTrack NewTrackLocalStaticRTP",
|
||||
"err", err, "user_id", c.userID, "kind", kind)
|
||||
return
|
||||
}
|
||||
|
||||
room := h.GetVoiceRoom(channelID)
|
||||
if room == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Store track on room.
|
||||
room.SetTrack(c.userID, kind, track, local)
|
||||
vt := room.GetTrack(c.userID, kind)
|
||||
|
||||
// Collect other participant IDs (lock ordering: VoiceRoom.mu released before voiceMu).
|
||||
participantIDs := room.ParticipantIDs()
|
||||
|
||||
// Add local track to each other participant's PC.
|
||||
addedCount := 0
|
||||
for _, pid := range participantIDs {
|
||||
if pid == c.userID {
|
||||
continue
|
||||
}
|
||||
other := h.GetClient(pid)
|
||||
if other == nil {
|
||||
slog.Debug("setupOnTrack: participant not found", "from", c.userID, "to", pid)
|
||||
continue
|
||||
}
|
||||
otherPC := other.getPC()
|
||||
if otherPC == nil {
|
||||
slog.Debug("setupOnTrack: participant has no PC", "from", c.userID, "to", pid)
|
||||
continue
|
||||
}
|
||||
sender, addErr := otherPC.AddTrack(local)
|
||||
if addErr != nil {
|
||||
slog.Error("setupOnTrack AddTrack",
|
||||
"err", addErr,
|
||||
"from", c.userID, "to", pid)
|
||||
continue
|
||||
}
|
||||
if vt != nil {
|
||||
vt.AddSender(pid, sender)
|
||||
}
|
||||
addedCount++
|
||||
slog.Debug("setupOnTrack track added to subscriber",
|
||||
"from", c.userID, "to", pid, "kind", kind)
|
||||
h.renegotiateParticipant(other)
|
||||
}
|
||||
slog.Info("SFU track fan-out",
|
||||
"from_user", c.userID,
|
||||
"channel_id", channelID,
|
||||
"kind", kind,
|
||||
"participants", len(participantIDs),
|
||||
"tracks_added", addedCount)
|
||||
|
||||
// Log transceiver state on each subscriber's PC for this track
|
||||
for _, pid := range participantIDs {
|
||||
if pid == c.userID {
|
||||
continue
|
||||
}
|
||||
other := h.GetClient(pid)
|
||||
if other == nil {
|
||||
continue
|
||||
}
|
||||
otherPC := other.getPC()
|
||||
if otherPC == nil {
|
||||
continue
|
||||
}
|
||||
for _, tr := range otherPC.GetTransceivers() {
|
||||
if tr.Sender() != nil && tr.Sender().Track() != nil &&
|
||||
tr.Sender().Track().StreamID() == fmt.Sprintf("user-%d-%s", c.userID, kind) {
|
||||
slog.Info("subscriber transceiver state",
|
||||
"subscriber", pid,
|
||||
"track_from", c.userID,
|
||||
"direction", tr.Direction().String(),
|
||||
"mid", tr.Mid(),
|
||||
"sender_track_id", tr.Sender().Track().ID(),
|
||||
"sender_track_stream", tr.Sender().Track().StreamID())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RTP forwarding + audio level goroutine.
|
||||
// Capture the done channel so this goroutine exits even if PC.Close fails.
|
||||
done := c.getVoiceDone()
|
||||
go func() {
|
||||
buf := make([]byte, 1500)
|
||||
var pktCount uint64
|
||||
|
||||
// Warn if no RTP packets arrive within 5 seconds
|
||||
noPacketTimer := time.AfterFunc(5*time.Second, func() {
|
||||
slog.Warn("RTP: no packets received after 5s",
|
||||
"user_id", c.userID,
|
||||
"channel_id", channelID,
|
||||
"kind", kind)
|
||||
})
|
||||
defer noPacketTimer.Stop()
|
||||
|
||||
for {
|
||||
// Check if voice session was torn down.
|
||||
select {
|
||||
case <-done:
|
||||
slog.Info("RTP goroutine exiting via done signal",
|
||||
"user_id", c.userID, "channel_id", channelID,
|
||||
"kind", kind,
|
||||
"packets_forwarded", pktCount)
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
n, _, readErr := track.Read(buf)
|
||||
if readErr != nil {
|
||||
slog.Info("RTP read ended",
|
||||
"user_id", c.userID,
|
||||
"channel_id", channelID,
|
||||
"kind", kind,
|
||||
"packets_forwarded", pktCount,
|
||||
"err", readErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Forward RTP to local track (Pion fans out to all subscribers).
|
||||
if _, writeErr := local.Write(buf[:n]); writeErr != nil {
|
||||
slog.Info("RTP write ended",
|
||||
"user_id", c.userID,
|
||||
"channel_id", channelID,
|
||||
"kind", kind,
|
||||
"packets_forwarded", pktCount,
|
||||
"err", writeErr.Error())
|
||||
return
|
||||
}
|
||||
pktCount++
|
||||
if pktCount == 1 {
|
||||
noPacketTimer.Stop()
|
||||
slog.Info("RTP first packet received",
|
||||
"user_id", c.userID,
|
||||
"channel_id", channelID,
|
||||
"kind", kind,
|
||||
"bytes", n)
|
||||
} else if pktCount%1000 == 0 {
|
||||
slog.Info("RTP forwarding",
|
||||
"user_id", c.userID,
|
||||
"channel_id", channelID,
|
||||
"kind", kind,
|
||||
"packets", pktCount)
|
||||
}
|
||||
|
||||
// Speaker detection only applies to audio tracks.
|
||||
if kind != "audio" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract audio level directly from raw RTP bytes (avoids full Unmarshal).
|
||||
level, ok := extractAudioLevel(buf, n)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
currentRoom := h.GetVoiceRoom(channelID)
|
||||
if currentRoom == nil {
|
||||
return
|
||||
}
|
||||
currentRoom.UpdateSpeakerLevel(c.userID, level)
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
// broadcastVoiceStateUpdate fetches the current voice state for the client
|
||||
// and broadcasts it to all members of the voice channel they are in.
|
||||
func (h *Hub) broadcastVoiceStateUpdate(c *Client) {
|
||||
|
||||
+151
-871
File diff suppressed because it is too large
Load Diff
@@ -1,346 +0,0 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
// trackKey builds a composite map key for a user's track of a given kind.
|
||||
func trackKey(userID int64, kind string) string {
|
||||
return fmt.Sprintf("%d-%s", userID, kind)
|
||||
}
|
||||
|
||||
// ErrRoomFull is returned when attempting to add a participant to a full voice room.
|
||||
var ErrRoomFull = errors.New("voice room is full")
|
||||
|
||||
// VoiceTrack pairs an incoming remote track with its local fan-out track.
|
||||
type VoiceTrack struct {
|
||||
UserID int64
|
||||
Remote *webrtc.TrackRemote
|
||||
Local *webrtc.TrackLocalStaticRTP
|
||||
senderMu sync.RWMutex
|
||||
Senders map[int64]*webrtc.RTPSender // subscriber userID -> sender
|
||||
}
|
||||
|
||||
// AddSender records a subscriber's RTPSender (thread-safe).
|
||||
func (vt *VoiceTrack) AddSender(userID int64, s *webrtc.RTPSender) {
|
||||
vt.senderMu.Lock()
|
||||
defer vt.senderMu.Unlock()
|
||||
vt.Senders[userID] = s
|
||||
}
|
||||
|
||||
// RemoveSender removes and returns a subscriber's RTPSender.
|
||||
func (vt *VoiceTrack) RemoveSender(userID int64) *webrtc.RTPSender {
|
||||
vt.senderMu.Lock()
|
||||
defer vt.senderMu.Unlock()
|
||||
s := vt.Senders[userID]
|
||||
delete(vt.Senders, userID)
|
||||
return s
|
||||
}
|
||||
|
||||
// CopySenders returns a snapshot of the senders map for iteration.
|
||||
func (vt *VoiceTrack) CopySenders() map[int64]*webrtc.RTPSender {
|
||||
vt.senderMu.RLock()
|
||||
defer vt.senderMu.RUnlock()
|
||||
cp := make(map[int64]*webrtc.RTPSender, len(vt.Senders))
|
||||
for k, v := range vt.Senders {
|
||||
cp[k] = v
|
||||
}
|
||||
return cp
|
||||
}
|
||||
|
||||
// VoiceParticipant represents one user in a voice room.
|
||||
type VoiceParticipant struct {
|
||||
UserID int64
|
||||
JoinedAt time.Time
|
||||
}
|
||||
|
||||
// VoiceRoomConfig holds per-room configuration derived from channel settings and server defaults.
|
||||
type VoiceRoomConfig struct {
|
||||
ChannelID int64
|
||||
MaxUsers int // 0 = unlimited
|
||||
Quality string // low|medium|high
|
||||
MixingThreshold int // forwarding → selective threshold
|
||||
TopSpeakers int // N for top-N selection
|
||||
MaxVideo int // max simultaneous video streams
|
||||
}
|
||||
|
||||
// VoiceRoom manages voice participants for a single channel.
|
||||
// It does NOT hold PeerConnections yet — those come in Phase 3/4.
|
||||
type VoiceRoom struct {
|
||||
config VoiceRoomConfig
|
||||
participants map[int64]*VoiceParticipant
|
||||
tracks map[string]*VoiceTrack
|
||||
mode string // "forwarding" or "selective"
|
||||
detector *SpeakerDetector
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewVoiceRoom creates a new voice room in "forwarding" mode.
|
||||
func NewVoiceRoom(cfg VoiceRoomConfig) *VoiceRoom {
|
||||
topN := cfg.TopSpeakers
|
||||
if topN <= 0 {
|
||||
topN = 3
|
||||
}
|
||||
slog.Info("voice room created",
|
||||
"channel_id", cfg.ChannelID,
|
||||
"max_users", cfg.MaxUsers,
|
||||
"quality", cfg.Quality,
|
||||
"mixing_threshold", cfg.MixingThreshold,
|
||||
"max_video", cfg.MaxVideo)
|
||||
return &VoiceRoom{
|
||||
config: cfg,
|
||||
participants: make(map[int64]*VoiceParticipant),
|
||||
tracks: make(map[string]*VoiceTrack),
|
||||
mode: "forwarding",
|
||||
detector: NewSpeakerDetector(topN),
|
||||
}
|
||||
}
|
||||
|
||||
// AddParticipant adds a user to the voice room. Returns ErrRoomFull if
|
||||
// MaxUsers > 0 and the room is already at capacity. Adding a duplicate
|
||||
// user ID is a no-op.
|
||||
func (r *VoiceRoom) AddParticipant(userID int64) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
// Duplicate check — already present, nothing to do.
|
||||
if _, exists := r.participants[userID]; exists {
|
||||
slog.Debug("voice room participant already present",
|
||||
"channel_id", r.config.ChannelID, "user_id", userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
if r.config.MaxUsers > 0 && len(r.participants) >= r.config.MaxUsers {
|
||||
return ErrRoomFull
|
||||
}
|
||||
|
||||
r.participants[userID] = &VoiceParticipant{
|
||||
UserID: userID,
|
||||
JoinedAt: time.Now(),
|
||||
}
|
||||
|
||||
slog.Info("voice room participant added",
|
||||
"channel_id", r.config.ChannelID,
|
||||
"user_id", userID,
|
||||
"participants", len(r.participants))
|
||||
|
||||
r.updateMode()
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveParticipant removes a user from the voice room. No-op if the user
|
||||
// is not present.
|
||||
func (r *VoiceRoom) RemoveParticipant(userID int64) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, exists := r.participants[userID]; !exists {
|
||||
return
|
||||
}
|
||||
|
||||
delete(r.participants, userID)
|
||||
r.detector.RemoveSpeaker(userID)
|
||||
slog.Info("voice room participant removed",
|
||||
"channel_id", r.config.ChannelID,
|
||||
"user_id", userID,
|
||||
"participants", len(r.participants))
|
||||
r.updateMode()
|
||||
}
|
||||
|
||||
// ParticipantCount returns the number of participants (thread-safe).
|
||||
func (r *VoiceRoom) ParticipantCount() int {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return len(r.participants)
|
||||
}
|
||||
|
||||
// IsEmpty returns true if the room has no participants.
|
||||
func (r *VoiceRoom) IsEmpty() bool {
|
||||
return r.ParticipantCount() == 0
|
||||
}
|
||||
|
||||
// Mode returns the current mixing mode ("forwarding" or "selective").
|
||||
func (r *VoiceRoom) Mode() string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.mode
|
||||
}
|
||||
|
||||
// ParticipantIDs returns a slice of all participant user IDs.
|
||||
func (r *VoiceRoom) ParticipantIDs() []int64 {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
ids := make([]int64, 0, len(r.participants))
|
||||
for id := range r.participants {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// HasParticipant checks whether the given user is in the room.
|
||||
func (r *VoiceRoom) HasParticipant(userID int64) bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
_, exists := r.participants[userID]
|
||||
return exists
|
||||
}
|
||||
|
||||
// Close clears all participants and tracks from the room.
|
||||
func (r *VoiceRoom) Close() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
slog.Info("voice room closing",
|
||||
"channel_id", r.config.ChannelID,
|
||||
"participants", len(r.participants),
|
||||
"tracks", len(r.tracks))
|
||||
r.participants = make(map[int64]*VoiceParticipant)
|
||||
r.tracks = make(map[string]*VoiceTrack)
|
||||
r.mode = "forwarding"
|
||||
}
|
||||
|
||||
// SetTrack stores a VoiceTrack for the given user and kind (replaces any existing one).
|
||||
func (r *VoiceRoom) SetTrack(userID int64, kind string, remote *webrtc.TrackRemote, local *webrtc.TrackLocalStaticRTP) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
key := trackKey(userID, kind)
|
||||
_, replaced := r.tracks[key]
|
||||
r.tracks[key] = &VoiceTrack{
|
||||
UserID: userID,
|
||||
Remote: remote,
|
||||
Local: local,
|
||||
Senders: make(map[int64]*webrtc.RTPSender),
|
||||
}
|
||||
codec := ""
|
||||
if remote != nil {
|
||||
codec = remote.Codec().MimeType
|
||||
}
|
||||
slog.Debug("voice room track set",
|
||||
"channel_id", r.config.ChannelID,
|
||||
"user_id", userID,
|
||||
"kind", kind,
|
||||
"replaced", replaced,
|
||||
"codec", codec,
|
||||
"total_tracks", len(r.tracks))
|
||||
}
|
||||
|
||||
// RemoveTrack removes and returns the VoiceTrack for the given user and kind.
|
||||
// Returns nil if no track exists for that user/kind.
|
||||
func (r *VoiceRoom) RemoveTrack(userID int64, kind string) *VoiceTrack {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
key := trackKey(userID, kind)
|
||||
vt, ok := r.tracks[key]
|
||||
if ok {
|
||||
delete(r.tracks, key)
|
||||
slog.Debug("voice room track removed",
|
||||
"channel_id", r.config.ChannelID,
|
||||
"user_id", userID,
|
||||
"kind", kind,
|
||||
"remaining_tracks", len(r.tracks))
|
||||
}
|
||||
return vt
|
||||
}
|
||||
|
||||
// GetTracks returns a snapshot of all current tracks.
|
||||
func (r *VoiceRoom) GetTracks() []*VoiceTrack {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
result := make([]*VoiceTrack, 0, len(r.tracks))
|
||||
for _, vt := range r.tracks {
|
||||
result = append(result, vt)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// TrackUserIDs returns the deduplicated user IDs of all users that have an active track.
|
||||
func (r *VoiceRoom) TrackUserIDs() []int64 {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
seen := make(map[int64]struct{})
|
||||
for _, vt := range r.tracks {
|
||||
seen[vt.UserID] = struct{}{}
|
||||
}
|
||||
ids := make([]int64, 0, len(seen))
|
||||
for id := range seen {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// GetTrack returns the VoiceTrack for the given user and kind, or nil if not present.
|
||||
func (r *VoiceRoom) GetTrack(userID int64, kind string) *VoiceTrack {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.tracks[trackKey(userID, kind)]
|
||||
}
|
||||
|
||||
// GetUserTracks returns all tracks belonging to the given user.
|
||||
func (r *VoiceRoom) GetUserTracks(userID int64) []*VoiceTrack {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
prefix := fmt.Sprintf("%d-", userID)
|
||||
var result []*VoiceTrack
|
||||
for key, vt := range r.tracks {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
result = append(result, vt)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// UpdateSpeakerLevel updates the audio level for a user in this room's detector.
|
||||
// level is the raw RFC 6464 dBov value: 0 = loudest, 127 = silence.
|
||||
func (r *VoiceRoom) UpdateSpeakerLevel(userID int64, level uint8) {
|
||||
r.detector.UpdateLevel(userID, level)
|
||||
}
|
||||
|
||||
// TopSpeakers returns the current top-N active speakers for this room.
|
||||
func (r *VoiceRoom) TopSpeakers() []int64 {
|
||||
return r.detector.TopSpeakers()
|
||||
}
|
||||
|
||||
// Config returns a copy of the room's configuration.
|
||||
func (r *VoiceRoom) Config() VoiceRoomConfig {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.config
|
||||
}
|
||||
|
||||
// updateMode checks participant count vs threshold with ±2 hysteresis.
|
||||
// Must be called with r.mu held.
|
||||
func (r *VoiceRoom) updateMode() {
|
||||
count := len(r.participants)
|
||||
threshold := r.config.MixingThreshold
|
||||
|
||||
if threshold <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
oldMode := r.mode
|
||||
switch r.mode {
|
||||
case "forwarding":
|
||||
if count >= threshold {
|
||||
r.mode = "selective"
|
||||
}
|
||||
case "selective":
|
||||
if count <= threshold-2 {
|
||||
r.mode = "forwarding"
|
||||
}
|
||||
}
|
||||
if r.mode != oldMode {
|
||||
slog.Info("voice room mode changed",
|
||||
"channel_id", r.config.ChannelID,
|
||||
"old_mode", oldMode,
|
||||
"new_mode", r.mode,
|
||||
"participants", count,
|
||||
"threshold", threshold)
|
||||
}
|
||||
}
|
||||
@@ -1,346 +0,0 @@
|
||||
package ws_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/owncord/server/ws"
|
||||
)
|
||||
|
||||
func defaultRoomConfig() ws.VoiceRoomConfig {
|
||||
return ws.VoiceRoomConfig{
|
||||
ChannelID: 1,
|
||||
MaxUsers: 0,
|
||||
Quality: "medium",
|
||||
MixingThreshold: 5,
|
||||
TopSpeakers: 3,
|
||||
MaxVideo: 4,
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewVoiceRoom(t *testing.T) {
|
||||
cfg := defaultRoomConfig()
|
||||
room := ws.NewVoiceRoom(cfg)
|
||||
|
||||
if room.Mode() != "forwarding" {
|
||||
t.Errorf("NewVoiceRoom() mode = %q, want %q", room.Mode(), "forwarding")
|
||||
}
|
||||
if !room.IsEmpty() {
|
||||
t.Error("NewVoiceRoom() should be empty")
|
||||
}
|
||||
if room.ParticipantCount() != 0 {
|
||||
t.Errorf("NewVoiceRoom() count = %d, want 0", room.ParticipantCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceRoom_AddParticipant(t *testing.T) {
|
||||
room := ws.NewVoiceRoom(defaultRoomConfig())
|
||||
|
||||
if err := room.AddParticipant(100); err != nil {
|
||||
t.Fatalf("AddParticipant(100) returned error: %v", err)
|
||||
}
|
||||
if err := room.AddParticipant(200); err != nil {
|
||||
t.Fatalf("AddParticipant(200) returned error: %v", err)
|
||||
}
|
||||
|
||||
if room.ParticipantCount() != 2 {
|
||||
t.Errorf("ParticipantCount() = %d, want 2", room.ParticipantCount())
|
||||
}
|
||||
if room.IsEmpty() {
|
||||
t.Error("room should not be empty after adding participants")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceRoom_AddParticipant_Full(t *testing.T) {
|
||||
cfg := defaultRoomConfig()
|
||||
cfg.MaxUsers = 2
|
||||
room := ws.NewVoiceRoom(cfg)
|
||||
|
||||
if err := room.AddParticipant(1); err != nil {
|
||||
t.Fatalf("AddParticipant(1) returned error: %v", err)
|
||||
}
|
||||
if err := room.AddParticipant(2); err != nil {
|
||||
t.Fatalf("AddParticipant(2) returned error: %v", err)
|
||||
}
|
||||
|
||||
err := room.AddParticipant(3)
|
||||
if err == nil {
|
||||
t.Fatal("AddParticipant(3) should return error when room is full")
|
||||
}
|
||||
if !errors.Is(err, ws.ErrRoomFull) {
|
||||
t.Errorf("error = %v, want ErrRoomFull", err)
|
||||
}
|
||||
if room.ParticipantCount() != 2 {
|
||||
t.Errorf("ParticipantCount() = %d, want 2 (third should not be added)", room.ParticipantCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceRoom_AddParticipant_Unlimited(t *testing.T) {
|
||||
cfg := defaultRoomConfig()
|
||||
cfg.MaxUsers = 0
|
||||
room := ws.NewVoiceRoom(cfg)
|
||||
|
||||
for i := int64(1); i <= 50; i++ {
|
||||
if err := room.AddParticipant(i); err != nil {
|
||||
t.Fatalf("AddParticipant(%d) returned error: %v", i, err)
|
||||
}
|
||||
}
|
||||
if room.ParticipantCount() != 50 {
|
||||
t.Errorf("ParticipantCount() = %d, want 50", room.ParticipantCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceRoom_RemoveParticipant(t *testing.T) {
|
||||
room := ws.NewVoiceRoom(defaultRoomConfig())
|
||||
_ = room.AddParticipant(1)
|
||||
_ = room.AddParticipant(2)
|
||||
_ = room.AddParticipant(3)
|
||||
|
||||
room.RemoveParticipant(2)
|
||||
|
||||
if room.ParticipantCount() != 2 {
|
||||
t.Errorf("ParticipantCount() = %d, want 2", room.ParticipantCount())
|
||||
}
|
||||
if room.HasParticipant(2) {
|
||||
t.Error("HasParticipant(2) = true after removal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceRoom_RemoveParticipant_NotPresent(t *testing.T) {
|
||||
room := ws.NewVoiceRoom(defaultRoomConfig())
|
||||
_ = room.AddParticipant(1)
|
||||
|
||||
// Should not panic.
|
||||
room.RemoveParticipant(999)
|
||||
|
||||
if room.ParticipantCount() != 1 {
|
||||
t.Errorf("ParticipantCount() = %d, want 1", room.ParticipantCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceRoom_HasParticipant(t *testing.T) {
|
||||
room := ws.NewVoiceRoom(defaultRoomConfig())
|
||||
_ = room.AddParticipant(42)
|
||||
|
||||
if !room.HasParticipant(42) {
|
||||
t.Error("HasParticipant(42) = false, want true")
|
||||
}
|
||||
if room.HasParticipant(99) {
|
||||
t.Error("HasParticipant(99) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceRoom_ParticipantIDs(t *testing.T) {
|
||||
room := ws.NewVoiceRoom(defaultRoomConfig())
|
||||
_ = room.AddParticipant(10)
|
||||
_ = room.AddParticipant(20)
|
||||
_ = room.AddParticipant(30)
|
||||
|
||||
ids := room.ParticipantIDs()
|
||||
if len(ids) != 3 {
|
||||
t.Fatalf("ParticipantIDs() returned %d IDs, want 3", len(ids))
|
||||
}
|
||||
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
want := []int64{10, 20, 30}
|
||||
for i, id := range ids {
|
||||
if id != want[i] {
|
||||
t.Errorf("ParticipantIDs()[%d] = %d, want %d", i, id, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceRoom_Mode_ForwardingToSelective(t *testing.T) {
|
||||
cfg := defaultRoomConfig()
|
||||
cfg.MixingThreshold = 3
|
||||
room := ws.NewVoiceRoom(cfg)
|
||||
|
||||
_ = room.AddParticipant(1)
|
||||
_ = room.AddParticipant(2)
|
||||
if room.Mode() != "forwarding" {
|
||||
t.Errorf("mode after 2 users = %q, want %q", room.Mode(), "forwarding")
|
||||
}
|
||||
|
||||
_ = room.AddParticipant(3)
|
||||
if room.Mode() != "selective" {
|
||||
t.Errorf("mode after 3 users (threshold=3) = %q, want %q", room.Mode(), "selective")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceRoom_Mode_SelectiveToForwarding_Hysteresis(t *testing.T) {
|
||||
cfg := defaultRoomConfig()
|
||||
cfg.MixingThreshold = 5
|
||||
room := ws.NewVoiceRoom(cfg)
|
||||
|
||||
// Add 5 participants to trigger selective mode.
|
||||
for i := int64(1); i <= 5; i++ {
|
||||
_ = room.AddParticipant(i)
|
||||
}
|
||||
if room.Mode() != "selective" {
|
||||
t.Fatalf("mode after 5 users (threshold=5) = %q, want %q", room.Mode(), "selective")
|
||||
}
|
||||
|
||||
// Remove 1: count=4, still selective (4 > 5-2=3).
|
||||
room.RemoveParticipant(5)
|
||||
if room.Mode() != "selective" {
|
||||
t.Errorf("mode at count=4 should still be %q (hysteresis), got %q", "selective", room.Mode())
|
||||
}
|
||||
|
||||
// Remove 1 more: count=3, 3 <= 5-2=3 → switch to forwarding.
|
||||
room.RemoveParticipant(4)
|
||||
if room.Mode() != "forwarding" {
|
||||
t.Errorf("mode at count=3 should be %q (3 <= threshold-2=3), got %q", "forwarding", room.Mode())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceRoom_Close(t *testing.T) {
|
||||
room := ws.NewVoiceRoom(defaultRoomConfig())
|
||||
_ = room.AddParticipant(1)
|
||||
_ = room.AddParticipant(2)
|
||||
|
||||
room.Close()
|
||||
|
||||
if !room.IsEmpty() {
|
||||
t.Error("room should be empty after Close()")
|
||||
}
|
||||
if room.ParticipantCount() != 0 {
|
||||
t.Errorf("ParticipantCount() = %d after Close(), want 0", room.ParticipantCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceRoom_Concurrent(t *testing.T) {
|
||||
cfg := defaultRoomConfig()
|
||||
cfg.MaxUsers = 0
|
||||
room := ws.NewVoiceRoom(cfg)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 50
|
||||
|
||||
// Add participants concurrently.
|
||||
for i := int64(1); i <= goroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int64) {
|
||||
defer wg.Done()
|
||||
_ = room.AddParticipant(id)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if room.ParticipantCount() != goroutines {
|
||||
t.Errorf("ParticipantCount() = %d after concurrent adds, want %d", room.ParticipantCount(), goroutines)
|
||||
}
|
||||
|
||||
// Remove participants concurrently.
|
||||
for i := int64(1); i <= goroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int64) {
|
||||
defer wg.Done()
|
||||
room.RemoveParticipant(id)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if !room.IsEmpty() {
|
||||
t.Errorf("room should be empty after concurrent removes, count = %d", room.ParticipantCount())
|
||||
}
|
||||
|
||||
// Mix add/remove concurrently.
|
||||
for i := int64(1); i <= goroutines; i++ {
|
||||
wg.Add(2)
|
||||
go func(id int64) {
|
||||
defer wg.Done()
|
||||
_ = room.AddParticipant(id)
|
||||
}(i)
|
||||
go func(id int64) {
|
||||
defer wg.Done()
|
||||
room.RemoveParticipant(id)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Just verify no panic and count is non-negative.
|
||||
if room.ParticipantCount() < 0 {
|
||||
t.Errorf("ParticipantCount() = %d, should not be negative", room.ParticipantCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceRoom_AddParticipant_Duplicate(t *testing.T) {
|
||||
room := ws.NewVoiceRoom(defaultRoomConfig())
|
||||
_ = room.AddParticipant(1)
|
||||
_ = room.AddParticipant(1) // duplicate
|
||||
|
||||
// Should not double-count.
|
||||
if room.ParticipantCount() != 1 {
|
||||
t.Errorf("ParticipantCount() = %d after duplicate add, want 1", room.ParticipantCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceRoom_AddTrack(t *testing.T) {
|
||||
room := ws.NewVoiceRoom(defaultRoomConfig())
|
||||
_ = room.AddParticipant(100)
|
||||
room.SetTrack(100, "audio", nil, nil)
|
||||
tracks := room.GetTracks()
|
||||
if len(tracks) != 1 {
|
||||
t.Fatalf("GetTracks() len = %d, want 1", len(tracks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceRoom_RemoveTrack(t *testing.T) {
|
||||
room := ws.NewVoiceRoom(defaultRoomConfig())
|
||||
_ = room.AddParticipant(100)
|
||||
room.SetTrack(100, "audio", nil, nil)
|
||||
vt := room.RemoveTrack(100, "audio")
|
||||
if vt == nil {
|
||||
t.Fatal("RemoveTrack returned nil")
|
||||
}
|
||||
tracks := room.GetTracks()
|
||||
if len(tracks) != 0 {
|
||||
t.Fatalf("GetTracks() after remove len = %d, want 0", len(tracks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceRoom_GetTrackUserIDs(t *testing.T) {
|
||||
room := ws.NewVoiceRoom(defaultRoomConfig())
|
||||
_ = room.AddParticipant(100)
|
||||
_ = room.AddParticipant(200)
|
||||
room.SetTrack(100, "audio", nil, nil)
|
||||
room.SetTrack(200, "audio", nil, nil)
|
||||
ids := room.TrackUserIDs()
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
if len(ids) != 2 || ids[0] != 100 || ids[1] != 200 {
|
||||
t.Fatalf("TrackUserIDs() = %v, want [100 200]", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceRoom_Close_ClearsTracks(t *testing.T) {
|
||||
room := ws.NewVoiceRoom(defaultRoomConfig())
|
||||
_ = room.AddParticipant(100)
|
||||
room.SetTrack(100, "audio", nil, nil)
|
||||
room.Close()
|
||||
if len(room.GetTracks()) != 0 {
|
||||
t.Fatal("Close() should clear tracks")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceTrack_AddRemoveSender(t *testing.T) {
|
||||
room := ws.NewVoiceRoom(defaultRoomConfig())
|
||||
_ = room.AddParticipant(100)
|
||||
room.SetTrack(100, "audio", nil, nil)
|
||||
vt := room.GetTrack(100, "audio")
|
||||
if vt == nil {
|
||||
t.Fatal("GetTrack returned nil")
|
||||
}
|
||||
// AddSender with nil (unit test, no real sender)
|
||||
vt.AddSender(200, nil)
|
||||
senders := vt.CopySenders()
|
||||
if len(senders) != 1 {
|
||||
t.Fatalf("CopySenders len = %d, want 1", len(senders))
|
||||
}
|
||||
vt.RemoveSender(200)
|
||||
senders = vt.CopySenders()
|
||||
if len(senders) != 0 {
|
||||
t.Fatalf("CopySenders after remove len = %d, want 0", len(senders))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user