Retire legacy implementations and flatten managed layout
Build and test / test (macos-latest) (push) Canceled after 0s
Build and test / test (ubuntu-24.04) (push) Canceled after 0s
Build and test / test (windows-latest) (push) Canceled after 0s
Build and test / apple-client (push) Canceled after 0s

This commit is contained in:
2026-09-21 00:11:32 +02:00
parent dd811a0bb8
commit 08e6c5930a
422 changed files with 252 additions and 38242 deletions
@@ -0,0 +1,32 @@
using Voicecat.V1;
namespace VoiceCat.Windows;
public sealed partial class VoiceCatClient
{
public VcResult KickUser(uint id, string? reason = null) => Request(new() { Kick = new() { UserId = id, Reason = reason ?? "" } });
public VcResult BanUser(uint id, string? reason = null, ulong expiresUnixMs = 0) => Request(new() { Ban = new() { UserId = id, Reason = reason ?? "", ExpiresUnixMs = expiresUnixMs } });
public VcResult MoveUser(uint id, uint channelId) => Request(new() { MoveUser = new() { UserId = id, ChannelId = channelId } });
public VcResult SetServerMute(uint id, bool muted, bool deafened) => Request(new() { ServerMute = new() { UserId = id, Muted = muted, Deafened = deafened } });
public VcResult SetPermission(uint id, PermissionsInfo permissions) => Request(new() { SetPermission = new() { UserId = id, Permissions = new()
{ IsAdmin = permissions.IsAdmin, CanCreateTempChannel = permissions.CanCreateTempChannel, CanAdminAccounts = permissions.CanAdminAccounts, CanBan = permissions.CanBan, CanKick = permissions.CanKick, CanMoveUsers = permissions.CanMoveUsers } } });
public VcResult CreateAccount(string username, string password) => Request(new() { CreateAccount = new() { Username = username, Password = password } });
public VcResult ResetPassword(string username, string password) => Request(new() { ResetPassword = new() { Username = username, NewPassword = password } });
public VcResult DeleteAccount(string username) => Request(new() { DeleteAccount = new() { Username = username } });
public VcResult RequestAccountList() => Request(new() { ListAccounts = new() });
public VcResult CreateChannel(ChannelEditInfo info) => Request(new() { CreateChannel = new() { Channel = Channel(info), Password = info.Password ?? "" } });
public VcResult EditChannel(ChannelEditInfo info) => Request(new() { EditChannel = new() { Channel = Channel(info), Password = info.Password ?? "" } });
public VcResult DeleteChannel(uint id) => Request(new() { DeleteChannel = new() { ChannelId = id } });
public VcResult SendText(VcTextScope scope, uint targetId, string body)
{
try { core.Send(new() { TextMessage = new() { Scope = (TextScope)scope, TargetId = targetId, Body = body, ClientMsgId = Guid.NewGuid().ToString("N") } }); return VcResult.Ok; }
catch { return VcResult.NotConnected; }
}
private static Voicecat.V1.Channel Channel(ChannelEditInfo info) => new()
{
Id = info.Id, ParentId = info.ParentId, Name = info.Name, Topic = info.Topic, MaxUsers = info.MaxUsers, Order = unchecked((int)info.SortOrder),
Audio = new() { Codec = info.Audio.Codec, Mode = info.Audio.Stereo ? ChannelMode.ModeStereo : ChannelMode.ModeMono, SampleRate = info.Audio.SampleRate,
BitrateBps = info.Audio.BitrateBps, FrameMs = info.Audio.FrameMs, Application = (OpusApplication)info.Audio.Application, Complexity = info.Audio.Complexity,
Fec = info.Audio.Fec, ExpectedPacketLoss = info.Audio.ExpectedPacketLoss, Dtx = info.Audio.Dtx, Dred = info.Audio.Dred }
};
}
@@ -0,0 +1,116 @@
using VoiceCat.Audio;
using Voicecat.V1;
namespace VoiceCat.Windows;
public sealed partial class VoiceCatClient
{
private sealed record PcmRegistration(nint Callback, nint User);
private PcmRegistration? pcm;
public (VcResult Result, uint StreamId) StartStream(VcStreamKind kind, string label) => Start(kind, label, false);
public (VcResult Result, uint StreamId) StartStreamExternalFeed(VcStreamKind kind, string label) => Start(kind, label, true);
private (VcResult, uint) Start(VcStreamKind kind, string label, bool external)
{
StreamInfo? stream = null;
try
{
stream = core.StartStreamAsync((StreamKind)kind, label).GetAwaiter().GetResult();
local[stream.StreamId] = new(stream, external);
if (!external && backend is not null) captures[stream.StreamId] = Capture(stream);
return (VcResult.Ok, stream.StreamId);
}
catch (Exception exception)
{
if (stream is not null) { local.TryRemove(stream.StreamId, out _); core.StopStream(stream.StreamId); }
Queue(new(VcEventType.Error, Result: VcResult.Audio, Text: exception.Message)); return (VcResult.Audio, 0);
}
}
private IAudioCapture Capture(StreamInfo stream) => backend!.OpenCapture(devices.GetValueOrDefault(stream.StreamId), stream.Kind == StreamKind.StreamScreenAudio,
(samples, channels) => StreamFeedPcm(stream.StreamId, samples, samples.Length / channels, (uint)channels));
public VcResult StopStream(uint id)
{
if (captures.Remove(id, out var capture)) capture.Dispose();
if (!local.TryRemove(id, out LocalStream? stream)) return VcResult.InvalidArg;
try { core.StopStream(stream.Info.StreamId); return VcResult.Ok; } catch { return VcResult.NotConnected; }
}
public VcResult SetInputDevice(uint id, string? device)
{
devices[id] = device;
if (!captures.Remove(id, out var previous)) return VcResult.Ok;
previous.Dispose();
try { var info = local[id].Info.Clone(); info.StreamId = id; captures[id] = Capture(info); return VcResult.Ok; }
catch { return VcResult.Audio; }
}
public VcResult SetCaptureChannels(uint id, uint channels)
{ try { LocalStream stream = local[id]; core.Audio.SetCaptureChannels(stream.Info.StreamId, checked((int)channels)); stream.CaptureChannels = (int)channels; return VcResult.Ok; } catch { return VcResult.InvalidArg; } }
public VcResult AudioRestart()
{
foreach (uint id in captures.Keys.ToArray()) { VcResult result = SetInputDevice(id, devices.GetValueOrDefault(id)); if (result != VcResult.Ok) return result; }
return VcResult.Ok;
}
public VcResult SetInputMode(VcInputMode mode) { if (!Enum.IsDefined(mode)) return VcResult.InvalidArg; core.Audio.InputMode = (AudioInputMode)mode; return VcResult.Ok; }
public VcResult SetVadThreshold(float threshold) { if (!float.IsFinite(threshold) || threshold is < 0 or > 1) return VcResult.InvalidArg; core.Audio.VadThreshold = threshold; return VcResult.Ok; }
public VcResult SetPushToTalk(bool active) { core.Audio.PushToTalk = active; return VcResult.Ok; }
public VcResult SetSelfMute(bool muted, bool deafened) { core.Audio.MicMuted = muted; core.Audio.Deafened = deafened; return VcResult.Ok; }
public VcResult SetOutputVolume(float gain) { if (!float.IsFinite(gain) || gain is < 0 or > 4) return VcResult.InvalidArg; core.Audio.OutputGain = gain; return VcResult.Ok; }
public VcResult SetInputGain(float gain) { if (!float.IsFinite(gain) || gain is < 0 or > 4) return VcResult.InvalidArg; core.Audio.InputGain = gain; return VcResult.Ok; }
public VcResult SetInputNoiseReduction(bool enabled) { core.Audio.InputNoiseReduction = enabled; return VcResult.Ok; }
public VcResult SetAudioBufferMilliseconds(int milliseconds)
{
try
{
core.Audio.DeviceBufferMilliseconds = milliseconds;
if (Volatile.Read(ref playback) is { } output) output.BufferMilliseconds = milliseconds;
return VcResult.Ok;
}
catch (ArgumentOutOfRangeException) { return VcResult.InvalidArg; }
}
public VcResult SetRemoteStream(uint userId, uint streamId, float gain, bool muted, bool nr)
{ try { core.Audio.SetRemotePlayback(userId, streamId, gain, muted, nr); return VcResult.Ok; } catch { return VcResult.InvalidArg; } }
public (VcResult Result, RemoteStreamState? State) GetRemoteStream(uint userId, uint streamId)
{
var state = core.Audio.GetRemotePlayback(userId, streamId);
return state is { } value ? (VcResult.Ok, new(value.Gain, value.Muted, value.NoiseReduction)) : (VcResult.InvalidArg, null);
}
public (VcResult Result, AudioConfigInfo? Config) GetStreamAudioConfig(uint userId, uint streamId)
{
var info = core.Users.FirstOrDefault(u => u.Id == userId)?.Streams.FirstOrDefault(s => s.StreamId == streamId);
if (core.Authentication?.Self.Id == userId && local.TryGetValue(streamId, out LocalStream? own)) info = own.Info;
return info is null ? (VcResult.InvalidArg, null) : (VcResult.Ok, Audio(info.Audio));
}
public VcResult StreamFeedPcm(uint id, ReadOnlySpan<short> samples, int samplesPerChannel, uint channels)
{
if (samplesPerChannel < 0 || channels is not (1 or 2) || samples.Length != (long)samplesPerChannel * channels) return VcResult.InvalidArg;
if (!local.TryGetValue(id, out LocalStream? stream)) return VcResult.InvalidArg;
core.Audio.FeedPcm(Volatile.Read(ref stream.Info).StreamId, samples, (int)channels); return VcResult.Ok; // A full real-time ring drops, never waits.
}
public VcResult SetPcmSink(nint callback, nint user) { Volatile.Write(ref pcm, callback == 0 ? null : new(callback, user)); return VcResult.Ok; }
private unsafe void ForwardPcm(uint userId, uint streamId, ReadOnlySpan<short> samples, int channels)
{
var target = Volatile.Read(ref pcm); if (target is null) return;
fixed (short* input = samples)
((delegate* unmanaged[Cdecl]<nint, uint, uint, short*, nuint, uint, uint, void>)target.Callback)(target.User, userId, streamId, input, (nuint)(samples.Length / channels), (uint)channels, 48000);
}
// Server-authoritative moves/edits stop old SSRCs. Reannounce with the new channel
// configuration while keeping UI/external-feed ids stable; captures continue feeding
// the alias and switch to the new audio owner only when negotiation completes.
private void ReconcileStreams()
{
var active = core.LocalStreams;
foreach (LocalStream stream in local.Values)
if (!active.Any(s => s.StreamId == stream.Info.StreamId) && Interlocked.CompareExchange(ref stream.Restarting, 1, 0) == 0) _ = RestartAsync(stream);
}
private async Task RestartAsync(LocalStream stream)
{
try
{
StreamInfo previous = stream.Info;
StreamInfo next = await core.StartStreamAsync(previous.Kind, previous.Label, stream.CaptureChannels).ConfigureAwait(false);
if (!local.ContainsKey(stream.Alias)) core.StopStream(next.StreamId);
else Volatile.Write(ref stream.Info, next);
}
catch (Exception exception) { Queue(new(VcEventType.Error, Result: VcResult.Audio, Text: exception.Message)); }
finally { Volatile.Write(ref stream.Restarting, 0); }
}
}
+108
View File
@@ -0,0 +1,108 @@
// Windows-facing state and command values used by the WinForms application.
namespace VoiceCat.Windows;
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>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>Reply to VoiceCatClient.JoinChannelAsync's underlying vc_join_channel.</summary>
JoinResult = 12,
/// <summary>The TOFU server-identity gate — see VcTofuStatus.</summary>
ServerIdentity = 13,
/// <summary>Async result for moderation/admin/channel operations.</summary>
GenericResult = 14,
/// <summary>Reply to VoiceCatClient.RequestAccountList — call ListAccounts() to read.</summary>
AccountList = 15,
/// <summary>Voice-plane subscription state. u32a = 1 (subscribed) or 0 (unsubscribed).</summary>
VoiceState = 16,
}
/// <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,
}
@@ -0,0 +1,76 @@
// Windows-facing records used by the WinForms application.
namespace VoiceCat.Windows;
public sealed record ChannelInfo(
uint Id,
uint ParentId,
string Name,
string Topic,
bool PasswordProtected,
uint MaxUsers,
uint SortOrder,
AudioConfigInfo Audio);
public sealed record ChannelEditInfo(
uint Id,
uint ParentId,
string Name,
string Topic,
bool PasswordProtected,
string? Password,
uint MaxUsers,
uint SortOrder,
AudioConfigInfo Audio);
public sealed record UserInfo(
uint Id,
string Nickname,
bool IsGuest,
uint ChannelId,
bool SelfMicMuted,
bool SelfDeafened,
bool ServerMuted,
bool ServerDeafened,
bool VoiceSubscribed);
public sealed record PermissionsInfo(
bool CanCreateTempChannel,
bool CanKick,
bool CanBan,
bool CanMoveUsers,
bool CanAdminAccounts,
bool IsAdmin);
public sealed record AccountInfo(
string Username,
bool IsAdmin,
ulong CreatedAtUnixMs,
ulong LastLoginUnixMs);
public sealed record StreamSummary(
uint StreamId,
VcStreamKind Kind,
string Label);
public sealed record RemoteStreamState(
float Gain,
bool Muted,
bool NoiseReduction);
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,
bool Dred);
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../../src/VoiceCat.Core/VoiceCat.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,197 @@
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.Windows;
// 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();
playback.BufferMilliseconds = core.Audio.DeviceBufferMilliseconds;
}
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();
}
}
@@ -0,0 +1,7 @@
namespace VoiceCat.Windows;
// Compatibility event consumed by WinForms. The managed core owns protocol and audio;
// this assembly contains no libvoicecat bindings or native handles.
public sealed record VoiceCatEvent(VcEventType Type, VcConnectionState ConnectionState = VcConnectionState.Disconnected,
VcResult Result = VcResult.Ok, uint UserId = 0, uint ChannelId = 0, uint StreamId = 0, VcTextScope TextScope = VcTextScope.Channel,
uint U32a = 0, string? Text = null, ulong TimestampUnixMs = 0);
@@ -0,0 +1,51 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"BouncyCastle.Cryptography": {
"type": "Transitive",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"Google.Protobuf": {
"type": "Transitive",
"resolved": "3.36.1",
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
},
"voicecat.audio": {
"type": "Project",
"dependencies": {
"VoiceCat.Codec": "[1.0.0, )",
"VoiceCat.Dsp": "[1.0.0, )",
"VoiceCat.Protocol": "[1.0.0, )"
}
},
"voicecat.codec": {
"type": "Project"
},
"voicecat.core": {
"type": "Project",
"dependencies": {
"VoiceCat.Audio": "[1.0.0, )",
"VoiceCat.Crypto": "[1.0.0, )"
}
},
"voicecat.crypto": {
"type": "Project",
"dependencies": {
"BouncyCastle.Cryptography": "[2.6.2, )",
"VoiceCat.Protocol": "[1.0.0, )"
}
},
"voicecat.dsp": {
"type": "Project"
},
"voicecat.protocol": {
"type": "Project",
"dependencies": {
"Google.Protobuf": "[3.36.1, )"
}
}
}
}
}
@@ -0,0 +1,58 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.7, )",
"resolved": "10.0.7",
"contentHash": "AA/yhzFHNtQZXLdqjzujPy25G8EWwGWsAnxOE2zYSBoT/8QHP6ketN3CToD3DFreO653ipUwnKHo22B8AlBMCw=="
},
"BouncyCastle.Cryptography": {
"type": "Transitive",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"Google.Protobuf": {
"type": "Transitive",
"resolved": "3.36.1",
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
},
"voicecat.audio": {
"type": "Project",
"dependencies": {
"VoiceCat.Codec": "[1.0.0, )",
"VoiceCat.Dsp": "[1.0.0, )",
"VoiceCat.Protocol": "[1.0.0, )"
}
},
"voicecat.codec": {
"type": "Project"
},
"voicecat.core": {
"type": "Project",
"dependencies": {
"VoiceCat.Audio": "[1.0.0, )",
"VoiceCat.Crypto": "[1.0.0, )"
}
},
"voicecat.crypto": {
"type": "Project",
"dependencies": {
"BouncyCastle.Cryptography": "[2.6.2, )",
"VoiceCat.Protocol": "[1.0.0, )"
}
},
"voicecat.dsp": {
"type": "Project"
},
"voicecat.protocol": {
"type": "Project",
"dependencies": {
"Google.Protobuf": "[3.36.1, )"
}
}
},
"net10.0/win-x64": {}
}
}