diff --git a/PROGRESS.md b/PROGRESS.md index f5b509d..9f2e35a 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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 diff --git a/clients/apple/dotnet/README.md b/clients/apple/dotnet/README.md index 8c943f6..d13cb64 100644 --- a/clients/apple/dotnet/README.md +++ b/clients/apple/dotnet/README.md @@ -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. diff --git a/clients/apple/dotnet/VoiceCat.Mac/AppDelegate.cs b/clients/apple/dotnet/VoiceCat.Mac/AppDelegate.cs index 8298250..2c3e3eb 100644 --- a/clients/apple/dotnet/VoiceCat.Mac/AppDelegate.cs +++ b/clients/apple/dotnet/VoiceCat.Mac/AppDelegate.cs @@ -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() diff --git a/clients/apple/dotnet/VoiceCat.Mac/ConnectWindowController.cs b/clients/apple/dotnet/VoiceCat.Mac/ConnectWindowController.cs index efa0bf1..04302f7 100644 --- a/clients/apple/dotnet/VoiceCat.Mac/ConnectWindowController.cs +++ b/clients/apple/dotnet/VoiceCat.Mac/ConnectWindowController.cs @@ -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; } diff --git a/clients/apple/dotnet/VoiceCat.Mac/MacAudioBackend.cs b/clients/apple/dotnet/VoiceCat.Mac/MacAudioBackend.cs new file mode 100644 index 0000000..979db97 --- /dev/null +++ b/clients/apple/dotnet/VoiceCat.Mac/MacAudioBackend.cs @@ -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 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 reportFailure; + private AVAudioPcmBuffer? pendingInput; + private bool inputProvided; + private int disposed; + + internal Capture(CapturePcmHandler handler, Action 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((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 reportFailure; + private int callbackFailed; + private int disposed; + + internal Playback(Action 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 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((void*)data, requested); + Span 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(); + } + } + +} diff --git a/clients/apple/dotnet/VoiceCat.Mac/MainWindowController.cs b/clients/apple/dotnet/VoiceCat.Mac/MainWindowController.cs index 010dbb4..627716f 100644 --- a/clients/apple/dotnet/VoiceCat.Mac/MainWindowController.cs +++ b/clients/apple/dotnet/VoiceCat.Mac/MainWindowController.cs @@ -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 pcm, int channels) + { + uint streamId = microphoneStreamId; + if (streamId != 0) client.Audio.FeedPcm(streamId, pcm, channels); + } + + private void PlayMixedPcm(ReadOnlySpan 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); } } diff --git a/clients/apple/dotnet/VoiceCat.Mac/VoiceCat.Mac.csproj b/clients/apple/dotnet/VoiceCat.Mac/VoiceCat.Mac.csproj index 9fa36ea..068b0ff 100644 --- a/clients/apple/dotnet/VoiceCat.Mac/VoiceCat.Mac.csproj +++ b/clients/apple/dotnet/VoiceCat.Mac/VoiceCat.Mac.csproj @@ -6,6 +6,7 @@ 14.0 enable enable + true VoiceCat net.iamtalon.voicecat true diff --git a/docs/porting-to-dotnet.md b/docs/porting-to-dotnet.md index 976ad61..9f448bc 100644 --- a/docs/porting-to-dotnet.md +++ b/docs/porting-to-dotnet.md @@ -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. ---