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
.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:
+15
-12
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user