DEV Community

AiLaoHuYu
AiLaoHuYu

Posted on

Audio works, video is black: debugging a layered WebRTC SFU in Go

Audio connects instantly, the viewer hears everything, and the video tile stays black. The publisher side looks fine. ReplaceTrack returns nil, which the code logs as success.

That nil is where most of these debugging sessions go wrong. ReplaceTrack returning nil means one thing: the track was bound to a sender that already existed. It does not mean the negotiated SDP allows that m-line to send, it does not mean both legs agreed on a codec, and it does not mean a single video RTP packet ever reached the downstream leg. Three separate failures that produce the same black rectangle.

This is the order that separates them, cheapest check first.

The whole thing in one line: audio and video travel the same connection but not the same negotiated path, so a working audio leg proves nothing about video.

First, read the negotiated direction, not the code

Direction in SDP is not a preference. It is the answer both sides ended up with, and it is what controls the RTP path.

The sending leg's video m-section must be sendonly or sendrecv. The receiving leg must be recvonly or sendrecv. ReplaceTrack does not renegotiate direction. You can add a track to a sender that the remote side accepted as inactive and get a perfectly fine nil back.

m=video ...
a=mid:<same mid on both sides>
a=sendrecv | a=sendonly   # sender side
a=recvonly | a=sendrecv   # receiver side
a=rtpmap:<pt> <matching video codec>
Enter fullscreen mode Exit fullscreen mode

If the video m-line has no usable a=ssrc from the sender, or the remote direction is recvonly/inactive, stop. That is a negotiation fault, and no amount of buffering or async handling will fix it. Setting direction on one side only is not enough — the negotiated answer is the one that matters.

Second, the codec has to match across the boundary

A TrackLocalStaticRTP rewrites the SSRC and the payload type when it writes into a sender. It does not transcode, and it does not rewrite codec parameters.

So forwarding H.264 between two legs that negotiated different profiles, or writing VP8 packets into a sender that negotiated H.264, gives you a track object that exists, accepts writes, and never decodes. The mime type looking similar is not the same as the codec being compatible.

remoteTrack.Codec().RTPCodecCapability on the inbound side and the negotiated codec on the outbound sender are not interchangeable just because both say video/H264. Pin the same codec on both legs, or transcode at the boundary. Pick one deliberately.

Third, find out whether RTP arrived at all

Before touching the forwarding loop, get evidence. Two log lines split the problem in half: packets that never arrived, versus packets that arrived and were not used.

// After negotiation, before the forwarding loop starts.
for _, encoding := range subSFU.videoRTPTransceiver.Sender().GetParameters().Encodings {
    log.Printf("sub video sender ssrc=%d pt=%d", encoding.SSRC, encoding.PayloadType)
}

// In the master forwarding loop.
if err := localTrack.WriteRTP(packet); err != nil {
    log.Printf("sub video write failed: %v", err)
}
Enter fullscreen mode Exit fullscreen mode

On the receiving side, log the track handed to OnTrack, then read pc.GetStats() for the inbound RTP stream and check whether packetsReceived moves.

If the downstream leg never receives the first video packet, the fault is in negotiation or sending. If it receives packets and the decoder never produces frames, the fault is codec compatibility or keyframes. Those are different bugs.

Fourth, a forwarding loop that hides its own death

The classic version of this loop:

for {
    packet, _, err := remoteTrack.ReadRTP()
    if err != nil {
        log.Printf("upstream video read failed: %v", err)
        return
    }

    if err := localTrack.WriteRTP(packet); err != nil {
        log.Printf("sub video write failed: %v", err)
        return
    }
}
Enter fullscreen mode Exit fullscreen mode

Both exits matter. A single failed binding must not be mistaken for a successful fan-out, and a read that returns an error must not kill the goroutine silently — the symptom of a dead forwarding goroutine is exactly "audio works, video disappeared" on the leg it was feeding.

One more trap in the same loop: ReadRTP() allocates to Pion's receive MTU. If you keep a fixed byte slice and reuse it, handle io.ErrShortBuffer explicitly instead of letting the goroutine exit. The failure looks identical to a network problem and costs an afternoon.

Fifth, flowing RTP with a black tile is a keyframe problem

Packets can arrive continuously while the viewer stays black, because the first decodable frame never arrived. Send a PLI on the inbound video receiver and then confirm that framesReceived and framesDecoded actually advance in GetStats(). If framesReceived climbs and framesDecoded stays flat, you are looking at codec parameters or a missing keyframe, not transport.

Keep that check separate from the previous one. Transport and decode fail with the same pixels on screen.

The pattern that avoids the whole renegotiation dance

Instead of adding video late and hoping the negotiation cooperates: create the video transceiver with a placeholder TrackLocalStaticRTP before the very first offer/answer, negotiate it as sendrecv, and only then swap in the real upstream track once the initial negotiation is stable.

The placeholder costs one unused m-line and removes an entire class of "the sender exists but the direction does not allow media" failures. Each downstream leg gets its own binding of the same local track, and every write is checked — one failed binding must not be mistaken for a successful fan-out.

The check order that saves time

  1. Read the negotiated direction on both legs. No sendrecv/sendonly on the sender means it is a negotiation bug.
  2. Confirm the same codec on both legs. Similar mime types are not proof.
  3. Log the sender SSRC and payload type, then check packetsReceived on the inbound leg.
  4. Handle every error in the forwarding loop; a dead goroutine looks like a network fault.
  5. If packets flow but frames do not decode, request a keyframe and compare framesReceived with framesDecoded.
  6. Prefer a placeholder transceiver negotiated up front over adding video mid-session.

None of this is exotic. All of it is invisible when the publisher and the viewer happen to agree by accident, which is why demos pass and production does not.

I packaged the working version of all of this — the Go signaling server and SFU, the browser publisher and viewer, the ICE restart and rejoin chain, and the diagnostics panel that shows the selected candidate path and the per-leg counters — as a self-hosted source kit, so you can prove each layer on your own server instead of inferring it from a black box:

https://lowlatencykit.com/

If you only want to know whether your own TURN server allocates, the relay test page is free and needs no account:

https://lowlatencykit.com/test/turn/

Top comments (0)