Skip to content

Voice: client-side capture, relay and spatial mixing (M2) - #245

Merged
Segfaultd merged 1 commit into
developfrom
feature/voice-client
Jul 30, 2026
Merged

Voice: client-side capture, relay and spatial mixing (M2)#245
Segfaultd merged 1 commit into
developfrom
feature/voice-client

Conversation

@Segfaultd

@Segfaultd Segfaultd commented Jul 30, 2026

Copy link
Copy Markdown
Member

Completes proximity voice chat. The server half landed in #243, but nothing had ever produced or consumed a frame — this is the half that makes the feature exist.

Clients encode Opus and send to the server, which relays the payload without decoding it. Receiving clients decode one stream per speaker and mix with distance attenuation and constant-power stereo panning.

What's here

  • vendors/miniaudio/ — miniaudio 0.11.25, pinned by commit, trimmed to device I/O (no decoders, resource manager, node graph or engine).
  • voice/client/audio_device.{h,cpp}CaptureDevice (s16/mono/48k → SpscRing) and PlaybackDevice (f32/stereo/48k, pulls a render callback).
  • voice/client/i_voice_sink.hSubmit / ReleaseSpeaker, so a project can render voice through its own audio engine without touching transport, codec or scripting.
  • voice/client/voice_client.{h,cpp} — the relay session, push-to-talk, speaker admission, and the built-in mixing sink.
  • mixer.cppLimitStereoBuffer, the counterpart to MixFrameInto's deliberate lack of clamping, with five new tests.

The relay session opens on connect, not at init: the encoder channel is keyed on our own GUID and the relay target is the server, and neither exists earlier.

Generic by default

Speaker positions come from the replicated entity set in the client Instance, mirroring how the server's voice router already gets them. Proximity voice is therefore positioned correctly for any mod out of the box — a project supplies only the listener transform, which is the one part that depends on its camera.

The Instance also suppresses transmission whenever its own chat box has the caret or a web view holds focus, so no mod can accidentally broadcast a player typing.

Voice is unconditional — no InstanceOptions opt-out. A client with no microphone degrades to listen-only rather than disabling the feature.

Threading

The device thread touches only wait-free rings, per-slot atomic ids and a triple-buffered world snapshot. It never allocates, locks, or calls MafiaNet. Triple buffering rather than double, so the writer's next target is never a buffer the reader picked up one callback ago.

Two-layer bound

kMaxAudibleTalkers (6) caps the mix fan-in via a fixed slot array, evicting the most distant talker for a nearer one with hysteresis so speakers at similar range can't trade the last slot every tick.

Decode cannot be bounded from the Framework at all — RakVoice decodes in OnReceive, inside RakPeer::Receive, before application code sees a frame. So kMaxDecodedTalkers (12) goes to RakVoice::SetMaxDecodedSpeakers, added in MafiaNet for this. It is deliberately larger than the mix cap: were they equal, the codec's recency-based choice would decide who is audible and the distance-based mixer would have nothing left to choose between.

⚠️ Version bump

This bumps cmake/MafiaNetPin.cmake, which bump_version.sh classifies as MAJOR.

That is conservative here: the relay wire format and the message-id enum are untouched, the new MafiaNet API is additive and off by default, and old/new peers are genuinely compatible. The pin is a proxy for "the wire may have moved" and the tooling can't tell the difference. Consider overriding the classification rather than spending a major on an additive API.

Depends on MafiaNet caf9469a (already on master).

Testing

Verified on a real two-client session (two instances, one server): capture → Opus encode → relay → decode → per-speaker mix → playback, with clean session open/close on both peers and zero warnings or errors. Devices negotiate 48 kHz natively, so no resampling.

Playback was initially mushy. Root cause was a missing jitter buffer: RakVoice flushes on a 50 ms throttle while the device consumes every 20 ms, so frames arrive in bursts and playing the first one that lands left the buffer riding empty — every hiccup spliced silence mid-word, which sounds like distortion rather than a gap. Speakers now prime to 60 ms and re-prime after an underrun, and the buffer is capped so late-packet bursts and capture/playback clock drift can't push voice steadily further behind. Bitrate also went 24 → 40 kbps (Opus' reference for a mono stream is 64). Confirmed clean by ear afterwards.

FrameworkTests: 187 passing, 0 failed (was 182; +5 for the limiter). FrameworkClient 64-bit and M2OClient 32-bit both build clean. check_style.sh shows zero new violations against develop.

Not yet exercised, all needing more than two co-located players: distance falloff and the 25-unit cutoff, left/right panning, two simultaneous talkers, slot eviction (7+ speakers) and the new decode cap (13+).

Follow-up

Opus inband FEC is off (libopus default) and RakVoice exposes no ctl for it. Irrelevant on a LAN, but it will matter over the internet on UnreliableSequenced. That's another MafiaNet change plus a pin bump, so it's deliberately not bundled here.

Completes the voice feature: nothing had produced or consumed a frame
until now. Clients encode Opus and send to the server, which relays
without decoding; receiving clients decode one stream per speaker and
mix with distance attenuation and stereo panning.

Vendors miniaudio 0.11.25 (pinned by commit, device I/O only) and adds
CaptureDevice/PlaybackDevice, IVoiceSink, and VoiceClient. The relay
session opens on connect, since the encoder channel is keyed on our own
GUID and the relay target is the server -- neither exists before then.
Registered via CoreModules alongside VoiceServer.

Voice is unconditional; a client with no microphone degrades to
listen-only rather than opting out.

Speaker positions come from the replicated entity set in the client
Instance, mirroring how the server's voice router gets them, so
proximity voice is positioned correctly for any mod out of the box. A
project supplies only the listener transform, which is the one part
that depends on its camera. The Instance also suppresses transmission
whenever its own chat box has the caret or a web view holds focus, so
no mod can accidentally broadcast a player typing.

Threading: the device thread touches only wait-free rings, per-slot
atomic ids and a triple-buffered world snapshot. It never allocates,
locks, or calls MafiaNet. Triple buffering rather than double, so the
writer's next target is never a buffer the reader picked up one
callback ago.

Playback is jitter buffered. RakVoice flushes on a 50ms throttle while
the device consumes every 20ms, so frames arrive in bursts and playing
the first one that lands leaves the buffer riding empty; every hiccup
then splices silence into the middle of a word, which sounds like
distortion rather than a gap. Speakers now prime to 60ms before being
heard and re-prime after an underrun, and the buffer is capped so
late-packet bursts and capture/playback clock drift cannot push voice
steadily further behind. Bitrate is 40kbps: Opus' reference for a mono
stream is 64kbps and 24kbps was audibly band-limited.

Bounds are split across two layers. kMaxAudibleTalkers caps the mix
fan-in via a fixed slot array, evicting the most distant talker for a
nearer one with hysteresis so speakers at similar range cannot trade
the last slot every tick. Decode cannot be bounded from here at all --
RakVoice decodes in OnReceive before application code sees a frame --
so kMaxDecodedTalkers goes to RakVoice::SetMaxDecodedSpeakers, added in
MafiaNet for this and picked up by the pin bump. It is deliberately
larger than the mix cap: were they equal, the codec's recency-based
choice would decide who is audible and the distance-based mixer would
have nothing left to choose between.

Moves final-stage limiting into mixer.cpp as LimitStereoBuffer, the
counterpart to MixFrameInto's deliberate lack of clamping, and covers
it with five tests (187 total, was 182).

No SetNoiseFilter: RNNoise needs 480-sample frames and voice runs at
960. No SetLoopbackMode: it is a no-op in relay mode, since
OnRelayVoiceData drops frames whose origin is our own GUID.
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Adds a RakVoice client voice subsystem with miniaudio capture/playback, spatialized speaker mixing, framework lifecycle integration, updated voice configuration, and stereo limiter tests.

Changes

Client voice subsystem

Layer / File(s) Summary
Voice dependency and build wiring
cmake/MafiaNetPin.cmake, code/framework/CMakeLists.txt, vendors/*
Pins the MafiaNet voice changes, adds miniaudio as a vendor library, and links and compiles voice sources for the client.
Voice contracts and audio primitives
code/framework/src/voice/voice_config.h, code/framework/src/voice/client/audio_device.*, code/framework/src/voice/client/i_voice_sink.h, code/framework/src/voice/client/voice_client.h, code/framework/src/voice/client/mixer.*
Defines voice configuration, capture/playback device APIs, sink contracts, client state interfaces, and nonlinear stereo limiting.
Voice client runtime
code/framework/src/voice/client/voice_client.cpp
Implements voice sessions, microphone transmission, speaker admission and eviction, decoded-frame routing, spatialized mixing, jitter handling, and sink switching.
Framework lifecycle integration and validation
code/framework/src/core_modules.h, code/framework/src/integrations/client/instance.*, code/tests/modules/voice_mixer_ut.h
Registers and updates the voice client during framework initialization, networking, and shutdown, while testing limiter bounds, monotonicity, and volume behavior.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

  • MafiaHub/Framework#243: Adds the corresponding RakVoice server-side subsystem and shares the MafiaNet pin update.

Poem

A rabbit heard voices hop through the air,
With miniaudio humming a tune everywhere.
Speakers were placed, and soft limits grew,
Framework threads carried each note through.
“Squeak!” said the bunny, “the mix sounds bright!”

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 2.28% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly captures the main change: client-side voice capture, relay, and spatial mixing.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/voice-client

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cmake/MafiaNetPin.cmake`:
- Line 27: Update the MafiaNet FetchContent configuration in
vendors/CMakeLists.txt to disable GIT_SHALLOW when using the MAFIANET_PIN commit
hash, while preserving the existing pinned revision and other fetch settings.

In `@code/framework/src/integrations/client/instance.cpp`:
- Around line 549-557: Update Instance::UpdateNetworking() so SetSpeakerPosition
uses the same MafiaNet::ToPeerGuid-derived key as PumpSpeakers(), rather than
casting entity->ownerGUID directly. Preserve the existing unassigned-owner guard
and ensure speaker admission and placement lookups share one identifier
representation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 60420e62-99e2-40e8-af93-b8d2c8d86f10

📥 Commits

Reviewing files that changed from the base of the PR and between 9c1f9b1 and bc1d5c4.

📒 Files selected for processing (19)
  • cmake/MafiaNetPin.cmake
  • code/framework/CMakeLists.txt
  • code/framework/src/core_modules.h
  • code/framework/src/integrations/client/instance.cpp
  • code/framework/src/integrations/client/instance.h
  • code/framework/src/voice/client/audio_device.cpp
  • code/framework/src/voice/client/audio_device.h
  • code/framework/src/voice/client/i_voice_sink.h
  • code/framework/src/voice/client/mixer.cpp
  • code/framework/src/voice/client/mixer.h
  • code/framework/src/voice/client/voice_client.cpp
  • code/framework/src/voice/client/voice_client.h
  • code/framework/src/voice/voice_config.h
  • code/tests/modules/voice_mixer_ut.h
  • vendors/CMakeLists.txt
  • vendors/miniaudio/CMakeLists.txt
  • vendors/miniaudio/LICENSE
  • vendors/miniaudio/miniaudio.c
  • vendors/miniaudio/miniaudio.h

Comment thread cmake/MafiaNetPin.cmake
Comment thread code/framework/src/integrations/client/instance.cpp
@Segfaultd
Segfaultd merged commit 0721a80 into develop Jul 30, 2026
6 checks passed
@Segfaultd
Segfaultd deleted the feature/voice-client branch July 30, 2026 12:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants