Compare commits

...

3 Commits

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 12:21:19 +02:00
5 changed files with 323 additions and 50 deletions

View File

@@ -255,6 +255,14 @@ void APS_AI_ConvAgent_AgentDialogue::InterruptForPlayer(EFloor Engaged)
{ {
W->GetTimerManager().ClearTimer(TurnTimer); 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 // 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). // witness instead of forwarding (their dialogue logic no-ops while !bRunning).

View File

@@ -21,6 +21,17 @@ static TAutoConsoleVariable<int32> CVarDebugInteraction(
TEXT("Debug HUD for Interaction. -1=use property, 0=off, 1-3=verbosity."), TEXT("Debug HUD for Interaction. -1=use property, 0=off, 1-3=verbosity."),
ECVF_Default); 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 // Constructor
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -132,6 +143,9 @@ void UPS_AI_ConvAgent_InteractionComponent::TickComponent(float DeltaTime, ELeve
bInitialized = true; bInitialized = true;
// If a replay recording started before the mic existed, begin capturing now.
UpdateReplayCapture();
if (bDebug) if (bDebug)
{ {
UE_LOG(LogPS_AI_ConvAgent_Select, Log, UE_LOG(LogPS_AI_ConvAgent_Select, Log,
@@ -1062,10 +1076,34 @@ void UPS_AI_ConvAgent_InteractionComponent::CleanupRetainedGaze(UPS_AI_ConvAgent
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
void UPS_AI_ConvAgent_InteractionComponent::OnMicAudioCaptured(const TArray<float>& FloatPCM) void UPS_AI_ConvAgent_InteractionComponent::OnMicAudioCaptured(const TArray<float>& FloatPCM)
{ {
UPS_AI_ConvAgent_ElevenLabsComponent* Agent = SelectedAgent.Get(); if (UPS_AI_ConvAgent_ElevenLabsComponent* Agent = SelectedAgent.Get())
if (!Agent) return; {
Agent->FeedExternalAudio(FloatPCM); Agent->FeedExternalAudio(FloatPCM);
return;
}
// No agent selected: if a replay is being recorded, still capture this pawn's
// voice for the recorder. Convert to int16, accumulate, and relay in chunks —
// the host broadcasts locally; a remote client sends a Server RPC.
if (!bReplayRecordActive) return;
TArray<uint8> Bytes;
ConvReplay_FloatToInt16Bytes(FloatPCM, Bytes);
RecordMicBuffer.Append(Bytes);
static constexpr int32 RecordChunkMinBytes = 3200; // ~100ms @ 16kHz mono int16
if (RecordMicBuffer.Num() >= RecordChunkMinBytes)
{
if (GetOwnerRole() == ROLE_Authority)
{
OnPlayerVoiceForRecording.Broadcast(Cast<APawn>(GetOwner()), RecordMicBuffer);
}
else
{
ServerRecordVoice(RecordMicBuffer);
}
RecordMicBuffer.Reset();
}
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -1154,7 +1192,7 @@ void UPS_AI_ConvAgent_InteractionComponent::GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps) const TArray<FLifetimeProperty>& OutLifetimeProps) const
{ {
Super::GetLifetimeReplicatedProps(OutLifetimeProps); Super::GetLifetimeReplicatedProps(OutLifetimeProps);
// No replicated properties — but the component must be replicated for RPCs. DOREPLIFETIME(UPS_AI_ConvAgent_InteractionComponent, bReplayRecordActive);
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -1231,6 +1269,50 @@ void UPS_AI_ConvAgent_InteractionComponent::ServerRelayMicAudio_Implementation(
Agent->ServerSendMicAudioFromPlayer(SenderPawn, PCMToSend); 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( void UPS_AI_ConvAgent_InteractionComponent::ServerRelaySendText_Implementation(
AActor* AgentActor, const FString& Text) AActor* AgentActor, const FString& Text)
{ {

View File

@@ -26,6 +26,12 @@ DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnConvAgentDeselected,
/** Fired when no agent is within interaction range/view. */ /** Fired when no agent is within interaction range/view. */
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnNoConvAgentInRange); 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 // UPS_AI_ConvAgent_InteractionComponent
// //
@@ -195,6 +201,11 @@ public:
meta = (ToolTip = "Fires when no agent meets the distance/view criteria.")) meta = (ToolTip = "Fires when no agent meets the distance/view criteria."))
FOnNoConvAgentInRange OnNoAgentInRange; 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 ───────────────────────────────────────────────────────── // ── Blueprint API ─────────────────────────────────────────────────────────
/** Get the currently selected agent (null if none). */ /** Get the currently selected agent (null if none). */
@@ -225,6 +236,12 @@ public:
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_ConvAgent|Interaction") UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_ConvAgent|Interaction")
void ClearSelection(); 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 ─────────────────────────────────────────────────── // ── Network relay RPCs ───────────────────────────────────────────────────
// UE5 clients can only call Server RPCs on actors they own. NPC actors are // 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. // server-owned, so clients can't call RPCs on them directly.
@@ -253,6 +270,11 @@ public:
UFUNCTION(Server, Unreliable) UFUNCTION(Server, Unreliable)
void ServerRelayMicAudio(AActor* AgentActor, const TArray<uint8>& PCMBytes); 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. */ /** Relay: send text message to an NPC agent. */
UFUNCTION(Server, Reliable) UFUNCTION(Server, Reliable)
void ServerRelaySendText(AActor* AgentActor, const FString& Text); void ServerRelaySendText(AActor* AgentActor, const FString& Text);
@@ -317,6 +339,22 @@ private:
UPROPERTY() UPROPERTY()
UPS_AI_ConvAgent_MicrophoneCaptureComponent* MicComponent = nullptr; 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. /** True once the one-time lazy init in TickComponent has completed.
* Deferred from BeginPlay because IsLocallyControlled() may return false * Deferred from BeginPlay because IsLocallyControlled() may return false
* before the PlayerController has been replicated/possessed. */ * before the PlayerController has been replicated/possessed. */

View File

@@ -3,6 +3,7 @@
#include "PS_AI_ConvReplay_Subsystem.h" #include "PS_AI_ConvReplay_Subsystem.h"
#include "PS_AI_ConvReplay_AgentTag.h" #include "PS_AI_ConvReplay_AgentTag.h"
#include "PS_AI_ConvAgent_ElevenLabsComponent.h" #include "PS_AI_ConvAgent_ElevenLabsComponent.h"
#include "PS_AI_ConvAgent_InteractionComponent.h"
#include "ReplaySystemBPLibrary.h" #include "ReplaySystemBPLibrary.h"
#include "VoiceModule.h" #include "VoiceModule.h"
@@ -37,6 +38,21 @@ static TAutoConsoleVariable<float> CVarPlayerGain(
TEXT("Extra gain multiplier applied to replayed player mic audio (on top of auto-normalization). Default 1.0."), TEXT("Extra gain multiplier applied to replayed player mic audio (on top of auto-normalization). Default 1.0."),
ECVF_Default); ECVF_Default);
// Target loudness (RMS, 0..1) each replayed player utterance is normalized toward.
// Higher = louder (but boosts a quiet mic's noise/quantization more). Tune live to taste.
static TAutoConsoleVariable<float> CVarPlayerTargetRms(
TEXT("ps.convreplay.PlayerTargetRms"),
0.08f,
TEXT("Target loudness (RMS, 0..1) for replayed player mic. Higher = louder. Default 0.08."),
ECVF_Default);
// Verbose per-record logging (dispatch + per-utterance audio stats). Off by default.
static TAutoConsoleVariable<int32> CVarConvReplayDebug(
TEXT("ps.convreplay.Debug"),
0,
TEXT("ConvReplay verbose logging: 1 = per-record dispatch + per-utterance audio stats. Default 0."),
ECVF_Default);
namespace PS_AI_ConvReplay_Audio namespace PS_AI_ConvReplay_Audio
{ {
static constexpr int32 SampleRate = 16000; static constexpr int32 SampleRate = 16000;
@@ -202,6 +218,37 @@ UPS_AI_ConvReplay_AgentTag* UPS_AI_ConvReplay_Subsystem::FindAgentForRecord(cons
return Best; return Best;
} }
// ─────────────────────────────────────────────────────────────────────────────
// Agent-less player-voice capture
// ─────────────────────────────────────────────────────────────────────────────
void UPS_AI_ConvReplay_Subsystem::RefreshRecordingMics()
{
UWorld* World = GetWorld();
if (!World) return;
// Server-authoritative arming: for every player pawn's InteractionComponent not
// yet armed, enable agent-less capture (replicated to the owning client) and
// subscribe to its server-side voice delegate.
for (TActorIterator<APawn> It(World); It; ++It)
{
APawn* Pawn = *It;
if (!Pawn) continue;
UPS_AI_ConvAgent_InteractionComponent* Mic =
Pawn->FindComponentByClass<UPS_AI_ConvAgent_InteractionComponent>();
if (!Mic || RecordingMicComps.Contains(Mic)) continue;
Mic->SetReplayRecordActive(true);
Mic->OnPlayerVoiceForRecording.AddDynamic(this, &UPS_AI_ConvReplay_Subsystem::HandlePlayerVoiceForRecording);
RecordingMicComps.Add(Mic);
}
}
void UPS_AI_ConvReplay_Subsystem::HandlePlayerVoiceForRecording(APawn* SpeakerPawn, const TArray<uint8>& PCM)
{
// Same pipeline as agent-routed player audio (segmentation, Opus, sidecar).
RecordPlayerAudio(SpeakerPawn, PCM);
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Recording session // Recording session
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -238,6 +285,9 @@ void UPS_AI_ConvReplay_Subsystem::BeginRecordingSession()
{ {
if (Tag) Tag->BeginRecording(this); if (Tag) Tag->BeginRecording(this);
} }
// Arm player mics for agent-less voice capture.
RefreshRecordingMics();
} }
void UPS_AI_ConvReplay_Subsystem::EndRecordingSession() void UPS_AI_ConvReplay_Subsystem::EndRecordingSession()
@@ -255,6 +305,23 @@ void UPS_AI_ConvReplay_Subsystem::EndRecordingSession()
if (Tag) Tag->EndRecording(); if (Tag) Tag->EndRecording();
} }
// Disarm player mics.
for (UPS_AI_ConvAgent_InteractionComponent* Mic : RecordingMicComps)
{
if (Mic)
{
Mic->SetReplayRecordActive(false);
Mic->OnPlayerVoiceForRecording.RemoveDynamic(this, &UPS_AI_ConvReplay_Subsystem::HandlePlayerVoiceForRecording);
}
}
RecordingMicComps.Reset();
// Flush any agent utterance still open at stop.
for (UPS_AI_ConvReplay_AgentTag* Tag : Tags)
{
if (Tag) FlushAgentSegment(Tag);
}
AgentAccum.Reset(); AgentAccum.Reset();
PlayerAccum.Reset(); PlayerAccum.Reset();
@@ -270,6 +337,9 @@ void UPS_AI_ConvReplay_Subsystem::EndRecordingSession()
void UPS_AI_ConvReplay_Subsystem::TickRecording() void UPS_AI_ConvReplay_Subsystem::TickRecording()
{ {
// Arm any player mics that appeared after recording began (late joiners).
RefreshRecordingMics();
// Flush player segments that have gone silent. // Flush player segments that have gone silent.
const double Now = NowDemoSeconds(); const double Now = NowDemoSeconds();
TArray<TWeakObjectPtr<APawn>> ToFlush; TArray<TWeakObjectPtr<APawn>> ToFlush;
@@ -293,25 +363,54 @@ void UPS_AI_ConvReplay_Subsystem::RecordAgentStarted(UPS_AI_ConvReplay_AgentTag*
if (!bRecording || !Tag) return; if (!bRecording || !Tag) return;
FAgentAccum& A = AgentAccum.FindOrAdd(Tag); FAgentAccum& A = AgentAccum.FindOrAdd(Tag);
A.StartTime = NowDemoSeconds(); A.StartTime = NowDemoSeconds();
A.LastAudioTime = A.StartTime;
A.PCM.Reset(); A.PCM.Reset();
A.bOpen = true; A.bOpen = true;
if (AActor* Owner = Tag->GetOwner()) if (AActor* Owner = Tag->GetOwner())
{ {
A.Pos = Owner->GetActorLocation(); A.Pos = Owner->GetActorLocation();
} }
if (CVarConvReplayDebug.GetValueOnGameThread() > 0)
{
UE_LOG(LogPS_AI_ConvReplay, Log, TEXT("REC agent-START id=%s t=%.2f"), *Tag->GetResolvedId(), A.StartTime);
}
} }
void UPS_AI_ConvReplay_Subsystem::RecordAgentAudio(UPS_AI_ConvReplay_AgentTag* Tag, const TArray<uint8>& PCM) void UPS_AI_ConvReplay_Subsystem::RecordAgentAudio(UPS_AI_ConvReplay_AgentTag* Tag, const TArray<uint8>& PCM)
{ {
if (!bRecording || !Tag || PCM.Num() == 0) return; if (!bRecording || !Tag || PCM.Num() == 0) return;
FAgentAccum& A = AgentAccum.FindOrAdd(Tag); FAgentAccum& A = AgentAccum.FindOrAdd(Tag);
const double Now = NowDemoSeconds();
const bool bDbg = CVarConvReplayDebug.GetValueOnGameThread() > 0;
if (!A.bOpen) if (!A.bOpen)
{ {
// Audio before an explicit start — open a segment defensively. // Audio before an explicit OnAgentStartedSpeaking — open a segment defensively.
A.StartTime = NowDemoSeconds(); A.StartTime = Now;
A.LastAudioTime = Now;
A.bOpen = true;
if (AActor* Owner = Tag->GetOwner()) A.Pos = Owner->GetActorLocation();
if (bDbg) UE_LOG(LogPS_AI_ConvReplay, Warning, TEXT("REC agent-audio-NO-START (defensive open) id=%s t=%.2f"), *Tag->GetResolvedId(), Now);
}
else if (A.PCM.Num() > 0 && (Now - A.LastAudioTime) > AgentGapSplitSeconds)
{
// Audio resumed after a long gap while the segment is still open. The earlier
// audio was a DISTINCT utterance — typically the first-message greeting, which
// never fires OnAgentStoppedSpeaking (no agent_response), so the segment stayed
// open and the real reply would otherwise be glued onto the greeting's early
// StartTime. Flush the earlier utterance at ITS time, then start a fresh segment
// here so this audio (the reply) gets its correct, later timestamp.
if (bDbg)
{
UE_LOG(LogPS_AI_ConvReplay, Log,
TEXT("REC agent-SPLIT id=%s gap=%.2fs → flush segStart=%.2f, new seg at %.2f"),
*Tag->GetResolvedId(), Now - A.LastAudioTime, A.StartTime, Now);
}
FlushAgentSegment(Tag); // writes the earlier utterance, resets A
A.StartTime = Now;
A.bOpen = true; A.bOpen = true;
if (AActor* Owner = Tag->GetOwner()) A.Pos = Owner->GetActorLocation(); if (AActor* Owner = Tag->GetOwner()) A.Pos = Owner->GetActorLocation();
} }
A.LastAudioTime = Now;
A.PCM.Append(PCM); A.PCM.Append(PCM);
} }
@@ -321,16 +420,14 @@ void UPS_AI_ConvReplay_Subsystem::RecordAgentText(UPS_AI_ConvReplay_AgentTag* Ta
AgentAccum.FindOrAdd(Tag).Text = Text; AgentAccum.FindOrAdd(Tag).Text = Text;
} }
void UPS_AI_ConvReplay_Subsystem::RecordAgentStopped(UPS_AI_ConvReplay_AgentTag* Tag) void UPS_AI_ConvReplay_Subsystem::FlushAgentSegment(UPS_AI_ConvReplay_AgentTag* Tag)
{ {
if (!bRecording || !Tag) return; if (!Tag) return;
FAgentAccum* A = AgentAccum.Find(Tag); FAgentAccum* A = AgentAccum.Find(Tag);
if (!A || !A->bOpen || A->PCM.Num() == 0) if (!A) return;
{
if (A) { A->bOpen = false; A->PCM.Reset(); A->Text.Reset(); }
return;
}
if (A->bOpen && A->PCM.Num() > 0)
{
FPS_AI_ConvReplay_Record R; FPS_AI_ConvReplay_Record R;
R.Kind = static_cast<uint8>(EPS_AI_ConvReplay_Kind::AgentSpeech); R.Kind = static_cast<uint8>(EPS_AI_ConvReplay_Kind::AgentSpeech);
R.TimeSec = A->StartTime; R.TimeSec = A->StartTime;
@@ -342,11 +439,25 @@ void UPS_AI_ConvReplay_Subsystem::RecordAgentStopped(UPS_AI_ConvReplay_AgentTag*
EncodeUtterance(A->PCM, R.Audio); EncodeUtterance(A->PCM, R.Audio);
WriteRecord(R); WriteRecord(R);
if (CVarConvReplayDebug.GetValueOnGameThread() > 0)
{
UE_LOG(LogPS_AI_ConvReplay, Log,
TEXT("REC agent-STOP id=%s start=%.2f now=%.2f dur=%.2f text='%.50s'"),
*R.Id, R.TimeSec, NowDemoSeconds(), R.DurationSec, *R.Text);
}
}
A->bOpen = false; A->bOpen = false;
A->PCM.Reset(); A->PCM.Reset();
A->Text.Reset(); A->Text.Reset();
} }
void UPS_AI_ConvReplay_Subsystem::RecordAgentStopped(UPS_AI_ConvReplay_AgentTag* Tag)
{
if (!bRecording || !Tag) return;
FlushAgentSegment(Tag);
}
void UPS_AI_ConvReplay_Subsystem::RecordAction(UPS_AI_ConvReplay_AgentTag* Tag, const FString& ActionName) void UPS_AI_ConvReplay_Subsystem::RecordAction(UPS_AI_ConvReplay_AgentTag* Tag, const FString& ActionName)
{ {
if (!bRecording || !Tag) return; if (!bRecording || !Tag) return;
@@ -393,6 +504,10 @@ void UPS_AI_ConvReplay_Subsystem::RecordUserTranscript(UPS_AI_ConvReplay_AgentTa
} }
} }
WriteRecord(R); WriteRecord(R);
if (CVarConvReplayDebug.GetValueOnGameThread() > 0)
{
UE_LOG(LogPS_AI_ConvReplay, Log, TEXT("REC user-TEXT t=%.2f text='%.50s'"), R.TimeSec, *Text);
}
} }
void UPS_AI_ConvReplay_Subsystem::RecordPlayerAudio(APawn* SpeakerPawn, const TArray<uint8>& PCM) void UPS_AI_ConvReplay_Subsystem::RecordPlayerAudio(APawn* SpeakerPawn, const TArray<uint8>& PCM)
@@ -436,6 +551,12 @@ void UPS_AI_ConvReplay_Subsystem::FlushPlayerSegment(APawn* Pawn)
R.Position = A->Pos; R.Position = A->Pos;
EncodeUtterance(A->PCM, R.Audio); EncodeUtterance(A->PCM, R.Audio);
WriteRecord(R); WriteRecord(R);
if (CVarConvReplayDebug.GetValueOnGameThread() > 0)
{
UE_LOG(LogPS_AI_ConvReplay, Log,
TEXT("REC player-SEG id=%s start=%.2f now=%.2f dur=%.2f"),
*R.Id, R.TimeSec, NowDemoSeconds(), R.DurationSec);
}
} }
A->bActive = false; A->bActive = false;
A->PCM.Reset(); A->PCM.Reset();
@@ -499,9 +620,12 @@ void UPS_AI_ConvReplay_Subsystem::DispatchDueRecords(double NowSec, bool bAudioA
} }
R.bPlayed = true; R.bPlayed = true;
if (CVarConvReplayDebug.GetValueOnGameThread() > 0)
{
UE_LOG(LogPS_AI_ConvReplay, Log, UE_LOG(LogPS_AI_ConvReplay, Log,
TEXT("Dispatch kind=%d id='%s' t=%.2f (now=%.2f) audio=%dB speed-allowed=%d"), TEXT("Dispatch kind=%d id='%s' t=%.2f (now=%.2f) audio=%dB speed-allowed=%d"),
R.Kind, *R.Id, R.TimeSec, NowSec, R.Audio.Num(), bAudioAllowed ? 1 : 0); R.Kind, *R.Id, R.TimeSec, NowSec, R.Audio.Num(), bAudioAllowed ? 1 : 0);
}
OnReplayLinePlayed.Broadcast( OnReplayLinePlayed.Broadcast(
FName(*R.Id), FName(*R.Id),
@@ -521,10 +645,13 @@ void UPS_AI_ConvReplay_Subsystem::DispatchDueRecords(double NowSec, bool bAudioA
if (bDecoded) if (bDecoded)
{ {
Comp->PlayExternalAudioPCM(PCM); Comp->PlayExternalAudioPCM(PCM);
if (CVarConvReplayDebug.GetValueOnGameThread() > 0)
{
UE_LOG(LogPS_AI_ConvReplay, Log, UE_LOG(LogPS_AI_ConvReplay, Log,
TEXT(" agent speech → '%s' : played %d PCM bytes."), TEXT(" agent speech → '%s' : played %d PCM bytes."),
*Tag->GetResolvedId(), PCM.Num()); *Tag->GetResolvedId(), PCM.Num());
} }
}
else else
{ {
UE_LOG(LogPS_AI_ConvReplay, Warning, UE_LOG(LogPS_AI_ConvReplay, Warning,
@@ -854,30 +981,36 @@ void UPS_AI_ConvReplay_Subsystem::NormalizePlayerPcm(TArray<uint8>& Pcm16)
// Bidirectional AGC: bring each utterance to a consistent target RMS — BOOST quiet // Bidirectional AGC: bring each utterance to a consistent target RMS — BOOST quiet
// ones and ATTENUATE loud ones — so speaking loud no longer runs hot. Then the live CVar. // ones and ATTENUATE loud ones — so speaking loud no longer runs hot. Then the live CVar.
double Gain = (Rms > 1e-5) ? (PlayerTargetRms / Rms) : 1.0; const double TargetRms = FMath::Clamp(CVarPlayerTargetRms.GetValueOnGameThread(), 0.02f, 0.5f);
double Gain = (Rms > 1e-5) ? (TargetRms / Rms) : 1.0;
Gain = FMath::Clamp(Gain, static_cast<double>(PlayerMinGain), static_cast<double>(PlayerMaxGain)); Gain = FMath::Clamp(Gain, static_cast<double>(PlayerMinGain), static_cast<double>(PlayerMaxGain));
Gain *= FMath::Max(0.0f, CVarPlayerGain.GetValueOnGameThread()); Gain *= FMath::Max(0.0f, CVarPlayerGain.GetValueOnGameThread());
// Soft-knee limiter on the output: samples above the threshold are smoothly // Feed-forward peak limiter with an envelope follower (instant attack, slow release)
// compressed toward full-scale (tanh) instead of hard-clipped, so residual peaks // instead of instantaneous waveshaping. It lowers the gain SMOOTHLY around transient
// (or a hot CVar) don't produce harsh saturation. // peaks — never reshaping individual samples — so we reach the target loudness without
const double T = static_cast<double>(PlayerSoftClipThreshold); // the harmonic distortion a hard/soft clipper produces. The peak lands exactly on the
int32 ClippedOut = 0; // ceiling (no overshoot), and gain recovers over ~40 ms (no audible pumping).
const double Ceiling = 0.97;
const double EnvRelease = 0.995; // per-sample peak-envelope decay (~9 ms @ 16 kHz):
// catches the rare brief spikes, lets the body stay loud between them
double Env = 0.0;
int32 Limited = 0;
for (int32 i = 0; i < N; ++i) for (int32 i = 0; i < N; ++i)
{ {
double x = (S[i] / 32768.0) * Gain; const double s = (S[i] / 32768.0) * Gain;
const double a = FMath::Abs(x); const double a = FMath::Abs(s);
if (a > T) Env = FMath::Max(a, Env * EnvRelease); // instant attack, slow release
{ const double Gr = (Env > Ceiling) ? (Ceiling / Env) : 1.0;
const double s = (x < 0.0) ? -1.0 : 1.0; if (Gr < 1.0) ++Limited;
x = s * (T + (1.0 - T) * FMath::Tanh((a - T) / (1.0 - T))); const int32 v = FMath::RoundToInt(s * Gr * 32767.0);
++ClippedOut;
}
const int32 v = FMath::RoundToInt(x * 32767.0);
S[i] = static_cast<int16>(FMath::Clamp(v, -32768, 32767)); S[i] = static_cast<int16>(FMath::Clamp(v, -32768, 32767));
} }
if (CVarConvReplayDebug.GetValueOnGameThread() > 0)
{
UE_LOG(LogPS_AI_ConvReplay, Log, UE_LOG(LogPS_AI_ConvReplay, Log,
TEXT("Player utterance: rms=%.3f peak=%d/32767 clipped-in=%d/%d gain=%.2f soft-limited=%d"), TEXT("Player utterance: rms=%.3f peak=%d/32767 clipped-in=%d/%d gain=%.2f limited=%d/%d"),
Rms, Peak, ClippedIn, N, Gain, ClippedOut); Rms, Peak, ClippedIn, N, Gain, Limited, N);
}
} }

View File

@@ -9,6 +9,7 @@
class UPS_AI_ConvReplay_AgentTag; class UPS_AI_ConvReplay_AgentTag;
class UPS_AI_ConvAgent_ElevenLabsComponent; class UPS_AI_ConvAgent_ElevenLabsComponent;
class UPS_AI_ConvAgent_InteractionComponent;
class UAudioComponent; class UAudioComponent;
class APawn; class APawn;
class IVoiceEncoder; class IVoiceEncoder;
@@ -73,6 +74,11 @@ private:
void TickRecording(); void TickRecording();
void TickReplay(); 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. // Agent discovery / lookup.
void RebuildTagList(); void RebuildTagList();
UPS_AI_ConvReplay_AgentTag* FindTagById(const FString& Id); UPS_AI_ConvReplay_AgentTag* FindTagById(const FString& Id);
@@ -98,6 +104,7 @@ private:
// Helpers. // Helpers.
double NowDemoSeconds() const; double NowDemoSeconds() const;
void FlushAgentSegment(UPS_AI_ConvReplay_AgentTag* Tag); // write + reset an agent utterance
void FlushPlayerSegment(APawn* Pawn); void FlushPlayerSegment(APawn* Pawn);
static bool IsVoiced(const TArray<uint8>& PCM16); static bool IsVoiced(const TArray<uint8>& PCM16);
static FString ResolvePlayerId(APawn* Pawn); static FString ResolvePlayerId(APawn* Pawn);
@@ -118,10 +125,15 @@ private:
UPROPERTY(Transient) UPROPERTY(Transient)
TArray<TObjectPtr<UPS_AI_ConvReplay_AgentTag>> Tags; 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. // Per-agent recording accumulator.
struct FAgentAccum struct FAgentAccum
{ {
double StartTime = 0.0; double StartTime = 0.0;
double LastAudioTime = 0.0; // demo time of the last appended chunk (gap detection)
FVector Pos = FVector::ZeroVector; FVector Pos = FVector::ZeroVector;
FString Text; FString Text;
TArray<uint8> PCM; TArray<uint8> PCM;
@@ -149,13 +161,13 @@ private:
TArray<TObjectPtr<UAudioComponent>> PlayerAudioPool; TArray<TObjectPtr<UAudioComponent>> PlayerAudioPool;
// Tunables. // 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 PlayerSilenceFlushSec = 0.6f;
static constexpr float VoicedRmsThreshold = 0.02f; static constexpr float VoicedRmsThreshold = 0.02f;
static constexpr float ScrubBackThreshold = 0.05f; // seconds jumped back => scrub 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 ScrubFwdThreshold = 1.5f; // seconds jumped forward => scrub / fast-seek
static constexpr float NormalSpeedTolerance = 0.35f; // only play audio within this of 1x static constexpr float NormalSpeedTolerance = 0.35f; // only play audio within this of 1x
static constexpr float PlayerTargetRms = 0.12f; // target loudness for replayed player mic (1..1 scale) // 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 PlayerMinGain = 0.1f; // allow attenuating LOUD utterances (bidirectional AGC)
static constexpr float PlayerMaxGain = 12.0f; // cap so near-silence isn't blown up static constexpr float PlayerMaxGain = 16.0f; // cap so near-silence isn't blown up
static constexpr float PlayerSoftClipThreshold = 0.8f; // above this (|x|), peaks are soft-limited, not hard-clipped
}; };