using System; using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics; using System.Globalization; using System.IO; using System.IO.Pipes; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Runtime.Versioning; using System.Text; using System.Threading; using BepInEx; using BepInEx.Configuration; using BepInEx.Logging; using HarmonyLib; using Microsoft.CodeAnalysis; using Microsoft.Win32.SafeHandles; using RemoteVoiceSplit.Core; using RemoteVoiceSplit.Interop.Game; using RemoteVoiceSplit.Interop.ProcessAudio; using UnityEngine; [assembly: CompilationRelaxations(8)] [assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)] [assembly: Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)] [assembly: TargetFramework(".NETStandard,Version=v2.1", FrameworkDisplayName = ".NET Standard 2.1")] [assembly: AssemblyCompany("aoirint")] [assembly: AssemblyConfiguration("Release")] [assembly: AssemblyDescription("Moves remote Lethal Company player voices to an OBS-capturable audio process.")] [assembly: AssemblyFileVersion("0.1.0.0")] [assembly: AssemblyInformationalVersion("0.1.0+405c323fff7319af684519e269fe3edb7308eeeb")] [assembly: AssemblyProduct("Remote Voice Split")] [assembly: AssemblyTitle("RemoteVoiceSplit")] [assembly: AssemblyMetadata("RepositoryUrl", "https://github.com/aoirint/RemoteVoiceSplit")] [assembly: AssemblyVersion("0.1.0.0")] namespace Microsoft.CodeAnalysis { [CompilerGenerated] [Embedded] internal sealed class EmbeddedAttribute : Attribute { } } namespace System.Runtime.CompilerServices { [CompilerGenerated] [Embedded] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Event | AttributeTargets.Parameter | AttributeTargets.ReturnValue | AttributeTargets.GenericParameter, AllowMultiple = false, Inherited = false)] internal sealed class NullableAttribute : Attribute { public readonly byte[] NullableFlags; public NullableAttribute(byte P_0) { NullableFlags = new byte[1] { P_0 }; } public NullableAttribute(byte[] P_0) { NullableFlags = P_0; } } [CompilerGenerated] [Embedded] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Method | AttributeTargets.Interface | AttributeTargets.Delegate, AllowMultiple = false, Inherited = false)] internal sealed class NullableContextAttribute : Attribute { public readonly byte Flag; public NullableContextAttribute(byte P_0) { Flag = P_0; } } } namespace RemoteVoiceSplit { [BepInPlugin("com.aoirint.remotevoicesplit", "Remote Voice Split", "0.1.0")] [BepInProcess("Lethal Company.exe")] public sealed class Plugin : BaseUnityPlugin { private void Awake() { if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { ((BaseUnityPlugin)this).Logger.LogError((object)"Remote Voice Split requires Windows process audio capture and has been disabled on this platform."); return; } try { ConfigEntry enabled = ((BaseUnityPlugin)this).Config.Bind("General", "Enabled", true, "Set to false to disable this mod. Changes made through BepInEx configuration APIs apply immediately."); ConfigEntry fallbackToGameOutput = ((BaseUnityPlugin)this).Config.Bind("General", "FallbackToGameOutput", false, "Keep remote voices on the normal game output whenever separate process output cannot accept them. The default false value prevents remote voice from leaking into the game-audio recording track, but also makes remote voice inaudible until separate output recovers. Changes made through BepInEx configuration APIs apply immediately."); int num = AudioSettings.outputSampleRate; if (num <= 0) { num = 48000; ((BaseUnityPlugin)this).Logger.LogWarning((object)"Unity did not report an output sample rate; falling back to 48000 Hz for remote voice process output."); } string audioHostPath = Path.Combine(Path.GetDirectoryName(typeof(Plugin).Assembly.Location) ?? throw new InvalidOperationException("The plugin assembly directory is unavailable."), "RemoteVoiceSplit.AudioHost.exe"); if (PluginRuntime.Initialize(((BaseUnityPlugin)this).Logger, num, Process.GetCurrentProcess().Id, audioHostPath, enabled, fallbackToGameOutput)) { ((BaseUnityPlugin)this).Logger.LogInfo((object)"Remote Voice Split 0.1.0 loaded."); } else { ((BaseUnityPlugin)this).Logger.LogInfo((object)"Remote Voice Split process-lifetime routing was already initialized."); } } catch (Exception ex) { ((BaseUnityPlugin)this).Logger.LogError((object)("Remote Voice Split could not initialize and was disabled: " + ex.GetType().Name + ": " + ex.Message)); } } } public static class MyPluginInfo { public const string PLUGIN_GUID = "com.aoirint.remotevoicesplit"; public const string PLUGIN_NAME = "Remote Voice Split"; public const string PLUGIN_VERSION = "0.1.0"; } } namespace RemoteVoiceSplit.Interop.ProcessAudio { internal static class DetachedAudioHostLauncher { private struct StartupInfo { public uint Size; public IntPtr Reserved; public IntPtr Desktop; public IntPtr Title; public uint X; public uint Y; public uint XSize; public uint YSize; public uint XCountChars; public uint YCountChars; public uint FillAttribute; public uint Flags; public ushort ShowWindow; public ushort ReservedSize; public IntPtr ReservedData; public IntPtr StandardInput; public IntPtr StandardOutput; public IntPtr StandardError; } private struct StartupInfoEx { public StartupInfo StartupInfo; public IntPtr AttributeList; } private struct ProcessInformation { public IntPtr Process; public IntPtr Thread; public uint ProcessId; public uint ThreadId; } private const uint ProcessCreateProcess = 128u; private const uint QueryLimitedInformation = 4096u; private const uint ExtendedStartupInfoPresent = 524288u; private const int ParentProcessAttribute = 131072; public static int Launch(string executablePath, string pipeName, int gameProcessId) { ValidateArguments(executablePath, pipeName, gameProcessId); uint shellProcessId = GetShellProcessId(); using SafeProcessHandle safeProcessHandle = OpenProcess(4224u, inheritHandle: false, shellProcessId); if (safeProcessHandle.IsInvalid) { throw CreateWin32Exception("Windows could not open the desktop shell as the audio host parent."); } VerifyShellImage(safeProcessHandle); IntPtr intPtr = IntPtr.Zero; IntPtr intPtr2 = IntPtr.Zero; bool flag = false; try { IntPtr size = IntPtr.Zero; InitializeProcThreadAttributeList(IntPtr.Zero, 1, 0u, ref size); if (size == IntPtr.Zero) { throw CreateWin32Exception("Windows could not size the audio host process attribute list."); } intPtr = Marshal.AllocHGlobal(size); if (!InitializeProcThreadAttributeList(intPtr, 1, 0u, ref size)) { throw CreateWin32Exception("Windows could not initialize the audio host process attribute list."); } flag = true; intPtr2 = Marshal.AllocHGlobal(IntPtr.Size); Marshal.WriteIntPtr(intPtr2, safeProcessHandle.DangerousGetHandle()); if (!UpdateProcThreadAttribute(intPtr, 0u, new IntPtr(131072), intPtr2, new IntPtr(IntPtr.Size), IntPtr.Zero, IntPtr.Zero)) { throw CreateWin32Exception("Windows could not assign the desktop shell as the audio host parent."); } return LaunchWithAttributes(executablePath, pipeName, gameProcessId, intPtr); } finally { if (flag) { DeleteProcThreadAttributeList(intPtr); } if (intPtr2 != IntPtr.Zero) { Marshal.FreeHGlobal(intPtr2); } if (intPtr != IntPtr.Zero) { Marshal.FreeHGlobal(intPtr); } } } private static void ValidateArguments(string executablePath, string pipeName, int gameProcessId) { if (string.IsNullOrWhiteSpace(executablePath)) { throw new ArgumentException("The audio host path is required.", "executablePath"); } if (!File.Exists(executablePath)) { throw new FileNotFoundException("The packaged audio host was not found.", executablePath); } if (string.IsNullOrWhiteSpace(pipeName)) { throw new ArgumentException("The pipe name is required.", "pipeName"); } if (gameProcessId <= 0) { throw new ArgumentOutOfRangeException("gameProcessId"); } } private static uint GetShellProcessId() { IntPtr shellWindow = GetShellWindow(); if (shellWindow == IntPtr.Zero) { throw new InvalidOperationException("Windows did not report an interactive desktop shell."); } GetWindowThreadProcessId(shellWindow, out var processId); if (processId == 0) { throw CreateWin32Exception("Windows did not report the desktop shell process."); } return processId; } private static void VerifyShellImage(SafeProcessHandle shellProcess) { string folderPath = Environment.GetFolderPath(Environment.SpecialFolder.Windows); if (string.IsNullOrWhiteSpace(folderPath)) { throw new InvalidOperationException("Windows did not report its installation directory."); } string fullPath = Path.GetFullPath(Path.Combine(folderPath, "explorer.exe")); string fullPath2 = Path.GetFullPath(ProcessImagePath.Get(shellProcess)); if (!string.Equals(fullPath, fullPath2, StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException("The interactive desktop shell is not the Windows Explorer executable."); } } private static int LaunchWithAttributes(string executablePath, string pipeName, int gameProcessId, IntPtr attributeList) { string fullPath = Path.GetFullPath(executablePath); StringBuilder commandLine = new StringBuilder(string.Format(CultureInfo.InvariantCulture, "{0} --pipe {1} --game-process-id {2}", QuoteCommandLineArgument(fullPath), QuoteCommandLineArgument(pipeName), gameProcessId)); checked { StartupInfoEx startupInfo = new StartupInfoEx { StartupInfo = new StartupInfo { Size = (uint)Marshal.SizeOf() }, AttributeList = attributeList }; if (!CreateProcess(fullPath, commandLine, IntPtr.Zero, IntPtr.Zero, inheritHandles: false, 524288u, IntPtr.Zero, Path.GetDirectoryName(fullPath), ref startupInfo, out var processInformation)) { throw CreateWin32Exception("Windows could not start the audio host process."); } CloseHandle(processInformation.Thread); CloseHandle(processInformation.Process); return (int)processInformation.ProcessId; } } private static string QuoteCommandLineArgument(string value) { StringBuilder stringBuilder = new StringBuilder(value.Length + 2); stringBuilder.Append('"'); int num = 0; foreach (char c in value) { switch (c) { case '\\': num++; break; case '"': stringBuilder.Append('\\', checked(num * 2 + 1)); stringBuilder.Append(c); num = 0; break; default: stringBuilder.Append('\\', num); num = 0; stringBuilder.Append(c); break; } } stringBuilder.Append('\\', checked(num * 2)); stringBuilder.Append('"'); return stringBuilder.ToString(); } private static Win32Exception CreateWin32Exception(string message) { return new Win32Exception(Marshal.GetLastWin32Error(), message); } [DllImport("user32.dll")] private static extern IntPtr GetShellWindow(); [DllImport("user32.dll", SetLastError = true)] private static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId); [DllImport("kernel32.dll", SetLastError = true)] private static extern SafeProcessHandle OpenProcess(uint desiredAccess, [MarshalAs(UnmanagedType.Bool)] bool inheritHandle, uint processId); [DllImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool InitializeProcThreadAttributeList(IntPtr attributeList, int attributeCount, uint flags, ref IntPtr size); [DllImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool UpdateProcThreadAttribute(IntPtr attributeList, uint flags, IntPtr attribute, IntPtr value, IntPtr size, IntPtr previousValue, IntPtr returnSize); [DllImport("kernel32.dll")] private static extern void DeleteProcThreadAttributeList(IntPtr attributeList); [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool CreateProcess(string applicationName, StringBuilder commandLine, IntPtr processAttributes, IntPtr threadAttributes, [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, uint creationFlags, IntPtr environment, string? currentDirectory, ref StartupInfoEx startupInfo, out ProcessInformation processInformation); [DllImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool CloseHandle(IntPtr handle); } internal static class NamedPipeServerIdentity { public static int GetServerProcessId(NamedPipeClientStream pipe) { if (pipe == null) { throw new ArgumentNullException("pipe"); } if (!GetNamedPipeServerProcessId(pipe.SafePipeHandle, out var serverProcessId)) { throw new Win32Exception(Marshal.GetLastWin32Error(), "Windows could not identify the audio host pipe server."); } return checked((int)serverProcessId); } [DllImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool GetNamedPipeServerProcessId(SafePipeHandle pipe, out uint serverProcessId); } internal static class ProcessImagePath { private const uint QueryLimitedInformation = 4096u; public static string Get(int processId) { using SafeProcessHandle safeProcessHandle = OpenProcess(4096u, inheritHandle: false, checked((uint)processId)); if (safeProcessHandle.IsInvalid) { throw new Win32Exception(Marshal.GetLastWin32Error(), "Windows could not open a process for image-path verification."); } return Get(safeProcessHandle); } public static string Get(SafeProcessHandle process) { if (process == null) { throw new ArgumentNullException("process"); } if (process.IsInvalid || process.IsClosed) { throw new ArgumentException("A live process handle is required.", "process"); } StringBuilder stringBuilder = new StringBuilder(32768); int size = stringBuilder.Capacity; if (!QueryFullProcessImageName(process, 0u, stringBuilder, ref size)) { throw new Win32Exception(Marshal.GetLastWin32Error(), "Windows could not read a process executable path."); } return stringBuilder.ToString(); } [DllImport("kernel32.dll", SetLastError = true)] private static extern SafeProcessHandle OpenProcess(uint desiredAccess, [MarshalAs(UnmanagedType.Bool)] bool inheritHandle, uint processId); [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool QueryFullProcessImageName(SafeProcessHandle process, uint flags, StringBuilder executableName, ref int size); } internal static class ProcessTreeSnapshot { [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] private struct ProcessEntry32 { public uint Size; public uint Usage; public uint ProcessId; public IntPtr DefaultHeapId; public uint ModuleId; public uint Threads; public uint ParentProcessId; public int BasePriority; public uint Flags; [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string ExecutableFile; } private sealed class SafeSnapshotHandle : SafeHandleZeroOrMinusOneIsInvalid { private SafeSnapshotHandle() : base(ownsHandle: true) { } protected override bool ReleaseHandle() { return CloseHandle(handle); } } private const uint SnapshotProcesses = 2u; public static bool IsSelfOrDescendant(int candidateProcessId, int ancestorProcessId) { return ProcessAncestry.IsSelfOrDescendant(candidateProcessId, ancestorProcessId, Capture()); } private static Dictionary Capture() { checked { using SafeSnapshotHandle safeSnapshotHandle = CreateToolhelp32Snapshot(2u, 0u); if (safeSnapshotHandle.IsInvalid) { throw new InvalidOperationException($"Windows could not snapshot the process tree. Error {Marshal.GetLastWin32Error()}."); } Dictionary dictionary = new Dictionary(); ProcessEntry32 entry = new ProcessEntry32 { Size = (uint)Marshal.SizeOf() }; if (!Process32First(safeSnapshotHandle, ref entry)) { throw new InvalidOperationException($"Windows could not read the process tree. Error {Marshal.GetLastWin32Error()}."); } do { dictionary[(int)entry.ProcessId] = (int)entry.ParentProcessId; entry.Size = (uint)Marshal.SizeOf(); } while (Process32Next(safeSnapshotHandle, ref entry)); int lastWin32Error = Marshal.GetLastWin32Error(); if (lastWin32Error != 18) { throw new InvalidOperationException($"Windows process enumeration failed. Error {lastWin32Error}."); } return dictionary; } } [DllImport("kernel32.dll", SetLastError = true)] private static extern SafeSnapshotHandle CreateToolhelp32Snapshot(uint flags, uint processId); [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool Process32First(SafeSnapshotHandle snapshot, ref ProcessEntry32 entry); [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool Process32Next(SafeSnapshotHandle snapshot, ref ProcessEntry32 entry); [DllImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool CloseHandle(IntPtr handle); } internal sealed class VoiceProcessRouter : IDisposable { private const int CaptureCapacitySamples = 131072; private static readonly TimeSpan SessionRetryDelay = TimeSpan.FromSeconds(1.0); private readonly ManualLogSource _logger; private readonly VoiceAudioMixer _mixer = new VoiceAudioMixer(); private readonly ManualResetEvent _stop = new ManualResetEvent(initialState: false); private readonly RoutingSessionGate _routingSession = new RoutingSessionGate(); private readonly Thread _worker; private readonly int _sampleRate; private readonly int _gameProcessId; private readonly string _audioHostPath; private readonly RemoteVoiceSettingsState _settings; private bool _disposed; public bool IsReady => _routingSession.IsReady; public VoiceProcessRouter(ManualLogSource logger, int sampleRate, int gameProcessId, string audioHostPath, RemoteVoiceSettingsState settings) { _logger = logger ?? throw new ArgumentNullException("logger"); if (sampleRate <= 0) { throw new ArgumentOutOfRangeException("sampleRate"); } if (gameProcessId <= 0) { throw new ArgumentOutOfRangeException("gameProcessId"); } if (string.IsNullOrWhiteSpace(audioHostPath)) { throw new ArgumentException("The audio host path is required.", "audioHostPath"); } _sampleRate = sampleRate; _gameProcessId = gameProcessId; _audioHostPath = audioHostPath; _settings = settings ?? throw new ArgumentNullException("settings"); _worker = new Thread(WorkerMain) { IsBackground = true, Name = "RemoteVoiceSplit pipe sender" }; _worker.Start(); } public VoiceCaptureStream RegisterCapture() { ThrowIfDisposed(); return _mixer.Register(131072); } public void UnregisterCapture(VoiceCaptureStream stream) { _mixer.Unregister(stream); } public RoutingSubmissionLease? TrySubmit(VoiceCaptureStream stream, float[] samples, int channels) { RoutingSubmissionLease routingSubmissionLease = _routingSession.TryBeginSubmission(); if (routingSubmissionLease == null) { return null; } if (!stream.TryWrite(samples, channels)) { routingSubmissionLease.Dispose(); return null; } return routingSubmissionLease; } public void Dispose() { if (!_disposed) { _disposed = true; SetNotReady(); _stop.Set(); if (_worker.Join(TimeSpan.FromSeconds(7.0))) { _stop.Dispose(); } else { TryLog((LogLevel)4, "The remote voice audio-host thread did not stop within seven seconds; its stop handle was left intact."); } } } private void WorkerMain() { string pipeName = $"RemoteVoiceSplit-{_gameProcessId}-{Guid.NewGuid():N}"; int num = 0; string a = null; while (!_stop.WaitOne(0)) { try { if (!IsExpectedHostProcess(num)) { num = DetachedAudioHostLauncher.Launch(_audioHostPath, pipeName, _gameProcessId); } RunSession(pipeName, num); a = null; } catch (Exception ex) { string text = ex.GetType().Name + ": " + ex.Message; if (!string.Equals(a, text, StringComparison.Ordinal)) { TryLog((LogLevel)2, (!_settings.Enabled) ? ("Remote voice process output is unavailable while Remote Voice Split is disabled; Unity output remains enabled. " + text) : (_settings.FallbackToGameOutput ? ("Remote voice process output is unavailable; Unity output remains enabled. " + text) : ("Remote voice process output is unavailable; remote voice remains silent until it recovers. " + text))); a = text; } } finally { SetNotReady(); } _stop.WaitOne(SessionRetryDelay); } } private void RunSession(string pipeName, int expectedHostProcessId) { using NamedPipeClientStream namedPipeClientStream = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous); namedPipeClientStream.Connect(5000); using BinaryReader reader = new BinaryReader(namedPipeClientStream, Encoding.UTF8, leaveOpen: true); using BinaryWriter writer = new BinaryWriter(namedPipeClientStream, Encoding.UTF8, leaveOpen: true); AudioHostProtocol.WriteClientHello(writer, _sampleRate); int num = AudioHostProtocol.ReadServerReady(reader); if (num != expectedHostProcessId) { throw new InvalidOperationException("The audio host handshake did not match the launched process."); } int serverProcessId = NamedPipeServerIdentity.GetServerProcessId(namedPipeClientStream); if (num != serverProcessId) { throw new InvalidOperationException("The audio host handshake did not match the actual pipe server process."); } VerifyExpectedHostProcess(num); SetReady(); TryLog((LogLevel)16, "Remote voice process output is ready. Select 'Lethal Company Remote Voice Split' in OBS Application Audio Capture."); SendAudio(namedPipeClientStream, writer); } private bool IsExpectedHostProcess(int processId) { if (processId <= 0) { return false; } try { VerifyExpectedHostProcess(processId); return true; } catch { return false; } } private void VerifyExpectedHostProcess(int processId) { string fullPath = Path.GetFullPath(_audioHostPath); string fullPath2 = Path.GetFullPath(ProcessImagePath.Get(processId)); if (!string.Equals(fullPath, fullPath2, StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException("The actual pipe server is not the packaged Remote Voice Split audio host."); } if (ProcessTreeSnapshot.IsSelfOrDescendant(processId, _gameProcessId)) { throw new InvalidOperationException("The audio host remained inside the game process tree and cannot provide an isolated OBS source."); } } private void SendAudio(NamedPipeClientStream pipe, BinaryWriter writer) { checked { int num = Math.Max(1, unchecked(_sampleRate / 100)) * 2; if (num > 16384) { throw new InvalidOperationException("The runtime sample rate exceeds the audio host protocol block limit."); } float[] array = new float[num]; byte[] array2 = new byte[num * 4]; Stopwatch stopwatch = Stopwatch.StartNew(); while (!_stop.WaitOne(TimeSpan.FromMilliseconds(10.0))) { if (_mixer.Mix(array, num)) { Buffer.BlockCopy(array, 0, array2, 0, array2.Length); writer.Write(num); writer.Write(array2, 0, array2.Length); writer.Flush(); stopwatch.Restart(); } else if (stopwatch.Elapsed >= TimeSpan.FromSeconds(1.0)) { writer.Write(0); writer.Flush(); stopwatch.Restart(); } if (!pipe.IsConnected) { throw new EndOfStreamException("The audio host disconnected."); } } } } private void SetReady() { _routingSession.Activate(); } private void SetNotReady() { _routingSession.Deactivate(); _mixer.Clear(); } private void TryLog(LogLevel level, string message) { //IL_0006: Unknown result type (might be due to invalid IL or missing references) try { _logger.Log(level, (object)message); } catch { } } private void ThrowIfDisposed() { if (_disposed) { throw new ObjectDisposedException("VoiceProcessRouter"); } } } } namespace RemoteVoiceSplit.Interop.Game { internal static class GameReflection { public static Type StartOfRoundType { get; } = RequireType("StartOfRound"); public static FieldInfo AllPlayerScriptsField { get; } = RequireField(StartOfRoundType, "allPlayerScripts"); public static FieldInfo LocalPlayerControllerField { get; } = RequireField(StartOfRoundType, "localPlayerController"); public static FieldInfo IsPlayerControlledField { get; } = RequireField(RequireType("GameNetcodeStuff.PlayerControllerB"), "isPlayerControlled"); public static FieldInfo IsPlayerDeadField { get; } = RequireField(RequireType("GameNetcodeStuff.PlayerControllerB"), "isPlayerDead"); public static FieldInfo CurrentVoiceSourceField { get; } = RequireField(RequireType("GameNetcodeStuff.PlayerControllerB"), "currentVoiceChatAudioSource"); public static MethodInfo StartOfRoundRefreshMethod { get; } = RequireMethod(StartOfRoundType, "RefreshPlayerVoicePlaybackObjects"); private static Type RequireType(string name) { return AccessTools.TypeByName(name) ?? throw new TypeLoadException("Lethal Company v81 type '" + name + "' was not found."); } private static FieldInfo RequireField(Type type, string name) { return AccessTools.Field(type, name) ?? throw new MissingFieldException(type.FullName, name); } private static MethodInfo RequireMethod(Type type, string name) { return AccessTools.Method(type, name, Type.EmptyTypes, (Type[])null) ?? throw new MissingMethodException(type.FullName, name); } } internal static class IntegrationContext { private static ManualLogSource? _logger; private static VoiceRoutingContext? _routing; public static void Initialize(ManualLogSource logger, VoiceProcessRouter router, RemoteVoiceSettingsState settings) { _logger = logger; _routing = new VoiceRoutingContext(router, settings); } public static void Clear() { _routing = null; _logger = null; } public static bool TryGetRouting(out VoiceRoutingContext? routing) { routing = _routing; return routing != null; } public static void RunGuarded(string operation, Action action) { try { action(); } catch (Exception ex) { TryLog((LogLevel)2, operation + " failed without interrupting the game: " + ex.GetType().Name + ": " + ex.Message); } } private static void TryLog(LogLevel level, string message) { //IL_000b: Unknown result type (might be due to invalid IL or missing references) try { ManualLogSource? logger = _logger; if (logger != null) { logger.Log(level, (object)message); } } catch { } } } internal sealed class VoiceRoutingContext { public VoiceProcessRouter Router { get; } public RemoteVoiceSettingsState Settings { get; } public VoiceRoutingContext(VoiceProcessRouter router, RemoteVoiceSettingsState settings) { Router = router; Settings = settings; } } internal static class PluginRuntime { private static readonly object Gate = new object(); private static Harmony? _harmony; private static ManualLogSource? _logger; private static VoiceProcessRouter? _router; private static RemoteVoiceConfiguration? _configuration; private static bool _initialized; public static bool Initialize(ManualLogSource logger, int sampleRate, int gameProcessId, string audioHostPath, ConfigEntry enabled, ConfigEntry fallbackToGameOutput) { //IL_0082: Unknown result type (might be due to invalid IL or missing references) //IL_0088: Expected O, but got Unknown if (logger == null) { throw new ArgumentNullException("logger"); } if (enabled == null) { throw new ArgumentNullException("enabled"); } if (fallbackToGameOutput == null) { throw new ArgumentNullException("fallbackToGameOutput"); } lock (Gate) { if (_initialized) { return false; } VoiceProcessRouter router = null; Harmony val = null; RemoteVoiceConfiguration remoteVoiceConfiguration = null; try { remoteVoiceConfiguration = new RemoteVoiceConfiguration(enabled, fallbackToGameOutput, logger); router = new VoiceProcessRouter(logger, sampleRate, gameProcessId, audioHostPath, remoteVoiceConfiguration.State); IntegrationContext.Initialize(logger, router, remoteVoiceConfiguration.State); val = new Harmony("com.aoirint.remotevoicesplit"); val.PatchAll(typeof(Plugin).Assembly); _logger = logger; _router = router; _harmony = val; _configuration = remoteVoiceConfiguration; Application.quitting += OnApplicationQuitting; _initialized = true; return true; } catch { IntegrationContext.Clear(); TryUnpatch(val, logger); TryDisposeRouter(router, logger); TryDisposeConfiguration(remoteVoiceConfiguration, logger); _harmony = null; _router = null; _logger = null; _configuration = null; throw; } } } private static void OnApplicationQuitting() { lock (Gate) { if (_initialized) { _initialized = false; Application.quitting -= OnApplicationQuitting; ManualLogSource logger = _logger; Harmony? harmony = _harmony; VoiceProcessRouter router = _router; RemoteVoiceConfiguration configuration = _configuration; _harmony = null; _router = null; _logger = null; _configuration = null; TryLog(logger, (LogLevel)16, "Remote Voice Split is stopping because the game application is quitting."); IntegrationContext.Clear(); TryUnpatch(harmony, logger); TryDisposeRouter(router, logger); TryDisposeConfiguration(configuration, logger); } } } private static void TryUnpatch(Harmony? harmony, ManualLogSource? logger) { try { if (harmony != null) { harmony.UnpatchSelf(); } } catch (Exception ex) { TryLog(logger, (LogLevel)4, "Harmony cleanup failed during application shutdown: " + ex.GetType().Name + ": " + ex.Message); } } private static void TryDisposeRouter(VoiceProcessRouter? router, ManualLogSource? logger) { try { router?.Dispose(); } catch (Exception ex) { TryLog(logger, (LogLevel)4, "Audio-router cleanup failed during application shutdown: " + ex.GetType().Name + ": " + ex.Message); } } private static void TryDisposeConfiguration(RemoteVoiceConfiguration? configuration, ManualLogSource? logger) { try { configuration?.Dispose(); } catch (Exception ex) { TryLog(logger, (LogLevel)4, "Configuration cleanup failed during application shutdown: " + ex.GetType().Name + ": " + ex.Message); } } private static void TryLog(ManualLogSource? logger, LogLevel level, string message) { //IL_0004: Unknown result type (might be due to invalid IL or missing references) try { if (logger != null) { logger.Log(level, (object)message); } } catch { } } } internal sealed class RemoteVoiceConfiguration : IDisposable { private readonly ConfigEntry _enabledEntry; private readonly ConfigEntry _fallbackEntry; private readonly ManualLogSource _logger; private bool _disposed; public RemoteVoiceSettingsState State { get; } public RemoteVoiceConfiguration(ConfigEntry enabledEntry, ConfigEntry fallbackEntry, ManualLogSource logger) { _enabledEntry = enabledEntry ?? throw new ArgumentNullException("enabledEntry"); _fallbackEntry = fallbackEntry ?? throw new ArgumentNullException("fallbackEntry"); _logger = logger ?? throw new ArgumentNullException("logger"); State = new RemoteVoiceSettingsState(enabledEntry.Value, fallbackEntry.Value); _enabledEntry.SettingChanged += OnEnabledSettingChanged; _fallbackEntry.SettingChanged += OnFallbackSettingChanged; State.Update(_enabledEntry.Value, _fallbackEntry.Value); } public void Dispose() { if (!_disposed) { _disposed = true; _enabledEntry.SettingChanged -= OnEnabledSettingChanged; _fallbackEntry.SettingChanged -= OnFallbackSettingChanged; } } private void OnEnabledSettingChanged(object sender, EventArgs args) { if (TryApply()) { TryLog((LogLevel)16, State.Enabled ? "Remote Voice Split is enabled." : "Remote Voice Split is disabled; remote voices remain on the normal game output."); } } private void OnFallbackSettingChanged(object sender, EventArgs args) { if (TryApply()) { TryLog((LogLevel)16, State.FallbackToGameOutput ? "Remote voice fallback to normal game output is enabled." : "Remote voice fallback to normal game output is disabled; unavailable remote voice will remain silent."); } } private bool TryApply() { try { State.Update(_enabledEntry.Value, _fallbackEntry.Value); return true; } catch (Exception ex) { TryLog((LogLevel)2, "Remote voice configuration could not be applied: " + ex.GetType().Name + ": " + ex.Message); return false; } } private void TryLog(LogLevel level, string message) { //IL_0006: Unknown result type (might be due to invalid IL or missing references) try { _logger.Log(level, (object)message); } catch { } } } internal sealed class VoiceCaptureFilter : MonoBehaviour { private sealed class CaptureRegistration { public VoiceProcessRouter Router { get; } public VoiceCaptureStream Stream { get; } public RemoteVoiceSettingsState Settings { get; } public AtomicCommitLease CommitLease { get; } = new AtomicCommitLease(); public CaptureRegistration(VoiceProcessRouter router, VoiceCaptureStream stream, RemoteVoiceSettingsState settings) { Router = router; Stream = stream; Settings = settings; } } private readonly AtomicRegistration _registration = new AtomicRegistration(); public void Initialize(VoiceProcessRouter router, RemoteVoiceSettingsState settings) { CaptureRegistration captureRegistration = _registration.Read(); if (captureRegistration != null && captureRegistration.Router == router && captureRegistration.Settings == settings) { ((Behaviour)this).enabled = true; return; } Deactivate(); CaptureRegistration value = new CaptureRegistration(router, router.RegisterCapture(), settings); _registration.Exchange(value); ((Behaviour)this).enabled = true; } public void Deactivate() { ((Behaviour)this).enabled = false; CaptureRegistration captureRegistration = _registration.Exchange(null); if (captureRegistration != null) { captureRegistration.CommitLease.Retire(); captureRegistration.Router.UnregisterCapture(captureRegistration.Stream); } } private void OnAudioFilterRead(float[] data, int channels) { CaptureRegistration captureRegistration = _registration.Read(); if (captureRegistration == null) { return; } bool enabled = captureRegistration.Settings.Enabled; if (!enabled) { captureRegistration.Stream.Clear(); return; } using RoutingSubmissionLease routingSubmissionLease = captureRegistration.Router.TrySubmit(captureRegistration.Stream, data, channels); bool flag = routingSubmissionLease != null; if (!RemoteVoiceOutputPolicy.ShouldClearUnityOutput(enabled, flag, captureRegistration.Settings.FallbackToGameOutput)) { return; } if (!flag) { captureRegistration.Stream.Clear(); } if (!captureRegistration.CommitLease.TryBegin()) { captureRegistration.Stream.Clear(); return; } try { if (!_registration.IsCurrent(captureRegistration)) { captureRegistration.Stream.Clear(); } else { Array.Clear(data, 0, data.Length); } } finally { captureRegistration.CommitLease.End(); } } private void OnDestroy() { Deactivate(); } } internal static class VoicePlaybackIntegration { public static void Refresh(object startOfRound, VoiceProcessRouter router, RemoteVoiceSettingsState settings) { object value = GameReflection.LocalPlayerControllerField.GetValue(startOfRound); if (value == null || !(GameReflection.AllPlayerScriptsField.GetValue(startOfRound) is Array array)) { return; } HashSet hashSet = new HashSet(); foreach (object item in array) { if (item != null) { bool isLocalPlayer = IsSameUnityObject(item, value); bool isPlayerControlled = (bool)(GameReflection.IsPlayerControlledField.GetValue(item) ?? ((object)false)); bool isPlayerDead = (bool)(GameReflection.IsPlayerDeadField.GetValue(item) ?? ((object)false)); object? value2 = GameReflection.CurrentVoiceSourceField.GetValue(item); AudioSource val = (AudioSource)((value2 is AudioSource) ? value2 : null); if (RemoteVoiceSelectionPolicy.ShouldCapture(isLocalPlayer, isPlayerControlled, isPlayerDead, (Object)(object)val != (Object)null) && !((Object)(object)val == (Object)null)) { hashSet.Add(val); (((Component)val).GetComponent() ?? ((Component)val).gameObject.AddComponent()).Initialize(router, settings); } } } VoiceCaptureFilter[] array2 = Object.FindObjectsOfType(true); foreach (VoiceCaptureFilter voiceCaptureFilter in array2) { AudioSource component = ((Component)voiceCaptureFilter).GetComponent(); if ((Object)(object)component == (Object)null || !hashSet.Contains(component)) { voiceCaptureFilter.Deactivate(); } } } private static bool IsSameUnityObject(object left, object right) { if (left == right) { return true; } Object val = (Object)((left is Object) ? left : null); if (val != null) { Object val2 = (Object)((right is Object) ? right : null); if (val2 != null) { return val == val2; } } return false; } } [HarmonyPatch] internal static class RefreshPlayerVoicePlaybackObjectsPatch { private static MethodInfo TargetMethod() { return GameReflection.StartOfRoundRefreshMethod; } [HarmonyPostfix] private static void Postfix(object __instance) { IntegrationContext.RunGuarded("Remote voice source attachment", delegate { if (IntegrationContext.TryGetRouting(out VoiceRoutingContext routing) && routing != null) { VoicePlaybackIntegration.Refresh(__instance, routing.Router, routing.Settings); } }); } } } namespace RemoteVoiceSplit.Core { internal sealed class AtomicRegistration where T : class { private T? _current; public T? Read() { return Volatile.Read(in _current); } public T? Exchange(T? value) { return Interlocked.Exchange(ref _current, value); } public bool IsCurrent(T value) { return Volatile.Read(in _current) == value; } } internal sealed class AtomicCommitLease { private const int Active = 0; private const int Committing = 1; private const int Retired = 2; private int _state; public bool TryBegin() { return Interlocked.CompareExchange(ref _state, 1, 0) == 0; } public void End() { if (Interlocked.CompareExchange(ref _state, 0, 1) != 1) { throw new InvalidOperationException("The commit lease is not active."); } } public void Retire() { SpinWait spinWait = default(SpinWait); while (Interlocked.CompareExchange(ref _state, 2, 0) != 0 && Volatile.Read(in _state) != 2) { spinWait.SpinOnce(); } } } internal sealed class AtomicUsageLease { private const int RetiredMask = int.MinValue; private const int UsageMask = int.MaxValue; private int _state; public bool TryBegin() { int num; do { num = Volatile.Read(in _state); if ((num & int.MinValue) != 0) { return false; } if ((num & 0x7FFFFFFF) == int.MaxValue) { throw new InvalidOperationException("The usage lease is exhausted."); } } while (Interlocked.CompareExchange(ref _state, num + 1, num) != num); return true; } public void End() { if ((Interlocked.Decrement(ref _state) & 0x7FFFFFFF) == int.MaxValue) { throw new InvalidOperationException("The usage lease was released without a matching acquisition."); } } public void Retire() { int num; do { num = Volatile.Read(in _state); } while ((num & int.MinValue) == 0 && Interlocked.CompareExchange(ref _state, num | int.MinValue, num) != num); SpinWait spinWait = default(SpinWait); while ((Volatile.Read(in _state) & 0x7FFFFFFF) != 0) { spinWait.SpinOnce(); } } } internal static class AudioHostProtocol { public const int Magic = 827610706; public const int Version = 1; public const int Channels = 2; public const int Ready = 1; public const int MaximumBlockSamples = 16384; public static void WriteClientHello(BinaryWriter writer, int sampleRate) { if (writer == null) { throw new ArgumentNullException("writer"); } if (sampleRate <= 0) { throw new ArgumentOutOfRangeException("sampleRate"); } writer.Write(827610706); writer.Write(1); writer.Write(sampleRate); writer.Write(2); writer.Flush(); } public static int ReadClientHello(BinaryReader reader) { if (reader == null) { throw new ArgumentNullException("reader"); } RequireEqual(827610706, reader.ReadInt32(), "magic"); RequireEqual(1, reader.ReadInt32(), "version"); int num = reader.ReadInt32(); if (num <= 0) { throw new InvalidDataException("The audio host protocol sample rate is invalid."); } RequireEqual(2, reader.ReadInt32(), "channel count"); return num; } public static void WriteServerReady(BinaryWriter writer, int processId) { if (writer == null) { throw new ArgumentNullException("writer"); } if (processId <= 0) { throw new ArgumentOutOfRangeException("processId"); } writer.Write(827610706); writer.Write(1); writer.Write(1); writer.Write(processId); writer.Flush(); } public static int ReadServerReady(BinaryReader reader) { if (reader == null) { throw new ArgumentNullException("reader"); } RequireEqual(827610706, reader.ReadInt32(), "magic"); RequireEqual(1, reader.ReadInt32(), "version"); RequireEqual(1, reader.ReadInt32(), "ready state"); int num = reader.ReadInt32(); if (num <= 0) { throw new InvalidDataException("The audio host process identifier is invalid."); } return num; } private static void RequireEqual(int expected, int actual, string field) { if (actual != expected) { throw new InvalidDataException("The audio host protocol " + field + " is incompatible."); } } } internal sealed class AudioRingBuffer { private readonly float[] _samples; private readonly int _mask; private int _readSequence; private int _writeSequence; public int CapacitySamples => _samples.Length; public int AvailableSamples { get { int num = Volatile.Read(in _writeSequence); int num2 = Volatile.Read(in _readSequence); uint num3 = (uint)(num - num2); if (num3 > _samples.Length) { return 0; } return (int)num3; } } public AudioRingBuffer(int capacitySamples) { if (capacitySamples < 2 || (capacitySamples & (capacitySamples - 1)) != 0) { throw new ArgumentOutOfRangeException("capacitySamples", "Capacity must be a power of two."); } _samples = new float[capacitySamples]; _mask = capacitySamples - 1; } public bool TryWriteStereo(float[] source, int channels) { return TryWriteStereo(source, (source != null) ? source.Length : 0, channels); } public bool TryWriteStereo(float[] source, int sourceSampleCount, int channels) { if (source == null) { throw new ArgumentNullException("source"); } if (sourceSampleCount < 0 || sourceSampleCount > source.Length || channels <= 0 || sourceSampleCount % channels != 0) { return false; } int num = sourceSampleCount / channels; int num2 = checked(num * 2); int writeSequence = _writeSequence; int num3 = Volatile.Read(in _readSequence); uint num4 = (uint)(writeSequence - num3); if (num4 > _samples.Length || num2 > _samples.Length - (int)num4) { return false; } int num5 = writeSequence & _mask; for (int i = 0; i < num; i++) { int num6 = i * channels; float num7 = source[num6]; float num8 = ((channels == 1) ? num7 : source[num6 + 1]); _samples[num5] = num7; _samples[(num5 + 1) & _mask] = num8; num5 = (num5 + 2) & _mask; } Volatile.Write(ref _writeSequence, writeSequence + num2); return true; } public int Read(float[] destination, int sampleCount) { return Read(destination, sampleCount, null); } internal int Read(float[] destination, int sampleCount, Action? beforePublish) { if (destination == null) { throw new ArgumentNullException("destination"); } if (sampleCount < 0 || sampleCount > destination.Length) { throw new ArgumentOutOfRangeException("sampleCount"); } int num = Volatile.Read(in _readSequence); uint num2 = (uint)(Volatile.Read(in _writeSequence) - num); if (num2 > _samples.Length) { return 0; } int num3 = Math.Min(sampleCount, (int)num2); int num4 = num & _mask; int num5 = Math.Min(num3, _samples.Length - num4); Array.Copy(_samples, num4, destination, 0, num5); Array.Copy(_samples, 0, destination, num5, num3 - num5); beforePublish?.Invoke(); if (Interlocked.CompareExchange(ref _readSequence, num + num3, num) != num) { Array.Clear(destination, 0, num3); return 0; } return num3; } public void Clear() { int value = Volatile.Read(in _writeSequence); Interlocked.Exchange(ref _readSequence, value); } } internal static class ProcessAncestry { public static bool IsSelfOrDescendant(int candidateProcessId, int ancestorProcessId, IReadOnlyDictionary parentByProcessId) { if (candidateProcessId <= 0) { throw new ArgumentOutOfRangeException("candidateProcessId"); } if (ancestorProcessId <= 0) { throw new ArgumentOutOfRangeException("ancestorProcessId"); } if (parentByProcessId == null) { throw new ArgumentNullException("parentByProcessId"); } int value = candidateProcessId; HashSet hashSet = new HashSet(); while (value > 0 && hashSet.Add(value)) { if (value == ancestorProcessId) { return true; } if (!parentByProcessId.TryGetValue(value, out value)) { return false; } } return false; } } internal static class RemoteVoiceOutputPolicy { public const bool DefaultEnabled = true; public const bool DefaultFallbackToGameOutput = false; public static bool ShouldClearUnityOutput(bool enabled, bool submissionAccepted, bool fallbackToGameOutput) { if (enabled) { if (!submissionAccepted) { return !fallbackToGameOutput; } return true; } return false; } } internal sealed class RemoteVoiceSettingsState { private int _enabled; private int _fallbackToGameOutput; public bool Enabled => Volatile.Read(in _enabled) != 0; public bool FallbackToGameOutput => Volatile.Read(in _fallbackToGameOutput) != 0; public RemoteVoiceSettingsState(bool enabled, bool fallbackToGameOutput) { Update(enabled, fallbackToGameOutput); } public void Update(bool enabled, bool fallbackToGameOutput) { Volatile.Write(ref _enabled, enabled ? 1 : 0); Volatile.Write(ref _fallbackToGameOutput, fallbackToGameOutput ? 1 : 0); } } internal static class RemoteVoiceSelectionPolicy { public static bool ShouldCapture(bool isLocalPlayer, bool isPlayerControlled, bool isPlayerDead, bool hasVoiceSource) { return !isLocalPlayer && (isPlayerControlled || isPlayerDead) && hasVoiceSource; } } internal sealed class RoutingSessionGate { private sealed class RoutingSessionEpoch { public AtomicUsageLease Usage { get; } = new AtomicUsageLease(); } private readonly object _transitionGate = new object(); private readonly AtomicRegistration _current = new AtomicRegistration(); public bool IsReady => _current.Read() != null; public void Activate() { lock (_transitionGate) { if (_current.Read() != null) { throw new InvalidOperationException("A routing session was already active."); } _current.Exchange(new RoutingSessionEpoch()); } } public RoutingSubmissionLease? TryBeginSubmission() { RoutingSessionEpoch routingSessionEpoch = _current.Read(); if (routingSessionEpoch == null || !routingSessionEpoch.Usage.TryBegin()) { return null; } if (!_current.IsCurrent(routingSessionEpoch)) { routingSessionEpoch.Usage.End(); return null; } return new RoutingSubmissionLease(routingSessionEpoch.Usage); } public void Deactivate() { lock (_transitionGate) { _current.Exchange(null)?.Usage.Retire(); } } } internal sealed class RoutingSubmissionLease : IDisposable { private AtomicUsageLease? _usage; public RoutingSubmissionLease(AtomicUsageLease usage) { _usage = usage; } public void Dispose() { Interlocked.Exchange(ref _usage, null)?.End(); } } internal sealed class VoiceAudioMixer { private readonly object _gate = new object(); private VoiceCaptureStream[] _streams = Array.Empty(); private float[] _scratch = Array.Empty(); public VoiceCaptureStream Register(int capacitySamples) { VoiceCaptureStream voiceCaptureStream = new VoiceCaptureStream(capacitySamples); lock (_gate) { VoiceCaptureStream[] streams = _streams; VoiceCaptureStream[] array = new VoiceCaptureStream[streams.Length + 1]; Array.Copy(streams, array, streams.Length); array[streams.Length] = voiceCaptureStream; Volatile.Write(ref _streams, array); return voiceCaptureStream; } } public void Unregister(VoiceCaptureStream stream) { if (stream == null) { return; } lock (_gate) { VoiceCaptureStream[] streams = _streams; int num = Array.IndexOf(streams, stream); if (num < 0) { return; } VoiceCaptureStream[] array = new VoiceCaptureStream[streams.Length - 1]; Array.Copy(streams, 0, array, 0, num); Array.Copy(streams, num + 1, array, num, streams.Length - num - 1); Volatile.Write(ref _streams, array); } stream.Clear(); } public bool Mix(float[] destination, int sampleCount) { if (destination == null) { throw new ArgumentNullException("destination"); } if (sampleCount < 0 || sampleCount > destination.Length || (sampleCount & 1) != 0) { throw new ArgumentOutOfRangeException("sampleCount"); } Array.Clear(destination, 0, sampleCount); EnsureScratchCapacity(sampleCount); bool flag = false; VoiceCaptureStream[] array = Volatile.Read(in _streams); for (int i = 0; i < array.Length; i++) { int num = array[i].Read(_scratch, sampleCount); flag = flag || num > 0; for (int j = 0; j < num; j++) { destination[j] += _scratch[j]; } } if (flag) { for (int k = 0; k < sampleCount; k++) { destination[k] = Math.Max(-1f, Math.Min(1f, destination[k])); } } return flag; } public void Clear() { VoiceCaptureStream[] array = Volatile.Read(in _streams); for (int i = 0; i < array.Length; i++) { array[i].Clear(); } } private void EnsureScratchCapacity(int sampleCount) { if (_scratch.Length < sampleCount) { _scratch = new float[sampleCount]; } } } internal sealed class VoiceCaptureStream { private readonly AudioRingBuffer _buffer; public VoiceCaptureStream(int capacitySamples) { _buffer = new AudioRingBuffer(capacitySamples); } public bool TryWrite(float[] samples, int channels) { return _buffer.TryWriteStereo(samples, channels); } public int Read(float[] destination, int sampleCount) { int num = _buffer.Read(destination, sampleCount); if ((num & 1) != 0) { throw new InvalidOperationException("A stereo stream returned a partial frame."); } return num; } public void Clear() { _buffer.Clear(); } } }