Skip to main content
Version: 2.x

Integrating ODIN Voice Chat in Unreal using C++

Although the Unreal Engine plugin comes with full Blueprint support to make it as easy as possible to use ODIN in your game, it is also easily possible to implement the plugin using C++. Please make sure to have a basic understanding of how ODIN works as this helps a lot of understanding the next steps. Additionally this guide assumes that you have basic knowledge of the Unreal Engine, its Editor and the C++ API.

info

This guide highlights the key steps to take to get started with ODIN. For a more detailed implementation please refer to our Unreal Sample project. Copy the C++ classes in the Unreal Sample Project and paste them in your own project to get started quickly!

Download the Unreal C++ sample project

Basic Process​

As outlined in the introduction, every user connected to the same ODIN room (identified by a string of your choice) can exchange both data and voice. An ODIN room is created automatically by the ODIN server as soon as the first user joins and is removed once the last user leaves.

To join a room, a room token is required. This token grants access to an ODIN room and can be generated directly within the client. While this approach is sufficient for testing and development, it is not recommended for production because it exposes your ODIN Access Keys to the client. In production environments, the room token should be created on a secured server. To support this, we provide ready-to-use packages for JavaScript (via npm) and PHP (via Composer). In addition, we offer a complete server implementation that can be deployed as a cloud function on AWS or Google Cloud.

Once the room has been joined, users can exchange data such as text chat messages or other real-time information. If voice communication is required, an outgoing voice data stream (e.g. a microphone connected to an UOdinEncoder) must be added to the room. This enables every participant to communicate with one another. More advanced techniques, such as 3D audio, allow users to update their spatial positions at regular intervals. The server then ensures that only nearby users hear the voice stream, reducing both bandwidth consumption and CPU usage. Details on these techniques will be discussed later.

Summary of the Basic Steps​

  • Obtain an access key.
  • Create a room token using the access key and a specific room identifier (string identifier).
  • Bind your event handlers and join the room with the generated room token.
  • Setup an Odin Encoder to connect a microphone to the room.
  • Clean up audio playback when peers leave, and leave the room when you are done.

Implementing with C++​

To integrate ODIN into an existing (or new) Unreal Engine project, you will work with the C++ classes provided in the Odin and OdinLibrary modules of the Odin Plugin. After installation, these modules must be added to your project's build file, located at: Source/<YourProject>/<YourProject>.build.cs In addition, you should include dependencies on Unreal Engine's AudioCapture, AudioCaptureCore and AudioMixer modules, as these provide the functionality required to capture microphone input from the user and to play back audio. Your PublicDependencyModuleNames entry should therefore look as follows:

PublicDependencyModuleNames.AddRange(new string[]
{
"Core",
"CoreUObject",
"Engine",
"InputCore",
"Odin",
"OdinLibrary",
"AudioCapture",
"AudioCaptureCore",
"AudioMixer"
});

Overview​

Below is the full class we are about to create. In the sample, it derives from UActorComponent, but you can place the code wherever it best fits your architecture. An UActorComponent is often a good choice because it can be attached to any AActor to add functionality in a modular way.

Header File:

#pragma once

#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "OdinRoom.h"
#include "OdinClientComponent.generated.h"

class UOdinAudioCapture;
class UOdinDecoder;
class UOdinEncoder;
class UOdinSynthComponent;

UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class ODINUNREALCPPSAMPLE_API UOdinClientComponent : public UActorComponent
{
GENERATED_BODY()

public:
UOdinClientComponent();

/** Sample-only credential. In production, obtain room tokens from a trusted backend instead. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Odin|Authentication")
FString AccessKey;

/** Connect using AccessKey. The sample character also calls this automatically after receiving its PlayerId. */
UFUNCTION(BlueprintCallable, Category = "Odin")
void ConnectToOdin(FGuid PlayerId);

/** Stop local and remote audio and leave the room. */
UFUNCTION(BlueprintCallable, Category = "Odin")
void DisconnectFromOdin();

protected:
virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;

UFUNCTION()
void OnRoomJoinSuccessHandler(UOdinRoom* OdinRoom, FOdinJoined Data);

UFUNCTION()
void OnPeerJoinedHandler(UOdinRoom* OdinRoom, FOdinPeerJoined PeerData);

UFUNCTION()
void OnPeerLeftHandler(UOdinRoom* OdinRoom, FOdinPeerLeft PeerData);

UFUNCTION()
void OnRoomErrorHandler(UOdinRoom* OdinRoom, FOdinError Data);

private:
void CleanupLocalAudio();
void CleanupPeerAudio(int64 PeerId);

UPROPERTY(Transient)
UOdinRoom* Room = nullptr;

UPROPERTY(Transient)
UOdinAudioCapture* Capture = nullptr;

UPROPERTY(Transient)
UOdinEncoder* Encoder = nullptr;

// Keep the decoders alive independently of synth components owned by remote characters.
UPROPERTY(Transient)
TMap<int64, UOdinDecoder*> PeerDecoders;

UPROPERTY(Transient)
TMap<int64, UOdinSynthComponent*> PeerSynths;
};

Source File:

#include "OdinClientComponent.h"
#include "Kismet/GameplayStatics.h"
#include "GameFramework/Character.h"
#include "Engine/World.h"
#include "OdinFunctionLibrary.h"
#include "OdinJsonObject.h"
#include "OdinGameInstance.h"
#include "OdinTokenGenerator.h"
#include "OdinAudio/OdinAudioCapture.h"
#include "OdinAudio/OdinDecoder.h"
#include "OdinAudio/OdinEncoder.h"
#include "OdinAudio/OdinPipeline.h"
#include "OdinAudio/OdinSynthComponent.h"

UOdinClientComponent::UOdinClientComponent()
{
PrimaryComponentTick.bCanEverTick = false;
SetIsReplicatedByDefault(true);
}

void UOdinClientComponent::OnPeerJoinedHandler(UOdinRoom* OdinRoom, FOdinPeerJoined PeerData)
{
if (!IsValid(OdinRoom) || OdinRoom != Room || OdinRoom->IsClosing())
{
return;
}

UOdinJsonObject* JSON = UOdinJsonObject::ConstructJsonObjectFromBytes(this, PeerData.user_data);

if (!IsValid(JSON) || !JSON->GetRootObject().IsValid()
|| !JSON->HasField(TEXT("PlayerId")))
{
UE_LOG(LogTemp, Warning, TEXT("Peer %s joined without a PlayerId user data field."), *PeerData.user_id);
return;
}
FString GUIDString = JSON->GetStringField(TEXT("PlayerId"));

FGuid PlayerId;
if (!FGuid::Parse(GUIDString, PlayerId))
{
UE_LOG(LogTemp, Warning, TEXT("Peer %s joined with an invalid PlayerId."), *PeerData.user_id);
return;
}

UOdinGameInstance* GameInstance = Cast<UOdinGameInstance>(UGameplayStatics::GetGameInstance(this));
if (!IsValid(GameInstance))
{
UE_LOG(LogTemp, Error, TEXT("Current Game Instance is of invalid type, please use a UOdinGameInstance."));
return;
}

ACharacter* Character = GameInstance->PlayerCharacters.FindRef(PlayerId);
if (!IsValid(Character))
{
UE_LOG(LogTemp, Warning, TEXT("Peer %s joined, but could not be mapped to a player character."), *PeerData.user_id);
return;
}

CleanupPeerAudio(PeerData.peer_id);

UOdinDecoder* Decoder = UOdinDecoder::ConstructDecoder(this, 48000, true);

UActorComponent* Component = Character->AddComponentByClass(UOdinSynthComponent::StaticClass(), false,
FTransform::Identity, false);
UOdinSynthComponent* Synth = Cast<UOdinSynthComponent>(Component);

PeerDecoders.Add(PeerData.peer_id, Decoder);
PeerSynths.Add(PeerData.peer_id, Synth);
GameInstance->OdinPlayerCharacters.Add(PeerData.peer_id, Character);

UOdinFunctionLibrary::RegisterDecoder(Decoder, OdinRoom, PeerData.peer_id);
Synth->SetDecoder(Decoder);

FSoundAttenuationSettings AttenuationSettings;
AttenuationSettings.bSpatialize = true;
AttenuationSettings.bAttenuate = true;
// Add further attenuation settings here
Synth->AdjustAttenuation(AttenuationSettings);
Synth->Activate();

UE_LOG(LogTemp, Log, TEXT("Added ODIN playback for peer %s."), *PeerData.user_id);
}

void UOdinClientComponent::OnPeerLeftHandler(UOdinRoom* OdinRoom, FOdinPeerLeft PeerData)
{
if (OdinRoom == Room)
{
// Also called for synthetic peer-left events on connection loss, close and reconnect.
CleanupPeerAudio(PeerData.peer_id);
}
}

void UOdinClientComponent::OnRoomJoinSuccessHandler(UOdinRoom* OdinRoom, FOdinJoined Data)
{
if (!IsValid(OdinRoom) || OdinRoom != Room || OdinRoom->IsClosing())
{
return;
}

UE_LOG(LogTemp, Log, TEXT("Joined ODIN room %s."), *Data.room_id);
CleanupLocalAudio();

Capture = NewObject<UOdinAudioCapture>(this);
if (!IsValid(Capture) || !Capture->RestartCapturing(false))
{
UE_LOG(LogTemp, Error, TEXT("Failed to open the microphone. Joined the room without local voice transmission."));
CleanupLocalAudio();
return;
}

const int32 SampleRate = Capture->GetSampleRate();
const int32 NumChannels = Capture->GetNumChannels();
// Peer id 0 lets the plugin resolve the local peer id automatically when sending to the linked room.
Encoder = UOdinEncoder::ConstructEncoder(this, 0, SampleRate, NumChannels >= 2);
if (!IsValid(Encoder) || !Encoder->GetHandle())
{
UE_LOG(LogTemp, Error, TEXT("Failed to create the ODIN encoder."));
CleanupLocalAudio();
return;
}

UOdinPipeline* Pipeline = Encoder->GetOrCreatePipeline();

const int32 ApmId = Pipeline->InsertApmEffect(0);
FOdinApmConfig ApmConfig;
// For local testing purposes, this is turned off - when running multiple clients on the same machine with the same
// microphone input, the Echo Canceller will kick in. In production you'd probably want to enable this.
ApmConfig.echo_canceller = false;
ApmConfig.noise_suppression = EOdinNoiseSuppression::ODIN_NOISE_SUPPRESSION_MODERATE;
ApmConfig.high_pass_filter = true;
ApmConfig.gain_controller = EOdinGainControllerVersion::ODIN_GAIN_CONTROLLER_V2;
ApmConfig.transient_suppressor = true;

// The plugin returns 0 on failure.
if (ApmId <= 0 || !Pipeline->SetApmConfig(ApmId, ApmConfig))
{
UE_LOG(LogTemp, Error, TEXT("Failed to configure the ODIN APM effect."));
CleanupLocalAudio();
return;
}

const int32 VadId = Pipeline->InsertVadEffect(1);
FOdinVadConfig VadConfig;
VadConfig.VoiceActivity = {
.Enabled = true,
.AttackThreshold = 0.7f,
.ReleaseThreshold = 0.6f
};
VadConfig.VolumeGate = {
.Enabled = true,
.AttackThreshold = -30.0f,
.ReleaseThreshold = -40.0f
};
if (VadId <= 0 || !Pipeline->SetVadConfig(VadId, VadConfig))
{
UE_LOG(LogTemp, Error, TEXT("Failed to configure the ODIN VAD effect."));
CleanupLocalAudio();
return;
}

Encoder->SetAudioGenerator(Capture);
UOdinFunctionLibrary::LinkEncoderToRoom(Encoder, OdinRoom);

Capture->StartCapturingAudio();
}

void UOdinClientComponent::OnRoomErrorHandler(UOdinRoom* OdinRoom, FOdinError Data)
{
if (OdinRoom == Room)
{
UE_LOG(LogTemp, Error, TEXT("ODIN room error: %s"), *Data.message);
}
}

void UOdinClientComponent::CleanupLocalAudio()
{
if (IsValid(Capture))
{
Capture->StopCapturingAudio();
}
if (IsValid(Encoder))
{
// FreeEncoder invalidates the handle used by the capture callback; GC removes its delegate.
UOdinFunctionLibrary::UnlinkEncoderFromRoom(Encoder);
UOdinEncoder::FreeEncoder(Encoder);
}
Encoder = nullptr;
Capture = nullptr;
}

void UOdinClientComponent::CleanupPeerAudio(int64 PeerId)
{
const bool bIsPlayerTracked = PeerDecoders.Contains(PeerId) || PeerSynths.Contains(PeerId);
UOdinSynthComponent* Synth = nullptr;
if (PeerSynths.RemoveAndCopyValue(PeerId, Synth) && IsValid(Synth))
{
Synth->Stop();
Synth->DestroyComponent();
}

UOdinDecoder* Decoder = nullptr;
if (PeerDecoders.RemoveAndCopyValue(PeerId, Decoder) && IsValid(Decoder))
{
UOdinDecoder::FreeDecoder(Decoder);
}

if (bIsPlayerTracked)
{
UWorld* World = GetWorld();
UOdinGameInstance* GameInstance = World ? Cast<UOdinGameInstance>(World->GetGameInstance()) : nullptr;
if (IsValid(GameInstance))
{
GameInstance->OdinPlayerCharacters.Remove(PeerId);
}
}
}

void UOdinClientComponent::ConnectToOdin(FGuid PlayerId)
{
if (AccessKey.IsEmpty())
{
UE_LOG(LogTemp, Error, TEXT("ODIN Access Key is empty. Set it on the OdinClient component in the editor or Blueprint before connecting."));
return;
}

UOdinTokenGenerator* TokenGenerator = UOdinTokenGenerator::ConstructTokenGenerator(this, AccessKey);
if (!IsValid(TokenGenerator) || !TokenGenerator->GetHandle())
{
UE_LOG(LogTemp, Error, TEXT("Failed to create the ODIN token generator. Please check the Access Key."));
return;
}

UOdinJsonObject* AuthJson = nullptr;
FString RoomToken;
TokenGenerator->GenerateRoomToken(TEXT("TestRoom"), TEXT("Player"), AuthJson, RoomToken);
TokenGenerator->ReleaseHandle();
if (!IsValid(AuthJson) || RoomToken.IsEmpty())
{
UE_LOG(LogTemp, Error, TEXT("Generate room token failed. Please check the Access Key."));
return;
}

DisconnectFromOdin();
if (IsValid(Room))
{
// Use free room to instantly shut down the old room.
Room->FreeRoom();
}
Room = UOdinRoom::ConstructRoom(this);
if (!IsValid(Room))
{
UE_LOG(LogTemp, Error, TEXT("Failed to create the ODIN room."));
return;
}

Room->OnRoomPeerJoinedBP.AddUniqueDynamic(this, &UOdinClientComponent::OnPeerJoinedHandler);
Room->OnRoomPeerLeftBP.AddUniqueDynamic(this, &UOdinClientComponent::OnPeerLeftHandler);
Room->OnRoomJoinedBP.AddUniqueDynamic(this, &UOdinClientComponent::OnRoomJoinSuccessHandler);
Room->OnRoomErrorBP.AddUniqueDynamic(this, &UOdinClientComponent::OnRoomErrorHandler);

UOdinJsonObject* UserDataObject = UOdinJsonObject::ConstructJsonObject(this);
UserDataObject->SetStringField(TEXT("PlayerId"), PlayerId.ToString());
AuthJson->SetStringField(TEXT("user_data"), UserDataObject->EncodeJson());

bool bSuccess = false;
Room->ConnectRoom(TEXT("https://gateway.odin.4players.io"), AuthJson->EncodeJson(), bSuccess);
if (!bSuccess)
{
UE_LOG(LogTemp, Error, TEXT("Failed to initiate the ODIN connection."));
DisconnectFromOdin();
Room = nullptr;
}
}

void UOdinClientComponent::DisconnectFromOdin()
{
CleanupLocalAudio();

// cleanup all peer audio playback
TArray<int64> PeerIds;
PeerDecoders.GetKeys(PeerIds);
for (int64 PeerId : PeerIds)
{
CleanupPeerAudio(PeerId);
}

if (IsValid(Room))
{
Room->OnRoomPeerJoinedBP.RemoveDynamic(this, &UOdinClientComponent::OnPeerJoinedHandler);
Room->OnRoomPeerLeftBP.RemoveDynamic(this, &UOdinClientComponent::OnPeerLeftHandler);
Room->OnRoomJoinedBP.RemoveDynamic(this, &UOdinClientComponent::OnRoomJoinSuccessHandler);
Room->OnRoomErrorBP.RemoveDynamic(this, &UOdinClientComponent::OnRoomErrorHandler);
Room->CloseRoom();
}
}

void UOdinClientComponent::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
DisconnectFromOdin();
if (IsValid(Room))
{
Room->FreeRoom();
}
Room = nullptr;
Super::EndPlay(EndPlayReason);
}

You can attach this component to any actor, but the local Player Controller is typically the most appropriate location: it exists exactly once per client and is owned by that client, which aligns well with per-user voice operations. Ensure the hosting actor is present on every client; for example, the GameMode only exists on the server and is therefore not suitable.

In the following sections, we will build this component step by step and explain the reasoning behind each part.

Creating the Component​

First, create the component class in C++. The simplest path is via the Unreal Editor:

  1. Open your project in the Unreal Editor.
  2. Navigate to Tools > New C++ Class....
  3. Select Actor Component as the parent class (this guide assumes UActorComponent, but choose whatever best fits your project).
  4. Name the class (for example OdinClientComponent) and set it to Public.
  5. Click Create.

The IDE (e.g., Visual Studio) will open and the project files should regenerate automatically. If they do not, return to your .uproject, right-click it in Explorer/Finder, and select Generate Visual Studio project files....

Once the class opens in your IDE, you can begin implementing the logic. Ensure your .build.cs already includes the required ODIN and audio modules as described above. This avoids "missing symbol" issues when you start adding ODIN types and audio capture code.

The component doesn't need to tick, but it should be replicated, so we set this up in the constructor:

UOdinClientComponent::UOdinClientComponent()
{
PrimaryComponentTick.bCanEverTick = false;
SetIsReplicatedByDefault(true);
}

Creating an Access Key​

Before you can connect to ODIN, you need to create an access key. An access key authenticates your requests to the ODIN server and contains your subscription-specific information, such as the maximum number of concurrent users allowed in a room and other configuration settings. A free access key allows up to 25 users to join the same room. For larger capacities or production use, you will need to subscribe to one of our paid tiers. See the pricing page for details.

For an in-depth explanation of access keys, refer to the Understanding Access Keys guide.

Creating a Demo Key​

For now, you can generate a demo access key suitable for up to 25 concurrent users using the widget below:

ODIN Access Key Generator

Click on the button to create an access key that you can use for free for up to 25 concurrent users.

Click Create Access Key and store the key securely. You will need it later when joining a room and exchanging data or voice streams. A demo access key can later on be upgraded to a full-access key.

Creating a Room Token​

For this example, we will create the room token directly on the client. All of the connection logic lives in a dedicated ConnectToOdin() function, which we mark as BlueprintCallable so it can also be triggered from Blueprints. The function takes an FGuid PlayerId, which we will use later on to map ODIN peers to player characters. In most real-world use cases you may not want players to automatically join voice chat as soon as the game starts. Typically you would trigger this with a specific gameplay event or user action. For a first test, you can simply call ConnectToOdin(FGuid::NewGuid()) from your component's BeginPlay() function.

Instead of hard-coding the access key, we expose it as an editable AccessKey property. This way you can set the key in the Details panel of the component (or of the Blueprint that owns it) without touching the code. In the sample project, open Content/Odin/Blueprints/BP_OdinCPPPlayerController, select the inherited OdinClient component and set Odin > Authentication > Access Key.

Header:

public:
/** Sample-only credential. In production, obtain room tokens from a trusted backend instead. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Odin|Authentication")
FString AccessKey;

UFUNCTION(BlueprintCallable, Category = "Odin")
void ConnectToOdin(FGuid PlayerId);

In the source file, construct a UOdinTokenGenerator with the access key and call GenerateRoomToken. Besides the raw JWT RoomToken, the token generator fills a UOdinJsonObject pointer with an extended authentication JSON, which we will use later on. The token generator is only needed for this single call, so we release its native handle right afterwards. Make sure to include the relevant ODIN header files in order to access the required functionality.

#include "OdinJsonObject.h"
#include "OdinTokenGenerator.h"

void UOdinClientComponent::ConnectToOdin(FGuid PlayerId)
{
if (AccessKey.IsEmpty())
{
UE_LOG(LogTemp, Error, TEXT("ODIN Access Key is empty. Set it on the OdinClient component in the editor or Blueprint before connecting."));
return;
}

UOdinTokenGenerator* TokenGenerator = UOdinTokenGenerator::ConstructTokenGenerator(this, AccessKey);
if (!IsValid(TokenGenerator) || !TokenGenerator->GetHandle())
{
UE_LOG(LogTemp, Error, TEXT("Failed to create the ODIN token generator. Please check the Access Key."));
return;
}

UOdinJsonObject* AuthJson = nullptr;
FString RoomToken;
TokenGenerator->GenerateRoomToken(TEXT("TestRoom"), TEXT("Player"), AuthJson, RoomToken);
TokenGenerator->ReleaseHandle();
if (!IsValid(AuthJson) || RoomToken.IsEmpty())
{
UE_LOG(LogTemp, Error, TEXT("Generate room token failed. Please check the Access Key."));
return;
}

// ...
}
Please Note

In production, the room token should always be generated on a secure backend (e.g., a cloud function) and delivered to the client on demand. For testing, generating a token directly in the client is acceptable, but do not commit your access key to a public repository or ship it in client assets.

Both TestRoom and Player serve as placeholders for the room name and user name. In practice, you will want to integrate these with your game's logic for assigning users to rooms and identities. For testing, it is enough that all clients use the same room name and generate the room token with the same access key in order for them to connect to the same ODIN room.

Configure the Room Access​

To join a room in v2 of the ODIN Voice Plugin, we construct the UOdinRoom and call ConnectRoom. Note that we no longer pass APM settings to ConstructRoom. These are handled by the Audio Pipeline, which we will configure later on.

Keep a pointer to the room in your header so you can manage it later:

#include "OdinRoom.h"

// ...

private:
UPROPERTY(Transient)
UOdinRoom* Room = nullptr;

Then construct the room in ConnectToOdin(). Since ConnectToOdin() may be called more than once (e.g. to reconnect), we first leave any previous room. DisconnectFromOdin() is described in the Leaving a Room section. FreeRoom() then releases the old room immediately.

	DisconnectFromOdin();
if (IsValid(Room))
{
// Use free room to instantly shut down the old room.
Room->FreeRoom();
}
Room = UOdinRoom::ConstructRoom(this);
if (!IsValid(Room))
{
UE_LOG(LogTemp, Error, TEXT("Failed to create the ODIN room."));
return;
}

Event Flow​

Once your client is connected to the ODIN server, several events will be triggered that enable you to set up your scene and connect audio output to player objects. Understanding and handling these events correctly is essential for a functioning voice integration.

Check out the Event Flow section of our Manual for more information.

These events form the backbone of how ODIN synchronizes users and audio streams in real time. By correctly handling these callbacks, you ensure that your voice chat remains stable, synchronized, and responsive as players join, speak, or leave.

Binding Room Events​

To handle ODIN events, create functions that you bind to the corresponding event delegates of the UOdinRoom. In this guide we will connect four events:

  • OnRoomPeerJoinedBP: a peer joined the room. This is where we set up audio playback for the peer.
  • OnRoomPeerLeftBP: a peer left the room. This is where we clean up audio playback for the peer.
  • OnRoomJoinedBP: our own client joined the room. This is where we start capturing the microphone.
  • OnRoomErrorBP: an error occurred in the room.

Because these are dynamic multicast delegates (usable in Blueprints), the callback functions must be declared with the UFUNCTION() macro.

Header:

protected:
UFUNCTION()
void OnRoomJoinSuccessHandler(UOdinRoom* OdinRoom, FOdinJoined Data);

UFUNCTION()
void OnPeerJoinedHandler(UOdinRoom* OdinRoom, FOdinPeerJoined PeerData);

UFUNCTION()
void OnPeerLeftHandler(UOdinRoom* OdinRoom, FOdinPeerLeft PeerData);

UFUNCTION()
void OnRoomErrorHandler(UOdinRoom* OdinRoom, FOdinError Data);

We bind these handlers in ConnectToOdin() right after constructing the room and before calling ConnectRoom, so that no events are missed.

Source:

	Room->OnRoomPeerJoinedBP.AddUniqueDynamic(this, &UOdinClientComponent::OnPeerJoinedHandler);
Room->OnRoomPeerLeftBP.AddUniqueDynamic(this, &UOdinClientComponent::OnPeerLeftHandler);
Room->OnRoomJoinedBP.AddUniqueDynamic(this, &UOdinClientComponent::OnRoomJoinSuccessHandler);
Room->OnRoomErrorBP.AddUniqueDynamic(this, &UOdinClientComponent::OnRoomErrorHandler);
Bind your events before joining a room

Always set up and bind your event handlers before calling ConnectRoom. While your client is connecting, ODIN fires OnRoomPeerJoinedBP for every peer that is already present in the room. Events that fire before a handler is bound are not replayed later, so you would not hear players who were in the room before you.

Handling the Peer Joined Event​

Next, implement the functions. For this walkthrough we start with OnPeerJoinedHandler. To receive the incoming audio of the connecting peer, we create an UOdinDecoder instance using UOdinDecoder::ConstructDecoder(this, 48000, true). The 48000 refers to the sample rate in Hz that we want the decoder to produce. The true refers to the number of Audio Channels we want the decoder to output: true for stereo output, false for mono. Then we connect the decoder to the peer that just joined by calling UOdinFunctionLibrary::RegisterDecoder(Decoder, OdinRoom, PeerData.peer_id).

A decoder processes audio datagrams received from remote peers. To play back the resulting audio, you need an audio component that converts the stream into audible output: the Odin Synth Component. Use UOdinSynthComponent::SetDecoder to connect the synth to the UOdinDecoder object. This establishes the playback path. After assigning, make sure to activate the component so audio is actually produced.

For full 3D voice chat, the recommended approach is to add an Odin Synth Component to each player character or pawn and position it near the character's head. This ensures accurate spatialization and attenuation.

We will ignore 3D spatialization for now and provide 2D output only. In that case, you can call AActor::AddComponentByClass() at runtime to create and attach an Odin Synth Component to the locally controlled player character. This avoids the need to resolve which character corresponds to the ODIN peer and simplifies initial setup.

ODIN does not clean up decoders and synth components for us when a peer leaves. This means we need to keep track of which decoder and synth component belong to which peer, using two TMap properties. Marking them as UPROPERTY also keeps the decoders alive, independently of the synth components.

Header:

#include "OdinAudio/OdinDecoder.h"
#include "OdinAudio/OdinSynthComponent.h"

// ...

private:
// Keep the decoders alive independently of synth components owned by remote characters.
UPROPERTY(Transient)
TMap<int64, UOdinDecoder*> PeerDecoders;

UPROPERTY(Transient)
TMap<int64, UOdinSynthComponent*> PeerSynths;

At the start of each handler, we check that the event belongs to the room we are currently using and that this room is not being closed. This way, late events of a room we already left are ignored.

Source:

#include "Kismet/GameplayStatics.h"
#include "GameFramework/Character.h"
#include "OdinFunctionLibrary.h"

// ...

void UOdinClientComponent::OnPeerJoinedHandler(UOdinRoom* OdinRoom, FOdinPeerJoined PeerData)
{
if (!IsValid(OdinRoom) || OdinRoom != Room || OdinRoom->IsClosing())
{
return;
}

// Create a Decoder with 48000hz sample rate and choose the Channel mode (true for stereo, false for mono)
UOdinDecoder* Decoder = UOdinDecoder::ConstructDecoder(this, 48000, true);

// Create Odin Synth Component on the local player character
ACharacter* Player = UGameplayStatics::GetPlayerCharacter(this, 0);
UActorComponent* Component = Player->AddComponentByClass(UOdinSynthComponent::StaticClass(), false,
FTransform::Identity, false);
UOdinSynthComponent* Synth = Cast<UOdinSynthComponent>(Component);

// Keep track of decoder and synth, so we can clean them up when the peer leaves
PeerDecoders.Add(PeerData.peer_id, Decoder);
PeerSynths.Add(PeerData.peer_id, Synth);

// Register the decoder to the room and peer, assign it to the synth and start playback
UOdinFunctionLibrary::RegisterDecoder(Decoder, OdinRoom, PeerData.peer_id);
Synth->SetDecoder(Decoder);
Synth->Activate();

UE_LOG(LogTemp, Log, TEXT("Added ODIN playback for peer %s."), *PeerData.user_id);
}

That's it. With this setup, every user connected to the same room will be heard at full volume through the local player's position.

info

In a real 3D game you would not route all audio through the local player. Instead, you would map the ODIN Peer ID to your Unreal Player's Unique Net ID and assign each UOdinDecoder output to the correct player character. This way Unreal's audio engine automatically applies spatialization and attenuation, i.e. by reducing the volume as players move farther away from the listener.

This approach is presented later on in the guide.

Handling the Peer Left Event​

When a peer leaves the room, OnRoomPeerLeftBP is triggered. Odin Synth Components are not cleaned up automatically, so we stop and destroy the synth component we created for that peer and free its decoder with UOdinDecoder::FreeDecoder. The cleanup logic lives in its own CleanupPeerAudio() function, because we will reuse it when leaving the room.

OnRoomPeerLeftBP is not only triggered when a remote peer leaves. The plugin also reports every known peer as left when the connection is lost, when the room is closed, or before a reconnect. Handling this event therefore covers all of these cases.

Header:

private:
void CleanupPeerAudio(int64 PeerId);

Source:

void UOdinClientComponent::OnPeerLeftHandler(UOdinRoom* OdinRoom, FOdinPeerLeft PeerData)
{
if (OdinRoom == Room)
{
// Also called for synthetic peer-left events on connection loss, close and reconnect.
CleanupPeerAudio(PeerData.peer_id);
}
}

void UOdinClientComponent::CleanupPeerAudio(int64 PeerId)
{
UOdinSynthComponent* Synth = nullptr;
if (PeerSynths.RemoveAndCopyValue(PeerId, Synth) && IsValid(Synth))
{
Synth->Stop();
Synth->DestroyComponent();
}

UOdinDecoder* Decoder = nullptr;
if (PeerDecoders.RemoveAndCopyValue(PeerId, Decoder) && IsValid(Decoder))
{
UOdinDecoder::FreeDecoder(Decoder);
}
}

Handling Room Errors​

To get notified when something goes wrong in the room, we simply log the error message provided by OnRoomErrorBP:

void UOdinClientComponent::OnRoomErrorHandler(UOdinRoom* OdinRoom, FOdinError Data)
{
if (OdinRoom == Room)
{
UE_LOG(LogTemp, Error, TEXT("ODIN room error: %s"), *Data.message);
}
}

Joining a Room​

Now we have everything in place to join a room. A room token for the Odin room TestRoom has been created, the room object has been constructed and our event handlers are bound. The final step is to initiate the join at the end of ConnectToOdin().

	bool bSuccess = false;
Room->ConnectRoom(TEXT("https://gateway.odin.4players.io"), RoomToken, bSuccess);
if (!bSuccess)
{
UE_LOG(LogTemp, Error, TEXT("Failed to initiate the ODIN connection."));
DisconnectFromOdin();
Room = nullptr;
}

In this step, we simply use the Room pointer together with the default ODIN gateway URL and the room token. With this, the client will attempt to join the room. If the connection can't be initiated, we clean up again.

info

The default gateway used here is located in Europe. If you'd like to connect to a gateway in another region, please take a look at our available gateways list.

Adding Microphone Input​

Now that we have successfully joined a room, we need to add our own microphone input, so that other users in the room can hear us. We do this in the OnRoomJoinSuccessHandler:

  1. Create an UOdinAudioCapture object and open the microphone by calling RestartCapturing(false). Passing false opens the default capture device without starting the capture yet. If no microphone can be opened, we still stay in the room, so we can listen to the other peers.
  2. Create an UOdinEncoder with UOdinEncoder::ConstructEncoder, using the sample rate and channel count of the audio capture.
  3. Connect the audio capture to the encoder with UOdinEncoder::SetAudioGenerator.
  4. Link the encoder to the room with UOdinFunctionLibrary::LinkEncoderToRoom.
  5. Call UOdinAudioCapture::StartCapturingAudio to start capturing.

Since capturing requires keeping the capture object alive, we keep the pointers to the UOdinAudioCapture and UOdinEncoder objects in UPROPERTY() marked instance variables. This also allows us to start and stop microphone input whenever needed.

Header:

#include "OdinAudio/OdinAudioCapture.h"
#include "OdinAudio/OdinEncoder.h"

// ...

private:
void CleanupLocalAudio();

UPROPERTY(Transient)
UOdinAudioCapture* Capture = nullptr;

UPROPERTY(Transient)
UOdinEncoder* Encoder = nullptr;

Source:

void UOdinClientComponent::OnRoomJoinSuccessHandler(UOdinRoom* OdinRoom, FOdinJoined Data)
{
if (!IsValid(OdinRoom) || OdinRoom != Room || OdinRoom->IsClosing())
{
return;
}

UE_LOG(LogTemp, Log, TEXT("Joined ODIN room %s."), *Data.room_id);
CleanupLocalAudio();

Capture = NewObject<UOdinAudioCapture>(this);
if (!IsValid(Capture) || !Capture->RestartCapturing(false))
{
UE_LOG(LogTemp, Error, TEXT("Failed to open the microphone. Joined the room without local voice transmission."));
CleanupLocalAudio();
return;
}

const int32 SampleRate = Capture->GetSampleRate();
const int32 NumChannels = Capture->GetNumChannels();
// Peer id 0 lets the plugin resolve the local peer id automatically when sending to the linked room.
Encoder = UOdinEncoder::ConstructEncoder(this, 0, SampleRate, NumChannels >= 2);
if (!IsValid(Encoder) || !Encoder->GetHandle())
{
UE_LOG(LogTemp, Error, TEXT("Failed to create the ODIN encoder."));
CleanupLocalAudio();
return;
}

// Configure the audio pipeline here, see the next section

Encoder->SetAudioGenerator(Capture);
UOdinFunctionLibrary::LinkEncoderToRoom(Encoder, OdinRoom);

Capture->StartCapturingAudio();
}

CleanupLocalAudio() stops the capture, unlinks the encoder from the room and frees it. We call it before creating new objects, whenever setting up the microphone fails, and when leaving the room:

void UOdinClientComponent::CleanupLocalAudio()
{
if (IsValid(Capture))
{
Capture->StopCapturingAudio();
}
if (IsValid(Encoder))
{
// FreeEncoder invalidates the handle used by the capture callback; GC removes its delegate.
UOdinFunctionLibrary::UnlinkEncoderFromRoom(Encoder);
UOdinEncoder::FreeEncoder(Encoder);
}
Encoder = nullptr;
Capture = nullptr;
}

Configuring Audio Processing (APM)​

In V2, Audio Processing Module (APM) settings like Noise Suppression and Voice Activity Detection are configured on the UOdinPipeline of the Encoder. This allows to set different effects for each room or scenario.

Configure the pipeline after creating the UOdinEncoder and before linking it to the room and starting the capture. Retrieve the encoder's audio pipeline, insert the APM and/or VAD effects and apply their configuration structures. Each insert call returns the id of the new effect, which you then use to apply the configuration.

InsertApmEffect optionally takes a sample rate and a stereo flag. These describe the format of the playback audio that the echo canceller uses as its reference signal. The defaults (0, which uses the current audio mixer's sample rate and channel count) are the right choice in most cases, so we only pass the index.

	// Inside OnRoomJoinSuccessHandler, after creating the encoder
UOdinPipeline* Pipeline = Encoder->GetOrCreatePipeline();

const int32 ApmId = Pipeline->InsertApmEffect(0);
FOdinApmConfig ApmConfig;
// For local testing purposes, this is turned off - when running multiple clients on the same machine with the same
// microphone input, the Echo Canceller will kick in. In production you'd probably want to enable this.
ApmConfig.echo_canceller = false;
ApmConfig.noise_suppression = EOdinNoiseSuppression::ODIN_NOISE_SUPPRESSION_MODERATE;
ApmConfig.high_pass_filter = true;
ApmConfig.gain_controller = EOdinGainControllerVersion::ODIN_GAIN_CONTROLLER_V2;
ApmConfig.transient_suppressor = true;

// The plugin returns 0 on failure.
if (ApmId <= 0 || !Pipeline->SetApmConfig(ApmId, ApmConfig))
{
UE_LOG(LogTemp, Error, TEXT("Failed to configure the ODIN APM effect."));
CleanupLocalAudio();
return;
}

const int32 VadId = Pipeline->InsertVadEffect(1);
FOdinVadConfig VadConfig;
VadConfig.VoiceActivity = {
.Enabled = true,
.AttackThreshold = 0.7f,
.ReleaseThreshold = 0.6f
};
VadConfig.VolumeGate = {
.Enabled = true,
.AttackThreshold = -30.0f,
.ReleaseThreshold = -40.0f
};
if (VadId <= 0 || !Pipeline->SetVadConfig(VadId, VadConfig))
{
UE_LOG(LogTemp, Error, TEXT("Failed to configure the ODIN VAD effect."));
CleanupLocalAudio();
return;
}

Leaving a Room​

To leave the room, we add a DisconnectFromOdin() function, which is BlueprintCallable as well. It stops the local microphone input, cleans up the playback of all peers, unbinds our event handlers and closes the room. CloseRoom() leaves the room gracefully: our own peer leaves the room on the server and the native room is released once it is closed. If you need to release a room immediately instead, use FreeRoom(), as we do in ConnectToOdin().

We also call DisconnectFromOdin() from EndPlay(), so everything is cleaned up when the owning actor is destroyed or the game ends.

Header:

public:
/** Stop local and remote audio and leave the room. */
UFUNCTION(BlueprintCallable, Category = "Odin")
void DisconnectFromOdin();

protected:
virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;

Source:

void UOdinClientComponent::DisconnectFromOdin()
{
CleanupLocalAudio();

// cleanup all peer audio playback
TArray<int64> PeerIds;
PeerDecoders.GetKeys(PeerIds);
for (int64 PeerId : PeerIds)
{
CleanupPeerAudio(PeerId);
}

if (IsValid(Room))
{
Room->OnRoomPeerJoinedBP.RemoveDynamic(this, &UOdinClientComponent::OnPeerJoinedHandler);
Room->OnRoomPeerLeftBP.RemoveDynamic(this, &UOdinClientComponent::OnPeerLeftHandler);
Room->OnRoomJoinedBP.RemoveDynamic(this, &UOdinClientComponent::OnRoomJoinSuccessHandler);
Room->OnRoomErrorBP.RemoveDynamic(this, &UOdinClientComponent::OnRoomErrorHandler);
Room->CloseRoom();
}
}

void UOdinClientComponent::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
DisconnectFromOdin();
if (IsValid(Room))
{
Room->FreeRoom();
}
Room = nullptr;
Super::EndPlay(EndPlayReason);
}

Enabling 3D Audio​

So far, we have enabled voice chat in the application, but most likely you will also want to make use of Unreal's 3D audio engine. Enabling this is straightforward: you can assign Attenuation Settings directly to your Odin Synth Component. However, there is another important consideration: making sure that the Odin Synth Components are positioned correctly in the scene.

The simplest approach is to attach Odin Synth Components to the Pawns that represent the respective players. To do this reliably, you need to track which Odin Peer is associated with which player. Let's take a closer look at how to achieve that.

The exact implementation depends on your networking and replication system. In most cases you will be using Unreal Engine's native networking, so we will assume that setup here. If you are using a different system, you will need to adapt the approach to fit your networking solution.

Creating the Necessary Classes​

The goal of the next steps is to map each Odin Peer Id to the corresponding Character in the scene so that every UOdinDecoder can be assigned to the correct Actor. To achieve this we need to create two additional classes alongside the existing UOdinClientComponent.

First, we create a new class derived from Unreal's ACharacter. Each ACharacter exists on all clients, and there is one instance for every connected player (including a possible ListenServer). This makes it the ideal place to replicate a player's ID from the server to all clients. In this guide, the class is called AOdinCharacter. Don't forget to update your Game Mode to reference this class so it will be used in your project. Alternatively, you can derive your current Default Player Character class from AOdinCharacter to retain existing functionality. In the sample project, the Blueprint BP_OdinCPPThirdPersonCharacter derives from AOdinCharacter for this purpose.

Next, we create a class derived from UGameInstance to hold the mapping. A Game Instance object exists on each client but is never replicated, making it suitable for storing and maintaining per-client data. In this guide, the derived class is called UOdinGameInstance. Set it as the Game Instance Class in your project's Maps & Modes settings.

After creating these two classes and regenerating the Visual Studio project files, you can begin implementing the logic to connect Odin peers with their corresponding characters.

Creating the Player Character Maps​

To assign the correct Odin Synth Component to the appropriate player-controlled character, we need to track a unique identifier for each player, their characters, and their Odin Peer IDs.

In the UOdinGameInstance class, create two maps: one mapping the unique player ID to the player character (using an FGuid), and another mapping the Odin Peer ID to the player character (using an int64). The first map keeps track of which player character belongs to which player. When another Odin peer joins the voice chat room, you can then use this mapping to determine which peer ID corresponds to which player character.

Your header file should look similar to this:

#pragma once

#include "CoreMinimal.h"
#include "Engine/GameInstance.h"
#include "OdinGameInstance.generated.h"

UCLASS()
class ODINUNREALCPPSAMPLE_API UOdinGameInstance : public UGameInstance
{
GENERATED_BODY()

public:
UPROPERTY()
TMap<FGuid, ACharacter*> PlayerCharacters;
UPROPERTY()
TMap<int64, ACharacter*> OdinPlayerCharacters;
};

Passing the Player Id as User Data​

So far, we have used the raw RoomToken to join the room. Now we also need to pass the PlayerId given to ConnectToOdin() as User Data, so other clients can map our ODIN peer to our player character.

To pass the PlayerId as User Data, use the UOdinJsonObject helper class from the Odin SDK. Create a UOdinJsonObject, add a String Field with the key PlayerId, and set the value to the GUID cast to FString. Then, we will take the AuthJson object, which we previously initialized with a call to GenerateRoomToken, and add the user data json object as a string field with the key user_data, by calling EncodeJson(). This will initialize the client's Peer User Data with the required PlayerId.

We then use the UOdinJsonObject::EncodeJson function to retrieve the authentication JSON as an FString, which replaces the RoomToken during the UOdinRoom::ConnectRoom call.

Source:

void UOdinClientComponent::ConnectToOdin(FGuid PlayerId)
{
// Token generation, room construction and event binding as before
// ...

UOdinJsonObject* UserDataObject = UOdinJsonObject::ConstructJsonObject(this);
UserDataObject->SetStringField(TEXT("PlayerId"), PlayerId.ToString());
AuthJson->SetStringField(TEXT("user_data"), UserDataObject->EncodeJson());

bool bSuccess = false;
Room->ConnectRoom(TEXT("https://gateway.odin.4players.io"), AuthJson->EncodeJson(), bSuccess);
if (!bSuccess)
{
UE_LOG(LogTemp, Error, TEXT("Failed to initiate the ODIN connection."));
DisconnectFromOdin();
Room = nullptr;
}
}
note

The AuthJson object was initialized automatically by the call to GenerateRoomToken. In production, you will not be able to use this function, so you will need to setup an authentication JSON by yourself. Luckily this is a simple process: the AuthJson object initially only contains a string field with key token and the value set to the generated room token. To setup your own authentication JSON object, create a new UOdinJsonObject and set the token string field appropriately. You can then continue with setting up the user data as described above.

Propagating an Identifier​

If you have not done so earlier, now is the time to attach the component to your Player Controller. For this sample we assume you have added UOdinClientComponent to your game's default PlayerController. You can do this either in C++ within your PlayerController class, or via the Blueprint Editor if your default controller is a Blueprint. In the sample project, AOdinPlayerController creates the component in its constructor:

AOdinPlayerController::AOdinPlayerController()
{
OdinClient = CreateDefaultSubobject<UOdinClientComponent>(TEXT("OdinClient"));
}

With that in place, we can propagate a per-player identifier for the current game session. You can use GUIDs or any existing unique player identifiers you already maintain (for example from a login flow). First, declare the corresponding replicated variable in C++ and configure the class for replication. We want each client to react as soon as PlayerId is replicated, so it can be added to the local map of player characters and player IDs. The header will look like this in the end:

Header:

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "OdinCharacter.generated.h"

UCLASS()
class ODINUNREALCPPSAMPLE_API AOdinCharacter : public ACharacter
{
GENERATED_BODY()

public:
// Sets default values for this character's properties
AOdinCharacter();

protected:
// Called when the game starts or when spawned
virtual void BeginPlay() override;

public:
UPROPERTY(ReplicatedUsing = OnRep_PlayerId)
FGuid PlayerId;

UFUNCTION()
void OnRep_PlayerId();

virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
};

Next, implement GetLifetimeReplicatedProps() to enable replication of PlayerId.

Source:

void AOdinCharacter::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);

DOREPLIFETIME(AOdinCharacter, PlayerId);
}

You can assign the ID at any appropriate point during startup. A good entry point is the BeginPlay function of the Character class. On the server, set the replicated variable so it propagates to all clients. Also add the newly created PlayerId to the Game Instance's PlayerCharacters map.

If this character is locally controlled, start the ODIN room join routine on the UOdinClientComponent. If you added a test call to ConnectToOdin() in the component's BeginPlay() earlier, remove it now.

#include "OdinGameInstance.h"
#include "OdinClientComponent.h"
#include "Net/UnrealNetwork.h"
#include "Kismet/GameplayStatics.h"

// ...

void AOdinCharacter::BeginPlay()
{
Super::BeginPlay();

// check if running on any kind of server
if (GetNetMode() == NM_DedicatedServer || GetNetMode() == NM_ListenServer)
{
// create new guid
PlayerId = FGuid::NewGuid();

// add guid and reference to this character to the game instance's player character map
UOdinGameInstance* GameInstance = Cast<UOdinGameInstance>(UGameplayStatics::GetGameInstance(this));
if (!GameInstance)
{
UE_LOG(LogTemp, Error,
TEXT(
"AOdinCharacter::BeginPlay: Retrieved invalid Game Instance type, please use UOdinGameInstance as a Game Instance base class."
))
return;
}

GameInstance->PlayerCharacters.Add(PlayerId, this);

UE_LOG(LogTemp, Warning, TEXT("Created PlayerId: %s"), *PlayerId.ToString());

// if this character is also controlled locally we want to start the routine to join the Odin Room
if (IsLocallyControlled())
{
UOdinClientComponent* OdinClientComponent = UGameplayStatics::GetPlayerControllerFromID(this, 0)->
GetComponentByClass<UOdinClientComponent>();
if (OdinClientComponent)
{
OdinClientComponent->ConnectToOdin(PlayerId);
}
}
}
}

Finally, implement OnRep_PlayerId(), which is invoked on each client once the PlayerId arrives. Here you perform the same bookkeeping as above, without creating a new GUID. Since the client now knows its PlayerId, it can start the ConnectToOdin() routine.

void AOdinCharacter::OnRep_PlayerId()
{
UOdinGameInstance* GameInstance = Cast<UOdinGameInstance>(UGameplayStatics::GetGameInstance(this));
if (!GameInstance)
{
UE_LOG(LogTemp, Error,
TEXT(
"AOdinCharacter::OnRep_PlayerId: Retrieved invalid Game Instance type, please use UOdinGameInstance as a Game Instance base class."
))
return;
}

GameInstance->PlayerCharacters.Add(PlayerId, this);

UE_LOG(LogTemp, Warning, TEXT("Replicated PlayerId: %s"), *PlayerId.ToString());

if (IsLocallyControlled())
{
UOdinClientComponent* OdinClientComponent = UGameplayStatics::GetPlayerControllerFromID(this, 0)->
GetComponentByClass<UOdinClientComponent>();
if (OdinClientComponent)
{
OdinClientComponent->ConnectToOdin(PlayerId);
}
}
}

Handling the Peer Joined Event with an Identifier​

With player identifiers in place, each client will receive an event when a player joins the ODIN room. In this handler, extract the player identifier from the provided user data. Peers that joined without a valid PlayerId, e.g. clients of another application using the same room, are skipped. Once you resolve which character belongs to the newly joined peer, add that character to the new map using the passed Peer Id.

With the identifier mapping in place, you can now attach the Odin Synth Component to the correct player character. Do not attach the component to the local player by default. Instead, resolve the target character using the PlayerId and your Game Instance's PlayerCharacters map, then attach and activate the synth component on that character. Before setting up the new playback, we call CleanupPeerAudio() in case we already set up playback for this peer earlier.

The finished OnPeerJoinedHandler() will look like this:

void UOdinClientComponent::OnPeerJoinedHandler(UOdinRoom* OdinRoom, FOdinPeerJoined PeerData)
{
if (!IsValid(OdinRoom) || OdinRoom != Room || OdinRoom->IsClosing())
{
return;
}

UOdinJsonObject* JSON = UOdinJsonObject::ConstructJsonObjectFromBytes(this, PeerData.user_data);

if (!IsValid(JSON) || !JSON->GetRootObject().IsValid()
|| !JSON->HasField(TEXT("PlayerId")))
{
UE_LOG(LogTemp, Warning, TEXT("Peer %s joined without a PlayerId user data field."), *PeerData.user_id);
return;
}
FString GUIDString = JSON->GetStringField(TEXT("PlayerId"));

FGuid PlayerId;
if (!FGuid::Parse(GUIDString, PlayerId))
{
UE_LOG(LogTemp, Warning, TEXT("Peer %s joined with an invalid PlayerId."), *PeerData.user_id);
return;
}

UOdinGameInstance* GameInstance = Cast<UOdinGameInstance>(UGameplayStatics::GetGameInstance(this));
if (!IsValid(GameInstance))
{
UE_LOG(LogTemp, Error, TEXT("Current Game Instance is of invalid type, please use a UOdinGameInstance."));
return;
}

ACharacter* Character = GameInstance->PlayerCharacters.FindRef(PlayerId);
if (!IsValid(Character))
{
UE_LOG(LogTemp, Warning, TEXT("Peer %s joined, but could not be mapped to a player character."), *PeerData.user_id);
return;
}

CleanupPeerAudio(PeerData.peer_id);

UOdinDecoder* Decoder = UOdinDecoder::ConstructDecoder(this, 48000, true);

UActorComponent* Component = Character->AddComponentByClass(UOdinSynthComponent::StaticClass(), false,
FTransform::Identity, false);
UOdinSynthComponent* Synth = Cast<UOdinSynthComponent>(Component);

PeerDecoders.Add(PeerData.peer_id, Decoder);
PeerSynths.Add(PeerData.peer_id, Synth);
GameInstance->OdinPlayerCharacters.Add(PeerData.peer_id, Character);

UOdinFunctionLibrary::RegisterDecoder(Decoder, OdinRoom, PeerData.peer_id);
Synth->SetDecoder(Decoder);

FSoundAttenuationSettings AttenuationSettings;
AttenuationSettings.bSpatialize = true;
AttenuationSettings.bAttenuate = true;
// Add further attenuation settings here
Synth->AdjustAttenuation(AttenuationSettings);
Synth->Activate();

UE_LOG(LogTemp, Log, TEXT("Added ODIN playback for peer %s."), *PeerData.user_id);
}

Since we now also store the peer in the Game Instance's OdinPlayerCharacters map, CleanupPeerAudio() needs to remove it from there when the peer leaves:

void UOdinClientComponent::CleanupPeerAudio(int64 PeerId)
{
const bool bIsPlayerTracked = PeerDecoders.Contains(PeerId) || PeerSynths.Contains(PeerId);
UOdinSynthComponent* Synth = nullptr;
if (PeerSynths.RemoveAndCopyValue(PeerId, Synth) && IsValid(Synth))
{
Synth->Stop();
Synth->DestroyComponent();
}

UOdinDecoder* Decoder = nullptr;
if (PeerDecoders.RemoveAndCopyValue(PeerId, Decoder) && IsValid(Decoder))
{
UOdinDecoder::FreeDecoder(Decoder);
}

if (bIsPlayerTracked)
{
UWorld* World = GetWorld();
UOdinGameInstance* GameInstance = World ? Cast<UOdinGameInstance>(World->GetGameInstance()) : nullptr;
if (IsValid(GameInstance))
{
GameInstance->OdinPlayerCharacters.Remove(PeerId);
}
}
}

Conclusion​

This is all you need to add Odin Synth Components to the correct player characters. From here, you can adjust the attenuation settings to whatever best fits your 3D world. The most straightforward approach is simply assigning an attenuation settings asset created in the Unreal Editor to your Odin Synth Component.

Because Odin itself is agnostic of the underlying audio engine, you are free to use the built-in Unreal Audio Engine or any third-party solution such as Steam Audio, Microsoft Audio, FMOD, or Wwise. All of these work by configuring your project and applying the appropriate attenuation settings to the Odin Synth Component.


This guide was last updated for the ODIN Voice Unreal Plugin version 20208.0.0 and Unreal Engine 5.8.