feat: external PCM feed/tap API (vc_stream_feed_pcm + vc_set_pcm_sink)
Promotes vc_test_inject_capture (mono-only, TEST-ONLY) to a public, stereo-capable production API and adds a symmetric PCM tap on the receive side. Enables ReplayKit (iOS), ScreenCaptureKit (macOS), bots, soundboards, and custom clients — all without a hardware audio device. Core C++: - voicecat.h: new vc_stream_feed_pcm, vc_pcm_sink_cb typedef, vc_set_pcm_sink; vc_test_inject_capture kept as deprecated alias - audio_engine: stereo-aware inject_capture (channels param + ring reset on channel-count change); atomic pcm_sink_ fired per decoded frame in on_playback; RemoteStream carries user_id/stream_id for RT-safe sink metadata; init_recv_stream takes user_id+stream_id - client.cpp: stream_feed_pcm / set_pcm_sink implementations; sync_remote_streams passes user_id/stream_id to init_recv_stream - voicecat.cpp: trampolines + channels=1/2 validation Tests: test_external_pcm (headless, 3 sub-tests: mono round-trip, stereo feed L≠R, sink metadata+disable). ctest 23/23. Swift: feedPcm / setPcmSink in VoiceCatClient.swift + 4 XCTest smoke tests (ExternalPcmTests.swift). C#: StreamFeedPcm / SetPcmSink in VoiceCatClient.cs + NativeMethods.cs (vc_stream_feed_pcm unsafe P/Invoke, VcPcmSinkCallback delegate, vc_set_pcm_sink via nint) + 4 xUnit smoke tests (ExternalPcmTests.cs). Docs: architecture.md §4 new subsection, voice.md §9 updated (macOS/iOS now reference vc_stream_feed_pcm), protocol.md §8 explicit no-protocol-change note, roadmap.md M5 entry. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
55
clients/windows/VoiceCat.Interop.Tests/ExternalPcmTests.cs
Normal file
55
clients/windows/VoiceCat.Interop.Tests/ExternalPcmTests.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,25 @@ internal static partial class NativeMethods
|
||||
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,
|
||||
|
||||
@@ -225,6 +225,20 @@ public sealed class VoiceCatClient : IDisposable
|
||||
return (r, r == VcResult.Ok ? Marshaling.ToManaged(in native) : null);
|
||||
}
|
||||
|
||||
// ── External PCM feed / tap ─────────────────────────────────────────────────────────
|
||||
public unsafe VcResult StreamFeedPcm(uint streamId, ReadOnlySpan<short> pcm,
|
||||
int samplesPerChannel, uint channels)
|
||||
{
|
||||
fixed (short* p = pcm)
|
||||
return NativeMethods.vc_stream_feed_pcm(_handle.DangerousGetHandle(),
|
||||
streamId, p, (nuint)samplesPerChannel, channels);
|
||||
}
|
||||
|
||||
// Pass Marshal.GetFunctionPointerForDelegate(cb) for a managed delegate, or
|
||||
// IntPtr.Zero to disable. Keep the delegate alive for the lifetime of the registration.
|
||||
public VcResult SetPcmSink(nint cb, IntPtr user) =>
|
||||
NativeMethods.vc_set_pcm_sink(_handle.DangerousGetHandle(), cb, user);
|
||||
|
||||
// ── M5: Moderation & admin ───────────────────────────────────────────────────────────
|
||||
public VcResult KickUser(uint userId, string? reason = null) =>
|
||||
NativeMethods.vc_kick_user(_handle.DangerousGetHandle(), userId, reason);
|
||||
|
||||
Reference in New Issue
Block a user