Squashed buildout of the tgcalls testbench: - CLI test tool with --mode p2p/reflector/group/group-churn, cross-version interop (--version, --version2), and quiet/summary output - Linux toolchain + Docker multi-stage build, AWS Fargate mass test harness, local parallel mass test harness with signaling loss simulation - SCTP writable gate, retransmission timer tuning, role-based handshake - InstanceV2CompatImpl (PeerConnection backend with V2Impl signaling) and SignalingTranslator for v14.0.0 interop - In-process Go/Pion SFU (ICE+DTLS+SRTP+SCTP per participant) with audio RTP forwarding, ActiveAudio/VideoSsrcs data channel broadcast, RTCP feedback path, and CGo c-archive integration - GroupInstanceReferenceImpl (PeerConnection group-call) and mixed-impl group mode (--reference-participants), with SDP munging for simulcast - H264 simulcast group video (FakeVideoTrackSource pattern generator, FakeVideoSink frame counting, --video flag, two-pass channel setup, reactive video setup from ActiveVideoSsrcs) - Group churn stress mode (--mode group-churn, --churn-cycles) - SFU stream-quality adaptation: BandwidthEstimator, LayerSelector state machine, RtxRingBuffer, simulcast SSRC rewrite - Transport-cc feedback generation, NetworkSimulator (delay/jitter/loss/ token-bucket bandwidth), --network-scenario step-down-up - CLAUDE.md updates throughout Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1240 lines
33 KiB
Go
1240 lines
33 KiB
Go
package main
|
|
|
|
/*
|
|
#include <stdlib.h>
|
|
*/
|
|
import "C"
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
"unsafe"
|
|
|
|
"github.com/pion/ice/v4"
|
|
"github.com/pion/logging"
|
|
"github.com/pion/rtcp"
|
|
)
|
|
|
|
// --- JSON types for tgcalls join protocol ---
|
|
|
|
type joinPayload struct {
|
|
SSRC int32 `json:"ssrc"` // signed in JSON, cast to uint32
|
|
Ufrag string `json:"ufrag"`
|
|
Pwd string `json:"pwd"`
|
|
Fingerprints []fingerprintJSON `json:"fingerprints"`
|
|
SSRCGroups []ssrcGroupJSON `json:"ssrc-groups"`
|
|
}
|
|
|
|
type ssrcGroupJSON struct {
|
|
Semantics string `json:"semantics"` // "SIM" or "FID"
|
|
Sources []int32 `json:"sources"` // tgcalls serializes as "sources", not "ssrcs"
|
|
}
|
|
|
|
// ssrcInfo identifies the owner and type of an SSRC in the registry.
|
|
type ssrcInfo struct {
|
|
participantID int
|
|
kind string // "audio", "video", "video-rtx"
|
|
layer int // -1 for audio, 0/1/2 for video simulcast layers
|
|
}
|
|
|
|
// SimulcastLayer holds the primary and RTX SSRCs for one simulcast layer.
|
|
type SimulcastLayer struct {
|
|
SSRC uint32
|
|
FidSSRC uint32
|
|
}
|
|
|
|
type fingerprintJSON struct {
|
|
Hash string `json:"hash"`
|
|
Fingerprint string `json:"fingerprint"`
|
|
Setup string `json:"setup"`
|
|
}
|
|
|
|
type joinResponse struct {
|
|
Transport transportJSON `json:"transport"`
|
|
Video *videoResponseJSON `json:"video,omitempty"`
|
|
}
|
|
|
|
type videoResponseJSON struct {
|
|
Endpoint string `json:"endpoint"`
|
|
ServerSSRCs []videoServerSSRC `json:"server_ssrcs,omitempty"`
|
|
PayloadTypes []videoPayloadTypeJSON `json:"payload-types"`
|
|
RTPHdrexts []rtpHdrextJSON `json:"rtp-hdrexts"`
|
|
}
|
|
|
|
type videoServerSSRC struct {
|
|
SSRC int32 `json:"ssrc"`
|
|
Groups []ssrcGroupJSON `json:"ssrc-groups,omitempty"`
|
|
}
|
|
|
|
type videoPayloadTypeJSON struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
Clockrate int `json:"clockrate"`
|
|
Channels int `json:"channels,omitempty"`
|
|
Parameters map[string]string `json:"parameters,omitempty"`
|
|
Feedback []rtcpFeedbackJSON `json:"rtcp-fbs,omitempty"`
|
|
}
|
|
|
|
type rtcpFeedbackJSON struct {
|
|
Type string `json:"type"`
|
|
Subtype string `json:"subtype,omitempty"`
|
|
}
|
|
|
|
type rtpHdrextJSON struct {
|
|
ID int `json:"id"`
|
|
URI string `json:"uri"`
|
|
}
|
|
|
|
type transportJSON struct {
|
|
Ufrag string `json:"ufrag"`
|
|
Pwd string `json:"pwd"`
|
|
Fingerprints []fingerprintJSON `json:"fingerprints"`
|
|
Candidates []candidateJSON `json:"candidates"`
|
|
}
|
|
|
|
type candidateJSON struct {
|
|
Port string `json:"port"`
|
|
Protocol string `json:"protocol"`
|
|
Network string `json:"network"`
|
|
Generation string `json:"generation"`
|
|
ID string `json:"id"`
|
|
Component string `json:"component"`
|
|
Foundation string `json:"foundation"`
|
|
Priority string `json:"priority"`
|
|
IP string `json:"ip"`
|
|
Type string `json:"type"`
|
|
}
|
|
|
|
// --- SFU ---
|
|
|
|
type SFU struct {
|
|
mu sync.RWMutex
|
|
participants map[int]*Participant
|
|
ssrcRegistry map[uint32]ssrcInfo
|
|
videoSSRCs map[int][]SimulcastLayer // participantID -> simulcast layers
|
|
rtxBuffers map[int]*RtxRingBuffer // senderID -> RTX ring buffer
|
|
layerSelectors map[[2]int]*LayerSelector // [receiverID, senderID] -> selector
|
|
maxActiveLayer map[int]int // senderID -> highest layer with traffic
|
|
twccGenerators map[int]*TransportCCGenerator // senderID -> transport-cc generator
|
|
loggerFactory logging.LoggerFactory
|
|
log logging.LeveledLogger
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
func NewSFU() *SFU {
|
|
lf := logging.NewDefaultLoggerFactory()
|
|
lf.DefaultLogLevel = logging.LogLevelDebug
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
return &SFU{
|
|
participants: make(map[int]*Participant),
|
|
ssrcRegistry: make(map[uint32]ssrcInfo),
|
|
videoSSRCs: make(map[int][]SimulcastLayer),
|
|
rtxBuffers: make(map[int]*RtxRingBuffer),
|
|
layerSelectors: make(map[[2]int]*LayerSelector),
|
|
maxActiveLayer: make(map[int]int),
|
|
twccGenerators: make(map[int]*TransportCCGenerator),
|
|
loggerFactory: lf,
|
|
log: lf.NewLogger("sfu"),
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
}
|
|
}
|
|
|
|
// Join processes a participant's join payload and returns the SFU's join response JSON.
|
|
// iceControlling: true for CustomImpl clients (which hardcode CONTROLLED role),
|
|
// false for PeerConnection clients (standard ICE: full agent is controlling when remote is ice-lite).
|
|
func (s *SFU) Join(participantID int, joinPayloadJSON string, iceControlling bool) (string, error) {
|
|
// 1. Parse join payload.
|
|
var payload joinPayload
|
|
if err := json.Unmarshal([]byte(joinPayloadJSON), &payload); err != nil {
|
|
return "", fmt.Errorf("parse join payload: %w", err)
|
|
}
|
|
|
|
// 2. Extract audio SSRC (signed int32 -> uint32).
|
|
audioSSRC := uint32(payload.SSRC)
|
|
|
|
// 3. Extract fingerprint from payload (use first sha-256 fingerprint).
|
|
var remoteFingerprint string
|
|
for _, fp := range payload.Fingerprints {
|
|
if fp.Hash == "sha-256" {
|
|
remoteFingerprint = fp.Fingerprint
|
|
break
|
|
}
|
|
}
|
|
if remoteFingerprint == "" && len(payload.Fingerprints) > 0 {
|
|
remoteFingerprint = payload.Fingerprints[0].Fingerprint
|
|
}
|
|
|
|
// 4. Parse video ssrc-groups into SimulcastLayers.
|
|
var simSSRCs []uint32 // SIM group: primary SSRCs per layer
|
|
fidMap := make(map[uint32]uint32) // primary SSRC -> RTX SSRC
|
|
for _, g := range payload.SSRCGroups {
|
|
switch g.Semantics {
|
|
case "SIM":
|
|
for _, v := range g.Sources {
|
|
simSSRCs = append(simSSRCs, uint32(v))
|
|
}
|
|
case "FID":
|
|
if len(g.Sources) == 2 {
|
|
fidMap[uint32(g.Sources[0])] = uint32(g.Sources[1])
|
|
}
|
|
}
|
|
}
|
|
|
|
var videoLayers []SimulcastLayer
|
|
for _, primary := range simSSRCs {
|
|
layer := SimulcastLayer{SSRC: primary}
|
|
if rtx, ok := fidMap[primary]; ok {
|
|
layer.FidSSRC = rtx
|
|
}
|
|
videoLayers = append(videoLayers, layer)
|
|
}
|
|
|
|
// 5. Create participant config.
|
|
config := ParticipantConfig{
|
|
AudioSSRC: audioSSRC,
|
|
Ufrag: payload.Ufrag,
|
|
Pwd: payload.Pwd,
|
|
Fingerprint: remoteFingerprint,
|
|
}
|
|
|
|
// 6. Create participant.
|
|
p, err := NewParticipant(participantID, config, s.loggerFactory)
|
|
if err != nil {
|
|
return "", fmt.Errorf("create participant: %w", err)
|
|
}
|
|
|
|
// 7. Wire Colibri callback for video constraint messages.
|
|
p.SetColibriCallback(s.handleColibriMessage)
|
|
|
|
// 7b. Wire RTCP feedback callback for PLI/FIR forwarding.
|
|
p.SetRTCPFeedbackCallback(s.handleRTCPFeedback)
|
|
|
|
// 8. Gather ICE candidates.
|
|
candidates, err := p.GatherCandidates()
|
|
if err != nil {
|
|
p.Close()
|
|
return "", fmt.Errorf("gather candidates: %w", err)
|
|
}
|
|
|
|
// 9. Register participant and all SSRCs.
|
|
s.mu.Lock()
|
|
s.participants[participantID] = p
|
|
s.ssrcRegistry[audioSSRC] = ssrcInfo{participantID: participantID, kind: "audio", layer: -1}
|
|
if len(videoLayers) > 0 {
|
|
s.videoSSRCs[participantID] = videoLayers
|
|
for i, vl := range videoLayers {
|
|
s.ssrcRegistry[vl.SSRC] = ssrcInfo{participantID: participantID, kind: "video", layer: i}
|
|
if vl.FidSSRC != 0 {
|
|
s.ssrcRegistry[vl.FidSSRC] = ssrcInfo{participantID: participantID, kind: "video-rtx", layer: i}
|
|
}
|
|
}
|
|
s.rtxBuffers[participantID] = NewRtxRingBuffer(200)
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
s.log.Infof("Registered participant %d: audio=%d, video layers=%d", participantID, audioSSRC, len(videoLayers))
|
|
for i, vl := range videoLayers {
|
|
s.log.Infof(" video layer %d: ssrc=%d fid=%d", i, vl.SSRC, vl.FidSSRC)
|
|
}
|
|
|
|
// 10. Build response JSON.
|
|
var candidatesJSON []candidateJSON
|
|
for _, c := range candidates {
|
|
candidatesJSON = append(candidatesJSON, iceCandidateToJSON(c))
|
|
}
|
|
|
|
resp := joinResponse{
|
|
Transport: transportJSON{
|
|
Ufrag: p.LocalUfrag(),
|
|
Pwd: p.LocalPwd(),
|
|
Fingerprints: []fingerprintJSON{
|
|
{
|
|
Hash: "sha-256",
|
|
Fingerprint: p.Fingerprint(),
|
|
Setup: "active", // SFU is DTLS client (active); tgcalls is SSL_SERVER (passive)
|
|
},
|
|
},
|
|
Candidates: candidatesJSON,
|
|
},
|
|
}
|
|
|
|
// Add video section if participant has video SSRCs.
|
|
if len(videoLayers) > 0 {
|
|
resp.Video = &videoResponseJSON{
|
|
Endpoint: fmt.Sprintf("%d", participantID),
|
|
PayloadTypes: []videoPayloadTypeJSON{
|
|
{
|
|
ID: 100,
|
|
Name: "H264",
|
|
Clockrate: 90000,
|
|
Parameters: map[string]string{
|
|
"profile-level-id": "42e01f",
|
|
"packetization-mode": "1",
|
|
},
|
|
Feedback: []rtcpFeedbackJSON{
|
|
{Type: "goog-remb"},
|
|
{Type: "transport-cc"},
|
|
{Type: "ccm", Subtype: "fir"},
|
|
{Type: "nack"},
|
|
{Type: "nack", Subtype: "pli"},
|
|
},
|
|
},
|
|
{
|
|
ID: 101,
|
|
Name: "rtx",
|
|
Clockrate: 90000,
|
|
Parameters: map[string]string{
|
|
"apt": "100",
|
|
},
|
|
},
|
|
},
|
|
RTPHdrexts: []rtpHdrextJSON{
|
|
{ID: 2, URI: "http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time"},
|
|
{ID: 3, URI: "http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01"},
|
|
{ID: 13, URI: "urn:3gpp:video-orientation"},
|
|
},
|
|
}
|
|
}
|
|
|
|
respBytes, err := json.Marshal(resp)
|
|
if err != nil {
|
|
return "", fmt.Errorf("marshal response: %w", err)
|
|
}
|
|
|
|
// 11. Start connection + RTP forwarding in background.
|
|
go func() {
|
|
if err := p.Connect(s.ctx, payload.Ufrag, payload.Pwd, iceControlling); err != nil {
|
|
s.log.Warnf("Participant %d connect failed: %v", participantID, err)
|
|
return
|
|
}
|
|
s.log.Infof("Participant %d connected, starting RTP forwarding", participantID)
|
|
|
|
// Start transport-cc feedback generator for this participant.
|
|
twccGen := NewTransportCCGenerator(func(data []byte) {
|
|
if err := p.WriteRTCP(data); err != nil {
|
|
s.log.Debugf("TWCC feedback to participant %d failed: %v", participantID, err)
|
|
}
|
|
})
|
|
s.mu.Lock()
|
|
s.twccGenerators[participantID] = twccGen
|
|
s.mu.Unlock()
|
|
|
|
// Broadcast updated SSRC list to all participants (after data channel is ready).
|
|
// Small delay to let the data channel establish.
|
|
time.Sleep(500 * time.Millisecond)
|
|
s.broadcastActiveSSRCs()
|
|
s.broadcastActiveVideoSSRCs()
|
|
|
|
s.forwardRTP(p)
|
|
}()
|
|
|
|
return string(respBytes), nil
|
|
}
|
|
|
|
// readParticipants returns a snapshot of the current participant map.
|
|
func (s *SFU) readParticipants() map[int]*Participant {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
snap := make(map[int]*Participant, len(s.participants))
|
|
for id, p := range s.participants {
|
|
snap[id] = p
|
|
}
|
|
return snap
|
|
}
|
|
|
|
// forwardRTP reads RTP from a participant and forwards to all others.
|
|
// For video/video-rtx SSRCs, only forwards to receivers whose requested layer matches.
|
|
// Audio is forwarded unconditionally to all other participants.
|
|
func (s *SFU) forwardRTP(from *Participant) {
|
|
for {
|
|
stream, ssrc, err := from.AcceptStream()
|
|
if err != nil {
|
|
select {
|
|
case <-s.ctx.Done():
|
|
return
|
|
default:
|
|
s.log.Warnf("Participant %d AcceptStream error: %v", from.ID, err)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Register SSRC if not already known (assume audio for undeclared SSRCs).
|
|
s.mu.Lock()
|
|
if _, exists := s.ssrcRegistry[ssrc]; !exists {
|
|
s.log.Warnf("Participant %d: undeclared SSRC %d, registering as audio", from.ID, ssrc)
|
|
s.ssrcRegistry[ssrc] = ssrcInfo{participantID: from.ID, kind: "audio", layer: -1}
|
|
}
|
|
info := s.ssrcRegistry[ssrc]
|
|
s.mu.Unlock()
|
|
|
|
s.log.Infof("Participant %d: accepted stream SSRC=%d (kind=%s, layer=%d)", from.ID, ssrc, info.kind, info.layer)
|
|
|
|
go func(streamInfo ssrcInfo) {
|
|
buf := make([]byte, 1500)
|
|
for {
|
|
n, err := stream.Read(buf)
|
|
if err != nil {
|
|
select {
|
|
case <-s.ctx.Done():
|
|
return
|
|
default:
|
|
s.log.Debugf("Participant %d stream read error: %v", from.ID, err)
|
|
return
|
|
}
|
|
}
|
|
|
|
pkt := make([]byte, n)
|
|
copy(pkt, buf[:n])
|
|
|
|
from.ingressSim.Send(pkt, func(simPkt []byte) {
|
|
// Record transport-cc arrival after ingress simulation.
|
|
twccSeq, ok := parseTWCCSeq(simPkt, 3)
|
|
if ok {
|
|
s.mu.RLock()
|
|
gen := s.twccGenerators[from.ID]
|
|
s.mu.RUnlock()
|
|
if gen != nil {
|
|
gen.RecordArrival(twccSeq)
|
|
}
|
|
}
|
|
|
|
s.processIncomingRTP(from, simPkt, ssrc, streamInfo)
|
|
})
|
|
}
|
|
}(info)
|
|
}
|
|
}
|
|
|
|
// processIncomingRTP handles a single RTP packet from a participant after ingress simulation.
|
|
func (s *SFU) processIncomingRTP(from *Participant, pkt []byte, ssrc uint32, streamInfo ssrcInfo) {
|
|
if streamInfo.kind == "audio" {
|
|
// Audio: forward to all other participants unconditionally.
|
|
s.mu.RLock()
|
|
for id, p := range s.participants {
|
|
if id == from.ID {
|
|
continue
|
|
}
|
|
if _, err := p.WriteRTP(pkt); err != nil {
|
|
s.log.Debugf("WriteRTP to participant %d failed: %v", id, err)
|
|
}
|
|
}
|
|
s.mu.RUnlock()
|
|
} else {
|
|
// Video/video-rtx: forward the best available layer to each receiver.
|
|
// Track max active layer for video (not video-rtx) packets.
|
|
if streamInfo.kind == "video" {
|
|
s.mu.Lock()
|
|
rtxBuf := s.rtxBuffers[from.ID]
|
|
maxActiveIncreased := false
|
|
if cur, ok := s.maxActiveLayer[from.ID]; !ok || streamInfo.layer > cur {
|
|
s.maxActiveLayer[from.ID] = streamInfo.layer
|
|
maxActiveIncreased = true
|
|
}
|
|
newMax := s.maxActiveLayer[from.ID]
|
|
var selectorsToNotify []*LayerSelector
|
|
if maxActiveIncreased {
|
|
for key, sel := range s.layerSelectors {
|
|
if key[1] == from.ID {
|
|
selectorsToNotify = append(selectorsToNotify, sel)
|
|
}
|
|
}
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
// Notify layer selectors outside the lock to avoid deadlock.
|
|
for _, sel := range selectorsToNotify {
|
|
sel.OnMaxActiveLayerIncreased(newMax)
|
|
}
|
|
|
|
if rtxBuf != nil && len(pkt) >= 4 {
|
|
seqNum := uint16(pkt[2])<<8 | uint16(pkt[3])
|
|
var ts uint32
|
|
if len(pkt) >= 8 {
|
|
ts = uint32(pkt[4])<<24 | uint32(pkt[5])<<16 | uint32(pkt[6])<<8 | uint32(pkt[7])
|
|
}
|
|
rtxBuf.Push(pkt, seqNum, ts)
|
|
}
|
|
}
|
|
|
|
s.mu.RLock()
|
|
maxActive, hasActive := s.maxActiveLayer[from.ID]
|
|
s.mu.RUnlock()
|
|
|
|
snap := s.readParticipants()
|
|
for id, p := range snap {
|
|
if id == from.ID {
|
|
continue
|
|
}
|
|
// Determine effective layer: use selectedLayer if set,
|
|
// otherwise use maxActiveLayer (best available).
|
|
selectedLayer := p.GetSelectedLayer(from.ID)
|
|
requestedLayer := p.GetRequestedLayer(from.ID)
|
|
var effectiveLayer int
|
|
if selectedLayer >= 0 {
|
|
effectiveLayer = selectedLayer
|
|
} else if requestedLayer >= 0 {
|
|
// Pre-selector: forward at best available, capped by request.
|
|
effectiveLayer = requestedLayer
|
|
} else {
|
|
continue // receiver doesn't want video from this sender
|
|
}
|
|
// Clamp to what the sender actually produces.
|
|
if hasActive && effectiveLayer > maxActive {
|
|
effectiveLayer = maxActive
|
|
}
|
|
if streamInfo.layer == effectiveLayer {
|
|
fwdPkt := pkt
|
|
// If forwarding a non-base layer, rewrite the SSRC in the
|
|
// RTP header to the primary (layer 0) SSRC. The receiver's
|
|
// video sink is attached to the primary SSRC only.
|
|
if effectiveLayer > 0 && len(fwdPkt) >= 12 {
|
|
s.mu.RLock()
|
|
senderLayers := s.videoSSRCs[from.ID]
|
|
s.mu.RUnlock()
|
|
if len(senderLayers) > 0 {
|
|
primarySSRC := senderLayers[0].SSRC
|
|
var rtxSSRC uint32
|
|
if streamInfo.kind == "video-rtx" && senderLayers[0].FidSSRC != 0 {
|
|
rtxSSRC = senderLayers[0].FidSSRC
|
|
}
|
|
fwdPkt = make([]byte, len(pkt))
|
|
copy(fwdPkt, pkt)
|
|
targetSSRC := primarySSRC
|
|
if streamInfo.kind == "video-rtx" && rtxSSRC != 0 {
|
|
targetSSRC = rtxSSRC
|
|
}
|
|
fwdPkt[8] = byte(targetSSRC >> 24)
|
|
fwdPkt[9] = byte(targetSSRC >> 16)
|
|
fwdPkt[10] = byte(targetSSRC >> 8)
|
|
fwdPkt[11] = byte(targetSSRC)
|
|
}
|
|
}
|
|
if _, err := p.WriteRTP(fwdPkt); err != nil {
|
|
s.log.Debugf("WriteRTP video to participant %d failed: %v", id, err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// heightToLayer maps a requested video height to a simulcast layer index.
|
|
func heightToLayer(height int) int {
|
|
if height <= 0 {
|
|
return -1
|
|
}
|
|
if height <= 90 {
|
|
return 0
|
|
}
|
|
if height <= 180 {
|
|
return 1
|
|
}
|
|
return 2
|
|
}
|
|
|
|
// handleColibriMessage processes an incoming Colibri message from a participant.
|
|
func (s *SFU) handleColibriMessage(participantID int, msg string) {
|
|
var base struct {
|
|
ColibriClass string `json:"colibriClass"`
|
|
}
|
|
if err := json.Unmarshal([]byte(msg), &base); err != nil {
|
|
s.log.Debugf("Participant %d: invalid Colibri JSON: %v", participantID, err)
|
|
return
|
|
}
|
|
|
|
switch base.ColibriClass {
|
|
case "ReceiverVideoConstraints":
|
|
s.handleReceiverVideoConstraints(participantID, msg)
|
|
default:
|
|
s.log.Debugf("Participant %d: unhandled Colibri class: %s", participantID, base.ColibriClass)
|
|
}
|
|
}
|
|
|
|
// handleRTCPFeedback is called when a participant sends PLI or FIR for a MediaSSRC.
|
|
// It looks up the sender of that SSRC and forwards a new PLI to them.
|
|
func (s *SFU) handleRTCPFeedback(fromID int, mediaSSRC uint32, isFIR bool) {
|
|
s.mu.RLock()
|
|
info, ok := s.ssrcRegistry[mediaSSRC]
|
|
if !ok {
|
|
s.mu.RUnlock()
|
|
s.log.Debugf("RTCP feedback from %d: unknown MediaSSRC=%d", fromID, mediaSSRC)
|
|
return
|
|
}
|
|
sender, senderOk := s.participants[info.participantID]
|
|
s.mu.RUnlock()
|
|
|
|
if !senderOk {
|
|
s.log.Debugf("RTCP feedback from %d: sender %d not found for MediaSSRC=%d", fromID, info.participantID, mediaSSRC)
|
|
return
|
|
}
|
|
|
|
kind := "PLI"
|
|
if isFIR {
|
|
kind = "FIR"
|
|
}
|
|
s.log.Infof("Forwarding %s to participant %d for MediaSSRC=%d (requested by %d)", kind, info.participantID, mediaSSRC, fromID)
|
|
|
|
// Construct and send PLI to the sender (PLI is simpler and universally supported).
|
|
pli := &rtcp.PictureLossIndication{
|
|
SenderSSRC: 0,
|
|
MediaSSRC: mediaSSRC,
|
|
}
|
|
data, err := rtcp.Marshal([]rtcp.Packet{pli})
|
|
if err != nil {
|
|
s.log.Warnf("Failed to marshal PLI: %v", err)
|
|
return
|
|
}
|
|
|
|
if err := sender.WriteRTCP(data); err != nil {
|
|
s.log.Debugf("Failed to send PLI to participant %d: %v", info.participantID, err)
|
|
}
|
|
}
|
|
|
|
type receiverVideoConstraints struct {
|
|
DefaultConstraints *videoConstraint `json:"defaultConstraints"`
|
|
Constraints map[string]videoConstraint `json:"constraints"`
|
|
}
|
|
|
|
type videoConstraint struct {
|
|
MinHeight int `json:"minHeight"`
|
|
MaxHeight int `json:"maxHeight"`
|
|
}
|
|
|
|
type senderVideoConstraints struct {
|
|
ColibriClass string `json:"colibriClass"`
|
|
VideoConstraints senderVideoConstraint `json:"videoConstraints"`
|
|
}
|
|
|
|
type senderVideoConstraint struct {
|
|
IdealHeight int `json:"idealHeight"`
|
|
}
|
|
|
|
func (s *SFU) handleReceiverVideoConstraints(receiverID int, msg string) {
|
|
var rvc receiverVideoConstraints
|
|
if err := json.Unmarshal([]byte(msg), &rvc); err != nil {
|
|
s.log.Warnf("Participant %d: bad ReceiverVideoConstraints: %v", receiverID, err)
|
|
return
|
|
}
|
|
|
|
s.mu.RLock()
|
|
receiver, ok := s.participants[receiverID]
|
|
s.mu.RUnlock()
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Track which senders are affected so we can update their SenderVideoConstraints.
|
|
affectedSenders := make(map[int]bool)
|
|
|
|
// Apply per-endpoint constraints and wire LayerSelector.
|
|
for endpointStr, constraint := range rvc.Constraints {
|
|
var senderID int
|
|
if _, err := fmt.Sscanf(endpointStr, "%d", &senderID); err != nil {
|
|
continue
|
|
}
|
|
layer := heightToLayer(constraint.MaxHeight)
|
|
receiver.SetRequestedLayer(senderID, layer)
|
|
s.ensureLayerSelector(receiverID, senderID, layer)
|
|
affectedSenders[senderID] = true
|
|
}
|
|
|
|
// Apply default constraints to all other senders with video.
|
|
// Collect senderIDs under RLock, release, then call ensureLayerSelector (needs write lock).
|
|
if rvc.DefaultConstraints != nil {
|
|
defaultLayer := heightToLayer(rvc.DefaultConstraints.MaxHeight)
|
|
var defaultSenders []int
|
|
s.mu.RLock()
|
|
for senderID := range s.videoSSRCs {
|
|
if senderID == receiverID {
|
|
continue
|
|
}
|
|
if !affectedSenders[senderID] {
|
|
defaultSenders = append(defaultSenders, senderID)
|
|
}
|
|
}
|
|
s.mu.RUnlock()
|
|
|
|
for _, senderID := range defaultSenders {
|
|
receiver.SetRequestedLayer(senderID, defaultLayer)
|
|
s.ensureLayerSelector(receiverID, senderID, defaultLayer)
|
|
affectedSenders[senderID] = true
|
|
}
|
|
}
|
|
|
|
// Send SenderVideoConstraints to each affected sender.
|
|
// idealHeight = max height any receiver wants from this sender.
|
|
for senderID := range affectedSenders {
|
|
s.sendSenderVideoConstraints(senderID)
|
|
}
|
|
|
|
// Send PLI to each sender that the receiver wants video from.
|
|
// This triggers a keyframe so the decoder can start producing frames.
|
|
for senderID := range affectedSenders {
|
|
layer := receiver.GetRequestedLayer(senderID)
|
|
if layer >= 0 {
|
|
s.mu.RLock()
|
|
layers := s.videoSSRCs[senderID]
|
|
s.mu.RUnlock()
|
|
if len(layers) > 0 {
|
|
s.handleRTCPFeedback(receiverID, layers[0].SSRC, false)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ensureLayerSelector creates or updates a LayerSelector for a (receiver, sender) pair.
|
|
func (s *SFU) ensureLayerSelector(receiverID, senderID, maxLayer int) {
|
|
if maxLayer < 0 {
|
|
return // no video requested from this sender
|
|
}
|
|
|
|
key := [2]int{receiverID, senderID}
|
|
|
|
s.mu.Lock()
|
|
existing, exists := s.layerSelectors[key]
|
|
if exists {
|
|
s.mu.Unlock()
|
|
existing.SetMaxLayer(maxLayer)
|
|
return
|
|
}
|
|
|
|
receiver, recvOk := s.participants[receiverID]
|
|
videoLayers := s.videoSSRCs[senderID]
|
|
s.mu.Unlock()
|
|
|
|
if !recvOk {
|
|
return
|
|
}
|
|
|
|
initialLayer := maxLayer
|
|
if initialLayer > 2 {
|
|
initialLayer = 2
|
|
}
|
|
|
|
// Set selectedLayer immediately, clamped to what the sender produces.
|
|
// The forwardRTP loop will further clamp to maxActiveLayer on each packet.
|
|
s.mu.RLock()
|
|
maxActive, hasActive := s.maxActiveLayer[senderID]
|
|
s.mu.RUnlock()
|
|
layer := initialLayer
|
|
if hasActive && layer > maxActive {
|
|
layer = maxActive
|
|
}
|
|
receiver.SetSelectedLayer(senderID, layer)
|
|
|
|
layersCopy := make([]SimulcastLayer, len(videoLayers))
|
|
copy(layersCopy, videoLayers)
|
|
|
|
cb := LayerSelectorCallbacks{
|
|
GetEffectiveBW: func() float64 {
|
|
return receiver.bwEstimator.EffectiveBps()
|
|
},
|
|
SetSelectedLayer: func(layer int) {
|
|
receiver.SetSelectedLayer(senderID, layer)
|
|
},
|
|
SendPLI: func(ssrc uint32) {
|
|
s.handleRTCPFeedback(receiverID, ssrc, false)
|
|
},
|
|
GetSenderVideoLayers: func() []SimulcastLayer {
|
|
return layersCopy
|
|
},
|
|
GetRtxBuffer: func() *RtxRingBuffer {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.rtxBuffers[senderID]
|
|
},
|
|
SendRtxPadding: func(rtxPayload []byte, rtxSSRC uint32, seqNum uint16, timestamp uint32) {
|
|
hdr := make([]byte, 12+len(rtxPayload))
|
|
hdr[0] = 0x80 // V=2
|
|
hdr[1] = 101 // PT=101 (RTX for H264)
|
|
hdr[2] = byte(seqNum >> 8)
|
|
hdr[3] = byte(seqNum)
|
|
hdr[4] = byte(timestamp >> 24)
|
|
hdr[5] = byte(timestamp >> 16)
|
|
hdr[6] = byte(timestamp >> 8)
|
|
hdr[7] = byte(timestamp)
|
|
hdr[8] = byte(rtxSSRC >> 24)
|
|
hdr[9] = byte(rtxSSRC >> 16)
|
|
hdr[10] = byte(rtxSSRC >> 8)
|
|
hdr[11] = byte(rtxSSRC)
|
|
copy(hdr[12:], rtxPayload)
|
|
if _, err := receiver.WriteRTP(hdr); err != nil {
|
|
s.log.Debugf("RTX padding to participant %d failed: %v", receiverID, err)
|
|
}
|
|
},
|
|
Log: func(level string, format string, args ...interface{}) {
|
|
msg := fmt.Sprintf(format, args...)
|
|
if level == "INFO" {
|
|
s.log.Infof("%s", msg)
|
|
} else {
|
|
s.log.Debugf("%s", msg)
|
|
}
|
|
},
|
|
}
|
|
|
|
ls := NewLayerSelector(receiverID, senderID, initialLayer, maxLayer, cb)
|
|
|
|
s.mu.Lock()
|
|
s.layerSelectors[key] = ls
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
func (s *SFU) sendSenderVideoConstraints(senderID int) {
|
|
snap := s.readParticipants()
|
|
|
|
maxHeight := 0
|
|
for id, p := range snap {
|
|
if id == senderID {
|
|
continue
|
|
}
|
|
layer := p.GetRequestedLayer(senderID)
|
|
var h int
|
|
switch layer {
|
|
case 0:
|
|
h = 90
|
|
case 1:
|
|
h = 180
|
|
case 2:
|
|
h = 720
|
|
default:
|
|
continue
|
|
}
|
|
if h > maxHeight {
|
|
maxHeight = h
|
|
}
|
|
}
|
|
|
|
sender, ok := snap[senderID]
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
svc := senderVideoConstraints{
|
|
ColibriClass: "SenderVideoConstraints",
|
|
VideoConstraints: senderVideoConstraint{IdealHeight: maxHeight},
|
|
}
|
|
data, _ := json.Marshal(svc)
|
|
if err := sender.SendText(string(data)); err != nil {
|
|
s.log.Debugf("SendText SenderVideoConstraints to %d: %v", senderID, err)
|
|
}
|
|
}
|
|
|
|
// QuerySSRC returns the participant ID for a given SSRC, or -1 if unknown.
|
|
func (s *SFU) QuerySSRC(ssrc uint32) int {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
if info, ok := s.ssrcRegistry[ssrc]; ok {
|
|
return info.participantID
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// QueryVideoSSRCs returns a JSON array of simulcast layers for a given participant.
|
|
// Format: [{"ssrc":N,"fidSsrc":M},...]
|
|
// Returns "[]" if the participant has no video SSRCs.
|
|
func (s *SFU) QueryVideoSSRCs(participantID int) string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
layers, ok := s.videoSSRCs[participantID]
|
|
if !ok || len(layers) == 0 {
|
|
return "[]"
|
|
}
|
|
|
|
type layerJSON struct {
|
|
SSRC uint32 `json:"ssrc"`
|
|
FidSSRC uint32 `json:"fidSsrc"`
|
|
}
|
|
out := make([]layerJSON, len(layers))
|
|
for i, l := range layers {
|
|
out[i] = layerJSON{SSRC: l.SSRC, FidSSRC: l.FidSSRC}
|
|
}
|
|
data, _ := json.Marshal(out)
|
|
return string(data)
|
|
}
|
|
|
|
// SetNetworkParams configures network simulation for a participant.
|
|
// direction: 0 = ingress (from client), 1 = egress (to client).
|
|
func (s *SFU) SetNetworkParams(participantID int, direction int, delayMs, jitterMs int, dropRate float64, bandwidthBps int64) {
|
|
s.mu.RLock()
|
|
p, ok := s.participants[participantID]
|
|
s.mu.RUnlock()
|
|
if !ok {
|
|
return
|
|
}
|
|
var sim *NetworkSimulator
|
|
if direction == 0 {
|
|
sim = p.ingressSim
|
|
} else {
|
|
sim = p.egressSim
|
|
}
|
|
if sim != nil {
|
|
sim.SetParams(delayMs, jitterMs, dropRate, bandwidthBps)
|
|
}
|
|
dirName := "ingress"
|
|
if direction == 1 {
|
|
dirName = "egress"
|
|
}
|
|
s.log.Infof("Participant %d %s: delay=%dms jitter=%dms drop=%.2f bw=%d bps",
|
|
participantID, dirName, delayMs, jitterMs, dropRate, bandwidthBps)
|
|
}
|
|
|
|
// broadcastActiveSSRCs sends the current set of active audio SSRCs to all connected participants.
|
|
// Each participant receives a list excluding their own SSRC.
|
|
func (s *SFU) broadcastActiveSSRCs() {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
// Collect all audio SSRCs per participant.
|
|
participantSSRCs := make(map[int]uint32) // participantID -> audioSSRC
|
|
for ssrc, info := range s.ssrcRegistry {
|
|
if info.kind == "audio" {
|
|
if _, exists := participantSSRCs[info.participantID]; !exists {
|
|
participantSSRCs[info.participantID] = ssrc
|
|
}
|
|
}
|
|
}
|
|
|
|
for id, p := range s.participants {
|
|
var ssrcs []int32
|
|
for otherID, ssrc := range participantSSRCs {
|
|
if otherID == id {
|
|
continue
|
|
}
|
|
ssrcs = append(ssrcs, int32(ssrc))
|
|
}
|
|
|
|
msg := buildActiveSSRCsMessage(ssrcs)
|
|
if err := p.SendText(msg); err != nil {
|
|
s.log.Debugf("SendText to participant %d: %v", id, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// broadcastActiveVideoSSRCs sends the current set of active video SSRCs to all connected participants.
|
|
// Each participant receives a list excluding their own video SSRCs.
|
|
func (s *SFU) broadcastActiveVideoSSRCs() {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
// Only broadcast if any participant has video.
|
|
if len(s.videoSSRCs) == 0 {
|
|
return
|
|
}
|
|
|
|
for id, p := range s.participants {
|
|
var entries []videoSSRCEntry
|
|
for otherID, layers := range s.videoSSRCs {
|
|
if otherID == id {
|
|
continue
|
|
}
|
|
if len(layers) == 0 {
|
|
continue
|
|
}
|
|
entry := videoSSRCEntry{
|
|
EndpointID: fmt.Sprintf("%d", otherID),
|
|
SSRC: int32(layers[0].SSRC),
|
|
}
|
|
// Build SIM group.
|
|
simGroup := ssrcGroupJSON{Semantics: "SIM"}
|
|
for _, l := range layers {
|
|
simGroup.Sources = append(simGroup.Sources, int32(l.SSRC))
|
|
}
|
|
entry.SSRCGroups = append(entry.SSRCGroups, simGroup)
|
|
// Build FID groups.
|
|
for _, l := range layers {
|
|
if l.FidSSRC != 0 {
|
|
entry.SSRCGroups = append(entry.SSRCGroups, ssrcGroupJSON{
|
|
Semantics: "FID",
|
|
Sources: []int32{int32(l.SSRC), int32(l.FidSSRC)},
|
|
})
|
|
}
|
|
}
|
|
entries = append(entries, entry)
|
|
}
|
|
|
|
if len(entries) == 0 {
|
|
continue
|
|
}
|
|
|
|
msg := buildActiveVideoSSRCsMessage(entries)
|
|
if err := p.SendText(msg); err != nil {
|
|
s.log.Debugf("SendText video SSRCs to participant %d: %v", id, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
type videoSSRCEntry struct {
|
|
EndpointID string `json:"endpointId"`
|
|
SSRC int32 `json:"ssrc"`
|
|
SSRCGroups []ssrcGroupJSON `json:"ssrcGroups"`
|
|
}
|
|
|
|
func buildActiveVideoSSRCsMessage(entries []videoSSRCEntry) string {
|
|
type msg struct {
|
|
ColibriClass string `json:"colibriClass"`
|
|
SSRCs []videoSSRCEntry `json:"ssrcs"`
|
|
}
|
|
data, _ := json.Marshal(msg{ColibriClass: "ActiveVideoSsrcs", SSRCs: entries})
|
|
return string(data)
|
|
}
|
|
|
|
func buildActiveSSRCsMessage(ssrcs []int32) string {
|
|
buf := []byte(`{"colibriClass":"ActiveAudioSsrcs","ssrcs":[`)
|
|
for i, ssrc := range ssrcs {
|
|
if i > 0 {
|
|
buf = append(buf, ',')
|
|
}
|
|
buf = append(buf, fmt.Sprintf("%d", ssrc)...)
|
|
}
|
|
buf = append(buf, ']', '}')
|
|
return string(buf)
|
|
}
|
|
|
|
// Leave removes a participant from the SFU, closes their transport,
|
|
// and broadcasts updated SSRC lists to remaining participants.
|
|
func (s *SFU) Leave(participantID int) error {
|
|
s.mu.Lock()
|
|
p, ok := s.participants[participantID]
|
|
if !ok {
|
|
s.mu.Unlock()
|
|
return fmt.Errorf("participant %d not found", participantID)
|
|
}
|
|
|
|
// Remove from participants map.
|
|
delete(s.participants, participantID)
|
|
|
|
// Remove all SSRCs owned by this participant.
|
|
for ssrc, info := range s.ssrcRegistry {
|
|
if info.participantID == participantID {
|
|
delete(s.ssrcRegistry, ssrc)
|
|
}
|
|
}
|
|
|
|
// Remove video SSRCs.
|
|
delete(s.videoSSRCs, participantID)
|
|
|
|
// Remove RTX buffer for this sender.
|
|
delete(s.rtxBuffers, participantID)
|
|
delete(s.maxActiveLayer, participantID)
|
|
|
|
// Stop and remove all layer selectors involving this participant.
|
|
var toStop []*LayerSelector
|
|
for key, ls := range s.layerSelectors {
|
|
if key[0] == participantID || key[1] == participantID {
|
|
toStop = append(toStop, ls)
|
|
delete(s.layerSelectors, key)
|
|
}
|
|
}
|
|
|
|
// Remove TWCC generator.
|
|
var twccGen *TransportCCGenerator
|
|
if gen, ok := s.twccGenerators[participantID]; ok {
|
|
twccGen = gen
|
|
delete(s.twccGenerators, participantID)
|
|
}
|
|
|
|
s.mu.Unlock()
|
|
|
|
// Stop layer selectors outside the lock.
|
|
for _, ls := range toStop {
|
|
ls.Stop()
|
|
}
|
|
|
|
// Stop TWCC generator outside the lock.
|
|
if twccGen != nil {
|
|
twccGen.Stop()
|
|
}
|
|
|
|
// Close transport (outside lock — Close can block).
|
|
if err := p.Close(); err != nil {
|
|
s.log.Warnf("Error closing participant %d: %v", participantID, err)
|
|
}
|
|
|
|
s.log.Infof("Participant %d left", participantID)
|
|
|
|
// Broadcast updated SSRC lists to remaining participants.
|
|
s.broadcastActiveSSRCs()
|
|
s.broadcastActiveVideoSSRCs()
|
|
|
|
return nil
|
|
}
|
|
|
|
// Destroy closes all participants and cancels the SFU context.
|
|
func (s *SFU) Destroy() {
|
|
s.cancel()
|
|
s.mu.Lock()
|
|
// Stop all layer selectors before closing participants.
|
|
for _, ls := range s.layerSelectors {
|
|
ls.Stop()
|
|
}
|
|
s.layerSelectors = nil
|
|
// Stop all TWCC generators.
|
|
for _, gen := range s.twccGenerators {
|
|
gen.Stop()
|
|
}
|
|
s.twccGenerators = nil
|
|
for id, p := range s.participants {
|
|
if err := p.Close(); err != nil {
|
|
s.log.Warnf("Error closing participant %d: %v", id, err)
|
|
}
|
|
}
|
|
s.participants = nil
|
|
s.ssrcRegistry = nil
|
|
s.videoSSRCs = nil
|
|
s.rtxBuffers = nil
|
|
s.maxActiveLayer = nil
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// iceCandidateToJSON converts a pion ICE candidate to our JSON format.
|
|
func iceCandidateToJSON(c ice.Candidate) candidateJSON {
|
|
return candidateJSON{
|
|
Port: fmt.Sprintf("%d", c.Port()),
|
|
Protocol: "udp",
|
|
Network: "0",
|
|
Generation: "0",
|
|
ID: c.ID(),
|
|
Component: fmt.Sprintf("%d", c.Component()),
|
|
Foundation: c.Foundation(),
|
|
Priority: fmt.Sprintf("%d", c.Priority()),
|
|
IP: c.Address(),
|
|
Type: "host",
|
|
}
|
|
}
|
|
|
|
// --- Global SFU registry ---
|
|
|
|
var (
|
|
sfuRegistry = make(map[int]*SFU)
|
|
sfuRegistryMu sync.Mutex
|
|
sfuNextID int32
|
|
)
|
|
|
|
// --- CGo exports ---
|
|
|
|
//export GoSfu_Init
|
|
func GoSfu_Init() C.int {
|
|
fmt.Println("[GoSfu] Initialized")
|
|
return 0
|
|
}
|
|
|
|
//export GoSfu_Create
|
|
func GoSfu_Create() C.int {
|
|
handle := int(atomic.AddInt32(&sfuNextID, 1))
|
|
sfu := NewSFU()
|
|
sfuRegistryMu.Lock()
|
|
sfuRegistry[handle] = sfu
|
|
sfuRegistryMu.Unlock()
|
|
fmt.Printf("[GoSfu] Created SFU handle=%d\n", handle)
|
|
return C.int(handle)
|
|
}
|
|
|
|
//export GoSfu_Destroy
|
|
func GoSfu_Destroy(handle C.int) {
|
|
h := int(handle)
|
|
sfuRegistryMu.Lock()
|
|
sfu, ok := sfuRegistry[h]
|
|
if ok {
|
|
delete(sfuRegistry, h)
|
|
}
|
|
sfuRegistryMu.Unlock()
|
|
if ok {
|
|
sfu.Destroy()
|
|
fmt.Printf("[GoSfu] Destroyed SFU handle=%d\n", h)
|
|
}
|
|
}
|
|
|
|
//export GoSfu_Join
|
|
func GoSfu_Join(handle C.int, participantID C.int, joinPayloadJSON *C.char, iceControlling C.int) *C.char {
|
|
h := int(handle)
|
|
sfuRegistryMu.Lock()
|
|
sfu, ok := sfuRegistry[h]
|
|
sfuRegistryMu.Unlock()
|
|
if !ok {
|
|
errMsg := fmt.Sprintf(`{"error":"unknown SFU handle %d"}`, h)
|
|
return C.CString(errMsg)
|
|
}
|
|
|
|
payload := C.GoString(joinPayloadJSON)
|
|
resp, err := sfu.Join(int(participantID), payload, iceControlling != 0)
|
|
if err != nil {
|
|
errMsg := fmt.Sprintf(`{"error":"%s"}`, err.Error())
|
|
return C.CString(errMsg)
|
|
}
|
|
return C.CString(resp)
|
|
}
|
|
|
|
//export GoSfu_Leave
|
|
func GoSfu_Leave(handle C.int, participantID C.int) C.int {
|
|
h := int(handle)
|
|
sfuRegistryMu.Lock()
|
|
sfu, ok := sfuRegistry[h]
|
|
sfuRegistryMu.Unlock()
|
|
if !ok {
|
|
return -1
|
|
}
|
|
if err := sfu.Leave(int(participantID)); err != nil {
|
|
fmt.Printf("[GoSfu] Leave error: %v\n", err)
|
|
return -1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
//export GoSfu_QuerySsrc
|
|
func GoSfu_QuerySsrc(handle C.int, ssrc C.uint) C.int {
|
|
h := int(handle)
|
|
sfuRegistryMu.Lock()
|
|
sfu, ok := sfuRegistry[h]
|
|
sfuRegistryMu.Unlock()
|
|
if !ok {
|
|
return -1
|
|
}
|
|
return C.int(sfu.QuerySSRC(uint32(ssrc)))
|
|
}
|
|
|
|
//export GoSfu_QueryVideoSsrcs
|
|
func GoSfu_QueryVideoSsrcs(handle C.int, participantID C.int) *C.char {
|
|
h := int(handle)
|
|
sfuRegistryMu.Lock()
|
|
sfu, ok := sfuRegistry[h]
|
|
sfuRegistryMu.Unlock()
|
|
if !ok {
|
|
return C.CString("[]")
|
|
}
|
|
return C.CString(sfu.QueryVideoSSRCs(int(participantID)))
|
|
}
|
|
|
|
//export GoSfu_SetNetworkParams
|
|
func GoSfu_SetNetworkParams(handle C.int, participantID C.int, direction C.int, delayMs C.int, jitterMs C.int, dropRate C.double, bandwidthBps C.long) {
|
|
h := int(handle)
|
|
sfuRegistryMu.Lock()
|
|
sfu, ok := sfuRegistry[h]
|
|
sfuRegistryMu.Unlock()
|
|
if !ok {
|
|
return
|
|
}
|
|
sfu.SetNetworkParams(int(participantID), int(direction), int(delayMs), int(jitterMs), float64(dropRate), int64(bandwidthBps))
|
|
}
|
|
|
|
//export GoSfu_Free
|
|
func GoSfu_Free(ptr *C.char) {
|
|
C.free(unsafe.Pointer(ptr))
|
|
}
|
|
|
|
//export GoSfu_Shutdown
|
|
func GoSfu_Shutdown() {
|
|
sfuRegistryMu.Lock()
|
|
for h, sfu := range sfuRegistry {
|
|
sfu.Destroy()
|
|
delete(sfuRegistry, h)
|
|
}
|
|
sfuRegistryMu.Unlock()
|
|
fmt.Println("[GoSfu] Shutdown")
|
|
}
|
|
|
|
func main() {}
|