import type { VoiceEffectSettings } from "./audio/voiceEffects.js"; import type { AudioUDPOffer } from "./transport/session.js"; export type CaptureQuality = "native" | "4k" | "1440p" | "1080p" | "720p" | "480p" | "360p" | "240p" | "144p" | "96p" | "64p" | "48p" | "32p" | "24p" | "16p" | "8p" | "4p"; export type CameraFps = 5 | 10 | 15 | 24 | 30 | 60; /** Higher than the camera's, because a screen share of a game wants them. */ export type ScreenShareFps = 30 | 60 | 90 | 120 | 144 | 165 | 240; export interface VoiceConfig { audio: { /** Empty means "whatever the platform hands back". */ deviceId?: string; muted: boolean; /** Set by a moderator, not by the person. Distinct from `muted` on purpose. */ serverMuted: boolean; deafened: boolean; serverDeafened: boolean; /** Playback gain for everyone else, 0–1. */ outputVolume: number; inputMode: "voice_activity" | "push_to_talk"; /** How loud the captured signal is sent, 0–1. */ volume: number; /** Play the microphone back locally, for testing it. */ loopback: boolean; /** Off on native: the platform does its own noise suppression. */ noiseSuppression: boolean; /** Threshold in dB. 0 disables gating, which push-to-talk relies on. */ noiseGate: number; /** How long the gate stays open after the signal drops, in ms. */ noiseGateRelease: number; autoGain: { enabled: boolean; targetDb: number; }; compressorEnabled: boolean; compressorAmount: number; /** The own transport's voice bitrate, 96–192. Unset is 192 (GRYT-1823). */ bitrateKbps?: number; }; camera: { deviceId?: string; quality: CaptureQuality; fps: CameraFps; codec?: string; mirrored: boolean; }; screen: { quality: CaptureQuality; fps: ScreenShareFps; codec?: string; /** Prefers framerate over resolution. */ gamingMode: boolean; }; connection: { stunHosts: string[]; /** Lower latency, fewer niceties. */ eSportsMode: boolean; maxBitrate?: number | null; /** Loads opus.wasm. Set it to try the own voice transport when the SFU offers one (GRYT-1818). */ audioTransportWasm?: () => Promise; /** Use the native voice engine when the platform has one and the SFU offers UDP (GRYT-1822). */ nativeAudio?: boolean; }; } export interface SfuTransport { send(message: SfuOutbound): void; onMessage(handler: (message: SfuInbound) => void): () => void; readonly ready: boolean; close(): void; } export type SfuOutbound = { event: "client_join"; data: string; } | { event: "offer"; data: string; } | { event: "answer"; data: string; } | { event: "candidate"; data: string; } | { event: "renegotiate"; data: string; } | { event: "keep_alive"; data: string; } | { event: "still_here"; data: string; }; export type SfuInbound = { event: "room_joined"; data: string; } | { event: "offer"; data: string; } | { event: "answer"; data: string; } | { event: "candidate"; data: string; }; export interface RoomCoordinator { requestAccess(channelId: string): Promise; /** Mirrors `voice:room:leave`, which carries nothing. */ leave(): void; /** Mirrors `voice:channel:joined`. False on the way out. */ announceJoined(joined: boolean): void; setLocalStream(streamId: string | null): void; peerChanged(streamId: string, present: boolean): void; readonly connected: boolean; onReconnected(handler: () => void): () => void; } export interface RoomAccess { granted: boolean; roomId?: string; sfuUrls?: string[]; /** Opaque; the engine forwards it to the SFU and does not read it. */ joinToken?: unknown; cacheKey?: string; /** Populated when refused, so the caller can say why rather than "failed". */ reason?: string; retryAfterMs?: number; } export interface VoicePlatform { /** For logs and for the one or two places behaviour genuinely differs. */ readonly name: string; createPeerConnection(config: RTCConfiguration): RTCPeerConnection; getMicrophone(deviceId?: string): Promise; getCamera(constraints: CameraConstraints): Promise; /** Undefined where the platform has no such concept, which is phones. */ getScreen?(constraints: ScreenConstraints): Promise; createNoiseSuppressor?(): NoiseSuppressor; createAudioPipeline?(options: AudioPipelineOptions): AudioPipeline; startNativeAudio?(offer: AudioUDPOffer, effects: VoiceEffectSettings): Promise; } export interface NativeAudioSession { setSending(on: boolean): void; setOutputGain(gain: number): void; setEffects(effects: VoiceEffectSettings): void; /** Mono bits per second, stereo at 4/3 of it (GRYT-1823). Optional: older engines stay at 192 kbps. */ setBitrate?(bitrate: number): void; /** The sender ids heard speaking, for the SFU's audio_senders to name (GRYT-1830). */ speaking(): number[]; /** Volume per sender id, 1 = as sent; anyone not named plays at 1 (GRYT-1831). */ setGains(gains: Map): void; /** Each sender heard and its level in dBFS, and the mic's, for the halo. Silence is -120. */ levels(): { ids: number[]; db: number[]; mic: number; micSpeaking: boolean; }; /** Each sender's audio waiting to play, and the audio session's own delays, all in ms. */ latency(): NativeLatency; close(): void; } export interface NativeLatency { ids: number[]; bufferedMs: number[]; inputMs: number; outputMs: number; ioBufferMs: number; /** The rate the audio session actually runs at. Below 48000 the voice is band-limited. */ sampleRate: number; /** Measured round trips to each sender, in ms, aligned with ids; -1 where none has come back yet (GRYT-1836). */ rttMs: number[]; } export interface CameraConstraints { deviceId?: string; width?: number; height?: number; fps: number; } export interface ScreenConstraints { width?: number; height?: number; fps: number; withAudio: boolean; } export interface NoiseSuppressor { initialize(audioContext: AudioContext): Promise; setEnabled(enabled: boolean): void; /** Null until `initialize` resolves, and after `destroy`. */ getNode(): AudioWorkletNode | null; destroy(): void; } export interface AudioPipelineOptions { /** The capture stream to process. */ source: MediaStream; noiseSuppression: boolean; compressorAmount: number; /** Ignored where the platform has no equivalent. */ gain?: number; } export interface AudioPipeline { readonly output: MediaStream; /** For the speaking indicator. Null where the platform cannot measure it. */ getLevel(): number | null; setGain(value: number): void; setMuted(muted: boolean): void; destroy(): void; } export interface VoiceEngineOptions { config: VoiceConfig; transport: SfuTransport; room: RoomCoordinator; platform: VoicePlatform; /** The client's `voiceLog`, which is how every hard bug here has been found. */ log?: VoiceLogger; } export interface VoiceLogger { info(scope: string, message: string, detail?: unknown): void; warn(scope: string, message: string, detail?: unknown): void; fail(scope: string, message: string, detail?: unknown): void; }