Compare commits

...

16 Commits

Author SHA1 Message Date
793a00efe7 updated scenes 2025-10-30 21:15:12 +01:00
272cbfdca8 refactored scripts to avoid execution order issues 2025-10-30 20:32:29 +01:00
761f6b1bfe added comprehensive overview document 2025-10-30 12:18:47 +01:00
639ac894ce refactored ConvaiUDPSpeechSender for faster audio clip detection and simplified UDPPeerDiscovery to maintain connection without timeouts or heartbeats 2025-10-27 15:57:40 +01:00
fae9ddc9af updated APK files 2025-10-27 14:46:53 +01:00
521c426da7 removed unused threadRunning field 2025-10-27 14:36:06 +01:00
dd50987678 updated refresh rates 2025-10-25 17:20:22 +02:00
bac096c4e3 adjusted scene 2 for changes 2025-10-25 16:00:52 +02:00
868180e3ec refactored VRExperimentController to utilize SharedUDPListener for packet handling 2025-10-25 15:58:28 +02:00
f3b4a4ddb0 adjusted scenes 2025-10-25 15:33:53 +02:00
3f72973ff5 created centralized UDP Listener 2025-10-25 14:58:22 +02:00
4ddb89d011 fixed some warnings 2025-10-25 14:07:50 +02:00
1274d6277d refactored UDP audio sender and receiver scripts to maintain last known peer IP during connection loss, improved logging for audio clip monitoring and peer discovery status 2025-10-25 13:59:17 +02:00
4d77a4753a improved logging for audio receiver status 2025-10-23 03:25:45 +02:00
fd7e08679f refactored UDP audio sender/receiver scripts to support port reuse for shared access, enhanced logging for recording state and audio clip monitoring 2025-10-23 03:23:33 +02:00
73b921fc9b enhanced UDP audio sender/receiver scripts with metrics for packet tracking and improved NPC assignment logic 2025-10-23 03:08:51 +02:00
25 changed files with 1814 additions and 745 deletions

Binary file not shown.

Binary file not shown.

BIN
Unity-Master/Assets/Resources/NetworkConfig.asset (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Unity-Master/Assets/Resources/OVROverlayCanvasSettings.asset (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 44c8d4b7ac986c847a36bd9a8d84f4b6
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

Binary file not shown.

View File

@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using Convai.Scripts.Runtime.Core;
@ -28,8 +27,15 @@ namespace Convai.Scripts.Runtime.Multiplayer
// Events
public Action<bool> OnAudioReceiving;
// Metrics for debug UI
private int _totalPacketsReceived = 0;
private DateTime _lastPacketReceivedTime;
public int TotalPacketsReceived => _totalPacketsReceived;
public float TimeSinceLastReceive => _lastPacketReceivedTime != default ?
(float)(DateTime.UtcNow - _lastPacketReceivedTime).TotalSeconds : -1f;
public int ListenPort => listenPort;
// Network components
private UdpClient _udpListener;
private IPEndPoint _remoteEndPoint;
private bool _isListening = false;
private int listenPort;
@ -39,24 +45,19 @@ namespace Convai.Scripts.Runtime.Multiplayer
private bool _isReceivingAudio = false;
private int _expectedSequence = 0;
private const uint MAGIC_NUMBER = 0xC0A1; // Simple magic number for packet validation
private const uint ACK_MAGIC = 0xC0A2; // ACK magic to confirm START control
// Timing for auto-stop
private float _lastPacketTime;
private const float AUTO_STOP_DELAY = 1.0f; // Stop listening after 1 second of no packets
// Packet structure (matching ConvaiSimpleUDPAudioSender)
// SIMPLIFIED Packet structure (matching ConvaiSimpleUDPAudioSender)
private struct AudioPacketData
{
public uint magicNumber;
public int sequence;
public int sampleCount;
public int microphonePosition;
public bool isEndSignal;
public bool isStartSignal;
public short[] audioSamples;
public long timestamp;
}
[Header("Recording Storage")]
@ -106,7 +107,18 @@ namespace Convai.Scripts.Runtime.Multiplayer
{
_cancellationTokenSource = new CancellationTokenSource();
}
StartListening();
StartCoroutine(WaitAndSubscribe());
// Immediately try to assign an enabled NPC
if (useActiveNPC && targetNPC == null)
{
var currentActiveNPC = FindEnabledConvaiNPC();
if (currentActiveNPC != null)
{
targetNPC = currentActiveNPC;
ConvaiLogger.Info($"🔄 UDP Audio Receiver assigned target NPC on enable: {targetNPC.characterName} (on {targetNPC.gameObject.name})", ConvaiLogger.LogCategory.Character);
}
}
}
private void OnDestroy()
@ -135,6 +147,47 @@ namespace Convai.Scripts.Runtime.Multiplayer
{
StopTalkingSimulation();
}
// Continuously update target NPC if using active NPC mode
if (useActiveNPC)
{
var currentActiveNPC = FindEnabledConvaiNPC();
// Update whenever the active NPC changes (including null → NPC or NPC → different NPC)
if (currentActiveNPC != targetNPC)
{
targetNPC = currentActiveNPC;
if (targetNPC != null)
{
ConvaiLogger.Info($"🔄 UDP Audio Receiver updated target NPC to: {targetNPC.characterName} (on {targetNPC.gameObject.name})", ConvaiLogger.LogCategory.Character);
}
else
{
ConvaiLogger.Info($"🔄 UDP Audio Receiver cleared target NPC", ConvaiLogger.LogCategory.Character);
}
}
}
}
/// <summary>
/// Finds an enabled ConvaiNPC in the scene (doesn't rely on ConvaiNPCManager raycasting)
/// </summary>
private ConvaiNPC FindEnabledConvaiNPC()
{
// Find all ConvaiNPC components in the scene (including inactive GameObjects)
var allNPCs = FindObjectsOfType<ConvaiNPC>(true);
// Return the first one that's on an active GameObject
foreach (var npc in allNPCs)
{
if (npc.gameObject.activeInHierarchy && npc.enabled)
{
return npc;
}
}
return null;
}
private void InitializeNetwork()
@ -151,10 +204,10 @@ namespace Convai.Scripts.Runtime.Multiplayer
private void InitializeConvai()
{
// Get target NPC
// Get target NPC by finding enabled NPCs in the scene
if (useActiveNPC)
{
targetNPC = ConvaiNPCManager.Instance?.GetActiveConvaiNPC();
targetNPC = FindEnabledConvaiNPC();
}
if (targetNPC == null)
@ -163,7 +216,7 @@ namespace Convai.Scripts.Runtime.Multiplayer
}
else
{
ConvaiLogger.Info($"UDP Audio Receiver V2 initialized with NPC: {targetNPC.characterName}", ConvaiLogger.LogCategory.Character);
ConvaiLogger.Info($"UDP Audio Receiver V2 initialized with NPC: {targetNPC.characterName} (on {targetNPC.gameObject.name})", ConvaiLogger.LogCategory.Character);
}
}
@ -174,20 +227,34 @@ namespace Convai.Scripts.Runtime.Multiplayer
try
{
_udpListener = new UdpClient(listenPort);
// Subscribe to shared listener
SharedUDPListener.Instance.OnPacketReceived += HandlePacketReceived;
_isListening = true;
ConvaiLogger.Info($"Simple UDP Audio Receiver V2 listening on port {listenPort}", ConvaiLogger.LogCategory.Character);
// Start listening for incoming packets
_ = ListenForAudioPackets(_cancellationTokenSource.Token);
ConvaiLogger.Info($" Audio Receiver subscribed to shared listener, listening for magic 0x{MAGIC_NUMBER:X}", ConvaiLogger.LogCategory.Character);
}
catch (Exception ex)
{
ConvaiLogger.Error($"Failed to start UDP listener: {ex.Message}", ConvaiLogger.LogCategory.Character);
ConvaiLogger.Error($"❌ FAILED to subscribe Audio Receiver: {ex.Message}", ConvaiLogger.LogCategory.Character);
ConvaiLogger.Error($"Stack trace: {ex.StackTrace}", ConvaiLogger.LogCategory.Character);
}
}
private System.Collections.IEnumerator WaitAndSubscribe()
{
float timeout = 3f;
while (SharedUDPListener.Instance == null && timeout > 0f)
{
timeout -= Time.unscaledDeltaTime;
yield return null;
}
if (SharedUDPListener.Instance == null)
{
ConvaiLogger.Error("SharedUDPListener not ready after wait.", ConvaiLogger.LogCategory.Character);
yield break;
}
StartListening();
}
public void StopListening()
{
@ -195,9 +262,12 @@ namespace Convai.Scripts.Runtime.Multiplayer
return;
_isListening = false;
_udpListener?.Close();
_udpListener?.Dispose();
_udpListener = null;
// Unsubscribe from shared listener
if (SharedUDPListener.Instance != null)
{
SharedUDPListener.Instance.OnPacketReceived -= HandlePacketReceived;
}
// Stop any ongoing simulation
StopTalkingSimulation();
@ -205,29 +275,22 @@ namespace Convai.Scripts.Runtime.Multiplayer
ConvaiLogger.Info("Stopped UDP Audio Receiver V2", ConvaiLogger.LogCategory.Character);
}
private async Task ListenForAudioPackets(CancellationToken cancellationToken)
private void HandlePacketReceived(byte[] data, IPEndPoint senderEndPoint)
{
try
{
while (_isListening && !cancellationToken.IsCancellationRequested)
{
var result = await _udpListener.ReceiveAsync();
_remoteEndPoint = result.RemoteEndPoint;
await ProcessReceivedPacket(result.Buffer, result.RemoteEndPoint);
}
}
catch (ObjectDisposedException)
{
// Normal when stopping
}
catch (Exception ex)
{
ConvaiLogger.Error($"Error in UDP listener: {ex.Message}", ConvaiLogger.LogCategory.Character);
}
// Check if this is an audio packet (by magic number)
if (data.Length < 12) return;
uint magic = BitConverter.ToUInt32(data, 0);
if (magic != MAGIC_NUMBER) return;
// Update remote endpoint
_remoteEndPoint = senderEndPoint;
// Process audio packet
_ = ProcessReceivedPacket(data, senderEndPoint);
}
private async Task ProcessReceivedPacket(byte[] data, IPEndPoint sender)
private Task ProcessReceivedPacket(byte[] data, IPEndPoint sender)
{
try
{
@ -238,28 +301,21 @@ namespace Convai.Scripts.Runtime.Multiplayer
var packet = packetData.Value;
_lastPacketTime = Time.time;
if (enableDebugLogging)
{
if (packet.isEndSignal)
ConvaiLogger.DebugLog($"Received end signal from {sender}", ConvaiLogger.LogCategory.Character);
else
ConvaiLogger.DebugLog($"Received audio packet {packet.sequence} with {packet.sampleCount} samples", ConvaiLogger.LogCategory.Character);
}
// Update metrics
_totalPacketsReceived++;
_lastPacketReceivedTime = DateTime.UtcNow;
// Handle START control: acknowledge and begin simulation
if (packet.isStartSignal)
// SIMPLIFIED: Check for end signal (sampleCount == -1)
bool isEndSignal = (packet.sampleCount == -1);
if (enableDebugLogging && packet.sequence % 50 == 0)
{
SendStartAck(sender);
if (!_isReceivingAudio)
{
StartTalkingSimulation();
}
OnAudioReceiving?.Invoke(true);
return;
ConvaiLogger.DebugLog($"📥 Received audio packet #{packet.sequence} (magic: 0x{packet.magicNumber:X}) from {sender}, {packet.sampleCount} samples", ConvaiLogger.LogCategory.Character);
}
if (packet.isEndSignal)
if (isEndSignal)
{
ConvaiLogger.Info($"📥 Received END signal from {sender}", ConvaiLogger.LogCategory.Character);
StopTalkingSimulation();
OnAudioReceiving?.Invoke(false);
}
@ -268,6 +324,7 @@ namespace Convai.Scripts.Runtime.Multiplayer
// If this is the first packet, start the talking simulation
if (packet.sequence == 0 && !_isReceivingAudio)
{
ConvaiLogger.Info($"📥 Received FIRST audio packet from {sender}, starting simulation", ConvaiLogger.LogCategory.Character);
StartTalkingSimulation();
}
@ -292,6 +349,8 @@ namespace Convai.Scripts.Runtime.Multiplayer
{
ConvaiLogger.Error($"Error processing received packet: {ex.Message}", ConvaiLogger.LogCategory.Character);
}
return Task.CompletedTask;
}
private void StartTalkingSimulation()
@ -299,10 +358,10 @@ namespace Convai.Scripts.Runtime.Multiplayer
if (_isReceivingAudio) return;
MainThreadDispatcher.Instance.RunOnMainThread(() => {
// Update target NPC if using active NPC
// Update target NPC by finding enabled NPCs in the scene
if (useActiveNPC)
{
targetNPC = ConvaiNPCManager.Instance?.GetActiveConvaiNPC();
targetNPC = FindEnabledConvaiNPC();
}
if (targetNPC == null)
@ -324,7 +383,7 @@ namespace Convai.Scripts.Runtime.Multiplayer
// This is the KEY! Simulate a talk key press to trigger normal Convai flow
ConvaiInputManager.Instance.talkKeyInteract?.Invoke(true);
ConvaiLogger.Info($"🎤 Started talking simulation for {targetNPC.characterName} (remote player audio)", ConvaiLogger.LogCategory.Character);
ConvaiLogger.Info($"🎤 Started talking simulation for {targetNPC.characterName} (on {targetNPC.gameObject.name}) (remote player audio)", ConvaiLogger.LogCategory.Character);
});
}
@ -349,7 +408,7 @@ namespace Convai.Scripts.Runtime.Multiplayer
private AudioPacketData? ParseSimpleAudioPacket(byte[] data)
{
if (data.Length < 17) // Minimum header size to match sender
if (data.Length < 12) // SIMPLIFIED: Minimum header size (4+4+4)
return null;
try
@ -361,27 +420,22 @@ namespace Convai.Scripts.Runtime.Multiplayer
offset += 4;
if (magic != MAGIC_NUMBER)
{
if (enableDebugLogging)
ConvaiLogger.Warn($"❌ Invalid magic number: expected 0x{MAGIC_NUMBER:X}, got 0x{magic:X}", ConvaiLogger.LogCategory.Character);
return null;
}
// Read header (matching sender's 17-byte format)
// SIMPLIFIED header (12 bytes total)
int sequence = BitConverter.ToInt32(data, offset);
offset += 4;
int sampleCount = BitConverter.ToInt32(data, offset);
offset += 4;
int microphonePosition = BitConverter.ToInt32(data, offset);
offset += 4;
byte flags = data[offset];
offset += 1;
bool isEndSignal = (flags == 1);
bool isStartSignal = (flags == 2);
// Read audio data
// Read audio data (if not end signal)
short[] audioSamples = null;
if (!isEndSignal && !isStartSignal && sampleCount > 0)
if (sampleCount > 0)
{
int audioDataSize = sampleCount * sizeof(short);
if (data.Length >= offset + audioDataSize)
@ -389,6 +443,10 @@ namespace Convai.Scripts.Runtime.Multiplayer
audioSamples = new short[sampleCount];
Buffer.BlockCopy(data, offset, audioSamples, 0, audioDataSize);
}
else
{
ConvaiLogger.Warn($"⚠️ Packet too small: expected {offset + audioDataSize} bytes, got {data.Length}", ConvaiLogger.LogCategory.Character);
}
}
return new AudioPacketData
@ -396,11 +454,7 @@ namespace Convai.Scripts.Runtime.Multiplayer
magicNumber = magic,
sequence = sequence,
sampleCount = sampleCount,
microphonePosition = microphonePosition,
isEndSignal = isEndSignal,
isStartSignal = isStartSignal,
audioSamples = audioSamples,
timestamp = 0 // Not provided in sender format
audioSamples = audioSamples
};
}
catch (Exception ex)
@ -410,26 +464,6 @@ namespace Convai.Scripts.Runtime.Multiplayer
}
}
private void SendStartAck(IPEndPoint sender)
{
try
{
if (_udpListener == null || sender == null)
return;
byte[] ack = new byte[8];
BitConverter.GetBytes(ACK_MAGIC).CopyTo(ack, 0);
BitConverter.GetBytes(-1).CopyTo(ack, 4);
_udpListener.SendAsync(ack, ack.Length, sender);
if (enableDebugLogging)
ConvaiLogger.DebugLog($"Sent START ACK to {sender}", ConvaiLogger.LogCategory.Character);
}
catch (Exception ex)
{
ConvaiLogger.Warn($"Failed to send START ACK: {ex.Message}", ConvaiLogger.LogCategory.Character);
}
}
private void BufferAudioPacket(int sequence, short[] samples)
{

View File

@ -48,28 +48,34 @@ namespace Convai.Scripts.Runtime.Multiplayer
private AudioClip _audioClip;
private bool _isRecording = false;
private CancellationTokenSource _cancellationTokenSource;
private CancellationTokenSource _ackCancellationTokenSource;
private int _lastMicrophonePosition = 0;
private float[] _audioBuffer;
private string _selectedMicrophone;
private int _packetSequence = 0;
private volatile bool _startAckReceived = false;
private bool _xrAButtonPrevPressed = false;
private InputAction _xrTalkAction;
private InputAction _xrTestAction;
private bool _usingExternalTalkAction = false;
private InputAction _externalTalkAction;
// Protocol constants
// Protocol constants - SIMPLIFIED
private const uint AUDIO_MAGIC = 0xC0A1;
private const uint ACK_MAGIC = 0xC0A2;
private const byte FLAG_AUDIO = 0;
private const byte FLAG_END = 1;
private const byte FLAG_START = 2;
public event Action<bool> OnRecordingStateChanged;
// Metrics for debug UI
private int _totalPacketsSent = 0;
private DateTime _lastPacketSentTime;
public int TotalPacketsSent => _totalPacketsSent;
public float TimeSinceLastSend => _lastPacketSentTime != default ?
(float)(DateTime.UtcNow - _lastPacketSentTime).TotalSeconds : -1f;
public string CurrentTargetIP => targetIP;
public int CurrentTargetPort => targetPort;
public bool UsingDiscovery => NetworkConfig.Instance?.useAutoDiscovery ?? false;
[Header("Recording Storage")]
[SerializeField] private bool saveLocalAudio = true;
[SerializeField] private int localSampleRate = 16000;
@ -108,10 +114,6 @@ namespace Convai.Scripts.Runtime.Multiplayer
InitializeAudio();
_persistentDataPath = Application.persistentDataPath;
_cancellationTokenSource = new CancellationTokenSource();
_ackCancellationTokenSource = new CancellationTokenSource();
// Start ACK listener
_ = ListenForAcks(_ackCancellationTokenSource.Token);
// Setup Input System action for XR A/primary button
if (useInputSystemXR)
@ -148,8 +150,6 @@ namespace Convai.Scripts.Runtime.Multiplayer
StopRecording();
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_ackCancellationTokenSource?.Cancel();
_ackCancellationTokenSource?.Dispose();
if (_usingExternalTalkAction)
TeardownExternalTalkInputAction();
else
@ -167,13 +167,9 @@ namespace Convai.Scripts.Runtime.Multiplayer
private void HandlePeerLost()
{
var cfg = NetworkConfig.Instance;
if (cfg != null)
{
targetIP = cfg.fallbackBroadcastIP;
_targetEndPoint = new IPEndPoint(IPAddress.Parse(targetIP), targetPort);
ConvaiLogger.Warn($"🎤 Audio sender falling back to broadcast: {targetIP}", ConvaiLogger.LogCategory.Character);
}
// Don't change targetIP - keep sending to the last known peer IP
// The peer might come back online and we'll automatically reconnect
ConvaiLogger.Warn($"🎤 Audio sender: Peer connection lost, but continuing to send to {targetIP}:{targetPort}", ConvaiLogger.LogCategory.Character);
}
private void InitializeNetwork()
@ -470,20 +466,16 @@ namespace Convai.Scripts.Runtime.Multiplayer
_isRecording = true;
_lastMicrophonePosition = 0;
_packetSequence = 0;
_startAckReceived = false;
_localSessionStart = DateTime.UtcNow;
lock (_localAudioLock)
{
_localSamples.Clear();
}
ConvaiLogger.Info("Started recording for UDP transmission (Simple)", ConvaiLogger.LogCategory.Character);
ConvaiLogger.Info($"🎤 Started recording for UDP transmission to {targetIP}:{targetPort}", ConvaiLogger.LogCategory.Character);
OnRecordingStateChanged?.Invoke(true);
// Send START control and wait briefly for ACK to ensure receiver is ready
_ = SendStartOfRecordingSignalAndAwaitAck();
// Start continuous audio processing
// Start continuous audio processing immediately (SIMPLIFIED - no ACK waiting)
_ = ProcessAudioContinuously(_cancellationTokenSource.Token);
}
catch (Exception ex)
@ -609,6 +601,10 @@ namespace Convai.Scripts.Runtime.Multiplayer
// Send the packet
await _udpClient.SendAsync(packet, packet.Length, _targetEndPoint);
// Update metrics
_totalPacketsSent++;
_lastPacketSentTime = DateTime.UtcNow;
if (enableDebugLogging && _packetSequence % 10 == 0) // Log every 10th packet
{
ConvaiLogger.DebugLog($"Sent packet {_packetSequence} with {currentChunkSamples} samples", ConvaiLogger.LogCategory.Character);
@ -630,15 +626,13 @@ namespace Convai.Scripts.Runtime.Multiplayer
private byte[] CreateSimpleAudioPacket(float[] audioData, int startIndex, int sampleCount)
{
// Simple packet structure:
// SIMPLIFIED packet structure:
// 4 bytes: Magic number (0xC0A1)
// 4 bytes: Packet sequence number
// 4 bytes: Sample count in this packet
// 4 bytes: Start position in stream
// 1 byte: Flags (0 = normal audio, 1 = end of recording)
// N bytes: Audio data (converted to shorts)
int headerSize = 17; // 4 + 4 + 4 + 4 + 1
int headerSize = 12; // 4 + 4 + 4 (SIMPLIFIED from 17)
int audioDataSize = sampleCount * sizeof(short);
byte[] packet = new byte[headerSize + audioDataSize];
@ -656,15 +650,7 @@ namespace Convai.Scripts.Runtime.Multiplayer
BitConverter.GetBytes(sampleCount).CopyTo(packet, offset);
offset += 4;
// Start position
BitConverter.GetBytes(_lastMicrophonePosition + startIndex).CopyTo(packet, offset);
offset += 4;
// Flags (0 for normal audio)
packet[offset] = FLAG_AUDIO;
offset += 1;
// Convert audio samples to bytes (same as Convai approach)
// Convert audio samples to bytes
for (int i = 0; i < sampleCount; i++)
{
float sample = audioData[startIndex + i];
@ -675,6 +661,11 @@ namespace Convai.Scripts.Runtime.Multiplayer
offset += 2;
}
if (enableDebugLogging && _packetSequence % 50 == 0)
{
ConvaiLogger.DebugLog($"📤 Sent audio packet #{_packetSequence} (magic: 0x{AUDIO_MAGIC:X}) to {targetIP}:{targetPort}, {sampleCount} samples", ConvaiLogger.LogCategory.Character);
}
return packet;
}
@ -682,8 +673,8 @@ namespace Convai.Scripts.Runtime.Multiplayer
{
try
{
// Create end packet
byte[] packet = new byte[17]; // Header only, no audio data
// SIMPLIFIED end packet
byte[] packet = new byte[12]; // Header only, no audio data
int offset = 0;
// Magic number
@ -694,18 +685,12 @@ namespace Convai.Scripts.Runtime.Multiplayer
BitConverter.GetBytes(_packetSequence).CopyTo(packet, offset);
offset += 4;
// Sample count (0 for end signal)
BitConverter.GetBytes(0).CopyTo(packet, offset);
offset += 4;
// Start position
BitConverter.GetBytes(_lastMicrophonePosition).CopyTo(packet, offset);
offset += 4;
// Flags (1 for end of recording)
packet[offset] = FLAG_END;
// Sample count (-1 for end signal)
BitConverter.GetBytes(-1).CopyTo(packet, offset);
_udpClient.SendAsync(packet, packet.Length, _targetEndPoint);
ConvaiLogger.Info($"📤 Sent END signal (magic: 0x{AUDIO_MAGIC:X}) to {targetIP}:{targetPort}", ConvaiLogger.LogCategory.Character);
}
catch (Exception ex)
{
@ -807,90 +792,6 @@ namespace Convai.Scripts.Runtime.Multiplayer
}
}
private async Task SendStartOfRecordingSignalAndAwaitAck()
{
try
{
const int maxAttempts = 3;
const int ackTimeoutMs = 250;
for (int attempt = 1; attempt <= maxAttempts && !_startAckReceived; attempt++)
{
// Build START control packet (no audio, special flag)
byte[] packet = new byte[17];
int offset = 0;
BitConverter.GetBytes(AUDIO_MAGIC).CopyTo(packet, offset);
offset += 4;
// Use -1 as the special sequence for START control
BitConverter.GetBytes(-1).CopyTo(packet, offset);
offset += 4;
BitConverter.GetBytes(0).CopyTo(packet, offset);
offset += 4;
BitConverter.GetBytes(_lastMicrophonePosition).CopyTo(packet, offset);
offset += 4;
packet[offset] = FLAG_START;
await _udpClient.SendAsync(packet, packet.Length, _targetEndPoint);
// Wait for ACK
int waited = 0;
while (!_startAckReceived && waited < ackTimeoutMs)
{
await Task.Delay(10);
waited += 10;
}
if (_startAckReceived)
{
if (enableDebugLogging)
ConvaiLogger.DebugLog("Received START ACK from receiver", ConvaiLogger.LogCategory.Character);
break;
}
else if (enableDebugLogging)
{
ConvaiLogger.Warn($"No START ACK (attempt {attempt}/{maxAttempts}), retrying...", ConvaiLogger.LogCategory.Character);
}
}
}
catch (Exception ex)
{
ConvaiLogger.Warn($"Error during START ACK process: {ex.Message}", ConvaiLogger.LogCategory.Character);
}
}
private async Task ListenForAcks(CancellationToken token)
{
// Use the same UDP client; acks will be sent back to our ephemeral local port
while (!token.IsCancellationRequested)
{
try
{
var result = await _udpClient.ReceiveAsync();
var data = result.Buffer;
if (data == null || data.Length < 8)
continue;
uint magic = BitConverter.ToUInt32(data, 0);
if (magic != ACK_MAGIC)
continue;
int seq = BitConverter.ToInt32(data, 4);
if (seq == -1)
{
_startAckReceived = true;
}
}
catch (ObjectDisposedException)
{
break;
}
catch (Exception)
{
// Ignore and keep listening
}
}
}
// Public methods for external control
public void SetTargetEndpoint(string ip, int port)
{

View File

@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using Convai.Scripts.Runtime.LoggerSystem;
@ -11,8 +10,18 @@ using UnityEngine;
namespace Convai.Scripts.Runtime.Multiplayer
{
/// <summary>
/// UDP Speech Receiver - Receives high-quality Convai speech with proper buffering
/// This version reconstructs the original AudioClip objects for seamless playback
/// UDP Speech Receiver - Receives and plays NPC speech audio from remote player
///
/// FLOW (Player 1 → Player 2):
/// 1. Player 2 speaks to Player 1's NPC
/// 2. Player 1's NPC responds with speech
/// 3. Player 1's ConvaiUDPSpeechSender transmits the audio
/// 4. THIS COMPONENT receives the audio packets
/// 5. Reconstructs AudioClips from the packets
/// 6. Plays them back on local AudioSource
///
/// This component should be on a NetworkManager or similar persistent object.
/// It receives speech from the remote player's NPC.
/// </summary>
public class ConvaiUDPSpeechReceiver : MonoBehaviour
{
@ -29,7 +38,6 @@ namespace Convai.Scripts.Runtime.Multiplayer
[SerializeField] private bool showTranscripts = true;
// Network components
private UdpClient _udpListener;
private IPEndPoint _remoteEndPoint;
private bool _isListening = false;
private int listenPort;
@ -54,6 +62,14 @@ namespace Convai.Scripts.Runtime.Multiplayer
public Action<string> OnTranscriptReceived;
public Action<AudioClip> OnAudioClipReceived;
// Metrics for debug UI
private int _totalClipsReceived = 0;
private DateTime _lastClipReceivedTime;
public int TotalClipsReceived => _totalClipsReceived;
public float TimeSinceLastReceive => _lastClipReceivedTime != default ?
(float)(DateTime.UtcNow - _lastClipReceivedTime).TotalSeconds : -1f;
public int ListenPort => listenPort;
// Data structures
private struct SpeechPacket
{
@ -128,7 +144,7 @@ namespace Convai.Scripts.Runtime.Multiplayer
{
_cancellationTokenSource = new CancellationTokenSource();
}
StartListening();
StartCoroutine(WaitAndSubscribe());
}
private void OnDestroy()
@ -193,19 +209,34 @@ namespace Convai.Scripts.Runtime.Multiplayer
try
{
_udpListener = new UdpClient(listenPort);
// Subscribe to shared listener
SharedUDPListener.Instance.OnPacketReceived += HandlePacketReceived;
_isListening = true;
ConvaiLogger.Info($"UDP Speech Receiver listening on port {listenPort}", ConvaiLogger.LogCategory.Character);
// Start listening for incoming packets
_ = ListenForSpeechPackets(_cancellationTokenSource.Token);
ConvaiLogger.Info($" Speech Receiver subscribed to shared listener, listening for magic 0x{MAGIC_NUMBER:X}", ConvaiLogger.LogCategory.Character);
}
catch (Exception ex)
{
ConvaiLogger.Error($"Failed to start UDP speech receiver: {ex.Message}", ConvaiLogger.LogCategory.Character);
ConvaiLogger.Error($"❌ FAILED to subscribe Speech Receiver: {ex.Message}", ConvaiLogger.LogCategory.Character);
}
}
private System.Collections.IEnumerator WaitAndSubscribe()
{
float timeout = 3f;
while (SharedUDPListener.Instance == null && timeout > 0f)
{
timeout -= Time.unscaledDeltaTime;
yield return null;
}
if (SharedUDPListener.Instance == null)
{
ConvaiLogger.Error("SharedUDPListener not ready after wait.", ConvaiLogger.LogCategory.Character);
yield break;
}
StartListening();
}
public void StopListening()
{
@ -213,9 +244,12 @@ namespace Convai.Scripts.Runtime.Multiplayer
return;
_isListening = false;
_udpListener?.Close();
_udpListener?.Dispose();
_udpListener = null;
// Unsubscribe from shared listener
if (SharedUDPListener.Instance != null)
{
SharedUDPListener.Instance.OnPacketReceived -= HandlePacketReceived;
}
// Stop any ongoing playback
StopSpeechPlayback();
@ -223,29 +257,22 @@ namespace Convai.Scripts.Runtime.Multiplayer
ConvaiLogger.Info("Stopped UDP Speech Receiver", ConvaiLogger.LogCategory.Character);
}
private async Task ListenForSpeechPackets(CancellationToken cancellationToken)
private void HandlePacketReceived(byte[] data, IPEndPoint senderEndPoint)
{
try
{
while (_isListening && !cancellationToken.IsCancellationRequested)
{
var result = await _udpListener.ReceiveAsync();
_remoteEndPoint = result.RemoteEndPoint;
await ProcessReceivedPacket(result.Buffer, result.RemoteEndPoint);
}
}
catch (ObjectDisposedException)
{
// Normal when stopping
}
catch (Exception ex)
{
ConvaiLogger.Error($"Error in UDP speech listener: {ex.Message}", ConvaiLogger.LogCategory.Character);
}
// Check if this is a speech packet (by magic number)
if (data.Length < 4) return;
uint magic = BitConverter.ToUInt32(data, 0);
if (magic != MAGIC_NUMBER) return;
// Update remote endpoint
_remoteEndPoint = senderEndPoint;
// Process speech packet
_ = ProcessReceivedPacket(data, senderEndPoint);
}
private async Task ProcessReceivedPacket(byte[] data, IPEndPoint sender)
private Task ProcessReceivedPacket(byte[] data, IPEndPoint sender)
{
try
{
@ -313,6 +340,8 @@ namespace Convai.Scripts.Runtime.Multiplayer
{
ConvaiLogger.Error($"Error processing speech packet: {ex.Message}", ConvaiLogger.LogCategory.Character);
}
return Task.CompletedTask;
}
private void HandleAudioStartPacket(SpeechPacket packet)
@ -446,6 +475,10 @@ namespace Convai.Scripts.Runtime.Multiplayer
OnAudioClipReceived?.Invoke(clip);
// Update metrics
_totalClipsReceived++;
_lastClipReceivedTime = DateTime.UtcNow;
if (enableDebugLogging)
ConvaiLogger.DebugLog($"✅ Reconstructed audio clip {sequence}: {clip.length:F2}s, '{incomingClip.transcript}'", ConvaiLogger.LogCategory.Character);
}

View File

@ -12,8 +12,18 @@ using System.Collections;
namespace Convai.Scripts.Runtime.Multiplayer
{
/// <summary>
/// UDP Speech Sender - Simple and reliable approach using events
/// Hooks into AudioManager events to capture when clips are about to be played
/// UDP Speech Sender - Captures and transmits NPC speech audio to remote player
///
/// FLOW (Player 1 → Player 2):
/// 1. Player 2 speaks (via ConvaiSimpleUDPAudioSender on their device)
/// 2. Player 1 receives voice input (via ConvaiSimpleUDPAudioReceiver)
/// 3. Player 1's NPC generates response speech (Convai API)
/// 4. THIS COMPONENT monitors Player 1's NPC AudioSource
/// 5. When new AudioClips appear, transmit them to Player 2
/// 6. Player 2's ConvaiUDPSpeechReceiver plays the audio
///
/// This component should be on a NetworkManager or similar persistent object.
/// It will find and monitor ConvaiNPC components on Avatar objects in the scene.
/// </summary>
public class ConvaiUDPSpeechSender : MonoBehaviour
{
@ -26,7 +36,6 @@ namespace Convai.Scripts.Runtime.Multiplayer
[Header("Audio Settings")]
[SerializeField] private int maxSamplesPerPacket = 8192;
[SerializeField] private bool sendTranscripts = true;
// Network components
private UdpClient _udpClient;
@ -51,6 +60,16 @@ namespace Convai.Scripts.Runtime.Multiplayer
public Action<bool> OnSpeechTransmission;
public Action<string> OnSpeechSent;
// Metrics for debug UI
private int _totalClipsSent = 0;
private DateTime _lastClipSentTime;
public int TotalClipsSent => _totalClipsSent;
public float TimeSinceLastSend => _lastClipSentTime != default ?
(float)(DateTime.UtcNow - _lastClipSentTime).TotalSeconds : -1f;
public string CurrentTargetIP => targetIP;
public int CurrentTargetPort => targetPort;
public bool UsingDiscovery => NetworkConfig.Instance?.useAutoDiscovery ?? false;
private void Start()
{
// Get network config from global instance
@ -92,6 +111,53 @@ namespace Convai.Scripts.Runtime.Multiplayer
CleanupNetwork();
}
private void Update()
{
// Continuously update source NPC if using active NPC mode
if (useActiveNPC)
{
var currentActiveNPC = FindEnabledConvaiNPC();
if (currentActiveNPC != sourceNPC)
{
// Cleanup old subscriptions
CleanupNPCSubscriptions();
// Update to new NPC
sourceNPC = currentActiveNPC;
SubscribeToNPCEvents();
if (sourceNPC != null)
{
ConvaiLogger.Info($"🔄 UDP Speech Sender updated source NPC to: {sourceNPC.characterName} (on {sourceNPC.gameObject.name})", ConvaiLogger.LogCategory.Character);
}
else
{
ConvaiLogger.Info($"🔄 UDP Speech Sender cleared source NPC", ConvaiLogger.LogCategory.Character);
}
}
}
}
/// <summary>
/// Finds an enabled ConvaiNPC in the scene (doesn't rely on ConvaiNPCManager raycasting)
/// </summary>
private ConvaiNPC FindEnabledConvaiNPC()
{
// Find all ConvaiNPC components in the scene (including inactive GameObjects)
var allNPCs = FindObjectsOfType<ConvaiNPC>(true);
// Return the first one that's on an active GameObject
foreach (var npc in allNPCs)
{
if (npc.gameObject.activeInHierarchy && npc.enabled)
{
return npc;
}
}
return null;
}
private void HandlePeerDiscovered(string peerIP)
{
targetIP = peerIP;
@ -101,13 +167,9 @@ namespace Convai.Scripts.Runtime.Multiplayer
private void HandlePeerLost()
{
var cfg = NetworkConfig.Instance;
if (cfg != null)
{
targetIP = cfg.fallbackBroadcastIP;
_targetEndPoint = new IPEndPoint(IPAddress.Parse(targetIP), targetPort);
ConvaiLogger.Warn($"🔊 Speech sender falling back to broadcast: {targetIP}", ConvaiLogger.LogCategory.Character);
}
// Don't change targetIP - keep sending to the last known peer IP
// The peer might come back online and we'll automatically reconnect
ConvaiLogger.Warn($"🔊 Speech sender: Peer connection lost, but continuing to send to {targetIP}:{targetPort}", ConvaiLogger.LogCategory.Character);
}
private void InitializeNetwork()
@ -128,15 +190,24 @@ namespace Convai.Scripts.Runtime.Multiplayer
private void InitializeConvai()
{
// Prefer local ConvaiNPC on the same GameObject, then fall back to active NPC
// Prefer local ConvaiNPC on the same GameObject, then fall back to finding enabled NPC
var localNPC = GetComponent<ConvaiNPC>();
if (localNPC != null)
{
sourceNPC = localNPC;
ConvaiLogger.Info($"Speech Sender: Using local NPC {sourceNPC.characterName}", ConvaiLogger.LogCategory.Character);
}
else if (useActiveNPC)
{
sourceNPC = ConvaiNPCManager.Instance?.GetActiveConvaiNPC();
sourceNPC = FindEnabledConvaiNPC();
if (sourceNPC != null)
{
ConvaiLogger.Info($"Speech Sender: Found NPC {sourceNPC.characterName} on {sourceNPC.gameObject.name}", ConvaiLogger.LogCategory.Character);
}
else
{
ConvaiLogger.Warn("Speech Sender: No ConvaiNPC found in scene yet", ConvaiLogger.LogCategory.Character);
}
}
SubscribeToNPCEvents();
@ -150,18 +221,26 @@ namespace Convai.Scripts.Runtime.Multiplayer
private void SubscribeToNPCEvents()
{
if (sourceNPC?.AudioManager != null)
if (sourceNPC == null)
{
// Hook into the character talking events
sourceNPC.AudioManager.OnCharacterTalkingChanged += HandleCharacterTalkingChanged;
sourceNPC.AudioManager.OnAudioTranscriptAvailable += HandleTranscriptAvailable;
ConvaiLogger.Info($"UDP Speech Sender subscribed to NPC: {sourceNPC.characterName}", ConvaiLogger.LogCategory.Character);
ConvaiLogger.Warn("SubscribeToNPCEvents: sourceNPC is null", ConvaiLogger.LogCategory.Character);
return;
}
else
if (sourceNPC.AudioManager == null)
{
ConvaiLogger.Warn("No source NPC available for speech transmission", ConvaiLogger.LogCategory.Character);
ConvaiLogger.Warn($"SubscribeToNPCEvents: AudioManager is null for {sourceNPC.characterName}", ConvaiLogger.LogCategory.Character);
return;
}
// Hook into the character talking events
sourceNPC.AudioManager.OnCharacterTalkingChanged += HandleCharacterTalkingChanged;
sourceNPC.AudioManager.OnAudioTranscriptAvailable += HandleTranscriptAvailable;
ConvaiLogger.Info($"✅ UDP Speech Sender subscribed to NPC: {sourceNPC.characterName} (on {sourceNPC.gameObject.name}), AudioManager: {sourceNPC.AudioManager.name}", ConvaiLogger.LogCategory.Character);
// Also start continuous monitoring as a fallback in case events don't fire
StartCoroutine(ContinuousAudioMonitoring());
}
private void HandleCharacterTalkingChanged(bool isTalking)
@ -170,11 +249,13 @@ namespace Convai.Scripts.Runtime.Multiplayer
if (isTalking)
{
ConvaiLogger.Info($"🔊 NPC {sourceNPC.characterName} started talking, monitoring audio clips...", ConvaiLogger.LogCategory.Character);
// Start monitoring for audio clips
StartCoroutine(MonitorAudioClips());
}
else
{
ConvaiLogger.Info($"🔊 NPC {sourceNPC.characterName} stopped talking", ConvaiLogger.LogCategory.Character);
// End speech transmission
_ = SendFinalPacket();
}
@ -190,17 +271,33 @@ namespace Convai.Scripts.Runtime.Multiplayer
private IEnumerator MonitorAudioClips()
{
if (sourceNPC?.AudioManager == null) yield break;
if (sourceNPC?.AudioManager == null)
{
ConvaiLogger.Error("MonitorAudioClips: AudioManager is null on sourceNPC", ConvaiLogger.LogCategory.Character);
yield break;
}
AudioSource audioSource = sourceNPC.AudioManager.GetComponent<AudioSource>();
AudioClip lastClip = null;
while (sourceNPC.IsCharacterTalking)
if (audioSource == null)
{
ConvaiLogger.Error($"MonitorAudioClips: No AudioSource found on AudioManager ({sourceNPC.AudioManager.name})", ConvaiLogger.LogCategory.Character);
yield break;
}
ConvaiLogger.Info($"🔊 Started monitoring audio clips on {audioSource.name} for NPC {sourceNPC.characterName}", ConvaiLogger.LogCategory.Character);
AudioClip lastClip = null;
int checkCount = 0;
while (sourceNPC != null && sourceNPC.IsCharacterTalking)
{
checkCount++;
// SIMPLIFIED: Check EVERY frame if there's a new clip, don't wait for events
if (audioSource?.clip != null && audioSource.clip != lastClip)
{
// New clip detected!
lastClip = audioSource.clip;
ConvaiLogger.Info($"🔊 NEW CLIP DETECTED: {lastClip.name}, length: {lastClip.length:F2}s, samples: {lastClip.samples}, freq: {lastClip.frequency}", ConvaiLogger.LogCategory.Character);
// Only send if we haven't sent this clip before
if (!_sentClips.Contains(lastClip))
@ -210,18 +307,135 @@ namespace Convai.Scripts.Runtime.Multiplayer
// Get the transcript from the most recent available transcript
string transcript = GetRecentTranscript();
// Start transmission if not already started
if (!_isSendingSpeech)
{
_isSendingSpeech = true;
OnSpeechTransmission?.Invoke(true);
ConvaiLogger.Info($"🔊 Starting speech transmission", ConvaiLogger.LogCategory.Character);
}
// Send this clip
ConvaiLogger.Info($"🔊 TRANSMITTING CLIP to {targetIP}:{targetPort}", ConvaiLogger.LogCategory.Character);
_ = TransmitAudioClip(lastClip, transcript);
}
else
{
ConvaiLogger.Warn($"🔊 Clip already sent, skipping: {lastClip.name}", ConvaiLogger.LogCategory.Character);
}
}
yield return new WaitForSeconds(0.1f); // Check every 100ms
// Log periodically to show we're still monitoring
if (enableDebugLogging && checkCount % 100 == 0)
{
ConvaiLogger.DebugLog($"🔊 Monitoring... check #{checkCount}, current clip: {(audioSource?.clip != null ? audioSource.clip.name : "null")}, isTalking: {sourceNPC.IsCharacterTalking}", ConvaiLogger.LogCategory.Character);
}
yield return new WaitForSeconds(0.05f); // Check every 50ms for faster detection
}
ConvaiLogger.Info($"🔊 Stopped monitoring audio clips (NPC stopped talking or was destroyed). Checks performed: {checkCount}", ConvaiLogger.LogCategory.Character);
// Clear sent clips when done
_sentClips.Clear();
}
private IEnumerator ContinuousAudioMonitoring()
{
ConvaiLogger.Info("🔊 Starting continuous audio monitoring as fallback", ConvaiLogger.LogCategory.Character);
AudioClip lastMonitoredClip = null;
float lastNPCCheckLog = 0f;
while (true)
{
// Wait a bit between checks
yield return new WaitForSeconds(0.2f);
// Check if we still have a valid source NPC
if (sourceNPC == null || sourceNPC.AudioManager == null)
{
// Log every 2 seconds if no NPC found (AGGRESSIVE LOGGING)
if (Time.time - lastNPCCheckLog > 2f)
{
if (sourceNPC == null)
ConvaiLogger.Warn("⚠️ [Speech Sender] No source NPC found! Looking for ConvaiNPC in scene...", ConvaiLogger.LogCategory.Character);
else
ConvaiLogger.Warn($"⚠️ [Speech Sender] NPC '{sourceNPC.characterName}' has no AudioManager!", ConvaiLogger.LogCategory.Character);
// Try to find NPC again
if (useActiveNPC)
{
var foundNPC = FindEnabledConvaiNPC();
if (foundNPC != null && foundNPC != sourceNPC)
{
ConvaiLogger.Info($"✅ [Speech Sender] Found NPC: {foundNPC.characterName} on GameObject '{foundNPC.gameObject.name}'", ConvaiLogger.LogCategory.Character);
sourceNPC = foundNPC;
SubscribeToNPCEvents();
}
}
lastNPCCheckLog = Time.time;
}
yield return new WaitForSeconds(1f); // Wait longer if no NPC
continue;
}
// Get the audio source
AudioSource audioSource = sourceNPC.AudioManager.GetComponent<AudioSource>();
if (audioSource == null)
{
if (Time.time - lastNPCCheckLog > 2f)
{
ConvaiLogger.Warn($"⚠️ [Speech Sender] NPC '{sourceNPC.characterName}' AudioManager has no AudioSource component!", ConvaiLogger.LogCategory.Character);
lastNPCCheckLog = Time.time;
}
yield return new WaitForSeconds(1f);
continue;
}
// Check if there's a new audio clip playing
if (audioSource.clip != null &&
audioSource.clip != lastMonitoredClip &&
audioSource.isPlaying)
{
lastMonitoredClip = audioSource.clip;
// Only send if we haven't sent this clip before
if (!_sentClips.Contains(lastMonitoredClip))
{
_sentClips.Add(lastMonitoredClip);
ConvaiLogger.Info($"🔊 [Continuous Monitor] NEW CLIP DETECTED: {lastMonitoredClip.name}, length: {lastMonitoredClip.length:F2}s", ConvaiLogger.LogCategory.Character);
// Start transmission if not already started
if (!_isSendingSpeech)
{
_isSendingSpeech = true;
OnSpeechTransmission?.Invoke(true);
}
string transcript = "";
_ = TransmitAudioClip(lastMonitoredClip, transcript);
}
}
// Clean up old clips from the sent list if NPC is not talking
if (!sourceNPC.IsCharacterTalking && _sentClips.Count > 0)
{
if (enableDebugLogging)
ConvaiLogger.DebugLog($"🔊 [Continuous Monitor] NPC stopped talking, clearing sent clips list ({_sentClips.Count} clips)", ConvaiLogger.LogCategory.Character);
_sentClips.Clear();
// Send final packet
if (_isSendingSpeech)
{
_ = SendFinalPacket();
}
}
}
}
private string GetRecentTranscript()
{
// Try to get transcript from the NPC's recent activity
@ -235,15 +449,6 @@ namespace Convai.Scripts.Runtime.Multiplayer
try
{
// Start transmission if not already started
if (!_isSendingSpeech)
{
_isSendingSpeech = true;
OnSpeechTransmission?.Invoke(true);
ConvaiLogger.Info($"🔊 Starting speech transmission", ConvaiLogger.LogCategory.Character);
}
// Use the current speech sequence for this entire clip
int clipSequence = _speechSequence;
@ -259,6 +464,10 @@ namespace Convai.Scripts.Runtime.Multiplayer
// Only increment sequence after the entire clip is sent
_speechSequence++;
// Update metrics
_totalClipsSent++;
_lastClipSentTime = DateTime.UtcNow;
OnSpeechSent?.Invoke(transcript);
if (enableDebugLogging)
@ -275,8 +484,7 @@ namespace Convai.Scripts.Runtime.Multiplayer
byte[] packet = CreateAudioStartPacket(audioClip, transcript, sequence);
await _udpClient.SendAsync(packet, packet.Length, _targetEndPoint);
if (enableDebugLogging)
ConvaiLogger.DebugLog($"📤 Sent start packet {sequence}: {audioClip.samples} samples", ConvaiLogger.LogCategory.Character);
ConvaiLogger.Info($"📤 Sent speech START packet #{sequence} (magic: 0x{MAGIC_NUMBER:X}) to {targetIP}:{targetPort}, {audioClip.samples} samples", ConvaiLogger.LogCategory.Character);
}
private async Task SendAudioClipInChunks(AudioClip audioClip, int sequence)
@ -483,6 +691,9 @@ namespace Convai.Scripts.Runtime.Multiplayer
{
ConvaiNPCManager.Instance.OnActiveNPCChanged -= HandleActiveNPCChanged;
}
// Stop all coroutines when cleaning up (will restart with new NPC)
StopAllCoroutines();
}
private void CleanupNetwork()

View File

@ -0,0 +1,714 @@
using System;
using System.Text;
using Convai.Scripts.Runtime.Multiplayer;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UI;
namespace Convai.Scripts.Runtime.Multiplayer
{
/// <summary>
/// In-game debug UI for network diagnostics in VR builds
/// Shows peer discovery, voice/speech status, and packet counters
/// </summary>
public class NetworkDebugUI : MonoBehaviour
{
[Header("UI Configuration")]
[SerializeField] private bool showOnStart = true;
[SerializeField] private KeyCode toggleKey = KeyCode.F1;
[SerializeField] private bool useVRInput = true;
[Header("UI Positioning (VR)")]
[SerializeField] private float distanceFromCamera = 2.5f;
[SerializeField] private float verticalOffset = 0.5f;
[SerializeField] private Vector3 rotationOffset = new Vector3(0, 0, 0);
[Header("Update Settings")]
[SerializeField] private float updateInterval = 0.5f; // Update UI twice per second
// UI Components
private Canvas _canvas;
private Text _debugText;
private GameObject _panel;
private Camera _mainCamera;
// Component references
private UDPPeerDiscovery _peerDiscovery;
private ConvaiSimpleUDPAudioSender _audioSender;
private ConvaiUDPSpeechSender _speechSender;
private ConvaiSimpleUDPAudioReceiver _audioReceiver;
private ConvaiUDPSpeechReceiver _speechReceiver;
// State
private bool _isVisible = true;
private float _lastUpdateTime;
private InputAction _toggleAction;
// Packet tracking for enhanced debugging
private System.Collections.Generic.Queue<string> _packetLog = new System.Collections.Generic.Queue<string>();
private const int MAX_PACKET_LOG = 15;
private int _lastAudioSentCount = 0;
private int _lastAudioReceivedCount = 0;
private int _lastSpeechSentCount = 0;
private int _lastSpeechReceivedCount = 0;
private float _lastRateCheckTime = 0f;
private void Start()
{
_mainCamera = Camera.main;
// Find components
FindNetworkComponents();
// Create UI
CreateDebugUI();
_isVisible = showOnStart;
_panel.SetActive(_isVisible);
// Setup VR input for toggle
if (useVRInput)
{
SetupVRToggleInput();
}
UpdateDebugInfo();
}
private void FindNetworkComponents()
{
_peerDiscovery = UDPPeerDiscovery.Instance;
// Always re-scan for the best (most active) component
// This is important for components on NPCs that get enabled/disabled
_audioSender = FindBestComponent<ConvaiSimpleUDPAudioSender>();
_speechSender = FindBestComponent<ConvaiUDPSpeechSender>();
_audioReceiver = FindBestComponent<ConvaiSimpleUDPAudioReceiver>();
_speechReceiver = FindBestComponent<ConvaiUDPSpeechReceiver>();
}
/// <summary>
/// Finds a component, prioritizing enabled ones in active hierarchies
/// </summary>
private T FindBestComponent<T>() where T : MonoBehaviour
{
var allComponents = FindObjectsOfType<T>(true); // Include inactive
if (allComponents.Length == 0)
return null;
// Priority 1: Enabled component in active hierarchy
foreach (var comp in allComponents)
{
if (comp.enabled && comp.gameObject.activeInHierarchy)
return comp;
}
// Priority 2: Component on active GameObject (even if component disabled)
foreach (var comp in allComponents)
{
if (comp.gameObject.activeInHierarchy)
return comp;
}
// Priority 3: Any component (even if GameObject is inactive)
return allComponents[0];
}
private void CreateDebugUI()
{
// Create canvas
GameObject canvasObj = new GameObject("NetworkDebugCanvas");
canvasObj.transform.SetParent(transform);
_canvas = canvasObj.AddComponent<Canvas>();
_canvas.renderMode = RenderMode.WorldSpace;
CanvasScaler scaler = canvasObj.AddComponent<CanvasScaler>();
scaler.dynamicPixelsPerUnit = 10;
// Create panel background
_panel = new GameObject("DebugPanel");
_panel.transform.SetParent(canvasObj.transform, false);
Image panelImage = _panel.AddComponent<Image>();
panelImage.color = new Color(0, 0, 0, 0.85f);
RectTransform panelRect = _panel.GetComponent<RectTransform>();
panelRect.sizeDelta = new Vector2(900, 1200); // Increased size for more info
// Create text
GameObject textObj = new GameObject("DebugText");
textObj.transform.SetParent(_panel.transform, false);
_debugText = textObj.AddComponent<Text>();
_debugText.font = Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf");
_debugText.fontSize = 18;
_debugText.color = Color.white;
_debugText.alignment = TextAnchor.UpperLeft;
_debugText.horizontalOverflow = HorizontalWrapMode.Overflow;
_debugText.verticalOverflow = VerticalWrapMode.Overflow;
RectTransform textRect = textObj.GetComponent<RectTransform>();
textRect.anchorMin = new Vector2(0, 0);
textRect.anchorMax = new Vector2(1, 1);
textRect.offsetMin = new Vector2(20, 20);
textRect.offsetMax = new Vector2(-20, -20);
// Setup canvas transform for VR
RectTransform canvasRect = _canvas.GetComponent<RectTransform>();
canvasRect.sizeDelta = new Vector2(900, 1200);
canvasRect.localScale = Vector3.one * 0.001f; // Scale down for VR viewing
}
private void SetupVRToggleInput()
{
try
{
// Create input action for Y/B button (left controller secondary button)
_toggleAction = new InputAction("ToggleDebug", InputActionType.Button);
// Bind to left controller secondary button (Y on Quest)
_toggleAction.AddBinding("<XRController>{LeftHand}/secondaryButton");
_toggleAction.AddBinding("<OculusTouchController>{LeftHand}/secondaryButton");
_toggleAction.AddBinding("<MetaTouchController>{LeftHand}/secondaryButton");
_toggleAction.AddBinding("<QuestProTouchController>{LeftHand}/secondaryButton");
// Also bind keyboard for editor testing
_toggleAction.AddBinding("<Keyboard>/f1");
_toggleAction.started += ctx => ToggleVisibility();
_toggleAction.Enable();
}
catch (Exception ex)
{
Debug.LogWarning($"Failed to setup VR toggle input: {ex.Message}");
}
}
private void Update()
{
// Handle keyboard toggle
if (Input.GetKeyDown(toggleKey))
{
ToggleVisibility();
}
// Update UI text periodically
if (Time.time - _lastUpdateTime >= updateInterval)
{
UpdateDebugInfo();
_lastUpdateTime = Time.time;
}
// Position UI in front of camera
if (_isVisible && _mainCamera != null)
{
PositionUIInFrontOfCamera();
}
}
private void PositionUIInFrontOfCamera()
{
Vector3 cameraForward = _mainCamera.transform.forward;
Vector3 cameraRight = _mainCamera.transform.right;
Vector3 cameraUp = _mainCamera.transform.up;
// Position in front of camera
Vector3 targetPosition = _mainCamera.transform.position +
cameraForward * distanceFromCamera +
cameraUp * verticalOffset;
_canvas.transform.position = targetPosition;
// Rotate to face camera
_canvas.transform.rotation = Quaternion.LookRotation(cameraForward, cameraUp);
_canvas.transform.Rotate(rotationOffset);
}
private void ToggleVisibility()
{
_isVisible = !_isVisible;
_panel.SetActive(_isVisible);
}
private void UpdateDebugInfo()
{
if (_debugText == null) return;
// Re-check for components that might have been disabled at startup
FindNetworkComponents();
StringBuilder sb = new StringBuilder();
// Header
sb.AppendLine("═══ NETWORK DEBUG ═══");
sb.AppendLine($"Time: {DateTime.Now:HH:mm:ss}");
sb.AppendLine();
// Port Binding Status
AppendPortBindingStatus(sb);
sb.AppendLine();
// Peer Discovery Status
AppendPeerDiscoveryStatus(sb);
sb.AppendLine();
// Audio Sender (Voice Input)
AppendAudioSenderStatus(sb);
sb.AppendLine();
// Speech Sender (NPC Response)
AppendSpeechSenderStatus(sb);
sb.AppendLine();
// Audio Receiver
AppendAudioReceiverStatus(sb);
sb.AppendLine();
// Speech Receiver
AppendSpeechReceiverStatus(sb);
sb.AppendLine();
// Packet Rates
AppendPacketRates(sb);
sb.AppendLine();
// Event Log
AppendEventLog(sb);
sb.AppendLine();
// Packet Log
AppendPacketLog(sb);
_debugText.text = sb.ToString();
}
private void AppendPortBindingStatus(StringBuilder sb)
{
sb.AppendLine("🔌 PORT BINDING STATUS");
// Check if shared listener is active
bool sharedListenerActive = SharedUDPListener.Instance != null && SharedUDPListener.Instance.IsListening;
if (sharedListenerActive)
{
sb.AppendLine($"✅ Shared UDP Listener ACTIVE");
sb.AppendLine($" Port: {SharedUDPListener.Instance.ListenPort}");
sb.AppendLine($" Total Packets: {SharedUDPListener.Instance.TotalPacketsReceived}");
}
else
{
sb.AppendLine($"❌ Shared UDP Listener NOT FOUND!");
sb.AppendLine($" Add SharedUDPListener to scene!");
}
// Check which components are subscribed
bool discoveryActive = _peerDiscovery != null;
bool audioReceiverActive = _audioReceiver != null;
bool speechReceiverActive = _speechReceiver != null;
sb.AppendLine($"Discovery: {(discoveryActive ? " SUBSCRIBED" : " NOT FOUND")} (0x44495343)");
sb.AppendLine($"Audio RX: {(audioReceiverActive ? " SUBSCRIBED" : " NOT FOUND")} (0xC0A1)");
sb.AppendLine($"Speech RX: {(speechReceiverActive ? " SUBSCRIBED" : " NOT FOUND")} (0xC0A3)");
if (!sharedListenerActive)
{
sb.AppendLine("⚠️ CRITICAL: No shared listener!");
}
}
private void AppendPeerDiscoveryStatus(StringBuilder sb)
{
sb.AppendLine("🔍 PEER DISCOVERY");
if (_peerDiscovery != null)
{
string stateColor = GetConnectionStateColor(_peerDiscovery.CurrentState);
string stateIcon = GetConnectionStateIcon(_peerDiscovery.CurrentState);
sb.AppendLine($"State: {_peerDiscovery.CurrentState} {stateIcon}");
sb.AppendLine($"Local Player ID: {_peerDiscovery.LocalPlayerID}");
sb.AppendLine($"Peer Player ID: {(_peerDiscovery.PeerPlayerID > 0 ? _peerDiscovery.PeerPlayerID.ToString() : "None")}");
sb.AppendLine($"Peer IP: {(_peerDiscovery.IsConnected ? _peerDiscovery.PeerIP : "None")}");
if (_peerDiscovery.IsConnected)
{
float timeSince = _peerDiscovery.TimeSinceLastPeerPacket;
sb.AppendLine($"Last Packet: {timeSince:F1}s ago");
}
}
else
{
sb.AppendLine("❌ NOT FOUND - Add UDPPeerDiscovery component!");
}
}
private void AppendAudioSenderStatus(StringBuilder sb)
{
sb.AppendLine("🎤 VOICE INPUT SENDER");
if (_audioSender != null && _audioSender.gameObject != null)
{
// Check if component's GameObject is active AND enabled in hierarchy
bool isActive = _audioSender.enabled && _audioSender.gameObject.activeInHierarchy;
if (!isActive)
{
sb.AppendLine("⏸️ FOUND BUT DISABLED");
sb.AppendLine($"(GameObject: {_audioSender.gameObject.name})");
sb.AppendLine($"(Active: {_audioSender.gameObject.activeSelf}, InHierarchy: {_audioSender.gameObject.activeInHierarchy})");
}
else
{
sb.AppendLine($"GameObject: {_audioSender.gameObject.name} ✅");
sb.AppendLine($"Target: {_audioSender.CurrentTargetIP}:{_audioSender.CurrentTargetPort}");
sb.AppendLine($"Recording: {(_audioSender.IsRecording ? "YES " : "NO")}");
sb.AppendLine($"Packets Sent: {_audioSender.TotalPacketsSent}");
float timeSince = _audioSender.TimeSinceLastSend;
if (timeSince >= 0)
{
sb.AppendLine($"Last Send: {timeSince:F1}s ago");
}
else
{
sb.AppendLine($"Last Send: Never");
}
sb.AppendLine($"Using Discovery: {(_audioSender.UsingDiscovery ? "YES" : "NO")}");
// WARNING if not sending packets while recording
if (_audioSender.IsRecording && _audioSender.TotalPacketsSent == 0)
{
sb.AppendLine("⚠️ RECORDING BUT NO PACKETS SENT!");
}
}
}
else
{
sb.AppendLine("❌ NOT FOUND IN SCENE");
}
}
private void AppendSpeechSenderStatus(StringBuilder sb)
{
sb.AppendLine("🔊 SPEECH SENDER");
if (_speechSender != null && _speechSender.gameObject != null)
{
// Check if component's GameObject is active AND enabled in hierarchy
bool isActive = _speechSender.enabled && _speechSender.gameObject.activeInHierarchy;
if (!isActive)
{
sb.AppendLine("⏸️ FOUND BUT DISABLED");
sb.AppendLine($"(GameObject: {_speechSender.gameObject.name})");
sb.AppendLine($"(Active: {_speechSender.gameObject.activeSelf}, InHierarchy: {_speechSender.gameObject.activeInHierarchy})");
}
else
{
sb.AppendLine($"GameObject: {_speechSender.gameObject.name} ✅");
sb.AppendLine($"Target: {_speechSender.CurrentTargetIP}:{_speechSender.CurrentTargetPort}");
// Show source NPC
var sourceNPC = _speechSender.SourceNPC;
if (sourceNPC != null)
{
sb.AppendLine($"Source NPC: {sourceNPC.characterName} ✅");
// Show if NPC is currently talking
if (sourceNPC.IsCharacterTalking)
{
sb.AppendLine($"NPC Talking: YES 🗣️");
}
}
else
{
sb.AppendLine($"Source NPC: None ⚠️");
}
sb.AppendLine($"Transmitting: {(_speechSender.IsSendingSpeech ? "YES " : "NO")}");
sb.AppendLine($"Clips Sent: {_speechSender.TotalClipsSent}");
float timeSince = _speechSender.TimeSinceLastSend;
if (timeSince >= 0)
{
sb.AppendLine($"Last Send: {timeSince:F1}s ago");
}
else
{
sb.AppendLine($"Last Send: Never");
}
sb.AppendLine($"Using Discovery: {(_speechSender.UsingDiscovery ? "YES" : "NO")}");
// WARNING if transmitting but no clips sent
if (_speechSender.IsSendingSpeech && _speechSender.TotalClipsSent == 0)
{
sb.AppendLine("⚠️ TRANSMITTING BUT NO CLIPS SENT!");
}
}
}
else
{
sb.AppendLine("❌ NOT FOUND IN SCENE");
}
}
private void AppendAudioReceiverStatus(StringBuilder sb)
{
sb.AppendLine("🎧 VOICE INPUT RECEIVER");
if (_audioReceiver != null && _audioReceiver.gameObject != null)
{
// Check if component's GameObject is active AND enabled in hierarchy
bool isActive = _audioReceiver.enabled && _audioReceiver.gameObject.activeInHierarchy;
if (!isActive)
{
sb.AppendLine("⏸️ FOUND BUT DISABLED");
sb.AppendLine($"(GameObject: {_audioReceiver.gameObject.name})");
sb.AppendLine($"(Active: {_audioReceiver.gameObject.activeSelf}, InHierarchy: {_audioReceiver.gameObject.activeInHierarchy})");
}
else
{
sb.AppendLine($"GameObject: {_audioReceiver.gameObject.name} ✅");
sb.AppendLine($"Listen Port: {_audioReceiver.ListenPort} (shared)");
sb.AppendLine($"Listening: {(_audioReceiver.IsListening ? "YES " : "NO ")}");
// Show active NPC
var targetNPC = _audioReceiver.TargetNPC;
if (targetNPC != null)
{
sb.AppendLine($"Active NPC: {targetNPC.characterName} ✅");
}
else
{
sb.AppendLine($"Active NPC: None ⚠️");
}
sb.AppendLine($"Receiving: {(_audioReceiver.IsReceivingAudio ? "YES " : "NO")}");
sb.AppendLine($"Packets Received: {_audioReceiver.TotalPacketsReceived}");
float timeSince = _audioReceiver.TimeSinceLastReceive;
if (timeSince >= 0)
{
sb.AppendLine($"Last Receive: {timeSince:F1}s ago");
}
else
{
sb.AppendLine($"Last Receive: Never");
}
// WARNING if not listening
if (!_audioReceiver.IsListening)
{
sb.AppendLine("⚠️ NOT LISTENING - PORT BIND FAILED?");
}
}
}
else
{
sb.AppendLine("❌ NOT FOUND IN SCENE");
}
}
private void AppendSpeechReceiverStatus(StringBuilder sb)
{
sb.AppendLine("🔉 SPEECH RECEIVER");
if (_speechReceiver != null && _speechReceiver.gameObject != null)
{
// Check if component's GameObject is active AND enabled in hierarchy
bool isActive = _speechReceiver.enabled && _speechReceiver.gameObject.activeInHierarchy;
if (!isActive)
{
sb.AppendLine("⏸️ FOUND BUT DISABLED");
sb.AppendLine($"(GameObject: {_speechReceiver.gameObject.name})");
sb.AppendLine($"(Active: {_speechReceiver.gameObject.activeSelf}, InHierarchy: {_speechReceiver.gameObject.activeInHierarchy})");
}
else
{
sb.AppendLine($"GameObject: {_speechReceiver.gameObject.name} ✅");
sb.AppendLine($"Listen Port: {_speechReceiver.ListenPort} (shared)");
sb.AppendLine($"Listening: {(_speechReceiver.IsListening ? "YES " : "NO ")}");
sb.AppendLine($"Playing: {(_speechReceiver.IsPlayingSequence ? "YES " : "NO")}");
sb.AppendLine($"Clips Received: {_speechReceiver.TotalClipsReceived}");
sb.AppendLine($"Queued Clips: {_speechReceiver.QueuedClipCount}");
float timeSince = _speechReceiver.TimeSinceLastReceive;
if (timeSince >= 0)
{
sb.AppendLine($"Last Receive: {timeSince:F1}s ago");
}
else
{
sb.AppendLine($"Last Receive: Never");
}
// WARNING if not listening
if (!_speechReceiver.IsListening)
{
sb.AppendLine("⚠️ NOT LISTENING - PORT BIND FAILED?");
}
}
}
else
{
sb.AppendLine("❌ NOT FOUND IN SCENE");
}
}
private void AppendPacketRates(StringBuilder sb)
{
sb.AppendLine("📊 PACKET RATES (last second)");
float currentTime = Time.time;
float deltaTime = currentTime - _lastRateCheckTime;
if (deltaTime >= 1f)
{
// Calculate rates
int audioSentDelta = 0;
int audioReceivedDelta = 0;
int speechSentDelta = 0;
int speechReceivedDelta = 0;
if (_audioSender != null)
{
audioSentDelta = _audioSender.TotalPacketsSent - _lastAudioSentCount;
_lastAudioSentCount = _audioSender.TotalPacketsSent;
}
if (_audioReceiver != null)
{
audioReceivedDelta = _audioReceiver.TotalPacketsReceived - _lastAudioReceivedCount;
_lastAudioReceivedCount = _audioReceiver.TotalPacketsReceived;
}
if (_speechSender != null)
{
speechSentDelta = _speechSender.TotalClipsSent - _lastSpeechSentCount;
_lastSpeechSentCount = _speechSender.TotalClipsSent;
}
if (_speechReceiver != null)
{
speechReceivedDelta = _speechReceiver.TotalClipsReceived - _lastSpeechReceivedCount;
_lastSpeechReceivedCount = _speechReceiver.TotalClipsReceived;
}
_lastRateCheckTime = currentTime;
// Log significant activity
if (audioSentDelta > 0)
LogPacketActivity($"Sent {audioSentDelta} voice packets (0xC0A1)");
if (audioReceivedDelta > 0)
LogPacketActivity($"Received {audioReceivedDelta} voice packets (0xC0A1)");
if (speechSentDelta > 0)
LogPacketActivity($"Sent {speechSentDelta} speech clips (0xC0A3)");
if (speechReceivedDelta > 0)
LogPacketActivity($"Received {speechReceivedDelta} speech clips (0xC0A3)");
}
sb.AppendLine($"Voice Sent: {(_audioSender != null ? _audioSender.TotalPacketsSent : 0)} total");
sb.AppendLine($"Voice Received: {(_audioReceiver != null ? _audioReceiver.TotalPacketsReceived : 0)} total");
sb.AppendLine($"Speech Sent: {(_speechSender != null ? _speechSender.TotalClipsSent : 0)} clips");
sb.AppendLine($"Speech Received: {(_speechReceiver != null ? _speechReceiver.TotalClipsReceived : 0)} clips");
}
private void LogPacketActivity(string message)
{
string timestamp = DateTime.Now.ToString("HH:mm:ss");
_packetLog.Enqueue($"[{timestamp}] {message}");
while (_packetLog.Count > MAX_PACKET_LOG)
{
_packetLog.Dequeue();
}
}
private void AppendPacketLog(StringBuilder sb)
{
sb.AppendLine("📦 PACKET ACTIVITY LOG");
if (_packetLog.Count > 0)
{
foreach (var entry in _packetLog)
{
sb.AppendLine(entry);
}
}
else
{
sb.AppendLine("No packet activity yet");
}
}
private void AppendEventLog(StringBuilder sb)
{
sb.AppendLine("📋 CONNECTION EVENTS");
if (_peerDiscovery != null && _peerDiscovery.EventLog.Count > 0)
{
int startIndex = Math.Max(0, _peerDiscovery.EventLog.Count - 6);
for (int i = startIndex; i < _peerDiscovery.EventLog.Count; i++)
{
sb.AppendLine(_peerDiscovery.EventLog[i]);
}
}
else
{
sb.AppendLine("No events yet");
}
}
private string GetConnectionStateColor(UDPPeerDiscovery.ConnectionState state)
{
return state switch
{
UDPPeerDiscovery.ConnectionState.Connected => "green",
UDPPeerDiscovery.ConnectionState.Discovering => "yellow",
UDPPeerDiscovery.ConnectionState.Lost => "red",
_ => "white"
};
}
private string GetConnectionStateIcon(UDPPeerDiscovery.ConnectionState state)
{
return state switch
{
UDPPeerDiscovery.ConnectionState.Connected => "✅",
UDPPeerDiscovery.ConnectionState.Discovering => "⏳",
UDPPeerDiscovery.ConnectionState.Lost => "❌",
UDPPeerDiscovery.ConnectionState.Disconnected => "⭕",
_ => "❓"
};
}
private void OnDestroy()
{
if (_toggleAction != null)
{
_toggleAction.Disable();
_toggleAction.Dispose();
}
}
// Public methods for external control
public void Show()
{
_isVisible = true;
_panel.SetActive(true);
}
public void Hide()
{
_isVisible = false;
_panel.SetActive(false);
}
}
}

View File

@ -0,0 +1,12 @@
fileFormatVersion: 2
guid: b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,195 @@
using System;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using Convai.Scripts.Runtime.LoggerSystem;
using UnityEngine;
namespace Convai.Scripts.Runtime.Multiplayer
{
/// <summary>
/// Shared UDP Listener - Single UDP socket that dispatches packets to appropriate handlers
/// Solves the port binding issue where multiple components tried to bind to the same port
/// </summary>
[DefaultExecutionOrder(-10000)]
public class SharedUDPListener : MonoBehaviour
{
// Singleton
private static SharedUDPListener _instance;
public static SharedUDPListener Instance => _instance;
// Network components
private UdpClient _udpClient;
private bool _isListening = false;
private int _listenPort;
private CancellationTokenSource _cancellationTokenSource;
// Packet handlers - registered by other components
public event Action<byte[], IPEndPoint> OnPacketReceived;
// Metrics
private int _totalPacketsReceived = 0;
private DateTime _lastPacketTime;
public int TotalPacketsReceived => _totalPacketsReceived;
public bool IsListening => _isListening;
public int ListenPort => _listenPort;
private void Awake()
{
// Singleton pattern
if (_instance != null && _instance != this)
{
Destroy(gameObject);
return;
}
_instance = this;
DontDestroyOnLoad(gameObject);
}
private void Start()
{
// Get network config
var cfg = NetworkConfig.Instance;
if (cfg != null)
{
_listenPort = cfg.port;
}
else
{
ConvaiLogger.Error("NetworkConfig not found! Using default port 1221", ConvaiLogger.LogCategory.Character);
_listenPort = 1221;
}
_cancellationTokenSource = new CancellationTokenSource();
StartListening();
}
private void OnDestroy()
{
StopListening();
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
if (_instance == this)
_instance = null;
}
private void StartListening()
{
if (_isListening) return;
try
{
// Create single UDP client for ALL network traffic
_udpClient = new UdpClient();
_udpClient.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
_udpClient.Client.Bind(new IPEndPoint(IPAddress.Any, _listenPort));
_udpClient.EnableBroadcast = true;
_isListening = true;
ConvaiLogger.Info($"✅ Shared UDP Listener BOUND to port {_listenPort}", ConvaiLogger.LogCategory.Character);
ConvaiLogger.Info($" Will dispatch packets to handlers based on magic numbers:", ConvaiLogger.LogCategory.Character);
ConvaiLogger.Info($" - Discovery: 0x44495343", ConvaiLogger.LogCategory.Character);
ConvaiLogger.Info($" - Voice Audio: 0xC0A1", ConvaiLogger.LogCategory.Character);
ConvaiLogger.Info($" - NPC Speech: 0xC0A3", ConvaiLogger.LogCategory.Character);
ConvaiLogger.Info($" - Avatar Sync: 0xC0A0", ConvaiLogger.LogCategory.Character);
ConvaiLogger.Info($" - Experiment Control: JSON (text-based)", ConvaiLogger.LogCategory.Character);
// Start listening for all packets
_ = ListenForPackets(_cancellationTokenSource.Token);
}
catch (Exception ex)
{
ConvaiLogger.Error($"❌ FAILED to bind Shared UDP Listener to port {_listenPort}: {ex.Message}", ConvaiLogger.LogCategory.Character);
ConvaiLogger.Error($"Stack trace: {ex.StackTrace}", ConvaiLogger.LogCategory.Character);
}
}
private void StopListening()
{
if (!_isListening) return;
_isListening = false;
_udpClient?.Close();
_udpClient?.Dispose();
_udpClient = null;
ConvaiLogger.Info("Shared UDP Listener stopped", ConvaiLogger.LogCategory.Character);
}
private async Task ListenForPackets(CancellationToken cancellationToken)
{
try
{
while (_isListening && !cancellationToken.IsCancellationRequested)
{
var result = await _udpClient.ReceiveAsync();
_totalPacketsReceived++;
_lastPacketTime = DateTime.UtcNow;
// Dispatch to all registered handlers
OnPacketReceived?.Invoke(result.Buffer, result.RemoteEndPoint);
}
}
catch (ObjectDisposedException)
{
// Normal when stopping
}
catch (Exception ex)
{
if (_isListening)
{
ConvaiLogger.Error($"Error in shared UDP listener: {ex.Message}", ConvaiLogger.LogCategory.Character);
}
}
}
/// <summary>
/// Send a packet through the shared UDP client
/// </summary>
public async Task<int> SendAsync(byte[] data, IPEndPoint endPoint)
{
if (_udpClient == null || !_isListening)
{
ConvaiLogger.Warn("Cannot send packet - UDP client not initialized", ConvaiLogger.LogCategory.Character);
return 0;
}
try
{
return await _udpClient.SendAsync(data, data.Length, endPoint);
}
catch (Exception ex)
{
ConvaiLogger.Error($"Failed to send UDP packet: {ex.Message}", ConvaiLogger.LogCategory.Character);
return 0;
}
}
/// <summary>
/// Send a broadcast packet
/// </summary>
public async Task<int> SendBroadcastAsync(byte[] data, int port)
{
if (_udpClient == null || !_isListening)
{
ConvaiLogger.Warn("Cannot send broadcast - UDP client not initialized", ConvaiLogger.LogCategory.Character);
return 0;
}
try
{
var broadcastEndpoint = new IPEndPoint(IPAddress.Broadcast, port);
return await _udpClient.SendAsync(data, data.Length, broadcastEndpoint);
}
catch (Exception ex)
{
ConvaiLogger.Error($"Failed to send broadcast packet: {ex.Message}", ConvaiLogger.LogCategory.Character);
return 0;
}
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d600859d24aeed44da6b10910a773ab3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,6 +1,5 @@
using System;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using Convai.Scripts.Runtime.LoggerSystem;
@ -29,7 +28,6 @@ namespace Convai.Scripts.Runtime.Multiplayer
public event Action<ConnectionState> OnConnectionStateChanged;
// Network components
private UdpClient _udpClient;
private bool _isRunning = false;
private int _listenPort;
private CancellationTokenSource _cancellationTokenSource;
@ -39,6 +37,7 @@ namespace Convai.Scripts.Runtime.Multiplayer
private byte _peerPlayerID = 0;
private DateTime _lastPeerPacketTime;
private ConnectionState _connectionState = ConnectionState.Disconnected;
private bool _hasEverConnected = false; // Track if we've connected this session
// Discovery protocol constants
private const uint DISCOVERY_MAGIC = 0x44495343; // "DISC" in hex
@ -58,12 +57,19 @@ namespace Convai.Scripts.Runtime.Multiplayer
Lost
}
// Event log for debugging
private System.Collections.Generic.List<string> _eventLog = new System.Collections.Generic.List<string>();
private const int MAX_LOG_ENTRIES = 20;
// Public properties
public string PeerIP => _peerIP;
public byte LocalPlayerID => localPlayerID;
public byte PeerPlayerID => _peerPlayerID;
public ConnectionState CurrentState => _connectionState;
public bool IsConnected => _connectionState == ConnectionState.Connected;
public float TimeSinceLastPeerPacket => _connectionState == ConnectionState.Connected ?
(float)(DateTime.UtcNow - _lastPeerPacketTime).TotalSeconds : -1f;
public System.Collections.Generic.List<string> EventLog => _eventLog;
private void Awake()
{
@ -91,6 +97,9 @@ namespace Convai.Scripts.Runtime.Multiplayer
}
_cancellationTokenSource = new CancellationTokenSource();
// Always start with discovery on game launch (IP addresses may have changed)
LogEvent($"🔍 Discovery started (Player {localPlayerID})");
StartDiscovery();
}
@ -106,15 +115,8 @@ namespace Convai.Scripts.Runtime.Multiplayer
private void Update()
{
// Check for peer timeout
if (_connectionState == ConnectionState.Connected)
{
TimeSpan timeSinceLastPacket = DateTime.UtcNow - _lastPeerPacketTime;
if (timeSinceLastPacket.TotalSeconds > peerTimeoutSeconds)
{
HandlePeerLost();
}
}
// No timeout checks - once connected, stay connected until restart
// Removed: peer timeout monitoring
}
private void StartDiscovery()
@ -123,25 +125,25 @@ namespace Convai.Scripts.Runtime.Multiplayer
try
{
// Create UDP client with port reuse for shared port
_udpClient = new UdpClient();
_udpClient.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
_udpClient.Client.Bind(new IPEndPoint(IPAddress.Any, _listenPort));
_udpClient.EnableBroadcast = true;
// Wait for shared listener to be ready
if (SharedUDPListener.Instance == null)
{
ConvaiLogger.Error("SharedUDPListener not found! Make sure it's in the scene.", ConvaiLogger.LogCategory.Character);
return;
}
// Subscribe to shared listener
SharedUDPListener.Instance.OnPacketReceived += HandlePacketReceived;
_isRunning = true;
SetConnectionState(ConnectionState.Discovering);
ConvaiLogger.Info($"🔍 Peer Discovery started - Player {localPlayerID} on port {_listenPort}", ConvaiLogger.LogCategory.Character);
// Start listening for discovery packets
_ = ListenForDiscoveryPackets(_cancellationTokenSource.Token);
ConvaiLogger.Info($" Peer Discovery subscribed to shared listener - Player {localPlayerID}, listening for magic 0x{DISCOVERY_MAGIC:X}", ConvaiLogger.LogCategory.Character);
// Start broadcasting discovery requests
_ = BroadcastDiscoveryRequests(_cancellationTokenSource.Token);
// Start heartbeat when connected
_ = SendHeartbeats(_cancellationTokenSource.Token);
// NO HEARTBEATS - once connected, stay connected
}
catch (Exception ex)
{
@ -154,36 +156,27 @@ namespace Convai.Scripts.Runtime.Multiplayer
if (!_isRunning) return;
_isRunning = false;
_udpClient?.Close();
_udpClient?.Dispose();
_udpClient = null;
// Unsubscribe from shared listener
if (SharedUDPListener.Instance != null)
{
SharedUDPListener.Instance.OnPacketReceived -= HandlePacketReceived;
}
SetConnectionState(ConnectionState.Disconnected);
ConvaiLogger.Info("Peer Discovery stopped", ConvaiLogger.LogCategory.Character);
}
private async Task ListenForDiscoveryPackets(CancellationToken cancellationToken)
private void HandlePacketReceived(byte[] data, IPEndPoint senderEndPoint)
{
while (_isRunning && !cancellationToken.IsCancellationRequested)
{
try
{
var result = await _udpClient.ReceiveAsync();
await ProcessDiscoveryPacket(result.Buffer, result.RemoteEndPoint);
}
catch (ObjectDisposedException)
{
// Normal when stopping
break;
}
catch (Exception ex)
{
if (_isRunning)
{
ConvaiLogger.Error($"Error receiving discovery packet: {ex.Message}", ConvaiLogger.LogCategory.Character);
}
}
}
// Check if this is a discovery packet (by magic number)
if (data.Length < 14) return;
uint magic = BitConverter.ToUInt32(data, 0);
if (magic != DISCOVERY_MAGIC) return;
// Process discovery packet
_ = ProcessDiscoveryPacket(data, senderEndPoint);
}
private async Task BroadcastDiscoveryRequests(CancellationToken cancellationToken)
@ -192,8 +185,9 @@ namespace Convai.Scripts.Runtime.Multiplayer
{
try
{
// Only broadcast if we're still discovering
if (_connectionState == ConnectionState.Discovering || _connectionState == ConnectionState.Lost)
// Only broadcast if we're discovering AND haven't connected yet this session
// Once connected, we remember the peer IP for this session
if (_connectionState == ConnectionState.Discovering && !_hasEverConnected)
{
await SendDiscoveryRequest();
}
@ -219,8 +213,10 @@ namespace Convai.Scripts.Runtime.Multiplayer
{
await Task.Delay((int)(heartbeatInterval * 1000), cancellationToken);
// Only send heartbeat if connected
if (_connectionState == ConnectionState.Connected && !string.IsNullOrEmpty(_peerIP))
// Send heartbeat if we have a peer IP (Connected or Lost state)
// This allows automatic reconnection when peer comes back online
if (!string.IsNullOrEmpty(_peerIP) &&
(_connectionState == ConnectionState.Connected || _connectionState == ConnectionState.Lost || _connectionState == ConnectionState.Discovering))
{
await SendHeartbeat();
}
@ -242,9 +238,8 @@ namespace Convai.Scripts.Runtime.Multiplayer
{
byte[] packet = CreateDiscoveryPacket(PACKET_TYPE_REQUEST);
// Broadcast to subnet (will be blocked by AP isolation but we try anyway)
var broadcastEndPoint = new IPEndPoint(IPAddress.Broadcast, _listenPort);
await _udpClient.SendAsync(packet, packet.Length, broadcastEndPoint);
// Broadcast to subnet using shared listener
await SharedUDPListener.Instance.SendBroadcastAsync(packet, _listenPort);
if (enableDebugLogging && UnityEngine.Random.value < 0.1f) // Log 10% of broadcasts to reduce spam
{
@ -262,7 +257,7 @@ namespace Convai.Scripts.Runtime.Multiplayer
try
{
byte[] packet = CreateDiscoveryPacket(PACKET_TYPE_RESPONSE);
await _udpClient.SendAsync(packet, packet.Length, targetEndPoint);
await SharedUDPListener.Instance.SendAsync(packet, targetEndPoint);
if (enableDebugLogging)
{
@ -283,7 +278,7 @@ namespace Convai.Scripts.Runtime.Multiplayer
byte[] packet = CreateDiscoveryPacket(PACKET_TYPE_HEARTBEAT);
var peerEndPoint = new IPEndPoint(IPAddress.Parse(_peerIP), _listenPort);
await _udpClient.SendAsync(packet, packet.Length, peerEndPoint);
await SharedUDPListener.Instance.SendAsync(packet, peerEndPoint);
}
catch (Exception ex)
{
@ -373,16 +368,8 @@ namespace Convai.Scripts.Runtime.Multiplayer
break;
case PACKET_TYPE_HEARTBEAT:
// Heartbeat keeps connection alive
if (_connectionState == ConnectionState.Connected && _peerIP == senderIP)
{
// Connection still alive
}
else if (_connectionState != ConnectionState.Connected)
{
// Reconnected
HandlePeerDiscovered(senderIP, senderPlayerID);
}
// Ignore heartbeats - no reconnection logic
// Once connected, stay connected until restart
break;
}
}
@ -397,10 +384,12 @@ namespace Convai.Scripts.Runtime.Multiplayer
_peerIP = peerIP;
_peerPlayerID = peerPlayerID;
_lastPeerPacketTime = DateTime.UtcNow;
_hasEverConnected = true; // Mark that we've connected this session
SetConnectionState(ConnectionState.Connected);
ConvaiLogger.Info($"✅ Peer discovered! Player {peerPlayerID} at {peerIP}", ConvaiLogger.LogCategory.Character);
ConvaiLogger.Info($"✅ Peer discovered! Player {peerPlayerID} at {peerIP} (will remember for this session)", ConvaiLogger.LogCategory.Character);
LogEvent($"✅ Peer discovered! Player {peerPlayerID} at {peerIP}");
// Notify listeners
OnPeerDiscovered?.Invoke(peerIP);
@ -408,19 +397,12 @@ namespace Convai.Scripts.Runtime.Multiplayer
private void HandlePeerLost()
{
ConvaiLogger.Warn($"⚠️ Peer connection lost (Player {_peerPlayerID} at {_peerIP})", ConvaiLogger.LogCategory.Character);
ConvaiLogger.Warn($"⚠️ Peer connection lost (Player {_peerPlayerID} at {_peerIP}) - but ignoring, keeping connection alive", ConvaiLogger.LogCategory.Character);
LogEvent($"⚠️ Peer connection lost (Player {_peerPlayerID}) - IGNORING (stay connected until restart)");
string lostPeerIP = _peerIP;
_peerIP = "";
_peerPlayerID = 0;
SetConnectionState(ConnectionState.Lost);
// Notify listeners
OnPeerLost?.Invoke();
// Restart discovery
SetConnectionState(ConnectionState.Discovering);
// DO NOTHING - keep the connection state as Connected
// Once connected at the start of the session, stay connected forever
// Senders will continue to send to the stored IP
}
private void SetConnectionState(ConnectionState newState)
@ -441,8 +423,12 @@ namespace Convai.Scripts.Runtime.Multiplayer
public void RestartDiscovery()
{
ConvaiLogger.Info("Manually restarting peer discovery", ConvaiLogger.LogCategory.Character);
LogEvent("🔄 Manually restarting discovery");
// Reset state
_peerIP = "";
_peerPlayerID = 0;
_hasEverConnected = false;
SetConnectionState(ConnectionState.Discovering);
}
@ -454,6 +440,20 @@ namespace Convai.Scripts.Runtime.Multiplayer
ConvaiLogger.Info($"Peer IP: {(_peerIP ?? "None")}", ConvaiLogger.LogCategory.Character);
ConvaiLogger.Info($"Peer Player ID: {_peerPlayerID}", ConvaiLogger.LogCategory.Character);
ConvaiLogger.Info($"Listen Port: {_listenPort}", ConvaiLogger.LogCategory.Character);
ConvaiLogger.Info($"Has Ever Connected This Session: {_hasEverConnected}", ConvaiLogger.LogCategory.Character);
}
private void LogEvent(string message)
{
string timestamp = DateTime.Now.ToString("HH:mm:ss");
string logEntry = $"[{timestamp}] {message}";
_eventLog.Add(logEntry);
// Keep only last N entries
if (_eventLog.Count > MAX_LOG_ENTRIES)
{
_eventLog.RemoveAt(0);
}
}
}
}

Binary file not shown.

View File

@ -2,9 +2,6 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using UnityEngine;
public class UDPAvatarReceiver : MonoBehaviour
@ -38,9 +35,6 @@ public class UDPAvatarReceiver : MonoBehaviour
[SerializeField] private bool showDebugInfo = false;
[SerializeField] private bool logReceivedPackets = false;
private UdpClient udpClient;
private Thread udpListenerThread;
private bool threadRunning = false;
private int listenPort;
private Dictionary<string, Transform> boneCache;
private List<Transform> allBones; // For full data mode
@ -104,7 +98,7 @@ public class UDPAvatarReceiver : MonoBehaviour
if (enableReceiver)
{
StartUDPListener();
StartCoroutine(WaitAndSubscribe());
}
if (showDebugInfo)
@ -205,122 +199,71 @@ public class UDPAvatarReceiver : MonoBehaviour
{
try
{
if (allowPortSharing)
{
// Create UDP client with port reuse for local testing
udpClient = new UdpClient();
udpClient.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
udpClient.Client.Bind(new IPEndPoint(IPAddress.Any, listenPort));
}
else
{
// Standard UDP client binding
udpClient = new UdpClient(listenPort);
}
threadRunning = true;
udpListenerThread = new Thread(new ThreadStart(UDPListenerThread));
udpListenerThread.IsBackground = true;
udpListenerThread.Start();
// Subscribe to shared listener
Convai.Scripts.Runtime.Multiplayer.SharedUDPListener.Instance.OnPacketReceived += HandlePacketReceived;
if (showDebugInfo)
Debug.Log($"UDP Avatar Receiver started on port {listenPort} (filtering binary avatar data only, Port sharing: {allowPortSharing})");
Debug.Log($"UDP Avatar Receiver subscribed to shared listener on port {listenPort} (filtering avatar magic 0x{AVATAR_MAGIC:X})");
}
catch (Exception e)
{
if (allowPortSharing)
Debug.LogError($"Failed to subscribe to shared UDP listener: {e.Message}");
enableReceiver = false;
}
}
System.Collections.IEnumerator WaitAndSubscribe()
{
float timeout = 3f;
while (Convai.Scripts.Runtime.Multiplayer.SharedUDPListener.Instance == null && timeout > 0f)
{
timeout -= Time.unscaledDeltaTime;
yield return null;
}
if (Convai.Scripts.Runtime.Multiplayer.SharedUDPListener.Instance == null)
{
Debug.LogError("SharedUDPListener not ready after wait.");
enableReceiver = false;
yield break;
}
StartUDPListener();
}
void HandlePacketReceived(byte[] data, IPEndPoint senderEndPoint)
{
// Check if this is avatar data (by magic number)
if (!IsAvatarData(data)) return;
// Process avatar packet
CompactAvatarData avatarData = DeserializeCompactData(data);
// Check if this is from the target player (0 means accept from any player)
if (targetPlayerID == 0 || avatarData.playerID == targetPlayerID)
{
// Check for packet loss
if (avatarData.sequenceNumber > lastSequenceNumber + 1)
{
Debug.LogWarning($"Failed to start UDP listener with port sharing: {e.Message}");
Debug.LogWarning("Trying with different port...");
TryAlternativePort();
packetsDropped += (int)(avatarData.sequenceNumber - lastSequenceNumber - 1);
}
else
lastSequenceNumber = avatarData.sequenceNumber;
packetsReceived++;
// Store the new data (thread-safe)
lock (dataLock)
{
Debug.LogError($"Failed to start UDP listener: {e.Message}");
enableReceiver = false;
lastReceivedData = avatarData;
hasNewData = true;
}
if (logReceivedPackets && packetsReceived % 30 == 0)
{
string modeStr = avatarData.isFullDataMode ? "FULL" : "OPT";
Debug.Log($"Received {modeStr} packet #{avatarData.sequenceNumber} from player {avatarData.playerID}, size: {data.Length} bytes, bones: {avatarData.bones?.Length ?? 0}, blend shapes: {avatarData.blendShapes?.Length ?? 0}");
}
}
}
void TryAlternativePort()
{
// Try a few alternative ports for local testing
int[] alternativePorts = { 8081, 8082, 8083, 8084, 8085 };
foreach (int port in alternativePorts)
{
try
{
udpClient = new UdpClient(port);
threadRunning = true;
udpListenerThread = new Thread(new ThreadStart(UDPListenerThread));
udpListenerThread.IsBackground = true;
udpListenerThread.Start();
Debug.Log($"UDP listener started on alternative port {port}");
return;
}
catch (Exception)
{
// Try next port
continue;
}
}
Debug.LogError("Failed to start UDP listener on any available port");
enableReceiver = false;
}
void UDPListenerThread()
{
IPEndPoint remoteEndPoint = new IPEndPoint(IPAddress.Any, 0);
while (threadRunning)
{
try
{
byte[] data = udpClient.Receive(ref remoteEndPoint);
if (data.Length > 0 && IsAvatarData(data))
{
CompactAvatarData avatarData = DeserializeCompactData(data);
// Check if this is from the target player (0 means accept from any player)
if (targetPlayerID == 0 || avatarData.playerID == targetPlayerID)
{
// Check for packet loss
if (avatarData.sequenceNumber > lastSequenceNumber + 1)
{
packetsDropped += (int)(avatarData.sequenceNumber - lastSequenceNumber - 1);
}
lastSequenceNumber = avatarData.sequenceNumber;
packetsReceived++;
// Store the new data (thread-safe)
lock (dataLock)
{
lastReceivedData = avatarData;
hasNewData = true;
}
if (logReceivedPackets && packetsReceived % 30 == 0)
{
string modeStr = avatarData.isFullDataMode ? "FULL" : "OPT";
Debug.Log($"Received {modeStr} packet #{avatarData.sequenceNumber} from player {avatarData.playerID}, size: {data.Length} bytes, bones: {avatarData.bones?.Length ?? 0}, blend shapes: {avatarData.blendShapes?.Length ?? 0}");
}
}
}
}
catch (Exception e)
{
if (threadRunning) // Only log errors if we're supposed to be running
{
Debug.LogError($"UDP receive error: {e.Message}");
}
}
}
}
bool IsAvatarData(byte[] data)
{
@ -616,19 +559,10 @@ public class UDPAvatarReceiver : MonoBehaviour
void StopUDPListener()
{
threadRunning = false;
if (udpClient != null)
// Unsubscribe from shared listener
if (Convai.Scripts.Runtime.Multiplayer.SharedUDPListener.Instance != null)
{
udpClient.Close();
udpClient.Dispose();
udpClient = null;
}
if (udpListenerThread != null)
{
udpListenerThread.Join(1000); // Wait up to 1 second for thread to finish
udpListenerThread = null;
Convai.Scripts.Runtime.Multiplayer.SharedUDPListener.Instance.OnPacketReceived -= HandlePacketReceived;
}
}
@ -688,9 +622,9 @@ public class UDPAvatarReceiver : MonoBehaviour
int GetActualListenPort()
{
if (udpClient?.Client?.LocalEndPoint != null)
if (Convai.Scripts.Runtime.Multiplayer.SharedUDPListener.Instance != null)
{
return ((IPEndPoint)udpClient.Client.LocalEndPoint).Port;
return Convai.Scripts.Runtime.Multiplayer.SharedUDPListener.Instance.ListenPort;
}
return listenPort;
}

View File

@ -2,9 +2,6 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using UnityEngine;
/// <summary>
@ -41,9 +38,6 @@ public class UDPAvatarReceiverAgent : MonoBehaviour
[SerializeField] private bool showDebugInfo = false;
[SerializeField] private bool logReceivedPackets = false;
private UdpClient udpClient;
private Thread udpListenerThread;
private bool threadRunning = false;
private int listenPort;
private Dictionary<string, Transform> boneCache;
private List<Transform> allBones; // For full data mode
@ -107,7 +101,7 @@ public class UDPAvatarReceiverAgent : MonoBehaviour
if (enableReceiver)
{
StartUDPListener();
StartCoroutine(WaitAndSubscribe());
}
if (showDebugInfo)
@ -208,122 +202,71 @@ public class UDPAvatarReceiverAgent : MonoBehaviour
{
try
{
if (allowPortSharing)
{
// Create UDP client with port reuse for local testing
udpClient = new UdpClient();
udpClient.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
udpClient.Client.Bind(new IPEndPoint(IPAddress.Any, listenPort));
}
else
{
// Standard UDP client binding
udpClient = new UdpClient(listenPort);
}
threadRunning = true;
udpListenerThread = new Thread(new ThreadStart(UDPListenerThread));
udpListenerThread.IsBackground = true;
udpListenerThread.Start();
// Subscribe to shared listener
Convai.Scripts.Runtime.Multiplayer.SharedUDPListener.Instance.OnPacketReceived += HandlePacketReceived;
if (showDebugInfo)
Debug.Log($"UDP Avatar Receiver Agent started on port {listenPort} (filtering binary avatar data only, Port sharing: {allowPortSharing})");
Debug.Log($"UDP Avatar Receiver Agent subscribed to shared listener on port {listenPort} (filtering avatar magic 0x{AVATAR_MAGIC:X})");
}
catch (Exception e)
{
if (allowPortSharing)
Debug.LogError($"Failed to subscribe to shared UDP listener: {e.Message}");
enableReceiver = false;
}
}
System.Collections.IEnumerator WaitAndSubscribe()
{
float timeout = 3f;
while (Convai.Scripts.Runtime.Multiplayer.SharedUDPListener.Instance == null && timeout > 0f)
{
timeout -= Time.unscaledDeltaTime;
yield return null;
}
if (Convai.Scripts.Runtime.Multiplayer.SharedUDPListener.Instance == null)
{
Debug.LogError("SharedUDPListener not ready after wait.");
enableReceiver = false;
yield break;
}
StartUDPListener();
}
void HandlePacketReceived(byte[] data, IPEndPoint senderEndPoint)
{
// Check if this is avatar data (by magic number)
if (!IsAvatarData(data)) return;
// Process avatar packet
CompactAvatarData avatarData = DeserializeCompactData(data);
// Check if this is from the target player (0 means accept from any player)
if (targetPlayerID == 0 || avatarData.playerID == targetPlayerID)
{
// Check for packet loss
if (avatarData.sequenceNumber > lastSequenceNumber + 1)
{
Debug.LogWarning($"Failed to start UDP listener with port sharing: {e.Message}");
Debug.LogWarning("Trying with different port...");
TryAlternativePort();
packetsDropped += (int)(avatarData.sequenceNumber - lastSequenceNumber - 1);
}
else
lastSequenceNumber = avatarData.sequenceNumber;
packetsReceived++;
// Store the new data (thread-safe)
lock (dataLock)
{
Debug.LogError($"Failed to start UDP listener: {e.Message}");
enableReceiver = false;
lastReceivedData = avatarData;
hasNewData = true;
}
if (logReceivedPackets && packetsReceived % 30 == 0)
{
string modeStr = avatarData.isFullDataMode ? "FULL" : "OPT";
Debug.Log($"Received {modeStr} packet #{avatarData.sequenceNumber} from player {avatarData.playerID}, size: {data.Length} bytes, bones: {avatarData.bones?.Length ?? 0}, blend shapes: {avatarData.blendShapes?.Length ?? 0}");
}
}
}
void TryAlternativePort()
{
// Try a few alternative ports for local testing
int[] alternativePorts = { 8081, 8082, 8083, 8084, 8085 };
foreach (int port in alternativePorts)
{
try
{
udpClient = new UdpClient(port);
threadRunning = true;
udpListenerThread = new Thread(new ThreadStart(UDPListenerThread));
udpListenerThread.IsBackground = true;
udpListenerThread.Start();
Debug.Log($"UDP listener started on alternative port {port}");
return;
}
catch (Exception)
{
// Try next port
continue;
}
}
Debug.LogError("Failed to start UDP listener on any available port");
enableReceiver = false;
}
void UDPListenerThread()
{
IPEndPoint remoteEndPoint = new IPEndPoint(IPAddress.Any, 0);
while (threadRunning)
{
try
{
byte[] data = udpClient.Receive(ref remoteEndPoint);
if (data.Length > 0 && IsAvatarData(data))
{
CompactAvatarData avatarData = DeserializeCompactData(data);
// Check if this is from the target player (0 means accept from any player)
if (targetPlayerID == 0 || avatarData.playerID == targetPlayerID)
{
// Check for packet loss
if (avatarData.sequenceNumber > lastSequenceNumber + 1)
{
packetsDropped += (int)(avatarData.sequenceNumber - lastSequenceNumber - 1);
}
lastSequenceNumber = avatarData.sequenceNumber;
packetsReceived++;
// Store the new data (thread-safe)
lock (dataLock)
{
lastReceivedData = avatarData;
hasNewData = true;
}
if (logReceivedPackets && packetsReceived % 30 == 0)
{
string modeStr = avatarData.isFullDataMode ? "FULL" : "OPT";
Debug.Log($"Received {modeStr} packet #{avatarData.sequenceNumber} from player {avatarData.playerID}, size: {data.Length} bytes, bones: {avatarData.bones?.Length ?? 0}, blend shapes: {avatarData.blendShapes?.Length ?? 0}");
}
}
}
}
catch (Exception e)
{
if (threadRunning) // Only log errors if we're supposed to be running
{
Debug.LogError($"UDP receive error: {e.Message}");
}
}
}
}
bool IsAvatarData(byte[] data)
{
@ -619,19 +562,10 @@ public class UDPAvatarReceiverAgent : MonoBehaviour
void StopUDPListener()
{
threadRunning = false;
if (udpClient != null)
// Unsubscribe from shared listener
if (Convai.Scripts.Runtime.Multiplayer.SharedUDPListener.Instance != null)
{
udpClient.Close();
udpClient.Dispose();
udpClient = null;
}
if (udpListenerThread != null)
{
udpListenerThread.Join(1000); // Wait up to 1 second for thread to finish
udpListenerThread = null;
Convai.Scripts.Runtime.Multiplayer.SharedUDPListener.Instance.OnPacketReceived -= HandlePacketReceived;
}
}
@ -691,9 +625,9 @@ public class UDPAvatarReceiverAgent : MonoBehaviour
int GetActualListenPort()
{
if (udpClient?.Client?.LocalEndPoint != null)
if (Convai.Scripts.Runtime.Multiplayer.SharedUDPListener.Instance != null)
{
return ((IPEndPoint)udpClient.Client.LocalEndPoint).Port;
return Convai.Scripts.Runtime.Multiplayer.SharedUDPListener.Instance.ListenPort;
}
return listenPort;
}

View File

@ -2,9 +2,7 @@ using System;
using System.Collections;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using UnityEngine;
using Newtonsoft.Json;
@ -34,8 +32,6 @@ public class VRExperimentController : MonoBehaviour
[SerializeField] private bool enableDebugLogging = true;
// Network components
private UdpClient udpClient;
private Thread udpListenerThread;
private bool isListening = false;
private int udpPort;
@ -68,7 +64,7 @@ public class VRExperimentController : MonoBehaviour
}
InitializeObjectMaps();
StartUDPListener();
StartCoroutine(WaitAndSubscribe());
// Initially deactivate all objects and avatars
DeactivateAllObjects();
@ -197,70 +193,49 @@ public class VRExperimentController : MonoBehaviour
{
try
{
if (allowPortSharing)
{
// Create UDP client with port reuse for local testing
udpClient = new UdpClient();
udpClient.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
udpClient.Client.Bind(new IPEndPoint(IPAddress.Any, udpPort));
}
else
{
// Standard UDP client binding
udpClient = new UdpClient(udpPort);
}
udpListenerThread = new Thread(new ThreadStart(UDPListenerLoop));
udpListenerThread.IsBackground = true;
// Subscribe to shared listener
Convai.Scripts.Runtime.Multiplayer.SharedUDPListener.Instance.OnPacketReceived += HandlePacketReceived;
isListening = true;
udpListenerThread.Start();
LogMessage($"UDP Experiment Control Listener started on port {udpPort} (filtering JSON messages only, Port sharing: {allowPortSharing})");
LogMessage($"UDP Experiment Control Listener subscribed to shared listener on port {udpPort} (filtering JSON messages only)");
}
catch (Exception e)
{
if (allowPortSharing)
{
LogError($"Failed to start UDP listener with port sharing: {e.Message}");
LogMessage("Trying with different port...");
TryAlternativePort();
}
else
{
LogError($"Failed to start UDP listener: {e.Message}");
}
LogError($"Failed to subscribe to shared UDP listener: {e.Message}");
}
}
/// <summary>
/// Try alternative ports for local testing
/// </summary>
private void TryAlternativePort()
private System.Collections.IEnumerator WaitAndSubscribe()
{
// Try a few alternative ports for local testing
int[] alternativePorts = { 1222, 1223, 1224, 1225, 1226 };
foreach (int port in alternativePorts)
float timeout = 3f;
while (Convai.Scripts.Runtime.Multiplayer.SharedUDPListener.Instance == null && timeout > 0f)
{
try
{
udpClient = new UdpClient(port);
udpListenerThread = new Thread(new ThreadStart(UDPListenerLoop));
udpListenerThread.IsBackground = true;
isListening = true;
udpListenerThread.Start();
LogMessage($"UDP Experiment Control Listener started on alternative port {port}");
return;
}
catch (Exception)
{
// Try next port
continue;
}
timeout -= Time.unscaledDeltaTime;
yield return null;
}
if (Convai.Scripts.Runtime.Multiplayer.SharedUDPListener.Instance == null)
{
LogError("SharedUDPListener not ready after wait.");
yield break;
}
StartUDPListener();
}
/// <summary>
/// Handle packet received from shared listener
/// </summary>
private void HandlePacketReceived(byte[] data, IPEndPoint senderEndPoint)
{
// Check if this is an experiment control message (JSON)
if (!IsExperimentControlMessage(data)) return;
LogError("Failed to start UDP listener on any available port");
string message = Encoding.UTF8.GetString(data);
// Add message to queue for main thread processing
lock (queueLock)
{
messageQueue.Enqueue(message);
}
}
/// <summary>
@ -270,59 +245,15 @@ public class VRExperimentController : MonoBehaviour
{
isListening = false;
if (udpClient != null)
// Unsubscribe from shared listener
if (Convai.Scripts.Runtime.Multiplayer.SharedUDPListener.Instance != null)
{
udpClient.Close();
udpClient = null;
}
if (udpListenerThread != null && udpListenerThread.IsAlive)
{
udpListenerThread.Join(1000); // Wait up to 1 second
if (udpListenerThread.IsAlive)
{
udpListenerThread.Abort();
}
udpListenerThread = null;
Convai.Scripts.Runtime.Multiplayer.SharedUDPListener.Instance.OnPacketReceived -= HandlePacketReceived;
}
LogMessage("UDP Listener stopped");
}
/// <summary>
/// UDP listener loop (runs in separate thread)
/// </summary>
private void UDPListenerLoop()
{
while (isListening)
{
try
{
IPEndPoint remoteEndPoint = new IPEndPoint(IPAddress.Any, udpPort);
byte[] data = udpClient.Receive(ref remoteEndPoint);
// Check if this looks like a JSON experiment control message
if (IsExperimentControlMessage(data))
{
string message = Encoding.UTF8.GetString(data);
// Add message to queue for main thread processing
lock (queueLock)
{
messageQueue.Enqueue(message);
}
}
// If it's not an experiment control message (likely avatar data), ignore it
}
catch (Exception e)
{
if (isListening) // Only log if we're still supposed to be listening
{
LogError($"UDP Listener error: {e.Message}");
}
}
}
}
/// <summary>
/// Check if the received data is an experiment control message (JSON) vs avatar data (binary)
@ -684,9 +615,9 @@ public class VRExperimentController : MonoBehaviour
/// </summary>
private int GetActualListenPort()
{
if (udpClient?.Client?.LocalEndPoint != null)
if (Convai.Scripts.Runtime.Multiplayer.SharedUDPListener.Instance != null)
{
return ((IPEndPoint)udpClient.Client.LocalEndPoint).Port;
return Convai.Scripts.Runtime.Multiplayer.SharedUDPListener.Instance.ListenPort;
}
return udpPort;
}

View File

@ -0,0 +1,141 @@
# Masterarbeit UDP Multiplayer System: Structure & Roles Overview
This document explains how this custom Unity multiplayer system works. It covers real-time avatar, audio, and experiment synchronization, and goes through all the main multiplayer components.
---
## 1. High-Level Architecture
The system enables multiple VR clients (users, experimenters, or bots) to:
- Synchronize avatars (pose, facial blendshapes)
- Stream live audio (mic/NPC speech)
- React to central experiment control (tasks, object activation)
- Discover peers automatically (no manual IP entry)
- Debug and inspect all network state in real time
All this communication happens over a _single shared UDP port_ (from `NetworkConfig`), allowing both unicast and broadcast, and avoiding race conditions/conflicts in Unity's component-based design.
---
## 2. Key Modules & Their Roles
### **A. Avatar Synchronization**
**Scripts:**
- `UDPAvatarBroadcaster.cs`, `UDPAvatarBroadcasterAgent.cs`
- `UDPAvatarReceiver.cs`, `UDPAvatarReceiverAgent.cs`
- _Location_: Assets/Scripts (main code), "Agent" variants support ReadyPlayerMe/CC4 naming
**Function:**
- **Broadcaster**: Gathers local avatar transform (root, bones, blendshapes) and sends packets at a steady rate using UDP (binary, compact protocol)
- **Receiver**: Applies the most recent received pose/facial data to the local duplicate avatar
- **Mode:** Optimized (priority bones only) or Full (all bones/blends)
**Public API:**
- `SetPlayerID(byte id)` (filter by remote peer if needed)
- Inspector toggles (start/stop broadcast, debug info)
---
### **B. Audio Streaming**
**Scripts:**
- `ConvaiSimpleUDPAudioSender/Receiver.cs` (Player-to-player, live mic, PTT)
- `ConvaiUDPSpeechSender/Receiver.cs` (NPC/AI speech across network)
- _Location_: Assets/Scripts/Multiplayer
**Function:**
- **SimpleUDPAudioSender**: Captures mic, chunks to short UDP packets, broadcasts to peer
- **SimpleUDPAudioReceiver**: Buffers incoming packets, simulates mic input on target NPC
- **UDPSpeechSender/Receiver**: Dedicated for Convai/ReadyPlayerMe/NPC speech playback, includes transcript metadata and allows for sequential clip transmission (not just live voice)
**Public Events:**
- `OnAudioReceiving`, `OnSpeechReceiving` (Unity Actions for state/busy status)
---
### **C. Experiment State Control**
**Script:**
- `VRExperimentController.cs`
- _Location_: Assets/Scripts
**Function:**
- Listens to incoming UDP (JSON messages) for commands from a supervisor or experimenter
- Controls what avatars/objects/world elements are active based on experimental tasks/blocks/trials
- Ensures logic for practice rounds, condition changes, object resets
**Key API/Bindings:**
- Maps all avatars/objects via inspector
- Handles: start/stop experiment, practice block, activate/deactivate particular items
- Scene state queries (is object/condition active, etc)
---
### **D. UDP Port Sharing & Packet Routing**
**Script:**
- `SharedUDPListener.cs`
- _Location_: Assets/Scripts/Multiplayer
**Function:**
- **Singleton** MonoBehaviour that _owns the UDP socket_
- Dispatches incoming packets to all subscribers based on event: `OnPacketReceived(byte[] packet, IPEndPoint from)`
- Ensures only one script binds to each UDP port (solves race/conflict problem in Unity)
**Usage:**
- All networked scripts _subscribe/unsubscribe_ as needed
---
### **E. Peer Discovery & Dynamic Targeting**
**Script:**
- `UDPPeerDiscovery.cs`
- _Location_: Assets/Scripts/Multiplayer
**Function:**
- Broadcasts regular 'hello' packets with player ID and waits for similar packets from peers
- On discovery, updates all Broadcaster/Audio scripts to direct unicast, not just broadcast
- Handles peer disconnects/loss, provides connection state events
**Public Events:**
- `OnPeerDiscovered`, `OnPeerLost`, and status properties (connection state, peer IP, local player ID)
---
### **F. In-Game Network Debugging**
**Script:**
- `NetworkDebugUI.cs`
- _Location_: Assets/Scripts/Multiplayer
**Function:**
- Provides a world-space (VR compatible) or 2D overlay UI showing:
- Peer discovery status
- Audio/pose sender/receiver status (packets, port, active/inactive)
- Experiment state, currently active objects
- Packet throughput/logs
- Uses Input System for toggle, supports VR and editor
**Public API:**
- Toggle/show/hide programmatically
- Extendable for additional waveform, voice, or custom logs
---
## 4. Script/Module Summary Table
| Script | Role/Function | Key API / Events |
|-----------------------------------|----------------------------------------------------------------------|---------------------------------|
| UDPAvatarBroadcaster(.Agent) | Broadcast live pose/facial state as binary UDP | SetPlayerID, Inspector toggles |
| UDPAvatarReceiver(.Agent) | Receive/apply remote pose/facial updates | SetPlayerID |
| ConvaiSimpleUDPAudioSender/Rcv | Mic/PTT live audio stream; player voice | OnAudioReceiving |
| ConvaiUDPSpeechSender/Receiver | NPC/AI speech transfer with transcript | OnSpeechReceiving |
| VRExperimentController | Orchestrate scene, respond to experiment JSON, control object state | Condition/object methods |
| SharedUDPListener | UDP socket singleton, routes all packets | OnPacketReceived event |
| UDPPeerDiscovery | Peer IP autodiscovery, targeting updates | OnPeerDiscovered/Lost |
| NetworkDebugUI | VR debug/status in-game overlay | Show/Hide |
| NetworkConfig | ScriptableObject for IP/port/config | Static instance |
---

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 0b725a59a21efb044ba2d8b4ef29095c
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

Binary file not shown.