Compare commits
19 Commits
8ae73bbacb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f35c134aa | |||
| 086311da38 | |||
| c783613117 | |||
| d92e402722 | |||
| 231ca4145d | |||
| 087c83018c | |||
| f49a57f892 | |||
| 6d92ec3c26 | |||
| bcb6c44f57 | |||
| cf83a2302d | |||
| 5b3a11a733 | |||
| 2ca5643f32 | |||
| 4854a7fa9a | |||
| 41900991ef | |||
| efd9921fde | |||
| fee7d0c679 | |||
| 777ffa09a8 | |||
| ab2482a209 | |||
| c2ca2e3dbb |
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
Binary file not shown.
@@ -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();
|
||||
}
|
||||
@@ -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,9 +964,22 @@ 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.
|
||||
{
|
||||
return;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
uint32 CompressedSize = static_cast<uint32>(OpusWorkBuffer.Num());
|
||||
OpusEncoder->Encode(PCMData.GetData(), PCMData.Num(),
|
||||
OpusWorkBuffer.GetData(), CompressedSize);
|
||||
// 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
|
||||
|
||||
if (CompressedSize > 0)
|
||||
// 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)
|
||||
{
|
||||
TArray<uint8> CompressedData;
|
||||
CompressedData.Append(OpusWorkBuffer.GetData(), CompressedSize);
|
||||
MulticastReceiveAgentAudio(CompressedData);
|
||||
// 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(ContinuousPCM.GetData() + Offset, BlockBytes,
|
||||
OpusWorkBuffer.GetData(), CompressedSize);
|
||||
|
||||
if (CompressedSize > 0)
|
||||
{
|
||||
TArray<uint8> CompressedData;
|
||||
CompressedData.Append(OpusWorkBuffer.GetData(), CompressedSize);
|
||||
|
||||
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)
|
||||
// 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 (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)
|
||||
{
|
||||
bWarnedOnce = true;
|
||||
UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Warning,
|
||||
TEXT("[NET-SRV] Opus encoder unavailable — sending raw PCM in chunks (no compression)."));
|
||||
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,16 +2216,33 @@ 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();
|
||||
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());
|
||||
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
|
||||
{
|
||||
@@ -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,16 +2311,22 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::OnRep_ActiveSpeaker()
|
||||
AActor* Owner = GetOwner();
|
||||
if (!Owner) return;
|
||||
|
||||
if (auto* Gaze = Owner->FindComponentByClass<UPS_AI_ConvAgent_GazeComponent>())
|
||||
// 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 (NetActiveSpeakerPawn)
|
||||
if (auto* Gaze = Owner->FindComponentByClass<UPS_AI_ConvAgent_GazeComponent>())
|
||||
{
|
||||
Gaze->TargetActor = NetActiveSpeakerPawn;
|
||||
Gaze->ResetBodyTarget();
|
||||
UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Log,
|
||||
TEXT("[NET-REP] ActiveSpeaker changed to %s"), *NetActiveSpeakerPawn->GetName());
|
||||
if (NetActiveSpeakerPawn)
|
||||
{
|
||||
Gaze->TargetActor = NetActiveSpeakerPawn;
|
||||
Gaze->ResetBodyTarget();
|
||||
UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Log,
|
||||
TEXT("[NET-REP] ActiveSpeaker changed to %s"), *NetActiveSpeakerPawn->GetName());
|
||||
}
|
||||
// Don't clear gaze when null — keep looking at last speaker.
|
||||
}
|
||||
// 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);
|
||||
|
||||
// Feed lip-sync (within LOD or speaker).
|
||||
if (bIsSpeaker || LipSyncLODDistance <= 0.f || Dist <= LipSyncLODDistance)
|
||||
// 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)
|
||||
{
|
||||
OnAgentAudioData.Broadcast(PCMBuffer);
|
||||
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(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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Agent->FeedExternalAudio(FloatPCM);
|
||||
// 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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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; }
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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[]
|
||||
{
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Copyright ASTERION. All Rights Reserved.
|
||||
|
||||
#include "Modules/ModuleManager.h"
|
||||
|
||||
IMPLEMENT_MODULE(FDefaultModuleImpl, PS_AI_ConvReplay);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user