Port managed client audio and Windows application
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
using Voicecat.V1;
|
||||
|
||||
namespace VoiceCat.Interop;
|
||||
|
||||
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,106 @@
|
||||
using VoiceCat.Audio;
|
||||
using Voicecat.V1;
|
||||
|
||||
namespace VoiceCat.Interop;
|
||||
|
||||
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 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); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<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="../../../dotnet/src/VoiceCat.Core/VoiceCat.Core.csproj" />
|
||||
<Compile Include="../VoiceCat.Interop/Enums.cs" Link="Enums.cs" />
|
||||
<Compile Include="../VoiceCat.Interop/Models.cs" Link="Models.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace VoiceCat.Interop;
|
||||
|
||||
// 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": {}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user