Compare commits

...

9 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
19 changed files with 2751 additions and 28 deletions

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,26 @@ 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)

View File

@@ -206,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;

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;
@@ -1248,12 +1286,26 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::HandleAudioReceived(const TArray<uint
* static_cast<int32>(sizeof(int16)); // 640 at 16kHz mono
static constexpr int32 MaxOpusChunkBytes = 40 * BytesPerFrame; // 25600 = 0.8s
// Frame-align the message length (Encode drops any sub-frame remainder
// anyway). A frame-aligned length guarantees every sub-chunk holds >=1
// whole frame and lets us reliably mark the FINAL sub-chunk (bLast), so
// the client reassembles one message into a single enqueue (no per-chunk
// playback re-arm → no pops).
const int32 UsableBytes = (PCMData.Num() / BytesPerFrame) * BytesPerFrame;
// 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)
@@ -1262,7 +1314,7 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::HandleAudioReceived(const TArray<uint
const bool bLast = (Offset + BlockBytes >= UsableBytes);
uint32 CompressedSize = static_cast<uint32>(OpusWorkBuffer.Num());
OpusEncoder->Encode(PCMData.GetData() + Offset, BlockBytes,
OpusEncoder->Encode(ContinuousPCM.GetData() + Offset, BlockBytes,
OpusWorkBuffer.GetData(), CompressedSize);
if (CompressedSize > 0)
@@ -1718,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);
@@ -1892,7 +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(); // Drop any half-received message so it can't leak into the next turn.
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();
@@ -2102,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);
}
@@ -2125,7 +2216,18 @@ 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.
@@ -2209,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)
@@ -2220,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);
}
@@ -2332,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)
@@ -2369,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();
}
}
@@ -2413,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)
@@ -2623,6 +2767,45 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::MulticastReceiveAgentAudio_Implementa
}
}
// ─────────────────────────────────────────────────────────────────────────────
// 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()
{
if (GetOwnerRole() == ROLE_Authority) return;
@@ -2799,7 +2982,18 @@ 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.
@@ -2839,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;

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.
@@ -568,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
@@ -664,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();
@@ -812,6 +882,21 @@ private:
// 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;

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

@@ -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;
}
};