# Group calls (SFU)

Rooms with more than a few participants (classes, group readings, webinars) use a
server-side **selective forwarding unit** instead of a full mesh. Each participant
keeps one PeerConnection with the SFU: it uploads its tracks once and receives every
other participant's tracks on the same connection. The SFU forwards RTP without
decoding or transcoding.

Code: `internal/sfu` (forwarding), `internal/api/ext_sfu.go` (REST),
`sdk/js/calling-sfu.js` (`GroupClient`), `web/demo/group.html` (demo).

## Mesh or SFU?

| | Mesh (`CallingClient`) | SFU (`GroupClient`) |
|---|---|---|
| Participants | 2–4 | 4–~30 per room |
| Upload per participant | one encode per remote peer (N−1 uplinks) | one uplink |
| Server media cost | none (TURN only when needed) | CPU + bandwidth per forwarded track |
| Latency | lowest | + one hop through the API node |
| Use for | 1:1 calls, consultations, tiny groups | classes, group readings, webinars |

A 1:1 consultation should stay on mesh. Switch to the SFU when a room regularly holds
more than three or four video publishers, or when subscribers outnumber publishers
(webinar: a few presenters, many `subscriber`-scope viewers).

## Enabling

Per room, with the application API key:

```bash
# explicit
curl -X POST $API/v1/rooms/$ROOM/sfu/enable  -H "Authorization: Bearer ck_live_…"
curl -X POST $API/v1/rooms/$ROOM/sfu/disable -H "Authorization: Bearer ck_live_…"
curl        $API/v1/rooms/$ROOM/sfu          -H "Authorization: Bearer ck_live_…"
# or at creation: the SFU starts when the first peer joins
curl -X POST $API/v1/rooms -H "Authorization: Bearer ck_live_…" \
     -d '{"name":"class-7","max_peers":12,"metadata":{"sfu":"true"}}'
```

Status response:

```json
{ "room_id": "room_…", "enabled": true, "running": true,
  "peers": [ { "id": "alice", "connection_state": "connected", "published": 2, "subscribed": 4 } ],
  "tracks": [ { "id": "d4c1…", "kind": "video", "peer_id": "alice" } ] }
```

Lifecycle: the SFU joins the room as the virtual peer **`sfu`** when the first
participant joins and leaves when the last one leaves, so call records and
`call.started` / `call.ended` webhooks behave as for mesh rooms. `disable` stops a running
SFU and overrides `metadata.sfu` until `enable` is called again.

Things to know:

- The `sfu` peer **counts toward `max_peers`**: a room for 7 participants needs `max_peers: 8`.
- Peer id `sfu` is reserved; a token minted with that id would replace the SFU.
- `peer.joined` / `peer.left` events and `GET /v1/rooms/{id}/peers` include the `sfu` peer.
- The SFU uses the existing signaling socket; no extra ports on the API host beyond UDP for
  media (see *Deployment*).

## Client

```html
<script src="https://api.example.com/sdk/calling-sfu.js"></script>
<script>
const group = new GroupClient({ baseUrl: 'https://api.example.com', token });
group.on('track', ({ peerId, stream }) => videoFor(peerId).srcObject = stream);
group.on('track-ended', ({ peerId }) => removeVideo(peerId));
group.on('peer-joined', p => …);  group.on('peer-left', ({ id }) => …);
await group.connect();
await group.publish(await navigator.mediaDevices.getUserMedia({ video: true, audio: true }));
group.mute();            // audio; group.mute('video') for the camera
group.disconnect();
</script>
```

`connect()` without `publish()` is receive-only, which is what `subscriber`-scope tokens get
(they cannot send offers). The `peerId` in `track` events is the publisher's peer id: the SFU
sets each forwarded track's stream id to its owner.

Events: `connected`, `sfu-ready`, `peer-joined`, `peer-left`, `track`, `track-ended`,
`stream-ended`, `muted`/`unmuted`, `connection-state`, `data`, `error`, `disconnected`.

## Protocol

All messages use the normal signaling format (`docs/API.md`) with `to: "sfu"` / `from: "sfu"`.

| Direction | Message | Meaning |
|---|---|---|
| client → sfu | `offer` `{sdp,type}` | publish: the client's tracks (one PeerConnection per client) |
| sfu → client | `answer` `{sdp,type}` | accepts the publish |
| client → sfu | `data` `{kind:"subscribe"}` | receive-only connection (no offer needed; works for subscribers) |
| sfu → client | `offer` `{sdp,type}` | renegotiation whenever the set of forwarded tracks changes |
| client → sfu | `answer` `{sdp,type}` | completes the renegotiation |
| both | `ice` (RTCIceCandidateInit) | trickle ICE |
| client → sfu | `data` `{kind:"hangup"}` | close the connection (also done on `peer-left`) |
| sfu → client | `data` `{kind:"sfu-error", error}` | negotiation failure |

Negotiation follows the *perfect negotiation* pattern: the SFU is the impolite side and
ignores a client offer that collides with its own outstanding offer; the client (polite)
rolls back, answers, and re-offers from `onnegotiationneeded`.

Forwarding: each incoming `TrackRemote` becomes a `TrackLocalStaticRTP` added to every other
peer's connection; RTP packets are copied as-is (payload type and SSRC rewritten per receiver).
The SFU sends a PLI to every video publisher every 3 s and when a peer connects, so
late joiners get a keyframe without the SFU parsing the codec stream. When a peer leaves or
its connection fails, its tracks are removed and the remaining peers are renegotiated.

## Limits

- **No simulcast or SVC.** Each publisher sends one quality; every subscriber receives it.
  Cap the capture resolution in the client (the demo uses 640×480) for rooms above ~8.
- **No bandwidth adaptation** across subscribers: one slow receiver does not slow the
  publisher, but it will see loss. NACK/RTX from the default pion interceptors apply per leg.
- **CPU per track**: roughly one goroutine per published track plus per-receiver SRTP
  encryption. Budget ~N×(N−1) encrypted sends per packet per room with N video publishers;
  a 2-core node handles a handful of 8-person video rooms, more for audio-only.
- **No recording, no audio mixing, no active-speaker detection.**
- **Keyframes** are requested on a 3 s timer only; a late joiner may see up to 3 s of
  black video.
- Audio and video codecs are whatever the browsers negotiate (opus, VP8/VP9/H.264/AV1 as
  registered by pion's defaults); all subscribers of a track must support its codec.

## Deployment and scaling

- The SFU runs **inside the API process**; media flows through the API host, so it needs a
  public IP (or `SFU_PUBLIC_IP` for 1:1 NAT) and an open UDP range. Set
  `SFU_UDP_PORT_MIN` / `SFU_UDP_PORT_MAX` (for example 50000–50200) and open it in the
  firewall. `SFU_USE_TURN=true` makes the SFU also allocate through the configured TURN relay
  (only useful when the API host itself is unreachable directly).
- One SFU process per node: a room is pinned to the node its signaling hub runs on. When
  running several API nodes, route a room's WebSocket connections to one node (sticky by
  room id at the load balancer); there is no cascading between nodes.
- Scale vertically first (CPU and uplink: each video subscriber costs the publisher bitrate in
  egress). Rooms above ~30 participants, simulcast, recording or multi-region should move to
  a dedicated media server (LiveKit, mediasoup, Janus); keep this SFU for small groups and
  as the on-ramp.
- Metrics: `GET /v1/rooms/{id}/sfu` per room; the hub's `/metrics` counters include the
  `sfu` peer in room sizes.

## Testing

`internal/api/ext_sfu_test.go` spins up the API with `httptest`, enables the SFU on a room,
connects two pion clients over the signaling WebSocket, publishes an opus track from each and
asserts that each receives the other's track (`OnTrack`), that the SFU renegotiates, that
`metadata.sfu="true"` auto-starts it, and that it leaves when the room empties.
