using System.Runtime.InteropServices; using System.Threading.Channels; // The public, safe C# surface over libvoicecat. Everything below is a thin wrapper around // NativeMethods — see docs/architecture.md §4 ("the core owns audio; C# only orchestrates"). namespace VoiceCat.Interop; public sealed class VoiceCatClient : IDisposable { private readonly VoiceCatClientHandle _handle = new(); private readonly GCHandle _selfHandle; // Native string buffers backing vc_config — must outlive the WHOLE client lifetime, not // just vc_client_create(): client_name/client_version are read later, whenever connect() // actually runs on io_thread_ (vc_client just stores the raw pointers from vc_config by // value, it does not copy the string data). Freed in Dispose(), after vc_client_destroy // has returned (which synchronously joins every internal thread, so nothing can still be // reading these pointers by then). private nint _clientNamePtr; private nint _clientVersionPtr; private nint _tofuStorePathPtr; private readonly Channel _events = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true, SingleWriter = true, }); // on_level fires far more often than on_event and intermediate values are visually // irrelevant — coalesce to "latest sample per stream_id" instead of queuing every one. private readonly System.Collections.Concurrent.ConcurrentDictionary _latestLevels = new(); /// Raised from PumpEvents() (i.e. on whatever thread calls it — see that method's /// doc comment) for every event, in order, never coalesced. public event Action? EventReceived; /// Raised from PumpEvents() with the latest RMS level per stream_id since the /// last pump. public event Action? LevelChanged; public unsafe VoiceCatClient(string clientName, string clientVersion, VcLogLevel logLevel = VcLogLevel.Info, string? tofuStorePath = null) { _selfHandle = GCHandle.Alloc(this, GCHandleType.Normal); _clientNamePtr = Marshal.StringToCoTaskMemUTF8(clientName); _clientVersionPtr = Marshal.StringToCoTaskMemUTF8(clientVersion); _tofuStorePathPtr = tofuStorePath is null ? 0 : Marshal.StringToCoTaskMemUTF8(tofuStorePath); var cfg = new VcConfigNative { ClientName = _clientNamePtr, ClientVersion = _clientVersionPtr, LogLevel = logLevel, TofuStorePath = _tofuStorePathPtr, }; var cb = new VcCallbacksNative { OnEvent = (nint)(delegate* unmanaged)&NativeCallbacks.OnEvent, OnLevel = (nint)(delegate* unmanaged)&NativeCallbacks.OnLevel, User = GCHandle.ToIntPtr(_selfHandle), }; nint native = NativeMethods.vc_client_create(in cfg, cb); _handle.SetHandle(native); if (_handle.IsInvalid) { FreeConfigStrings(); _selfHandle.Free(); throw new InvalidOperationException("vc_client_create failed."); } } /// /// Drains every event/level sample queued since the last call. Call this from a /// System.Windows.Forms.Timer.Tick on the UI thread (~30-50ms) — this is the boundary /// where the core's own event-delivery thread hands off to the UI thread; see /// docs/architecture.md §3 and this project's README for why a Timer + Channel was chosen /// over a message-only window + PostMessage. /// public void PumpEvents() { while (_events.Reader.TryRead(out var ev)) EventReceived?.Invoke(ev); if (!_latestLevels.IsEmpty) { foreach (var (streamId, rms) in _latestLevels) LevelChanged?.Invoke(streamId, rms); _latestLevels.Clear(); } } internal void EnqueueEvent(VoiceCatEvent ev) { // Temporary diagnostic (manual debugging session) — confirms the native callback // chain (UnmanagedCallersOnly -> GCHandle resolve -> here) actually fires, independent // of whether the UI-thread drain (PumpEvents) ever sees it. Console.WriteLine($"[VoiceCatClient] EnqueueEvent (native thread): {ev}"); _events.Writer.TryWrite(ev); } internal void EnqueueLevel(uint streamId, float rms) => _latestLevels[streamId] = rms; // ── Connection & auth ──────────────────────────────────────────────────────────────── public VcResult Connect(string host, ushort port) => NativeMethods.vc_connect(_handle.DangerousGetHandle(), host, port); public VcResult Disconnect() => NativeMethods.vc_disconnect(_handle.DangerousGetHandle()); public VcResult AuthenticateGuest(string nickname) => NativeMethods.vc_authenticate_guest(_handle.DangerousGetHandle(), nickname); public VcResult AuthenticateUser(string username, string password) => NativeMethods.vc_authenticate_user(_handle.DangerousGetHandle(), username, password); // ── TOFU server-identity gate (M4) ────────────────────────────────────────────────────── public VcResult ConfirmServerIdentity(bool accept) => NativeMethods.vc_confirm_server_identity(_handle.DangerousGetHandle(), accept ? 1 : 0); /// The Ed25519 identity fingerprint from ServerHello, hex-formatted — display /// only, NOT the value the TOFU gate pins on (see VcTofuStatus's doc comment). Empty /// string if not yet available. public string GetServerIdentityDisplay() { nint c = _handle.DangerousGetHandle(); NativeMethods.vc_get_server_identity_display(c, 0, 0, out nuint len); if (len == 0) return string.Empty; nint buf = Marshal.AllocHGlobal((int)len + 1); try { NativeMethods.vc_get_server_identity_display(c, buf, len + (nuint)1, out _); return Marshal.PtrToStringUTF8(buf) ?? string.Empty; } finally { Marshal.FreeHGlobal(buf); } } // ── Channels ───────────────────────────────────────────────────────────────────────── /// Result arrives as a VcEventType.JoinResult event, not via this return value /// (which only reflects "request queued" — see voicecat.h's vc_join_channel doc comment). /// NOTE: no in-tree channel has a server-side password to check yet (M5+ feature) — this /// path is wired but not yet exercisable end-to-end. public VcResult JoinChannel(uint channelId, string? password = null) => NativeMethods.vc_join_channel(_handle.DangerousGetHandle(), channelId, password); public VcResult LeaveChannel() => NativeMethods.vc_leave_channel(_handle.DangerousGetHandle()); public List ListChannels() { NativeMethods.vc_list_channels(_handle.DangerousGetHandle(), out var native); return Marshaling.ToManaged(ref native); } public List ListUsers() { NativeMethods.vc_list_users(_handle.DangerousGetHandle(), out var native); return Marshaling.ToManaged(ref native); } public List ListUserStreams(uint userId) { var r = NativeMethods.vc_list_user_streams(_handle.DangerousGetHandle(), userId, out var native); return r == VcResult.Ok ? Marshaling.ToManaged(ref native) : new List(); } // ── Local media streams ───────────────────────────────────────────────────────────────── public (VcResult Result, uint StreamId) StartStream(VcStreamKind kind, string label) { nint labelPtr = Marshal.StringToCoTaskMemUTF8(label); try { var desc = new VcStreamDescNative { Kind = kind, DeviceId = 0, Label = labelPtr }; var r = NativeMethods.vc_stream_start(_handle.DangerousGetHandle(), in desc, out uint streamId); return (r, streamId); } finally { Marshal.FreeCoTaskMem(labelPtr); } } public VcResult StopStream(uint streamId) => NativeMethods.vc_stream_stop(_handle.DangerousGetHandle(), streamId); public VcResult SetInputDevice(uint streamId, string? deviceId) => NativeMethods.vc_set_input_device(_handle.DangerousGetHandle(), streamId, deviceId); public VcResult SetInputMode(VcInputMode mode) => NativeMethods.vc_set_input_mode(_handle.DangerousGetHandle(), mode); public VcResult SetVadThreshold(float threshold) => NativeMethods.vc_set_vad_threshold(_handle.DangerousGetHandle(), threshold); public VcResult SetPushToTalk(bool active) => NativeMethods.vc_set_push_to_talk(_handle.DangerousGetHandle(), active ? 1 : 0); public VcResult SetSelfMute(bool micMuted, bool deafened) => NativeMethods.vc_set_self_mute(_handle.DangerousGetHandle(), micMuted ? 1 : 0, deafened ? 1 : 0); public VcResult SetRemoteStream(uint userId, uint streamId, float gain, bool muted, bool noiseReduction) => NativeMethods.vc_set_remote_stream(_handle.DangerousGetHandle(), userId, streamId, gain, muted ? 1 : 0, noiseReduction ? 1 : 0); public (VcResult Result, AudioConfigInfo? Config) GetStreamAudioConfig(uint userId, uint streamId) { var r = NativeMethods.vc_get_stream_audio_config(_handle.DangerousGetHandle(), userId, streamId, out var native); return (r, r == VcResult.Ok ? Marshaling.ToManaged(in native) : null); } // ── Text ───────────────────────────────────────────────────────────────────────────── public VcResult SendText(VcTextScope scope, uint targetId, string utf8) => NativeMethods.vc_send_text(_handle.DangerousGetHandle(), scope, targetId, utf8); // ── Device enumeration (works pre-connect) ────────────────────────────────────────────── public List ListDevices(VcDeviceKind kind) { NativeMethods.vc_list_devices(_handle.DangerousGetHandle(), kind, out var native); return Marshaling.ToManaged(ref native); } // ── Lifecycle ──────────────────────────────────────────────────────────────────────── // NativeMethods.vc_version_string/vc_result_string return raw pointers to static, never- // freed string literals — see NativeMethods.cs's comment for why we don't let LibraryImport // auto-marshal these as `string` (it would try to free a static literal and corrupt the // heap). Marshal.PtrToStringUTF8 just reads; it never frees. public static string VersionString => Marshal.PtrToStringUTF8(NativeMethods.vc_version_string()) ?? string.Empty; public static string ResultString(VcResult code) => Marshal.PtrToStringUTF8(NativeMethods.vc_result_string(code)) ?? string.Empty; public void Dispose() { _handle.Dispose(); // runs vc_client_destroy (joins every internal thread) synchronously FreeConfigStrings(); if (_selfHandle.IsAllocated) _selfHandle.Free(); } private void FreeConfigStrings() { if (_clientNamePtr != 0) { Marshal.FreeCoTaskMem(_clientNamePtr); _clientNamePtr = 0; } if (_clientVersionPtr != 0) { Marshal.FreeCoTaskMem(_clientVersionPtr); _clientVersionPtr = 0; } if (_tofuStorePathPtr != 0) { Marshal.FreeCoTaskMem(_tofuStorePathPtr); _tofuStorePathPtr = 0; } } }