Port managed client audio and Windows application
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s

This commit is contained in:
2026-09-16 16:48:06 +02:00
parent 5a226ba543
commit 82ad4c2811
56 changed files with 2304 additions and 250 deletions
@@ -0,0 +1,193 @@
using System.Runtime.InteropServices;
using System.Threading.Channels;
using System.Collections.Concurrent;
using VoiceCat.Audio;
using VoiceCat.Core;
using VoiceCat.Crypto;
using Voicecat.V1;
using CoreClient = VoiceCat.Core.VoiceCatClient;
namespace VoiceCat.Interop;
// Preserves the shipped WinForms call/event surface while its implementation moves to
// the async managed core. Events still reach controls only through PumpEvents on the UI thread.
public sealed partial class VoiceCatClient : IDisposable
{
private readonly CoreClient core;
private readonly IAudioDeviceBackend? backend;
private readonly System.Threading.Channels.Channel<VoiceCatEvent> events = System.Threading.Channels.Channel.CreateUnbounded<VoiceCatEvent>();
private readonly Dictionary<uint, IAudioCapture> captures = [];
private readonly Dictionary<uint, string?> devices = [];
private readonly Dictionary<uint, StreamSummary[]> remoteStreams = [];
private readonly Dictionary<uint, bool> talkState = [];
private sealed class LocalStream(StreamInfo info, bool external)
{
internal readonly uint Alias = info.StreamId;
internal StreamInfo Info = info;
internal readonly bool External = external;
internal int CaptureChannels = 1;
internal int Restarting;
}
private readonly ConcurrentDictionary<uint, LocalStream> local = new();
private IAudioPlayback? playback;
private Task connecting = Task.CompletedTask;
private TaskCompletionSource<bool>? identity;
private int disposed;
private List<AccountInfo> accounts = [];
private bool audioFailureReported;
public event Action<VoiceCatEvent>? EventReceived;
public event Action<uint, float>? LevelChanged;
public CoreClient ManagedClient => core;
public VoiceCatClient(string clientName, string clientVersion, VcLogLevel logLevel = VcLogLevel.Info, string? tofuStorePath = null, IAudioDeviceBackend? audioBackend = null)
{
backend = audioBackend;
core = new(clientName, clientVersion, tofuStorePath);
core.ConnectionStateChanged += state =>
{
VcConnectionState mapped = state switch { ClientConnectionState.Connecting => VcConnectionState.Connecting, ClientConnectionState.VerifyingIdentity => VcConnectionState.VerifyingIdentity,
ClientConnectionState.Authenticating => VcConnectionState.Authenticating, ClientConnectionState.Connected => VcConnectionState.Connected, _ => VcConnectionState.Disconnected };
Queue(new(VcEventType.ConnectionState, ConnectionState: mapped));
if (mapped == VcConnectionState.Disconnected) Queue(new(VcEventType.Disconnected));
};
core.Audio.MixedPcm += pcm => Volatile.Read(ref playback)?.Write(pcm);
core.Audio.StreamPcm += ForwardPcm;
}
private void Queue(VoiceCatEvent message) => events.Writer.TryWrite(message);
public VcResult Connect(string host, ushort port)
{
if (!connecting.IsCompleted || core.State != ClientConnectionState.Disconnected) return VcResult.Already;
connecting = ConnectAsync(host, port);
return VcResult.Ok;
}
private async Task ConnectAsync(string host, ushort port)
{
try
{
await core.ConnectAsync(host, port, async (challenge, token) =>
{
identity = new(TaskCreationOptions.RunContinuationsAsynchronously);
Queue(new(VcEventType.ServerIdentity, U32a: (uint)challenge.Status, Text: challenge.CertificateFingerprint));
return await identity.Task.WaitAsync(token).ConfigureAwait(false);
}).ConfigureAwait(false);
}
catch (Exception exception) { Queue(new(VcEventType.Error, Result: VcResult.Io, Text: exception.Message)); }
}
public VcResult ConfirmServerIdentity(bool accept) { identity?.TrySetResult(accept); return VcResult.Ok; }
public VcResult AuthenticateGuest(string nickname) { _ = AuthenticateAsync(() => core.AuthenticateGuestAsync(nickname)); return VcResult.Ok; }
public VcResult AuthenticateUser(string username, string password) { _ = AuthenticateAsync(() => core.AuthenticateUserAsync(username, password)); return VcResult.Ok; }
private async Task AuthenticateAsync(Func<Task<AuthResult>> authenticate)
{
try { await connecting.ConfigureAwait(false); await authenticate().ConfigureAwait(false); }
catch (Exception exception) { Queue(new(VcEventType.AuthResult, Result: VcResult.AuthFailed, Text: exception.Message)); }
}
public VcResult Disconnect()
{
StopDevices(); core.DisconnectAsync().GetAwaiter().GetResult(); return VcResult.Ok;
}
public string GetServerIdentityDisplay() => core.ServerHello is { } hello ? Convert.ToHexString(hello.ServerIdentityFingerprint.Span) : "";
public void PumpEvents()
{
if (!audioFailureReported && core.Audio.Failure is { } failure) { audioFailureReported = true; Queue(new(VcEventType.Error, Result: VcResult.Audio, Text: failure.Message)); }
while (core.TryReadEvent(out Envelope? message)) Translate(message!);
while (events.Reader.TryRead(out VoiceCatEvent? message)) EventReceived?.Invoke(message);
foreach (LocalStream stream in local.Values)
{
var info = Volatile.Read(ref stream.Info);
var level = core.Audio.GetLocalLevel(info.StreamId); LevelChanged?.Invoke(stream.Alias, level.Level);
if (talkState.GetValueOrDefault(stream.Alias) != level.Talking)
{
talkState[stream.Alias] = level.Talking;
if (core.State == ClientConnectionState.Connected) core.Send(new() { StreamState = new() { StreamId = info.StreamId, Talking = level.Talking, Muted = core.Audio.MicMuted } });
EventReceived?.Invoke(new(VcEventType.TalkState, UserId: core.Authentication?.Self.Id ?? 0, StreamId: stream.Alias, U32a: level.Talking ? 1U : 0));
}
}
}
private void Translate(Envelope message)
{
if (message.AuthResult is not null) Queue(new(VcEventType.AuthResult, Result: message.AuthResult.Ok ? VcResult.Ok : VcResult.AuthFailed, UserId: message.AuthResult.Self?.Id ?? 0, Text: message.AuthResult.Error));
if (message.ServerState is not null) { remoteStreams.Clear(); foreach (User user in message.ServerState.Users) UpdateStreams(user); Queue(new(VcEventType.ChannelList)); }
if (message.ChannelEvent is not null) Queue(new(VcEventType.ChannelList));
if (message.UserEvent is not null)
{
var change = message.UserEvent;
if (change.User is { VoiceSubscribed: true } self && self.Id == core.Authentication?.Self.Id) ReconcileStreams();
if (change.User is not null) UpdateStreams(change.User);
if (change.Kind == UserEvent.Types.Kind.Left) remoteStreams.Remove(change.LeftId);
Queue(new(change.Kind switch { UserEvent.Types.Kind.Joined => VcEventType.UserJoined, UserEvent.Types.Kind.Left => VcEventType.UserLeft, _ => VcEventType.UserUpdated }, UserId: change.User?.Id ?? change.LeftId,
ChannelId: change.User?.ChannelId ?? 0, Text: change.Kind == UserEvent.Types.Kind.Joined ? change.User?.Nickname : change.Reason));
}
if (message.JoinChannelResult is not null) Queue(new(VcEventType.JoinResult, Result: message.JoinChannelResult.Ok ? VcResult.Ok : VcResult.InvalidArg, ChannelId: message.JoinChannelResult.ChannelId, Text: message.JoinChannelResult.Error));
if (message.VoiceSubscriptionResult is not null) Queue(new(VcEventType.VoiceState, U32a: message.VoiceSubscriptionResult.Subscribed ? 1U : 0));
if (message.TextMessage is not null) Queue(new(VcEventType.TextMessage, UserId: message.TextMessage.SenderId, ChannelId: message.TextMessage.TargetId, TextScope: (VcTextScope)message.TextMessage.Scope, Text: message.TextMessage.Body, TimestampUnixMs: message.TextMessage.SentAtUnixMs));
if (message.StreamState is not null) Queue(new(VcEventType.TalkState, UserId: message.StreamState.UserId, StreamId: message.StreamState.UserId == core.Authentication?.Self.Id ? local.Values.FirstOrDefault(s => s.Info.StreamId == message.StreamState.StreamId)?.Alias ?? message.StreamState.StreamId : message.StreamState.StreamId, U32a: message.StreamState.Talking ? 1U : 0));
if (message.GenericResult is not null) Queue(new(VcEventType.GenericResult, Result: message.GenericResult.Ok ? VcResult.Ok : message.GenericResult.Code == 6 ? VcResult.PermissionDenied : VcResult.InvalidArg, U32a: message.GenericResult.Code, Text: message.GenericResult.Message));
if (message.ListAccountsResult is not null)
{
accounts = message.ListAccountsResult.Accounts.Select(a => new AccountInfo(a.Username, a.IsAdmin, a.CreatedAtUnixMs, a.LastLoginUnixMs)).ToList();
Queue(new(VcEventType.AccountList));
}
if (message.Disconnect is not null) Queue(new(VcEventType.Error, Result: VcResult.Io, Text: message.Disconnect.Reason));
}
private void UpdateStreams(User user)
{
StreamSummary[] previous = remoteStreams.GetValueOrDefault(user.Id, []);
StreamSummary[] next = user.Id == core.Authentication?.Self.Id ? ListUserStreams(user.Id).ToArray() : user.Streams.Select(s => new StreamSummary(s.StreamId, (VcStreamKind)s.Kind, s.Label)).ToArray();
foreach (var stream in previous) if (!next.Any(s => s.StreamId == stream.StreamId)) Queue(new(VcEventType.StreamStopped, UserId: user.Id, StreamId: stream.StreamId));
foreach (var stream in next) if (!previous.Any(s => s.StreamId == stream.StreamId)) Queue(new(VcEventType.StreamStarted, UserId: user.Id, StreamId: stream.StreamId, U32a: (uint)stream.Kind, Text: stream.Label));
remoteStreams[user.Id] = next;
}
private VcResult Request(Envelope request)
{
if (core.State != ClientConnectionState.Connected) return VcResult.NotConnected;
try { _ = RequestAsync(request); return VcResult.Ok; }
catch { return VcResult.NotConnected; }
}
private async Task RequestAsync(Envelope request)
{
try { await core.RequestAsync(request).ConfigureAwait(false); }
catch (Exception exception) { Queue(new(VcEventType.Error, Result: VcResult.Io, Text: exception.Message)); }
}
public VcResult JoinChannel(uint id, string? password = null) => Request(new() { JoinChannel = new() { ChannelId = id, Password = password ?? "" } });
public VcResult LeaveChannel() => Request(new() { LeaveChannel = new() });
public VcResult JoinVoice()
{
try
{
if (backend is not null && playback is null) playback = backend.OpenPlayback();
var result = core.SubscribeVoiceAsync().GetAwaiter().GetResult();
if (!result.Ok) { Interlocked.Exchange(ref playback, null)?.Dispose(); }
return result.Ok ? VcResult.Ok : VcResult.Audio;
}
catch (Exception exception) { Queue(new(VcEventType.Error, Result: VcResult.Audio, Text: exception.Message)); return VcResult.Audio; }
}
public VcResult LeaveVoice() { StopDevices(); local.Clear(); return Request(new() { UnsubscribeVoice = new() }); }
public List<ChannelInfo> ListChannels() => core.Channels.Select(c => new ChannelInfo(c.Id, c.ParentId, c.Name, c.Topic, c.PasswordProtected, c.MaxUsers, unchecked((uint)c.Order), Audio(c.Audio))).ToList();
public List<UserInfo> ListUsers() => core.Users.Select(u => new UserInfo(u.Id, u.Nickname, u.IsGuest, u.ChannelId, u.SelfMicMuted, u.SelfDeafened, u.ServerMuted, u.ServerDeafened, u.VoiceSubscribed)).ToList();
public List<StreamSummary> ListUserStreams(uint id) => id == core.Authentication?.Self.Id
? local.Values.Select(s => new StreamSummary(s.Alias, (VcStreamKind)s.Info.Kind, s.Info.Label)).ToList()
: core.Users.FirstOrDefault(u => u.Id == id)?.Streams.Select(s => new StreamSummary(s.StreamId, (VcStreamKind)s.Kind, s.Label)).ToList() ?? [];
public PermissionsInfo GetPermissions() { Permissions p = core.Authentication?.Permissions ?? new(); return new(p.CanCreateTempChannel, p.CanKick, p.CanBan, p.CanMoveUsers, p.CanAdminAccounts, p.IsAdmin); }
public List<AccountInfo> ListAccounts() => accounts.ToList();
public List<DeviceInfo> ListDevices(VcDeviceKind kind) => backend?.Enumerate(kind == VcDeviceKind.Input).Select(d => new DeviceInfo(d.Id, d.Name, d.IsDefault)).ToList() ?? [];
public static string VersionString => "VoiceCat managed core 0.1.0 (protocol v2)";
public static string ResultString(VcResult result) => result.ToString();
private static AudioConfigInfo Audio(AudioConfig a) => new(a.Codec, a.Mode == ChannelMode.ModeStereo, a.SampleRate, a.BitrateBps, a.FrameMs, (uint)a.Application, a.Fec, a.ExpectedPacketLoss, a.Dtx, a.Complexity, a.Dred);
private void StopDevices()
{
foreach (var capture in captures.Values) capture.Dispose(); captures.Clear(); local.Clear();
IAudioPlayback? previous = Interlocked.Exchange(ref playback, null); previous?.Dispose();
}
public void Dispose()
{
if (Interlocked.Exchange(ref disposed, 1) != 0) return;
identity?.TrySetResult(false); StopDevices(); core.DisposeAsync().AsTask().GetAwaiter().GetResult();
}
}