# Voice Debugging

Source: https://docs.gryt.chat/docs/sfu/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?**

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

Three answers matter:

| Response | What it means |
|----------|---------------|
| `{"status":"healthy",…}` | The SFU is up and its outbound UDP works |
| `{"status":"starting","detail":"verifying UDP connectivity"}`, HTTP 503 | Still probing. It sends a STUN Binding Request and retries up to eight times, backing off to five seconds |
| Connection refused | Not 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:

```yaml
ports:
  - "3478:3478/udp"
```

<Callout type="warn">
Cloudflare Tunnel does not carry UDP. If your signalling goes through a tunnel,
the media port still has to be exposed directly.
</Callout>

## 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:

```bash
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

```bash
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:

| Field | What to look for |
|-------|------------------|
| SFU endpoint | Which SFU you actually reached. Confirms LAN versus WAN path |
| ICE remote | The address media is really going to |
| ICE local | Your side of the pair |
| Candidate type | `host` on a LAN, `srflx` through NAT |
| Round-trip time | Yours, next to the other participants' |
| Jitter buffer | Highlighted 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:

```javascript
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:

```javascript
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
