Tutorial — Conversation Client Plugin
Augmented Traveler local tutorial. Back to Unreal — Tutorials · the service: AI Mind — Process · how the figure uses it: Tutorial — Authoring a Q&A Experience with Ambient Idle
How to build ATConversation, a small C++ plugin that connects a figure in Unreal to the studio's conversation service. It does four jobs:
- records the visitor's question from the microphone;
- streams it to the service;
- receives text, a gesture tag and streaming voice audio;
- plays the audio so the lip-sync source can drive the face.
Draft. The code below is a skeleton showing the Unreal parts. Fix the message protocol with the AI engineer first (section 2). Compile and test it on the kiosk PC. Unreal engineer + AI engineer.
1. Why a C++ plugin
- Reuse: the same client works in every program level and every surface build.
- Performance and threading: audio and network work happen off the game thread.
- Conversion later: logic kept in C++ with thin Blueprint calls converts more easily when UE6 arrives (Tutorial — Blueprints for Augmented Traveler).
- Secrets stay out of Blueprints: API keys come from the kiosk's secure config, never from content.
2. The protocol (agree it first)
One WebSocket per conversation turn or session, wss:// in production:
| Direction | Message | Payload |
|---|---|---|
| → service | start (JSON) | figure ID, venue ID, session ID, language, audio format (16 kHz mono PCM16) |
| → service | audio frames (binary) | 20–40 ms of PCM each |
| → service | end_of_speech (JSON) | — (push-to-talk released, or end of speech detected) |
| ← service | transcript (JSON) | recognised question (shown briefly as a caption) |
| ← service | answer_meta (JSON) | answer ID, authored-or-generated flag, gesture tag, refusal flag, citation IDs |
| ← service | caption (JSON) | text chunks with timing |
| ← service | audio frames (binary) | TTS audio, 24 kHz mono PCM16 (agree the rate) |
| ← service | answer_end (JSON) | — |
| ← service | error (JSON) | code; the client falls back (section 6) |
If an authored answer matches, the service sends answer_meta with authored: true and a sequence ID. Unreal then plays that pre-animated Level Sequence instead of streaming audio (Tutorial — Sequencer for Monologues).
3. Create the plugin
- Edit → Plugins → + Add → Blank (a C++ plugin). Name it
ATConversation. It goes inAT_Studio/Plugins/. - In
ATConversation.uplugin: module type Runtime, loading phase Default. Also enable the engine plugin AudioCapture. - In
ATConversation.Build.cs, add the dependencies:
PublicDependencyModuleNames.AddRange(new string[] {
"Core", "CoreUObject", "Engine",
"WebSockets", "HTTP", "Json", "JsonUtilities",
"AudioCapture", "AudioCaptureCore", "AudioMixer"
});
4. The component (skeleton)
One UATConversationComponent, added to BP_AT_<Figure> (the child Blueprint of the MetaHuman; Tutorial — MetaHumans in Unreal).
// ATConversationComponent.h
#pragma once
#include "Components/ActorComponent.h"
#include "IWebSocket.h"
#include "ATConversationComponent.generated.h"
class USoundWaveProcedural;
class UAudioComponent;
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnATText, const FString&, Text);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnATAnswerMeta, const FString&, GestureTag, bool, bAuthored);
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnATSignal);
UCLASS(ClassGroup=(AT), meta=(BlueprintSpawnableComponent))
class ATCONVERSATION_API UATConversationComponent : public UActorComponent
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, Category="AT") FString ServiceUrl; // from kiosk config, not content
UPROPERTY(EditAnywhere, Category="AT") FString FigureId;
UPROPERTY(EditAnywhere, Category="AT") int32 TtsSampleRate = 24000;
UPROPERTY(BlueprintAssignable) FOnATText OnTranscript;
UPROPERTY(BlueprintAssignable) FOnATText OnCaption;
UPROPERTY(BlueprintAssignable) FOnATAnswerMeta OnAnswerMeta;
UPROPERTY(BlueprintAssignable) FOnATSignal OnAnswerEnd;
UPROPERTY(BlueprintAssignable) FOnATSignal OnServiceError;
UFUNCTION(BlueprintCallable, Category="AT") void BeginQuestion(); // push-to-talk pressed
UFUNCTION(BlueprintCallable, Category="AT") void EndQuestion(); // push-to-talk released
UPROPERTY(BlueprintReadOnly) UAudioComponent* VoiceAudio = nullptr; // lip-sync source listens to this
protected:
virtual void BeginPlay() override;
virtual void EndPlay(const EEndPlayReason::Type Reason) override;
private:
TSharedPtr<IWebSocket> Socket;
UPROPERTY() USoundWaveProcedural* VoiceWave = nullptr;
void HandleText(const FString& Message);
void HandleBinary(const void* Data, SIZE_T Size, SIZE_T BytesRemaining);
};
// ATConversationComponent.cpp (key parts)
#include "ATConversationComponent.h"
#include "WebSocketsModule.h"
#include "Sound/SoundWaveProcedural.h"
#include "Components/AudioComponent.h"
#include "Serialization/JsonSerializer.h"
#include "Async/Async.h"
void UATConversationComponent::BeginPlay()
{
Super::BeginPlay();
VoiceWave = NewObject<USoundWaveProcedural>(this);
VoiceWave->SetSampleRate(TtsSampleRate);
VoiceWave->NumChannels = 1;
VoiceWave->Duration = INDEFINITELY_LOOPING_DURATION;
VoiceWave->bLooping = false;
VoiceAudio = NewObject<UAudioComponent>(GetOwner());
VoiceAudio->RegisterComponent();
VoiceAudio->SetSound(VoiceWave);
}
void UATConversationComponent::BeginQuestion()
{
Socket = FWebSocketsModule::Get().CreateWebSocket(ServiceUrl);
TWeakObjectPtr<UATConversationComponent> Weak(this);
Socket->OnConnected().AddLambda([Weak]() {
if (Weak.IsValid()) { /* send "start" JSON; start mic capture (see section 5) */ }
});
Socket->OnMessage().AddLambda([Weak](const FString& Msg) {
// Callbacks may arrive off the game thread: hop back before touching UObjects.
AsyncTaskGameThread, [Weak, Msg]() { if (Weak.IsValid()) Weak->HandleText(Msg); };
});
Socket->OnRawMessage().AddLambda([Weak](const void* Data, SIZE_T Size, SIZE_T Remaining) {
TArray<uint8> Copy((const uint8*)Data, Size);
AsyncTaskGameThread, [Weak, Copy = MoveTemp(Copy), Remaining]( {
if (Weak.IsValid()) Weak->HandleBinary(Copy.GetData(), Copy.Num(), Remaining);
});
});
Socket->OnConnectionError().AddLambda([Weak](const FString&) {
AsyncTaskGameThread, [Weak]() { if (Weak.IsValid()) Weak->OnServiceError.Broadcast(); };
});
Socket->Connect();
}
void UATConversationComponent::HandleBinary(const void* Data, SIZE_T Size, SIZE_T)
{
// TTS PCM16 audio: queue it and start playback on the first chunk.
VoiceWave->QueueAudio(static_cast<const uint8*>(Data), Size);
if (!VoiceAudio->IsPlaying()) VoiceAudio->Play();
}
void UATConversationComponent::HandleText(const FString& Message)
{
TSharedPtr<FJsonObject> Obj;
if Create(Message), Obj) || !Obj.IsValid() return;
const FString Type = Obj->GetStringField(TEXT("type"));
if (Type == TEXT("transcript")) OnTranscript.Broadcast(Obj->GetStringField(TEXT("text")));
else if (Type == TEXT("caption")) OnCaption.Broadcast(Obj->GetStringField(TEXT("text")));
else if (Type == TEXT("answer_meta")) OnAnswerMeta.Broadcast(Obj->GetStringField(TEXT("gesture")), Obj->GetBoolField(TEXT("authored")));
else if (Type == TEXT("answer_end")) OnAnswerEnd.Broadcast();
else if (Type == TEXT("error")) OnServiceError.Broadcast();
}
EndQuestion() stops the microphone and sends end_of_speech. EndPlay closes the socket and calls VoiceWave->ResetAudio().
Not compiled yet. This is a skeleton. Check the exact signatures against the 5.8 API pages listed in the Sources and fix them in this page after the first build.
5. Microphone capture
- Use the AudioCapture plugin:
UAudioCaptureComponentfor a simple route, orAudio::FAudioCapture/IAudioCaptureStreamfor raw buffers in C++. - Convert the float samples to 16 kHz mono PCM16. Send 20–40 ms frames as binary WebSocket messages.
- Push-to-talk: a button (kiosk), controller trigger or pinch (headsets). Background noise in museums makes always-listening unreliable.
- Permissions: Android headsets need the runtime microphone permission; visionOS/macOS need the
.plistusage string. Linux may have no capture backend.
6. Latency and failure
- Target: first voice audio under about 1.5 s after the visitor stops speaking (AI Mind — Process).
- Play the "Thinking" gesture while waiting. Never use filler speech.
- Underflow:
USoundWaveProceduralfires an underflow delegate when the queue runs dry. Log it; it means the network or TTS is too slow. - Timeouts:
- no
answer_metawithin 4 s → play an authored "Let me think on that another time" line, then return to Waiting; - socket error → set the program to offline mode (Tutorial — Authoring a Timed Experience).
- no
- Log per turn: timestamps (question end → first audio), answer ID, refusal flag. No audio and no faces.
7. Wiring to the figure
VoiceAudiois the audio source for lip sync (the MetaHuman Audio Live Link Source or Audio2Face; Tutorial — Authoring a Q&A Experience with Ambient Idle).OnAnswerMetagesture tag → the State Tree picks the gesture clip.OnCaption/OnTranscript→ the caption UI (Tutorial — Captions UI).
8. Check before a venue build
Sources
- Epic, Plugins in Unreal Engine: https://dev.epicgames.com/documentation/unreal-engine/plugins-in-unreal-engine
- Epic, HTTP module API: https://dev.epicgames.com/documentation/unreal-engine/API/Runtime/HTTP
- Epic, WebSockets API: https://dev.epicgames.com/documentation/unreal-engine/API/Runtime/WebSockets
- Unreal Community Wiki, WebSocket client in C++: https://unrealcommunity.wiki/websocket-client-cpp-5vk7hp9e
- Epic, UAudioCaptureComponent: https://dev.epicgames.com/documentation/unreal-engine/API/Plugins/AudioCapture/UAudioCaptureComponent
- Epic, IAudioCaptureStream: https://dev.epicgames.com/documentation/unreal-engine/API/Runtime/AudioCaptureCore/IAudioCaptureStream
- Epic forums, Audio Capture component for mic capture: https://forums.unrealengine.com/t/knowledge-base-using-audio-capture-component-for-mic-capture/264943
- Epic, USoundWaveProcedural: https://dev.epicgames.com/documentation/unreal-engine/API/Runtime/Engine/USoundWaveProcedural
- Full list: Sources — 2026-09-20