Files
voice-cat/clients/windows/VoiceCat.Interop/VoiceCatClient.cs
Talon 6fe7bf0158
Some checks failed
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
feat: fix voice join/leave, channel edit defaults, channel-update stream restart
Three bugs fixed across the full stack (proto/server/core/ABI/Win/macOS/iOS):

1. Join/Leave Voice now truly subscribes/unsubscribes from the voice plane.
   Previously the button only toggled the local mic — receiving was always on
   (gated by channel membership alone). Added a protocol-level voice subscription
   concept: new SubscribeVoiceRequest/UnsubscribeVoiceRequest/VoiceSubscriptionResult
   proto messages, User.voice_subscribed field, vc_join_voice/vc_leave_voice C ABI
   functions, VC_EVENT_VOICE_STATE event, server-side voice_subscribed flag checked
   by the SFU relay recipient filter, and core-client gating of remote-stream
   decoder setup. All three clients rewired to subscribe+mic on Join / unsubscribe
   on Leave. Text chat works regardless of voice subscription.

2. Channel edit dialog now shows the channel's actual current settings. The read
   struct vc_channel was missing sort_order and audio fields — only the write
   struct vc_channel_info had them. Extended vc_channel with both (additive, no
   ABI break), updated the session model and list_channels marshaling to populate
   them, and updated all three clients' edit callers to use actual channel info
   instead of hardcoded defaults.

3. Channel parameter updates now automatically restart everyone's streams.
   Previously editing a channel's audio config persisted and broadcast a
   ChannelEvent::UPDATED, but no layer restarted streams — encoders/decoders are
   frozen at announce time. handle_channel_event now detects audio-config changes
   on the user's current channel and stop->starts each active local stream. The
   server reads the updated config on re-announce; peers wire up fresh decoders
   at the new ssrc.

All 29 CTest tests pass; Windows DLL + C# client build clean. Apple clients not
yet compile-verified (Windows environment).
2026-06-24 14:29:39 +02:00

448 lines
21 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 VcResult JoinVoice() =>
NativeMethods.vc_join_voice(_handle.DangerousGetHandle());
public VcResult LeaveVoice() =>
NativeMethods.vc_leave_voice(_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);
}
}
/// <summary>
/// Like <see cref="StartStream"/> but sets <c>external_feed = 1</c> so the core skips its
/// own WASAPI loopback. The caller is responsible for feeding PCM via
/// <see cref="StreamFeedPcm"/>. Used by the Windows per-app capture path.
/// </summary>
public (VcResult Result, uint StreamId) StartStreamExternalFeed(VcStreamKind kind, string label)
{
nint labelPtr = Marshal.StringToCoTaskMemUTF8(label);
try
{
var desc = new VcStreamDescNative { Kind = kind, DeviceId = 0, Label = labelPtr, ExternalFeed = 1 };
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);
/// <summary>Sets the mic capture channel count (1 = mono, 2 = stereo) for the given stream.
/// Applied when the capture device next (re)starts — call before Join Voice, or pair with an
/// audio restart to take effect live. Real stereo only reaches the wire on a stereo channel;
/// the core folds a stereo mic to mono on a mono channel.</summary>
public VcResult SetCaptureChannels(uint streamId, uint channels) =>
NativeMethods.vc_set_capture_channels(_handle.DangerousGetHandle(), streamId, channels);
/// <summary>Uninitializes and re-initializes the capture and playback devices on a running
/// engine, applying pending changes (e.g. capture channel count) that only take effect on a
/// device restart. No-op if audio isn't running.</summary>
public VcResult AudioRestart() =>
NativeMethods.vc_audio_restart(_handle.DangerousGetHandle());
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);
/// <summary>Send-side microphone input gain, applied to captured MIC PCM before VAD/encode.
/// 0.0 = silent, 1.0 = unity (default), &gt;1.0 amplifies (clamped to int16). LOCAL only.</summary>
public VcResult SetInputGain(float gain) =>
NativeMethods.vc_set_input_gain(_handle.DangerousGetHandle(), gain < 0f ? 0f : gain);
/// <summary>Send-side microphone noise suppression (RNNoise). Denoises captured MIC PCM before
/// the input gain and VAD/PTT gate, so everyone hears the cleaned signal. MIC stream only,
/// mono only; always LOCAL — no protocol traffic. Independent of per-listener receive-side
/// NR in <see cref="SetRemoteStream"/>.</summary>
public VcResult SetInputNoiseReduction(bool enable) =>
NativeMethods.vc_set_input_noise_reduction(_handle.DangerousGetHandle(), enable ? 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, 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,
Dred = info.Audio.Dred ? 1 : 0,
}
};
}
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; }
}
}