1

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

  1. Could this issue be related to asynchronous RTP forwarding or buffering, like discussed in this issue?
  2. 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 RTPSender is not nil before calling ReplaceTrack.
  • Created separate localTrack per 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.

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.