# The seams

Source: https://docs.gryt.chat/docs/build/voice-seams

The five interfaces the engine can't work out for itself

Signalling, ICE, track management and the connection state machine are the same wherever the engine runs. Five interfaces cover the parts that aren't, and they're the whole API surface an embedder has to think about.

## VoiceConfig

What the person has chosen. It's passed in and never read from a store, which is also what makes the values testable without a React tree.

```typescript
interface VoiceConfig {
  audio: {
    deviceId?: string;
    muted: boolean;
    serverMuted: boolean;
    deafened: boolean;
    serverDeafened: boolean;
    outputVolume: number;
    inputMode: "voice_activity" | "push_to_talk";
    volume: number;
    loopback: boolean;
    noiseSuppression: boolean;
    noiseGate: number;
    noiseGateRelease: number;
    autoGain: { enabled: boolean; targetDb: number };
    compressorEnabled: boolean;
    compressorAmount: number;
  };
  camera: {
    deviceId?: string;
    quality: CaptureQuality;
    fps: CameraFps;
    codec?: string;
    mirrored: boolean;
  };
  screen: {
    quality: CaptureQuality;
    fps: ScreenShareFps;
    codec?: string;
    gamingMode: boolean;
  };
  connection: {
    stunHosts: string[];
    eSportsMode: boolean;
    maxBitrate?: number | null;
  };
}
```

`muted` and `serverMuted` are separate on purpose: one is the person's choice, the other a moderator's. `noiseGate` is a threshold on a 0 to 100 scale, not a decibel figure, and 0
disables gating, which is what push-to-talk relies on. The gate takes an RMS
level, converts it to dBFS, and maps that onto 0 to 100 before comparing. The
doc comment on the field in `types.ts` says dB and is wrong; the `AudioParam` it
feeds is declared `minValue: 0, maxValue: 100`. `noiseSuppression` is off on native platforms, where the OS does its own.

`stunHosts` deserves a note. The client derives these from whichever server is on screen. The engine isn't told which server that is, because knowing about a server list is the one thing it's deliberately kept out of, so it gets the answer instead of the lookup.

<Callout type="warn" title="inputMode uses underscores">
`"voice_activity"` and `"push_to_talk"`. This field said `"voice-activity"` and `"push-to-talk"` until GRYT-340 and nothing caught it, because the only code reading it was inside the package and the only code writing it was in the client. Both halves compiled, and they would have met for the first time at runtime with push-to-talk never engaging.
</Callout>

### Delivery

`VoiceConfigProvider` is how it arrives, and `useVoiceConfig` is how the engine reads it.

```tsx
<VoiceConfigProvider config={config} callbacks={callbacks} target={target}>
  {children}
</VoiceConfigProvider>
```

`callbacks` is the other direction: things the engine finds out and the app might want to store. There's one so far, `onCameraDeviceChanged`, which fires when the camera that opened isn't the one that was configured, usually because it has been unplugged. These are deliberately not setters. The engine says what it observed and the app writes it down, or doesn't.

## VoiceTarget and RoomCoordinator

`VoiceTarget` is where to connect and what to talk to when it gets there.

```typescript
interface VoiceTarget {
  id: string;
  room: RoomCoordinator;
}
```

The engine never learns what `id` means. It compares it, reports it on the connection state, and uses it as a cache key.

`RoomCoordinator` is the part of joining a channel that isn't generic WebRTC. Who may enter a channel and how many fit are the server's rules, so the engine asks and something else answers.

```typescript
interface RoomCoordinator {
  requestAccess(channelId: string): Promise<RoomAccess>;
  leave(): void;
  announceJoined(joined: boolean): void;
  setLocalStream(streamId: string | null): void;
  peerChanged(streamId: string, present: boolean): void;
  readonly connected: boolean;
  onReconnected(handler: () => void): () => void;
}
```

`setLocalStream` takes a stream id rather than a description of what is being published, because the server matches the id against what arrives at the SFU and doesn't care whether it's a camera or a screen.

`connected` and `onReconnected` exist for the reconnect policy. Retrying the SFU while signalling is down burns attempts against something that can't answer.

`requestAccess` resolves with a `RoomAccess`:

```typescript
interface RoomAccess {
  granted: boolean;
  roomId?: string;
  sfuUrls?: string[];
  joinToken?: unknown;
  cacheKey?: string;
  reason?: string;
  retryAfterMs?: number;
}
```

`sfuUrls` is a list of candidates rather than a single answer. The engine probes them and picks, which is what `selectBestSfuUrl` is for. Handing over one chosen URL would either throw that away or push it into every embedder. `cacheKey` is what the chosen URL is cached against so a reconnect can skip the probing, and it's opaque to the engine. The Gryt client passes the server's host, which is exactly the sort of thing the engine isn't supposed to know it has.

`reason` and `retryAfterMs` are populated when access is refused, so the caller can say why instead of "failed".

## VoiceHost

Two questions about the runtime, both fixed for the life of the process.

```typescript
interface VoiceHost {
  hasNativeCapture(): boolean;
  getNativeAudio(): NativeAudioCapture | null;
  getNativeScreen(): NativeScreenCapture | null;
  allowsInsecureTransport(): boolean;
}
```

Both capture surfaces are optional and independent. A platform may have one and not the other, and returning null is a normal answer rather than a failure: callers fall back to the standard web capture path.

`allowsInsecureTransport` is a separate question from native capture, and conflating them is a real mistake rather than a tidy shortcut. What it really asks is whether the code is inside a browser's mixed-content sandbox, which is why LAN servers on plain `ws://` are reachable from the desktop app and invisible from app.gryt.chat. On Electron both answers happen to be the same expression, which is how they came to be conflated in the first place. React Native is the counterexample: native capture and no mixed-content rule.

The default is `webHost`, which answers no to both.

## SfuTransport

Signalling with the SFU, in the generic half: offer, answer, candidate, and a keep-alive. The package owns what the messages mean and the caller owns the socket, so an embedder can carry them over anything.

```typescript
interface SfuTransport {
  send(message: SfuOutbound): void;
  onMessage(handler: (message: SfuInbound) => void): () => void;
  readonly ready: boolean;
  close(): void;
}
```

Outbound events are `client_join`, `offer`, `answer`, `candidate`, `renegotiate` and `keep_alive`. Inbound are `room_joined`, `offer`, `answer` and `candidate`. Every one carries a string `data`.

## VoicePlatform

Capture, playback and peer construction, which differ per platform.

```typescript
interface VoicePlatform {
  createPeerConnection(config: RTCConfiguration): RTCPeerConnection;
  getMicrophone(deviceId?: string): Promise<MediaStream>;
  getCamera(constraints: CameraConstraints): Promise<MediaStream>;
  getScreen?(constraints: ScreenConstraints): Promise<MediaStream>;
  listDevices(): Promise<VoiceDevice[]>;
  createAudioPipeline(options: AudioPipelineOptions): AudioPipeline;
}
```

`getScreen` is optional because some platforms have no such concept, which is phones. `createAudioPipeline` is the audio graph: on the web it's built from `AudioContext` and `AudioWorklet`, and a native implementation would use its own audio API and skip noise suppression, since the phone already does it.

The interface is deliberately narrow. Everything that isn't capture or playback is shared, so anything you want to add here is worth re-examining first.

<Callout type="info" title="VoicePlatform is wired up, SfuTransport is not">
`VoicePlatform` has been wired up since `0.4.0`. Each entry point registers one on the way in. The main barrel registers the web platform, `@gryt/voice/native` registers the React Native one, and `setVoicePlatform` overrides whichever was registered. `useMicrophone` and the SFU connect flow both call `getVoicePlatform()`, so an embedder that supplies nothing still gets a working web platform.

`SfuTransport` is still a type nothing consumes. The engine opens its own WebSocket to the SFU, and there's no way to pass one in. Read it as the intended boundary rather than as configuration.
</Callout>

## Logging

The package logs its own connect and disconnect flow through `voiceLog`, which is exported and prints numbered, colour-coded steps to the console under phases like `CONNECT`, `MIC`, `SFU-WS` and `WEBRTC`. It's how every hard bug in this code has been found, and it's worth reading the console the first time a call doesn't come up.

```typescript

voiceLog.step("CONNECT", 1, "Requesting microphone access");
voiceLog.ok("CONNECT", 1, "Microphone acquired", { deviceId });
voiceLog.fail("CONNECT", 1, "Microphone denied", error);
```

A `VoiceLogger` interface is exported alongside `VoiceEngineOptions`, for injecting a logger rather than using this one. Like `SfuTransport`, nothing reads it yet.
