feat(M4): Windows WinForms client, TOFU identity pinning, VAD threshold + always-on mode

Core ABI extensions (voicecat.h):
- vc_list_channels / vc_list_users / vc_list_user_streams — pull-based snapshot getters
  for the channel-tree and user-list UI; session_model_mu_ guards cross-thread reads
- VC_EVENT_JOIN_RESULT / vc_join_channel — channel join with optional password
- VC_EVENT_SERVER_IDENTITY + vc_confirm_server_identity — TOFU gate that blocks io_thread_
  until the UI approves or rejects; pins TLS leaf-cert SHA-256 (not declared Ed25519)
- vc_get_server_identity_display — Ed25519 fingerprint for human-readable display only
- VC_INPUT_ALWAYS_ON = 2 in vc_input_mode — transmit unconditionally, no VAD gate
- vc_set_vad_threshold — live RMS threshold update (0.0–1.0); EnergyVadProcessor stores
  it atomically so the audio RT path reads without a lock

C++ implementation:
- SessionModel::apply_snapshot / apply_channel_event fixed to populate parent_id,
  password_protected, and max_users (were permanently zeroed)
- TlsContext::peer_cert_fingerprint — SHA-256 of peer leaf cert DER via mbedTLS
- TofuStore split into peek (read-only) + pin (write) so first-connect only persists
  after user approval; tofu_store_path in vc_config for per-user pin file location
- TcpAcceptor uses dual-stack IPv6+IPv4 fallback (fixes localhost → ::1 on Windows)
- windows-client CMake preset: Release shared DLL, static MinGW runtime, no tools/tests
- New C++ tests: test_channel_user_list_abi, test_tofu_flow (14/14 green)

Windows client (clients/windows/ — .NET 10 WinForms):
- VoiceCat.Interop: LibraryImport P/Invoke surface, UnmanagedCallersOnly callbacks,
  Channel<VoiceCatEvent> event delivery drained by 30ms WinForms Timer
- VoiceCat.App: ConnectDialog (saved servers, DPAPI password storage), ServerIdentity-
  Dialog (TOFU first-connect / mismatch warning), MainForm (channel TreeView, user
  ListBox, RichTextBox chat, voice controls, device pickers, VAD/PTT/always-on mode,
  per-user gain/mute/NR tuning, VAD sensitivity TrackBar, level meter ProgressBar)
- PttKeyCaptureDialog — focus-scoped PTT key capture (documented limitation)
- PerUserTuningDialog — real-time gain/mute/NR applied to all of a user's streams
- Accessibility: explicit AccessibleName/Description on every control, & mnemonics,
  Activity log ListBox as durable screen-reader record, AutomationNotification for
  curated live announcements

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 00:35:16 +02:00
parent 5be869c61a
commit 63b241cc2e
56 changed files with 4685 additions and 35 deletions

View File

@@ -0,0 +1,104 @@
// Mirrors of voicecat.h's enums. Keep these in lockstep with core/include/voicecat.h —
// values are append-only per the C ABI's house rule, so it's safe to add new members at the
// end here too, but never renumber/remove existing ones.
namespace VoiceCat.Interop;
public enum VcResult
{
Ok = 0,
NotImplemented = 1,
InvalidArg = 2,
NotConnected = 3,
Already = 4,
AuthFailed = 5,
PermissionDenied = 6,
Timeout = 7,
Io = 8,
Protocol = 9,
Crypto = 10,
Audio = 11,
Internal = 12,
}
public enum VcLogLevel
{
Trace = 0,
Debug = 1,
Info = 2,
Warn = 3,
Error = 4,
Off = 5,
}
public enum VcConnectionState
{
Disconnected = 0,
Connecting = 1,
TlsHandshake = 2,
Authenticating = 3,
Connected = 4,
/// <summary>M4: handshake succeeded, waiting on vc_confirm_server_identity().</summary>
VerifyingIdentity = 5,
}
public enum VcTextScope
{
Channel = 0,
Private = 1,
Server = 2,
}
public enum VcDeviceKind
{
Input = 0,
Output = 1,
}
public enum VcStreamKind
{
Mic = 0,
/// <summary>System/desktop audio (WASAPI loopback on Windows).</summary>
ScreenAudio = 1,
AuxDevice = 2,
}
/// <summary>Send-side input gate (docs/voice.md §11).</summary>
public enum VcInputMode
{
VoiceActivation = 0,
PushToTalk = 1,
AlwaysOn = 2, // transmit unconditionally, no VAD gate
}
public enum VcEventType
{
ConnectionState = 0,
AuthResult = 1,
ChannelList = 2,
UserJoined = 3,
UserLeft = 4,
UserUpdated = 5,
TextMessage = 6,
StreamStarted = 7,
StreamStopped = 8,
TalkState = 9,
Error = 10,
Disconnected = 11,
/// <summary>M4: reply to VoiceCatClient.JoinChannelAsync's underlying vc_join_channel.</summary>
JoinResult = 12,
/// <summary>M4: the TOFU server-identity gate — see VcTofuStatus.</summary>
ServerIdentity = 13,
}
/// <summary>
/// TOFU server-identity classification. Pins the TLS leaf certificate's own SHA-256
/// fingerprint (real, verifiable from the handshake) — NOT the declared Ed25519 identity
/// fingerprint from ServerHello, which is informational/display-only (see
/// VoiceCatClient.GetServerIdentityDisplay and docs/security.md §1.1).
/// </summary>
public enum VcTofuStatus
{
FirstConnect = 0,
Matched = 1,
Mismatch = 2,
}

View File

@@ -0,0 +1,90 @@
using System.Runtime.InteropServices;
// Shared "walk a native array of owned-struct entries, convert to managed records, free the
// native list" pattern — identical shape for vc_device_list/vc_channel_list/vc_user_list/
// vc_stream_summary_list (all core-allocated, caller-freed; see voicecat.h's doc comments on
// each). The matching vc_free_*_list call happens INSIDE each ToManaged here, immediately
// after the conversion, so callers never need to remember to free anything themselves.
namespace VoiceCat.Interop;
internal static class Marshaling
{
public static List<DeviceInfo> ToManaged(ref VcDeviceListNative native)
{
var result = new List<DeviceInfo>((int)native.Count);
int size = Marshal.SizeOf<VcDeviceNative>();
for (nuint i = 0; i < native.Count; i++)
{
var raw = Marshal.PtrToStructure<VcDeviceNative>(native.Items + (int)i * size);
result.Add(new DeviceInfo(
Marshal.PtrToStringUTF8(raw.Id) ?? string.Empty,
Marshal.PtrToStringUTF8(raw.Name) ?? string.Empty,
raw.IsDefault != 0));
}
NativeMethods.vc_free_device_list(ref native);
return result;
}
public static List<ChannelInfo> ToManaged(ref VcChannelListNative native)
{
var result = new List<ChannelInfo>((int)native.Count);
int size = Marshal.SizeOf<VcChannelNative>();
for (nuint i = 0; i < native.Count; i++)
{
var raw = Marshal.PtrToStructure<VcChannelNative>(native.Items + (int)i * size);
result.Add(new ChannelInfo(
raw.Id,
raw.ParentId,
Marshal.PtrToStringUTF8(raw.Name) ?? string.Empty,
raw.PasswordProtected != 0,
raw.MaxUsers));
}
NativeMethods.vc_free_channel_list(ref native);
return result;
}
public static List<UserInfo> ToManaged(ref VcUserListNative native)
{
var result = new List<UserInfo>((int)native.Count);
int size = Marshal.SizeOf<VcUserNative>();
for (nuint i = 0; i < native.Count; i++)
{
var raw = Marshal.PtrToStructure<VcUserNative>(native.Items + (int)i * size);
result.Add(new UserInfo(
raw.Id,
Marshal.PtrToStringUTF8(raw.Nickname) ?? string.Empty,
raw.IsGuest != 0,
raw.ChannelId));
}
NativeMethods.vc_free_user_list(ref native);
return result;
}
public static List<StreamSummary> ToManaged(ref VcStreamSummaryListNative native)
{
var result = new List<StreamSummary>((int)native.Count);
int size = Marshal.SizeOf<VcStreamSummaryNative>();
for (nuint i = 0; i < native.Count; i++)
{
var raw = Marshal.PtrToStructure<VcStreamSummaryNative>(native.Items + (int)i * size);
result.Add(new StreamSummary(
raw.StreamId,
raw.Kind,
Marshal.PtrToStringUTF8(raw.Label) ?? string.Empty));
}
NativeMethods.vc_free_stream_summary_list(ref native);
return result;
}
public static AudioConfigInfo ToManaged(in VcAudioConfigNative native) => new(
native.Codec,
native.Mode != 0,
native.SampleRate,
native.BitrateBps,
native.FrameMs,
native.Application,
native.Fec != 0,
native.ExpectedPacketLoss,
native.Dtx != 0,
native.Complexity);
}

View File

@@ -0,0 +1,39 @@
// Plain managed record types — what survives past the native struct/free-list lifetime
// (Marshaling.cs converts the native *Native structs into these and immediately frees the
// native list). Nothing here holds an IntPtr.
namespace VoiceCat.Interop;
public sealed record ChannelInfo(
uint Id,
uint ParentId,
string Name,
bool PasswordProtected,
uint MaxUsers);
public sealed record UserInfo(
uint Id,
string Nickname,
bool IsGuest,
uint ChannelId);
public sealed record StreamSummary(
uint StreamId,
VcStreamKind Kind,
string Label);
public sealed record DeviceInfo(
string Id,
string Name,
bool IsDefault);
public sealed record AudioConfigInfo(
uint Codec,
bool Stereo,
uint SampleRate,
uint BitrateBps,
uint FrameMs,
uint Application,
bool Fec,
uint ExpectedPacketLoss,
bool Dtx,
uint Complexity);

View File

@@ -0,0 +1,33 @@
using System.Runtime.InteropServices;
// [UnmanagedCallersOnly] static methods for vc_callbacks.on_event/on_level — true native
// function pointers, not GC-tracked delegates (avoids the classic P/Invoke pitfall where a
// delegate is collected by the GC sometime after the call that registered it returns; see
// docs/tech-stack.md §2). vc_callbacks.user is a GCHandle-wrapped VoiceCatClient (allocated in
// VoiceCatClient's constructor, freed in Dispose) — these methods must be static, so they
// resolve back to the right client instance via that handle rather than closing over state.
namespace VoiceCat.Interop;
internal static unsafe class NativeCallbacks
{
[UnmanagedCallersOnly]
internal static void OnEvent(IntPtr userContext, VcEventNative* ev)
{
if (userContext == IntPtr.Zero) return;
if (GCHandle.FromIntPtr(userContext).Target is not VoiceCatClient client) return;
// CRITICAL (voicecat.h's vc_event doc comment): ev->Text is owned by the core and
// valid ONLY for the duration of this callback. Convert to a managed string NOW,
// before returning — never store/queue the raw VcEventNative across the callback
// boundary, or Text will be a dangling pointer by the time it's read.
client.EnqueueEvent(VoiceCatEvent.FromNative(*ev));
}
[UnmanagedCallersOnly]
internal static void OnLevel(IntPtr userContext, uint streamId, float rms)
{
if (userContext == IntPtr.Zero) return;
if (GCHandle.FromIntPtr(userContext).Target is not VoiceCatClient client) return;
client.EnqueueLevel(streamId, rms);
}
}

View File

@@ -0,0 +1,134 @@
using System.Runtime.InteropServices;
// Raw P/Invoke surface over core/include/voicecat.h, via LibraryImport (source-generated —
// no runtime reflection marshaling stub; see docs/tech-stack.md §2). One entry per voicecat.h
// function. `vc_client*` is represented as a raw `nint` here — VoiceCatClientHandle (a
// SafeHandle) owns the create/destroy lifetime one level up; these declarations never see a
// SafeHandle directly, per .NET's own SafeHandle convention.
//
// "voicecat" resolves to voicecat.dll via the OS's standard DLL search order (same directory
// as the .exe first) — see clients/windows/README.md for how it gets there at build time.
namespace VoiceCat.Interop;
internal static partial class NativeMethods
{
private const string LibName = "voicecat";
// ── Lifecycle ────────────────────────────────────────────────────────────────────────
// NOTE: these two return `const char*` pointing at STATIC string literals the core never
// expects the caller to free. Declaring them as `string` with StringMarshalling.Utf8
// would be wrong: the built-in Utf8StringMarshaller's return-value convention assumes the
// native callee allocated the string FOR this call and that the marshaller should free it
// afterward — calling that on a static literal corrupts the heap (confirmed: it crashes
// with STATUS_HEAP_CORRUPTION / 0xC0000374). Return the raw pointer instead and convert
// with Marshal.PtrToStringUTF8 ourselves, without ever freeing it — see VoiceCatClient.cs.
[LibraryImport(LibName)]
internal static partial nint vc_version_string();
[LibraryImport(LibName)]
internal static partial nint vc_result_string(VcResult code);
[LibraryImport(LibName)]
internal static partial nint vc_client_create(in VcConfigNative cfg, VcCallbacksNative cb);
[LibraryImport(LibName)]
internal static partial void vc_client_destroy(nint c);
// ── Connection & auth (async; results via on_event) ────────────────────────────────────
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
internal static partial VcResult vc_connect(nint c, string host, ushort port);
[LibraryImport(LibName)]
internal static partial VcResult vc_disconnect(nint c);
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
internal static partial VcResult vc_authenticate_guest(nint c, string nickname);
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
internal static partial VcResult vc_authenticate_user(nint c, string username, string password);
// ── Channels ─────────────────────────────────────────────────────────────────────────
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
internal static partial VcResult vc_join_channel(nint c, uint channelId, string? password);
[LibraryImport(LibName)]
internal static partial VcResult vc_leave_channel(nint c);
// ── Local media streams ─────────────────────────────────────────────────────────────────
[LibraryImport(LibName)]
internal static partial VcResult vc_stream_start(nint c, in VcStreamDescNative desc,
out uint outStreamId);
[LibraryImport(LibName)]
internal static partial VcResult vc_stream_stop(nint c, uint streamId);
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
internal static partial VcResult vc_set_input_device(nint c, uint streamId, string? deviceId);
[LibraryImport(LibName)]
internal static partial VcResult vc_set_input_mode(nint c, VcInputMode mode);
[LibraryImport(LibName)]
internal static partial VcResult vc_set_vad_threshold(nint c, float threshold);
[LibraryImport(LibName)]
internal static partial VcResult vc_set_push_to_talk(nint c, int active);
[LibraryImport(LibName)]
internal static partial VcResult vc_set_self_mute(nint c, int micMuted, int deafened);
[LibraryImport(LibName)]
internal static partial VcResult vc_set_remote_stream(nint c, uint userId, uint streamId,
float gain, int muted, int noiseReduction);
[LibraryImport(LibName)]
internal static partial VcResult vc_get_stream_audio_config(nint c, uint userId,
uint streamId, out VcAudioConfigNative outCfg);
// TEST-ONLY in the core (see voicecat.h) — declared for ABI parity; the real app never
// calls this (no microphone-bypass path in production UI).
[LibraryImport(LibName)]
internal static unsafe partial VcResult vc_test_inject_capture(nint c, uint streamId,
short* pcm, nuint samples);
// ── Text ─────────────────────────────────────────────────────────────────────────────
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
internal static partial VcResult vc_send_text(nint c, VcTextScope scope, uint targetId,
string utf8);
// ── Device enumeration ───────────────────────────────────────────────────────────────
[LibraryImport(LibName)]
internal static partial VcResult vc_list_devices(nint c, VcDeviceKind kind,
out VcDeviceListNative outList);
[LibraryImport(LibName)]
internal static partial void vc_free_device_list(ref VcDeviceListNative list);
// ── M4: channel / user / stream snapshot getters ────────────────────────────────────────
[LibraryImport(LibName)]
internal static partial VcResult vc_list_channels(nint c, out VcChannelListNative outList);
[LibraryImport(LibName)]
internal static partial void vc_free_channel_list(ref VcChannelListNative list);
[LibraryImport(LibName)]
internal static partial VcResult vc_list_users(nint c, out VcUserListNative outList);
[LibraryImport(LibName)]
internal static partial void vc_free_user_list(ref VcUserListNative list);
[LibraryImport(LibName)]
internal static partial VcResult vc_list_user_streams(nint c, uint userId,
out VcStreamSummaryListNative outList);
[LibraryImport(LibName)]
internal static partial void vc_free_stream_summary_list(ref VcStreamSummaryListNative list);
// ── M4: TOFU server-identity gate ───────────────────────────────────────────────────────
[LibraryImport(LibName)]
internal static partial VcResult vc_confirm_server_identity(nint c, int accept);
[LibraryImport(LibName)]
internal static partial VcResult vc_get_server_identity_display(nint c, nint outBuf,
nuint bufCap, out nuint outLen);
}

View File

@@ -0,0 +1,127 @@
using System.Runtime.InteropServices;
// Native (blittable) struct layouts mirroring voicecat.h field-for-field. These are the raw
// P/Invoke shapes — NativeMethods.cs uses them directly; Marshaling.cs converts them to the
// managed record types in Models.cs. const char* fields stay IntPtr here (LibraryImport's
// StringMarshalling only auto-converts top-level string parameters/returns, not struct
// fields) and must be hand-marshaled — see Marshaling.cs and VoiceCatClient.cs.
namespace VoiceCat.Interop;
[StructLayout(LayoutKind.Sequential)]
internal struct VcEventNative
{
public VcEventType Type;
public VcConnectionState ConnectionState;
public int Result; // vc_result
public uint UserId;
public uint ChannelId;
public uint StreamId;
public VcTextScope TextScope;
public uint U32a;
public IntPtr Text; // owned by core, valid ONLY for the callback's duration
public ulong TimestampUnixMs;
}
[StructLayout(LayoutKind.Sequential)]
internal struct VcConfigNative
{
public IntPtr ClientName;
public IntPtr ClientVersion;
public VcLogLevel LogLevel;
public IntPtr TofuStorePath;
}
[StructLayout(LayoutKind.Sequential)]
internal struct VcCallbacksNative
{
public IntPtr OnEvent; // delegate* unmanaged<IntPtr, VcEventNative*, void>
public IntPtr OnLevel; // delegate* unmanaged<IntPtr, uint, float, void>
public IntPtr User;
}
[StructLayout(LayoutKind.Sequential)]
internal struct VcStreamDescNative
{
public VcStreamKind Kind;
public IntPtr DeviceId; // unused by vc_stream_start today — device selection is a
// separate vc_set_input_device call; always IntPtr.Zero here.
public IntPtr Label;
}
[StructLayout(LayoutKind.Sequential)]
internal struct VcAudioConfigNative
{
public uint Codec;
public uint Mode;
public uint SampleRate;
public uint BitrateBps;
public uint FrameMs;
public uint Application;
public int Fec;
public uint ExpectedPacketLoss;
public int Dtx;
public uint Complexity;
}
[StructLayout(LayoutKind.Sequential)]
internal struct VcDeviceNative
{
public IntPtr Id;
public IntPtr Name;
public int IsDefault;
}
[StructLayout(LayoutKind.Sequential)]
internal struct VcDeviceListNative
{
public IntPtr Items;
public nuint Count;
}
[StructLayout(LayoutKind.Sequential)]
internal struct VcChannelNative
{
public uint Id;
public uint ParentId;
public IntPtr Name;
public int PasswordProtected;
public uint MaxUsers;
}
[StructLayout(LayoutKind.Sequential)]
internal struct VcChannelListNative
{
public IntPtr Items;
public nuint Count;
}
[StructLayout(LayoutKind.Sequential)]
internal struct VcUserNative
{
public uint Id;
public IntPtr Nickname;
public int IsGuest;
public uint ChannelId;
}
[StructLayout(LayoutKind.Sequential)]
internal struct VcUserListNative
{
public IntPtr Items;
public nuint Count;
}
[StructLayout(LayoutKind.Sequential)]
internal struct VcStreamSummaryNative
{
public uint StreamId;
public VcStreamKind Kind;
public IntPtr Label;
}
[StructLayout(LayoutKind.Sequential)]
internal struct VcStreamSummaryListNative
{
public IntPtr Items;
public nuint Count;
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- Native function pointers (NativeCallbacks.cs) and struct-array pointer walking
(Marshaling.cs) need unsafe blocks. -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<RootNamespace>VoiceCat.Interop</RootNamespace>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,253 @@
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<VoiceCatEvent> _events =
Channel.CreateUnbounded<VoiceCatEvent>(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<uint, float> _latestLevels = new();
/// <summary>Raised from PumpEvents() (i.e. on whatever thread calls it — see that method's
/// doc comment) for every event, in order, never coalesced.</summary>
public event Action<VoiceCatEvent>? EventReceived;
/// <summary>Raised from PumpEvents() with the latest RMS level per stream_id since the
/// last pump.</summary>
public event Action<uint, float>? 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<nint, VcEventNative*, void>)&NativeCallbacks.OnEvent,
OnLevel = (nint)(delegate* unmanaged<nint, uint, float, void>)&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.");
}
}
/// <summary>
/// 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.
/// </summary>
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);
/// <summary>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.</summary>
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 ─────────────────────────────────────────────────────────────────────────
/// <summary>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.</summary>
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<ChannelInfo> ListChannels()
{
NativeMethods.vc_list_channels(_handle.DangerousGetHandle(), out var native);
return Marshaling.ToManaged(ref native);
}
public List<UserInfo> ListUsers()
{
NativeMethods.vc_list_users(_handle.DangerousGetHandle(), out var native);
return Marshaling.ToManaged(ref native);
}
public List<StreamSummary> 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<StreamSummary>();
}
// ── 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<DeviceInfo> 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; }
}
}

View File

@@ -0,0 +1,18 @@
using Microsoft.Win32.SafeHandles;
// Standard SafeHandle pattern for vc_client* — guarantees vc_client_destroy runs even on an
// unhandled exception or a finalizer pass, which a bare `nint` field would not.
namespace VoiceCat.Interop;
internal sealed class VoiceCatClientHandle : SafeHandleZeroOrMinusOneIsInvalid
{
public VoiceCatClientHandle() : base(ownsHandle: true) { }
public new void SetHandle(nint handle) => base.SetHandle(handle);
protected override bool ReleaseHandle()
{
NativeMethods.vc_client_destroy(handle);
return true;
}
}

View File

@@ -0,0 +1,32 @@
using System.Runtime.InteropServices;
namespace VoiceCat.Interop;
/// <summary>
/// Managed copy of a vc_event — safe to hold/queue past the native callback's return (unlike
/// VcEventNative, whose Text pointer is only valid during the callback).
/// </summary>
public sealed record VoiceCatEvent(
VcEventType Type,
VcConnectionState ConnectionState,
VcResult Result,
uint UserId,
uint ChannelId,
uint StreamId,
VcTextScope TextScope,
uint U32a,
string? Text,
ulong TimestampUnixMs)
{
internal static VoiceCatEvent FromNative(in VcEventNative ev) => new(
ev.Type,
ev.ConnectionState,
(VcResult)ev.Result,
ev.UserId,
ev.ChannelId,
ev.StreamId,
ev.TextScope,
ev.U32a,
ev.Text == IntPtr.Zero ? null : Marshal.PtrToStringUTF8(ev.Text),
ev.TimestampUnixMs);
}