dot-voice

Voice chat for Godot — microphone capture with push-to-talk or voice activation, a pluggable codec, one jitter buffer per speaker, and server-side routing to everybody, a team or a radius.

dot-voice is voice chat: capture, a pluggable codec, a jitter buffer per speaker, and routing decided on the server.

Requires dot-core. It works with dot-moderation and dot-server without importing either.

Capture Push-to-talk, or voice activation with hysteresis and a hangover so words are not clipped.
Codecs pcm16 (exact) and adpcm (a quarter of the bandwidth). Register your own and nothing else changes.
Wire A twelve-byte header carrying a full 32-bit speaker peer id, sequence, frame length, channel and codec. Every field bounds-checked, because a voice packet arrives before its sender has done anything trustworthy.
Playback A jitter buffer per speaker that reorders, drops duplicates, caps its own growth, and fades over a lost frame rather than cutting to silence.
Routing All, team, proximity, or a filter you write. Decided on the server.
Muting A moderator’s mute is enforced on the server. A player’s own mute list is applied on their machine. Those are different features and it matters.

Three things worth knowing first

Nothing opens a microphone on its own. Not on ready, not because a config flag was true in an exported default. start_capture() is an explicit call you make after the player has agreed, and it returns a failure you can put on screen.

The devices are behind an interface, which is why there is a test suite at all. DotVoiceSource and DotVoiceSink have a real implementation and a buffer implementation, so the suite writes samples in one end and reads audio out of the other with no audio device in the process — the gate, the codec, the packet, the router and the jitter buffer all run the code a player runs.

Both ends must agree on the format. Sample rate, frame length and codec. DotVoiceConfig.format_fingerprint() is one number to exchange at handshake, because a mismatch is not an error — it is noise, or speech at the wrong speed.

The server’s switch and the game’s channel

sv_voice_enable turns voice off on the router, which then relays nothing (quietly, and counted), and sv_voice_mode forces a channel over the game’s own. Register them with DotVoiceRouter.bind_cvars(add, FLAG_REPLICATED) on the authoritative router; clients call DotVoiceManager.follow_cvars(link) and only hide their key, because the enforcement is the server’s. If the shell has not published its link yet, follow_cvars(null) waits for it.

Every packet is relayed on the router’s default_channel, never the one the client wrote. That field was once never read, so every game that chose team or proximity voice relayed to everybody. honour_packet_channel opts back into the packet’s field.

Proximity, gag and mute

Proximity voice is a gain the server works out per listener and carries in the packet’s flags byte (the version stays 2); a packet past the radius is not sent at all, and the router resets any gain bits a client wrote. The cvars are sv_voice_proximity_range, _min, _falloff, _occlusion, _occlusion_gain, _team_bypass and _global_bypass. Occlusion stays off until the game sets occlusion_fn and turns the cvar on. DotVoiceProximity holds the falloff curves and a ray occluder.

DotVoiceLookMute lets a player look at a speaker: a tap on voice_gag gags them, a double tap on voice_mute mutes them. Both are rebindable and local to that player’s machine, kept apart from local_mutes so a chat sm and a look-mute do not undo each other. The server only says whether they are allowed: sv_voice_gag_allowed, sv_voice_mute_allowed and sv_voice_gag_default.

The microphone opens on the first press

DotVoiceManager.update_talk(held) opens the microphone on the first press of the talk key (TALK_ACTION, dot_voice_talk, V by default). That press is the player’s consent, and it is the user gesture a browser insists on before it will grant a microphone. A voice build in a browser needs the project’s audio/driver/enable_input on, and for a delivered game it is the client shell’s setting that counts. Positional playback needs speaker_position_fn; without it voices are flat rather than sitting at the origin.

Where a game plugs in

To change Where
Where bytes go DotVoiceManager.send_fn, DotVoiceRouter.send_fn
Where audio comes from DotVoiceSource subclass
Where audio goes DotVoiceSink subclass, or DotVoiceManager.sink_factory
The codec DotVoiceCodec subclass plus DotVoiceCodec.register
Who hears whom DotVoiceRouter.team_fn / position_fn / listener_filter
Whether a listener in proximity range can actually hear — a wall, a closed door DotVoiceRouter.can_hear_fn(listener, speaker, listener_at, speaker_at), asked only of listeners already inside the range
Who may be heard at all Anything with is_voice_muted(peer) registered as dot_mute_source
Feel and bandwidth DotVoiceConfig
DotVoiceCodec subclass

id(), encode(), decode(), bytes_for(), is_stateful(), reset(), duplicate_codec(). Then DotVoiceCodec.register(...). This is the seam an Opus GDExtension drops into — Godot exposes no Opus encoder to GDScript, and the two shipped codecs are what the language can do.

DotVoiceSource / DotVoiceSink subclass

start, stop, has_frame / can_accept, read_frame / write_frame, is_running, describe.

Signals

talking_changed, speaker_changed, frame_sent, speech_relayed, speech_refused, capture_failed. Those plus input_level() are what a voice HUD needs; drawing it is your game’s or dot-ui’s.

What is deliberately not here

  • Opus. The codec interface exists so a GDExtension can be dropped in.
  • Echo cancellation and noise suppression. Both are real signal processing and belong in a GDExtension. Push-to-talk is the reason this is usable without them.
  • A voice UI.
  • A transport. send_fn takes bytes, exactly as in dot-net.
  • Proof that the microphone works. The device layer needs an audio device, so the headless suite cannot cover it and says so rather than leaving you to find out.