Compare commits

...

24 Commits

Author SHA1 Message Date
2f35c134aa ConvAgent: cut BOTH agents' speech when the player interrupts the dialogue
On a player barge-in, the engaged agent stops (its mic feeds the player's voice to ElevenLabs, which interrupts it), but the witness agent never hears the player and kept finishing its line (e.g. '...I'll give you the cash...') mid-scene. InterruptForPlayer now calls InterruptAgent() on both CompA and CompB. Conversations stay open; the witness still receives the contextual_updates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:58:32 +02:00
086311da38 ConvReplay: split agent utterances on silence gaps (fix reply glued to greeting)
A first-message greeting opens the agent segment but never fires OnAgentStoppedSpeaking (no agent_response), so the segment stayed open and the later reply was appended to it — inheriting the greeting's early StartTime and playing before the player's question on replay.

RecordAgentAudio now flushes the open segment and starts a fresh one when audio resumes after a >1.5s gap, so the greeting keeps its time and the reply gets its own (correct, later) timestamp. Also flush open agent segments at record stop. Added record-side diagnostic logs behind ps.convreplay.Debug (agent-START/STOP/SPLIT, player-SEG, user-TEXT).

Record-side fix: existing sidecars keep the old segmentation; re-record to benefit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 10:06:40 +02:00
c783613117 ConvReplay: agent-less player voice recording + audio polish
Record every player's mic into the replay even with no agent in the scene (listen-server/standalone). The bridge arms each player pawn's InteractionComponent via a replicated flag; the host captures locally, remote clients relay via a new ServerRecordVoice RPC, and the server broadcasts OnPlayerVoiceForRecording into the existing player-audio pipeline. Agent and agent-less paths are mutually exclusive (no double-record).

Replay routing: match a recorded agent by id, else by nearest position (runtime-spawned NPCs get unstable names on replay), keeping lip-sync.

Player audio: per-utterance bidirectional AGC toward a target RMS with an envelope peak limiter (instant attack, ~9ms release) instead of hard clipping. Live CVars: ps.convreplay.PlayerTargetRms (default 0.08), ps.convreplay.PlayerGain, ps.convreplay.Debug (per-record logs off by default).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 12:21:19 +02:00
d92e402722 fix emotion poses 2026-07-03 10:56:39 +02:00
231ca4145d ConvReplay: record & replay agent + player conversation audio in demos
New PS_AI_ConvReplay bridge plugin records ElevenLabs agent TTS and player mic audio (Opus) into a sidecar next to the .replay, and plays it back during DemoPlay with lip-sync / body-expression / gaze / emotion. Agent audio is re-fed through the agent's own voice; player audio is spatialized at the recorded position.

ConvAgent hooks (generic): PlayExternalAudioPCM, StopExternalAudio, TriggerReplayedAction/TriggerReplayedClientToolCall, and an OnPlayerAudioCaptured delegate broadcast in ServerSendMicAudioFromPlayer.

Agent matching is id-or-nearest-position (runtime-spawned NPCs get unstable names on replay). Player level normalized per-utterance (bidirectional AGC + soft limiter), tunable live via ps.convreplay.PlayerGain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 10:43:02 +02:00
087c83018c ConvAgent: fire the voice-onset interrupt server-side (dedicated-server support)
The agent-to-agent dialogue interrupt was detected only in FeedExternalAudio,
which runs client-side for a remote player. On a DEDICATED server (no host
player) no one could trigger the reliable VAD interrupt -- everyone fell back to
the slower ElevenLabs transcript path.

Also broadcast OnUserVoiceDetected from ServerSendMicAudioFromPlayer, where the
player's mic (the host's OR a remote client's, relayed) always arrives on the
server. Shares the LastUserVoiceOnset debounce so the host does not double-fire.
The orchestrator still attributes the correct player (ResolveSpeakingPlayer
finds the connected client via NetConnectedPawns).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 17:18:10 +02:00
f49a57f892 ConvAgent: agent-to-agent dialogue orchestrator + player 3-way floor mode
New APS_AI_ConvAgent_AgentDialogue actor drives two ElevenLabs agents in a
text-only conversation (they speak aloud and look at each other), and lets a
player barge in and take over.

Orchestrator (AgentDialogue):
- Single-floor agent<->agent turn-taking over text (no STT); gaze override so
  the agents look at each other; TurnDelaySeconds for pacing.
- Voice-onset interrupt: the player is detected via a local mic VAD
  (OnUserVoiceDetected), not the unreliable STT round-trip. On interrupt both
  agents turn to the player; auto-released once the player leaves both agents.
- 3-way "floor" mode: the engaged agent is reframed ("a real person now
  addresses you") while the witness agent overhears the exchange via
  contextual_update, so it can reference it when the player later turns to it.
  Configurable messages: Interrupt/Bystander context + overheard prefix.

Gaze: honour GazeOverrideTarget on every path that could steal the gaze back to
the player -- OnRep_ActiveSpeaker, InteractionComponent AttachGazeTarget /
SetSelectedAgent, and the PS_AI_Behavior proximity gaze (IsConversationActiveOnPawn
now treats an agent-to-agent dialogue as an active conversation).

Conversation lifecycle: a watching player joining/leaving by look no longer
tears down an orchestrated agent's WebSocket (ServerLeaveConversation guarded by
GazeOverrideTarget) -- fixes agents going silent / lip sync deactivating when
the player looks away.

ElevenLabs: add SendContextualUpdate (contextual_update -- inject context with no
reply triggered) on the component and WebSocket proxy; add OnUserVoiceDetected
local voice-onset event fired from FeedExternalAudio.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 17:07:10 +02:00
6d92ec3c26 ConvAgent: add waveform-splice discontinuity debug detector
Diagnostic for the audio "pops": EnqueueAgentAudio compares the last sample of
the previous enqueued block with the first sample of the new one and logs
[AUDIO-DBG] with the delta + a running count when the jump exceeds a threshold
(gated behind ps.ai.ConvAgent.Debug.ElevenLabs; role-tagged). The host's raw-PCM
stream is continuous so its count stays ~0; a client whose count climbs at
message cadence still has a splice problem. Confirms the carry-over fix
(bcb6c44) at runtime.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 16:45:16 +02:00
bcb6c44f57 ConvAgent: carry Opus sub-frame remainder across messages (fix pops)
The remaining pops were per-MESSAGE, not per-sub-chunk: the Opus encoder
processes whole 640-byte frames only, so each ElevenLabs message dropped its
<1-frame (<20ms) tail. The lost ~10-16ms per message left a waveform
discontinuity at each message splice -> audible click (2-3 per sentence).
Confirmed by log (message sizes not 640-aligned; no underflow) and the
standalone-vs-client asymmetry: both use the same playback queue, but the host
plays raw PCM (no drop) and is clean while clients play the Opus-encoded stream
and pop.

Fix (server-only path): carry the sub-frame remainder to the next message.
Prepend the previous message's leftover before encoding, emit only whole frames,
and stash the new tail. No samples dropped -> continuous stream -> no splice
click. Leftover reset per turn (new-turn guard + StopAgentAudio).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 16:41:25 +02:00
cf83a2302d ConvAgent: reassemble agent audio sub-chunks into one enqueue (fix pops)
After sub-chunking each ElevenLabs message into N ~0.8s Opus packets, the
client enqueued each sub-chunk separately, re-running the playback state
machine per sub-chunk (spurious Play() restart / pre-buffer race / underrun
at the seam) -> an audible pop at each ~0.8s boundary (2-3 per sentence).
Confirmed via multi-agent analysis + logs (no generation skips, no decode
truncation -> not a codec issue; the discontinuity is on the enqueue side).

- MulticastReceiveAgentAudio gains a bLastSubChunk flag; the server marks the
  final sub-chunk of each message (encode path frame-aligned so the last
  sub-chunk is well-defined; raw-PCM fallback likewise).
- The client accumulates decoded PCM across a message's sub-chunks and calls
  EnqueueAgentAudio (and lip-sync broadcast) ONCE, on the last sub-chunk.
- ClientReassemblyPCM reset in StopAgentAudio so a half-received message can't
  leak across a turn/interruption.

No added latency: a message's sub-chunks are sent back-to-back in one server
tick and arrive together. Each RPC still <=40 frames (Reliable, under the
encode/decode limits).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 16:23:46 +02:00
5b3a11a733 ConvAgent: agent audio multicast back to Reliable (fix Opus crackle/gaps)
Sending Opus over Unreliable multicast caused missing words and crackling:
Opus is a stateful inter-frame codec, so a dropped or reordered packet
desyncs the decoder and corrupts all following frames until the stream
resets. Unreliable was originally adopted only to stop a reliable-buffer
overflow — but that overflow came from 32-152KB raw-PCM RPCs (pre-Opus).
With Opus + sub-chunking each RPC is ~2KB (2-3 partial bunches) at
~1.25 RPC/s, so the 512-bunch reliable buffer stays in single digits and
cannot overflow. Reliable restores ordered, lossless delivery.

- MulticastReceiveAgentAudio: Unreliable -> Reliable.
- Keep the 40-frame (0.8s, ~2KB) sub-chunking — it is what keeps packets
  small enough for Reliable to be safe.
- Harden the raw-PCM fallback (only reached if Opus init fails): hard-cap
  one message at 320KB and drop the tail so it can never overflow the
  reliable buffer, and warn on every fallback (config bug: [Voice]).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:31:02 +02:00
2ca5643f32 ConvAgent: fix agent voice truncation on remote clients (Opus)
Long ElevenLabs audio messages were cut off on remote clients:
- the client Opus decode buffer was only 32000 bytes (~0.88s), so the
  engine decoder aborted with "Decompression buffer too small" and
  dropped the tail of any packet longer than that;
- the server also encoded a whole message as a single Opus packet, which
  the engine encoder caps at 76 frames (~1.52s), silently dropping the rest.
The host/authority plays raw PCM and never decodes, so standalone and
listen-server hosts were unaffected, which masked the bug.

Fix (HandleAudioReceived + MulticastReceiveAgentAudio_Implementation):
- sub-chunk the Opus encode into <=40-frame (25600-byte, 0.8s) blocks,
  one RPC each: stays under both the 76-frame encode cap and the client
  decode buffer, and keeps each Unreliable RPC small;
- raise the client decode buffer 32000 -> 64*1024 as headroom.

Empirically confirmed: networked client logged 16x "Decompression buffer
too small" with Opus active; standalone (Opus equally active) logged zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:11:28 +02:00
4854a7fa9a ConvAgent: send agent audio multicast Unreliable + add size debug log
- MulticastReceiveAgentAudio: Reliable -> Unreliable. Realtime audio: a lost or
  oversized packet is dropped (minor glitch) instead of backing up the reliable
  buffer and dropping the client connection.
- Raw-PCM fallback chunk size 32000 -> 4000 bytes so an unreliable RPC is not
  auto-promoted to reliable when fragmented into many partial bunches (which
  would reintroduce the overflow).
- Add net-audio size log gated behind ps.ai.ConvAgent.Debug.ElevenLabs; fires on
  Authority even in solo PIE: "[NET-SRV] Opus audio chunk: N bytes (raw M -> Xx)".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 19:52:48 +02:00
41900991ef ConvAgent: enable Voice module so the Opus codec initializes
The project had no [Voice] section, so the engine default bEnabled=false left
FVoiceModule::CreateVoiceEncoder() returning null (Encoder/Decoder NULL).
Networked agent audio then fell back to raw PCM in large reliable multicast
RPCs, overflowing the reliable buffer and dropping VR client connections the
moment the avatar spoke. Enabling Voice lets Opus compress (~13-20x), keeping
RPCs small.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 19:52:48 +02:00
efd9921fde ElevenLabs: guard null gaze target on late join (replicated ref not resolved)
A client that joins while a conversation is already active can run OnRep with
NetConnectedPawns/NetActiveSpeakerPawn entries whose replicated pawn references
haven't resolved yet (actor channel not open). NetConnectedPawns.Num() > 0 did
not guarantee a non-null element, so OnRep_ConversationState dereferenced a null
pawn in its log (crash in Dev/PIE) and ApplyConversationGaze set TargetActor to
null (gaze glitch). Both now skip until the reference resolves — OnRep fires
again once it's patched in. More likely with 3+ clients.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 18:16:35 +02:00
fee7d0c679 Gaze: stateless head/eyes so client matches server (fix ~60° head offset)
Single-player clients sometimes showed the head turned ~60° off while the
server looked correct. Two client-only causes:

1. The client overwrote the actor's rotation every frame (SetActorRotation)
   and then read back its own output as the "replicated" yaw, so SmoothedBodyYaw
   diverged from the true server body yaw.
2. The head/eye cascade was sticky / path-dependent (TargetHeadYaw updated on
   overflow events), so running it independently on a client fed by lagged,
   stepped replication latched into a different state — the head overflowed to
   MaxHeadYaw and stayed there.

Rewrite head/eyes as a STATELESS function of the actual (authoritative on the
server, replicated on the client) body facing + target direction. No sticky
state, FInterpTo only eases the visual. The client no longer overrides the actor
rotation. Body turn stays authority-only (lazy turn beyond head+eyes range).
Server and client now converge to the same pose by construction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 18:13:53 +02:00
777ffa09a8 ElevenLabs: fix multi-player speaker arbitration with open mic
With an open mic streaming continuously (Server VAD + interruption), every
chunk refreshed the active speaker's LastSpeakTime, so SpeakerSwitchHysteresis
never elapsed and SpeakerIdleTimeout never fired — the first speaker locked the
floor forever and the agent never turned to anyone else.

Now only chunks with real voice energy (RMS >= 0.02) hold/take the floor: the
active speaker is still forwarded continuously (silence tail for VAD), but their
lock only persists while they actually speak. When they pause, another player's
voiced audio can switch in and the agent turns to them (NetActiveSpeakerPawn
replicated -> gaze). Validated with two players on one NPC.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 18:13:44 +02:00
ab2482a209 ElevenLabs: remove temporary [MIC-DBG] mic uplink diagnostics
Client mic uplink confirmed working in a packaged build across two PCs
(networked Server VAD): the agent now replies to the player's voice.
Remove the [MIC-DBG]/[MIC-DBG-SRV] instrumentation added to trace it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:27:39 +02:00
c2ca2e3dbb ElevenLabs: fix client mic uplink in networked Server VAD mode
Networked clients could hear the agent but the agent never replied to them
(worked in standalone/listen-server host). Root causes, client-only paths
that never run on the authority:

1. Deferred StartListening was never retried once the conversation went live.
   OnRep_ConversationState now (re)starts listening when bNetIsConversing
   replicates and the local player is in the conversation — closes the
   RPC/replication ordering race so bIsListening reliably opens the uplink.

2. The client stripped ALL silent mic chunks to save bandwidth, but ElevenLabs
   Server VAD needs the trailing silence to detect end-of-turn — so the agent
   heard the words but never the pause and never responded. Replaced the hard
   silence drop with a silence "tail": keep streaming for MicSilenceTailSeconds
   (default 2s, tunable) after the last voiced chunk, then drop during idle.
   The authority path is unchanged (always streams the full mic).

Also: debug HUD now shows "CONNECTED (via server)" on clients instead of a
misleading "DISCONNECTED" (clients have no local WebSocket; IsConnected() is
always false there).

[MIC-DBG] temporary diagnostics are kept in this commit to validate the fix on
another machine; they will be removed in a follow-up once confirmed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:07:30 +02:00
8ae73bbacb compil bug fix 2026-06-24 16:58:53 +02:00
6a8c22c77e ElevenLabs: editor-only API key for agent authoring (no leak in builds)
Re-enables agent/tool/action-set authoring in the editor after the API
key was removed from Project Settings. Adds an editor-only key source so
it can never be committed-as-config or baked into a packaged build.

- New UPS_AI_ConvAgent_EditorSettings_ElevenLabs (UDeveloperSettings,
  config=EditorPerProjectUserSettings, Editor category): single global
  field, stored under Saved/Config (never cooked), in the editor module
  (never packaged).
- ResolveEditorApiKey(): editor setting -> ELEVENLABS_API_KEY env -> runtime
  SaveGame fallback. All three editor customizations route through it.
- Fix the now-stale "API Key not set in Project Settings" messages.

Runtime path is untouched: packaged game still uses the client-provided
key via the SaveGame (Set/GetElevenLabsAPIKey).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 15:36:47 +02:00
7d99a89ac7 android 2026-06-12 08:09:24 +02:00
a5b0d5db84 PS_AI_Behavior: freeze movement during conversation + manual lever
Auto pause now triggers as soon as a conversation is genuinely active (agent greeting included) instead of waiting for the user to speak — the NPC no longer walks away during its opening line.

Add DisableBehaviorMovement()/EnableBehaviorMovement() (BlueprintCallable): freeze the NPC's autonomous movement while keeping the Behavior Tree running (perception, threat, gaze, reactions). Movement is paused when EITHER manually disabled OR a conversation is active. Both drive the ConversationPaused Blackboard flag read by the movement-branch decorators; leave the Flee branch un-decorated so fleeing still works.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:41:06 +02:00
cfade2a265 PS_AI_Behavior: make spline StopFollowing a firm, BT-respected stop
StopFollowing() now sets a bManuallyStopped flag (cleared only by an explicit StartFollowing/StartFollowingAtDistance). The FindAndFollowSpline and FollowSpline BT tasks bail while it is set, so the BT no longer auto-resumes a manual stop on the next tick. ResumeFollowing() also respects the flag. PauseFollowing() is unchanged (BT may resume).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:40:56 +02:00
32 changed files with 3274 additions and 295 deletions

View File

@@ -166,6 +166,14 @@ DefaultPlatformService=Null
[OnlineSubsystemNull]
bEnabled=true
[Voice]
; Enable the Voice module so FVoiceModule::CreateVoiceEncoder() returns a valid
; Opus encoder/decoder. The engine default is bEnabled=false, which leaves the
; encoder NULL and makes networked agent audio fall back to raw PCM in large
; reliable multicast RPCs — these overflow the reliable buffer and drop VR
; client connections the moment the avatar speaks. With Opus, chunks are ~1-4KB.
bEnabled=true
[/Script/AndroidFileServerEditor.AndroidFileServerRuntimeSettings]
bEnablePlugin=True
bAllowNetworkConnection=True

View File

@@ -16,7 +16,8 @@
"PlatformAllowList": [
"Win64",
"Mac",
"Linux"
"Linux",
"Android"
]
},
{

View File

@@ -32,6 +32,14 @@ EBTNodeResult::Type UPS_AI_Behavior_BTTask_FindAndFollowSpline::ExecuteTask(
return EBTNodeResult::Failed;
}
// Respect a manual StopFollowing(): do not search for or resume a spline until
// the caller explicitly StartFollowing() again. Without this, the task would
// resume any CurrentSpline and override the stop on the very next tick.
if (Follower->bManuallyStopped)
{
return EBTNodeResult::Failed;
}
// Debug: log state on entry
if (UPS_AI_Behavior_Statics::IsDebugEnabled(AIC->GetPawn()))
{
@@ -268,6 +276,18 @@ void UPS_AI_Behavior_BTTask_FindAndFollowSpline::TickTask(
return;
}
// Manual StopFollowing() requested mid-approach: cancel the move-to-spline and bail.
if (UPS_AI_Behavior_SplineFollowerComponent* StopCheck =
AIC->GetPawn()->FindComponentByClass<UPS_AI_Behavior_SplineFollowerComponent>())
{
if (StopCheck->bManuallyStopped)
{
AIC->StopMovement();
FinishLatentTask(OwnerComp, EBTNodeResult::Failed);
return;
}
}
// Check if we've reached the spline
if (AIC->GetMoveStatus() == EPathFollowingStatus::Idle)
{

View File

@@ -24,6 +24,12 @@ EBTNodeResult::Type UPS_AI_Behavior_BTTask_FollowSpline::ExecuteTask(
AIC->GetPawn()->FindComponentByClass<UPS_AI_Behavior_SplineFollowerComponent>();
if (!Follower) return EBTNodeResult::Failed;
// Respect a manual StopFollowing(): refuse to resume until an explicit restart.
if (Follower->bManuallyStopped)
{
return EBTNodeResult::Failed;
}
if (!Follower->CurrentSpline)
{
UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] FollowSpline: no current spline set."),

View File

@@ -730,6 +730,11 @@ void APS_AI_Behavior_AIController::TryBindGazeComponent()
CachedConvAgentComponent = ConvComp;
ConvProp_bNetIsConversing = CastField<FBoolProperty>(
ConvClass->FindPropertyByName(TEXT("bNetIsConversing")));
// Agent-to-agent dialogue: the orchestrator pins this agent's gaze on
// the other agent via GazeOverrideTarget. Cache it so we can treat that
// as an active conversation (see IsConversationActiveOnPawn).
ConvProp_GazeOverrideTarget = CastField<FObjectProperty>(
ConvClass->FindPropertyByName(TEXT("GazeOverrideTarget")));
}
}
@@ -797,9 +802,73 @@ void APS_AI_Behavior_AIController::ClearGazeTarget()
bool APS_AI_Behavior_AIController::IsConversationActiveOnPawn() const
{
UActorComponent* ConvComp = CachedConvAgentComponent.Get();
if (!ConvComp || !ConvProp_bNetIsConversing) return false;
if (!ConvComp) return false;
return ConvProp_bNetIsConversing->GetPropertyValue_InContainer(ConvComp);
// Player-driven conversation (a human joined this agent).
if (ConvProp_bNetIsConversing &&
ConvProp_bNetIsConversing->GetPropertyValue_InContainer(ConvComp))
{
return true;
}
// Agent-to-agent dialogue: the orchestrator sets GazeOverrideTarget on this
// agent. Treat it as an active conversation so proximity gaze / wandering back
// off and the ConvAgent owns the gaze (agents look at each other, not a passing
// player). Clearing the override (StopDialogue) restores normal behavior.
if (ConvProp_GazeOverrideTarget &&
ConvProp_GazeOverrideTarget->GetObjectPropertyValue_InContainer(ConvComp))
{
return true;
}
return false;
}
void APS_AI_Behavior_AIController::ApplyMovementPaused(bool bPaused)
{
if (bPaused == bConversationPaused)
{
return; // no change — idempotent
}
bConversationPaused = bPaused;
// Blackboard flag read by the movement-branch decorators (Patrol / Spline).
if (Blackboard)
{
Blackboard->SetValueAsBool(PS_AI_Behavior_BB::ConversationPaused, bPaused);
}
if (bPaused)
{
StopMovement(); // cancel any in-flight MoveTo immediately
}
if (APawn* MyPawn = GetPawn())
{
if (auto* Spline = MyPawn->FindComponentByClass<UPS_AI_Behavior_SplineFollowerComponent>())
{
if (bPaused) { Spline->PauseFollowing(); }
else { Spline->ResumeFollowing(); }
}
}
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Movement %s."),
*GetName(), bPaused ? TEXT("paused") : TEXT("resumed"));
}
void APS_AI_Behavior_AIController::DisableBehaviorMovement()
{
bMovementManuallyDisabled = true;
ApplyMovementPaused(true);
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] DisableBehaviorMovement — manual freeze (BT keeps running)."), *GetName());
}
void APS_AI_Behavior_AIController::EnableBehaviorMovement()
{
bMovementManuallyDisabled = false;
// Stay paused if a conversation is still active; otherwise resume.
ApplyMovementPaused(IsConversationActiveOnPawn());
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] EnableBehaviorMovement — manual freeze released."), *GetName());
}
void APS_AI_Behavior_AIController::UpdateGazeTarget(float DeltaSeconds)
@@ -842,117 +911,26 @@ void APS_AI_Behavior_AIController::UpdateGazeTarget(float DeltaSeconds)
}
}
// ── Priority 2: Conversation active manage movement pause via local VAD
// ── Priority 2: Conversation active OR manual freeze → pause movement ──
// The BT keeps running; a "ConversationPaused" Blackboard decorator blocks the
// movement branches (Patrol / Spline). Leave the Flee branch un-decorated so
// fleeing still works. Movement pauses as soon as the conversation is genuinely
// active (agent greeting included) — it no longer waits for the user to speak —
// or whenever DisableBehaviorMovement() was called.
{
const bool bConversing = IsConversationActiveOnPawn();
// Check local VAD: is the user actually speaking?
// The mic is on the PLAYER's Pawn — find it dynamically
bool bUserSpeaking = false;
if (MicProp_bIsUserSpeaking)
{
// Try cached mic first
UActorComponent* MicComp = CachedMicComponent.Get();
if (!MicComp)
{
// Find mic on any player pawn
static UClass* MicClass = LoadClass<UActorComponent>(nullptr,
TEXT("/Script/PS_AI_ConvAgent.PS_AI_ConvAgent_MicrophoneCaptureComponent"));
if (MicClass)
{
for (FConstPlayerControllerIterator It = GetWorld()->GetPlayerControllerIterator(); It; ++It)
{
if (APlayerController* PC = It->Get())
{
if (APawn* PlayerPawn = PC->GetPawn())
{
MicComp = PlayerPawn->FindComponentByClass(MicClass);
if (MicComp)
{
CachedMicComponent = MicComp;
break;
}
}
}
}
}
}
if (MicComp)
{
bUserSpeaking = MicProp_bIsUserSpeaking->GetPropertyValue_InContainer(MicComp);
}
}
// Once user has spoken during this conversation, stay paused until conversation ends
if (bConversing && bUserSpeaking)
{
bUserHasSpokenInConversation = true;
}
if (bConversing && bUserHasSpokenInConversation)
{
// Set BB flag to block movement branches via decorator
if (!bConversationPaused)
{
bConversationPaused = true;
if (Blackboard)
{
Blackboard->SetValueAsBool(PS_AI_Behavior_BB::ConversationPaused, true);
}
StopMovement();
APawn* ConvPawn = GetPawn();
if (ConvPawn)
{
auto* Spline = ConvPawn->FindComponentByClass<UPS_AI_Behavior_SplineFollowerComponent>();
if (Spline)
{
Spline->PauseFollowing();
}
}
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] User speaking — conversation pause."), *GetName());
}
ProximityGazeTarget = nullptr;
ProximityGazeDuration = 0.0f;
return; // Gaze managed by ConvAgent
}
ApplyMovementPaused(bMovementManuallyDisabled || bConversing);
if (bConversing)
{
// Connected but user hasn't spoken yet — don't touch gaze, don't pause
// Gaze is driven by the ConvAgent during conversation.
ProximityGazeTarget = nullptr;
ProximityGazeDuration = 0.0f;
return;
}
// Conversation ended — clear BB flag, resume movement
if (bConversationPaused)
{
bConversationPaused = false;
bUserHasSpokenInConversation = false;
if (Blackboard)
{
Blackboard->SetValueAsBool(PS_AI_Behavior_BB::ConversationPaused, false);
}
APawn* ConvPawn = GetPawn();
if (ConvPawn)
{
if (auto* Spline = ConvPawn->FindComponentByClass<UPS_AI_Behavior_SplineFollowerComponent>())
{
Spline->ResumeFollowing();
}
}
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Conversation ended — resumed."), *GetName());
}
// Manual-only freeze (no active conversation): movement stays paused above,
// but proximity gaze below still runs so the NPC can look around.
}
// ── Priority 3/4: Proximity gaze ────────────────────────────────

View File

@@ -58,6 +58,7 @@ bool UPS_AI_Behavior_SplineFollowerComponent::StartFollowingAtDistance(
CurrentDistance = FMath::Clamp(StartDistance, 0.0f, Spline->GetSplineLength());
bMovingForward = bForward;
bIsFollowing = true;
bManuallyStopped = false; // an explicit Start lifts any previous manual stop
LastHandledJunctionIndex = -1;
UE_LOG(LogPS_AI_Behavior, Warning,
@@ -82,18 +83,23 @@ bool UPS_AI_Behavior_SplineFollowerComponent::StartFollowingAtDistance(
void UPS_AI_Behavior_SplineFollowerComponent::StopFollowing()
{
bIsFollowing = false;
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] Stopped following spline."),
// Firm, manual stop: the Behavior Tree spline tasks will refuse to re-start
// following until StartFollowing()/StartFollowingAtDistance() is called again.
bManuallyStopped = true;
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] StopFollowing: manual stop (BT will not auto-resume)."),
GetOwner() ? *GetOwner()->GetName() : TEXT("?"));
}
void UPS_AI_Behavior_SplineFollowerComponent::PauseFollowing()
{
// Pause is NOT a manual stop — the BT is allowed to resume it.
bIsFollowing = false;
}
void UPS_AI_Behavior_SplineFollowerComponent::ResumeFollowing()
{
if (CurrentSpline)
// Do not resume over a manual stop — only an explicit Start lifts that.
if (CurrentSpline && !bManuallyStopped)
{
bIsFollowing = true;
}

View File

@@ -134,6 +134,22 @@ public:
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior")
void ResetBehavior();
/**
* Freeze the NPC's autonomous movement WITHOUT stopping the Behavior Tree.
* The BT keeps running (perception, threat, gaze, reactions) but movement
* branches are blocked via the ConversationPaused Blackboard flag, and any
* in-flight move is cancelled. Fleeing is NOT blocked (leave the Flee branch
* un-decorated). Use this to keep an NPC in place during a scripted moment
* (e.g. dialogue) regardless of the automatic conversation pause.
*/
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior")
void DisableBehaviorMovement();
/** Release the manual movement freeze set by DisableBehaviorMovement().
* Movement stays paused if a conversation is still active. */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior")
void EnableBehaviorMovement();
protected:
virtual void OnPossess(APawn* InPawn) override;
virtual void OnUnPossess() override;
@@ -176,6 +192,11 @@ private:
/** Check if a conversation is active on the Pawn (via reflection on ElevenLabsComponent). */
bool IsConversationActiveOnPawn() const;
/** Apply (or release) the movement pause: syncs the ConversationPaused Blackboard
* flag, stops in-flight movement, and pauses/resumes the spline follower.
* No-op if the state is unchanged. */
void ApplyMovementPaused(bool bPaused);
// Cached reflection pointers (null if PS_AI_ConvAgent not loaded)
TWeakObjectPtr<UActorComponent> CachedGazeComponent;
FObjectProperty* GazeProp_TargetActor = nullptr;
@@ -185,6 +206,8 @@ private:
// Conversation detection cache
TWeakObjectPtr<UActorComponent> CachedConvAgentComponent;
FBoolProperty* ConvProp_bNetIsConversing = nullptr;
// Set by the ConvAgent agent-to-agent orchestrator while two agents converse.
FObjectProperty* ConvProp_GazeOverrideTarget = nullptr;
// Mic VAD cache (local voice activity detection)
TWeakObjectPtr<UActorComponent> CachedMicComponent;
@@ -194,6 +217,11 @@ private:
bool bConversationPaused = false;
bool bUserHasSpokenInConversation = false;
// True while movement is frozen by an explicit DisableBehaviorMovement() call.
// Combined with the automatic conversation pause: movement is paused when
// either this is set OR a conversation is active.
bool bMovementManuallyDisabled = false;
// Proximity gaze state
TWeakObjectPtr<AActor> ProximityGazeTarget;
float ProximityGazeDuration = 0.0f;

View File

@@ -106,6 +106,13 @@ public:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Replicated, Category = "Spline Follower|Runtime")
bool bIsFollowing = false;
/** Set by StopFollowing(): a firm, manual stop. While true, the Behavior Tree
* spline tasks refuse to (re)start following — only an explicit StartFollowing()
* / StartFollowingAtDistance() clears it. This is what distinguishes a deliberate
* stop from PauseFollowing() (which the BT is allowed to auto-resume). */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Spline Follower|Runtime")
bool bManuallyStopped = false;
// ─── Delegates ──────────────────────────────────────────────────────
/** Fired when approaching a junction. Use to make custom spline selection. */

View File

@@ -0,0 +1,561 @@
// Copyright ASTERION. All Rights Reserved.
#include "PS_AI_ConvAgent_AgentDialogue.h"
#include "PS_AI_ConvAgent_ElevenLabsComponent.h"
#include "GameFramework/Actor.h"
#include "GameFramework/Pawn.h"
#include "GameFramework/PlayerController.h"
#include "TimerManager.h"
#include "Engine/World.h"
DEFINE_LOG_CATEGORY_STATIC(LogPS_AI_ConvAgent_Dialogue, Log, All);
static const TCHAR* FloorName(uint8 F)
{
switch (F) { case 1: return TEXT("A"); case 2: return TEXT("B"); default: return TEXT("None"); }
}
APS_AI_ConvAgent_AgentDialogue::APS_AI_ConvAgent_AgentDialogue()
{
// Ticks only while "addressing player" (auto-release watcher); disabled otherwise.
PrimaryActorTick.bCanEverTick = true;
PrimaryActorTick.bStartWithTickEnabled = false;
bReplicates = false; // server-side orchestration; agents replicate their own audio
}
void APS_AI_ConvAgent_AgentDialogue::BeginPlay()
{
Super::BeginPlay();
if (bAutoStartOnBeginPlay && HasAuthority())
{
StartDialogue();
}
}
void APS_AI_ConvAgent_AgentDialogue::EndPlay(const EEndPlayReason::Type Reason)
{
StopDialogue(false);
UnbindAll();
Super::EndPlay(Reason);
}
UPS_AI_ConvAgent_ElevenLabsComponent* APS_AI_ConvAgent_AgentDialogue::ResolveAgentComp(AActor* Actor) const
{
return Actor ? Actor->FindComponentByClass<UPS_AI_ConvAgent_ElevenLabsComponent>() : nullptr;
}
void APS_AI_ConvAgent_AgentDialogue::StartDialogue()
{
if (!HasAuthority())
{
UE_LOG(LogPS_AI_ConvAgent_Dialogue, Warning,
TEXT("StartDialogue ignored — must run on the server (authority)."));
return;
}
if (bRunning)
{
UE_LOG(LogPS_AI_ConvAgent_Dialogue, Log, TEXT("StartDialogue: already running."));
return;
}
CompA = ResolveAgentComp(AgentA);
CompB = ResolveAgentComp(AgentB);
if (!CompA || !CompB)
{
UE_LOG(LogPS_AI_ConvAgent_Dialogue, Warning,
TEXT("StartDialogue: no ElevenLabs component on %s and/or %s."),
*GetNameSafe(AgentA), *GetNameSafe(AgentB));
return;
}
if (CompA == CompB)
{
UE_LOG(LogPS_AI_ConvAgent_Dialogue, Warning, TEXT("StartDialogue: AgentA and AgentB are the same agent."));
return;
}
UnbindAll();
BindAgent(CompA, /*bIsA=*/true);
BindAgent(CompB, /*bIsA=*/false);
// CRITICAL: this is a TEXT-only loop. The agents must NEVER open their mics,
// otherwise each one HEARS the other's TTS and self-replies (via STT) — that
// produces the overlap / "already has the answer before the other spoke" chaos.
CompA->bAutoStartListening = false;
CompB->bAutoStartListening = false;
// The dialogue is interrupted when the PLAYER speaks — detected via a local mic
// voice-onset (primary) and OnAgentTranscript (backup). Enable transcript, and
// force Server turn mode + interruption so per-agent editor config can't silently
// block the mic path / the player barging in.
CompA->bEnableUserTranscript = true;
CompB->bEnableUserTranscript = true;
CompA->TurnMode = EPS_AI_ConvAgent_TurnMode_ElevenLabs::Server;
CompB->TurnMode = EPS_AI_ConvAgent_TurnMode_ElevenLabs::Server;
CompA->bAllowInterruption = true;
CompB->bAllowInterruption = true;
// Make the two agents look at EACH OTHER (not at a bystanding player).
// Replicated, so the audience sees it correctly on every machine.
CompA->SetGazeOverrideTarget(AgentB);
CompB->SetGazeOverrideTarget(AgentA);
UE_LOG(LogPS_AI_ConvAgent_Dialogue, Warning,
TEXT("[Dialogue] Reminder: both agents must have an EMPTY FirstMessage in their AgentConfig "
"(a configured first message auto-greets on connect and collides with the opening)."));
// Reset state.
Floor = EFloor::None;
TurnId = 0;
bTextReady = false;
bSpeechDone = false;
bForwardedThisTurn = false;
PendingLine.Reset();
bOpeningSent = false;
bRunning = true;
CompA->StartConversation();
CompB->StartConversation();
TryKickoff();
UE_LOG(LogPS_AI_ConvAgent_Dialogue, Log,
TEXT("[Dialogue] started: %s <-> %s (starter=%s, maxTurns=%d, waitForSpeech=%d)."),
*GetNameSafe(AgentA), *GetNameSafe(AgentB),
bAgentAStarts ? *GetNameSafe(AgentA) : *GetNameSafe(AgentB),
MaxTurns, bWaitForSpeechToFinish ? 1 : 0);
}
void APS_AI_ConvAgent_AgentDialogue::StopDialogue(bool bEndConversations)
{
if (!bRunning && !bAddressingPlayer) { return; }
bRunning = false;
bAddressingPlayer = false;
AddressedByPlayer = nullptr;
EngagedFloor = EFloor::None;
SetActorTickEnabled(false);
if (UWorld* W = GetWorld())
{
W->GetTimerManager().ClearTimer(TurnTimer);
}
// Restore normal (player-tracking) gaze.
if (CompA) { CompA->SetGazeOverrideTarget(nullptr); }
if (CompB) { CompB->SetGazeOverrideTarget(nullptr); }
if (bEndConversations)
{
if (CompA) { CompA->EndConversation(); }
if (CompB) { CompB->EndConversation(); }
}
UnbindAll();
UE_LOG(LogPS_AI_ConvAgent_Dialogue, Log, TEXT("[Dialogue] stopped after %d turns."), TurnId);
OnDialogueFinished.Broadcast(TurnId);
}
void APS_AI_ConvAgent_AgentDialogue::BindAgent(UPS_AI_ConvAgent_ElevenLabsComponent* Comp, bool bIsA)
{
if (!Comp) { return; }
if (bIsA)
{
Comp->OnAgentTextResponse.AddDynamic(this, &APS_AI_ConvAgent_AgentDialogue::HandleTextA);
Comp->OnAgentStoppedSpeaking.AddDynamic(this, &APS_AI_ConvAgent_AgentDialogue::HandleStoppedA);
Comp->OnAgentConnected.AddDynamic(this, &APS_AI_ConvAgent_AgentDialogue::HandleConnectedA);
// Per-agent interrupt/relay triggers (voice-onset = primary, transcript = words).
Comp->OnUserVoiceDetected.AddDynamic(this, &APS_AI_ConvAgent_AgentDialogue::HandleVoiceOnsetA);
Comp->OnAgentTranscript.AddDynamic(this, &APS_AI_ConvAgent_AgentDialogue::HandleTranscriptA);
}
else
{
Comp->OnAgentTextResponse.AddDynamic(this, &APS_AI_ConvAgent_AgentDialogue::HandleTextB);
Comp->OnAgentStoppedSpeaking.AddDynamic(this, &APS_AI_ConvAgent_AgentDialogue::HandleStoppedB);
Comp->OnAgentConnected.AddDynamic(this, &APS_AI_ConvAgent_AgentDialogue::HandleConnectedB);
Comp->OnUserVoiceDetected.AddDynamic(this, &APS_AI_ConvAgent_AgentDialogue::HandleVoiceOnsetB);
Comp->OnAgentTranscript.AddDynamic(this, &APS_AI_ConvAgent_AgentDialogue::HandleTranscriptB);
}
}
void APS_AI_ConvAgent_AgentDialogue::UnbindAll()
{
if (CompA)
{
CompA->OnAgentTextResponse.RemoveDynamic(this, &APS_AI_ConvAgent_AgentDialogue::HandleTextA);
CompA->OnAgentStoppedSpeaking.RemoveDynamic(this, &APS_AI_ConvAgent_AgentDialogue::HandleStoppedA);
CompA->OnAgentConnected.RemoveDynamic(this, &APS_AI_ConvAgent_AgentDialogue::HandleConnectedA);
CompA->OnAgentTranscript.RemoveDynamic(this, &APS_AI_ConvAgent_AgentDialogue::HandleTranscriptA);
CompA->OnUserVoiceDetected.RemoveDynamic(this, &APS_AI_ConvAgent_AgentDialogue::HandleVoiceOnsetA);
}
if (CompB)
{
CompB->OnAgentTextResponse.RemoveDynamic(this, &APS_AI_ConvAgent_AgentDialogue::HandleTextB);
CompB->OnAgentStoppedSpeaking.RemoveDynamic(this, &APS_AI_ConvAgent_AgentDialogue::HandleStoppedB);
CompB->OnAgentConnected.RemoveDynamic(this, &APS_AI_ConvAgent_AgentDialogue::HandleConnectedB);
CompB->OnAgentTranscript.RemoveDynamic(this, &APS_AI_ConvAgent_AgentDialogue::HandleTranscriptB);
CompB->OnUserVoiceDetected.RemoveDynamic(this, &APS_AI_ConvAgent_AgentDialogue::HandleVoiceOnsetB);
}
}
void APS_AI_ConvAgent_AgentDialogue::HandleVoiceOnsetA() { OnPlayerSpoke(EFloor::A, FString()); }
void APS_AI_ConvAgent_AgentDialogue::HandleVoiceOnsetB() { OnPlayerSpoke(EFloor::B, FString()); }
void APS_AI_ConvAgent_AgentDialogue::HandleTranscriptA(const FPS_AI_ConvAgent_TranscriptSegment_ElevenLabs& Segment)
{
if (Segment.Speaker == TEXT("user")) { OnPlayerSpoke(EFloor::A, Segment.Text); }
}
void APS_AI_ConvAgent_AgentDialogue::HandleTranscriptB(const FPS_AI_ConvAgent_TranscriptSegment_ElevenLabs& Segment)
{
if (Segment.Speaker == TEXT("user")) { OnPlayerSpoke(EFloor::B, Segment.Text); }
}
void APS_AI_ConvAgent_AgentDialogue::OnPlayerSpoke(EFloor Which, const FString& PlayerWords)
{
if (bRunning)
{
// The player barged into the agent-to-agent dialogue: enter 3-way floor mode.
InterruptForPlayer(Which);
}
else if (bAddressingPlayer)
{
// Already talking to the player — did they turn to the OTHER agent?
if (Which != EngagedFloor) { SetEngaged(Which); }
}
else
{
return; // orchestrator idle
}
// Relay the player's actual words (transcript path) to the WITNESS agent, so it
// "overhears" the exchange and can reference it later. Only while in floor mode.
if (bAddressingPlayer && !PlayerWords.IsEmpty() && !OverheardPrefix.IsEmpty())
{
RelayToBystander(Which, OverheardPrefix + TEXT("(la personne) ") + PlayerWords);
}
}
void APS_AI_ConvAgent_AgentDialogue::InterruptForPlayer(EFloor Engaged)
{
APawn* Player = ResolveSpeakingPlayer();
if (!Player)
{
// Couldn't identify the speaker — fall back to a plain stop.
StopDialogue(/*bEndConversations=*/false);
return;
}
// Enter 3-way "floor" mode: stop the agent↔agent forwarding, turn BOTH agents to the
// player, and keep relaying the exchange to the witness agent. Agents stay connected;
// the orchestrator ticks to auto-release once the player leaves both (see Tick).
UE_LOG(LogPS_AI_ConvAgent_Dialogue, Log,
TEXT("[Dialogue] player '%s' spoke to %s — entering 3-way floor mode."),
*GetNameSafe(Player), FloorName((uint8)Engaged));
bRunning = false;
if (UWorld* W = GetWorld())
{
W->GetTimerManager().ClearTimer(TurnTimer);
}
// The player barged in — cut BOTH agents' current speech now. The engaged agent
// is already interrupted by the player's mic (ElevenLabs), but the WITNESS agent
// never hears the player, so without this it keeps finishing its dealer<->client
// line ("...I'll give you the cash...") while the player is talking. Interrupting
// leaves the conversations open; the witness still receives the contextual_updates.
if (CompA) { CompA->InterruptAgent(); }
if (CompB) { CompB->InterruptAgent(); }
// Keep the agent bindings: in floor mode the text/transcript handlers RELAY to the
// witness instead of forwarding (their dialogue logic no-ops while !bRunning).
if (CompA) { CompA->SetGazeOverrideTarget(Player); }
if (CompB) { CompB->SetGazeOverrideTarget(Player); }
bAddressingPlayer = true;
AddressedByPlayer = Player;
EngagedFloor = EFloor::None; // force SetEngaged to send the reframe messages
SetEngaged(Engaged);
SetActorTickEnabled(true);
}
void APS_AI_ConvAgent_AgentDialogue::SetEngaged(EFloor Which)
{
if (Which == EFloor::None || Which == EngagedFloor) { return; }
EngagedFloor = Which;
UPS_AI_ConvAgent_ElevenLabsComponent* Engaged = (Which == EFloor::A) ? CompA.Get() : CompB.Get();
UPS_AI_ConvAgent_ElevenLabsComponent* Bystander = (Which == EFloor::A) ? CompB.Get() : CompA.Get();
UE_LOG(LogPS_AI_ConvAgent_Dialogue, Log,
TEXT("[Dialogue] player now addressing %s (the other agent becomes witness)."),
FloorName((uint8)Which));
// Silent reframe (contextual_update = no reply triggered).
if (Engaged && !InterruptContextMessage.IsEmpty())
{
Engaged->SendContextualUpdate(InterruptContextMessage);
}
if (Bystander && !BystanderContextMessage.IsEmpty())
{
Bystander->SendContextualUpdate(BystanderContextMessage);
}
}
void APS_AI_ConvAgent_AgentDialogue::RelayToBystander(EFloor EngagedSide, const FString& FormattedLine)
{
UPS_AI_ConvAgent_ElevenLabsComponent* Bystander = (EngagedSide == EFloor::A) ? CompB.Get() : CompA.Get();
if (Bystander)
{
Bystander->SendContextualUpdate(FormattedLine);
}
}
void APS_AI_ConvAgent_AgentDialogue::Tick(float DeltaSeconds)
{
Super::Tick(DeltaSeconds);
if (!bAddressingPlayer) { return; }
APawn* Player = AddressedByPlayer.Get();
if (!Player)
{
EndPlayerAddress();
return;
}
// Release once the player has walked farther than the threshold from BOTH agents.
const FVector P = Player->GetActorLocation();
const float Threshold = PlayerAddressReleaseDistance;
const bool bFarFromA = !AgentA || FVector::Dist(P, AgentA->GetActorLocation()) > Threshold;
const bool bFarFromB = !AgentB || FVector::Dist(P, AgentB->GetActorLocation()) > Threshold;
if (bFarFromA && bFarFromB)
{
UE_LOG(LogPS_AI_ConvAgent_Dialogue, Log,
TEXT("[Dialogue] player left both agents — releasing."));
EndPlayerAddress();
}
}
void APS_AI_ConvAgent_AgentDialogue::EndPlayerAddress()
{
bAddressingPlayer = false;
AddressedByPlayer = nullptr;
EngagedFloor = EFloor::None;
SetActorTickEnabled(false);
// Hand the agents back to normal gaze and end their (now idle) conversations.
if (CompA) { CompA->SetGazeOverrideTarget(nullptr); CompA->EndConversation(); }
if (CompB) { CompB->SetGazeOverrideTarget(nullptr); CompB->EndConversation(); }
UnbindAll(); // floor mode kept the bindings active — release them now.
UE_LOG(LogPS_AI_ConvAgent_Dialogue, Log,
TEXT("[Dialogue] player address ended — agents released (after %d turns)."), TurnId);
OnDialogueFinished.Broadcast(TurnId);
}
APawn* APS_AI_ConvAgent_AgentDialogue::ResolveSpeakingPlayer() const
{
// The player who spoke is the active speaker on one of the agents (a pawn that is
// neither AgentA nor AgentB), else a connected non-agent pawn, else the local player.
AActor* A = AgentA.Get();
AActor* B = AgentB.Get();
auto IsPlayer = [A, B](APawn* P) -> bool { return P && P != A && P != B; };
if (CompA && IsPlayer(CompA->NetActiveSpeakerPawn)) { return CompA->NetActiveSpeakerPawn; }
if (CompB && IsPlayer(CompB->NetActiveSpeakerPawn)) { return CompB->NetActiveSpeakerPawn; }
if (CompA) { for (APawn* P : CompA->NetConnectedPawns) { if (IsPlayer(P)) { return P; } } }
if (CompB) { for (APawn* P : CompB->NetConnectedPawns) { if (IsPlayer(P)) { return P; } } }
// Voice-onset path: NetActiveSpeakerPawn is usually unset (the player never formally
// joined the agent). Pick the player pawn NEAREST to either agent.
UWorld* W = GetWorld();
if (!W) { return nullptr; }
APawn* Nearest = nullptr;
double BestDistSq = TNumericLimits<double>::Max();
for (FConstPlayerControllerIterator It = W->GetPlayerControllerIterator(); It; ++It)
{
APlayerController* PC = It->Get();
APawn* P = PC ? PC->GetPawn() : nullptr;
if (!IsPlayer(P)) { continue; }
const FVector L = P->GetActorLocation();
double D = TNumericLimits<double>::Max();
if (A) { D = FMath::Min(D, FVector::DistSquared(L, A->GetActorLocation())); }
if (B) { D = FMath::Min(D, FVector::DistSquared(L, B->GetActorLocation())); }
if (D < BestDistSq) { BestDistSq = D; Nearest = P; }
}
if (Nearest) { return Nearest; }
if (APlayerController* PC = W->GetFirstPlayerController())
{
return PC->GetPawn();
}
return nullptr;
}
void APS_AI_ConvAgent_AgentDialogue::BeginTurn(EFloor NewFloor)
{
Floor = NewFloor;
bTextReady = false;
bSpeechDone = false;
bForwardedThisTurn = false;
PendingLine.Reset();
}
bool APS_AI_ConvAgent_AgentDialogue::IsFloor(bool bFromA) const
{
return (bFromA && Floor == EFloor::A) || (!bFromA && Floor == EFloor::B);
}
void APS_AI_ConvAgent_AgentDialogue::TryKickoff()
{
if (!bRunning || bOpeningSent) { return; }
const bool bBothReady = CompA && CompB && CompA->IsConnected() && CompB->IsConnected();
if (!bBothReady)
{
UE_LOG(LogPS_AI_ConvAgent_Dialogue, Log, TEXT("[Dialogue] Kickoff waiting: A.connected=%d B.connected=%d"),
(CompA && CompA->IsConnected()) ? 1 : 0, (CompB && CompB->IsConnected()) ? 1 : 0);
return; // wait for the other OnAgentConnected
}
bOpeningSent = true;
BeginTurn(bAgentAStarts ? EFloor::A : EFloor::B);
UPS_AI_ConvAgent_ElevenLabsComponent* Starter = (Floor == EFloor::A) ? CompA : CompB;
UE_LOG(LogPS_AI_ConvAgent_Dialogue, Log, TEXT("[Dialogue] FLOOR=%s SEND-OPENING '%s'"),
FloorName((uint8)Floor), *OpeningMessage);
Starter->SendTextMessage(OpeningMessage);
}
void APS_AI_ConvAgent_AgentDialogue::TryForward()
{
if (!bRunning || bForwardedThisTurn) { return; }
// Need the floor-holder's full text; and, unless we're on a headless server,
// also require it to have finished speaking so the voices never overlap.
if (!bTextReady) { return; }
if (bWaitForSpeechToFinish && !bSpeechDone) { return; }
UPS_AI_ConvAgent_ElevenLabsComponent* Listener = (Floor == EFloor::A) ? CompB : CompA;
AActor* SpeakerActor = (Floor == EFloor::A) ? AgentA : AgentB;
if (!Listener) { return; }
if (MaxTurns > 0 && TurnId >= MaxTurns)
{
StopDialogue(false);
return;
}
bForwardedThisTurn = true;
++TurnId;
const FString Line = PendingLine;
UE_LOG(LogPS_AI_ConvAgent_Dialogue, Log, TEXT("[Dialogue] turn=%d FORWARD %s->%s : '%.60s'"),
TurnId, FloorName((uint8)Floor), FloorName((uint8)(Floor == EFloor::A ? EFloor::B : EFloor::A)), *Line);
OnDialogueTurn.Broadcast(TurnId, SpeakerActor, Line);
// Hand the floor to the listener BEFORE sending, so the listener's upcoming
// text/speech events are attributed to it (and the old speaker's late/duplicate
// events are now non-floor and get ignored).
BeginTurn(Floor == EFloor::A ? EFloor::B : EFloor::A);
auto DoSend = [this, Line, Listener]()
{
if (bRunning && Listener && Listener->IsConnected())
{
Listener->SendTextMessage(Line);
}
};
if (TurnDelaySeconds > 0.0f)
{
if (UWorld* W = GetWorld())
{
W->GetTimerManager().SetTimer(TurnTimer, FTimerDelegate::CreateLambda(DoSend), TurnDelaySeconds, false);
}
else { DoSend(); }
}
else { DoSend(); }
}
// ── Delegate handlers ─────────────────────────────────────────────────────────
// Every handler ignores events from the agent that does NOT hold the floor.
void APS_AI_ConvAgent_AgentDialogue::HandleTextA(const FString& Text)
{
if (bAddressingPlayer)
{
// Floor mode: the ENGAGED agent's spoken line is overheard by the witness.
if (EngagedFloor == EFloor::A && !Text.IsEmpty() && !OverheardPrefix.IsEmpty())
{
RelayToBystander(EFloor::A, OverheardPrefix + TEXT("(l'autre personnage) ") + Text);
}
return;
}
if (!bRunning) { return; }
if (!IsFloor(/*bFromA=*/true))
{
UE_LOG(LogPS_AI_ConvAgent_Dialogue, Verbose, TEXT("[Dialogue] IGNORED text from NON-FLOOR A (floor=%s)"), FloorName((uint8)Floor));
return;
}
if (Text.IsEmpty()) { return; }
PendingLine = Text;
bTextReady = true;
TryForward();
}
void APS_AI_ConvAgent_AgentDialogue::HandleTextB(const FString& Text)
{
if (bAddressingPlayer)
{
// Floor mode: the ENGAGED agent's spoken line is overheard by the witness.
if (EngagedFloor == EFloor::B && !Text.IsEmpty() && !OverheardPrefix.IsEmpty())
{
RelayToBystander(EFloor::B, OverheardPrefix + TEXT("(l'autre personnage) ") + Text);
}
return;
}
if (!bRunning) { return; }
if (!IsFloor(/*bFromA=*/false))
{
UE_LOG(LogPS_AI_ConvAgent_Dialogue, Verbose, TEXT("[Dialogue] IGNORED text from NON-FLOOR B (floor=%s)"), FloorName((uint8)Floor));
return;
}
if (Text.IsEmpty()) { return; }
PendingLine = Text;
bTextReady = true;
TryForward();
}
void APS_AI_ConvAgent_AgentDialogue::HandleStoppedA()
{
if (!bRunning) { return; }
if (!IsFloor(/*bFromA=*/true))
{
UE_LOG(LogPS_AI_ConvAgent_Dialogue, Verbose, TEXT("[Dialogue] IGNORED stopped from NON-FLOOR A (floor=%s)"), FloorName((uint8)Floor));
return;
}
bSpeechDone = true;
TryForward();
}
void APS_AI_ConvAgent_AgentDialogue::HandleStoppedB()
{
if (!bRunning) { return; }
if (!IsFloor(/*bFromA=*/false))
{
UE_LOG(LogPS_AI_ConvAgent_Dialogue, Verbose, TEXT("[Dialogue] IGNORED stopped from NON-FLOOR B (floor=%s)"), FloorName((uint8)Floor));
return;
}
bSpeechDone = true;
TryForward();
}
void APS_AI_ConvAgent_AgentDialogue::HandleConnectedA(const FPS_AI_ConvAgent_ConversationInfo_ElevenLabs& Info)
{
TryKickoff();
}
void APS_AI_ConvAgent_AgentDialogue::HandleConnectedB(const FPS_AI_ConvAgent_ConversationInfo_ElevenLabs& Info)
{
TryKickoff();
}

View File

@@ -739,6 +739,23 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::StopListening()
}
}
void UPS_AI_ConvAgent_ElevenLabsComponent::SendContextualUpdate(const FString& Text)
{
// Server-authoritative: the WebSocket lives on the server. The agent-to-agent
// orchestrator (server) uses this to reframe the agents when the player takes over.
if (GetOwnerRole() == ROLE_Authority)
{
if (IsConnected() && WebSocketProxy)
{
WebSocketProxy->SendContextualUpdate(Text);
}
else
{
UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Warning, TEXT("SendContextualUpdate: not connected."));
}
}
}
void UPS_AI_ConvAgent_ElevenLabsComponent::SendTextMessage(const FString& Text)
{
if (GetOwnerRole() == ROLE_Authority)
@@ -900,6 +917,27 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::FeedExternalAudio(const TArray<float>
// Same logic as OnMicrophoneDataCaptured but called from an external source
// (e.g. InteractionComponent on the pawn) instead of a local mic component.
// On server: check local WebSocket. On client: check replicated conversation state.
// Local voice-onset detection: broadcast on the FIRST voiced frame BEFORE the
// listening/echo gates below, so an agent-to-agent dialogue can be interrupted the
// instant the player speaks — no "listening" state and no ElevenLabs STT round-trip
// needed. Debounced to fire once per utterance. (OnAudioCaptured is marshaled to the
// game thread by the mic component, so this runs on the game thread — safe to broadcast.)
{
float OnsetSumSq = 0.0f;
for (const float S : FloatPCM) { OnsetSumSq += S * S; }
const float OnsetRms = FMath::Sqrt(OnsetSumSq / FMath::Max(1, FloatPCM.Num()));
if (OnsetRms >= 0.02f)
{
const double NowSec = FPlatformTime::Seconds();
if (NowSec - LastUserVoiceOnset > 0.5)
{
LastUserVoiceOnset = NowSec;
OnUserVoiceDetected.Broadcast();
}
}
}
const bool bCanSend = (GetOwnerRole() == ROLE_Authority) ? IsConnected() : bNetIsConversing;
if (!bCanSend || !bIsListening) return;
@@ -909,13 +947,15 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::FeedExternalAudio(const TArray<float>
return;
}
// ── Network silence gate ────────────────────────────────────────────────
// On clients the mic audio is sent via unreliable RPCs (~3200 bytes every
// 100ms = ~256 Kbits/s). Sending silence saturates the connection and
// starves movement replication, causing chaotic teleporting.
// Skip silent chunks on clients only — the server path uses a local
// WebSocket and doesn't touch the network, so it keeps the full stream
// for proper ElevenLabs VAD (voice-activity detection).
// ── Network silence gate (clients only) ─────────────────────────────────
// On clients the mic audio is sent via unreliable RPCs (~256 Kbits/s).
// Streaming silence the whole session saturates the connection and starves
// movement replication (teleporting). But ElevenLabs Server VAD needs the
// silence AFTER speech to detect end-of-turn — dropping it entirely means the
// agent hears the words but never the pause, so it never replies.
// Compromise: keep streaming a short silence "tail" after the last voiced
// chunk (MicSilenceTailSeconds), then drop silence during long idle gaps.
// The authority path is untouched — it always streams the full mic.
if (GetOwnerRole() != ROLE_Authority)
{
float SumSq = 0.0f;
@@ -924,11 +964,24 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::FeedExternalAudio(const TArray<float>
SumSq += Sample * Sample;
}
const float Rms = FMath::Sqrt(SumSq / FMath::Max(1, FloatPCM.Num()));
if (Rms < 0.005f) // ~-46 dBFS — well below any speech level
const double NowSec = FPlatformTime::Seconds();
if (Rms >= 0.005f) // ~-46 dBFS — above this is voiced speech.
{
LastMicVoicedTime = NowSec; // Voiced: always send, remember when.
}
else
{
// Silence: send only within the tail window after the last voiced
// chunk (so VAD sees end-of-turn); drop once the tail has elapsed.
const bool bWithinTail = (LastMicVoicedTime > 0.0)
&& (NowSec - LastMicVoicedTime) <= MicSilenceTailSeconds;
if (!bWithinTail)
{
return;
}
}
}
TArray<uint8> PCMBytes = FloatPCMToInt16Bytes(FloatPCM);
@@ -1209,41 +1262,123 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::HandleAudioReceived(const TArray<uint
// Network: broadcast audio to all clients.
if (GetOwnerRole() == ROLE_Authority)
{
// Net-audio size debug: toggle at runtime with
// ps.ai.ConvAgent.Debug.ElevenLabs 1 (or set bDebug on the component).
const int32 NetDbgCVar = CVarDebugElevenLabs.GetValueOnGameThread();
const bool bNetAudioDebug = (NetDbgCVar >= 0) ? (NetDbgCVar > 0) : bDebug;
if (OpusEncoder.IsValid())
{
// Opus path: compress then send.
// Opus path: compress then send, in sub-blocks.
//
// A whole ElevenLabs message can be several seconds. Two hard limits
// force us to split it before each Encode()/RPC:
// 1. UE's Opus encoder caps ONE packet at 76 frames (~1.52s) — frames
// beyond that are silently dropped at encode (VoiceCodecOpus.cpp).
// 2. The client decode buffer only holds ~44 frames (~0.88s) before
// the engine decoder aborts with "Decompression buffer too small"
// and drops the packet tail (MulticastReceiveAgentAudio_Implementation).
// 40 frames (25600 bytes, 0.8s) stays safely under BOTH, so nothing is
// truncated on server or client. It also keeps each Unreliable RPC small.
static constexpr int32 BytesPerFrame =
(PS_AI_ConvAgent_Audio_ElevenLabs::SampleRate / 50)
* PS_AI_ConvAgent_Audio_ElevenLabs::Channels
* static_cast<int32>(sizeof(int16)); // 640 at 16kHz mono
static constexpr int32 MaxOpusChunkBytes = 40 * BytesPerFrame; // 25600 = 0.8s
// Carry the sub-frame remainder from the PREVIOUS message so no samples
// are dropped at message boundaries. Opus encodes whole 640-byte frames
// only; dropping each message's <1-frame tail left a ~10-16ms hole and a
// waveform discontinuity at every message splice → audible pops on clients
// (the host plays raw PCM directly, so standalone/host never had it).
// Prepend the leftover, encode only whole frames, stash the new tail.
if (!bAgentSpeaking)
{
// First message of a new turn — don't glue on the previous turn's tail.
AgentAudioEncodeLeftover.Reset();
}
TArray<uint8> ContinuousPCM = MoveTemp(AgentAudioEncodeLeftover);
ContinuousPCM.Append(PCMData);
const int32 UsableBytes = (ContinuousPCM.Num() / BytesPerFrame) * BytesPerFrame;
if (ContinuousPCM.Num() > UsableBytes)
{
AgentAudioEncodeLeftover.Append(
ContinuousPCM.GetData() + UsableBytes, ContinuousPCM.Num() - UsableBytes);
}
int32 Offset = 0;
while (Offset < UsableBytes)
{
const int32 BlockBytes = FMath::Min(MaxOpusChunkBytes, UsableBytes - Offset);
const bool bLast = (Offset + BlockBytes >= UsableBytes);
uint32 CompressedSize = static_cast<uint32>(OpusWorkBuffer.Num());
OpusEncoder->Encode(PCMData.GetData(), PCMData.Num(),
OpusEncoder->Encode(ContinuousPCM.GetData() + Offset, BlockBytes,
OpusWorkBuffer.GetData(), CompressedSize);
if (CompressedSize > 0)
{
TArray<uint8> CompressedData;
CompressedData.Append(OpusWorkBuffer.GetData(), CompressedSize);
MulticastReceiveAgentAudio(CompressedData);
if (bNetAudioDebug)
{
const float Ratio = static_cast<float>(BlockBytes) / static_cast<float>(CompressedSize);
UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Log,
TEXT("[NET-SRV] Opus audio chunk: %u bytes sent (raw %d → %.1fx compression, last=%d)."),
CompressedSize, BlockBytes, Ratio, bLast ? 1 : 0);
}
MulticastReceiveAgentAudio(CompressedData, bLast);
}
Offset += BlockBytes;
}
}
else
{
// Fallback: send raw PCM (no compression). ~32 KB/s at 16kHz 16-bit mono.
// Fine for LAN; revisit with proper Opus if internet play is needed.
// UE5 limits replicated TArrays to 65535 elements, so we must chunk.
static bool bWarnedOnce = false;
if (!bWarnedOnce)
{
bWarnedOnce = true;
// Fallback: Opus init failed → send raw PCM (no compression, ~32 KB/s).
// This should not happen in the working config — it means [Voice]
// bEnabled=true is missing in DefaultEngine.ini. Warn on every message
// so it is loud, not hidden behind a one-shot flag.
// The RPC is now Reliable, so we must protect the reliable buffer (512
// bunches): each ~4 KB chunk ≈ ~4 partial bunches. Hard-cap one message
// at MaxFallbackBytes and drop the tail so a single message can never
// approach the buffer and trigger a reliable-buffer overflow (the exact
// cause of the old VR disconnect).
UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Warning,
TEXT("[NET-SRV] Opus encoder unavailable — sending raw PCM in chunks (no compression)."));
TEXT("[NET-SRV] Opus encoder unavailable — sending raw PCM (no compression). "
"Set [Voice] bEnabled=true in DefaultEngine.ini to restore Opus."));
static constexpr int32 MaxChunkBytes = 4000;
static constexpr int32 MaxFallbackBytes = 320000; // ~80 reliable chunks/message, well under the 512-bunch buffer
const int32 SendableBytes = FMath::Min(PCMData.Num(), MaxFallbackBytes);
if (PCMData.Num() > MaxFallbackBytes)
{
UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Warning,
TEXT("[NET-SRV] Raw-PCM fallback capped at %d bytes — dropping %d-byte tail "
"to protect the reliable buffer."),
MaxFallbackBytes, PCMData.Num() - MaxFallbackBytes);
}
static constexpr int32 MaxChunkBytes = 32000; // 1s of 16kHz 16-bit mono, well under 65535 limit
int32 Offset = 0;
while (Offset < PCMData.Num())
while (Offset < SendableBytes)
{
const int32 ChunkSize = FMath::Min(MaxChunkBytes, PCMData.Num() - Offset);
const int32 ChunkSize = FMath::Min(MaxChunkBytes, SendableBytes - Offset);
const bool bLast = (Offset + ChunkSize >= SendableBytes);
TArray<uint8> Chunk;
Chunk.Append(PCMData.GetData() + Offset, ChunkSize);
MulticastReceiveAgentAudio(Chunk);
if (bNetAudioDebug)
{
UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Warning,
TEXT("[NET-SRV] RAW PCM chunk: %d bytes sent (NO compression — Opus disabled, last=%d)."),
ChunkSize, bLast ? 1 : 0);
}
MulticastReceiveAgentAudio(Chunk, bLast);
Offset += ChunkSize;
}
}
@@ -1635,6 +1770,42 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::OnProceduralUnderflow(
void UPS_AI_ConvAgent_ElevenLabsComponent::EnqueueAgentAudio(const TArray<uint8>& PCMData)
{
// Debug: flag a waveform discontinuity at the splice between the previous
// enqueued block and this one — the signature of the audio pops. The host's
// raw-PCM stream is continuous so its count should stay ~0; if a client's
// count climbs at message cadence, a splice problem remains.
{
const int32 DbgVal = CVarDebugElevenLabs.GetValueOnGameThread();
const bool bAudioDbg = (DbgVal >= 0) ? (DbgVal > 0) : bDebug;
if (bAudioDbg && PCMData.Num() >= static_cast<int32>(sizeof(int16)))
{
const int16* Samples = reinterpret_cast<const int16*>(PCMData.GetData());
const int32 NumSamples = PCMData.Num() / static_cast<int32>(sizeof(int16));
if (bHasLastEnqueuedSample)
{
const int32 Delta = FMath::Abs(static_cast<int32>(Samples[0]) - static_cast<int32>(LastEnqueuedSample));
static constexpr int32 DiscontinuityThreshold = 3000; // |delta| on the int16 scale
if (Delta > DiscontinuityThreshold)
{
++AudioSpliceDiscontinuityCount;
UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Warning,
TEXT("[AUDIO-DBG] Role=%d splice discontinuity: |delta|=%d (prev=%d first=%d) count=%d — pop likely here."),
static_cast<int32>(GetOwnerRole()), Delta,
static_cast<int32>(LastEnqueuedSample), static_cast<int32>(Samples[0]),
AudioSpliceDiscontinuityCount);
}
else if (DbgVal >= 2)
{
UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Log,
TEXT("[AUDIO-DBG] Role=%d splice ok: |delta|=%d"),
static_cast<int32>(GetOwnerRole()), Delta);
}
}
LastEnqueuedSample = Samples[NumSamples - 1];
bHasLastEnqueuedSample = true;
}
}
{
FScopeLock Lock(&AudioQueueLock);
AudioQueue.Append(PCMData);
@@ -1809,6 +1980,9 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::StopAgentAudio()
bool bWasSpeaking = false;
double Now = 0.0;
bPreBuffering = false; // Clear pre-buffer state on stop.
ClientReassemblyPCM.Reset(); // Client: drop any half-received message so it can't leak into the next turn.
AgentAudioEncodeLeftover.Reset(); // Server: drop the carried sub-frame tail so it can't glue onto the next turn.
bHasLastEnqueuedSample = false; // Debug: don't count the inter-turn splice as a discontinuity.
{
FScopeLock Lock(&AudioQueueLock);
AudioQueue.Empty();
@@ -2018,6 +2192,7 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::GetLifetimeReplicatedProps(
DOREPLIFETIME(UPS_AI_ConvAgent_ElevenLabsComponent, bNetIsConversing);
DOREPLIFETIME(UPS_AI_ConvAgent_ElevenLabsComponent, NetConnectedPawns);
DOREPLIFETIME(UPS_AI_ConvAgent_ElevenLabsComponent, NetActiveSpeakerPawn);
DOREPLIFETIME(UPS_AI_ConvAgent_ElevenLabsComponent, GazeOverrideTarget);
DOREPLIFETIME(UPS_AI_ConvAgent_ElevenLabsComponent, CurrentEmotion);
DOREPLIFETIME(UPS_AI_ConvAgent_ElevenLabsComponent, CurrentEmotionIntensity);
}
@@ -2041,17 +2216,34 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::OnRep_ConversationState()
// on the local pawn, but remote clients never run that code path.
if (UPS_AI_ConvAgent_GazeComponent* Gaze = Owner->FindComponentByClass<UPS_AI_ConvAgent_GazeComponent>())
{
if (bNetIsConversing && NetConnectedPawns.Num() > 0)
if (GazeOverrideTarget)
{
// Override (agent-to-agent dialogue): look at the fixed actor.
Gaze->bActive = true;
if (Gaze->TargetActor != GazeOverrideTarget)
{
Gaze->TargetActor = GazeOverrideTarget;
Gaze->ResetBodyTarget();
}
Gaze->bEnableBodyTracking = true;
}
else if (bNetIsConversing && NetConnectedPawns.Num() > 0)
{
Gaze->bActive = true;
// Use active speaker if set, otherwise first connected pawn.
// GazeTarget can be null on a late joiner if the replicated pawn
// reference hasn't resolved yet (actor channel not open). Skip until
// it resolves — OnRep fires again once the reference is patched in.
APawn* GazeTarget = NetActiveSpeakerPawn ? NetActiveSpeakerPawn : NetConnectedPawns.Last();
if (GazeTarget)
{
Gaze->TargetActor = GazeTarget;
Gaze->ResetBodyTarget();
Gaze->bEnableBodyTracking = true;
UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Log,
TEXT("[NET-REP] Gaze ACTIVATED, TargetActor set to %s"), *GazeTarget->GetName());
}
}
else
{
Gaze->bActive = false;
@@ -2084,6 +2276,25 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::OnRep_ConversationState()
}
}
// ── Client: (re)start listening once the conversation is confirmed live ──
// StartListening() called early on the client defers (bPendingStartListening)
// because bNetIsConversing wasn't replicated yet. The ClientConversationStarted
// relay RPC may also race ahead of this replication. OnRep is the reliable
// "conversation is live" signal, so consume the deferred listen here — this
// closes the race regardless of RPC/replication ordering. Gated on
// IsLocalPlayerConversating() so only the joining client opens its mic path.
if (GetOwnerRole() != ROLE_Authority && bNetIsConversing && IsLocalPlayerConversating())
{
if (bPendingStartListening
|| (bAutoStartListening && TurnMode == EPS_AI_ConvAgent_TurnMode_ElevenLabs::Server))
{
bPendingStartListening = false;
UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Log,
TEXT("[NET-REP] Conversation live on client — starting listening (mic uplink)."));
StartListening();
}
}
if (!bNetIsConversing)
{
// Conversation ended on server — clean up local playback.
@@ -2100,6 +2311,11 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::OnRep_ActiveSpeaker()
AActor* Owner = GetOwner();
if (!Owner) return;
// Guard: during an agent-to-agent dialogue the gaze is pinned to the other
// agent (GazeOverrideTarget) — a replicated active-speaker change (e.g. a
// player joining/speaking) must NOT yank the eyes back onto the player.
if (!GazeOverrideTarget)
{
if (auto* Gaze = Owner->FindComponentByClass<UPS_AI_ConvAgent_GazeComponent>())
{
if (NetActiveSpeakerPawn)
@@ -2111,6 +2327,7 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::OnRep_ActiveSpeaker()
}
// Don't clear gaze when null — keep looking at last speaker.
}
}
OnActiveSpeakerChanged.Broadcast(NetActiveSpeakerPawn, nullptr);
}
@@ -2223,8 +2440,11 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::ServerLeaveConversation_Implementatio
SetActiveSpeaker(NetConnectedPawns.Num() > 0 ? NetConnectedPawns[0] : nullptr);
}
// If no players left, fully end the conversation.
if (NetConnectedPawns.Num() == 0)
// If no players left, fully end the conversation — UNLESS the agent-to-agent
// orchestrator owns it (GazeOverrideTarget set). A player merely WATCHING the
// dialogue joins/leaves just by looking around; that must NOT close the agent's
// WebSocket, stop its audio, or deactivate its lip sync. Keep the dialogue alive.
if (NetConnectedPawns.Num() == 0 && !GazeOverrideTarget)
{
// Cancel any pending reconnection (ephemeral mode only).
if (!bPersistentSession)
@@ -2260,7 +2480,8 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::ServerLeaveConversation_Implementatio
}
else
{
// Other players still connected — update gaze.
// Players still connected, OR an orchestrated agent-to-agent dialogue owns
// this conversation (0 players but GazeOverrideTarget set) — keep it running.
ApplyConversationGaze();
}
}
@@ -2304,6 +2525,38 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::ServerSendMicAudioFromPlayer(
{
if (!WebSocketProxy || !WebSocketProxy->IsConnected()) return;
// Server-side voice-onset. This runs on the SERVER (only the server has a live
// WebSocket — clients returned above), and the player's mic — the host's OR a
// remote client's, relayed here — always passes through this function. Broadcasting
// OnUserVoiceDetected here is what makes the agent-to-agent dialogue interrupt work
// on a DEDICATED server (no host player). Debounced (shares LastUserVoiceOnset with
// the FeedExternalAudio path, so the host doesn't double-fire).
{
const int32 OnsetSamples = PCMBytes.Num() / (int32)sizeof(int16);
if (OnsetSamples > 0)
{
const int16* OS = reinterpret_cast<const int16*>(PCMBytes.GetData());
double OnsetSumSq = 0.0;
for (int32 i = 0; i < OnsetSamples; ++i) { const double v = OS[i] / 32768.0; OnsetSumSq += v * v; }
const double OnsetRms = FMath::Sqrt(OnsetSumSq / OnsetSamples);
if (OnsetRms >= 0.02)
{
const double NowSec = FPlatformTime::Seconds();
if (NowSec - LastUserVoiceOnset > 0.5)
{
LastUserVoiceOnset = NowSec;
OnUserVoiceDetected.Broadcast();
}
}
}
}
// Replay capture: expose every relayed player mic chunk (with its pawn) so an
// external recorder (PS_AI_ConvReplay) can save player speech. Server-only — clients
// have no live WebSocket and returned above. Fired before arbitration so audio from
// every player is captured, even a non-active speaker.
OnPlayerAudioCaptured.Broadcast(SpeakerPawn, PCMBytes);
// Standalone / single-player: bypass speaker arbitration entirely.
// There's only one player — no need for Contains check or speaker switching.
if (NetConnectedPawns.Num() <= 1)
@@ -2335,16 +2588,44 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::ServerSendMicAudioFromPlayer(
}
const double Now = FPlatformTime::Seconds();
LastSpeakTime.FindOrAdd(SpeakerPawn) = Now;
// If this player IS the active speaker, forward immediately.
// Real voice energy of this chunk (int16 mono PCM). Only ACTUAL speech may
// hold or take the floor — not the silence-tail or ambient that an open mic
// streams continuously. Otherwise the first speaker's continuous stream keeps
// refreshing LastSpeakTime, so no one else can ever switch in and the agent
// stays locked on them (multi-player gaze/turn-taking broken).
const int32 NumSamples = PCMBytes.Num() / (int32)sizeof(int16);
double SumSq = 0.0;
if (NumSamples > 0)
{
const int16* S = reinterpret_cast<const int16*>(PCMBytes.GetData());
for (int32 i = 0; i < NumSamples; ++i) { const double v = S[i] / 32768.0; SumSq += v * v; }
}
const double Rms = (NumSamples > 0) ? FMath::Sqrt(SumSq / NumSamples) : 0.0;
const bool bVoiced = (Rms >= 0.02); // speech vs ambient/breath; tune if needed.
// Only real voice asserts holding/taking the floor.
if (bVoiced)
{
LastSpeakTime.FindOrAdd(SpeakerPawn) = Now;
}
// The active speaker is always forwarded (keeps the silence tail flowing to
// ElevenLabs VAD for end-of-turn detection).
if (NetActiveSpeakerPawn == SpeakerPawn)
{
WebSocketProxy->SendAudioChunk(PCMBytes);
return;
}
// Speaker switch: only switch if current speaker has been silent
// A different player can only TAKE the floor with actual voice — their
// silence/tail must not steal it from the current speaker.
if (!bVoiced)
{
return;
}
// Speaker switch: only switch if current speaker has been voice-silent
// for at least SpeakerSwitchHysteresis seconds.
bool bCanSwitch = true;
if (NetActiveSpeakerPawn)
@@ -2420,7 +2701,7 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::ClientConversationFailed_Implementati
// Network: Multicast RPCs
// ─────────────────────────────────────────────────────────────────────────────
void UPS_AI_ConvAgent_ElevenLabsComponent::MulticastReceiveAgentAudio_Implementation(
const TArray<uint8>& AudioData)
const TArray<uint8>& AudioData, bool bLastSubChunk)
{
// Server already handled playback in HandleAudioReceived.
if (GetOwnerRole() == ROLE_Authority) return;
@@ -2438,13 +2719,17 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::MulticastReceiveAgentAudio_Implementa
if (OpusDecoder.IsValid())
{
// Opus path: decode compressed audio.
const uint32 MaxDecompressedSize = 16000 * 2;
// Buffer must hold a full packet's decoded PCM. The engine decoder aborts
// with "Decompression buffer too small to decode voice" (dropping the
// packet tail) if this is smaller than the packet needs. The server caps
// packets at 40 frames (25600 bytes); 64KB gives ample headroom even up to
// the engine's 76-frame (~48640 bytes) maximum, so no packet is truncated.
const uint32 MaxDecompressedSize = 64 * 1024;
PCMBuffer.SetNumUninitialized(MaxDecompressedSize);
uint32 DecompressedSize = MaxDecompressedSize;
OpusDecoder->Decode(AudioData.GetData(), AudioData.Num(),
PCMBuffer.GetData(), DecompressedSize);
if (DecompressedSize == 0) return;
PCMBuffer.SetNum(DecompressedSize);
PCMBuffer.SetNum(DecompressedSize); // may be 0 — handled by reassembly below
}
else
{
@@ -2452,14 +2737,73 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::MulticastReceiveAgentAudio_Implementa
PCMBuffer = AudioData;
}
// Local playback.
EnqueueAgentAudio(PCMBuffer);
// Reassemble one ElevenLabs message from its N sub-chunks, then enqueue ONCE.
// The server splits a message into sub-chunks (to respect the Opus 76-frame /
// client decode-buffer limits), sent back-to-back in the same tick. Enqueuing
// each sub-chunk separately re-ran the playback state machine → a pop at each
// ~0.8s seam. Accumulating and enqueuing once per message plays it contiguously.
if (PCMBuffer.Num() > 0)
{
ClientReassemblyPCM.Append(PCMBuffer);
}
if (!bLastSubChunk)
{
return; // wait for the remaining sub-chunks of this message
}
if (ClientReassemblyPCM.Num() > 0)
{
// Local playback (one contiguous block per message).
EnqueueAgentAudio(ClientReassemblyPCM);
// Feed lip-sync (within LOD or speaker).
if (bIsSpeaker || LipSyncLODDistance <= 0.f || Dist <= LipSyncLODDistance)
{
OnAgentAudioData.Broadcast(PCMBuffer);
OnAgentAudioData.Broadcast(ClientReassemblyPCM);
}
ClientReassemblyPCM.Reset();
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Replay support (PS_AI_ConvReplay bridge)
// ─────────────────────────────────────────────────────────────────────────────
void UPS_AI_ConvAgent_ElevenLabsComponent::PlayExternalAudioPCM(const TArray<uint8>& PCM16)
{
if (PCM16.Num() == 0) return;
// Same tail as MulticastReceiveAgentAudio_Implementation, minus the LOD culls:
// enqueue for playback (drives body expression + start/stop via bAgentSpeaking) and
// broadcast the raw PCM so the LipSync component animates visemes.
EnqueueAgentAudio(PCM16);
// During replay there is no live agent_response message, so the normal
// silence-detection stop path (TickComponent) would otherwise wait for the long
// hard-timeout. Mark the response as received so OnAgentStoppedSpeaking fires
// shortly after the buffer drains and body expression returns to idle.
// (Set AFTER EnqueueAgentAudio, which resets this flag on the first chunk.)
bAgentResponseReceived = true;
OnAgentAudioData.Broadcast(PCM16);
}
void UPS_AI_ConvAgent_ElevenLabsComponent::StopExternalAudio()
{
StopAgentAudio();
}
void UPS_AI_ConvAgent_ElevenLabsComponent::TriggerReplayedAction(const FString& ActionName)
{
PendingActionName = ActionName;
OnAgentActionRequested.Broadcast(ActionName);
}
void UPS_AI_ConvAgent_ElevenLabsComponent::TriggerReplayedClientToolCall(
const FPS_AI_ConvAgent_ClientToolCall_ElevenLabs& ToolCall)
{
OnAgentClientToolCall.Broadcast(ToolCall);
}
void UPS_AI_ConvAgent_ElevenLabsComponent::MulticastAgentStartedSpeaking_Implementation()
@@ -2638,14 +2982,27 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::ApplyConversationGaze()
auto* Gaze = Owner->FindComponentByClass<UPS_AI_ConvAgent_GazeComponent>();
if (!Gaze) return;
if (bNetIsConversing && NetConnectedPawns.Num() > 0)
// Override (agent-to-agent dialogue): look at a fixed actor, ignore players.
if (GazeOverrideTarget)
{
Gaze->bActive = true;
if (Gaze->TargetActor != GazeOverrideTarget)
{
Gaze->TargetActor = GazeOverrideTarget;
Gaze->ResetBodyTarget();
}
Gaze->bEnableBodyTracking = true;
}
else if (bNetIsConversing && NetConnectedPawns.Num() > 0)
{
Gaze->bActive = true;
// Look at active speaker if set, otherwise last connected pawn.
// GazeTarget can be null if a replicated pawn ref hasn't resolved yet
// (late joiner). Don't overwrite the target with null in that case.
AActor* GazeTarget = NetActiveSpeakerPawn
? static_cast<AActor*>(NetActiveSpeakerPawn)
: static_cast<AActor*>(NetConnectedPawns.Last());
if (Gaze->TargetActor != GazeTarget)
if (GazeTarget && Gaze->TargetActor != GazeTarget)
{
Gaze->TargetActor = GazeTarget;
Gaze->ResetBodyTarget();
@@ -2676,6 +3033,20 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::ApplyConversationGaze()
}
}
void UPS_AI_ConvAgent_ElevenLabsComponent::SetGazeOverrideTarget(AActor* Target)
{
if (GazeOverrideTarget == Target) return;
GazeOverrideTarget = Target; // replicates → OnRep_GazeOverrideTarget on clients
ApplyConversationGaze(); // apply immediately on the server/authority
}
void UPS_AI_ConvAgent_ElevenLabsComponent::OnRep_GazeOverrideTarget()
{
// Re-evaluate the gaze on clients (honours the override, or restores normal
// player-tracking when it was cleared to null).
ApplyConversationGaze();
}
void UPS_AI_ConvAgent_ElevenLabsComponent::SetActiveSpeaker(APawn* NewSpeaker)
{
if (NetActiveSpeakerPawn == NewSpeaker) return;
@@ -2714,12 +3085,18 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::DrawDebugHUD() const
const FColor GoodColor = FColor::Green;
const FString OwnerName = GetOwner()->GetName();
const bool bConnected = IsConnected();
// On a client there is no local WebSocket (it lives on the server), so
// IsConnected() is always false. Report the effective networked state via the
// replicated bNetIsConversing flag instead, to avoid a misleading "DISCONNECTED".
const bool bIsAuthority = (GetOwnerRole() == ROLE_Authority);
const bool bConnected = bIsAuthority ? IsConnected() : bNetIsConversing;
const TCHAR* ConnStr = bConnected
? (bIsAuthority ? TEXT("CONNECTED") : TEXT("CONNECTED (via server)"))
: TEXT("DISCONNECTED");
GEngine->AddOnScreenDebugMessage(BaseKey, DisplayTime,
bConnected ? GoodColor : FColor::Red,
FString::Printf(TEXT("=== ELEVENLABS [%s]: %s ==="), *OwnerName,
bConnected ? TEXT("CONNECTED") : TEXT("DISCONNECTED")));
FString::Printf(TEXT("=== ELEVENLABS [%s]: %s ==="), *OwnerName, ConnStr));
// Session info
FString ModeStr = (TurnMode == EPS_AI_ConvAgent_TurnMode_ElevenLabs::Server)

View File

@@ -405,11 +405,7 @@ void UPS_AI_ConvAgent_GazeComponent::TickComponent(
const FVector ToTarget = TargetPos - EyeOrigin;
// ── 2. Body: smooth interp toward sticky target ────────────────────
//
// TargetBodyWorldYaw is persistent — only updated when head+eyes
// can't reach the target. Same sticky pattern as TargetHeadYaw.
// ── 1b. Horizontal direction / world yaw to the target ─────────────
const FVector HorizontalDir = FVector(ToTarget.X, ToTarget.Y, 0.0f);
float TargetWorldYaw = 0.0f;
if (!HorizontalDir.IsNearlyZero(1.0f))
@@ -417,11 +413,21 @@ void UPS_AI_ConvAgent_GazeComponent::TickComponent(
TargetWorldYaw = HorizontalDir.Rotation().Yaw;
}
// Body smoothly interpolates toward its persistent target.
// Server/standalone: directly rotates the actor.
// Client: does NOT rotate — accepts replicated rotation from server.
if (bEnableBodyTracking && Owner->HasAuthority())
// ── 2. BODY (authority only): lazy turn ────────────────────────────
// Rotate the actor to face the target only once head+eyes can no longer
// cover it. Clients NEVER rotate the body — they receive it replicated.
// Body turn state (TargetBodyWorldYaw) lives on the authority only.
if (bEnableBodyTracking && Owner->HasAuthority() && !HorizontalDir.IsNearlyZero(1.0f))
{
const float BodyFacingYaw = Owner->GetActorRotation().Yaw + MeshForwardYawOffset;
const float DeltaToTarget = FMath::FindDeltaAngleDegrees(BodyFacingYaw, TargetWorldYaw);
// Commit to facing the target once it leaves the head+eyes range.
if (FMath::Abs(DeltaToTarget) > MaxHeadYaw + MaxEyeHorizontal)
{
TargetBodyWorldYaw = TargetWorldYaw - MeshForwardYawOffset;
}
const float BodyDelta = FMath::FindDeltaAngleDegrees(
Owner->GetActorRotation().Yaw, TargetBodyWorldYaw);
if (FMath::Abs(BodyDelta) > 0.1f)
@@ -431,122 +437,49 @@ void UPS_AI_ConvAgent_GazeComponent::TickComponent(
}
}
// ── Smoothed body yaw for cascade computations ──────────────────
// Server: direct copy (no lag). Client: interpolate toward replicated
// rotation to avoid step artifacts from discrete ~30Hz network updates.
// The client also overrides the actor's rotation with the smoothed value
// so the body mesh doesn't visually jump at network tick rate.
if (Owner->HasAuthority())
// Smoothed mirror of the (authoritative/replicated) body yaw — DEBUG
// only. NEVER written back to the actor: the old client override
// (SetActorRotation) corrupted the head input and made the client head
// diverge by up to MaxHeadYaw. The head reads the real actor yaw below.
{
SmoothedBodyYaw = Owner->GetActorRotation().Yaw;
}
else
{
const float ReplicatedYaw = Owner->GetActorRotation().Yaw;
const float ClientDelta = FMath::FindDeltaAngleDegrees(SmoothedBodyYaw, ReplicatedYaw);
SmoothedBodyYaw += FMath::FInterpTo(0.0f, ClientDelta, SafeDeltaTime, BodyInterpSpeed * 3.0f);
// Override actor rotation with smoothed value so the body mesh
// shows the interpolated rotation instead of the raw replicated steps.
// This is local-only — replication will overwrite on next network update,
// and SmoothedBodyYaw will absorb the correction smoothly.
FRotator SmoothedRot = Owner->GetActorRotation();
SmoothedRot.Yaw = SmoothedBodyYaw;
Owner->SetActorRotation(SmoothedRot);
const float ActorYaw = Owner->GetActorRotation().Yaw;
const float YawDelta = FMath::FindDeltaAngleDegrees(SmoothedBodyYaw, ActorYaw);
SmoothedBodyYaw += FMath::FInterpTo(0.0f, YawDelta, SafeDeltaTime, BodyInterpSpeed * 3.0f);
}
// ── 3. Compute DeltaYaw after body interp ──────────────────────────
// When body tracking is disabled (e.g. proximity gaze during spline patrol),
// sync SmoothedBodyYaw and TargetBodyWorldYaw to current actor rotation.
// This ensures the head offset is always relative to the actual body facing,
// not a stale cached value from a previous frame.
// When body tracking is off, keep the body target synced (debug only).
if (!bEnableBodyTracking)
{
SmoothedBodyYaw = Owner->GetActorRotation().Yaw;
TargetBodyWorldYaw = SmoothedBodyYaw;
TargetBodyWorldYaw = Owner->GetActorRotation().Yaw;
}
// ── 3. HEAD + EYES — STATELESS cascade ─────────────────────────────
// Pure function of the ACTUAL body facing (authoritative on the server,
// replicated on the client) and the target direction. No sticky /
// path-dependent state, so server and client converge to the same pose
// regardless of how the body rotation arrived (smooth vs ~30Hz steps).
// FInterpTo only eases the visual; it never latches.
const float BodyFacingYaw = Owner->GetActorRotation().Yaw + MeshForwardYawOffset;
float DeltaYaw = 0.0f;
if (!HorizontalDir.IsNearlyZero(1.0f))
{
const float CurrentFacingYaw = SmoothedBodyYaw + MeshForwardYawOffset;
DeltaYaw = FMath::FindDeltaAngleDegrees(CurrentFacingYaw, TargetWorldYaw);
DeltaYaw = FMath::FindDeltaAngleDegrees(BodyFacingYaw, TargetWorldYaw);
}
// ── 4. Pitch from 3D direction ─────────────────────────────────────
const float HorizontalDist = HorizontalDir.Size();
const float TargetPitch = (HorizontalDist > 1.0f)
? FMath::RadiansToDegrees(FMath::Atan2(ToTarget.Z, HorizontalDist))
: 0.0f;
// ── 5. BODY overflow: check against persistent TargetBodyWorldYaw ──
//
// Same pattern as head: check from body's TARGET position (not current).
// Body triggers when head+eyes combined range is exceeded.
const float BodyTargetFacing = TargetBodyWorldYaw + MeshForwardYawOffset;
const float DeltaFromBodyTarget = FMath::FindDeltaAngleDegrees(
BodyTargetFacing, TargetWorldYaw);
bool bBodyOverflowed = false;
if (bEnableBodyTracking && FMath::Abs(DeltaFromBodyTarget) > MaxHeadYaw + MaxEyeHorizontal)
{
// Body realigns to face target
TargetBodyWorldYaw = TargetWorldYaw - MeshForwardYawOffset;
// Head returns to ~0° since body will face target directly
TargetHeadYaw = 0.0f;
bBodyOverflowed = true;
}
// ── 6. HEAD: realign when eyes overflow (check against body TARGET) ──
//
// Head overflow is checked relative to where the BODY IS GOING
// (TargetBodyWorldYaw), not where it currently is. This prevents
// the head from overcompensating during body interpolation —
// otherwise the head turns to track while body catches up, then
// snaps back when body arrives (two-step animation artifact).
//
// GUARD: Skip eye overflow check when body just overflowed in this
// same frame. Body overflow already set TargetHeadYaw=0 (head will
// recenter as body turns). Allowing eye overflow to also fire would
// snap TargetHeadYaw to a second value in the same frame, causing
// a visible 1-2 frame jerk as FInterpTo chases two targets.
if (!bBodyOverflowed)
{
const float HeadDeltaYaw = FMath::FindDeltaAngleDegrees(
TargetBodyWorldYaw + MeshForwardYawOffset, TargetWorldYaw);
const float EyeDeltaYaw = HeadDeltaYaw - TargetHeadYaw;
if (FMath::Abs(EyeDeltaYaw) > MaxEyeHorizontal)
{
TargetHeadYaw = FMath::Clamp(HeadDeltaYaw, -MaxHeadYaw, MaxHeadYaw);
}
}
// Head smoothly interpolates toward its persistent target
// Head takes as much of the yaw gap as its range allows; eyes the rest.
// (TargetHeadYaw/Pitch are kept only as debug mirrors — no longer sticky.)
TargetHeadYaw = FMath::Clamp(DeltaYaw, -MaxHeadYaw, MaxHeadYaw);
CurrentHeadYaw = FMath::FInterpTo(CurrentHeadYaw, TargetHeadYaw, SafeDeltaTime, HeadInterpSpeed);
// Eyes = remaining gap (during transients, eyes may sit at MaxEye while
// the head catches up — ARKit normalization keeps visual deflection small)
CurrentEyeYaw = FMath::Clamp(DeltaYaw - CurrentHeadYaw, -MaxEyeHorizontal, MaxEyeHorizontal);
// ── 5. PITCH: relative cascade (Eyes → Head, no body pitch) ────────
// Same pattern: check against persistent TargetHeadPitch
const float EyeDeltaPitch = TargetPitch - TargetHeadPitch;
if (FMath::Abs(EyeDeltaPitch) > MaxEyeVertical)
{
// Eyes overflow → head realigns toward target pitch
TargetHeadPitch = FMath::Clamp(TargetPitch, -MaxHeadPitch, MaxHeadPitch);
}
CurrentHeadPitch = FMath::FInterpTo(CurrentHeadPitch, TargetHeadPitch, SafeDeltaTime, HeadInterpSpeed);
// Eyes = remaining pitch gap
CurrentEyePitch = FMath::Clamp(TargetPitch - CurrentHeadPitch, -MaxEyeVertical, MaxEyeVertical);
}
else

View File

@@ -21,6 +21,17 @@ static TAutoConsoleVariable<int32> CVarDebugInteraction(
TEXT("Debug HUD for Interaction. -1=use property, 0=off, 1-3=verbosity."),
ECVF_Default);
// Convert normalized float mic samples to int16 little-endian bytes (16kHz mono).
static void ConvReplay_FloatToInt16Bytes(const TArray<float>& In, TArray<uint8>& Out)
{
Out.SetNumUninitialized(In.Num() * static_cast<int32>(sizeof(int16)));
int16* D = reinterpret_cast<int16*>(Out.GetData());
for (int32 i = 0; i < In.Num(); ++i)
{
D[i] = static_cast<int16>(FMath::Clamp<int32>(FMath::RoundToInt(In[i] * 32767.0f), -32768, 32767));
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Constructor
// ─────────────────────────────────────────────────────────────────────────────
@@ -132,6 +143,9 @@ void UPS_AI_ConvAgent_InteractionComponent::TickComponent(float DeltaTime, ELeve
bInitialized = true;
// If a replay recording started before the mic existed, begin capturing now.
UpdateReplayCapture();
if (bDebug)
{
UE_LOG(LogPS_AI_ConvAgent_Select, Log,
@@ -191,7 +205,8 @@ void UPS_AI_ConvAgent_InteractionComponent::TickComponent(float DeltaTime, ELeve
// Re-activate passive gaze using configured checkboxes.
// ExecuteLeave killed the gaze — re-enable passively.
if (bAutoManageGaze)
// (Skip if the agent is in an agent-to-agent dialogue.)
if (bAutoManageGaze && !Pending->GazeOverrideTarget)
{
if (UPS_AI_ConvAgent_GazeComponent* Gaze = FindGazeOnAgent(Pending))
{
@@ -301,6 +316,9 @@ void UPS_AI_ConvAgent_InteractionComponent::UpdatePassiveGaze()
if (Agent == GazeRetainedAgent.Get()) continue;
if (Agent == PendingLeaveAgent.Get()) continue;
if (Agent->IsConversationDisabled()) continue;
// Skip agents driven by an agent-to-agent dialogue (they look at each
// other via GazeOverrideTarget — don't pull their gaze toward the player).
if (Agent->GazeOverrideTarget) continue;
AActor* AgentActor = Agent->GetOwner();
if (!AgentActor) continue;
@@ -600,7 +618,8 @@ void UPS_AI_ConvAgent_InteractionComponent::SetSelectedAgent(UPS_AI_ConvAgent_El
// ExecuteLeave → ServerLeaveConversation → ApplyConversationGaze()
// already killed bActive and cleared TargetActor. Override that:
// the agent should keep looking at the player passively.
if (bAutoManageGaze)
// (Skip while an agent-to-agent dialogue owns the gaze.)
if (bAutoManageGaze && !OldAgent->GazeOverrideTarget)
{
if (UPS_AI_ConvAgent_GazeComponent* Gaze = FindGazeOnAgent(OldAgent))
{
@@ -919,6 +938,11 @@ void UPS_AI_ConvAgent_InteractionComponent::AttachGazeTarget(
// from ever firing.
if (AgentPtr->IsConversationDisabled()) return;
// Agent-to-agent dialogue: this agent's gaze is pinned on the OTHER agent via
// GazeOverrideTarget. Don't attach it to the player just because the player is
// looking at / selecting it — let the override hold (the dialogue owns the gaze).
if (AgentPtr->GazeOverrideTarget) return;
if (UPS_AI_ConvAgent_GazeComponent* Gaze = FindGazeOnAgent(AgentPtr))
{
Gaze->TargetActor = GetOwner();
@@ -1052,10 +1076,34 @@ void UPS_AI_ConvAgent_InteractionComponent::CleanupRetainedGaze(UPS_AI_ConvAgent
// ─────────────────────────────────────────────────────────────────────────────
void UPS_AI_ConvAgent_InteractionComponent::OnMicAudioCaptured(const TArray<float>& FloatPCM)
{
UPS_AI_ConvAgent_ElevenLabsComponent* Agent = SelectedAgent.Get();
if (!Agent) return;
if (UPS_AI_ConvAgent_ElevenLabsComponent* Agent = SelectedAgent.Get())
{
Agent->FeedExternalAudio(FloatPCM);
return;
}
// No agent selected: if a replay is being recorded, still capture this pawn's
// voice for the recorder. Convert to int16, accumulate, and relay in chunks —
// the host broadcasts locally; a remote client sends a Server RPC.
if (!bReplayRecordActive) return;
TArray<uint8> Bytes;
ConvReplay_FloatToInt16Bytes(FloatPCM, Bytes);
RecordMicBuffer.Append(Bytes);
static constexpr int32 RecordChunkMinBytes = 3200; // ~100ms @ 16kHz mono int16
if (RecordMicBuffer.Num() >= RecordChunkMinBytes)
{
if (GetOwnerRole() == ROLE_Authority)
{
OnPlayerVoiceForRecording.Broadcast(Cast<APawn>(GetOwner()), RecordMicBuffer);
}
else
{
ServerRecordVoice(RecordMicBuffer);
}
RecordMicBuffer.Reset();
}
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -1144,7 +1192,7 @@ void UPS_AI_ConvAgent_InteractionComponent::GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
// No replicated properties — but the component must be replicated for RPCs.
DOREPLIFETIME(UPS_AI_ConvAgent_InteractionComponent, bReplayRecordActive);
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -1221,6 +1269,50 @@ void UPS_AI_ConvAgent_InteractionComponent::ServerRelayMicAudio_Implementation(
Agent->ServerSendMicAudioFromPlayer(SenderPawn, PCMToSend);
}
// ─────────────────────────────────────────────────────────────────────────────
// Agent-less replay recording (driven by PS_AI_ConvReplay)
// ─────────────────────────────────────────────────────────────────────────────
void UPS_AI_ConvAgent_InteractionComponent::SetReplayRecordActive(bool bActive)
{
if (GetOwnerRole() != ROLE_Authority) return;
if (bReplayRecordActive == bActive) return;
bReplayRecordActive = bActive;
// Authority (host player) receives no OnRep — apply locally now.
UpdateReplayCapture();
}
void UPS_AI_ConvAgent_InteractionComponent::OnRep_ReplayRecordActive()
{
UpdateReplayCapture();
}
void UPS_AI_ConvAgent_InteractionComponent::UpdateReplayCapture()
{
if (!bReplayRecordActive)
{
RecordMicBuffer.Reset();
// Only stop the mic if no agent conversation still needs it.
if (MicComponent && MicComponent->IsCapturing() && !SelectedAgent.IsValid())
{
MicComponent->StopCapture();
}
return;
}
// Recording: make sure the mic is capturing. On a remote pawn's server-side
// replica MicComponent is null (tick disabled) and this is a harmless no-op —
// that client captures on its own machine and relays via ServerRecordVoice.
if (MicComponent && !MicComponent->IsCapturing())
{
MicComponent->StartCapture();
}
}
void UPS_AI_ConvAgent_InteractionComponent::ServerRecordVoice_Implementation(const TArray<uint8>& PCMBytes)
{
OnPlayerVoiceForRecording.Broadcast(Cast<APawn>(GetOwner()), PCMBytes);
}
void UPS_AI_ConvAgent_InteractionComponent::ServerRelaySendText_Implementation(
AActor* AgentActor, const FString& Text)
{

View File

@@ -285,6 +285,23 @@ void UPS_AI_ConvAgent_WebSocket_ElevenLabsProxy::SendTextMessage(const FString&
SendJsonMessage(Msg);
}
void UPS_AI_ConvAgent_WebSocket_ElevenLabsProxy::SendContextualUpdate(const FString& Text)
{
if (!IsConnected())
{
UE_LOG(LogPS_AI_ConvAgent_WS_ElevenLabs, Warning, TEXT("SendContextualUpdate: not connected."));
return;
}
if (Text.IsEmpty()) return;
// API: { "type": "contextual_update", "text": "..." } — injects context WITHOUT
// triggering an agent response (unlike user_message).
TSharedPtr<FJsonObject> Msg = MakeShareable(new FJsonObject());
Msg->SetStringField(TEXT("type"), TEXT("contextual_update"));
Msg->SetStringField(TEXT("text"), Text);
SendJsonMessage(Msg);
}
void UPS_AI_ConvAgent_WebSocket_ElevenLabsProxy::SendInterrupt()
{
if (!IsConnected()) return;

View File

@@ -0,0 +1,181 @@
// Copyright ASTERION. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "PS_AI_ConvAgent_ElevenLabsComponent.h" // component type + delegates + ConversationInfo struct
#include "PS_AI_ConvAgent_AgentDialogue.generated.h"
class UPS_AI_ConvAgent_ElevenLabsComponent;
/** Fired each time a speaker's line is forwarded to the other agent (subtitles / logging). */
DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams(FPS_AI_ConvAgent_OnDialogueTurn,
int32, TurnIndex, AActor*, Speaker, const FString&, Text);
/** Fired when the dialogue stops (MaxTurns reached or StopDialogue). */
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FPS_AI_ConvAgent_OnDialogueFinished,
int32, TotalTurns);
/**
* Orchestrator actor that makes TWO ElevenLabs agents converse with each other
* over TEXT (no STT): each agent's completed text response is fed as a user
* message to the OTHER agent, which then replies. Both agents still speak aloud
* (TTS), so humans in the level hear the whole conversation.
*
* Setup: place this actor in the level, assign AgentA / AgentB to two actors that
* each already carry a PS_AI_ConvAgent_ElevenLabsComponent, set an OpeningMessage,
* then call StartDialogue() (or enable bAutoStartOnBeginPlay).
*
* Runs on the SERVER (authority) — the agents' WebSockets are server-owned.
* By default forwarding happens when a speaker FINISHES speaking so the two
* voices never overlap (needs audio playback → listen-server/host). On a
* dedicated server with no audio device, set bWaitForSpeechToFinish=false to
* drive turns from the text event instead.
*/
UCLASS(Blueprintable)
class PS_AI_CONVAGENT_API APS_AI_ConvAgent_AgentDialogue : public AActor
{
GENERATED_BODY()
public:
APS_AI_ConvAgent_AgentDialogue();
/** Actor carrying the FIRST agent's ElevenLabs component. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS AI ConvAgent|Dialogue")
TObjectPtr<AActor> AgentA;
/** Actor carrying the SECOND agent's ElevenLabs component. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS AI ConvAgent|Dialogue")
TObjectPtr<AActor> AgentB;
/** Opening line sent to the first speaker to kick off the dialogue. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS AI ConvAgent|Dialogue")
FString OpeningMessage = TEXT("Bonjour.");
/** If true the opening message goes to AgentA (A replies first); else to AgentB. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS AI ConvAgent|Dialogue")
bool bAgentAStarts = true;
/** Max forwarded turns before auto-stopping. 0 = unlimited. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS AI ConvAgent|Dialogue", meta = (ClampMin = "0"))
int32 MaxTurns = 20;
/** Pause (s) after a speaker's turn before the other is prompted. Adds breathing room. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS AI ConvAgent|Dialogue", meta = (ClampMin = "0.0"))
float TurnDelaySeconds = 0.0f;
/** True (default): forward a line when the speaker FINISHES speaking (clean, non-overlapping voices; needs audio playback).
* False: forward as soon as the text is ready (robust on dedicated servers, but voices may overlap). */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS AI ConvAgent|Dialogue")
bool bWaitForSpeechToFinish = true;
/** Start the dialogue automatically on BeginPlay (authority only). */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS AI ConvAgent|Dialogue")
bool bAutoStartOnBeginPlay = false;
/** After the player interrupts (by speaking), ALL dialogue agents look at the
* player until the player moves farther than this (cm) from BOTH agents, then
* the agents are released back to normal behavior and their conversations end. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS AI ConvAgent|Dialogue", meta = (ClampMin = "0.0"))
float PlayerAddressReleaseDistance = 500.0f;
/** Sent to BOTH agents as a SILENT context update (ElevenLabs contextual_update — no
* reply triggered) the instant the player interrupts, so the agents know a new/real
* person is now addressing them while KEEPING the prior conversation context.
* Leave empty to inject nothing. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS AI ConvAgent|Dialogue", meta = (MultiLine = "true"))
FString InterruptContextMessage = TEXT("Une nouvelle personne, bien reelle, s'adresse maintenant directement a toi, ce n'est plus ton interlocuteur precedent. Garde en memoire la conversation qui vient d'avoir lieu, mais reponds desormais a cette personne.");
/** Sent (contextual_update, no reply) to the agent the player is NOT talking to: it
* is a witness. Empty = don't inform the bystander. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS AI ConvAgent|Dialogue", meta = (MultiLine = "true"))
FString BystanderContextMessage = TEXT("Une nouvelle personne, bien reelle, vient d'entrer et s'adresse a ton interlocuteur, pas a toi pour l'instant. Ecoute ce qui se dit, tu pourras y faire reference plus tard quand elle se tournera vers toi.");
/** Prefix for the "overheard" lines relayed to the witness agent during a 3-way
* exchange (contextual_update, no reply). Empty = disable passive overhearing. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS AI ConvAgent|Dialogue")
FString OverheardPrefix = TEXT("[Tu entends la conversation a cote] ");
/** Fired each time a line is forwarded (TurnIndex, Speaker actor, the text). */
UPROPERTY(BlueprintAssignable, Category = "PS AI ConvAgent|Dialogue")
FPS_AI_ConvAgent_OnDialogueTurn OnDialogueTurn;
/** Fired when the dialogue ends. */
UPROPERTY(BlueprintAssignable, Category = "PS AI ConvAgent|Dialogue")
FPS_AI_ConvAgent_OnDialogueFinished OnDialogueFinished;
/** Open both conversations and kick off the exchange. Authority only. */
UFUNCTION(BlueprintCallable, Category = "PS AI ConvAgent|Dialogue")
void StartDialogue();
/** Stop forwarding turns. Agents stay connected unless bEndConversations. */
UFUNCTION(BlueprintCallable, Category = "PS AI ConvAgent|Dialogue")
void StopDialogue(bool bEndConversations = false);
/** True while the loop is active. */
UFUNCTION(BlueprintPure, Category = "PS AI ConvAgent|Dialogue")
bool IsRunning() const { return bRunning; }
protected:
virtual void BeginPlay() override;
virtual void EndPlay(const EEndPlayReason::Type Reason) override;
virtual void Tick(float DeltaSeconds) override; // only active in "addressing player" mode
private:
UPROPERTY(Transient) TObjectPtr<UPS_AI_ConvAgent_ElevenLabsComponent> CompA;
UPROPERTY(Transient) TObjectPtr<UPS_AI_ConvAgent_ElevenLabsComponent> CompB;
// Single-floor turn state: exactly ONE agent holds the floor at a time; every
// event from the OTHER agent is ignored. A turn forwards exactly once, when the
// floor-holder's text AND its speech are both complete.
enum class EFloor : uint8 { None, A, B };
EFloor Floor = EFloor::None;
int32 TurnId = 0; // number of forwarded turns
bool bTextReady = false; // floor-holder's full text arrived this turn
bool bSpeechDone = false; // floor-holder finished speaking this turn
bool bForwardedThisTurn = false; // exactly-one-forward guard
FString PendingLine; // floor-holder's line for this turn
bool bRunning = false;
bool bOpeningSent = false;
FTimerHandle TurnTimer;
// "Addressing player" mode: after the player interrupts by speaking, ALL dialogue
// agents are pinned to look at the player (fast, via the gaze override) and the
// orchestrator ticks to auto-release them once the player leaves both agents.
bool bAddressingPlayer = false;
UPROPERTY(Transient) TObjectPtr<APawn> AddressedByPlayer;
EFloor EngagedFloor = EFloor::None; // which agent the player is currently addressing
APawn* ResolveSpeakingPlayer() const; // the player whose voice was just transcribed
void EndPlayerAddress(); // release agents → normal + end conversations
UPS_AI_ConvAgent_ElevenLabsComponent* ResolveAgentComp(AActor* Actor) const;
void BindAgent(UPS_AI_ConvAgent_ElevenLabsComponent* Comp, bool bIsA);
void UnbindAll();
void TryKickoff();
void BeginTurn(EFloor NewFloor); // set floor + reset per-turn flags
bool IsFloor(bool bFromA) const; // is the given agent the current floor-holder?
void TryForward(); // forward once, when text AND speech done
UFUNCTION() void HandleTextA(const FString& Text);
UFUNCTION() void HandleTextB(const FString& Text);
UFUNCTION() void HandleStoppedA();
UFUNCTION() void HandleStoppedB();
UFUNCTION() void HandleConnectedA(const FPS_AI_ConvAgent_ConversationInfo_ElevenLabs& Info);
UFUNCTION() void HandleConnectedB(const FPS_AI_ConvAgent_ConversationInfo_ElevenLabs& Info);
// Per-agent interrupt/relay triggers. Knowing WHICH agent the player addressed lets
// us track the engaged agent and relay the exchange to the other ("witness") agent.
// Voice-onset = primary immediate trigger; transcript = backup + carries the words.
// Merely LOOKING at an agent never triggers these — only actually speaking does.
UFUNCTION() void HandleVoiceOnsetA();
UFUNCTION() void HandleVoiceOnsetB();
UFUNCTION() void HandleTranscriptA(const FPS_AI_ConvAgent_TranscriptSegment_ElevenLabs& Segment);
UFUNCTION() void HandleTranscriptB(const FPS_AI_ConvAgent_TranscriptSegment_ElevenLabs& Segment);
void OnPlayerSpoke(EFloor Which, const FString& PlayerWords); // player addressed agent Which
void InterruptForPlayer(EFloor Engaged); // enter 3-way floor mode (Engaged = addressee)
void SetEngaged(EFloor Which); // reframe both agents when the addressee changes
void RelayToBystander(EFloor EngagedSide, const FString& FormattedLine); // "overheard" context
};

View File

@@ -122,10 +122,21 @@ DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnAgentPassiveGazeStarted);
/** Fired when this agent stops passive gaze (left range or became selected). */
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnAgentPassiveGazeStopped);
/** Fired on the FIRST voiced frame of the local user's mic (via FeedExternalAudio),
* independent of the ElevenLabs STT round-trip — used to interrupt an agent-to-agent
* dialogue on voice onset. Debounced (~once per utterance). */
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnUserVoiceDetected);
// Non-dynamic delegate for raw agent audio (high-frequency, C++ consumers only).
// Delivers PCM chunks as int16, 16kHz mono, little-endian.
DECLARE_MULTICAST_DELEGATE_OneParam(FOnAgentAudioData, const TArray<uint8>& /*PCMData*/);
/** Fired on the SERVER for each player mic chunk relayed via ServerSendMicAudioFromPlayer
* (int16, 16kHz mono). Carries the speaking pawn so an external recorder (e.g. the
* PS_AI_ConvReplay bridge) can capture player speech for replay. */
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnPlayerAudioCaptured,
APawn*, SpeakerPawn, const TArray<uint8>&, PCMData);
// ─────────────────────────────────────────────────────────────────────────────
// UPS_AI_ConvAgent_ElevenLabsComponent
//
@@ -324,6 +335,12 @@ public:
meta = (ToolTip = "Fires when the agent is interrupted mid-speech. Audio is automatically stopped."))
FOnAgentInterrupted OnAgentInterrupted;
/** Fires on local user voice onset (first voiced mic frame via FeedExternalAudio),
* without waiting for the ElevenLabs STT round-trip. Used to interrupt an
* agent-to-agent dialogue the instant the player speaks. */
UPROPERTY(BlueprintAssignable, Category = "ASTERION|PS_AI_ConvAgent|ElevenLabs|Events")
FOnUserVoiceDetected OnUserVoiceDetected;
/** Fired when the server starts generating a response (before any audio arrives). Use this for "thinking..." UI feedback. In push-to-talk mode, the microphone is automatically closed when this fires. */
UPROPERTY(BlueprintAssignable, Category = "ASTERION|PS_AI_ConvAgent|ElevenLabs|Events",
meta = (ToolTip = "Fires when the server starts generating (before audio arrives).\nUse for 'thinking...' UI. Mic is auto-closed in push-to-talk mode."))
@@ -388,6 +405,12 @@ public:
* Used internally by UPS_AI_ConvAgent_LipSyncComponent for spectral analysis. */
FOnAgentAudioData OnAgentAudioData;
/** Fired on the SERVER for each player mic chunk relayed via ServerSendMicAudioFromPlayer
* (int16, 16kHz mono). Lets an external recorder capture player speech for replay.
* Not fired on clients — only the server holds the relayed mic stream. */
UPROPERTY(BlueprintAssignable, Category = "ASTERION|PS_AI_ConvAgent|ElevenLabs|Events")
FOnPlayerAudioCaptured OnPlayerAudioCaptured;
// ── Network state (replicated) ───────────────────────────────────────────
/** True when one or more players are in conversation with this NPC.
@@ -407,6 +430,20 @@ public:
UPROPERTY(ReplicatedUsing = OnRep_ActiveSpeaker, BlueprintReadOnly, Category = "ASTERION|PS_AI_ConvAgent|Network")
TObjectPtr<APawn> NetActiveSpeakerPawn = nullptr;
/** When set, the conversation gaze looks at THIS actor instead of any connected
* player pawn — used by the agent-to-agent dialogue so the two agents look at
* EACH OTHER rather than at a bystander. Replicated so clients honor it too. */
UPROPERTY(ReplicatedUsing = OnRep_GazeOverrideTarget, BlueprintReadOnly, Category = "ASTERION|PS_AI_ConvAgent|Gaze")
TObjectPtr<AActor> GazeOverrideTarget = nullptr;
UFUNCTION()
void OnRep_GazeOverrideTarget();
/** Force the conversation gaze to look at Target (pass null to clear and restore
* the normal player-tracking behaviour). Call on the server; replicates to clients. */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_ConvAgent|Gaze")
void SetGazeOverrideTarget(AActor* Target);
// ── Multi-player speaker arbitration (server only) ──────────────────────
/** Minimum seconds of silence from the current speaker before allowing a speaker switch.
@@ -439,6 +476,18 @@ public:
meta = (ClampMin = "0", ToolTip = "Distance beyond which lip-sync is skipped for non-speaking players. 0 = no LOD."))
float LipSyncLODDistance = 1500.f;
// ── Network mic uplink (clients) ─────────────────────────────────────────
/** Seconds of silence to keep streaming to the server AFTER the player's last
* voiced chunk, so ElevenLabs Server VAD can detect end-of-turn. Beyond this
* tail, silent chunks are dropped to save bandwidth (clients only — the
* authority path always streams the full mic). Set >= the agent's end-of-turn
* silence threshold (+ margin). 0 = drop all silence (breaks Server VAD). */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_AI_ConvAgent|Network|MultiPlayer",
meta = (ClampMin = "0.0", ClampMax = "5.0",
ToolTip = "Silence streamed after last voice so ElevenLabs detects end-of-turn (Server VAD).\nMust be >= the agent's end-of-turn silence. 0 = drop all silence (breaks Server VAD on clients)."))
float MicSilenceTailSeconds = 2.0f;
// ── Network RPCs ─────────────────────────────────────────────────────────
/** Join a shared conversation with this NPC. Multiple players can join.
@@ -472,9 +521,22 @@ public:
UFUNCTION(Server, Reliable)
void ServerRequestInterrupt();
/** Broadcast agent audio to all clients (Opus-compressed or raw PCM fallback). */
/** Broadcast agent audio to all clients (Opus-compressed or raw PCM fallback).
* Reliable: agent TTS must arrive complete and IN ORDER. Opus is a stateful
* (inter-frame-predictive) codec, so a dropped or reordered packet on an
* Unreliable channel desyncs the decoder → missing words + crackle. Reliable
* guarantees ordered, lossless delivery, keeping the decoder in sync.
* This does NOT reintroduce the old reliable-buffer overflow (which dropped
* VR connections): that came from 32-152 KB raw-PCM RPCs. With Opus each
* sub-chunk is ~2 KB (2-3 partial bunches, below the 8-bunch reliable-
* promotion threshold) at ~1.25 RPC/s, so in-flight bunches stay in single
* digits vs the 512-bunch buffer. Sub-chunking in HandleAudioReceived is what
* keeps packets this small — it must stay.
* bLastSubChunk marks the final sub-chunk of one ElevenLabs message so the
* client can reassemble all sub-chunks and enqueue ONCE per message (enqueuing
* each sub-chunk separately re-ran the playback state machine → pops). */
UFUNCTION(NetMulticast, Reliable)
void MulticastReceiveAgentAudio(const TArray<uint8>& AudioData);
void MulticastReceiveAgentAudio(const TArray<uint8>& AudioData, bool bLastSubChunk);
/** Notify all clients that the agent started speaking (first audio chunk). */
UFUNCTION(NetMulticast, Reliable)
@@ -543,10 +605,40 @@ public:
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_ConvAgent|ElevenLabs")
void SendTextMessage(const FString& Text);
/**
* Inject a "silent" context update: the agent incorporates it as context but does
* NOT reply to it (unlike SendTextMessage). Used to tell an agent that a new/real
* interlocutor is now speaking while keeping the prior conversation. Authority only.
*/
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_ConvAgent|ElevenLabs")
void SendContextualUpdate(const FString& Text);
/** Interrupt the agent's current utterance. */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_ConvAgent|ElevenLabs")
void InterruptAgent();
// ── Replay support (PS_AI_ConvReplay bridge) ─────────────────────────────
/**
* Play an external PCM buffer through this agent's voice, exactly as if it were
* live TTS: drives audio playback, lip-sync, body expression and start/stop events.
* Role-agnostic (works on a replay/simulated proxy); no LOD culling.
* @param PCM16 int16 samples, 16000 Hz mono, little-endian.
*/
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_ConvAgent|Replay")
void PlayExternalAudioPCM(const TArray<uint8>& PCM16);
/** Stop/flush any audio currently playing (used when scrubbing a replay). */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_ConvAgent|Replay")
void StopExternalAudio();
/** Re-broadcast a recorded perform_action reaction during replay (fires OnAgentActionRequested). */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_ConvAgent|Replay")
void TriggerReplayedAction(const FString& ActionName);
/** Re-broadcast a recorded custom client tool call during replay (fires OnAgentClientToolCall). */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_ConvAgent|Replay")
void TriggerReplayedClientToolCall(const FPS_AI_ConvAgent_ClientToolCall_ElevenLabs& ToolCall);
/**
* Cleanly disable conversation on this agent.
* Ends the WebSocket connection, stops all audio, and begins blending all
@@ -639,6 +731,9 @@ public:
int32 GetMicChunkMinBytesPublic() const;
private:
// Debounce timestamp (seconds) for the OnUserVoiceDetected voice-onset broadcast.
double LastUserVoiceOnset = 0.0;
// ── Network OnRep handlers ───────────────────────────────────────────────
UFUNCTION()
void OnRep_ConversationState();
@@ -782,6 +877,26 @@ private:
int32 AudioQueueReadOffset = 0;
FCriticalSection AudioQueueLock;
// Client-only: accumulates decoded PCM across the N sub-chunks of one
// ElevenLabs message; flushed to EnqueueAgentAudio() once, on the last
// sub-chunk (bLastSubChunk). Game-thread only. Reset in StopAgentAudio().
TArray<uint8> ClientReassemblyPCM;
// Server-only: sub-frame (<640 byte) tail carried from the previous agent
// audio message. Opus encodes whole frames only; without carry-over each
// message dropped its <1-frame tail, leaving a ~10-16ms hole + waveform
// discontinuity at every message splice → audible pops on clients. Reset
// per turn (new-turn guard in HandleAudioReceived + StopAgentAudio()).
TArray<uint8> AgentAudioEncodeLeftover;
// Debug: detects waveform discontinuities at enqueue/message splices (the
// signature of the audio "pops"). Tracks the last enqueued sample; a large
// jump to the next block's first sample is flagged and counted. Toggle with
// ps.ai.ConvAgent.Debug.ElevenLabs. Reset in StopAgentAudio().
int16 LastEnqueuedSample = 0;
bool bHasLastEnqueuedSample = false;
int32 AudioSpliceDiscontinuityCount = 0;
// Pre-buffer state: delay playback start to absorb TTS inter-chunk gaps.
bool bPreBuffering = false;
double PreBufferStartTime = 0.0;
@@ -843,6 +958,11 @@ private:
TArray<uint8> MicAccumulationBuffer;
FCriticalSection MicSendLock;
// Client mic uplink: timestamp (FPlatformTime::Seconds) of the last voiced chunk.
// Used to keep streaming a short silence tail after speech so ElevenLabs Server
// VAD can detect end-of-turn (see MicSilenceTailSeconds). 0 = never voiced yet.
double LastMicVoicedTime = 0.0;
/** Compute the minimum bytes from the user-facing MicChunkDurationMs.
* Formula: bytes = SampleRate * (ms / 1000) * BytesPerSample = 16000 * ms / 1000 * 2 = 32 * ms */
int32 GetMicChunkMinBytes() const { return MicChunkDurationMs * 32; }

View File

@@ -26,6 +26,12 @@ DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnConvAgentDeselected,
/** Fired when no agent is within interaction range/view. */
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnNoConvAgentInRange);
/** Fired on the SERVER for each player mic chunk captured for agent-less replay
* recording (int16, 16kHz mono). Lets an external recorder (PS_AI_ConvReplay)
* save player speech even when no agent is present in the scene. */
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnConvAgentPlayerVoiceForRecording,
APawn*, SpeakerPawn, const TArray<uint8>&, PCMData);
// ─────────────────────────────────────────────────────────────────────────────
// UPS_AI_ConvAgent_InteractionComponent
//
@@ -195,6 +201,11 @@ public:
meta = (ToolTip = "Fires when no agent meets the distance/view criteria."))
FOnNoConvAgentInRange OnNoAgentInRange;
/** Fired on the SERVER for each player mic chunk captured for agent-less replay
* recording. An external recorder (PS_AI_ConvReplay) subscribes to save player voice. */
UPROPERTY(BlueprintAssignable, Category = "ASTERION|PS_AI_ConvAgent|Interaction|Events")
FOnConvAgentPlayerVoiceForRecording OnPlayerVoiceForRecording;
// ── Blueprint API ─────────────────────────────────────────────────────────
/** Get the currently selected agent (null if none). */
@@ -225,6 +236,12 @@ public:
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_ConvAgent|Interaction")
void ClearSelection();
/** Enable/disable capturing this pawn's mic for replay recording even when no
* agent is selected. Server authority only; the flag replicates so the owning
* client starts capturing + relaying its mic. Driven by PS_AI_ConvReplay. */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_ConvAgent|Interaction")
void SetReplayRecordActive(bool bActive);
// ── Network relay RPCs ───────────────────────────────────────────────────
// UE5 clients can only call Server RPCs on actors they own. NPC actors are
// server-owned, so clients can't call RPCs on them directly.
@@ -253,6 +270,11 @@ public:
UFUNCTION(Server, Unreliable)
void ServerRelayMicAudio(AActor* AgentActor, const TArray<uint8>& PCMBytes);
/** Relay: stream this pawn's mic to the server purely for replay recording
* (no agent). The server broadcasts OnPlayerVoiceForRecording for the recorder. */
UFUNCTION(Server, Unreliable)
void ServerRecordVoice(const TArray<uint8>& PCMBytes);
/** Relay: send text message to an NPC agent. */
UFUNCTION(Server, Reliable)
void ServerRelaySendText(AActor* AgentActor, const FString& Text);
@@ -317,6 +339,22 @@ private:
UPROPERTY()
UPS_AI_ConvAgent_MicrophoneCaptureComponent* MicComponent = nullptr;
// ── Agent-less replay recording ──────────────────────────────────────────
/** Replicated: the server sets this while a replay is recording so the owning
* client captures + relays its mic even without a selected agent. */
UPROPERTY(ReplicatedUsing = OnRep_ReplayRecordActive)
bool bReplayRecordActive = false;
UFUNCTION()
void OnRep_ReplayRecordActive();
/** Start/stop mic capture to honour bReplayRecordActive (never stops the mic
* while an agent conversation still needs it). */
void UpdateReplayCapture();
/** Accumulates int16 mic bytes for the recording relay, chunked to limit RPC count. */
TArray<uint8> RecordMicBuffer;
/** True once the one-time lazy init in TickComponent has completed.
* Deferred from BeginPlay because IsLocallyControlled() may return false
* before the PlayerController has been replicated/possessed. */

View File

@@ -177,6 +177,12 @@ public:
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_ConvAgent|ElevenLabs")
void SendTextMessage(const FString& Text);
/**
* Inject a context update WITHOUT triggering an agent response.
* Sends: { "type": "contextual_update", "text": "..." }
*/
void SendContextualUpdate(const FString& Text);
/** Ask the agent to stop the current utterance. */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_ConvAgent|ElevenLabs")
void SendInterrupt();

View File

@@ -35,6 +35,8 @@ public class PS_AI_ConvAgentEditor : ModuleRules
"JsonUtilities",
// Asset Registry for scanning AgentConfig assets (batch update)
"AssetRegistry",
// UDeveloperSettings base class for editor-only API key settings
"DeveloperSettings",
});
}
}

View File

@@ -4,6 +4,7 @@
#include "PS_AI_ConvAgent_ActionSet_ElevenLabs.h"
#include "PS_AI_ConvAgent.h"
#include "PS_AI_ConvAgent_BlueprintLibrary.h"
#include "PS_AI_ConvAgent_EditorSettings_ElevenLabs.h"
#include "DetailLayoutBuilder.h"
#include "DetailCategoryBuilder.h"
@@ -66,8 +67,9 @@ void FPS_AI_ConvAgent_ActionSetCustomization_ElevenLabs::OnUpdateAllAgentsClicke
// ─────────────────────────────────────────────────────────────────────────────
FString FPS_AI_ConvAgent_ActionSetCustomization_ElevenLabs::GetAPIKey() const
{
// Key is client-provided and stored in a SaveGame slot (see BlueprintLibrary).
return UPS_AI_ConvAgent_BlueprintLibrary::GetElevenLabsAPIKey();
// Editor-only authoring key (Project Settings → Editor, or ELEVENLABS_API_KEY env).
// Never committed by a build, never packaged — see UPS_AI_ConvAgent_EditorSettings_ElevenLabs.
return UPS_AI_ConvAgent_EditorSettings_ElevenLabs::ResolveEditorApiKey();
}
UPS_AI_ConvAgent_ActionSet_ElevenLabs* FPS_AI_ConvAgent_ActionSetCustomization_ElevenLabs::GetEditedAsset() const

View File

@@ -5,6 +5,7 @@
#include "PS_AI_ConvAgent_Tool_ElevenLabs.h"
#include "PS_AI_ConvAgent.h"
#include "PS_AI_ConvAgent_BlueprintLibrary.h"
#include "PS_AI_ConvAgent_EditorSettings_ElevenLabs.h"
#include "AssetRegistry/AssetRegistryModule.h"
@@ -426,7 +427,7 @@ void FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs::OnFetchVoicesClicked(
const FString APIKey = GetAPIKey();
if (APIKey.IsEmpty())
{
SetStatusError(TEXT("API Key not set in Project Settings > PS AI ConvAgent - ElevenLabs."));
SetStatusError(TEXT("ElevenLabs API Key not set. Set it in Project Settings > Editor > 'PS AI ConvAgent - ElevenLabs (Editor)' (or the ELEVENLABS_API_KEY env var)."));
return;
}
@@ -632,7 +633,7 @@ void FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs::OnFetchLLMsClicked()
const FString APIKey = GetAPIKey();
if (APIKey.IsEmpty())
{
SetStatusError(TEXT("API Key not set in Project Settings > PS AI ConvAgent - ElevenLabs."));
SetStatusError(TEXT("ElevenLabs API Key not set. Set it in Project Settings > Editor > 'PS AI ConvAgent - ElevenLabs (Editor)' (or the ELEVENLABS_API_KEY env var)."));
return;
}
@@ -918,7 +919,7 @@ void FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs::OnCreateAgentClicked(
const FString APIKey = GetAPIKey();
if (APIKey.IsEmpty())
{
SetStatusError(TEXT("API Key not set in Project Settings."));
SetStatusError(TEXT("ElevenLabs API Key not set. Set it in Project Settings > Editor > 'PS AI ConvAgent - ElevenLabs (Editor)' (or the ELEVENLABS_API_KEY env var)."));
return;
}
@@ -1020,7 +1021,7 @@ void FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs::OnUpdateAgentClicked(
const FString APIKey = GetAPIKey();
if (APIKey.IsEmpty())
{
SetStatusError(TEXT("API Key not set in Project Settings."));
SetStatusError(TEXT("ElevenLabs API Key not set. Set it in Project Settings > Editor > 'PS AI ConvAgent - ElevenLabs (Editor)' (or the ELEVENLABS_API_KEY env var)."));
return;
}
@@ -1108,7 +1109,7 @@ void FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs::OnFetchAgentClicked()
const FString APIKey = GetAPIKey();
if (APIKey.IsEmpty())
{
SetStatusError(TEXT("API Key not set in Project Settings."));
SetStatusError(TEXT("ElevenLabs API Key not set. Set it in Project Settings > Editor > 'PS AI ConvAgent - ElevenLabs (Editor)' (or the ELEVENLABS_API_KEY env var)."));
return;
}
@@ -1509,8 +1510,9 @@ void FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs::OnFetchAgentClicked()
// ─────────────────────────────────────────────────────────────────────────────
FString FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs::GetAPIKey() const
{
// Key is client-provided and stored in a SaveGame slot (see BlueprintLibrary).
return UPS_AI_ConvAgent_BlueprintLibrary::GetElevenLabsAPIKey();
// Editor-only authoring key (Project Settings → Editor, or ELEVENLABS_API_KEY env).
// Never committed by a build, never packaged — see UPS_AI_ConvAgent_EditorSettings_ElevenLabs.
return UPS_AI_ConvAgent_EditorSettings_ElevenLabs::ResolveEditorApiKey();
}
UPS_AI_ConvAgent_AgentConfig_ElevenLabs* FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs::GetEditedAsset() const

View File

@@ -0,0 +1,27 @@
// Copyright ASTERION. All Rights Reserved.
#include "PS_AI_ConvAgent_EditorSettings_ElevenLabs.h"
#include "PS_AI_ConvAgent_BlueprintLibrary.h"
#include "HAL/PlatformMisc.h"
FString UPS_AI_ConvAgent_EditorSettings_ElevenLabs::ResolveEditorApiKey()
{
// 1) Explicit editor setting (Project Settings → Editor).
if (const UPS_AI_ConvAgent_EditorSettings_ElevenLabs* Settings = GetDefault<UPS_AI_ConvAgent_EditorSettings_ElevenLabs>())
{
if (!Settings->EditorApiKey.IsEmpty())
{
return Settings->EditorApiKey;
}
}
// 2) Environment variable (CI / shared machines).
const FString EnvKey = FPlatformMisc::GetEnvironmentVariable(TEXT("ELEVENLABS_API_KEY"));
if (!EnvKey.IsEmpty())
{
return EnvKey;
}
// 3) Runtime client key from the SaveGame, if one was set on this machine.
return UPS_AI_ConvAgent_BlueprintLibrary::GetElevenLabsAPIKey();
}

View File

@@ -0,0 +1,54 @@
// Copyright ASTERION. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "Engine/DeveloperSettings.h"
#include "PS_AI_ConvAgent_EditorSettings_ElevenLabs.generated.h"
/**
* Editor-only settings for ElevenLabs agent authoring.
*
* The API key entered here is used EXCLUSIVELY by the editor (voice/model/LLM
* fetch, agent create/sync, tool & action-set sync). It is:
* - declared in the EDITOR module → never compiled into a packaged game,
* - stored with config = EditorPerProjectUserSettings → written to
* Saved/Config/.../EditorPerProjectUserSettings.ini, which is never cooked
* into a build.
* → The key therefore can never end up in a shipped package.
*
* The shipped game does NOT use this key. The final client provides their own
* key at runtime, persisted in a SaveGame slot (see UPS_AI_ConvAgent_BlueprintLibrary).
*
* Exposed in Project Settings → Editor → "PS AI ConvAgent - ElevenLabs (Editor)".
*/
UCLASS(config = EditorPerProjectUserSettings, defaultconfig,
meta = (DisplayName = "PS AI ConvAgent - ElevenLabs (Editor)"))
class UPS_AI_ConvAgent_EditorSettings_ElevenLabs : public UDeveloperSettings
{
GENERATED_BODY()
public:
/**
* ElevenLabs API key used by the editor to author and sync agents.
* Editor-only — never committed by a build, never packaged.
* Leave empty and set the ELEVENLABS_API_KEY environment variable instead
* if you prefer (useful for CI / shared machines).
*/
UPROPERTY(config, EditAnywhere, Category = "ElevenLabs API",
meta = (DisplayName = "Editor API Key",
ToolTip = "ElevenLabs API key used ONLY by the editor for authoring/syncing agents.\nStored per-user under Saved/Config - never committed by a build, never packaged.\nThe shipped game uses the client-provided runtime key instead.\nAlternatively, leave empty and set the ELEVENLABS_API_KEY environment variable."))
FString EditorApiKey;
/** Show under the "Editor" section of Project Settings (not "Game"/"Plugins"). */
virtual FName GetCategoryName() const override { return FName(TEXT("Editor")); }
/**
* Resolve the API key used by editor authoring, in priority order:
* 1) the EditorApiKey above,
* 2) the ELEVENLABS_API_KEY environment variable (CI / shared machines),
* 3) the runtime client key from the SaveGame, if one happens to exist locally.
* Returns an empty string if none is set.
*/
static FString ResolveEditorApiKey();
};

View File

@@ -6,6 +6,7 @@
#include "PS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs.h"
#include "PS_AI_ConvAgent.h"
#include "PS_AI_ConvAgent_BlueprintLibrary.h"
#include "PS_AI_ConvAgent_EditorSettings_ElevenLabs.h"
#include "DetailLayoutBuilder.h"
#include "DetailCategoryBuilder.h"
@@ -243,7 +244,7 @@ void FPS_AI_ConvAgent_ToolCustomization_ElevenLabs::OnCreateToolClicked()
const FString APIKey = GetAPIKey();
if (APIKey.IsEmpty())
{
SetStatusError(TEXT("API Key not set in Project Settings."));
SetStatusError(TEXT("ElevenLabs API Key not set. Set it in Project Settings > Editor > 'PS AI ConvAgent - ElevenLabs (Editor)' (or the ELEVENLABS_API_KEY env var)."));
return;
}
@@ -354,7 +355,7 @@ void FPS_AI_ConvAgent_ToolCustomization_ElevenLabs::OnUpdateToolClicked()
const FString APIKey = GetAPIKey();
if (APIKey.IsEmpty())
{
SetStatusError(TEXT("API Key not set in Project Settings."));
SetStatusError(TEXT("ElevenLabs API Key not set. Set it in Project Settings > Editor > 'PS AI ConvAgent - ElevenLabs (Editor)' (or the ELEVENLABS_API_KEY env var)."));
return;
}
@@ -444,7 +445,7 @@ void FPS_AI_ConvAgent_ToolCustomization_ElevenLabs::OnFetchToolClicked()
const FString APIKey = GetAPIKey();
if (APIKey.IsEmpty())
{
SetStatusError(TEXT("API Key not set in Project Settings."));
SetStatusError(TEXT("ElevenLabs API Key not set. Set it in Project Settings > Editor > 'PS AI ConvAgent - ElevenLabs (Editor)' (or the ELEVENLABS_API_KEY env var)."));
return;
}
@@ -661,7 +662,7 @@ void FPS_AI_ConvAgent_ToolCustomization_ElevenLabs::OnUpdateAllAgentsClicked()
const FString APIKey = GetAPIKey();
if (APIKey.IsEmpty())
{
SetStatusError(TEXT("API Key not set in Project Settings > PS AI ConvAgent - ElevenLabs."));
SetStatusError(TEXT("ElevenLabs API Key not set. Set it in Project Settings > Editor > 'PS AI ConvAgent - ElevenLabs (Editor)' (or the ELEVENLABS_API_KEY env var)."));
return;
}
@@ -821,8 +822,9 @@ void FPS_AI_ConvAgent_ToolCustomization_ElevenLabs::OnUpdateAllAgentsClicked()
// ─────────────────────────────────────────────────────────────────────────────
FString FPS_AI_ConvAgent_ToolCustomization_ElevenLabs::GetAPIKey() const
{
// Key is client-provided and stored in a SaveGame slot (see BlueprintLibrary).
return UPS_AI_ConvAgent_BlueprintLibrary::GetElevenLabsAPIKey();
// Editor-only authoring key (Project Settings → Editor, or ELEVENLABS_API_KEY env).
// Never committed by a build, never packaged — see UPS_AI_ConvAgent_EditorSettings_ElevenLabs.
return UPS_AI_ConvAgent_EditorSettings_ElevenLabs::ResolveEditorApiKey();
}
UPS_AI_ConvAgent_Tool_ElevenLabs* FPS_AI_ConvAgent_ToolCustomization_ElevenLabs::GetEditedAsset() const

View File

@@ -0,0 +1,30 @@
{
"FileVersion": 3,
"Version": 1,
"VersionName": "1.0",
"FriendlyName": "PS AI ConvReplay",
"Description": "Bridge that records ElevenLabs agent + player conversation audio (and AI reactions) into Unreal replays, and plays it back during replay. Keeps PS_AI_ConvAgent and PS_ReplaySystem independent.",
"Category": "ASTERION",
"CreatedBy": "ASTERION",
"CanContainContent": false,
"IsBetaVersion": false,
"Installed": false,
"EnabledByDefault": false,
"Modules": [
{
"Name": "PS_AI_ConvReplay",
"Type": "Runtime",
"LoadingPhase": "Default"
}
],
"Plugins": [
{
"Name": "PS_AI_ConvAgent",
"Enabled": true
},
{
"Name": "ReplaySystem",
"Enabled": true
}
]
}

View File

@@ -0,0 +1,29 @@
// Copyright ASTERION. All Rights Reserved.
using UnrealBuildTool;
public class PS_AI_ConvReplay : ModuleRules
{
public PS_AI_ConvReplay(ReadOnlyTargetRules Target) : base(Target)
{
DefaultBuildSettings = BuildSettingsVersion.Latest;
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
PublicDependencyModuleNames.AddRange(new string[]
{
"Core",
"CoreUObject",
"Engine",
// Opus voice codec (own encoder/decoder instances — not shared with ConvAgent).
"Voice",
// The conversational-agent plugin whose delegates we tap and whose audio we replay.
"PS_AI_ConvAgent",
// Native replay wrapper (record/playback state, demo time, save path).
"ReplaySystem",
});
PrivateDependencyModuleNames.AddRange(new string[]
{
});
}
}

View File

@@ -0,0 +1,117 @@
// Copyright ASTERION. All Rights Reserved.
#include "PS_AI_ConvReplay_AgentTag.h"
#include "PS_AI_ConvReplay_Subsystem.h"
#include "GameFramework/Actor.h"
UPS_AI_ConvReplay_AgentTag::UPS_AI_ConvReplay_AgentTag()
{
PrimaryComponentTick.bCanEverTick = false;
// Exists on server (recording) and on replay clients (identity lookup). It is not
// itself replicated — the owner agent already replicates; the tag only needs to be
// present on both ends with the same resolved id.
SetIsReplicatedByDefault(false);
}
FString UPS_AI_ConvReplay_AgentTag::GetResolvedId() const
{
if (!AgentReplayId.IsNone())
{
return AgentReplayId.ToString();
}
return GetOwner() ? GetOwner()->GetName() : FString();
}
UPS_AI_ConvAgent_ElevenLabsComponent* UPS_AI_ConvReplay_AgentTag::GetAgentComponent() const
{
if (AActor* Owner = GetOwner())
{
return Owner->FindComponentByClass<UPS_AI_ConvAgent_ElevenLabsComponent>();
}
return nullptr;
}
void UPS_AI_ConvReplay_AgentTag::BeginRecording(UPS_AI_ConvReplay_Subsystem* InSubsystem)
{
Subsystem = InSubsystem;
Agent = GetAgentComponent();
if (!Agent || bBound)
{
return;
}
Agent->OnAgentStartedSpeaking.AddDynamic(this, &UPS_AI_ConvReplay_AgentTag::HandleStarted);
Agent->OnAgentStoppedSpeaking.AddDynamic(this, &UPS_AI_ConvReplay_AgentTag::HandleStopped);
Agent->OnAgentActionRequested.AddDynamic(this, &UPS_AI_ConvReplay_AgentTag::HandleActionRequested);
Agent->OnAgentClientToolCall.AddDynamic(this, &UPS_AI_ConvReplay_AgentTag::HandleClientToolCall);
Agent->OnAgentTextResponse.AddDynamic(this, &UPS_AI_ConvReplay_AgentTag::HandleTextResponse);
Agent->OnAgentTranscript.AddDynamic(this, &UPS_AI_ConvReplay_AgentTag::HandleTranscript);
Agent->OnPlayerAudioCaptured.AddDynamic(this, &UPS_AI_ConvReplay_AgentTag::HandlePlayerAudio);
// Raw PCM is a non-dynamic C++ multicast delegate.
AudioDataHandle = Agent->OnAgentAudioData.AddUObject(this, &UPS_AI_ConvReplay_AgentTag::HandleAgentAudioData);
bBound = true;
}
void UPS_AI_ConvReplay_AgentTag::EndRecording()
{
if (Agent && bBound)
{
Agent->OnAgentStartedSpeaking.RemoveDynamic(this, &UPS_AI_ConvReplay_AgentTag::HandleStarted);
Agent->OnAgentStoppedSpeaking.RemoveDynamic(this, &UPS_AI_ConvReplay_AgentTag::HandleStopped);
Agent->OnAgentActionRequested.RemoveDynamic(this, &UPS_AI_ConvReplay_AgentTag::HandleActionRequested);
Agent->OnAgentClientToolCall.RemoveDynamic(this, &UPS_AI_ConvReplay_AgentTag::HandleClientToolCall);
Agent->OnAgentTextResponse.RemoveDynamic(this, &UPS_AI_ConvReplay_AgentTag::HandleTextResponse);
Agent->OnAgentTranscript.RemoveDynamic(this, &UPS_AI_ConvReplay_AgentTag::HandleTranscript);
Agent->OnPlayerAudioCaptured.RemoveDynamic(this, &UPS_AI_ConvReplay_AgentTag::HandlePlayerAudio);
Agent->OnAgentAudioData.Remove(AudioDataHandle);
}
AudioDataHandle.Reset();
bBound = false;
Subsystem = nullptr;
}
void UPS_AI_ConvReplay_AgentTag::HandleStarted()
{
if (Subsystem) Subsystem->RecordAgentStarted(this);
}
void UPS_AI_ConvReplay_AgentTag::HandleStopped()
{
if (Subsystem) Subsystem->RecordAgentStopped(this);
}
void UPS_AI_ConvReplay_AgentTag::HandleActionRequested(const FString& ActionName)
{
if (Subsystem) Subsystem->RecordAction(this, ActionName);
}
void UPS_AI_ConvReplay_AgentTag::HandleClientToolCall(const FPS_AI_ConvAgent_ClientToolCall_ElevenLabs& ToolCall)
{
if (Subsystem) Subsystem->RecordToolCall(this, ToolCall);
}
void UPS_AI_ConvReplay_AgentTag::HandleTextResponse(const FString& Text)
{
if (Subsystem) Subsystem->RecordAgentText(this, Text);
}
void UPS_AI_ConvReplay_AgentTag::HandleTranscript(const FPS_AI_ConvAgent_TranscriptSegment_ElevenLabs& Segment)
{
// Only final user transcripts become player-text markers.
if (Subsystem && Segment.Speaker == TEXT("user") && Segment.bIsFinal)
{
Subsystem->RecordUserTranscript(this, Segment.Text);
}
}
void UPS_AI_ConvReplay_AgentTag::HandlePlayerAudio(APawn* SpeakerPawn, const TArray<uint8>& PCM)
{
if (Subsystem) Subsystem->RecordPlayerAudio(SpeakerPawn, PCM);
}
void UPS_AI_ConvReplay_AgentTag::HandleAgentAudioData(const TArray<uint8>& PCM)
{
if (Subsystem) Subsystem->RecordAgentAudio(this, PCM);
}

View File

@@ -0,0 +1,5 @@
// Copyright ASTERION. All Rights Reserved.
#include "Modules/ModuleManager.h"
IMPLEMENT_MODULE(FDefaultModuleImpl, PS_AI_ConvReplay);

View File

@@ -0,0 +1,71 @@
// Copyright ASTERION. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
// Pulls in the ElevenLabs component + FPS_AI_ConvAgent_ClientToolCall_ElevenLabs /
// FPS_AI_ConvAgent_TranscriptSegment_ElevenLabs (needed for the UFUNCTION handler signatures).
#include "PS_AI_ConvAgent_ElevenLabsComponent.h"
#include "PS_AI_ConvReplay_AgentTag.generated.h"
class UPS_AI_ConvReplay_Subsystem;
// ─────────────────────────────────────────────────────────────────────────────
// Per-agent bridge component.
//
// Add one to each conversational agent to give it a stable, replay-safe identity
// (AgentReplayId). During recording it binds the sibling ElevenLabs component's
// delegates and forwards every conversation event to the subsystem, tagged with
// this agent's identity. During replay the subsystem looks the agent up by id to
// route recorded audio / reactions back to it.
//
// If you don't add one, the subsystem auto-provisions a tag on every agent at
// record/replay start, using the owner actor name as the id (fine for
// placed-in-level agents; add an explicit tag for runtime-spawned ones).
// ─────────────────────────────────────────────────────────────────────────────
UCLASS(ClassGroup = "ASTERION|PS_AI_ConvReplay", meta = (BlueprintSpawnableComponent),
DisplayName = "PS AI ConvReplay - Agent Tag")
class PS_AI_CONVREPLAY_API UPS_AI_ConvReplay_AgentTag : public UActorComponent
{
GENERATED_BODY()
public:
UPS_AI_ConvReplay_AgentTag();
/** Stable id used to match recorded audio/reactions back to this agent on replay.
* Leave as None to fall back to the owner actor name (stable for placed agents). */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_AI_ConvReplay")
FName AgentReplayId;
/** Resolved identity string (AgentReplayId if set, else the owner actor name). */
FString GetResolvedId() const;
/** The sibling ElevenLabs component on the owner actor (may be null). */
UPS_AI_ConvAgent_ElevenLabsComponent* GetAgentComponent() const;
/** Bind the sibling component's delegates and start forwarding events. Called by the subsystem. */
void BeginRecording(UPS_AI_ConvReplay_Subsystem* InSubsystem);
/** Unbind delegates and stop forwarding. Called by the subsystem. */
void EndRecording();
private:
UPROPERTY(Transient) TObjectPtr<UPS_AI_ConvReplay_Subsystem> Subsystem;
UPROPERTY(Transient) TObjectPtr<UPS_AI_ConvAgent_ElevenLabsComponent> Agent;
FDelegateHandle AudioDataHandle;
bool bBound = false;
// Dynamic-delegate handlers (must be UFUNCTION and match the delegate signatures).
UFUNCTION() void HandleStarted();
UFUNCTION() void HandleStopped();
UFUNCTION() void HandleActionRequested(const FString& ActionName);
UFUNCTION() void HandleClientToolCall(const FPS_AI_ConvAgent_ClientToolCall_ElevenLabs& ToolCall);
UFUNCTION() void HandleTextResponse(const FString& Text);
UFUNCTION() void HandleTranscript(const FPS_AI_ConvAgent_TranscriptSegment_ElevenLabs& Segment);
UFUNCTION() void HandlePlayerAudio(APawn* SpeakerPawn, const TArray<uint8>& PCM);
// Non-dynamic (raw C++ multicast) handler for agent PCM.
void HandleAgentAudioData(const TArray<uint8>& PCM);
};

View File

@@ -0,0 +1,173 @@
// Copyright ASTERION. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "Subsystems/WorldSubsystem.h"
#include "PS_AI_ConvReplay_Types.h"
#include "PS_AI_ConvReplay_Subsystem.generated.h"
class UPS_AI_ConvReplay_AgentTag;
class UPS_AI_ConvAgent_ElevenLabsComponent;
class UPS_AI_ConvAgent_InteractionComponent;
class UAudioComponent;
class APawn;
class IVoiceEncoder;
class IVoiceDecoder;
struct FPS_AI_ConvAgent_ClientToolCall_ElevenLabs;
/** Fired on the replay client when a recorded line's timestamp is reached.
* Use it to drive subtitles / a conversation log. Audio playback is automatic. */
DECLARE_DYNAMIC_MULTICAST_DELEGATE_FourParams(FPS_AI_ConvReplay_OnReplayLinePlayed,
FName, AgentId, const FString&, Speaker, const FString&, Text, float, TimeSec);
// ─────────────────────────────────────────────────────────────────────────────
// World subsystem that records the conversation audio track while a replay is
// being recorded, and plays it back while a replay is being played.
//
// • Record: taps every agent's ElevenLabs delegates (via AgentTag), accumulates
// PCM per speaker, Opus-encodes each utterance and appends it to an external
// sidecar file (<ReplayName>.convreplay) next to the .replay — nothing heavy
// goes into the demo stream. Also records AI reactions (perform_action / custom
// tools) and player transcripts.
// • Replay: loads the sidecar, and each tick fires the records whose timestamp is
// reached — agent audio through the agent's own voice (drives lip-sync + body
// expression), player audio spatialized at the recorded location, reactions
// re-broadcast. Gaze / emotion / facial expression replay for free (replicated).
// ─────────────────────────────────────────────────────────────────────────────
UCLASS()
class PS_AI_CONVREPLAY_API UPS_AI_ConvReplay_Subsystem : public UTickableWorldSubsystem
{
GENERATED_BODY()
public:
// USubsystem / UWorldSubsystem
virtual void Initialize(FSubsystemCollectionBase& Collection) override;
virtual void Deinitialize() override;
virtual bool DoesSupportWorldType(const EWorldType::Type WorldType) const override;
// FTickableGameObject
virtual void Tick(float DeltaTime) override;
virtual TStatId GetStatId() const override;
/** Fired on the replay client for each recorded line as its timestamp is reached. */
UPROPERTY(BlueprintAssignable, Category = "ASTERION|PS_AI_ConvReplay")
FPS_AI_ConvReplay_OnReplayLinePlayed OnReplayLinePlayed;
// ── Record callbacks (invoked by AgentTag on the recording machine) ──────
void RecordAgentStarted(UPS_AI_ConvReplay_AgentTag* Tag);
void RecordAgentAudio(UPS_AI_ConvReplay_AgentTag* Tag, const TArray<uint8>& PCM);
void RecordAgentText(UPS_AI_ConvReplay_AgentTag* Tag, const FString& Text);
void RecordAgentStopped(UPS_AI_ConvReplay_AgentTag* Tag);
void RecordAction(UPS_AI_ConvReplay_AgentTag* Tag, const FString& ActionName);
void RecordToolCall(UPS_AI_ConvReplay_AgentTag* Tag, const FPS_AI_ConvAgent_ClientToolCall_ElevenLabs& ToolCall);
void RecordUserTranscript(UPS_AI_ConvReplay_AgentTag* Tag, const FString& Text);
void RecordPlayerAudio(APawn* SpeakerPawn, const TArray<uint8>& PCM);
private:
// Session transitions.
void BeginRecordingSession();
void EndRecordingSession();
void BeginReplaySession();
void EndReplaySession();
void TickRecording();
void TickReplay();
// Agent-less player-voice capture (InteractionComponents on player pawns).
void RefreshRecordingMics();
UFUNCTION()
void HandlePlayerVoiceForRecording(APawn* SpeakerPawn, const TArray<uint8>& PCM);
// Agent discovery / lookup.
void RebuildTagList();
UPS_AI_ConvReplay_AgentTag* FindTagById(const FString& Id);
// Robust match for a recorded agent record: exact id first (placed agents /
// explicit AgentReplayId), else the nearest agent to the recorded position
// (handles runtime-spawned agents whose actor names differ on replay).
UPS_AI_ConvReplay_AgentTag* FindAgentForRecord(const FString& Id, const FVector& Pos);
// Sidecar IO.
FString GetSidecarPath() const;
void WriteRecord(FPS_AI_ConvReplay_Record& Rec);
void LoadRecords();
// Opus (own instances — not shared with ConvAgent's live encoder).
void EnsureCodec();
bool EncodeUtterance(const TArray<uint8>& PCM, TArray<uint8>& OutPackets);
bool DecodeUtterance(const TArray<uint8>& Packets, TArray<uint8>& OutPCM);
// Replay dispatch.
void DispatchDueRecords(double NowSec, bool bAudioAllowed);
void ResetPlaybackFrom(double NowSec);
void PlayPlayerAudioAtLocation(const FVector& Loc, const TArray<uint8>& PCM);
// Helpers.
double NowDemoSeconds() const;
void FlushAgentSegment(UPS_AI_ConvReplay_AgentTag* Tag); // write + reset an agent utterance
void FlushPlayerSegment(APawn* Pawn);
static bool IsVoiced(const TArray<uint8>& PCM16);
static FString ResolvePlayerId(APawn* Pawn);
// Boost quiet mic capture: normalize a replayed player utterance toward a target
// RMS (peak-safe), then apply the ps.convreplay.PlayerGain CVar. In-place on int16 PCM.
static void NormalizePlayerPcm(TArray<uint8>& Pcm16);
// ── State ────────────────────────────────────────────────────────────────
bool bRecording = false;
bool bPlaying = false;
double LastReplayTime = -1.0;
IFileHandle* WriteHandle = nullptr;
TSharedPtr<IVoiceEncoder> Encoder;
TSharedPtr<IVoiceDecoder> Decoder;
UPROPERTY(Transient)
TArray<TObjectPtr<UPS_AI_ConvReplay_AgentTag>> Tags;
// Player InteractionComponents currently armed for agent-less voice recording.
UPROPERTY(Transient)
TArray<TObjectPtr<UPS_AI_ConvAgent_InteractionComponent>> RecordingMicComps;
// Per-agent recording accumulator.
struct FAgentAccum
{
double StartTime = 0.0;
double LastAudioTime = 0.0; // demo time of the last appended chunk (gap detection)
FVector Pos = FVector::ZeroVector;
FString Text;
TArray<uint8> PCM;
bool bOpen = false;
};
TMap<TWeakObjectPtr<UPS_AI_ConvReplay_AgentTag>, FAgentAccum> AgentAccum;
// Per-player recording accumulator (segmented by silence).
struct FPlayerAccum
{
double StartTime = 0.0;
double LastVoiceTime = 0.0;
FVector Pos = FVector::ZeroVector;
FString Id;
TArray<uint8> PCM;
bool bActive = false;
};
TMap<TWeakObjectPtr<APawn>, FPlayerAccum> PlayerAccum;
// Loaded conversation track (replay).
TArray<FPS_AI_ConvReplay_Record> Records;
// Transient one-shot components used to play back player voices during replay.
UPROPERTY(Transient)
TArray<TObjectPtr<UAudioComponent>> PlayerAudioPool;
// Tunables.
static constexpr float AgentGapSplitSeconds = 1.5f; // split an agent utterance if audio resumes after this gap (greeting vs reply)
static constexpr float PlayerSilenceFlushSec = 0.6f;
static constexpr float VoicedRmsThreshold = 0.02f;
static constexpr float ScrubBackThreshold = 0.05f; // seconds jumped back => scrub
static constexpr float ScrubFwdThreshold = 1.5f; // seconds jumped forward => scrub / fast-seek
static constexpr float NormalSpeedTolerance = 0.35f; // only play audio within this of 1x
// Target loudness (RMS, 1..1) is the live CVar ps.convreplay.PlayerTargetRms (default 0.14).
static constexpr float PlayerMinGain = 0.1f; // allow attenuating LOUD utterances (bidirectional AGC)
static constexpr float PlayerMaxGain = 16.0f; // cap so near-silence isn't blown up
};

View File

@@ -0,0 +1,70 @@
// Copyright ASTERION. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
// ─────────────────────────────────────────────────────────────────────────────
// One entry in the conversation sidecar track (<ReplayName>.convreplay).
//
// The sidecar is an append-only sequence of these records, each serialized
// back-to-back with FMemoryWriter (self-delimiting via FString/TArray length
// prefixes) so it can be streamed while recording and read in one pass at replay.
// Audio is stored as a sequence of length-prefixed Opus packets (see the
// subsystem's EncodeUtterance/DecodeUtterance), NOT inside the demo metadata.
// ─────────────────────────────────────────────────────────────────────────────
enum class EPS_AI_ConvReplay_Kind : uint8
{
AgentSpeech = 0, // an agent utterance: Audio + Text, played through the agent's voice
PlayerSpeech = 1, // a player utterance: Audio, played spatialized at Position
Action = 2, // a perform_action reaction: Text = action name
ToolCall = 3, // a custom client tool call: Text = tool name, Params = parameters
PlayerText = 4, // a player transcript marker (subtitle only, no audio)
};
struct FPS_AI_ConvReplay_Record
{
/** EPS_AI_ConvReplay_Kind. */
uint8 Kind = 0;
/** Start time in demo seconds (captured via GetCurrentReplayTime at record time). */
double TimeSec = 0.0;
/** Audio duration in seconds (0 for non-audio records). */
double DurationSec = 0.0;
/** Agent id (resolved from AgentTag / actor name) or player id. */
FString Id;
/** 0 = agent, 1 = user. */
uint8 Speaker = 0;
/** Transcript, or action name, or tool name depending on Kind. */
FString Text;
/** World location of the speaker at utterance start (used to spatialize player audio). */
FVector Position = FVector::ZeroVector;
/** Tool-call parameters (empty otherwise). */
TMap<FString, FString> Params;
/** Sequence of length-prefixed Opus packets (empty for non-audio records). */
TArray<uint8> Audio;
/** Runtime-only: set once this record has been dispatched during playback. NOT serialized. */
bool bPlayed = false;
friend FArchive& operator<<(FArchive& Ar, FPS_AI_ConvReplay_Record& R)
{
Ar << R.Kind;
Ar << R.TimeSec;
Ar << R.DurationSec;
Ar << R.Id;
Ar << R.Speaker;
Ar << R.Text;
Ar << R.Position;
Ar << R.Params;
Ar << R.Audio;
return Ar;
}
};