Question:
I'm implementing a layered SFU architecture using Pion WebRTC in Go. The setup is as follows:
Architecture
Master SFU:
- Receives a single upstream client stream (audio + video).
- Only forwards tracks to Sub SFUs. No mixing or transcoding.
- Also allows downstream clients to pull the stream directly.
Sub SFU(s):
- Receives the forwarded streams from Master SFU.
- Can provide streams to other clients.
- Main purpose: scale to more users or geographic distribution.
Single upstream client pushing a stream.
Flow:
Client P (push) ---> Master SFU ---> Sub SFU ---> Client(s) pull
|
|--> Client(s) pull
Problem
I use AddTransceiverFromKind during Sub SFU initialization and then ReplaceTrack in the OnTrackHandler when receiving tracks from the upstream stream.
Issue:
- Audio works fine.
- Video track never reaches the Sub SFU; the Sub SFU receives only audio.
I also tried setting the transceiver to RecvOnly or SendRecv on the Sub SFU side, but the video track still doesn't propagate.
Logs
- Master SFU
2025-11-05T18:04:20.353+0800 INFO sfu/ontrack.go:55 Received remote audio track
2025-11-05T18:04:20.355+0800 INFO sfu/ontrack.go:34 Received remote video track
2025-11-05T18:04:20.355+0800 INFO sfu/ontrack.go:51 Successfully replaced sub SFU video track {"subAddr": "127.0.0.1:20001"}
2025-11-05T18:04:20.355+0800 INFO sfu/ontrack.go:80 Starting RTP forwarding {"TrackKind": "video"}
2025-11-05T18:04:20.355+0800 INFO sfu/ontrack.go:70 Successfully replaced sub SFU audio track {"subAddr": "127.0.0.1:20001"}
2025-11-05T18:04:20.355+0800 INFO sfu/ontrack.go:80 Starting RTP forwarding {"TrackKind": "audio"}
- Sub SFU
2025-11-05T18:04:18.210+0800 INFO gotest/main.go:136 waiting for video and audio tracks
2025-11-05T18:04:20.356+0800 INFO sfu/ontrack.go:55 Received remote audio track
Relevant code snippets
1. Sub SFU initialization (AddTransceiverFromKind)
// Create PeerConnection
peerConn, err := api.NewPeerConnection(webrtc.Configuration{})
if err != nil {
return nil, fmt.Errorf("failed to create peerConnection: %w", err)
}
peerConn.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
log.Logger.Info("sub SFU ICEConnectionState:", zap.String("state", state.String()), zap.String("subAddr", subAddr))
})
// Add transceiver for receiving
if t, err := peerConn.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo, webrtc.RTPTransceiverInit{
Direction: webrtc.RTPTransceiverDirectionSendrecv,
}); err != nil {
return nil, fmt.Errorf("failed to add transceiver for receiving Video: %w", err)
} else {
sfu.videoRTPTransceiver = t
}
if t, err := peerConn.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{
Direction: webrtc.RTPTransceiverDirectionSendrecv,
}); err != nil {
return nil, fmt.Errorf("failed to add transceiver for receiving Audio: %w", err)
} else {
sfu.audioRTPTransceiver = t
}
// And Create offer、Wait for ICE gathering to complete、Exchange SDP...
2. OnTrackHandler (ReplaceTrack + localTrack)
localTrack, err = webrtc.NewTrackLocalStaticRTP(
remoteTrack.Codec().RTPCodecCapability, "video", "pion",
)
if err != nil {
log.Logger.Fatal("Failed to create video track", zap.Error(err))
}
localVideoTrackChan <- localTrack
// Replace all sub SFU tracks
lock.Lock()
for subAddr, subSFU := range SubSFUMap {
if err := subSFU.videoRTPTransceiver.Sender().ReplaceTrack(localTrack); err != nil {
log.Logger.Error("Failed to replace sub SFU video track", zap.String("addr", subAddr), zap.Error(err), zap.String("subAddr", subAddr))
continue
}
log.Logger.Info("Successfully replaced sub SFU video track", zap.String("subAddr", subAddr))
}
lock.Unlock()
3. RTP forwarding loop
go func() {
log.Logger.Info("Starting RTP forwarding", zap.Any("TrackKind", remoteTrack.Kind()))
rtpBuf := make([]byte, 1400)
for {
n, _, err := remoteTrack.Read(rtpBuf)
if err != nil {
log.Logger.Warn("Failed to read RTP", zap.Error(err))
return
}
if _, err := localTrack.Write(rtpBuf[:n]); err != nil {
log.Logger.Warn("Failed to write RTP", zap.Error(err))
}
}
}()
Questions
- Could this issue be related to asynchronous RTP forwarding or buffering, like discussed in this issue?
- Are there better patterns for single-push layered SFU with Pion WebRTC to forward video to Sub SFUs reliably?
Additional info
- Go version: 1.24.0
- Pion WebRTC version: v4.1.6
- Single upstream client (no simulcast / multiple tracks)
- Master SFU does not process tracks, just forwards to Sub SFU
What I've tried
- Changed transceiver direction (
RecvOnly/SendRecv). - Verified
RTPSenderis not nil before callingReplaceTrack. - Created separate
localTrackper kind (video/audio). - Checked that audio works, video does not.
Goal
- Master SFU: forward video + audio to Sub SFUs
- Sub SFUs: allow downstream clients to pull video + audio
- Avoid unnecessary SDP renegotiation if possible
Request
I would like to know what the issue is that is causing me to only receive audio and not be able to transmit video.