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
-9
View File
@@ -1,9 +0,0 @@
<Project>
<PropertyGroup>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<!-- Where the CMake `windows-client` preset puts the built DLL (see ../../CMakePresets.json
and clients/windows/README.md). Override with an environment variable or a
Directory.Build.props further down the tree if your build/ lives elsewhere. -->
<VoiceCatNativeDir Condition="'$(VoiceCatNativeDir)' == ''">$(MSBuildThisFileDirectory)..\..\build\windows-client\bin</VoiceCatNativeDir>
</PropertyGroup>
</Project>
+9 -31
View File
@@ -1,43 +1,21 @@
# VoiceCat — Windows client
# Windows client
The WinForms .NET 10 client uses `VoiceCat.Core` for TLS, TOFU, protocol state and encrypted UDP, and `VoiceCat.Audio` for streams, jitter, Opus and mixing. Its only native runtime component is `voicecat_media.dll`, the narrow Opus/RNNoise C shim. The old `voicecat.dll` core is no longer loaded or published.
## Build
Stage the pinned native media dependencies once, then build the solution:
The .NET 10 WinForms client uses `VoiceCat.Core` and `VoiceCat.Audio`. Its only native runtime
component is `voicecat_media.dll`, the narrow Opus/RNNoise shim.
```powershell
./dotnet/build-native.ps1
./scripts/build-native.ps1
dotnet restore clients/windows/VoiceCat.slnx --locked-mode
dotnet build clients/windows/VoiceCat.slnx -c Release --no-restore
```
The original `VoiceCat.Interop` project remains in the repository as a migration oracle. The app references `VoiceCat.Managed`, whose compatibility facade lets the existing accessible WinForms UI keep its event-pump shape while all networking and audio state live in the idiomatic managed libraries.
## Publish
```powershell
./clients/windows/publish-client.ps1
```
This produces a self-contained `win-x64` distribution in `dotnet/artifacts/client/win-x64`. The script requires `voicecat_media.dll` and fails if the legacy `voicecat.dll` appears.
The noninteractive startup and real WASAPI device check is:
Publishing produces `artifacts/client/win-x64`. The noninteractive form and WASAPI device gate
is:
```powershell
./dotnet/artifacts/client/win-x64/VoiceCat.App.exe --smoke-test --audio
./artifacts/client/win-x64/VoiceCat.App.exe --smoke-test --audio
```
`--smoke-test` constructs the real main form and pumps the managed client. `--audio` additionally opens the default WASAPI capture and render endpoints, moves PCM through both for three seconds, and fails if capture produces no samples.
## Run manually
```powershell
# Terminal 1
./dotnet/artifacts/server/win-x64/VoiceCat.Server.exe
# Terminal 2
dotnet run --project clients/windows/VoiceCat.App/VoiceCat.App.csproj
```
Manual release validation includes NVDA navigation and a sustained two-client listen test, as tracked in `docs/roadmap.md`.
Manual release validation includes keyboard operation, NVDA navigation, curated announcements,
and a sustained real call.
@@ -1,5 +1,5 @@
using VoiceCat.Audio;
using VoiceCat.Interop;
using VoiceCat.Windows;
namespace VoiceCat.App.Audio;
@@ -1,4 +1,4 @@
using VoiceCat.Interop;
using VoiceCat.Windows;
namespace VoiceCat.App.Forms;
@@ -1,7 +1,7 @@
using System.ComponentModel;
using VoiceCat.App.Audio;
using VoiceCat.App.Models;
using VoiceCat.Interop;
using VoiceCat.Windows;
namespace VoiceCat.App.Forms;
@@ -1,4 +1,4 @@
using VoiceCat.Interop;
using VoiceCat.Windows;
namespace VoiceCat.App.Forms;
@@ -1,5 +1,5 @@
using VoiceCat.App.Models;
using VoiceCat.Interop;
using VoiceCat.Windows;
namespace VoiceCat.App.Forms;
@@ -2,7 +2,7 @@ using VoiceCat.App.Audio;
using VoiceCat.App.Models;
using VoiceCat.App.Native;
using VoiceCat.App.Notifications;
using VoiceCat.Interop;
using VoiceCat.Windows;
namespace VoiceCat.App.Forms;
@@ -1,4 +1,4 @@
using VoiceCat.Interop;
using VoiceCat.Windows;
namespace VoiceCat.App.Forms;
@@ -1,4 +1,4 @@
using VoiceCat.Interop;
using VoiceCat.Windows;
namespace VoiceCat.App.Forms;
@@ -1,4 +1,4 @@
using VoiceCat.Interop;
using VoiceCat.Windows;
namespace VoiceCat.App.Forms;
@@ -1,4 +1,4 @@
using VoiceCat.Interop;
using VoiceCat.Windows;
namespace VoiceCat.App.Forms;
@@ -1,4 +1,4 @@
using VoiceCat.Interop;
using VoiceCat.Windows;
namespace VoiceCat.App.Forms;
@@ -1,4 +1,4 @@
using VoiceCat.Interop;
using VoiceCat.Windows;
namespace VoiceCat.App.Forms;
@@ -62,7 +62,7 @@ internal static partial class RawInput
public uint ExtraInformation;
}
// ── P/Invoke (source-generated via LibraryImport, matching VoiceCat.Interop) ────────────
// Raw Input P/Invoke (source-generated via LibraryImport).
[LibraryImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool RegisterRawInputDevices(
+1 -1
View File
@@ -20,7 +20,7 @@ internal static class Program
{
try
{
using var client = new VoiceCat.Interop.VoiceCatClient("Smoke", "test");
using var client = new VoiceCat.Windows.VoiceCatClient("Smoke", "test");
using var form = new MainForm(client, 0, "Smoke", "Managed core");
form.CreateControl();
if (form.Controls.Count == 0 || string.IsNullOrEmpty(form.Text)) return 1;
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\VoiceCat.Managed\VoiceCat.Managed.csproj" />
<ProjectReference Include="..\VoiceCat.Windows\VoiceCat.Windows.csproj" />
</ItemGroup>
<!-- Prismatoid — .NET bindings for the Prism speech library, used for spoken event
@@ -46,7 +46,7 @@
"voicecat.dsp": {
"type": "Project"
},
"voicecat.managed": {
"voicecat.windows": {
"type": "Project",
"dependencies": {
"VoiceCat.Core": "[1.0.0, )"
@@ -60,4 +60,4 @@
}
}
}
}
}
@@ -52,7 +52,7 @@
"voicecat.dsp": {
"type": "Project"
},
"voicecat.managed": {
"voicecat.windows": {
"type": "Project",
"dependencies": {
"VoiceCat.Core": "[1.0.0, )"
@@ -74,4 +74,4 @@
}
}
}
}
}
@@ -1,55 +0,0 @@
using System.Runtime.InteropServices;
using VoiceCat.Interop;
namespace VoiceCat.Interop.Tests;
/// <summary>
/// API-surface smoke tests for StreamFeedPcm / SetPcmSink through the P/Invoke layer.
/// These tests need voicecat.dll but NOT a running server — they verify the C# wrapper
/// compiles, is callable, and the error paths work at the C-ABI boundary. Full E2E relay
/// verification is handled by tests/test_external_pcm.cpp (C++ ctest).
/// </summary>
public sealed class ExternalPcmTests
{
[Fact]
public void StreamFeedPcm_NoActiveStream_ReturnsInvalidArg()
{
using var client = new VoiceCatClient("ext-pcm-test", "0.1", VcLogLevel.Off);
var pcm = new short[960];
// Stream 0 doesn't exist — core must return InvalidArg, not crash.
var result = client.StreamFeedPcm(0, pcm, 960, channels: 1);
Assert.Equal(VcResult.InvalidArg, result);
}
[Fact]
public void StreamFeedPcm_InvalidChannels_ReturnsInvalidArg()
{
using var client = new VoiceCatClient("ext-pcm-test", "0.1", VcLogLevel.Off);
var pcm = new short[960 * 3];
// channels=3 is not supported — trampoline rejects before touching audio state.
var result = client.StreamFeedPcm(0, pcm, 960, channels: 3);
Assert.Equal(VcResult.InvalidArg, result);
}
[Fact]
public void SetPcmSink_Zero_ReturnsOk()
{
using var client = new VoiceCatClient("ext-pcm-test", "0.1", VcLogLevel.Off);
// IntPtr.Zero = disable — default state, must be a no-op that returns Ok.
var result = client.SetPcmSink(IntPtr.Zero, IntPtr.Zero);
Assert.Equal(VcResult.Ok, result);
}
[Fact]
public void SetPcmSink_EnableThenDisable_BothSucceed()
{
using var client = new VoiceCatClient("ext-pcm-test", "0.1", VcLogLevel.Off);
// Keep the delegate alive until after we unregister it (not just until the P/Invoke call).
NativeMethods.VcPcmSinkCallback sink = static (_, _, _, _, _, _, _) => { };
nint fp = Marshal.GetFunctionPointerForDelegate(sink);
Assert.Equal(VcResult.Ok, client.SetPcmSink(fp, IntPtr.Zero));
Assert.Equal(VcResult.Ok, client.SetPcmSink(IntPtr.Zero, IntPtr.Zero));
GC.KeepAlive(sink);
}
}
@@ -1,40 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\VoiceCat.Interop\VoiceCat.Interop.csproj" />
</ItemGroup>
<!-- voicecat.dll must exist (build the `windows-client` CMake preset first — see
clients/windows/README.md) before these tests can run; the P/Invoke smoke tests need
a real native library to call into. -->
<ItemGroup>
<Content Include="$(VoiceCatNativeDir)\voicecat.dll" Condition="Exists('$(VoiceCatNativeDir)\voicecat.dll')">
<Link>voicecat.dll</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<Target Name="VoiceCatCheckNativeDll" BeforeTargets="Build">
<Error Condition="!Exists('$(VoiceCatNativeDir)\voicecat.dll')"
Text="voicecat.dll not found at '$(VoiceCatNativeDir)'. Build it first: cmake --preset windows-client &amp;&amp; cmake --build --preset windows-client (see clients/windows/README.md)." />
</Target>
</Project>
@@ -1,401 +0,0 @@
using System.Diagnostics;
using System.Text.RegularExpressions;
using VoiceCat.Interop;
namespace VoiceCat.Interop.Tests;
/// <summary>
/// Exercises the full connect -> TOFU event -> confirm -> guest auth -> list channels flow
/// purely through the P/Invoke layer (NativeMethods/VoiceCatClient), against a real
/// `voicecat-server.exe` (the same binary the C++ ctest suite uses) — this is the same flow
/// tests/test_tofu_flow.cpp already proves at the C++ level, now proven reachable through
/// P/Invoke specifically: marshaling bugs, callback-lifetime bugs, and calling-convention
/// mistakes are all P/Invoke-specific failure modes ctest alone cannot catch.
/// </summary>
public sealed class VoiceCatClientSmokeTests : IDisposable
{
private readonly string _tempDir;
private readonly Process _server;
private readonly ushort _port;
public VoiceCatClientSmokeTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), "vc_csharp_smoke_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempDir);
string serverExe = Path.Combine(FindRepoRoot(), "build", "dev", "bin", "voicecat-server.exe");
Assert.True(File.Exists(serverExe),
$"voicecat-server.exe not found at '{serverExe}' — build the dev preset first " +
"(cmake --preset dev && cmake --build --preset dev).");
var psi = new ProcessStartInfo(serverExe)
{
Arguments = $"--port 0 --data-dir \"{_tempDir}\" --name CSharpSmokeTest",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
_server = Process.Start(psi) ?? throw new InvalidOperationException("failed to start voicecat-server.exe");
// "[voicecat-server] <name> — TCP :<port> UDP :<port>" — see server/src/server.cpp.
// ReadLineAsync()+timeout (not a bare blocking ReadLine() in a deadline loop) so a
// server that never prints anything (crash, hang) can't hang this constructor forever
// — the deadline check must apply to the read itself, not just the loop around it.
ushort? port = null;
var deadline = DateTime.UtcNow.AddSeconds(10);
while (port is null && DateTime.UtcNow < deadline)
{
var readTask = _server.StandardOutput.ReadLineAsync();
var remaining = deadline - DateTime.UtcNow;
if (remaining <= TimeSpan.Zero || !readTask.Wait(remaining)) break;
string? line = readTask.Result;
if (line is null) break;
var m = Regex.Match(line, @"TCP :(\d+)");
if (m.Success) port = ushort.Parse(m.Groups[1].Value);
}
Assert.True(port is not null, "voicecat-server.exe did not report a bound TCP port within 10s.");
_port = port!.Value;
// Provision a known admin account so we can exercise moderation wrappers end-to-end.
string adminExe = Path.Combine(FindRepoRoot(), "build", "dev", "bin", "voicecat-admin.exe");
Assert.True(File.Exists(adminExe), "voicecat-admin.exe not found — build the dev preset.");
var adminPsi = new ProcessStartInfo(adminExe)
{
Arguments = $"--data-dir \"{_tempDir}\" account add admin2 --admin --password testpassword123",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
using (var adminProc = Process.Start(adminPsi) ?? throw new InvalidOperationException("failed to start voicecat-admin.exe"))
{
Assert.True(adminProc.WaitForExit(10000), "voicecat-admin.exe did not exit within 10s.");
Assert.Equal(0, adminProc.ExitCode);
}
}
public void Dispose()
{
try { if (!_server.HasExited) _server.Kill(entireProcessTree: true); } catch { /* best effort */ }
try { Directory.Delete(_tempDir, recursive: true); } catch { /* best effort */ }
}
private static string FindRepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "CMakePresets.json")))
dir = dir.Parent;
return dir?.FullName ?? throw new InvalidOperationException("Could not find repo root (CMakePresets.json) above " + AppContext.BaseDirectory);
}
private static bool PumpUntil(VoiceCatClient client, Func<bool> predicate, int timeoutMs)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
while (DateTime.UtcNow < deadline)
{
client.PumpEvents();
if (predicate()) return true;
Thread.Sleep(20);
}
client.PumpEvents();
return predicate();
}
[Fact]
public void VersionString_IsNonEmpty()
{
Assert.False(string.IsNullOrEmpty(VoiceCatClient.VersionString));
}
[Fact]
public void Connect_Tofu_Auth_ListChannels_RoundTrips()
{
var events = new List<VoiceCatEvent>();
using var client = new VoiceCatClient("vc-csharp-smoke", "0.1", VcLogLevel.Off,
tofuStorePath: Path.Combine(_tempDir, "tofu_pins.txt"));
client.EventReceived += events.Add;
Assert.Equal(VcResult.Ok, client.Connect("127.0.0.1", _port));
Assert.Equal(VcResult.Ok, client.AuthenticateGuest("CSharpSmoke"));
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ServerIdentity), 5000),
"did not receive VC_EVENT_SERVER_IDENTITY");
var identityEvent = events.First(e => e.Type == VcEventType.ServerIdentity);
Assert.Equal((uint)VcTofuStatus.FirstConnect, identityEvent.U32a);
Assert.NotNull(identityEvent.Text);
Assert.Equal(64, identityEvent.Text!.Length); // SHA-256 hex, no separators
// Auth must NOT complete before the identity is confirmed.
Assert.False(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AuthResult), 800));
Assert.Equal(VcResult.Ok, client.ConfirmServerIdentity(accept: true));
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AuthResult), 5000),
"did not receive VC_EVENT_AUTH_RESULT after confirming identity");
var authEvent = events.First(e => e.Type == VcEventType.AuthResult);
Assert.Equal(VcResult.Ok, authEvent.Result);
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ChannelList), 3000),
"did not receive VC_EVENT_CHANNEL_LIST");
var channels = client.ListChannels();
Assert.Contains(channels, c => c.Id == 1 && c.Name == "Lobby");
// Permissions getter round-trip.
var perms = client.GetPermissions();
Assert.False(perms.IsAdmin);
Assert.False(perms.CanKick);
// Moderation request wrappers queue without error. As a guest, account listing
// is rejected by the server with a GenericResult, which proves the wrapper path works
// end-to-end and that the event type is delivered through P/Invoke.
Assert.Equal(VcResult.Ok, client.RequestAccountList());
Assert.True(PumpUntil(client,
() => events.Any(e => e.Type == VcEventType.GenericResult), 3000),
"did not receive VC_EVENT_GENERIC_RESULT for guest ListAccounts");
var generic = events.First(e => e.Type == VcEventType.GenericResult);
Assert.Equal(VcResult.PermissionDenied, generic.Result);
client.Disconnect();
}
[Fact]
public void Admin_ChannelCrud_AccountCrud_RoundTrips()
{
var events = new List<VoiceCatEvent>();
using var client = new VoiceCatClient("vc-csharp-admin", "0.1", VcLogLevel.Off,
tofuStorePath: Path.Combine(_tempDir, "tofu_pins_admin.txt"));
client.EventReceived += events.Add;
Assert.Equal(VcResult.Ok, client.Connect("127.0.0.1", _port));
Assert.Equal(VcResult.Ok, client.AuthenticateUser("admin2", "testpassword123"));
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ServerIdentity), 5000));
Assert.Equal(VcResult.Ok, client.ConfirmServerIdentity(accept: true));
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AuthResult), 5000));
Assert.Equal(VcResult.Ok, events.First(e => e.Type == VcEventType.AuthResult).Result);
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ChannelList), 3000));
var perms = client.GetPermissions();
Assert.True(perms.IsAdmin || perms.CanAdminAccounts);
// Channel CRUD
Assert.Equal(VcResult.Ok, client.CreateChannel(new ChannelEditInfo(
Id: 0,
ParentId: 0,
Name: "CSharp Test Channel",
Topic: "Created by C# smoke test",
PasswordProtected: false,
Password: null,
MaxUsers: 42,
SortOrder: 0,
Audio: new AudioConfigInfo(0, true, 48000, 64000, 20, 1, true, 5, false, 10, false))));
Assert.True(PumpUntil(client,
() => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000),
"CreateChannel did not succeed");
var channels = client.ListChannels();
var created = channels.FirstOrDefault(c => c.Name == "CSharp Test Channel");
Assert.NotNull(created);
Assert.Equal("Created by C# smoke test", created.Topic);
Assert.True(created.PasswordProtected == false);
Assert.Equal(VcResult.Ok, client.EditChannel(new ChannelEditInfo(
created.Id,
created.ParentId,
created.Name,
"Updated topic",
created.PasswordProtected,
null,
100,
0,
new AudioConfigInfo(0, true, 48000, 64000, 20, 1, true, 5, false, 10, false))));
events.Clear();
Assert.True(PumpUntil(client,
() => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000),
"EditChannel did not succeed");
Assert.Equal(VcResult.Ok, client.DeleteChannel(created.Id));
events.Clear();
Assert.True(PumpUntil(client,
() => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000),
"DeleteChannel did not succeed");
// Account CRUD
Assert.Equal(VcResult.Ok, client.CreateAccount("csharp_smoke_user", "initialpw"));
events.Clear();
Assert.True(PumpUntil(client,
() => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000),
"CreateAccount did not succeed");
Assert.Equal(VcResult.Ok, client.RequestAccountList());
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AccountList), 3000));
var accounts = client.ListAccounts();
Assert.Contains(accounts, a => a.Username == "csharp_smoke_user");
Assert.Equal(VcResult.Ok, client.ResetPassword("csharp_smoke_user", "newpw123"));
events.Clear();
Assert.True(PumpUntil(client,
() => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000),
"ResetPassword did not succeed");
Assert.Equal(VcResult.Ok, client.DeleteAccount("csharp_smoke_user"));
events.Clear();
Assert.True(PumpUntil(client,
() => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000),
"DeleteAccount did not succeed");
client.Disconnect();
}
/// <summary>
/// Screen-audio (SCREEN_AUDIO) stream start/stop through the P/Invoke layer. The core's
/// WASAPI loopback path (VOICECAT_HAS_LOOPBACK) captures the default render endpoint; the
/// StreamAnnounce succeeds regardless of whether the loopback device actually initializes
/// on a headless box, so this test passes in CI while still exercising the full
/// StartStream -> StreamStarted -> StopStream -> StreamStopped path through P/Invoke.
/// See docs/voice.md §9 and MainForm's BtnScreenShareToggle_Click.
/// </summary>
[Fact]
public void ScreenAudioStream_Starts_And_Stops()
{
var events = new List<VoiceCatEvent>();
using var client = new VoiceCatClient("vc-csharp-screen", "0.1", VcLogLevel.Off,
tofuStorePath: Path.Combine(_tempDir, "tofu_pins_screen.txt"));
client.EventReceived += events.Add;
Assert.Equal(VcResult.Ok, client.Connect("127.0.0.1", _port));
Assert.Equal(VcResult.Ok, client.AuthenticateGuest("CSharpScreen"));
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ServerIdentity), 5000));
Assert.Equal(VcResult.Ok, client.ConfirmServerIdentity(accept: true));
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AuthResult), 5000));
Assert.Equal(VcResult.Ok, events.First(e => e.Type == VcEventType.AuthResult).Result);
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ChannelList), 3000));
Assert.Equal(VcResult.Ok, client.JoinVoice());
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.VoiceState && e.U32a == 1), 5000));
// Give the async UDP binding handshake a moment to land before announcing a stream
// (mirrors vccli's 500ms sleep after auth).
Thread.Sleep(500);
var (startResult, streamId) = client.StartStream(VcStreamKind.ScreenAudio, "Desktop audio");
Assert.Equal(VcResult.Ok, startResult);
Assert.True(streamId != 0, "StreamId should be non-zero on success");
// The core emits VC_EVENT_STREAM_STARTED for the local client too (client.cpp
// handle_stream_announce_result), so we see our own screen-audio stream start.
Assert.True(PumpUntil(client,
() => events.Any(e => e.Type == VcEventType.StreamStarted && e.StreamId == streamId), 5000),
"did not receive VC_EVENT_STREAM_STARTED for screen-audio stream");
Assert.Equal(VcResult.Ok, client.StopStream(streamId));
Assert.True(PumpUntil(client,
() => events.Any(e => e.Type == VcEventType.StreamStopped && e.StreamId == streamId), 5000),
"did not receive VC_EVENT_STREAM_STOPPED for screen-audio stream");
client.Disconnect();
}
/// <summary>
/// Per-stream receive-side controls (gain/mute/NR) round-trip through P/Invoke: two
/// clients in a channel, one publishes a MIC stream, the other SetRemoteStream's it then
/// GetRemoteStream's it back. Catches P/Invoke-specific marshaling bugs in
/// VcRemoteStreamStateNative (field order, bool-from-int, float precision) that the C++
/// ctest (test_m3_multistream) cannot. See docs/voice.md §1, §10.
/// </summary>
[Fact]
public void PerStream_RecvControls_RoundTrip_Through_PInvoke()
{
var eventsA = new List<VoiceCatEvent>();
var eventsB = new List<VoiceCatEvent>();
using var a = new VoiceCatClient("vc-csharp-mix-a", "0.1", VcLogLevel.Off,
tofuStorePath: Path.Combine(_tempDir, "tofu_pins_mix_a.txt"));
using var b = new VoiceCatClient("vc-csharp-mix-b", "0.1", VcLogLevel.Off,
tofuStorePath: Path.Combine(_tempDir, "tofu_pins_mix_b.txt"));
a.EventReceived += eventsA.Add;
b.EventReceived += eventsB.Add;
// Connect + auth A first, then B — staggering avoids concurrent TLS handshakes against
// the same server (mirrors the C++ test_m3_multistream harness, which connects A to
// completion before starting B).
Assert.Equal(VcResult.Ok, a.Connect("127.0.0.1", _port));
Assert.Equal(VcResult.Ok, a.AuthenticateGuest("CSharpMixA"));
Assert.True(PumpUntil(a, () => eventsA.Any(e => e.Type == VcEventType.ServerIdentity), 5000));
Assert.Equal(VcResult.Ok, a.ConfirmServerIdentity(accept: true));
Assert.True(PumpUntil(a, () => eventsA.Any(e => e.Type == VcEventType.AuthResult), 5000));
Assert.Equal(VcResult.Ok, eventsA.First(e => e.Type == VcEventType.AuthResult).Result);
Assert.True(PumpUntil(a, () => eventsA.Any(e => e.Type == VcEventType.ChannelList), 3000));
Assert.Equal(VcResult.Ok, b.Connect("127.0.0.1", _port));
Assert.Equal(VcResult.Ok, b.AuthenticateGuest("CSharpMixB"));
Assert.True(PumpUntil(b, () => eventsB.Any(e => e.Type == VcEventType.ServerIdentity), 5000));
Assert.Equal(VcResult.Ok, b.ConfirmServerIdentity(accept: true));
Assert.True(PumpUntil(b, () => eventsB.Any(e => e.Type == VcEventType.AuthResult), 5000));
Assert.Equal(VcResult.Ok, eventsB.First(e => e.Type == VcEventType.AuthResult).Result);
Assert.True(PumpUntil(b, () => eventsB.Any(e => e.Type == VcEventType.ChannelList), 3000));
// Both join Lobby (channel 1) so voice relays between them.
Assert.Equal(VcResult.Ok, a.JoinChannel(1, null));
Assert.True(PumpUntil(a, () => eventsA.Any(e => e.Type == VcEventType.JoinResult), 5000),
"A did not receive VC_EVENT_JOIN_RESULT");
Assert.Equal(VcResult.Ok, b.JoinChannel(1, null));
Assert.True(PumpUntil(b, () => eventsB.Any(e => e.Type == VcEventType.JoinResult), 5000),
"B did not receive VC_EVENT_JOIN_RESULT");
Assert.Equal(VcResult.Ok, a.JoinVoice());
Assert.Equal(VcResult.Ok, b.JoinVoice());
Assert.True(PumpUntil(a, () => eventsA.Any(e => e.Type == VcEventType.VoiceState && e.U32a == 1), 5000));
Assert.True(PumpUntil(b, () => eventsB.Any(e => e.Type == VcEventType.VoiceState && e.U32a == 1), 5000));
// UDP binding handshake is async; give it a moment (mirrors ScreenAudio test).
Thread.Sleep(500);
// A publishes a MIC stream.
var (startResult, streamId) = a.StartStream(VcStreamKind.Mic, "mix-test-mic");
Assert.Equal(VcResult.Ok, startResult);
Assert.True(streamId != 0);
// B sees A's stream and can enumerate it.
uint aUid = 0;
Assert.True(PumpUntil(b, () =>
{
return eventsB.Any(e => e.Type == VcEventType.StreamStarted && e.StreamId == streamId);
}, 5000), "B did not see A's STREAM_STARTED");
// Resolve A's user id from B's user list.
Assert.True(PumpUntil(b, () =>
{
aUid = b.ListUsers().FirstOrDefault(u => u.Nickname == "CSharpMixA")?.Id ?? 0;
return aUid != 0;
}, 3000), "could not resolve A's user id on B");
Assert.True(aUid != 0);
Assert.True(PumpUntil(b, () => b.ListUserStreams(aUid).Any(s => s.StreamId == streamId), 3000),
"B could not enumerate A's stream");
var bStreams = b.ListUserStreams(aUid);
Assert.Contains(bStreams, s => s.StreamId == streamId && s.Kind == VcStreamKind.Mic);
// Before B ever sets anything, defaults read back (gain 1.0, unmuted, NR off).
var (r0, st0) = b.GetRemoteStream(aUid, streamId);
Assert.Equal(VcResult.Ok, r0);
Assert.NotNull(st0);
Assert.Equal(1.0f, st0!.Gain);
Assert.False(st0.Muted);
Assert.False(st0.NoiseReduction);
// B turns A down to 0.4×, mutes, and enables NR — then reads it back.
Assert.Equal(VcResult.Ok, b.SetRemoteStream(aUid, streamId, 0.4f, muted: true, noiseReduction: true));
var (r1, st1) = b.GetRemoteStream(aUid, streamId);
Assert.Equal(VcResult.Ok, r1);
Assert.NotNull(st1);
Assert.Equal(0.4f, st1!.Gain);
Assert.True(st1.Muted);
Assert.True(st1.NoiseReduction);
// Unknown stream id on a known user -> INVALID_ARG.
var (rBad, stBad) = b.GetRemoteStream(aUid, 0xDEADBEEF);
Assert.Equal(VcResult.InvalidArg, rBad);
Assert.Null(stBad);
a.Disconnect();
b.Disconnect();
}
}
@@ -1,129 +0,0 @@
using System.Runtime.InteropServices;
// Shared "walk a native array of owned-struct entries, convert to managed records, free the
// native list" pattern — identical shape for vc_device_list/vc_channel_list/vc_user_list/
// vc_stream_summary_list (all core-allocated, caller-freed; see voicecat.h's doc comments on
// each). The matching vc_free_*_list call happens INSIDE each ToManaged here, immediately
// after the conversion, so callers never need to remember to free anything themselves.
namespace VoiceCat.Interop;
internal static class Marshaling
{
public static List<DeviceInfo> ToManaged(ref VcDeviceListNative native)
{
var result = new List<DeviceInfo>((int)native.Count);
int size = Marshal.SizeOf<VcDeviceNative>();
for (nuint i = 0; i < native.Count; i++)
{
var raw = Marshal.PtrToStructure<VcDeviceNative>(native.Items + (int)i * size);
result.Add(new DeviceInfo(
Marshal.PtrToStringUTF8(raw.Id) ?? string.Empty,
Marshal.PtrToStringUTF8(raw.Name) ?? string.Empty,
raw.IsDefault != 0));
}
NativeMethods.vc_free_device_list(ref native);
return result;
}
public static List<ChannelInfo> ToManaged(ref VcChannelListNative native)
{
var result = new List<ChannelInfo>((int)native.Count);
int size = Marshal.SizeOf<VcChannelNative>();
for (nuint i = 0; i < native.Count; i++)
{
var raw = Marshal.PtrToStructure<VcChannelNative>(native.Items + (int)i * size);
result.Add(new ChannelInfo(
raw.Id,
raw.ParentId,
Marshal.PtrToStringUTF8(raw.Name) ?? string.Empty,
Marshal.PtrToStringUTF8(raw.Topic) ?? string.Empty,
raw.PasswordProtected != 0,
raw.MaxUsers,
raw.SortOrder,
ToManaged(in raw.Audio)));
}
NativeMethods.vc_free_channel_list(ref native);
return result;
}
public static List<UserInfo> ToManaged(ref VcUserListNative native)
{
var result = new List<UserInfo>((int)native.Count);
int size = Marshal.SizeOf<VcUserNative>();
for (nuint i = 0; i < native.Count; i++)
{
var raw = Marshal.PtrToStructure<VcUserNative>(native.Items + (int)i * size);
result.Add(new UserInfo(
raw.Id,
Marshal.PtrToStringUTF8(raw.Nickname) ?? string.Empty,
raw.IsGuest != 0,
raw.ChannelId,
raw.SelfMicMuted != 0,
raw.SelfDeafened != 0,
raw.ServerMuted != 0,
raw.ServerDeafened != 0,
raw.VoiceSubscribed != 0));
}
NativeMethods.vc_free_user_list(ref native);
return result;
}
public static List<StreamSummary> ToManaged(ref VcStreamSummaryListNative native)
{
var result = new List<StreamSummary>((int)native.Count);
int size = Marshal.SizeOf<VcStreamSummaryNative>();
for (nuint i = 0; i < native.Count; i++)
{
var raw = Marshal.PtrToStructure<VcStreamSummaryNative>(native.Items + (int)i * size);
result.Add(new StreamSummary(
raw.StreamId,
raw.Kind,
Marshal.PtrToStringUTF8(raw.Label) ?? string.Empty));
}
NativeMethods.vc_free_stream_summary_list(ref native);
return result;
}
public static RemoteStreamState ToManaged(in VcRemoteStreamStateNative native) => new(
native.Gain,
native.Muted != 0,
native.NoiseReduction != 0);
public static AudioConfigInfo ToManaged(in VcAudioConfigNative native) => new(
native.Codec,
native.Mode != 0,
native.SampleRate,
native.BitrateBps,
native.FrameMs,
native.Application,
native.Fec != 0,
native.ExpectedPacketLoss,
native.Dtx != 0,
native.Complexity,
native.Dred != 0);
public static PermissionsInfo ToManaged(in VcPermissionsNative native) => new(
native.CanCreateTempChannel != 0,
native.CanKick != 0,
native.CanBan != 0,
native.CanMoveUsers != 0,
native.CanAdminAccounts != 0,
native.IsAdmin != 0);
public static List<AccountInfo> ToManaged(ref VcAccountListNative native)
{
var result = new List<AccountInfo>((int)native.Count);
int size = Marshal.SizeOf<VcAccountNative>();
for (nuint i = 0; i < native.Count; i++)
{
var raw = Marshal.PtrToStructure<VcAccountNative>(native.Items + (int)i * size);
result.Add(new AccountInfo(
Marshal.PtrToStringUTF8(raw.Username) ?? string.Empty,
raw.IsAdmin != 0,
raw.CreatedAtUnixMs,
raw.LastLoginUnixMs));
}
NativeMethods.vc_free_account_list(ref native);
return result;
}
}
@@ -1,33 +0,0 @@
using System.Runtime.InteropServices;
// [UnmanagedCallersOnly] static methods for vc_callbacks.on_event/on_level — true native
// function pointers, not GC-tracked delegates (avoids the classic P/Invoke pitfall where a
// delegate is collected by the GC sometime after the call that registered it returns; see
// docs/tech-stack.md §2). vc_callbacks.user is a GCHandle-wrapped VoiceCatClient (allocated in
// VoiceCatClient's constructor, freed in Dispose) — these methods must be static, so they
// resolve back to the right client instance via that handle rather than closing over state.
namespace VoiceCat.Interop;
internal static unsafe class NativeCallbacks
{
[UnmanagedCallersOnly]
internal static void OnEvent(IntPtr userContext, VcEventNative* ev)
{
if (userContext == IntPtr.Zero) return;
if (GCHandle.FromIntPtr(userContext).Target is not VoiceCatClient client) return;
// CRITICAL (voicecat.h's vc_event doc comment): ev->Text is owned by the core and
// valid ONLY for the duration of this callback. Convert to a managed string NOW,
// before returning — never store/queue the raw VcEventNative across the callback
// boundary, or Text will be a dangling pointer by the time it's read.
client.EnqueueEvent(VoiceCatEvent.FromNative(*ev));
}
[UnmanagedCallersOnly]
internal static void OnLevel(IntPtr userContext, uint streamId, float rms)
{
if (userContext == IntPtr.Zero) return;
if (GCHandle.FromIntPtr(userContext).Target is not VoiceCatClient client) return;
client.EnqueueLevel(streamId, rms);
}
}
@@ -1,228 +0,0 @@
using System.Runtime.InteropServices;
// Raw P/Invoke surface over core/include/voicecat.h, via LibraryImport (source-generated —
// no runtime reflection marshaling stub; see docs/tech-stack.md §2). One entry per voicecat.h
// function. `vc_client*` is represented as a raw `nint` here — VoiceCatClientHandle (a
// SafeHandle) owns the create/destroy lifetime one level up; these declarations never see a
// SafeHandle directly, per .NET's own SafeHandle convention.
//
// "voicecat" resolves to voicecat.dll via the OS's standard DLL search order (same directory
// as the .exe first) — see clients/windows/README.md for how it gets there at build time.
namespace VoiceCat.Interop;
internal static partial class NativeMethods
{
private const string LibName = "voicecat";
// ── Lifecycle ────────────────────────────────────────────────────────────────────────
// NOTE: these two return `const char*` pointing at STATIC string literals the core never
// expects the caller to free. Declaring them as `string` with StringMarshalling.Utf8
// would be wrong: the built-in Utf8StringMarshaller's return-value convention assumes the
// native callee allocated the string FOR this call and that the marshaller should free it
// afterward — calling that on a static literal corrupts the heap (confirmed: it crashes
// with STATUS_HEAP_CORRUPTION / 0xC0000374). Return the raw pointer instead and convert
// with Marshal.PtrToStringUTF8 ourselves, without ever freeing it — see VoiceCatClient.cs.
[LibraryImport(LibName)]
internal static partial nint vc_version_string();
[LibraryImport(LibName)]
internal static partial nint vc_result_string(VcResult code);
[LibraryImport(LibName)]
internal static partial nint vc_client_create(in VcConfigNative cfg, VcCallbacksNative cb);
[LibraryImport(LibName)]
internal static partial void vc_client_destroy(nint c);
// ── Connection & auth (async; results via on_event) ────────────────────────────────────
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
internal static partial VcResult vc_connect(nint c, string host, ushort port);
[LibraryImport(LibName)]
internal static partial VcResult vc_disconnect(nint c);
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
internal static partial VcResult vc_authenticate_guest(nint c, string nickname);
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
internal static partial VcResult vc_authenticate_user(nint c, string username, string password);
// ── Channels ─────────────────────────────────────────────────────────────────────────
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
internal static partial VcResult vc_join_channel(nint c, uint channelId, string? password);
[LibraryImport(LibName)]
internal static partial VcResult vc_leave_channel(nint c);
[LibraryImport(LibName)]
internal static partial VcResult vc_join_voice(nint c);
[LibraryImport(LibName)]
internal static partial VcResult vc_leave_voice(nint c);
// ── Local media streams ─────────────────────────────────────────────────────────────────
[LibraryImport(LibName)]
internal static partial VcResult vc_stream_start(nint c, in VcStreamDescNative desc,
out uint outStreamId);
[LibraryImport(LibName)]
internal static partial VcResult vc_stream_stop(nint c, uint streamId);
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
internal static partial VcResult vc_set_input_device(nint c, uint streamId, string? deviceId);
[LibraryImport(LibName)]
internal static partial VcResult vc_set_capture_channels(nint c, uint streamId, uint channels);
[LibraryImport(LibName)]
internal static partial VcResult vc_audio_restart(nint c);
[LibraryImport(LibName)]
internal static partial VcResult vc_set_input_mode(nint c, VcInputMode mode);
[LibraryImport(LibName)]
internal static partial VcResult vc_set_vad_threshold(nint c, float threshold);
[LibraryImport(LibName)]
internal static partial VcResult vc_set_push_to_talk(nint c, int active);
[LibraryImport(LibName)]
internal static partial VcResult vc_set_self_mute(nint c, int micMuted, int deafened);
[LibraryImport(LibName)]
internal static partial VcResult vc_set_output_volume(nint c, float gain);
[LibraryImport(LibName)]
internal static partial VcResult vc_set_input_gain(nint c, float gain);
[LibraryImport(LibName)]
internal static partial VcResult vc_set_input_noise_reduction(nint c, int enable);
[LibraryImport(LibName)]
internal static partial VcResult vc_set_remote_stream(nint c, uint userId, uint streamId,
float gain, int muted, int noiseReduction);
[LibraryImport(LibName)]
internal static partial VcResult vc_get_remote_stream(nint c, uint userId, uint streamId,
out VcRemoteStreamStateNative outState);
[LibraryImport(LibName)]
internal static partial VcResult vc_get_stream_audio_config(nint c, uint userId,
uint streamId, out VcAudioConfigNative outCfg);
// TEST-ONLY in the core (see voicecat.h) — declared for ABI parity; the real app never
// calls this (no microphone-bypass path in production UI).
[LibraryImport(LibName)]
internal static unsafe partial VcResult vc_test_inject_capture(nint c, uint streamId,
short* pcm, nuint samples);
// ── External PCM feed / tap ──────────────────────────────────────────────────────────
[LibraryImport(LibName)]
internal static unsafe partial VcResult vc_stream_feed_pcm(nint c, uint streamId,
short* pcm, nuint samplesPerChannel, uint channels);
// Delegate type for the PCM sink callback — callers convert to a native function
// pointer via Marshal.GetFunctionPointerForDelegate (for instance members) or by casting
// a static lambda to delegate* unmanaged<> (for [UnmanagedCallersOnly] statics).
// Keep the delegate alive for the lifetime of the sink registration.
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate void VcPcmSinkCallback(IntPtr user, uint userId, uint streamId,
IntPtr pcm, nuint samplesPerChannel, uint channels, uint sampleRate);
// cb is a raw function pointer (IntPtr.Zero = disable). Use
// Marshal.GetFunctionPointerForDelegate(sinkDelegate) to convert from VcPcmSinkCallback.
[LibraryImport(LibName)]
internal static partial VcResult vc_set_pcm_sink(nint c, nint cb, IntPtr user);
// ── Text ─────────────────────────────────────────────────────────────────────────────
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
internal static partial VcResult vc_send_text(nint c, VcTextScope scope, uint targetId,
string utf8);
// ── Device enumeration ───────────────────────────────────────────────────────────────
[LibraryImport(LibName)]
internal static partial VcResult vc_list_devices(nint c, VcDeviceKind kind,
out VcDeviceListNative outList);
[LibraryImport(LibName)]
internal static partial void vc_free_device_list(ref VcDeviceListNative list);
// ── Channel / user / stream snapshot getters ────────────────────────────────────────────
[LibraryImport(LibName)]
internal static partial VcResult vc_list_channels(nint c, out VcChannelListNative outList);
[LibraryImport(LibName)]
internal static partial void vc_free_channel_list(ref VcChannelListNative list);
[LibraryImport(LibName)]
internal static partial VcResult vc_list_users(nint c, out VcUserListNative outList);
[LibraryImport(LibName)]
internal static partial void vc_free_user_list(ref VcUserListNative list);
[LibraryImport(LibName)]
internal static partial VcResult vc_list_user_streams(nint c, uint userId,
out VcStreamSummaryListNative outList);
[LibraryImport(LibName)]
internal static partial void vc_free_stream_summary_list(ref VcStreamSummaryListNative list);
// ── TOFU server-identity gate ───────────────────────────────────────────────────────────
[LibraryImport(LibName)]
internal static partial VcResult vc_confirm_server_identity(nint c, int accept);
[LibraryImport(LibName)]
internal static partial VcResult vc_get_server_identity_display(nint c, nint outBuf,
nuint bufCap, out nuint outLen);
// ── Moderation & admin ───────────────────────────────────────────────────────────────────
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
internal static partial VcResult vc_kick_user(nint c, uint userId, string? reason);
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
internal static partial VcResult vc_ban_user(nint c, uint userId, string? reason,
ulong expiresUnixMs);
[LibraryImport(LibName)]
internal static partial VcResult vc_set_permission(nint c, uint userId,
in VcPermissionsNative perms);
[LibraryImport(LibName)]
internal static partial VcResult vc_set_server_mute(nint c, uint userId, int muted,
int deafened);
[LibraryImport(LibName)]
internal static partial VcResult vc_move_user(nint c, uint userId, uint channelId);
[LibraryImport(LibName)]
internal static partial VcResult vc_create_channel(nint c, in VcChannelInfoNative info);
[LibraryImport(LibName)]
internal static partial VcResult vc_edit_channel(nint c, in VcChannelInfoNative info);
[LibraryImport(LibName)]
internal static partial VcResult vc_delete_channel(nint c, uint channelId);
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
internal static partial VcResult vc_create_account(nint c, string username, string password);
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
internal static partial VcResult vc_reset_password(nint c, string username,
string newPassword);
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
internal static partial VcResult vc_delete_account(nint c, string username);
[LibraryImport(LibName)]
internal static partial VcResult vc_list_accounts(nint c);
[LibraryImport(LibName)]
internal static partial VcResult vc_get_account_list(nint c, out VcAccountListNative outList);
[LibraryImport(LibName)]
internal static partial void vc_free_account_list(ref VcAccountListNative list);
[LibraryImport(LibName)]
internal static partial VcResult vc_get_permissions(nint c, out VcPermissionsNative outPerms);
}
-188
View File
@@ -1,188 +0,0 @@
using System.Runtime.InteropServices;
// Native (blittable) struct layouts mirroring voicecat.h field-for-field. These are the raw
// P/Invoke shapes — NativeMethods.cs uses them directly; Marshaling.cs converts them to the
// managed record types in Models.cs. const char* fields stay IntPtr here (LibraryImport's
// StringMarshalling only auto-converts top-level string parameters/returns, not struct
// fields) and must be hand-marshaled — see Marshaling.cs and VoiceCatClient.cs.
namespace VoiceCat.Interop;
[StructLayout(LayoutKind.Sequential)]
internal struct VcEventNative
{
public VcEventType Type;
public VcConnectionState ConnectionState;
public int Result; // vc_result
public uint UserId;
public uint ChannelId;
public uint StreamId;
public VcTextScope TextScope;
public uint U32a;
public IntPtr Text; // owned by core, valid ONLY for the callback's duration
public ulong TimestampUnixMs;
}
[StructLayout(LayoutKind.Sequential)]
internal struct VcConfigNative
{
public IntPtr ClientName;
public IntPtr ClientVersion;
public VcLogLevel LogLevel;
public IntPtr TofuStorePath;
}
[StructLayout(LayoutKind.Sequential)]
internal struct VcCallbacksNative
{
public IntPtr OnEvent; // delegate* unmanaged<IntPtr, VcEventNative*, void>
public IntPtr OnLevel; // delegate* unmanaged<IntPtr, uint, float, void>
public IntPtr User;
}
[StructLayout(LayoutKind.Sequential)]
internal struct VcStreamDescNative
{
public VcStreamKind Kind;
// Device selection uses vc_set_input_device; stream start passes null.
public IntPtr DeviceId;
public IntPtr Label;
// Mirrors vc_stream_desc::external_feed. When 1, the core skips its own WASAPI loopback
// and the caller feeds PCM via StreamFeedPcm (per-app capture path on Windows).
public int ExternalFeed;
}
[StructLayout(LayoutKind.Sequential)]
internal struct VcAudioConfigNative
{
public uint Codec;
public uint Mode;
public uint SampleRate;
public uint BitrateBps;
public uint FrameMs;
public uint Application;
public int Fec;
public uint ExpectedPacketLoss;
public int Dtx;
public uint Complexity;
public int Dred;
}
[StructLayout(LayoutKind.Sequential)]
internal struct VcPermissionsNative
{
public int CanCreateTempChannel;
public int CanKick;
public int CanBan;
public int CanMoveUsers;
public int CanAdminAccounts;
public int IsAdmin;
}
[StructLayout(LayoutKind.Sequential)]
internal struct VcChannelInfoNative
{
public uint Id;
public uint ParentId;
public IntPtr Name;
public IntPtr Topic;
public int PasswordProtected;
public IntPtr Password;
public uint MaxUsers;
public uint SortOrder;
public VcAudioConfigNative Audio;
}
[StructLayout(LayoutKind.Sequential)]
internal struct VcDeviceNative
{
public IntPtr Id;
public IntPtr Name;
public int IsDefault;
}
[StructLayout(LayoutKind.Sequential)]
internal struct VcDeviceListNative
{
public IntPtr Items;
public nuint Count;
}
[StructLayout(LayoutKind.Sequential)]
internal struct VcChannelNative
{
public uint Id;
public uint ParentId;
public IntPtr Name;
public IntPtr Topic;
public int PasswordProtected;
public uint MaxUsers;
public uint SortOrder;
public VcAudioConfigNative Audio;
}
[StructLayout(LayoutKind.Sequential)]
internal struct VcChannelListNative
{
public IntPtr Items;
public nuint Count;
}
[StructLayout(LayoutKind.Sequential)]
internal struct VcUserNative
{
public uint Id;
public IntPtr Nickname;
public int IsGuest;
public uint ChannelId;
public int SelfMicMuted;
public int SelfDeafened;
public int ServerMuted;
public int ServerDeafened;
public int VoiceSubscribed;
}
[StructLayout(LayoutKind.Sequential)]
internal struct VcUserListNative
{
public IntPtr Items;
public nuint Count;
}
[StructLayout(LayoutKind.Sequential)]
internal struct VcStreamSummaryNative
{
public uint StreamId;
public VcStreamKind Kind;
public IntPtr Label;
}
[StructLayout(LayoutKind.Sequential)]
internal struct VcStreamSummaryListNative
{
public IntPtr Items;
public nuint Count;
}
[StructLayout(LayoutKind.Sequential)]
internal struct VcRemoteStreamStateNative
{
public float Gain;
public int Muted;
public int NoiseReduction;
}
[StructLayout(LayoutKind.Sequential)]
internal struct VcAccountNative
{
public IntPtr Username;
public int IsAdmin;
public ulong CreatedAtUnixMs;
public ulong LastLoginUnixMs;
}
[StructLayout(LayoutKind.Sequential)]
internal struct VcAccountListNative
{
public IntPtr Items;
public nuint Count;
}
@@ -1,14 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- Native function pointers (NativeCallbacks.cs) and struct-array pointer walking
(Marshaling.cs) need unsafe blocks. -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<RootNamespace>VoiceCat.Interop</RootNamespace>
</PropertyGroup>
<ItemGroup><InternalsVisibleTo Include="VoiceCat.Interop.Tests" /></ItemGroup>
</Project>
@@ -1,441 +0,0 @@
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)
{
_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 ───────────────────────────────────────────────────────────
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).</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);
// ── 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; }
}
}
@@ -1,18 +0,0 @@
using Microsoft.Win32.SafeHandles;
// Standard SafeHandle pattern for vc_client* — guarantees vc_client_destroy runs even on an
// unhandled exception or a finalizer pass, which a bare `nint` field would not.
namespace VoiceCat.Interop;
internal sealed class VoiceCatClientHandle : SafeHandleZeroOrMinusOneIsInvalid
{
public VoiceCatClientHandle() : base(ownsHandle: true) { }
public new void SetHandle(nint handle) => base.SetHandle(handle);
protected override bool ReleaseHandle()
{
NativeMethods.vc_client_destroy(handle);
return true;
}
}
@@ -1,32 +0,0 @@
using System.Runtime.InteropServices;
namespace VoiceCat.Interop;
/// <summary>
/// Managed copy of a vc_event — safe to hold/queue past the native callback's return (unlike
/// VcEventNative, whose Text pointer is only valid during the callback).
/// </summary>
public sealed record VoiceCatEvent(
VcEventType Type,
VcConnectionState ConnectionState,
VcResult Result,
uint UserId,
uint ChannelId,
uint StreamId,
VcTextScope TextScope,
uint U32a,
string? Text,
ulong TimestampUnixMs)
{
internal static VoiceCatEvent FromNative(in VcEventNative ev) => new(
ev.Type,
ev.ConnectionState,
(VcResult)ev.Result,
ev.UserId,
ev.ChannelId,
ev.StreamId,
ev.TextScope,
ev.U32a,
ev.Text == IntPtr.Zero ? null : Marshal.PtrToStringUTF8(ev.Text),
ev.TimestampUnixMs);
}
@@ -1,6 +1,6 @@
using Voicecat.V1;
namespace VoiceCat.Interop;
namespace VoiceCat.Windows;
public sealed partial class VoiceCatClient
{
@@ -1,7 +1,7 @@
using VoiceCat.Audio;
using Voicecat.V1;
namespace VoiceCat.Interop;
namespace VoiceCat.Windows;
public sealed partial class VoiceCatClient
{
@@ -1,7 +1,5 @@
// Mirrors of voicecat.h's enums. Keep these in lockstep with core/include/voicecat.h —
// values are append-only per the C ABI's house rule, so it's safe to add new members at the
// end here too, but never renumber/remove existing ones.
namespace VoiceCat.Interop;
// Windows-facing state and command values used by the WinForms application.
namespace VoiceCat.Windows;
public enum VcResult
{
@@ -1,7 +1,5 @@
// Plain managed record types — what survives past the native struct/free-list lifetime
// (Marshaling.cs converts the native *Native structs into these and immediately frees the
// native list). Nothing here holds an IntPtr.
namespace VoiceCat.Interop;
// Windows-facing records used by the WinForms application.
namespace VoiceCat.Windows;
public sealed record ChannelInfo(
uint Id,
@@ -7,8 +7,6 @@
<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" />
<ProjectReference Include="../../../src/VoiceCat.Core/VoiceCat.Core.csproj" />
</ItemGroup>
</Project>
@@ -7,7 +7,7 @@ using VoiceCat.Crypto;
using Voicecat.V1;
using CoreClient = VoiceCat.Core.VoiceCatClient;
namespace VoiceCat.Interop;
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.
@@ -1,4 +1,4 @@
namespace VoiceCat.Interop;
namespace VoiceCat.Windows;
// Compatibility event consumed by WinForms. The managed core owns protocol and audio;
// this assembly contains no libvoicecat bindings or native handles.
+7 -7
View File
@@ -1,12 +1,12 @@
<Solution>
<Folder Name="/Managed core/">
<Project Path="../../dotnet/src/VoiceCat.Core/VoiceCat.Core.csproj" />
<Project Path="../../dotnet/src/VoiceCat.Audio/VoiceCat.Audio.csproj" />
<Project Path="../../dotnet/src/VoiceCat.Codec/VoiceCat.Codec.csproj" />
<Project Path="../../dotnet/src/VoiceCat.Crypto/VoiceCat.Crypto.csproj" />
<Project Path="../../dotnet/src/VoiceCat.Dsp/VoiceCat.Dsp.csproj" />
<Project Path="../../dotnet/src/VoiceCat.Protocol/VoiceCat.Protocol.csproj" />
<Project Path="../../src/VoiceCat.Core/VoiceCat.Core.csproj" />
<Project Path="../../src/VoiceCat.Audio/VoiceCat.Audio.csproj" />
<Project Path="../../src/VoiceCat.Codec/VoiceCat.Codec.csproj" />
<Project Path="../../src/VoiceCat.Crypto/VoiceCat.Crypto.csproj" />
<Project Path="../../src/VoiceCat.Dsp/VoiceCat.Dsp.csproj" />
<Project Path="../../src/VoiceCat.Protocol/VoiceCat.Protocol.csproj" />
</Folder>
<Project Path="VoiceCat.App/VoiceCat.App.csproj" />
<Project Path="VoiceCat.Managed/VoiceCat.Managed.csproj" />
<Project Path="VoiceCat.Windows/VoiceCat.Windows.csproj" />
</Solution>
+1 -2
View File
@@ -4,12 +4,11 @@ param(
)
$ErrorActionPreference = "Stop"
$repoRoot = (Resolve-Path "$PSScriptRoot/../..").Path
if ([string]::IsNullOrWhiteSpace($Output)) { $Output = "$repoRoot/dotnet/artifacts/client/$RuntimeIdentifier" }
if ([string]::IsNullOrWhiteSpace($Output)) { $Output = "$repoRoot/artifacts/client/$RuntimeIdentifier" }
dotnet publish "$PSScriptRoot/VoiceCat.App/VoiceCat.App.csproj" -c Release -r $RuntimeIdentifier --self-contained true `
-p:PublishSingleFile=true -p:PublishTrimmed=false -p:IncludeNativeLibrariesForSelfExtract=false `
-p:RestorePackagesWithLockFile=true -p:RestoreLockedMode=true `
"-p:NuGetLockFilePath=packages.publish.$RuntimeIdentifier.lock.json" -o $Output
if ($LASTEXITCODE -ne 0) { throw "Managed Windows client publish failed." }
if (-not (Test-Path "$Output/voicecat_media.dll")) { throw "Managed media shim was not published." }
if (Test-Path "$Output/voicecat.dll") { throw "Legacy VoiceCat native core must not be published." }
Write-Host "Published managed Windows client to $Output"