FnVoice - End-to-End Audio Pipeline
How a spoken word travels from one client's microphone to another client's speaker, mapped to the actual classes and methods.
The pipeline has six stages across two threads on each side plus the network relay:
- Mic Capture -
VoiceChatRecorder(Unity main thread, 50 Hz coroutine) - Encoding -
AudioEncodeThread(background thread) ->OpusEncoder - Relay - the FnVoice server forwards the room event to peers
- Receive - the receiving client's transport enqueues the packet
- Decoding -
AudioDecodeThread(background thread) ->OpusDecoder - Playback -
VoiceChatPlayerorVoiceChatPlayerOnFilterRead(per remote speaker)
The same code lives in all three copies (main Unity project, the Unity package, and the FigNetXR
project); paths below are relative to the FnVoice module's Runtime/VoiceChat/.
Sequence diagram
sequenceDiagram
participant Mic as Mic Capture<br/>(VoiceChatRecorder · main thread)
participant Enc as Encoding Thread<br/>(AudioEncodeThread)
participant Srv as Server<br/>(FnVoice relay)
participant Cli as Client<br/>(receiver transport)
participant Dec as Decoding Thread<br/>(AudioDecodeThread)
participant Play as Playback<br/>(VoiceChatPlayer / OnFilterRead)
Note over Mic: MicrophoneReadCoroutine ticks @ 50 Hz
Mic->>Mic: _clip.GetData(sampleBuffer) - read mic @ record rate (48k on Quest)
Mic->>Mic: AudioResampler.ResampleAudio -> 16k (box-average decimation)
Mic->>Mic: preprocessor.ProcessCaptureFrame (AEC/AGC/NS - identity when off)
Mic->>Mic: VoiceDetectorFloat.Process (VAD: speak vs silence)
Mic-)Enc: OnSampleAvailable(16k frame) - enqueue to encode thread
Note over Enc: EncodeThread (background)
Enc->>Enc: muted? -> skip
Enc->>Enc: VoiceChatUtils.Compress -> OpusEncoder.Encode (Opus)
Enc->>Enc: encrypt (if enabled)
Enc->>Enc: build VoiceChatPacket -> FNVoice_RoomEvent
Enc->>Srv: send RoomEvent (Unreliable / RUDP)
Srv->>Cli: relay VOICE_CHAT_EVENT to room peers
Cli-)Dec: AddCompressedAudio(event) - enqueue incoming
Note over Dec: DecodeThread (background)
Dec->>Dec: find DecoderState by session (NetworkId)
Dec->>Dec: out-of-order? PacketId <= LastPacketId -> drop
Dec->>Dec: decrypt (if needed)
Dec->>Dec: OpusDecoder.Decode -> PCM (16k, 640 samples)
Dec->>Dec: enqueue -> decodedVoipPacketsChannel
Note over Play: Update() on the remote player (main thread)
Play->>Dec: TryGetDecodedPacket()
Dec--)Play: ArraySegment<float> PCM
Play->>Play: ApplyGain
Play->>Play: write to clip / ring buffer -> AudioSource
Play->>Play: playDelay from FV.GetPing() (jitter buffer)
Step-by-step
1. Capture - VoiceChatRecorder (main thread, 50 Hz coroutine)
MicrophoneReadCoroutine->ProcessMicrophoneData->ReadAndProcessSample:_clip.GetData(...)pulls mic samples at the device record rate (Quest reportscaps=16000/48000-> records at 48 kHz).AudioResampler.ResampleAudiodownsamples 48k->16k via a box-average decimation (ResampleDownsample). (The windowed-sinc downsampler here garbled 16k on-device - see Notes.)ProcessAudioSampleruns the preprocessor (IAudioPreprocessor.ProcessCaptureFrame- AEC/AGC/NS; a no-opIdentityAudioPreprocessorwhen Audio Processing is off) then VAD (VoiceDetectorFloat) to decide speak vs silence (with aforceTransmithold + a small pre-roll buffer).OnSampleAvailable(buffer)fires the 16 kHz frame to the encode side.
2. Encode - AudioEncodeThread + VoiceChatUtils / OpusEncoder
(wired by AudioPipelinePlayGround in the demo, or FigNetVoiceManager in production)
- The encode thread dequeues the frame, skips if muted, then
VoiceChatUtils.Compress->CodecFactory.GetEncoder->OpusEncoder.Encode(640-sample 16k frame -> ~35-55 byte Opus packet), optional encrypt, wrapped in aVoiceChatPacket. FigNetVoiceManagerpacks it into anFNVoice_RoomEventand sends it Unreliable (RUDP) to the server.
3. Relay - Server
- Forwards
VOICE_CHAT_EVENTto the other members of the voice room.
4-5. Receive + Decode - AudioDecodeThread (background thread)
- Incoming events ->
AddCompressedAudio->ProcessAudioFrameslooks up the per-senderDecoderState. DecoderState.Decode: out-of-order guard (PacketId <= LastPacketId-> drop, past the first packets), decrypt if needed,OpusDecoder.Decode-> 640 PCM samples @16k, enqueue to a boundeddecodedVoipPacketsChannel(DropOldest).
6. Playback - VoiceChatPlayer / VoiceChatPlayerOnFilterRead (per remote speaker)
Update()pulls decoded PCM viaTryGetDecodedPacket, applies gain, buffers it, and uses a small ping-based delay (FV.GetPing()) as a jitter buffer for smooth playback.- The two players differ only in the final hop:
VoiceChatPlayer- writes PCM into anAudioClipcreated at the codec rate (16k); Unity's engine resamples 16k->hardware on playback. (Manual resampling here drifted the volume - see Notes.)VoiceChatPlayerOnFilterRead- resamples 16k->AudioSettings.outputSampleRatewith the accumulatorAudioResamplerinto a ring buffer drained byOnAudioFilterRead.
Notes - on-device gotchas (Quest 3 / arm64 / IL2CPP)
Two C#-side issues garbled 16 kHz playback on-device while the editor was clean (the 48 kHz preset bypasses both resamplers, so it was unaffected). Both are fixed in the current code:
- Capture downsample: the windowed-sinc
ResampleWithAntiAliasing(sum/weightSum normalization) produced garbage on-device -> replaced byResampleDownsample(box-average). - Playback: a per-frame
VoicePcmResamplerupsample drifted the playback volume up/down -> replaced by the codec-rate-clip approach (VoiceChatPlayer) and the accumulatorAudioResampler(OnFilterRead).
The native libopus -ffast-math build flag was investigated and ruled out (voice is clean with the flag
on or off); it is kept off only because Opus discourages it and it yields no measurable speedup for a
voice codec.