From a1e34e6dfadf81c29928db25775442d19eed390c Mon Sep 17 00:00:00 2001 From: "j.foucher" Date: Mon, 27 Apr 2026 12:37:18 +0200 Subject: [PATCH] Decouple ForceDisableConversation from action flow + mic timing fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ElevenLabsComponent::ForceDisableConversation() — removed ActionName param. Now a pure conversation master-switch with no knowledge of actions. OnReadyForAction is fired by the action flow itself: PendingActionName is set when OnAgentActionRequested is broadcasted, then OnReadyForAction fires either after blend-out (if conversation was disabled) or on the next tick (if not). - Rename bConversationDisabledByAction → bConversationForceDisabled — the old name implied coupling with actions which no longer exists. - Add bPendingStartListening — when StartListening is called before the WebSocket is connected (race between StartConversation and StartListening from InteractionComponent), defer instead of silently failing. Consumed automatically in HandleConnected / ClientStartConversation. Cleared by StopListening / EndConversation. - PerceptionComponent — demote the "perceived N actors but ALL filtered out" warning to Verbose; it's a normal case for civilians surrounded by non-hostile actors, not a misconfiguration. - CLAUDE.md — workflow rule: always describe and request confirmation before code changes. - Martin.uasset — agent config tweaks. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/CLAUDE.md | 15 +++ .../PS_AI_Behavior_PerceptionComponent.cpp | 6 +- .../Content/Agents/Martin.uasset | Bin 3898 -> 5671 bytes .../PS_AI_ConvAgent_ElevenLabsComponent.cpp | 93 +++++++++++++----- .../PS_AI_ConvAgent_ElevenLabsComponent.h | 43 ++++---- 5 files changed, 109 insertions(+), 48 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 5724cae..225c43f 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -1,5 +1,20 @@ # PS_AI_Agent — Notes pour Claude +## Workflow règles + +**TOUJOURS demander avant de lancer un changement de code.** Avant d'appeler Edit/Write/sed sur un fichier source : +1. Expliquer ce qu'on s'apprête à faire (quel fichier, quelle logique, quel impact) +2. Attendre la confirmation explicite de l'utilisateur +3. Seulement après → effectuer le changement + +Cela s'applique à : modifications de code (.cpp/.h), refactors, renames bulk, ajouts de nouvelles fonctions. +Cela ne s'applique PAS à : lecture de fichiers, recherches, git status/log/diff, builds, questions diagnostiques. + +Exceptions où on peut agir directement : +- L'utilisateur dit explicitement "fais-le" / "go" / "lance" / "commit" +- L'utilisateur demande explicitement un changement précis ("renomme X en Y", "ajoute cette fonction") +- Annulation / revert demandé par l'utilisateur + ## Plugins dans ce repo ### PS_AI_ConvAgent diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_PerceptionComponent.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_PerceptionComponent.cpp index 65701ca..91001be 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_PerceptionComponent.cpp +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_PerceptionComponent.cpp @@ -587,8 +587,10 @@ AActor* UPS_AI_Behavior_PerceptionComponent::GetHighestThreatActor( } else if (PerceivedActors.Num() > 0) { - UE_LOG(LogPS_AI_Behavior, Warning, - TEXT("[%s] GetHighestThreatActor: perceived %d actors but ALL were filtered out (myTeam=0x%02X). Check TeamIds and attitude."), + // Normal case: civilian surrounded by non-hostile actors, or disguised enemy + // seeing allies. Only log at Verbose — not an issue. + UE_LOG(LogPS_AI_Behavior, Verbose, + TEXT("[%s] GetHighestThreatActor: perceived %d actors, none hostile (myTeam=0x%02X)."), *Owner->GetName(), PerceivedActors.Num(), AIC ? AIC->GetGenericTeamId().GetId() : 255); } diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Content/Agents/Martin.uasset b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Content/Agents/Martin.uasset index a97e9d40606f9268c23c597183c57aed172ed1c9..97292d8d8a6386bb4f894b4c78fbecb1df955c56 100644 GIT binary patch literal 5671 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 diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_ElevenLabsComponent.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_ElevenLabsComponent.cpp index e5f3f52..3a7e0bc 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_ElevenLabsComponent.cpp +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_ElevenLabsComponent.cpp @@ -101,10 +101,9 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::TickComponent(float DeltaTime, ELevel { Super::TickComponent(DeltaTime, TickType, ThisTickFunction); - // ── ForceDisableConversation blend-out monitoring ───────────────────── - // After ForceDisableConversation(), sub-components are blending their - // CurrentActiveAlpha to 0. Once all are at (near) zero, fire OnReadyForAction - // so the game can start the physical action. + // ── Conversation disable: blend-out monitoring ───────────────────────── + // After ForceDisableConversation(), sub-components blend their CurrentActiveAlpha + // to 0. Once all near zero (or timeout), clear the wait flag. if (bWaitingForBlendOut) { BlendOutElapsedTime += DeltaTime; @@ -139,23 +138,33 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::TickComponent(float DeltaTime, ELevel bWaitingForBlendOut = false; BlendOutElapsedTime = 0.0f; UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Log, - TEXT("ForceDisableConversation: all components blended to neutral — firing OnReadyForAction for action '%s'."), - *PendingActionName); - OnReadyForAction.Broadcast(PendingActionName); + TEXT("ForceDisableConversation: all components blended to neutral.")); } else if (bTimedOut) { // Safety timeout — some component didn't reach alpha 0 in time. - // Fire OnReadyForAction anyway to avoid blocking the game action forever. bWaitingForBlendOut = false; BlendOutElapsedTime = 0.0f; UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Warning, - TEXT("ForceDisableConversation: blend-out timed out after %.1fs — not all components at neutral. Firing OnReadyForAction for action '%s' anyway."), - BlendOutTimeoutSeconds, *PendingActionName); - OnReadyForAction.Broadcast(PendingActionName); + TEXT("ForceDisableConversation: blend-out timed out after %.1fs — not all components at neutral."), + BlendOutTimeoutSeconds); } } + // ── Action flow: fire OnReadyForAction when ready ────────────────────── + // An action was requested via OnAgentActionRequested. Fire OnReadyForAction + // as soon as the agent is ready: + // - If ForceDisableConversation was called, wait for blend-out to complete. + // - Otherwise, fire on the next tick (quasi-immediate). + if (!PendingActionName.IsEmpty() && !bWaitingForBlendOut) + { + const FString ActionToFire = PendingActionName; + PendingActionName.Empty(); + UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Log, + TEXT("OnReadyForAction: firing for action '%s'."), *ActionToFire); + OnReadyForAction.Broadcast(ActionToFire); + } + // Response timeout: if the server hasn't started generating within ResponseTimeoutSeconds // after the user stopped speaking, notify Blueprint so it can react (e.g. show "try again"). if (bWaitingForAgentResponse && ResponseTimeoutSeconds > 0.0f && TurnEndTime > 0.0) @@ -400,7 +409,7 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::TickComponent(float DeltaTime, ELevel // ───────────────────────────────────────────────────────────────────────────── void UPS_AI_ConvAgent_ElevenLabsComponent::StartConversation() { - if (bConversationDisabledByAction) + if (bConversationForceDisabled) { UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Warning, TEXT("StartConversation: blocked — conversation disabled by ForceDisableConversation(). Call ForceEnableConversation() first.")); @@ -492,6 +501,9 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::StartConversation_Internal() void UPS_AI_ConvAgent_ElevenLabsComponent::EndConversation() { + // Clear any pending listen request so it doesn't leak across sessions. + bPendingStartListening = false; + if (GetOwnerRole() == ROLE_Authority) { // Standalone / listen-server: leave via the local player controller. @@ -525,7 +537,12 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::StartListening() const bool bEffectivelyConnected = IsConnected() || (GetOwnerRole() != ROLE_Authority && bNetIsConversing); if (!bEffectivelyConnected) { - UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Warning, TEXT("StartListening: not connected (IsConnected=%s bNetIsConversing=%s Role=%d)."), + // Not ready yet — defer until HandleConnected fires. + // Prevents silent audio loss when StartListening is called synchronously + // after StartConversation (async WebSocket handshake). + bPendingStartListening = true; + UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Log, + TEXT("StartListening deferred: WebSocket not connected yet (IsConnected=%s bNetIsConversing=%s Role=%d). Will open mic on connect."), IsConnected() ? TEXT("true") : TEXT("false"), bNetIsConversing ? TEXT("true") : TEXT("false"), static_cast(GetOwnerRole())); @@ -621,6 +638,10 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::StartListening() void UPS_AI_ConvAgent_ElevenLabsComponent::StopListening() { + // Also cancel any pending-start request (e.g. StartListening was deferred + // before WS connect and the user now wants to cancel). + bPendingStartListening = false; + if (!bIsListening) return; bIsListening = false; @@ -781,21 +802,19 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::InterruptAgent() // ForceDisableConversation / ForceEnableConversation // ───────────────────────────────────────────────────────────────────────────── -void UPS_AI_ConvAgent_ElevenLabsComponent::ForceDisableConversation(const FString& ActionName) +void UPS_AI_ConvAgent_ElevenLabsComponent::ForceDisableConversation() { - if (bConversationDisabledByAction) + if (bConversationForceDisabled) { UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Warning, - TEXT("ForceDisableConversation: already disabled (pending action: %s)."), *PendingActionName); + TEXT("ForceDisableConversation: already disabled.")); return; } UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Log, - TEXT("ForceDisableConversation: shutting down conversation for action '%s', blending to neutral."), *ActionName); + TEXT("ForceDisableConversation: shutting down conversation, blending to neutral.")); - PendingActionName = ActionName; - - bConversationDisabledByAction = true; + bConversationForceDisabled = true; bWaitingForBlendOut = true; BlendOutElapsedTime = 0.0f; @@ -856,7 +875,7 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::ForceDisableConversation(const FStrin void UPS_AI_ConvAgent_ElevenLabsComponent::ForceEnableConversation() { - if (!bConversationDisabledByAction) + if (!bConversationForceDisabled) { UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Warning, TEXT("ForceEnableConversation: not currently disabled.")); @@ -866,7 +885,7 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::ForceEnableConversation() UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Log, TEXT("ForceEnableConversation: conversation re-enabled.")); - bConversationDisabledByAction = false; + bConversationForceDisabled = false; bWaitingForBlendOut = false; BlendOutElapsedTime = 0.0f; @@ -1069,7 +1088,14 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::HandleConnected(const FPS_AI_ConvAgen { StartListening(); } - + // Consume any pending listen request deferred before WebSocket was ready. + else if (bPendingStartListening) + { + bPendingStartListening = false; + UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Log, + TEXT("HandleConnected: consuming pending StartListening request.")); + StartListening(); + } } void UPS_AI_ConvAgent_ElevenLabsComponent::HandleDisconnected(int32 StatusCode, const FString& Reason) @@ -1451,6 +1477,10 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::HandleClientToolCall(const FPS_AI_Con TEXT("[T+%.2fs] Agent action requested: %s"), T, *ActionName); } + // Track the pending action so OnReadyForAction can fire with the same name. + // If the user synchronously calls ForceDisableConversation() in their handler, + // OnReadyForAction will wait for blend-out. Otherwise it fires on next tick. + PendingActionName = ActionName; OnAgentActionRequested.Broadcast(ActionName); // Auto-respond so the agent can continue speaking. @@ -2100,7 +2130,7 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::ServerJoinConversation_Implementation if (!Pawn) return; // Block join while force-disabled (ForceDisableConversation active). - if (bConversationDisabledByAction) + if (bConversationForceDisabled) { UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Log, TEXT("[NET] ServerJoinConversation: blocked — conversation disabled by ForceDisableConversation().")); @@ -2367,6 +2397,14 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::ClientConversationStarted_Implementat { StartListening(); } + // Consume any pending listen request deferred before conversation was ready. + else if (bPendingStartListening) + { + bPendingStartListening = false; + UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Log, + TEXT("[Client] Consuming pending StartListening request.")); + StartListening(); + } } void UPS_AI_ConvAgent_ElevenLabsComponent::ClientConversationFailed_Implementation( @@ -2745,12 +2783,13 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::DrawDebugHUD() const bWantsReconnect ? TEXT(" (ACTIVE)") : TEXT(""))); // ForceDisable state - if (bConversationDisabledByAction || bWaitingForBlendOut) + if (bConversationForceDisabled || bWaitingForBlendOut) { const FColor DisableColor = FColor::Orange; + const FString PendingStr = PendingActionName.IsEmpty() ? TEXT("") : PendingActionName; GEngine->AddOnScreenDebugMessage(BaseKey + 9, DisplayTime, DisableColor, - FString::Printf(TEXT(" FORCE DISABLED — action: '%s' blendOut: %s (%.1fs)"), - *PendingActionName, + FString::Printf(TEXT(" FORCE DISABLED — pending action: '%s' blendOut: %s (%.1fs)"), + *PendingStr, bWaitingForBlendOut ? TEXT("WAITING") : TEXT("DONE"), BlendOutElapsedTime)); diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Public/PS_AI_ConvAgent_ElevenLabsComponent.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Public/PS_AI_ConvAgent_ElevenLabsComponent.h index 66ba3ac..a664534 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Public/PS_AI_ConvAgent_ElevenLabsComponent.h +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Public/PS_AI_ConvAgent_ElevenLabsComponent.h @@ -548,25 +548,21 @@ public: void InterruptAgent(); /** - * Cleanly disable conversation on this agent so the NPC can switch to game AI. + * Cleanly disable conversation on this agent. * Ends the WebSocket connection, stops all audio, and begins blending all * visual components (gaze, lip sync, facial expression, body expression) * back to their neutral state. While disabled, the InteractionComponent * cannot auto-restart a conversation with this agent. * - * When all components have blended to neutral, fires OnReadyForAction. + * This function ONLY handles the conversation master-switch. It has no + * knowledge of action/reaction flow — if an action was pending via + * OnAgentActionRequested, OnReadyForAction will fire automatically once + * blend-out completes. If no action is pending, nothing else happens. + * * Call ForceEnableConversation() later to allow conversation again. - * - * Typical usage from Blueprint (OnAgentActionRequested): - * 1. ForceDisableConversation("flee") - * 2. Wait for OnReadyForAction — receives the same ActionName - * 3. Play action montage based on ActionName - * 4. When done, call ForceEnableConversation() - * - * @param ActionName The action to perform once neutral (passed through to OnReadyForAction). */ UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_ConvAgent|ElevenLabs") - void ForceDisableConversation(const FString& ActionName); + void ForceDisableConversation(); /** * Re-enable conversation after a ForceDisableConversation() call. @@ -612,7 +608,7 @@ public: /** True while conversation is force-disabled (ForceDisableConversation was called). * While disabled, StartConversation and ServerJoinConversation are blocked. */ UFUNCTION(BlueprintPure, Category = "ASTERION|PS_AI_ConvAgent|ElevenLabs") - bool IsConversationDisabled() const { return bConversationDisabledByAction; } + bool IsConversationDisabled() const { return bConversationForceDisabled; } /** True while audio is being pre-buffered (playback hasn't started yet). * Used by the LipSync component to pause viseme queue consumption. */ @@ -706,24 +702,33 @@ private: UPROPERTY() USoundWaveProcedural* ProceduralSoundWave = nullptr; - // ── ForceDisableConversation state ─────────────────────────────────────── + // ── Conversation disable state ─────────────────────────────────────────── // Set by ForceDisableConversation(), cleared by ForceEnableConversation(). // While true, StartConversation() and ServerJoinConversation() are blocked. - bool bConversationDisabledByAction = false; - // True while waiting for all visual components to blend back to neutral. - // Monitored in TickComponent — fires OnReadyForAction when complete. + bool bConversationForceDisabled = false; + // True while waiting for all visual components to blend back to neutral + // after ForceDisableConversation(). Monitored in TickComponent. bool bWaitingForBlendOut = false; - // The action name passed to ForceDisableConversation(), forwarded to OnReadyForAction. - FString PendingActionName; - // Accumulated time waiting for blend-out. Safety timeout fires OnReadyForAction + // Accumulated time waiting for blend-out. Safety timeout to unblock // if components haven't reached neutral within this limit. float BlendOutElapsedTime = 0.0f; static constexpr float BlendOutTimeoutSeconds = 5.0f; + // ── Action flow state (independent from disable state) ─────────────────── + // Set when OnAgentActionRequested is broadcasted (LLM tool call). + // Cleared when OnReadyForAction is broadcasted. + // - If conversation is being disabled, OnReadyForAction waits for blend-out. + // - If not, OnReadyForAction fires on the next tick (quasi-immediate). + FString PendingActionName; + // ── State ───────────────────────────────────────────────────────────────── // Atomic: read from WASAPI background thread (OnMicrophoneDataCaptured), written from game thread. std::atomic bIsListening{false}; std::atomic bAgentSpeaking{false}; + // Set when StartListening() is called before the WebSocket is connected. + // Consumed in HandleConnected() to open the microphone automatically once ready. + // Cleared by StopListening() or EndConversation() to cancel the pending request. + bool bPendingStartListening = false; // True from the first agent_chat_response_part until the first audio chunk arrives. // Used to block StartListening() while the server is processing the previous turn. // Atomic: defensive — currently game-thread only, but documents thread-safety contract.