Complete managed macOS platform bring-up
.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:
@@ -1,4 +1,5 @@
|
||||
using AppKit;
|
||||
using AVFoundation;
|
||||
using CoreGraphics;
|
||||
using Foundation;
|
||||
using VoiceCat.Audio;
|
||||
@@ -12,7 +13,9 @@ 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 NSPopUpButton channels = new(new CGRect(20, 515, 245, 28), false);
|
||||
private readonly NSPopUpButton inputDevice = new(new CGRect(275, 515, 165, 28), false);
|
||||
private readonly NSPopUpButton outputDevice = new(new CGRect(450, 515, 165, 28), false);
|
||||
private readonly NSTextView users = new(new CGRect(0, 0, 190, 430)) { Editable = false, Selectable = true };
|
||||
private readonly NSTextView chat = new(new CGRect(0, 0, 510, 390)) { Editable = false, Selectable = true };
|
||||
private readonly NSPopUpButton messageTarget = new(new CGRect(230, 55, 160, 28), false);
|
||||
@@ -27,6 +30,7 @@ internal sealed class MainWindowController : NSWindowController
|
||||
private bool changingChannel;
|
||||
private IAudioCapture? microphone;
|
||||
private IAudioPlayback? playback;
|
||||
private long lastRemoteAudioTick;
|
||||
|
||||
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))
|
||||
@@ -35,6 +39,9 @@ internal sealed class MainWindowController : NSWindowController
|
||||
Window!.Title = $"VoiceCat — {nickname}"; Window.Center(); Window.MinSize = new CGSize(680, 480);
|
||||
var content = Window.ContentView!;
|
||||
((INSAccessibility)channels).AccessibilityLabel = "Channel"; channels.Activated += ChangeChannel; content.AddSubview(channels);
|
||||
PopulateAudioDevices(inputDevice, true); PopulateAudioDevices(outputDevice, false);
|
||||
((INSAccessibility)inputDevice).AccessibilityLabel = "Microphone input device"; inputDevice.Activated += ChangeAudioDevice; content.AddSubview(inputDevice);
|
||||
((INSAccessibility)outputDevice).AccessibilityLabel = "Audio output device"; outputDevice.Activated += ChangeAudioDevice; content.AddSubview(outputDevice);
|
||||
((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);
|
||||
@@ -58,8 +65,14 @@ internal sealed class MainWindowController : NSWindowController
|
||||
status.StringValue = "Disconnected: " + disconnected.Reason; voice.Enabled = send.Enabled = messageTarget.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;
|
||||
if (client.Audio.Failure is { } failure) { status.StringValue = "Audio stopped: " + failure.Message; return; }
|
||||
if (audioBackend.Failure is { } deviceFailure) { status.StringValue = "Audio device stopped: " + deviceFailure.Message; return; }
|
||||
if (joinedVoice)
|
||||
{
|
||||
(float level, bool talking) = client.Audio.GetLocalLevel(microphoneStreamId);
|
||||
bool receiving = Environment.TickCount64 - Volatile.Read(ref lastRemoteAudioTick) < 1_000;
|
||||
status.StringValue = $"Voice connected · Mic {(talking ? "sending" : "idle")} {level:P0} · Remote audio {(receiving ? "active" : "idle")}";
|
||||
}
|
||||
}
|
||||
private void RefreshState()
|
||||
{
|
||||
@@ -148,17 +161,46 @@ internal sealed class MainWindowController : NSWindowController
|
||||
finally { voice.Enabled = true; }
|
||||
}
|
||||
|
||||
private void PopulateAudioDevices(NSPopUpButton menu, bool input)
|
||||
{
|
||||
IReadOnlyList<AudioDeviceInfo> devices = audioBackend.Enumerate(input);
|
||||
foreach (AudioDeviceInfo device in devices)
|
||||
{
|
||||
menu.AddItem(device.Name + (device.IsDefault ? " (default)" : ""));
|
||||
menu.LastItem!.RepresentedObject = new NSString(device.Id);
|
||||
}
|
||||
int selected = devices.ToList().FindIndex(device => device.IsDefault);
|
||||
if (selected >= 0) menu.SelectItem(selected);
|
||||
menu.Enabled = devices.Count > 0;
|
||||
}
|
||||
|
||||
private async void ChangeAudioDevice(object? sender, EventArgs args)
|
||||
{
|
||||
if (!joinedVoice) return;
|
||||
try { await LeaveVoice(); await JoinVoice(); }
|
||||
catch (Exception exception) { status.StringValue = exception.Message; }
|
||||
}
|
||||
|
||||
private async Task JoinVoice()
|
||||
{
|
||||
AVAuthorizationStatus permission = AVCaptureDevice.GetAuthorizationStatus(AVAuthorizationMediaType.Audio);
|
||||
if (permission == AVAuthorizationStatus.NotDetermined)
|
||||
{
|
||||
bool granted = await AVCaptureDevice.RequestAccessForMediaTypeAsync(AVAuthorizationMediaType.Audio);
|
||||
permission = granted ? AVAuthorizationStatus.Authorized : AVAuthorizationStatus.Denied;
|
||||
}
|
||||
if (permission != AVAuthorizationStatus.Authorized)
|
||||
throw new UnauthorizedAccessException("Microphone access is disabled. Enable VoiceCat in System Settings → Privacy & Security → Microphone, then join voice again.");
|
||||
|
||||
var result = await client.SubscribeVoiceAsync();
|
||||
if (!result.Ok) throw new InvalidOperationException(result.Error);
|
||||
try
|
||||
{
|
||||
playback = audioBackend.OpenPlayback();
|
||||
playback = audioBackend.OpenPlayback(SelectedDevice(outputDevice));
|
||||
client.Audio.MixedPcm += PlayMixedPcm;
|
||||
StreamInfo stream = await client.StartStreamAsync(StreamKind.StreamMic, "Microphone");
|
||||
microphoneStreamId = stream.StreamId;
|
||||
microphone = audioBackend.OpenCapture(null, false, FeedMicrophone);
|
||||
microphone = audioBackend.OpenCapture(SelectedDevice(inputDevice), false, FeedMicrophone);
|
||||
joinedVoice = true; voice.Title = "Leave voice"; status.StringValue = "Voice connected";
|
||||
}
|
||||
catch
|
||||
@@ -186,11 +228,21 @@ internal sealed class MainWindowController : NSWindowController
|
||||
if (streamId != 0) client.Audio.FeedPcm(streamId, pcm, channels);
|
||||
}
|
||||
|
||||
private void PlayMixedPcm(ReadOnlySpan<short> pcm) => Volatile.Read(ref playback)?.Write(pcm);
|
||||
private static string? SelectedDevice(NSPopUpButton menu) => menu.SelectedItem?.RepresentedObject?.ToString();
|
||||
|
||||
private void PlayMixedPcm(ReadOnlySpan<short> pcm)
|
||||
{
|
||||
bool signal = false;
|
||||
foreach (short sample in pcm)
|
||||
if (sample is > 64 or < -64) { signal = true; break; }
|
||||
if (signal) Volatile.Write(ref lastRemoteAudioTick, Environment.TickCount64);
|
||||
Volatile.Read(ref playback)?.Write(pcm);
|
||||
}
|
||||
|
||||
private void StopAudioDevices()
|
||||
{
|
||||
microphoneStreamId = 0;
|
||||
Volatile.Write(ref lastRemoteAudioTick, 0);
|
||||
Interlocked.Exchange(ref microphone, null)?.Dispose();
|
||||
client.Audio.MixedPcm -= PlayMixedPcm;
|
||||
Interlocked.Exchange(ref playback, null)?.Dispose();
|
||||
|
||||
Reference in New Issue
Block a user