Gryt

Voice Debugging

Finding out why voice will not connect

Voice that works for you and for nobody else is nearly always ICE, which in practice means UDP reachability. Signalling runs over the same WebSocket as the rest of the app, so signalling working tells you nothing about whether media can get through.

Start here

Is the SFU actually ready?

curl -s http://localhost:5005/health

Three answers matter:

ResponseWhat it means
{"status":"healthy",…}The SFU is up and its outbound UDP works
{"status":"starting","detail":"verifying UDP connectivity"}, HTTP 503Still probing. It sends a STUN Binding Request and retries up to eight times, backing off to five seconds
Connection refusedNot running, or the UDP mux port was already taken and it refused to start

That last one is worth knowing about. The SFU binds ICE_UDP_MUX_PORT at startup and exits if something else holds it, rather than coming up and quietly handing out candidates on ports nobody opened. Check the logs for failed to create ICE UDP mux on port.

Is the one UDP port open?

All media goes over ICE_UDP_MUX_PORT — 3478 unless you set it. That single port has to be reachable from wherever your participants are, and if the SFU is in a container it has to be published:

ports:
  - "3478:3478/udp"

Cloudflare Tunnel does not carry UDP. If your signalling goes through a tunnel, the media port still has to be exposed directly.

The failure that looks like a configuration problem

Users outside your network reach the server, see the channel, and sit on "connecting" forever.

The usual cause is a NAT between the SFU's UDP socket and the internet — a Docker bridge, a cloud VPC — that rewrites the source port. Look for the candidate lines in the SFU log:

ICE candidate for <peer>: type=host  protocol=udp address=203.0.113.10:3478
ICE candidate for <peer>: type=srflx protocol=udp address=203.0.113.10:57599

The host candidate says 3478 because that is what the SFU bound. The srflx candidate says 57599 because that is what the outside world actually saw. When those differ, external peers need the second one, and they only get it if STUN is on.

So: keep DISABLE_STUN=false, which is the default. Turning STUN off is only safe when the SFU has a direct, port-preserving path out — host networking, bare metal, or a 1:1 NAT. Docker and most cloud networking are neither.

If the SFU is behind NAT or has several interfaces and you know the address it should be advertising:

ICE_ADVERTISE_IP=203.0.113.10

Comma-separate it for a machine that should be reachable over both a LAN and the internet. Clients are given every candidate and use whichever answers first.

Reading the logs

docker logs <sfu-container> -f

In order, a working join produces: a server connection and Server … registered room … successfully, then a client connection, then Client … validated for room, then peer connection state changes. Where it stops tells you which side to look at.

Two rejections have specific messages:

  • Join validation failed — the room is not registered, or the server credentials do not match. That is the Gryt server's side, not the client's.
  • there are no seats left in this voice server (n/200)MAX_PEERS. It counts every peer across every room on this SFU, not per server.

DEBUG is on unless you set it to false. VERBOSE_LOG=true adds per-packet RTP detail and is genuinely too noisy for anything but a short capture.

From the client

Everything the client knows about the connection is in Settings → Advanced, in the latency panel. It only fills in while you are connected to voice:

FieldWhat to look for
SFU endpointWhich SFU you actually reached. Confirms LAN versus WAN path
ICE remoteThe address media is really going to
ICE localYour side of the pair
Candidate typehost on a LAN, srflx through NAT
Round-trip timeYours, next to the other participants'
Jitter bufferHighlighted above 80 ms

If you are on the same network as the SFU and ICE remote shows a 192.168.x.x address at near-zero latency, the LAN path won. A public address at 20 ms means traffic is going out and back.

The same screen has toggles for two floating overlays, one for the microphone and one for video, showing live capture, codec, resolution and bitrate. Both are off by default and are separate from the latency panel. There is no third overlay for the connection itself; the latency panel is where that lives.

Checks in the browser

Microphone permission:

navigator.mediaDevices.getUserMedia({ audio: true })
  .then(stream => {
    console.log("granted", stream.getAudioTracks());
    stream.getTracks().forEach(t => t.stop());
  })
  .catch(err => console.error("denied", err));

Whether the browser can form candidates at all:

const pc = new RTCPeerConnection({ iceServers: [{ urls: "stun:stun.l.google.com:19302" }] });
pc.createDataChannel("x");
pc.onicecandidate = e => e.candidate && console.log(e.candidate.candidate);
await pc.setLocalDescription(await pc.createOffer());

No srflx line means STUN is not getting out from the client either, which is a different problem from the server side of the same symptom.

What to include when asking for help

  • The last ~50 lines of SFU logs, and the same from the Gryt server
  • Browser console errors
  • ICE_UDP_MUX_PORT, whether it is published, and whether it is open in the firewall
  • DISABLE_STUN and STUN_SERVERS
  • The latency panel: SFU endpoint, ICE remote and local, candidate type
  • Whether it fails for everyone or only for people outside your network

On this page