Fix network audio: raw PCM fallback when Opus codecs unavailable

UE5 5.5's FVoiceModule returns NULL encoder/decoder, breaking
Opus-based audio replication. This adds a transparent fallback:
- Server sends raw PCM when OpusEncoder is null (~32KB/s, fine for LAN)
- Client accepts raw PCM when OpusDecoder is null
- Changed MulticastReceiveAgentAudio from Unreliable to Reliable
  to handle larger uncompressed payloads without packet loss
- Added OnlineSubsystemNull config for future Opus compatibility
- Removed premature bAgentSpeaking=true from MulticastAgentStartedSpeaking
  to fix race condition with audio initialization

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
j.foucher 2026-03-02 14:58:21 +01:00
parent 5e1c50edf8
commit 6cac56fa06
3 changed files with 43 additions and 69 deletions

View File

@ -153,6 +153,12 @@ FontDPI=72
+EnumRedirects=(OldName="EPS_AI_Agent_Emotion", NewName="EPS_AI_ConvAgent_Emotion") +EnumRedirects=(OldName="EPS_AI_Agent_Emotion", NewName="EPS_AI_ConvAgent_Emotion")
+EnumRedirects=(OldName="EPS_AI_Agent_EmotionIntensity", NewName="EPS_AI_ConvAgent_EmotionIntensity") +EnumRedirects=(OldName="EPS_AI_Agent_EmotionIntensity", NewName="EPS_AI_ConvAgent_EmotionIntensity")
[OnlineSubsystem]
DefaultPlatformService=Null
[OnlineSubsystemNull]
bEnabled=true
[/Script/AndroidFileServerEditor.AndroidFileServerRuntimeSettings] [/Script/AndroidFileServerEditor.AndroidFileServerRuntimeSettings]
bEnablePlugin=True bEnablePlugin=True
bAllowNetworkConnection=True bAllowNetworkConnection=True

View File

@ -637,46 +637,35 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::HandleAudioReceived(const TArray<uint
QueueBefore, (static_cast<float>(QueueBefore) / 16000.0f) * 1000.0f); QueueBefore, (static_cast<float>(QueueBefore) / 16000.0f) * 1000.0f);
} }
// Network: Opus-compress and broadcast to all clients before local playback. // Network: broadcast audio to all clients.
if (GetOwnerRole() != ROLE_Authority || !OpusEncoder.IsValid()) if (GetOwnerRole() == ROLE_Authority)
{ {
static bool bWarnedOnce = false; if (OpusEncoder.IsValid())
if (!bWarnedOnce)
{ {
bWarnedOnce = true; // Opus path: compress then send.
UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Warning, uint32 CompressedSize = static_cast<uint32>(OpusWorkBuffer.Num());
TEXT("[NET-SRV] Cannot multicast audio! Role=%d (need %d=Authority), OpusEncoder=%s, FVoiceModule=%s"), OpusEncoder->Encode(PCMData.GetData(), PCMData.Num(),
static_cast<int32>(GetOwnerRole()), static_cast<int32>(ROLE_Authority), OpusWorkBuffer.GetData(), CompressedSize);
OpusEncoder.IsValid() ? TEXT("VALID") : TEXT("NULL"),
FVoiceModule::IsAvailable() ? TEXT("available") : TEXT("UNAVAILABLE"));
}
}
if (GetOwnerRole() == ROLE_Authority && OpusEncoder.IsValid())
{
uint32 CompressedSize = static_cast<uint32>(OpusWorkBuffer.Num());
int32 Remainder = OpusEncoder->Encode(PCMData.GetData(), PCMData.Num(),
OpusWorkBuffer.GetData(), CompressedSize);
if (CompressedSize > 0) if (CompressedSize > 0)
{
TArray<uint8> CompressedData;
CompressedData.Append(OpusWorkBuffer.GetData(), CompressedSize);
if (bDebug)
{ {
UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Log, TArray<uint8> CompressedData;
TEXT("[NET-SRV] Multicasting Opus audio: %d bytes PCM → %d bytes Opus (%.1f:1 ratio)"), CompressedData.Append(OpusWorkBuffer.GetData(), CompressedSize);
PCMData.Num(), CompressedSize, MulticastReceiveAgentAudio(CompressedData);
PCMData.Num() > 0 ? static_cast<float>(PCMData.Num()) / CompressedSize : 0.f);
} }
MulticastReceiveAgentAudio(CompressedData);
} }
else else
{ {
UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Warning, // Fallback: send raw PCM (no compression). ~32 KB/s at 16kHz 16-bit mono.
TEXT("[NET-SRV] Opus encode produced 0 bytes from %d bytes PCM — audio not sent."), // Fine for LAN; revisit with proper Opus if internet play is needed.
PCMData.Num()); static bool bWarnedOnce = false;
if (!bWarnedOnce)
{
bWarnedOnce = true;
UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Warning,
TEXT("[NET-SRV] Opus encoder unavailable — sending raw PCM (no compression)."));
}
MulticastReceiveAgentAudio(PCMData);
} }
} }
@ -1346,57 +1335,36 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::ClientConversationFailed_Implementati
// Network: Multicast RPCs // Network: Multicast RPCs
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
void UPS_AI_ConvAgent_ElevenLabsComponent::MulticastReceiveAgentAudio_Implementation( void UPS_AI_ConvAgent_ElevenLabsComponent::MulticastReceiveAgentAudio_Implementation(
const TArray<uint8>& OpusData) const TArray<uint8>& AudioData)
{ {
// Server already handled playback in HandleAudioReceived. // Server already handled playback in HandleAudioReceived.
if (GetOwnerRole() == ROLE_Authority) return; if (GetOwnerRole() == ROLE_Authority) return;
if (!OpusDecoder.IsValid())
{
UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Warning,
TEXT("[NET] MulticastReceiveAgentAudio: OpusDecoder is INVALID — audio dropped. FVoiceModule available: %s"),
FVoiceModule::IsAvailable() ? TEXT("YES") : TEXT("NO"));
return;
}
// LOD: skip audio if too far (unless this client is the speaker). // LOD: skip audio if too far (unless this client is the speaker).
const float Dist = GetDistanceToLocalPlayer(); const float Dist = GetDistanceToLocalPlayer();
const bool bIsSpeaker = IsLocalPlayerConversating(); const bool bIsSpeaker = IsLocalPlayerConversating();
if (!bIsSpeaker && AudioLODCullDistance > 0.f && Dist > AudioLODCullDistance) if (!bIsSpeaker && AudioLODCullDistance > 0.f && Dist > AudioLODCullDistance)
{ {
if (bDebug && DebugVerbosity >= 2)
{
UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Log,
TEXT("[NET] MulticastReceiveAgentAudio: LOD culled (dist=%.0f > cull=%.0f)"),
Dist, AudioLODCullDistance);
}
return; return;
} }
// Decode Opus → PCM.
const uint32 MaxDecompressedSize = 16000 * 2; // 1 second of 16kHz 16-bit mono
TArray<uint8> PCMBuffer; TArray<uint8> PCMBuffer;
PCMBuffer.SetNumUninitialized(MaxDecompressedSize);
uint32 DecompressedSize = MaxDecompressedSize;
OpusDecoder->Decode(OpusData.GetData(), OpusData.Num(),
PCMBuffer.GetData(), DecompressedSize);
if (DecompressedSize == 0) if (OpusDecoder.IsValid())
{ {
UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Warning, // Opus path: decode compressed audio.
TEXT("[NET] MulticastReceiveAgentAudio: Opus decode failed (0 bytes output from %d bytes input)"), const uint32 MaxDecompressedSize = 16000 * 2;
OpusData.Num()); PCMBuffer.SetNumUninitialized(MaxDecompressedSize);
return; uint32 DecompressedSize = MaxDecompressedSize;
OpusDecoder->Decode(AudioData.GetData(), AudioData.Num(),
PCMBuffer.GetData(), DecompressedSize);
if (DecompressedSize == 0) return;
PCMBuffer.SetNum(DecompressedSize);
} }
PCMBuffer.SetNum(DecompressedSize); else
if (bDebug)
{ {
UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Log, // Fallback: data is already raw PCM (sent uncompressed by server).
TEXT("[NET] MulticastReceiveAgentAudio: decoded %d bytes Opus → %d bytes PCM | bAgentSpeaking=%s | AudioComp playing=%s"), PCMBuffer = AudioData;
OpusData.Num(), DecompressedSize,
bAgentSpeaking ? TEXT("true") : TEXT("false"),
(AudioPlaybackComponent && AudioPlaybackComponent->IsPlaying()) ? TEXT("true") : TEXT("false"));
} }
// Local playback. // Local playback.

View File

@ -339,9 +339,9 @@ public:
UFUNCTION(Server, Reliable) UFUNCTION(Server, Reliable)
void ServerRequestInterrupt(); void ServerRequestInterrupt();
/** Broadcast Opus-compressed agent audio to all clients. */ /** Broadcast agent audio to all clients (Opus-compressed or raw PCM fallback). */
UFUNCTION(NetMulticast, Unreliable) UFUNCTION(NetMulticast, Reliable)
void MulticastReceiveAgentAudio(const TArray<uint8>& OpusData); void MulticastReceiveAgentAudio(const TArray<uint8>& AudioData);
/** Notify all clients that the agent started speaking (first audio chunk). */ /** Notify all clients that the agent started speaking (first audio chunk). */
UFUNCTION(NetMulticast, Reliable) UFUNCTION(NetMulticast, Reliable)