Add managed macOS Core Audio voice path
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / apple-client (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s

This commit is contained in:
2026-09-16 22:05:40 +02:00
parent e5ff484cac
commit edd7783a5c
8 changed files with 314 additions and 34 deletions
+15 -12
View File
@@ -10,18 +10,21 @@ up instantly. Newest status at the top.
## ▶ Where we left off / next action
- **In progress (2026-09-16): managed macOS client started.** Added a separate .NET 10
AppKit solution with native application/menu lifecycle, guest connection, explicit TOFU
approval, channel selection, roster, channel chat, voice subscription, disconnect state,
accessibility labels, sandbox entitlements and deterministic client disposal. The shared
protocol, crypto, audio and client-core projects are referenced directly; the Swift app
remains the release client while parity work continues. Added a macOS CI gate that builds
the native Opus/RNNoise shim and the AppKit solution. Local Windows compilation of AppKit
remains unavailable: installing the macOS workload rolled back after the machine's
Visual Studio workload manager failed while repairing an unrelated iOS/Android MSI.
**Next:** use the macOS CI compiler to correct any binding issues, then add Core Audio
playback/microphone capture and start/stop the managed microphone stream. Follow with
saved accounts/servers, private messages, moderation/settings, ScreenCaptureKit sharing,
- **In progress (2026-09-16): managed macOS client and default audio path.** Added a
separate .NET 10 AppKit solution with application/menu lifecycle, guest connection,
explicit TOFU approval, channel selection, roster/chat, disconnect state, accessibility
labels, sandbox entitlements and deterministic disposal. Voice join now opens bounded
stereo playback, subscribes, announces a managed microphone stream, converts the default
Core Audio input to interleaved 48 kHz int16 and feeds `VoiceCat.Audio`; leave/disconnect
tears the path down. Channel changes suspend and resume voice so streams renegotiate.
Playback uses `AVAudioSourceNode` plus the shared tested `PcmRing`; the render callback
allocates no managed memory and never locks or blocks. Current AppKit/AVFoundation calls
compile warning-free against Microsoft's 26.4.10259 macOS reference assembly. Added a
macOS CI gate for the real workload, native Opus/RNNoise shim and Apple solution. Local
workload installation remains blocked by this Windows machine's unrelated Visual Studio
iOS/Android MSI repair failure, so device execution is not claimed. **Next:** run the
macOS CI/device capture-playback test and ten-minute listen gate, then add saved accounts/
servers, non-default devices, private messages, moderation/settings, ScreenCaptureKit,
VoiceOver verification, signing and notarization.
- **Done (2026-09-16): Linux production packaging checkpoint.** Added a real TLS 1.3
+3 -2
View File
@@ -2,14 +2,15 @@
`VoiceCat.Mac` is the native AppKit C# port. It targets `net10.0-macos` and references the same `VoiceCat.Core` and `VoiceCat.Audio` assemblies used by the Windows client and managed CLI.
The current checkpoint is a functional guest-client shell: AppKit launch/menu lifecycle, host and nickname entry, explicit TOFU approval (including changed-key warning), channel selection, user roster, channel text, voice subscription, disconnect reporting, native accessibility labels, sandbox/network/audio entitlements, and deterministic disposal. It does not yet replace the Swift release. CoreAudio capture/playback, saved servers/accounts, moderation sheets, private messages, settings, ScreenCaptureKit sharing, VoiceOver verification, signing and notarization remain.
The current checkpoint is a functional guest client: AppKit launch/menu lifecycle, host and nickname entry, explicit TOFU approval (including changed-key warning), channel selection, user roster, channel text, voice subscription, managed microphone-stream lifecycle, default-device Core Audio capture/playback, disconnect reporting, native accessibility labels, sandbox/network/audio entitlements, and deterministic disposal. Capture is converted to interleaved 48 kHz int16 PCM through `AVAudioConverter`; playback uses an `AVAudioSourceNode` and the shared bounded `PcmRing`, so its render callback does not allocate, lock, or block. It does not yet replace the Swift release. Saved servers/accounts, non-default device selection, moderation sheets, private messages, settings, ScreenCaptureKit sharing, VoiceOver verification, signing and notarization remain.
Build on Apple Silicon macOS 15.6+ with Xcode 26 and the .NET 10 macOS workload:
```bash
dotnet workload install macos
pwsh ./dotnet/build-native.ps1
dotnet restore clients/apple/dotnet/VoiceCat.Apple.slnx
dotnet build clients/apple/dotnet/VoiceCat.Apple.slnx -c Debug
```
The repository's `dotnet/build-native.ps1` must first stage an `osx-arm64` `libvoicecat_media.dylib`; that shim contains only Opus/RNNoise. Windows and Linux cannot compile or validate AppKit bindings, so the macOS build is a required CI/release gate.
The native build stages an `osx-arm64` `libvoicecat_media.dylib`; that shim contains only Opus/RNNoise. The AppKit and AVFoundation calls are also compiled against Microsoft's current macOS reference assembly during development, but only a macOS host can link, launch, grant microphone access and verify live devices. The macOS CI build and manual listen test are required release gates.
@@ -11,7 +11,6 @@ internal sealed class AppDelegate : NSApplicationDelegate
NSApplication.SharedApplication.ActivationPolicy = NSApplicationActivationPolicy.Regular;
BuildMenu();
connect = new(); connect.ShowWindow(this);
NSApplication.SharedApplication.ActivateIgnoringOtherApps(true);
}
public override bool ApplicationShouldTerminateAfterLastWindowClosed(NSApplication sender) => true;
private static void BuildMenu()
@@ -9,7 +9,7 @@ namespace VoiceCat.Mac;
internal sealed class ConnectWindowController : NSWindowController
{
private readonly NSTextField host = new(new CGRect(120, 180, 280, 26)) { StringValue = "127.0.0.1:8384", PlaceholderString = "Host:port" };
private readonly NSTextField nickname = new(new CGRect(120, 140, 280, 26)) { StringValue = NSProcessInfo.ProcessInfo.UserName, PlaceholderString = "Nickname" };
private readonly NSTextField nickname = new(new CGRect(120, 140, 280, 26)) { StringValue = Environment.UserName, PlaceholderString = "Nickname" };
private readonly NSTextField status = NSTextField.CreateLabel("Ready to connect");
private readonly NSButton connect = new(new CGRect(300, 55, 100, 32)) { Title = "Connect", BezelStyle = NSBezelStyle.Rounded };
private VoiceCatClient? client;
@@ -23,7 +23,7 @@ internal sealed class ConnectWindowController : NSWindowController
var hostLabel = NSTextField.CreateLabel("Server"); hostLabel.Frame = new CGRect(30, 185, 80, 20); view.AddSubview(hostLabel); view.AddSubview(host);
var nicknameLabel = NSTextField.CreateLabel("Nickname"); nicknameLabel.Frame = new CGRect(30, 145, 80, 20); view.AddSubview(nicknameLabel); view.AddSubview(nickname);
status.Frame = new CGRect(30, 95, 370, 22); status.AccessibilityLabel = "Connection status"; view.AddSubview(status);
connect.AccessibilityLabel = "Connect to server"; connect.Activated += Connect; view.AddSubview(connect);
((INSAccessibility)connect).AccessibilityLabel = "Connect to server"; connect.Activated += Connect; view.AddSubview(connect);
Window.DefaultButtonCell = connect.Cell;
}
@@ -0,0 +1,200 @@
using System.Runtime.InteropServices;
using AVFoundation;
using Foundation;
using VoiceCat.Audio;
namespace VoiceCat.Mac;
internal sealed class MacAudioBackend : IAudioDeviceBackend
{
private static readonly AudioDeviceInfo[] DefaultInput = [new("", "System default microphone", true)];
private static readonly AudioDeviceInfo[] DefaultOutput = [new("", "System default output", true)];
private Exception? failure;
internal Exception? Failure => Volatile.Read(ref failure);
public IReadOnlyList<AudioDeviceInfo> Enumerate(bool input) => input ? DefaultInput : DefaultOutput;
public IAudioCapture OpenCapture(string? deviceId, bool loopback, CapturePcmHandler pcm)
{
if (loopback) throw new NotSupportedException("Screen audio uses ScreenCaptureKit, not microphone capture.");
if (!string.IsNullOrEmpty(deviceId)) throw new NotSupportedException("Selecting a non-default Core Audio input is not implemented yet.");
return new Capture(pcm, ReportFailure);
}
public IAudioPlayback OpenPlayback(string? deviceId = null)
{
if (!string.IsNullOrEmpty(deviceId)) throw new NotSupportedException("Selecting a non-default Core Audio output is not implemented yet.");
return new Playback(ReportFailure);
}
private void ReportFailure(Exception exception) => Interlocked.CompareExchange(ref failure, exception, null);
private sealed class Capture : IAudioCapture
{
private readonly CapturePcmHandler handler;
private readonly AVAudioEngine engine = new();
private readonly AVAudioFormat outputFormat;
private readonly AVAudioConverter converter;
private readonly AVAudioPcmBuffer converted;
private readonly AVAudioConverterInputHandler inputProvider;
private readonly Action<Exception> reportFailure;
private AVAudioPcmBuffer? pendingInput;
private bool inputProvided;
private int disposed;
internal Capture(CapturePcmHandler handler, Action<Exception> reportFailure)
{
this.handler = handler;
this.reportFailure = reportFailure;
AVAudioInputNode input = engine.InputNode;
AVAudioFormat inputFormat = input.GetBusOutputFormat(0);
uint channels = Math.Clamp(inputFormat.ChannelCount, 1u, 2u);
outputFormat = new(AVAudioCommonFormat.PCMInt16, 48_000, channels, true);
converter = new(inputFormat, outputFormat);
uint outputCapacity = checked((uint)Math.Ceiling(4_096 * 48_000 / inputFormat.SampleRate) + 64);
converted = new(outputFormat, outputCapacity);
inputProvider = ProvideInput;
input.InstallTapOnBus(0, 960, inputFormat, Convert);
engine.Prepare();
if (!engine.StartAndReturnError(out var error))
{
input.RemoveTapOnBus(0);
converted.Dispose();
converter.Dispose();
outputFormat.Dispose();
engine.Dispose();
throw new InvalidOperationException("Core Audio capture could not start: " + error.LocalizedDescription);
}
}
private unsafe void Convert(AVAudioPcmBuffer input, AVAudioTime _time)
{
try
{
pendingInput = input;
inputProvided = false;
converted.FrameLength = 0;
AVAudioConverterOutputStatus result = converter.ConvertToBuffer(converted, out NSError? error, inputProvider);
if (result == AVAudioConverterOutputStatus.Error)
{
reportFailure(new InvalidOperationException("Core Audio input conversion failed: " + error?.LocalizedDescription));
return;
}
if (converted.FrameLength == 0) return;
nint samples = Marshal.ReadIntPtr(converted.Int16ChannelData);
if (samples == 0) return;
int channels = checked((int)outputFormat.ChannelCount);
int remainingFrames = checked((int)converted.FrameLength);
var pcm = new ReadOnlySpan<short>((void*)samples, checked(remainingFrames * channels));
while (remainingFrames > 0)
{
int frames = Math.Min(960, remainingFrames);
handler(pcm[..(frames * channels)], channels);
pcm = pcm[(frames * channels)..];
remainingFrames -= frames;
}
}
catch (Exception exception) { reportFailure(exception); }
finally { pendingInput = null; }
}
private AVAudioBuffer ProvideInput(uint _, out AVAudioConverterInputStatus status)
{
if (!inputProvided && pendingInput is { } input)
{
inputProvided = true;
status = AVAudioConverterInputStatus.HaveData;
return input;
}
status = AVAudioConverterInputStatus.NoDataNow;
return null!;
}
public void Dispose()
{
if (Interlocked.Exchange(ref disposed, 1) != 0) return;
engine.InputNode.RemoveTapOnBus(0);
engine.Stop();
converted.Dispose();
converter.Dispose();
outputFormat.Dispose();
engine.Dispose();
}
}
private sealed class Playback : IAudioPlayback
{
private readonly PcmRing pcm = new(32_768);
private readonly AVAudioEngine engine = new();
private readonly AVAudioFormat format;
private readonly AVAudioSourceNode source;
private readonly Action<Exception> reportFailure;
private int callbackFailed;
private int disposed;
internal Playback(Action<Exception> reportFailure)
{
this.reportFailure = reportFailure;
format = new(AVAudioCommonFormat.PCMInt16, 48_000, 2, true);
source = new(format, Render);
engine.AttachNode(source);
engine.Connect(source, engine.MainMixerNode, format);
engine.Prepare();
if (!engine.StartAndReturnError(out var error))
{
engine.DetachNode(source);
source.Dispose();
format.Dispose();
engine.Dispose();
throw new InvalidOperationException("Core Audio playback could not start: " + error.LocalizedDescription);
}
}
public void Write(ReadOnlySpan<short> stereoPcm) => pcm.TryWrite(stereoPcm);
private unsafe int Render(IntPtr isSilence, IntPtr _, uint frameCount, IntPtr outputData)
{
try
{
int bufferCount = Marshal.ReadInt32(outputData);
int firstBuffer = IntPtr.Size == 8 ? 8 : 4;
if (bufferCount != 1) return Fail("Core Audio returned a non-interleaved playback buffer.");
int channels = Marshal.ReadInt32(outputData, firstBuffer);
int byteCount = Marshal.ReadInt32(outputData, firstBuffer + 4);
nint data = Marshal.ReadIntPtr(outputData, firstBuffer + 8);
int requested = checked((int)frameCount * 2);
if (channels != 2 || data == 0 || byteCount < requested * sizeof(short)) return Fail("Core Audio returned an invalid stereo playback buffer.");
var output = new Span<short>((void*)data, requested);
Span<short> discard = stackalloc short[1_920];
while (pcm.Count > 11_520) pcm.Read(discard[..Math.Min(discard.Length, pcm.Count - 11_520)]);
int read = pcm.Read(output);
output[read..].Clear();
if (isSilence != IntPtr.Zero) Marshal.WriteByte(isSilence, read == 0 ? (byte)1 : (byte)0);
return 0;
}
catch (Exception exception)
{
if (Interlocked.Exchange(ref callbackFailed, 1) == 0) reportFailure(exception);
return -1;
}
}
private int Fail(string message)
{
if (Interlocked.Exchange(ref callbackFailed, 1) == 0) reportFailure(new InvalidOperationException(message));
return -1;
}
public void Dispose()
{
if (Interlocked.Exchange(ref disposed, 1) != 0) return;
engine.Stop();
engine.DetachNode(source);
source.Dispose();
format.Dispose();
engine.Dispose();
}
}
}
@@ -1,6 +1,7 @@
using AppKit;
using CoreGraphics;
using Foundation;
using VoiceCat.Audio;
using VoiceCat.Core;
using Voicecat.V1;
@@ -9,6 +10,7 @@ namespace VoiceCat.Mac;
internal sealed class MainWindowController : NSWindowController
{
private readonly VoiceCatClient client;
private readonly MacAudioBackend audioBackend = new();
private readonly uint selfId;
private readonly NSPopUpButton channels = new(new CGRect(20, 515, 300, 28), false);
private readonly NSTextView users = new(new CGRect(0, 0, 190, 430)) { Editable = false, Selectable = true };
@@ -19,7 +21,10 @@ internal sealed class MainWindowController : NSWindowController
private readonly NSTextField status = NSTextField.CreateLabel("Connected");
private readonly NSTimer timer;
private uint currentChannel = 1;
private uint microphoneStreamId;
private bool joinedVoice;
private IAudioCapture? microphone;
private IAudioPlayback? playback;
internal MainWindowController(VoiceCatClient client, uint selfId, string nickname) : base(new NSWindow(new CGRect(0, 0, 760, 570),
NSWindowStyle.Titled | NSWindowStyle.Closable | NSWindowStyle.Resizable | NSWindowStyle.Miniaturizable, NSBackingStore.Buffered, false))
@@ -27,12 +32,12 @@ internal sealed class MainWindowController : NSWindowController
this.client = client; this.selfId = selfId;
Window!.Title = $"VoiceCat — {nickname}"; Window.Center(); Window.MinSize = new CGSize(680, 480);
var content = Window.ContentView!;
channels.AccessibilityLabel = "Channel"; channels.Activated += ChangeChannel; content.AddSubview(channels);
voice.AccessibilityLabel = "Join or leave voice"; voice.Activated += ToggleVoice; content.AddSubview(voice);
((INSAccessibility)channels).AccessibilityLabel = "Channel"; channels.Activated += ChangeChannel; content.AddSubview(channels);
((INSAccessibility)voice).AccessibilityLabel = "Join or leave voice"; voice.Activated += ToggleVoice; content.AddSubview(voice);
var userScroll = new NSScrollView(new CGRect(20, 90, 190, 410)) { HasVerticalScroller = true, DocumentView = users }; userScroll.AccessibilityLabel = "Users in channel"; content.AddSubview(userScroll);
var chatScroll = new NSScrollView(new CGRect(230, 90, 500, 410)) { HasVerticalScroller = true, DocumentView = chat }; chatScroll.AccessibilityLabel = "Channel messages"; content.AddSubview(chatScroll);
compose.AccessibilityLabel = "Message"; compose.Activated += Send; content.AddSubview(compose);
send.AccessibilityLabel = "Send message"; send.Activated += Send; content.AddSubview(send);
((INSAccessibility)compose).AccessibilityLabel = "Message"; compose.Activated += Send; content.AddSubview(compose);
((INSAccessibility)send).AccessibilityLabel = "Send message"; send.Activated += Send; content.AddSubview(send);
status.Frame = new CGRect(20, 22, 700, 22); status.AccessibilityLabel = "Connection status"; content.AddSubview(status);
timer = NSTimer.CreateRepeatingScheduledTimer(TimeSpan.FromMilliseconds(50), _ => Pump());
RefreshState();
@@ -44,13 +49,17 @@ internal sealed class MainWindowController : NSWindowController
{
if (envelope!.TextMessage is { } text) Append($"[{DateTime.Now:t}] {Name(text.SenderId)}: {text.Body}");
if (envelope.ServerState is not null || envelope.ChannelEvent is not null || envelope.UserEvent is not null) RefreshState();
if (envelope.Disconnect is { } disconnected) { status.StringValue = "Disconnected: " + disconnected.Reason; voice.Enabled = send.Enabled = false; }
if (envelope.Disconnect is { } disconnected)
{
StopAudioDevices(); joinedVoice = false; voice.Title = "Join voice";
status.StringValue = "Disconnected: " + disconnected.Reason; voice.Enabled = send.Enabled = false;
}
}
if (client.Audio.Failure is { } failure) status.StringValue = "Audio stopped: " + failure.Message;
if (audioBackend.Failure is { } deviceFailure) status.StringValue = "Audio device stopped: " + deviceFailure.Message;
}
private void RefreshState()
{
string? selected = channels.SelectedItem?.RepresentedObject?.ToString();
channels.RemoveAllItems();
foreach (var channel in client.Channels.OrderBy(c => c.Order).ThenBy(c => c.Name)) { channels.AddItem(channel.Name); channels.LastItem!.RepresentedObject = new NSString(channel.Id.ToString()); }
currentChannel = client.Users.FirstOrDefault(u => u.Id == selfId)?.ChannelId ?? currentChannel;
@@ -61,7 +70,14 @@ internal sealed class MainWindowController : NSWindowController
private async void ChangeChannel(object? sender, EventArgs args)
{
if (!uint.TryParse(channels.SelectedItem?.RepresentedObject?.ToString(), out uint id) || id == currentChannel) return;
try { var result = (await client.RequestAsync(new() { JoinChannel = new() { ChannelId = id } })).JoinChannelResult; if (!result.Ok) status.StringValue = result.Error; }
try
{
bool resumeVoice = joinedVoice;
if (resumeVoice) await LeaveVoice();
var result = (await client.RequestAsync(new() { JoinChannel = new() { ChannelId = id } })).JoinChannelResult;
if (!result.Ok) status.StringValue = result.Error;
if (resumeVoice) await JoinVoice();
}
catch (Exception exception) { status.StringValue = exception.Message; }
}
private void Send(object? sender, EventArgs args)
@@ -74,16 +90,70 @@ internal sealed class MainWindowController : NSWindowController
{
try
{
if (!joinedVoice) { var result = await client.SubscribeVoiceAsync(); if (!result.Ok) throw new InvalidOperationException(result.Error); joinedVoice = true; voice.Title = "Leave voice"; }
else { await client.RequestAsync(new() { UnsubscribeVoice = new() }); joinedVoice = false; voice.Title = "Join voice"; }
voice.Enabled = false;
if (!joinedVoice) await JoinVoice();
else await LeaveVoice();
}
catch (Exception exception) { status.StringValue = exception.Message; }
finally { voice.Enabled = true; }
}
private async Task JoinVoice()
{
var result = await client.SubscribeVoiceAsync();
if (!result.Ok) throw new InvalidOperationException(result.Error);
try
{
playback = audioBackend.OpenPlayback();
client.Audio.MixedPcm += PlayMixedPcm;
StreamInfo stream = await client.StartStreamAsync(StreamKind.StreamMic, "Microphone");
microphoneStreamId = stream.StreamId;
microphone = audioBackend.OpenCapture(null, false, FeedMicrophone);
joinedVoice = true; voice.Title = "Leave voice"; status.StringValue = "Voice connected";
}
catch
{
try { if (microphoneStreamId != 0) client.StopStream(microphoneStreamId); } catch { }
StopAudioDevices();
try { await client.SubscribeVoiceAsync(false); } catch { }
throw;
}
}
private async Task LeaveVoice()
{
uint streamId = microphoneStreamId;
joinedVoice = false; voice.Title = "Join voice";
StopAudioDevices();
if (streamId != 0) client.StopStream(streamId);
await client.SubscribeVoiceAsync(false);
status.StringValue = "Voice disconnected";
}
private void FeedMicrophone(ReadOnlySpan<short> pcm, int channels)
{
uint streamId = microphoneStreamId;
if (streamId != 0) client.Audio.FeedPcm(streamId, pcm, channels);
}
private void PlayMixedPcm(ReadOnlySpan<short> pcm) => Volatile.Read(ref playback)?.Write(pcm);
private void StopAudioDevices()
{
microphoneStreamId = 0;
Interlocked.Exchange(ref microphone, null)?.Dispose();
client.Audio.MixedPcm -= PlayMixedPcm;
Interlocked.Exchange(ref playback, null)?.Dispose();
}
private string Name(uint id) => client.Users.FirstOrDefault(u => u.Id == id)?.Nickname ?? $"User {id}";
private void Append(string line) { chat.Value = chat.Value.Length == 0 ? line : chat.Value + "\n" + line; chat.ScrollToEndOfDocument(this); }
private void Append(string line)
{
chat.Value = chat.Value.Length == 0 ? line : chat.Value + "\n" + line;
chat.ScrollRangeToVisible(new NSRange(chat.Value.Length, 0));
}
protected override void Dispose(bool disposing)
{
if (disposing) { timer.Invalidate(); client.DisposeAsync().AsTask().GetAwaiter().GetResult(); }
if (disposing) { timer.Invalidate(); StopAudioDevices(); client.DisposeAsync().AsTask().GetAwaiter().GetResult(); }
base.Dispose(disposing);
}
}
@@ -6,6 +6,7 @@
<SupportedOSPlatformVersion>14.0</SupportedOSPlatformVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<ApplicationTitle>VoiceCat</ApplicationTitle>
<ApplicationId>net.iamtalon.voicecat</ApplicationId>
<UseHardenedRuntime>true</UseHardenedRuntime>
+12 -6
View File
@@ -851,12 +851,18 @@ Per §8.2. AppKit port, ScreenCaptureKit per-app audio selection, VoiceOver pari
build produced.
**Checkpoint (2026-09-16):** `clients/apple/dotnet/VoiceCat.Mac` is a separate .NET 10
AppKit application that consumes `VoiceCat.Core` directly. Its first shell implements guest
connection, interactive TOFU approval, channel browsing, roster/chat, voice subscription,
disconnect state and native accessibility labels. A macOS CI job stages the shared native
codec/DSP shim and compiles the Apple solution. The existing Swift app remains the release
client until Core Audio input/output, the rest of the account/moderation/settings surface,
ScreenCaptureKit sharing, VoiceOver validation, signing and notarization are complete.
AppKit application that consumes `VoiceCat.Core` directly. It implements guest connection,
interactive TOFU approval, channel browsing, roster/chat, voice subscription, native
accessibility labels and default-device microphone/playback. Core Audio capture is resampled
and converted through `AVAudioConverter` before entering the managed audio engine. Stereo
playback uses `AVAudioSourceNode` and the shared bounded PCM ring; its render callback does
not allocate, lock or block. Voice stream lifetime follows subscription, disconnect and
channel changes. Current Apple API calls compile warning-free against Microsoft's
26.4.10259 macOS reference assembly, and a macOS CI job builds the native codec/DSP shim and
Apple solution. A real macOS device run and listen test remain required. The existing Swift
app remains the release client until saved servers/accounts, selectable devices, the rest of
the moderation/settings surface, ScreenCaptureKit, VoiceOver validation, signing and
notarization are complete.
---