Files
voice-cat/clients/windows/VoiceCat.Interop/VoiceCatClient.cs
Talon 615d2a8e5f feat: external PCM feed/tap API (vc_stream_feed_pcm + vc_set_pcm_sink)
Promotes vc_test_inject_capture (mono-only, TEST-ONLY) to a public,
stereo-capable production API and adds a symmetric PCM tap on the
receive side. Enables ReplayKit (iOS), ScreenCaptureKit (macOS), bots,
soundboards, and custom clients — all without a hardware audio device.

Core C++:
- voicecat.h: new vc_stream_feed_pcm, vc_pcm_sink_cb typedef,
  vc_set_pcm_sink; vc_test_inject_capture kept as deprecated alias
- audio_engine: stereo-aware inject_capture (channels param + ring
  reset on channel-count change); atomic pcm_sink_ fired per decoded
  frame in on_playback; RemoteStream carries user_id/stream_id for
  RT-safe sink metadata; init_recv_stream takes user_id+stream_id
- client.cpp: stream_feed_pcm / set_pcm_sink implementations;
  sync_remote_streams passes user_id/stream_id to init_recv_stream
- voicecat.cpp: trampolines + channels=1/2 validation

Tests: test_external_pcm (headless, 3 sub-tests: mono round-trip,
stereo feed L≠R, sink metadata+disable). ctest 23/23.

Swift: feedPcm / setPcmSink in VoiceCatClient.swift + 4 XCTest
smoke tests (ExternalPcmTests.swift).

C#: StreamFeedPcm / SetPcmSink in VoiceCatClient.cs + NativeMethods.cs
(vc_stream_feed_pcm unsafe P/Invoke, VcPcmSinkCallback delegate,
vc_set_pcm_sink via nint) + 4 xUnit smoke tests (ExternalPcmTests.cs).

Docs: architecture.md §4 new subsection, voice.md §9 updated
(macOS/iOS now reference vc_stream_feed_pcm), protocol.md §8 explicit
no-protocol-change note, roadmap.md M5 entry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 17:52:09 +02:00

396 lines
18 KiB
C#

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 SetOutputVolume(float gain) =>
NativeMethods.vc_set_output_volume(_handle.DangerousGetHandle(), gain < 0f ? 0f : gain);
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, RemoteStreamState? State) GetRemoteStream(uint userId, uint streamId)
{
var r = NativeMethods.vc_get_remote_stream(_handle.DangerousGetHandle(), userId,
streamId, out var native);
return (r, r == VcResult.Ok ? Marshaling.ToManaged(in native) : null);
}
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);
}
// ── External PCM feed / tap ─────────────────────────────────────────────────────────
public unsafe VcResult StreamFeedPcm(uint streamId, ReadOnlySpan<short> pcm,
int samplesPerChannel, uint channels)
{
fixed (short* p = pcm)
return NativeMethods.vc_stream_feed_pcm(_handle.DangerousGetHandle(),
streamId, p, (nuint)samplesPerChannel, channels);
}
// Pass Marshal.GetFunctionPointerForDelegate(cb) for a managed delegate, or
// IntPtr.Zero to disable. Keep the delegate alive for the lifetime of the registration.
public VcResult SetPcmSink(nint cb, IntPtr user) =>
NativeMethods.vc_set_pcm_sink(_handle.DangerousGetHandle(), cb, user);
// ── M5: Moderation & admin ───────────────────────────────────────────────────────────
public VcResult KickUser(uint userId, string? reason = null) =>
NativeMethods.vc_kick_user(_handle.DangerousGetHandle(), userId, reason);
public VcResult BanUser(uint userId, string? reason = null, ulong expiresUnixMs = 0) =>
NativeMethods.vc_ban_user(_handle.DangerousGetHandle(), userId, reason, expiresUnixMs);
public VcResult SetPermission(uint userId, PermissionsInfo perms)
{
var native = new VcPermissionsNative
{
CanCreateTempChannel = perms.CanCreateTempChannel ? 1 : 0,
CanKick = perms.CanKick ? 1 : 0,
CanBan = perms.CanBan ? 1 : 0,
CanMoveUsers = perms.CanMoveUsers ? 1 : 0,
CanAdminAccounts = perms.CanAdminAccounts ? 1 : 0,
IsAdmin = perms.IsAdmin ? 1 : 0,
};
return NativeMethods.vc_set_permission(_handle.DangerousGetHandle(), userId, in native);
}
public VcResult SetServerMute(uint userId, bool muted, bool deafened) =>
NativeMethods.vc_set_server_mute(_handle.DangerousGetHandle(), userId,
muted ? 1 : 0, deafened ? 1 : 0);
public VcResult MoveUser(uint userId, uint channelId) =>
NativeMethods.vc_move_user(_handle.DangerousGetHandle(), userId, channelId);
public VcResult CreateChannel(ChannelEditInfo info)
{
var native = ToNativeChannelInfo(info);
try
{
return NativeMethods.vc_create_channel(_handle.DangerousGetHandle(), in native);
}
finally
{
FreeChannelInfoStrings(native);
}
}
public VcResult EditChannel(ChannelEditInfo info)
{
var native = ToNativeChannelInfo(info);
try
{
return NativeMethods.vc_edit_channel(_handle.DangerousGetHandle(), in native);
}
finally
{
FreeChannelInfoStrings(native);
}
}
public VcResult DeleteChannel(uint channelId) =>
NativeMethods.vc_delete_channel(_handle.DangerousGetHandle(), channelId);
public VcResult CreateAccount(string username, string password) =>
NativeMethods.vc_create_account(_handle.DangerousGetHandle(), username, password);
public VcResult ResetPassword(string username, string newPassword) =>
NativeMethods.vc_reset_password(_handle.DangerousGetHandle(), username, newPassword);
public VcResult DeleteAccount(string username) =>
NativeMethods.vc_delete_account(_handle.DangerousGetHandle(), username);
public VcResult RequestAccountList() =>
NativeMethods.vc_list_accounts(_handle.DangerousGetHandle());
public List<AccountInfo> ListAccounts()
{
NativeMethods.vc_get_account_list(_handle.DangerousGetHandle(), out var native);
return Marshaling.ToManaged(ref native);
}
public PermissionsInfo GetPermissions()
{
NativeMethods.vc_get_permissions(_handle.DangerousGetHandle(), out var native);
return Marshaling.ToManaged(in native);
}
private static VcChannelInfoNative ToNativeChannelInfo(ChannelEditInfo info)
{
return new VcChannelInfoNative
{
Id = info.Id,
ParentId = info.ParentId,
Name = Marshal.StringToCoTaskMemUTF8(info.Name),
Topic = Marshal.StringToCoTaskMemUTF8(info.Topic),
PasswordProtected = info.PasswordProtected ? 1 : 0,
Password = string.IsNullOrEmpty(info.Password)
? 0
: Marshal.StringToCoTaskMemUTF8(info.Password),
MaxUsers = info.MaxUsers,
SortOrder = info.SortOrder,
Audio = new VcAudioConfigNative
{
Codec = info.Audio.Codec,
Mode = info.Audio.Stereo ? 1u : 0u,
SampleRate = info.Audio.SampleRate,
BitrateBps = info.Audio.BitrateBps,
FrameMs = info.Audio.FrameMs,
Application = info.Audio.Application,
Fec = info.Audio.Fec ? 1 : 0,
ExpectedPacketLoss = info.Audio.ExpectedPacketLoss,
Dtx = info.Audio.Dtx ? 1 : 0,
Complexity = info.Audio.Complexity,
}
};
}
private static void FreeChannelInfoStrings(VcChannelInfoNative native)
{
if (native.Name != 0) Marshal.FreeCoTaskMem(native.Name);
if (native.Topic != 0) Marshal.FreeCoTaskMem(native.Topic);
if (native.Password != 0) Marshal.FreeCoTaskMem(native.Password);
}
// ── 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; }
}
}