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:
jevb
2026-03-20 05:30:27 +01:00
parent 80e880d536
commit 72d5412ba7
26 changed files with 329 additions and 5434 deletions
+15 -8
View File
@@ -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()
-136
View File
@@ -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
}
-344
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+1 -407
View File
@@ -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")
-20
View File
@@ -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)
}
-8
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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")
}
}
-50
View File
@@ -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"])
}
}
-455
View File
@@ -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
}
-83
View File
@@ -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
}
-150
View File
@@ -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")
}
}
-102
View File
@@ -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
}
}
-106
View File
@@ -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)
}
}
-79
View File
@@ -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)
}
-164
View File
@@ -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
}
-293
View File
@@ -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")
}
}
-373
View File
@@ -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
View File
@@ -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) {
File diff suppressed because it is too large Load Diff
-346
View File
@@ -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)
}
}
-346
View File
@@ -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))
}
}