Editor/Drivers/AudioAndChatDriver.cs

Editor tool helpers for audio and chat testing. Provides methods to inject a voice audio stream (synthesize tone or read WAV), probe spatial audio between scene objects, and simulate chat actions including sending platform chat and simple filter testing.

File AccessProcess ExecutionNetworking
#nullable enable
using System;
using System.IO;
using System.Linq;
using Sandbox;

namespace Editor.Mcp
{
	internal static class AudioAndChatDriver
	{
		private static Scene? ResolveScene()
		{
			if ( Game.ActiveScene != null ) return Game.ActiveScene;
			var session = EditorRuntime.GetStaticProperty( "Editor.SceneEditorSession", "Active" );
			if ( session != null )
			{
				return EditorRuntime.GetMember( session, "Scene" ) as Scene;
			}
			return null;
		}

		public static ToolOutcome<object> InjectVoiceStream(
			string mode = "tone",
			float durationSeconds = 1.0f,
			int sampleRate = 44100,
			float frequencyHz = 440f,
			string? filePath = null,
			float volume = 1.0f,
			float pitch = 1.0f,
			string channel = "all",
			bool mute = false )
		{
			try
			{
				var clampedDuration = Math.Clamp( durationSeconds, 0.1f, 10.0f );
				var clampedRate = Math.Clamp( sampleRate, 8000, 48000 );
				var clampedVol = Math.Clamp( volume, 0.0f, 2.0f );
				var normMode = mode.ToLowerInvariant().Trim();

				short[] pcmSamples;

				if ( mute || normMode == "silence" )
				{
					pcmSamples = new short[(int)(clampedRate * clampedDuration)];
				}
				else if ( normMode == "wav_file" && !string.IsNullOrWhiteSpace( filePath ) && File.Exists( filePath ) )
				{
					var fileBytes = File.ReadAllBytes( filePath );
					// Check WAV header (RIFF)
					if ( fileBytes.Length > 44 && fileBytes[0] == 'R' && fileBytes[1] == 'I' && fileBytes[2] == 'F' && fileBytes[3] == 'F' )
					{
						// Skip standard 44-byte WAV header
						var sampleCount = (fileBytes.Length - 44) / 2;
						pcmSamples = new short[sampleCount];
						Buffer.BlockCopy( fileBytes, 44, pcmSamples, 0, pcmSamples.Length * 2 );
					}
					else
					{
						var sampleCount = fileBytes.Length / 2;
						pcmSamples = new short[sampleCount];
						Buffer.BlockCopy( fileBytes, 0, pcmSamples, 0, pcmSamples.Length * 2 );
					}
				}
				else // "tone" (synthesize sine wave burst)
				{
					var totalSamples = (int)(clampedRate * clampedDuration);
					pcmSamples = new short[totalSamples];
					var angularFreq = 2.0 * Math.PI * Math.Clamp( frequencyHz, 20f, 10000f ) / clampedRate;

					for ( int i = 0; i < totalSamples; i++ )
					{
						// Soft envelope fade in/out to avoid popping
						var envelope = 1.0;
						if ( i < 200 ) envelope = (double)i / 200;
						else if ( i > totalSamples - 200 ) envelope = (double)(totalSamples - i) / 200;

						var sampleVal = (short)(Math.Sin( i * angularFreq ) * 28000.0 * envelope * clampedVol);
						pcmSamples[i] = sampleVal;
					}
				}

				// Feed into SoundStream
				var stream = new SoundStream( clampedRate, 1 );
				stream.WriteData( pcmSamples.AsSpan() );
				stream.Play( clampedVol, pitch );

				return ToolOutcome<object>.Ready( new
				{
					Action = "inject_voice_stream",
					Mode = normMode,
					Channel = channel,
					SampleRate = clampedRate,
					Duration = clampedDuration,
					SampleCount = pcmSamples.Length,
					Volume = clampedVol,
					Pitch = pitch,
					IsMuted = mute,
					Status = "Voice stream audio played successfully"
				} );
			}
			catch ( Exception ex )
			{
				return ToolOutcome<object>.Failure( null, ToolStatus.Failed, "audio.stream_inject_failed", "Voice stream injection failed.", ex );
			}
		}

		public static ToolOutcome<object> ProbeSpatialAudio(
			string target,
			string? soundEventOrFile = null,
			float volume = 1.0f,
			float maxDistance = 1000f,
			bool testOcclusion = true,
			string? listenerTarget = null )
		{
			var scene = ResolveScene();
			if ( scene == null )
			{
				return ToolOutcome<object>.Failure( null, ToolStatus.Unavailable, "audio.no_scene", "No active scene loaded." );
			}

			var sourceGo = scene.Directory.FindByGuid( Guid.TryParse( target, out var guid ) ? guid : Guid.Empty )
				?? scene.GetAllObjects( false ).FirstOrDefault( o => string.Equals( o.Name, target, StringComparison.OrdinalIgnoreCase ) );

			if ( sourceGo == null )
			{
				return ToolOutcome<object>.Failure( new { Target = target }, ToolStatus.Degraded, "audio.source_not_found", $"Target '{target}' not found." );
			}

			var listenerGo = !string.IsNullOrWhiteSpace( listenerTarget )
				? (scene.Directory.FindByGuid( Guid.TryParse( listenerTarget, out var lg ) ? lg : Guid.Empty ) ?? scene.GetAllObjects( false ).FirstOrDefault( o => string.Equals( o.Name, listenerTarget, StringComparison.OrdinalIgnoreCase ) ))
				: scene.GetAllObjects( false ).FirstOrDefault( o => o.GetComponent<CameraComponent>() != null );

			var sourcePos = sourceGo.WorldPosition;
			var listenerPos = listenerGo?.WorldPosition ?? Vector3.Zero;
			var distance = Vector3.DistanceBetween( sourcePos, listenerPos );

			// Check wall / geometry obstruction between source and listener
			var trace = scene.Trace.Ray( sourcePos, listenerPos )
				.WithoutTags( "trigger" )
				.Run();

			var isObstructed = trace.Hit && trace.Distance < (distance - 5f);
			var calculatedAttenuation = Math.Clamp( 1.0f - (distance / Math.Max( maxDistance, 10f )), 0f, 1.0f );
			if ( isObstructed && testOcclusion )
			{
				calculatedAttenuation *= 0.35f; // Muffled by intervening geometry
			}

			// Spawn temporary spatial sound emitter on target
			var soundEmitter = sourceGo.Components.GetOrCreate<SoundPointComponent>();
			soundEmitter.Volume = volume;
			soundEmitter.Distance = maxDistance;
			soundEmitter.OcclusionEnabled = testOcclusion;
			if ( !string.IsNullOrWhiteSpace( soundEventOrFile ) )
			{
				Sound.Play( soundEventOrFile, sourcePos, volume );
			}
			else
			{
				soundEmitter.StartSound();
			}

			// Inspect voice and visemes if active on source
			var voiceComp = sourceGo.GetComponent<Voice>();

			return ToolOutcome<object>.Ready( new
			{
				Action = "spatial_audio_probe",
				Source = sourceGo.Name,
				SourcePosition = sourcePos,
				Listener = listenerGo?.Name ?? "MainCamera",
				ListenerPosition = listenerPos,
				Distance = distance,
				MaxDistance = maxDistance,
				IsObstructed = isObstructed,
				ObstructionCollider = trace.GameObject?.Name,
				EstimatedVolumeAttenuation = calculatedAttenuation,
				VoiceComponentPresent = voiceComp != null,
				VoiceAmplitude = voiceComp?.Amplitude ?? 0f,
				LaughterScore = voiceComp?.LaughterScore ?? 0f,
				VisemePhonemeCount = voiceComp?.Visemes?.Count ?? 0,
				Status = "Spatial audio probe verified"
			} );
		}

		public static ToolOutcome<object> SimulateChat(
			string action = "say",
			string message = "Hello from MCP test harness",
			string channel = "all",
			string? filterWord = null )
		{
			try
			{
				var normAction = action.ToLowerInvariant().Trim();

				if ( normAction == "say" )
				{
					// Send via platform chat and console fallback
					Sandbox.Platform.Chat.Say( message );
					ConsoleSystem.Run( $"say \"{message.Replace( "\"", "\\\"" )}\"" );

					return ToolOutcome<object>.Ready( new
					{
						Action = "say",
						Channel = channel,
						Message = message,
						Sent = true,
						PlatformChatEnabled = Sandbox.Platform.Chat.Enabled
					} );
				}

				if ( normAction == "announcement" || normAction == "add_text" )
				{
					Sandbox.Platform.Chat.AddText( message );

					return ToolOutcome<object>.Ready( new
					{
						Action = "announcement",
						Message = message,
						Broadcast = true
					} );
				}

				if ( normAction == "test_filter" )
				{
					var blockedWords = new[] { "cheat", "hack", "exploit", "toxic", "swear" };
					if ( !string.IsNullOrWhiteSpace( filterWord ) )
					{
						blockedWords = blockedWords.Append( filterWord.ToLowerInvariant().Trim() ).ToArray();
					}

					var isFiltered = blockedWords.Any( w => message.Contains( w, StringComparison.OrdinalIgnoreCase ) );
					var sanitizedMessage = message;
					foreach ( var w in blockedWords )
					{
						if ( sanitizedMessage.Contains( w, StringComparison.OrdinalIgnoreCase ) )
						{
							sanitizedMessage = sanitizedMessage.Replace( w, new string( '*', w.Length ), StringComparison.OrdinalIgnoreCase );
						}
					}

					return ToolOutcome<object>.Ready( new
					{
						Action = "test_filter",
						OriginalMessage = message,
						IsFiltered = isFiltered,
						SanitizedMessage = sanitizedMessage,
						WouldSuppress = isFiltered
					} );
				}

				return ToolOutcome<object>.Ready( new
				{
					Action = normAction,
					Message = message,
					Status = "Ready"
				} );
			}
			catch ( Exception ex )
			{
				return ToolOutcome<object>.Failure( null, ToolStatus.Failed, "chat.simulate_failed", "Chat simulation failed.", ex );
			}
		}
	}
}