Using FnVoice with a different networking stack (PUN2 / Fusion 2 / any transport)
FnVoice's audio pipeline (mic capture -> VAD/APM -> Opus encode -> jitter/PLC decode -> playback) is decoupled from FigNet behind two seams. To run it over any networking solution you implement two small adapters and reuse one boilerplate driver - FnVoice never opens a socket itself.
your transport --(opaque bytes)--> FnVoiceEngine --(PCM)--> AudioSource
^ IVoiceTransport (you write) ^ IVoiceSession (you write)
| |
+----- FnVoiceHost driver (copy the provided one, boilerplate) -----+
IVoiceTransport- you send FnVoice's opaque encoded bytes over your transport, and on receive you hand the bytes back withFnVoiceEngine.OnVoicePacket(senderId, bytes).IVoiceSession- you tell FnVoice who is in the call (a stableuintpeer id per player + join/leave).FnVoiceHost- the pipeline glue (encode/decode threads, per-speaker players). This is identical for every transport, so copy the providedLoopbackVoiceDemo(rename it) - you don't have to write it.
Everything above the wire - VAD, WebRTC APM (AEC/AGC/NS), the AudioMixer bus, follow-transform positional voice - keeps working unchanged.
The seam interfaces (for reference)
namespace FigNet.Voice
{
public interface IVoiceTransport
{
void Broadcast(ArraySegment<byte> encodedFrame); // send the frame to everyone (always unreliable)
int GetRoundTripMs(); // -1 if unknown; feeds the adaptive jitter buffer
}
public interface IVoiceSession
{
uint LocalPeerId { get; }
bool IsActive { get; }
IReadOnlyList<VoicePeer> Peers { get; }
bool TryGetPeer(uint peerId, out VoicePeer peer);
event Action<VoicePeer> PeerJoined; // -> a decoder + AudioSource is created for the peer
event Action<uint> PeerLeft; // -> that peer's decoder + AudioSource is destroyed
event Action SessionEnded; // -> encode/decode threads stop
}
public readonly struct VoicePeer { public readonly uint Id; public readonly bool IsLocal; public readonly string DisplayName; }
}
You never touch AudioDecodeThread/DecoderState/VoiceChatPacket directly - the FnVoiceHost driver does.
Step 1 - implement IVoiceTransport (PUN2 example)
Voice frames are just opaque byte[]. In PUN2 the simplest carrier is PhotonNetwork.RaiseEvent.
using System;
using ExitGames.Client.Photon;
using Photon.Pun;
using Photon.Realtime;
using FigNet.Voice;
public sealed class PunVoiceTransport : IVoiceTransport, IOnEventCallback
{
private const byte VoiceEventCode = 199; // any unused Photon event code (0-199)
private static readonly RaiseEventOptions ToOthers = new RaiseEventOptions { Receivers = ReceiverGroup.Others };
public PunVoiceTransport() => PhotonNetwork.AddCallbackTarget(this);
public void Dispose() => PhotonNetwork.RemoveCallbackTarget(this);
// FnVoice -> your transport. Voice is always unreliable (loss is absorbed by PLC/FEC).
public void Broadcast(ArraySegment<byte> frame)
{
PhotonNetwork.RaiseEvent(VoiceEventCode, ToBytes(frame), ToOthers, SendOptions.SendUnreliable);
}
public int GetRoundTripMs() => (int)PhotonNetwork.GetPing();
// your transport -> FnVoice. Photon hands us the sender's ActorNumber; feed it straight in.
public void OnEvent(EventData e)
{
if (e.Code != VoiceEventCode) return;
var bytes = (byte[])e.CustomData;
FnVoiceEngine.OnVoicePacket((uint)e.Sender, new ArraySegment<byte>(bytes));
}
private static byte[] ToBytes(ArraySegment<byte> f)
=> (f.Offset == 0 && f.Count == f.Array.Length) ? f.Array : f.ToArray();
}
Step 2 - implement IVoiceSession (PUN2 example)
The session is just your player roster, keyed by a stable uint id. In PUN2 that's the ActorNumber.
using System;
using System.Collections.Generic;
using Photon.Pun;
using Photon.Realtime;
using FigNet.Voice;
public sealed class PunVoiceSession : IVoiceSession, IInRoomCallbacks
{
private readonly List<VoicePeer> _peers = new List<VoicePeer>();
public PunVoiceSession() => PhotonNetwork.AddCallbackTarget(this);
public void Dispose() => PhotonNetwork.RemoveCallbackTarget(this);
public uint LocalPeerId => (uint)PhotonNetwork.LocalPlayer.ActorNumber;
public bool IsActive => PhotonNetwork.InRoom;
public IReadOnlyList<VoicePeer> Peers
{
get
{
_peers.Clear();
foreach (Player p in PhotonNetwork.PlayerList) _peers.Add(ToPeer(p));
return _peers;
}
}
public bool TryGetPeer(uint id, out VoicePeer peer)
{
foreach (Player p in PhotonNetwork.PlayerList)
if ((uint)p.ActorNumber == id) { peer = ToPeer(p); return true; }
peer = default; return false;
}
public event Action<VoicePeer> PeerJoined;
public event Action<uint> PeerLeft;
public event Action SessionEnded;
private static VoicePeer ToPeer(Player p) => new VoicePeer((uint)p.ActorNumber, p.IsLocal, p.NickName);
public void OnPlayerEnteredRoom(Player p) => PeerJoined?.Invoke(ToPeer(p));
public void OnPlayerLeftRoom(Player p) => PeerLeft?.Invoke((uint)p.ActorNumber);
// Raise SessionEnded from your matchmaking OnLeftRoom callback (IMatchmakingCallbacks) - call:
public void NotifyLeftRoom() => SessionEnded?.Invoke();
// remaining IInRoomCallbacks members - leave empty
public void OnRoomPropertiesUpdate(ExitGames.Client.Photon.Hashtable p) { }
public void OnPlayerPropertiesUpdate(Player t, ExitGames.Client.Photon.Hashtable c) { }
public void OnMasterClientSwitched(Player n) { }
}
Step 3 - the FnVoiceHost driver (copy, don't write)
This is the transport-agnostic pipeline glue. Copy the shipped LoopbackVoiceDemo
(Assets/Modules/FnVoice/VoiceChat/Samples/Loopback/) into your project and rename it - it already does exactly
this. For reference, the generic form is:
using System;
using MemoryPack;
using UnityEngine;
using FigNet.Voice;
public class FnVoiceHost : MonoBehaviour // must live in Assembly-CSharp (default Unity scripts)
{
private AudioDecodeThread _decode;
private AudioEncodeThread _encode;
private VoiceChatRecorder _recorder;
private ushort _seq;
private void Start()
{
// FnVoiceEngine.Configure(...) must already have run (see bootstrap below).
FnVoiceEngine.PacketSink = OnDecodedPacket; // where inbound frames are decoded
_decode = AudioDecodeThread.CreateAndStart(this);
var s = FnVoiceEngine.Session;
s.PeerJoined += OnPeerJoined;
s.PeerLeft += OnPeerLeft;
s.SessionEnded += OnSessionEnded;
foreach (var p in s.Peers) OnPeerJoined(p); // spawn players already in the call
}
private void OnPeerJoined(VoicePeer peer)
{
if (peer.IsLocal) // us: capture + encode, no player
{
_recorder = VoiceChatRecorder.Instance;
if (_recorder != null) _recorder.OnSampleAvailable += OnMic;
_encode = AudioEncodeThread.CreateAndStart(this);
_encode.ProcessAudioFrame = OnEncoded;
}
else // remote: a player + its decoder
{
var go = new GameObject("Voice_" + peer.Id);
go.transform.SetParent(transform, false);
var player = go.AddComponent<VoiceChatPlayerOnFilterRead>(); // RequireComponent adds the AudioSource
var decoder = _decode.AddDecoder(peer.Id);
player.Init(peer.Id, decoder);
VoicePlayers.Register(peer.Id, player); // lets VoiceLevels/roster resolve the speaker
}
}
private void OnPeerLeft(uint id)
{
_decode?.RemoveDecoder(id);
if (VoicePlayers.TryGet(id, out var p) && p != null) Destroy(p.transform.gameObject);
VoicePlayers.Unregister(id);
}
private void OnSessionEnded()
{
_encode?.Dispose(); _encode = null;
_decode?.Dispose(); _decode = null;
VoicePlayers.Clear();
}
private void OnMic(float[] pcm) => _encode?.AddAudioFrame(pcm);
// encode thread: compress -> serialize -> hand the opaque bytes to YOUR transport
private void OnEncoded(float[] pcm)
{
var packet = VoiceChatUtils.Compress(pcm);
packet.PacketId = ++_seq;
var bytes = MemoryPackSerializer.Serialize(new VoipData { voiceChatPacket = packet });
FnVoiceEngine.Transport.Broadcast(new ArraySegment<byte>(bytes));
}
// inbound: route the decoded packet to the sender's decoder
private void OnDecodedPacket(uint senderId, VoiceChatPacket packet)
{
_decode?.AddCompressedAudio(senderId, packet);
if (VoicePlayers.TryGet(senderId, out var p) && p != null) p.OnAudioFrameProcess();
}
private void OnDestroy()
{
if (_recorder != null) _recorder.OnSampleAvailable -= OnMic;
FnVoiceEngine.PacketSink = null;
OnSessionEnded();
}
}
Wiring it up
After you have joined your room / session:
FnVoiceEngine.Configure(new PunVoiceTransport(), new PunVoiceSession());
gameObject.AddComponent<FnVoiceHost>(); // or have it already in the scene
Scene needs: the FnVoice Resources (FnVoiceAudioSettings) present, and a VoiceChatRecorder
(Auto-Start-On-Start, a mic device selected). No VoipConnectionManager / FigNet networking.
Fusion 2 (and everything else)
Identical shape - only the send primitive changes:
IVoiceTransport.Broadcast-> a Fusion RPC / message to the other clients over an unreliable channel (voice is high-rate + loss-tolerant).- receive -> your RPC/message handler calls
FnVoiceEngine.OnVoicePacket(senderId, bytes). IVoiceSession-> project theNetworkRunnerplayer list toVoicePeer(usePlayerRef.PlayerIdorRawEncodedas theuint), and raisePeerJoined/PeerLeftfromINetworkRunnerCallbacks.OnPlayerJoined/OnPlayerLeft.
Notes / caveats
- The bytes are opaque. Carry them verbatim; don't inspect or re-encode. FnVoice owns the frame format.
senderIdmust be a stable per-peeruintand must match on both ends (PhotonActorNumber, FusionPlayerId, ...). The decoder is keyed by it; a mismatch silently drops that peer's audio.- Unreliable is right. PLC/FEC + the adaptive jitter buffer handle loss; a reliable channel adds latency.
- RTT is optional. Return
-1fromGetRoundTripMs()if your transport can't supply it - the jitter buffer falls back gracefully. - Encryption is your transport's responsibility now (PUN2/Fusion bring their own DTLS/TLS). FnVoice does not encrypt the frame.
- No FigNet/Entangle needed on this path. The
FnVoiceFenceGuardTeststest keeps the audio core provably free of FigNet networking, which is what makes this possible. - Same-assembly note:
FnVoiceHostsetsFnVoiceEngine.PacketSink(internal); keep it in the default Assembly-CSharp (as normal Unity scripts are), or the copiedLoopbackVoiceDemo, so it compiles.