Skip to main content
Version: 2.x
Version 2.x

This is the preview documentation for integrating ODIN Voice version 2.x with FMOD. There is no repository yet and screenshots are missing.

Integrating ODIN Voice Chat with the FMOD Audio Solution in Unity

FMOD and ODIN

Introduction​

Welcome to this guide on integrating the ODIN Voice Chat Plugin with the FMOD Audio Solution in Unity. It targets the ODIN Unity SDK 2.x. If you are still on the 1.x SDK, please use the 1.x version of this guide.

What You'll Learn:

  • Why ODIN needs custom capture and playback scripts once Unity's built-in audio is disabled
  • How the FMODMicrophoneReader, FMODPlaybackComponent and FMODOdinPlayback scripts work and how to use them
  • Properly set up the OdinInstance prefab when FMOD is your audio engine
  • Deal with limitations and potential pitfalls
warning

Note: This guide assumes that your project has disabled Unity's built-in audio, as FMOD recommends. Every script in this guide works with the audio engine enabled as well, but then the default ODIN components would work too.

Getting Started​

To follow this guide, you'll need to have some prerequisites:

  • Basic knowledge of Unity
  • The FMOD Plugin for Unity, which you can get here
  • The ODIN Unity SDK 2.x, available here

To set up FMOD in your project, please follow FMOD's in-depth integration tutorial. You can find the tutorial here. FMOD recommends disabling the Unity built-in audio on all platforms to prevent it from conflicting with FMOD. You do this in Project Settings > Audio > Disable Unity Audio, and the FMOD Setup Wizard offers to do it for you.

To set up the ODIN Unity SDK, please take a look at our manual for the high-level API:

Read the High Level API manual

Why you need custom scripts​

Disabling Unity's audio engine changes two things the default ODIN components rely on:

The SDK still handles the network side. Unity reports a samplerate of 0 without the audio engine, and the

OdinRoom

falls back to OdinRoom.DefaultSampleRate (48 kHz) for its encoders and decoders, so nothing has to change in the room setup as long as FMOD mixes at 48 kHz, which is its default.

If your FMOD mixer runs at a different rate, set OdinRoom.SampleRateOverride (SDK 20206.1.0 and later) before the scene with the OdinRoom loads. The room, its decoders and every OdinEncoder then use that rate instead of asking Unity:

OdinFmodSampleRate.cs
using FMODUnity;
using OdinNative.Unity;
using UnityEngine;

public static class OdinFmodSampleRate
{
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
private static void Apply()
{
// initialises FMOD if it is not running yet and reads the mixer format
RuntimeManager.CoreSystem.getSoftwareFormat(out int sampleRate, out _, out _);
OdinRoom.SampleRateOverride = (uint)sampleRate;
}
}

So the FMOD integration consists of three scripts that replace the audio ends of the pipeline:

ScriptReplacesJob
FMODMicrophoneReader

OdinMicrophoneReader

Records the microphone with FMOD and pushes the samples into an OdinEncoder
FMODOdinPlaybackThe default OnDecoderAdded / OnDecoderRemoved handlers of OdinRoomCreates and destroys one FMODPlaybackComponent per remote decoder
FMODPlaybackComponentOdinDecoderPops decoded audio from a MediaDecoder and plays it as an FMOD user stream

Scene setup​

  1. Add the OdinInstance prefab from Packages/io.fourplayers.odin/Runtime/ to your scene, as described in the High Level API manual.
  2. Add an OdinEncoder component to the same GameObject and assign the OdinRoom to its Room field. Leave Audio Provider empty, the FMOD reader pushes audio itself.
  3. Add the FMODMicrophoneReader script and assign the OdinEncoder to its Encoder field.
  4. Add the FMODOdinPlayback script and assign the OdinRoom to its Room field.
  5. On the OdinRoom component, change the event wiring in the Inspector. The OdinInstance prefab of the 2.x SDK ships with default listeners on these events that create Unity AudioSource playback, and the FMOD scripts have to take their place:
  6. Leave Auto Create Media on the OdinRoom enabled. The room then creates a decoder for a peer as soon as the first audio from that peer arrives and raises OnDecoderAdded, which is exactly the event the playback manager listens to.
  7. Join the room as usual, for example by setting

    OdinRoom.Token

    .
info

The scripts use the FMOD Core API through FMODUnity.RuntimeManager.CoreSystem, so they work with or without an FMOD Studio project. They only need the FMOD for Unity plugin to be installed and initialised.

FMODMicrophoneReader​

The FMODMicrophoneReader script replaces the

OdinMicrophoneReader

component. It records into an FMOD sound and pushes fixed-size chunks into an OdinEncoder, which encodes them and sends them to the ODIN servers.

Usage​

  1. Add an OdinEncoder to the OdinInstance GameObject and assign the OdinRoom to it.
  2. Add the FMODMicrophoneReader script and assign the OdinEncoder to its Encoder field.
  3. Optionally change Device Id to record from a device other than the system default. The index refers to FMOD's record driver list, see System.getRecordNumDrivers.

The reader configures the encoder to the recording format of the device, so the Samplerate and Stereo fields of the OdinEncoder do not need to be set by hand.

info

The script does not handle recording devices that are plugged in or removed at runtime. FMOD reports those changes through SYSTEM_CALLBACK_TYPE.RECORDLISTCHANGED, which is a good starting point if you need device switching. If you'd like to see extensions to this script, feel free to join our Discord server and let us know.

Full script​

FMODMicrophoneReader.cs
using System;
using System.Runtime.InteropServices;
using FMOD;
using OdinNative.Unity;
using UnityEngine;
using Debug = UnityEngine.Debug;

/// <summary>
/// Records the microphone with FMOD and pushes the samples into an <see cref="OdinEncoder"/>.
/// Replaces the OdinMicrophoneReader when Unity's audio engine is disabled.
/// </summary>
public class FMODMicrophoneReader : MonoBehaviour
{
[Tooltip("Encoder that sends the captured audio to its OdinRoom")]
public OdinEncoder Encoder;

[Tooltip("Index of the FMOD recording device, see System.getRecordNumDrivers")]
public int DeviceId = 0;

[Tooltip("Length of one chunk pushed to the encoder in milliseconds")]
public int ChunkMilliseconds = 20;

private CREATESOUNDEXINFO _recordingSoundInfo;
private Sound _recordingSound;
private uint _recordingSoundLength; // in PCM frames
private uint _readPosition; // in PCM frames
private int _nativeRate;
private int _nativeChannels;
private int _chunkFrames;
private float[] _chunk;
private bool _isRecording;

private void Start()
{
FMOD.System system = FMODUnity.RuntimeManager.CoreSystem;

system.getRecordNumDrivers(out int numDrivers, out _);
if (numDrivers <= 0)
{
Debug.LogWarning($"{nameof(FMODMicrophoneReader)} found no recording device");
return;
}

// retrieve microphone info like sampling rate and number of channels
system.getRecordDriverInfo(DeviceId, out _, 0, out _, out _nativeRate, out _, out _nativeChannels, out _);
if (_nativeChannels < 1 || _nativeChannels > 2)
{
Debug.LogError($"{nameof(FMODMicrophoneReader)} device {DeviceId} has {_nativeChannels} channels, ODIN encodes mono or stereo");
return;
}

_chunkFrames = _nativeRate * ChunkMilliseconds / 1000;
_chunk = new float[_chunkFrames * _nativeChannels];

// setup the recording sound that will contain the microphone data
_recordingSoundInfo.cbsize = Marshal.SizeOf(typeof(CREATESOUNDEXINFO));
_recordingSoundInfo.numchannels = _nativeChannels;
_recordingSoundInfo.defaultfrequency = _nativeRate;
_recordingSoundInfo.format = SOUND_FORMAT.PCMFLOAT;
// one second ring buffer
_recordingSoundInfo.length = (uint)(_nativeRate * sizeof(float) * _nativeChannels);

RESULT result = system.createSound("", MODE.LOOP_NORMAL | MODE.OPENUSER, ref _recordingSoundInfo, out _recordingSound);
if (result != RESULT.OK)
{
Debug.LogError($"{nameof(FMODMicrophoneReader)} createSound failed: {result}");
return;
}

result = system.recordStart(DeviceId, _recordingSound, true);
if (result != RESULT.OK)
{
Debug.LogError($"{nameof(FMODMicrophoneReader)} recordStart failed: {result}");
_recordingSound.release();
return;
}

_recordingSound.getLength(out _recordingSoundLength, TIMEUNIT.PCM);
_readPosition = 0;
_isRecording = true;

ConfigureEncoder();
}

/// <summary>
/// The encoder has to match the recording format. OdinEncoder creates its MediaEncoder when the room is
/// joined, so setting the fields here is enough as long as the room is not joined yet.
/// </summary>
private void ConfigureEncoder()
{
if (Encoder == null)
{
Debug.LogError($"{nameof(FMODMicrophoneReader)} has no {nameof(OdinEncoder)} assigned");
return;
}

Encoder.Samplerate = (uint)_nativeRate;
Encoder.Stereo = _nativeChannels == 2;

// an encoder that already exists was created with the wrong format, recreate it
if (Encoder.Encoder != null && Encoder.Encoder.IsAlive)
{
Encoder.enabled = false;
Encoder.enabled = true;
}
}

private void Update()
{
if (_isRecording == false || Encoder == null) return;

FMOD.System system = FMODUnity.RuntimeManager.CoreSystem;

// determine how much has been recorded since we last checked
system.getRecordPosition(DeviceId, out uint recordPosition);
uint available = recordPosition >= _readPosition
? recordPosition - _readPosition
: recordPosition + _recordingSoundLength - _readPosition;

// a stall (scene load, paused editor) leaves old audio behind; skip it instead of sending it late
uint maxBacklog = (uint)(_nativeRate / 5); // 200 ms
if (available > maxBacklog)
{
_readPosition = (_readPosition + available - maxBacklog) % _recordingSoundLength;
available = maxBacklog;
}

while (available >= _chunkFrames)
{
ReadChunk(_readPosition);
Encoder.PushAudio(_chunk, 0, false);

_readPosition = (_readPosition + (uint)_chunkFrames) % _recordingSoundLength;
available -= (uint)_chunkFrames;
}
}

/// <summary>
/// Copies one chunk from the FMOD recording sound into the managed buffer.
/// </summary>
private void ReadChunk(uint framePosition)
{
uint bytesPerFrame = sizeof(float) * (uint)_nativeChannels;

// FMOD wants byte offsets, ODIN and Marshal.Copy want sample counts
_recordingSound.@lock(framePosition * bytesPerFrame, (uint)_chunkFrames * bytesPerFrame,
out IntPtr ptr1, out IntPtr ptr2, out uint len1, out uint len2);

int samples1 = (int)(len1 / sizeof(float));
Marshal.Copy(ptr1, _chunk, 0, samples1);

// the ring buffer wrapped, the rest of the chunk starts at the beginning of the sound
if (ptr2 != IntPtr.Zero && len2 > 0)
Marshal.Copy(ptr2, _chunk, samples1, (int)(len2 / sizeof(float)));

_recordingSound.unlock(ptr1, ptr2, len1, len2);
}

private void OnDestroy()
{
if (_isRecording == false) return;
_isRecording = false;

FMODUnity.RuntimeManager.CoreSystem.recordStop(DeviceId);
_recordingSound.release();
}
}

How it works​

To read data from the microphone using FMOD, we'll need to perform the following steps:

  1. Set up and create an FMOD.Sound object, into which FMOD can store the microphone input data.
  2. Start the microphone recording and tell the OdinEncoder which format to expect.
  3. Continually read the FMOD microphone data and push it to the ODIN servers.

1. Setup​

The setup is performed in Unity's Start() method.

Retrieve Microphone Info

You need to retrieve details about the microphone, such as the sampling rate and the number of channels. We'll use this info to configure the FMOD recording sound in the next step and the ODIN encoder later on.

system.getRecordDriverInfo(DeviceId, out _, 0, out _, out _nativeRate, out _, out _nativeChannels, out _);

FMOD recommends recording at the device's native rate. With any other rate FMOD allocates a resampler, which adds latency. ODIN encodes mono or stereo, so the script refuses devices with more than two channels.

Configure Recording Sound Info

After obtaining the input device details, the next action is to set up the CREATESOUNDEXINFO object. This object carries essential metadata that FMOD needs for audio capture.

_recordingSoundInfo.cbsize = Marshal.SizeOf(typeof(CREATESOUNDEXINFO));
_recordingSoundInfo.numchannels = _nativeChannels;
_recordingSoundInfo.defaultfrequency = _nativeRate;
_recordingSoundInfo.format = SOUND_FORMAT.PCMFLOAT;
_recordingSoundInfo.length = (uint)(_nativeRate * sizeof(float) * _nativeChannels);

We use SOUND_FORMAT.PCMFLOAT because ODIN takes float samples. This avoids the need for audio data conversions later on. The length is set to capture one second of audio, which is the ring buffer the recording loops over.

Create Recording Sound

To hold the captured audio, FMOD requires us to create an FMOD Sound object.

system.createSound("", MODE.LOOP_NORMAL | MODE.OPENUSER, ref _recordingSoundInfo, out _recordingSound);

MODE.OPENUSER tells FMOD that we provide the format ourselves and MODE.LOOP_NORMAL lets the recording wrap around once the sound is full.

2. Recording​

At this point, we're ready to start capturing audio. To do so, call the recordStart method from FMOD's core system and remember the length of the sound in PCM frames, which we need to wrap our read position.

system.recordStart(DeviceId, _recordingSound, true);
_recordingSound.getLength(out _recordingSoundLength, TIMEUNIT.PCM);

Configure the encoder

This is the part that is new in 2.x. The OdinEncoder creates its native MediaEncoder when the room fires OnRoomJoined, using its Samplerate and Stereo fields. The reader sets both fields to the recording format before that happens.

Encoder.Samplerate = (uint)_nativeRate;
Encoder.Stereo = _nativeChannels == 2;

If the room was already joined when the reader started, the encoder already exists with the default 48 kHz mono format. Disabling and re-enabling the OdinEncoder unlinks that encoder and creates a new one with the updated fields.

3. Continually push microphone data​

In the Update() method, we manage the ongoing capture of audio data from the FMOD microphone and its transmission to the ODIN servers.

Find out how much was recorded

getRecordPosition returns the write position of the recording in PCM frames. The difference to our read position is the amount of new audio, taking the wrap-around of the ring buffer into account.

system.getRecordPosition(DeviceId, out uint recordPosition);
uint available = recordPosition >= _readPosition
? recordPosition - _readPosition
: recordPosition + _recordingSoundLength - _readPosition;

If the game stalled, for example during a scene load, the ring buffer contains audio that is far too old to send. The script skips everything older than 200 ms instead of pushing a burst of stale voice.

Push fixed-size chunks

In 2.x the encoder pushes the whole array it receives, there is no separate length parameter anymore. The script therefore reads chunks of a fixed size (20 ms by default) into a reusable buffer and pushes them one by one until less than one chunk is left.

while (available >= _chunkFrames)
{
ReadChunk(_readPosition);
Encoder.PushAudio(_chunk, 0, false);

_readPosition = (_readPosition + (uint)_chunkFrames) % _recordingSoundLength;
available -= (uint)_chunkFrames;
}

OdinEncoder.PushAudio has the same signature as the OnAudioData event of the

OdinMicrophoneReader

, so the encoder does not know or care that the samples come from FMOD. Silence detection is left to the encoder pipeline, which is why the isSilent flag is always false.

Read Microphone Data

Microphone data is read from the FMOD sound object with Sound.lock and copied into the chunk buffer with Marshal.Copy.

_recordingSound.@lock(framePosition * bytesPerFrame, (uint)_chunkFrames * bytesPerFrame,
out IntPtr ptr1, out IntPtr ptr2, out uint len1, out uint len2);

int samples1 = (int)(len1 / sizeof(float));
Marshal.Copy(ptr1, _chunk, 0, samples1);
if (ptr2 != IntPtr.Zero && len2 > 0)
Marshal.Copy(ptr2, _chunk, samples1, (int)(len2 / sizeof(float)));

_recordingSound.unlock(ptr1, ptr2, len1, len2);

It's crucial to be aware of the unit differences between FMOD, ODIN and Marshal.Copy. FMOD expects the lock offset and length in bytes, where one frame is sizeof(float) times the channel count. ODIN and Marshal.Copy work in samples. When the requested range crosses the end of the ring buffer, FMOD returns the remainder in the second pointer, so both parts have to be copied.

FMODOdinPlayback​

The FMODOdinPlayback script replaces the default decoder handling of the

OdinRoom

. By default the room reacts to OnDecoderAdded by adding an OdinDecoder with an AudioSource to the peer's GameObject. This script creates an FMODPlaybackComponent instead.

Usage​

  1. Add the FMODOdinPlayback script to the OdinInstance GameObject and assign the OdinRoom.
  2. Rewire the OnDecoderAdded, OnDecoderRemoved and OnPeerLeft events of the OdinRoom as described in Scene setup.
  3. Optionally assign a prefab with an FMODPlaybackComponent to Playback Prefab, for example to configure 3D playback or to attach your own components.

Full script​

FMODOdinPlayback.cs
using System.Collections.Generic;
using OdinNative.Unity;
using OdinNative.Unity.Events;
using OdinNative.Wrapper;
using OdinNative.Wrapper.Room;
using UnityEngine;

/// <summary>
/// Creates one <see cref="FMODPlaybackComponent"/> per remote decoder.
/// Wire <see cref="OnDecoderAdded"/>, <see cref="OnDecoderRemoved"/> and <see cref="OnPeerLeft"/>
/// to the matching events of the <see cref="OdinRoom"/>.
/// </summary>
public class FMODOdinPlayback : MonoBehaviour
{
public OdinRoom Room;

[Tooltip("Optional prefab with an FMODPlaybackComponent, e.g. configured for 3D playback")]
public FMODPlaybackComponent PlaybackPrefab;

private readonly List<FMODPlaybackComponent> _playbacks = new List<FMODPlaybackComponent>();

private void Awake()
{
if (Room == null)
Room = GetComponent<OdinRoom>();
}

/// <summary>
/// Replacement for OdinRoom.DecoderAddedPeerCreateComponent
/// </summary>
public void OnDecoderAdded(object sender, DecoderAddedEventArgs args)
{
var wrapperRoom = Room.GetBaseRoom<OdinNative.Wrapper.Room.Room>();
if (wrapperRoom == null || wrapperRoom.GetDecoder(args.PeerId, args.MediaId, out MediaDecoder decoder) == false)
{
Debug.LogWarning($"{nameof(FMODOdinPlayback)} found no decoder {args.MediaId} for peer {args.PeerId}");
return;
}

// parent to the peer object the room created, so it is destroyed together with the peer
Transform parent = transform;
foreach (OdinPeer peer in Room.GetComponentsInChildren<OdinPeer>(true))
{
if (peer.Id != args.PeerId) continue;
parent = peer.transform;
break;
}

FMODPlaybackComponent playback;
if (PlaybackPrefab != null)
playback = Instantiate(PlaybackPrefab, parent);
else
{
var container = new GameObject($"FMOD playback {args.MediaId}");
container.transform.SetParent(parent, false);
playback = container.AddComponent<FMODPlaybackComponent>();
}

playback.PeerId = args.PeerId;
playback.MediaId = args.MediaId;
playback.Play(decoder);
_playbacks.Add(playback);
}

/// <summary>
/// Replacement for OdinRoom.DecoderRemovedPeerRemoveComponent
/// </summary>
public void OnDecoderRemoved(object sender, DecoderRemovedEventArgs args)
{
RemovePlaybacks(playback => playback.PeerId == args.PeerId && playback.MediaId == args.MediaId);
}

/// <summary>
/// Additional listener next to OdinRoom.PeerLeftRemoveComponent
/// </summary>
public void OnPeerLeft(object sender, PeerLeftEventArgs args)
{
RemovePlaybacks(playback => playback.PeerId == args.PeerId);
}

private void RemovePlaybacks(System.Predicate<FMODPlaybackComponent> match)
{
for (int i = _playbacks.Count - 1; i >= 0; i--)
{
FMODPlaybackComponent playback = _playbacks[i];
// already destroyed together with its peer object
if (playback == null)
{
_playbacks.RemoveAt(i);
continue;
}

if (match(playback) == false) continue;

_playbacks.RemoveAt(i);
Destroy(playback.gameObject);
}
}
}

How it works​

Both OnDecoderAdded and OnDecoderRemoved are Unity events of the OdinRoom. The room raises them on the main thread, so the handlers can create and destroy GameObjects directly.

The event arguments carry the peer id and the media id of the decoder. With Auto Create Media enabled, the room creates one default decoder per peer with the media id OdinRoom.AutoDecoderMediaId, so there is exactly one playback object per remote peer. The handler looks the native decoder up in the wrapper room and hands it to the playback component.

var wrapperRoom = Room.GetBaseRoom<OdinNative.Wrapper.Room.Room>();
wrapperRoom.GetDecoder(args.PeerId, args.MediaId, out MediaDecoder decoder);

The playback object is parented to the GameObject that OnPeerJoined created for the peer. When the peer leaves, the default PeerLeftRemoveComponent listener destroys that GameObject, and Unity destroys the playback object with it. The OnPeerLeft handler of this script covers the case where you removed the default listener or keep your peer objects elsewhere.

info

The room owns the MediaDecoder. It disposes the decoder when the peer leaves or the room closes, so the playback component never disposes it. Once the decoder is gone, MediaDecoder.Pop returns false and the playback component hands FMOD silence until it is destroyed.

FMODPlaybackComponent​

The FMODPlaybackComponent script replaces the OdinDecoder. It creates an FMOD user stream whose read callback pops decoded audio from the ODIN MediaDecoder, and plays that stream on the master channel group.

Usage​

The script is created at runtime by FMODOdinPlayback. If you want to customise it, build a prefab with the component attached and assign it to Playback Prefab:

  • Enable Spatial to play the voice as a 3D sound. The component then updates the FMOD channel with the position of its GameObject every frame, so put the prefab or the peer object where the player's avatar is. FMOD needs a StudioListener in the scene for 3D playback.
  • Lower Read Milliseconds to reduce playback latency, at the cost of more callbacks.

Full script​

FMODPlaybackComponent.cs
using System;
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using FMOD;
using OdinNative.Wrapper;
using UnityEngine;
using Debug = UnityEngine.Debug;

/// <summary>
/// Plays an ODIN <see cref="MediaDecoder"/> through an FMOD user stream.
/// Replaces the OdinDecoder when Unity's audio engine is disabled.
/// </summary>
public class FMODPlaybackComponent : MonoBehaviour
{
[Tooltip("Play as 3D sound at the position of this GameObject")]
public bool Spatial = false;

[Tooltip("Audio FMOD requests per read callback in milliseconds. Smaller values lower the latency.")]
public int ReadMilliseconds = 20;

/// <summary>Peer this playback belongs to</summary>
public uint PeerId { get; set; }
/// <summary>Media id of the decoder this playback belongs to</summary>
public ulong MediaId { get; set; }
/// <summary>Decoder the audio is popped from</summary>
public MediaDecoder Decoder { get; private set; }
/// <summary>FMOD sound created for this playback</summary>
public Sound Sound => _sound;
/// <summary>FMOD channel the sound plays on</summary>
public Channel Channel => _channel;

private Sound _sound;
private Channel _channel;
private CREATESOUNDEXINFO _soundInfo;
private float[] _readBuffer = Array.Empty<float>();
private bool _isPlaying;

// FMOD calls the read callback from its own thread with the raw sound handle,
// so the component is looked up by that handle
private static readonly ConcurrentDictionary<IntPtr, FMODPlaybackComponent> PlaybacksBySound =
new ConcurrentDictionary<IntPtr, FMODPlaybackComponent>();
// kept in a static field so the delegate is never garbage collected while FMOD holds it
private static readonly SOUND_PCMREAD_CALLBACK PcmReadDelegate = PcmReadCallback;
private static float[] _silence = Array.Empty<float>();

/// <summary>
/// Create the FMOD stream for the decoder and start playing.
/// </summary>
public void Play(MediaDecoder decoder)
{
Stop();
if (decoder == null) return;
Decoder = decoder;

int channels = decoder.Stereo ? 2 : 1;
int rate = (int)decoder.Samplerate;

_soundInfo = new CREATESOUNDEXINFO();
_soundInfo.cbsize = Marshal.SizeOf(typeof(CREATESOUNDEXINFO));
_soundInfo.numchannels = channels;
_soundInfo.defaultfrequency = rate;
_soundInfo.format = SOUND_FORMAT.PCMFLOAT;
// frames FMOD asks for per read callback, this drives the playback latency
_soundInfo.decodebuffersize = (uint)(rate * ReadMilliseconds / 1000);
// one second, only bounds the loop; the callback provides the actual audio
_soundInfo.length = (uint)(rate * channels * sizeof(float));
_soundInfo.pcmreadcallback = PcmReadDelegate;

MODE mode = MODE.OPENUSER | MODE.CREATESTREAM | MODE.LOOP_NORMAL | (Spatial ? MODE._3D : MODE._2D);

FMOD.System system = FMODUnity.RuntimeManager.CoreSystem;
RESULT result = system.createSound("", mode, ref _soundInfo, out _sound);
if (result != RESULT.OK)
{
Debug.LogError($"{nameof(FMODPlaybackComponent)} createSound failed: {result}");
Decoder = null;
return;
}

PlaybacksBySound[_sound.handle] = this;

system.getMasterChannelGroup(out ChannelGroup master);
result = system.playSound(_sound, master, false, out _channel);
if (result != RESULT.OK)
{
Debug.LogError($"{nameof(FMODPlaybackComponent)} playSound failed: {result}");
Stop();
return;
}

_isPlaying = true;
Update3DAttributes();
}

/// <summary>
/// Stop playing and release the FMOD stream.
/// </summary>
public void Stop()
{
if (_sound.hasHandle())
{
PlaybacksBySound.TryRemove(_sound.handle, out _);
if (_isPlaying)
_channel.stop();
_sound.release();
_sound.clearHandle();
}

_isPlaying = false;
Decoder = null;
}

private void Update()
{
if (_isPlaying && Spatial)
Update3DAttributes();
}

private void Update3DAttributes()
{
if (Spatial == false) return;
ATTRIBUTES_3D attributes = FMODUnity.RuntimeUtils.To3DAttributes(transform);
_channel.set3DAttributes(ref attributes.position, ref attributes.velocity);
}

[AOT.MonoPInvokeCallback(typeof(SOUND_PCMREAD_CALLBACK))]
private static RESULT PcmReadCallback(IntPtr sound, IntPtr data, uint dataLength)
{
if (data == IntPtr.Zero) return RESULT.ERR_INVALID_PARAM;

int samples = (int)(dataLength / sizeof(float));
if (PlaybacksBySound.TryGetValue(sound, out FMODPlaybackComponent playback) && playback.Fill(data, samples))
return RESULT.OK;

// no decoder or no audio, hand FMOD silence
if (_silence.Length < samples)
_silence = new float[samples];
Marshal.Copy(_silence, 0, data, samples);
return RESULT.OK;
}

/// <summary>
/// Pops the requested amount of audio from the decoder into the FMOD buffer.
/// Runs on the FMOD stream thread.
/// </summary>
private bool Fill(IntPtr data, int samples)
{
MediaDecoder decoder = Decoder;
if (decoder == null || decoder.IsAlive == false) return false;

if (_readBuffer.Length != samples)
_readBuffer = new float[samples];

if (decoder.Pop(ref _readBuffer, out bool _) == false)
return false;

Marshal.Copy(_readBuffer, 0, data, samples);
return true;
}

private void OnDestroy()
{
Stop();
}
}

How it works​

To play ODIN audio through FMOD we need to perform the following steps:

  1. Create an FMOD user stream that matches the decoder format.
  2. Provide audio in the stream's read callback by popping it from the decoder.
  3. Release everything when the playback is stopped or destroyed.

1. Create the stream​

The MediaDecoder reports its Samplerate and Stereo flag, so the FMOD sound is created with exactly that format and no conversion is needed. With the audio engine disabled, the room creates its decoders at 48 kHz mono.

_soundInfo.numchannels = decoder.Stereo ? 2 : 1;
_soundInfo.defaultfrequency = (int)decoder.Samplerate;
_soundInfo.format = SOUND_FORMAT.PCMFLOAT;
_soundInfo.decodebuffersize = (uint)(rate * ReadMilliseconds / 1000);
_soundInfo.pcmreadcallback = PcmReadDelegate;

decodebuffersize is the number of PCM frames FMOD requests per read callback. This value sets the playback latency, because FMOD buffers that much audio ahead of the mixer. The 1.x sample left this at FMOD's default, which added roughly 500 ms of latency. With a 20 ms decode buffer the added latency stays in the range of a normal FMOD stream.

The sound is created with MODE.OPENUSER | MODE.CREATESTREAM | MODE.LOOP_NORMAL. OPENUSER makes FMOD take the format from the CREATESOUNDEXINFO, CREATESTREAM makes FMOD pull audio through the read callback instead of loading it once, and LOOP_NORMAL keeps the stream running past the one second length. Then it is simply played on the master channel group with playSound.

2. Fill the stream​

FMOD invokes the read callback from its own stream thread, not from the Unity main thread. This has two consequences for the code:

  • The callback has to be a static method marked with AOT.MonoPInvokeCallback, otherwise IL2CPP builds crash when FMOD calls into managed code. The component instance is found through the raw sound handle FMOD passes in, which is why Play registers the sound in a static dictionary.
  • Unity API calls are off limits inside the callback. The callback only touches the decoder and the FMOD buffer.
int samples = (int)(dataLength / sizeof(float));
if (PlaybacksBySound.TryGetValue(sound, out FMODPlaybackComponent playback) && playback.Fill(data, samples))
return RESULT.OK;

Fill pops exactly the requested number of samples from the decoder and copies them into FMOD's buffer. Popping from a second thread is safe: the room pushes incoming datagrams into the decoder from a network thread while the OdinDecoder pops on the main thread in the default setup, so the native decoder is built for that.

if (decoder.Pop(ref _readBuffer, out bool _) == false)
return false;

Marshal.Copy(_readBuffer, 0, data, samples);

Pop fills the whole array and returns a silence flag, which you could use to drive a talking indicator. When the decoder has no audio, for example because the peer is muted, the popped buffer contains silence, so FMOD always gets a full buffer. When Pop fails, the decoder was disposed and the callback writes zeros instead.

3. Clean up​

Stop removes the sound from the lookup dictionary first, so a callback that is already running cannot find the component anymore, then stops the channel and releases the sound. OnDestroy calls Stop, so destroying the GameObject is all FMODOdinPlayback has to do.

Limitations and pitfalls​

  • Two Unity warnings on startup with SDK versions before 20206.1.0. Audio system is disabled, so AudioSettings.outputSampleRate cannot be queried appears once from OdinRoom.Awake and once from OdinEncoder.Awake. Older SDKs read that property to detect the disabled engine and fall back to 48 kHz, and Unity logs the warning on every read. Since 20206.1.0 the SDK detects the state without the warning.
  • Rewire the room events. If the OdinRoom still calls DecoderAddedPeerCreateComponent, every remote peer gets an OdinDecoder that logs cannot create a playback clip while the Unity audio engine is disabled and stays silent. That message means the default handler is still wired.
  • The encoder format is fixed at creation. The OdinEncoder creates its MediaEncoder on OnRoomJoined. The reader sets the format in Start, which runs before the room joins as long as both components are in the scene from the beginning. For readers added at runtime the script recreates the encoder by toggling the component.
  • Device changes are not handled. Neither script reacts to a recording or playback device being plugged in or removed. FMOD reports record device changes through SYSTEM_CALLBACK_TYPE.RECORDLISTCHANGED.
  • One reader per encoder. To send the same microphone into several rooms, add one OdinEncoder per room and call PushAudio on each of them, or push through OdinRoom.ProxyAudio when all rooms use the same format.
  • Positional culling is separate from 3D playback. Spatial on the playback component only affects how FMOD renders the voice. To let the ODIN server cull peers by distance, set positions on the encoder with MediaEncoder.SetPosition as described in the manual.
  • Effects still work. Components like OdinVadComponent or OdinVolumeBoostComponent operate on the native pipeline of the encoder or decoder, not on Unity audio. Add them to the OdinEncoder with AddEffect, or set their Media to the MediaDecoder of a playback component.