Retire legacy implementations and flatten managed layout
Build and test / test (macos-latest) (push) Canceled after 0s
Build and test / test (ubuntu-24.04) (push) Canceled after 0s
Build and test / test (windows-latest) (push) Canceled after 0s
Build and test / apple-client (push) Canceled after 0s

This commit is contained in:
2026-09-21 00:11:32 +02:00
parent dd811a0bb8
commit 08e6c5930a
422 changed files with 252 additions and 38242 deletions
@@ -0,0 +1,166 @@
using AppKit;
using CoreGraphics;
using VoiceCat.Core;
using Voicecat.V1;
using Channel = Voicecat.V1.Channel;
namespace VoiceCat.Mac;
internal sealed class AdministrationWindowController : NSWindowController
{
private readonly VoiceCatClient client;
private readonly uint selfId;
private readonly NSPopUpButton users = new(new CGRect(25, 335, 310, 28), false);
private readonly NSPopUpButton channels = new(new CGRect(25, 175, 310, 28), false);
private readonly NSTextField status = NSTextField.CreateLabel("");
internal AdministrationWindowController(VoiceCatClient client, uint selfId) : base(new NSWindow(new CGRect(0, 0, 560, 420),
NSWindowStyle.Titled | NSWindowStyle.Closable, NSBackingStore.Buffered, false))
{
this.client = client; this.selfId = selfId; Window!.Title = "VoiceCat Administration"; Window.Center();
NSView view = Window.ContentView!;
AddLabel(view, "User moderation", 25, 375); view.AddSubview(users);
AddButton(view, "Move…", 350, 333, Move); AddButton(view, "Kick…", 440, 333, Kick);
AddButton(view, "Ban…", 350, 293, Ban); AddButton(view, "Permissions…", 440, 293, Permissions);
AddButton(view, "Server mute", 350, 253, ServerMute); AddButton(view, "Server deafen", 440, 253, ServerDeafen);
AddButton(view, "Audio tuning…", 25, 253, Tune);
AddLabel(view, "Channel administration", 25, 215); view.AddSubview(channels);
AddButton(view, "Create…", 350, 173, CreateChannel); AddButton(view, "Edit…", 440, 173, EditChannel);
AddButton(view, "Delete", 350, 133, DeleteChannel); AddButton(view, "Accounts…", 440, 133, Accounts);
var refresh = new NSButton(new CGRect(25, 75, 100, 32)) { Title = "Refresh" }; refresh.Activated += (_, _) => Refresh(); view.AddSubview(refresh);
status.Frame = new CGRect(25, 25, 510, 35); status.AccessibilityLabel = "Administration result"; view.AddSubview(status);
((INSAccessibility)users).AccessibilityLabel = "User to administer"; ((INSAccessibility)channels).AccessibilityLabel = "Channel to administer";
Refresh(); ApplyPermissions();
}
private void Refresh()
{
uint selectedUser = Selected(users), selectedChannel = Selected(channels);
users.RemoveAllItems(); foreach (User user in client.Users.Where(user => user.Id != selfId).OrderBy(user => user.Nickname)) Add(users, user.Nickname, user.Id);
channels.RemoveAllItems(); foreach (Channel channel in client.Channels.OrderBy(channel => channel.Order).ThenBy(channel => channel.Name)) Add(channels, channel.Name, channel.Id);
Select(users, selectedUser); Select(channels, selectedChannel);
}
private void ApplyPermissions()
{
Permissions permissions = client.Permissions;
status.StringValue = permissions.IsAdmin ? "Connected as administrator" : "Only actions allowed by your server permissions will succeed.";
}
private async void Move(object? sender, EventArgs args)
{
uint userId = Selected(users); if (userId == 0) return;
string[] names = client.Channels.Select(channel => channel.Name).ToArray(); int index = PromptChoice("Move user", "Destination channel", names); if (index < 0) return;
Show(await client.MoveUserAsync(userId, client.Channels[index].Id));
}
private async void Kick(object? sender, EventArgs args) { uint id = Selected(users); string? reason = PromptText("Kick user", "Reason", "Kicked by moderator"); if (id != 0 && reason is not null) Show(await client.KickUserAsync(id, reason)); }
private async void Ban(object? sender, EventArgs args)
{
uint id = Selected(users); if (id == 0) return;
string? reason = PromptText("Ban user", "Reason (append @1h, @1d, or @permanent)", "Banned by moderator @1d"); if (reason is null) return;
ulong expiry = 0; string lowered = reason.ToLowerInvariant();
if (lowered.EndsWith("@1h")) expiry = checked((ulong)DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeMilliseconds());
else if (lowered.EndsWith("@1d")) expiry = checked((ulong)DateTimeOffset.UtcNow.AddDays(1).ToUnixTimeMilliseconds());
Show(await client.BanUserAsync(id, reason.Replace("@1h", "", StringComparison.OrdinalIgnoreCase).Replace("@1d", "", StringComparison.OrdinalIgnoreCase).Replace("@permanent", "", StringComparison.OrdinalIgnoreCase).Trim(), expiry));
}
private async void Permissions(object? sender, EventArgs args)
{
uint id = Selected(users); if (id == 0) return;
Permissions? permissions = PermissionEditor.Run(); if (permissions is not null) Show(await client.SetPermissionsAsync(id, permissions));
}
private async void ServerMute(object? sender, EventArgs args) { User? user = User(); if (user is not null) Show(await client.SetServerMuteAsync(user.Id, !user.ServerMuted, user.ServerDeafened)); }
private async void ServerDeafen(object? sender, EventArgs args) { User? user = User(); if (user is not null) Show(await client.SetServerMuteAsync(user.Id, user.ServerMuted, !user.ServerDeafened)); }
private void Tune(object? sender, EventArgs args) { User? user = User(); if (user is not null) PerUserTuning.Run(client, user); }
private async void CreateChannel(object? sender, EventArgs args) { ChannelEdit? edit = ChannelEditor.Run(null, client.Channels); if (edit is not null) { Show(await client.CreateChannelAsync(edit.Channel, edit.Password)); Refresh(); } }
private async void EditChannel(object? sender, EventArgs args) { Channel? channel = Channel(); if (channel is null) return; ChannelEdit? edit = ChannelEditor.Run(channel, client.Channels); if (edit is not null) { Show(await client.EditChannelAsync(edit.Channel, edit.Password)); Refresh(); } }
private async void DeleteChannel(object? sender, EventArgs args)
{
Channel? channel = Channel(); if (channel is null || channel.Id == 1) return;
if (!Confirm("Delete channel?", channel.Name)) return; Show(await client.DeleteChannelAsync(channel.Id)); Refresh();
}
private async void Accounts(object? sender, EventArgs args) => await AccountEditor.RunAsync(client);
private User? User() => client.Users.FirstOrDefault(user => user.Id == Selected(users));
private Channel? Channel() => client.Channels.FirstOrDefault(channel => channel.Id == Selected(channels));
private void Show(GenericResult result) { status.StringValue = result.Ok ? result.Message.Length == 0 ? "Operation completed." : result.Message : result.Message; Refresh(); }
private static void Add(NSPopUpButton picker, string title, uint id) { picker.AddItem(title); picker.LastItem!.RepresentedObject = new Foundation.NSString(id.ToString()); }
private static uint Selected(NSPopUpButton picker) => uint.TryParse(picker.SelectedItem?.RepresentedObject?.ToString(), out uint id) ? id : 0;
private static void Select(NSPopUpButton picker, uint id) { for (nint i = 0; i < picker.ItemCount; i++) if (picker.ItemAtIndex(i)?.RepresentedObject?.ToString() == id.ToString()) { picker.SelectItem(i); break; } }
private static void AddLabel(NSView view, string value, double x, double y) { NSTextField label = NSTextField.CreateLabel(value); label.Frame = new CGRect(x, y, 300, 22); label.Font = NSFont.BoldSystemFontOfSize(13)!; view.AddSubview(label); }
private static void AddButton(NSView view, string title, double x, double y, EventHandler handler) { var button = new NSButton(new CGRect(x, y, 95, 32)) { Title = title }; button.Activated += handler; view.AddSubview(button); }
internal static string? PromptText(string title, string prompt, string value = "", bool secure = false)
{
NSTextField field = secure ? new NSSecureTextField(new CGRect(0, 0, 340, 26)) : new NSTextField(new CGRect(0, 0, 340, 26)); field.StringValue = value;
var alert = new NSAlert { MessageText = title, InformativeText = prompt, AccessoryView = field }; alert.AddButton("OK"); alert.AddButton("Cancel");
return alert.RunModal() == 1000 ? field.StringValue : null;
}
internal static int PromptChoice(string title, string prompt, string[] values)
{
var picker = new NSPopUpButton(new CGRect(0, 0, 340, 28), false); picker.AddItems(values);
var alert = new NSAlert { MessageText = title, InformativeText = prompt, AccessoryView = picker }; alert.AddButton("OK"); alert.AddButton("Cancel");
return alert.RunModal() == 1000 ? checked((int)picker.IndexOfSelectedItem) : -1;
}
internal static bool Confirm(string title, string detail) { var alert = new NSAlert { MessageText = title, InformativeText = detail, AlertStyle = NSAlertStyle.Warning }; alert.AddButton("Continue"); alert.AddButton("Cancel"); return alert.RunModal() == 1000; }
}
internal sealed record ChannelEdit(Channel Channel, string Password);
internal static class PermissionEditor
{
internal static Permissions? Run()
{
string[] labels = ["Create temporary channels", "Kick users", "Ban users", "Move users", "Manage accounts", "Full administrator"];
var view = new NSView(new CGRect(0, 0, 330, 160)); var checks = new List<NSButton>();
for (int i = 0; i < labels.Length; i++) { NSButton check = NSButton.CreateCheckbox(labels[i], () => { }); check.Frame = new CGRect(0, 135 - i * 25, 320, 22); view.AddSubview(check); checks.Add(check); }
var alert = new NSAlert { MessageText = "Set permissions", AccessoryView = view }; alert.AddButton("Save"); alert.AddButton("Cancel"); if (alert.RunModal() != 1000) return null;
return new() { CanCreateTempChannel = On(0), CanKick = On(1), CanBan = On(2), CanMoveUsers = On(3), CanAdminAccounts = On(4), IsAdmin = On(5) };
bool On(int i) => checks[i].State == NSCellStateValue.On;
}
}
internal static class PerUserTuning
{
internal static void Run(VoiceCatClient client, User user)
{
if (user.Streams.Count == 0) { AdministrationWindowController.PromptText("Audio tuning", "This user has no active streams."); return; }
var stream = new NSPopUpButton(new CGRect(0, 95, 340, 28), false); stream.AddItems(user.Streams.Select(value => $"{value.Label} ({value.Kind})").ToArray());
var gain = new NSSlider(new CGRect(0, 60, 340, 24)) { MinValue = 0, MaxValue = 400, DoubleValue = 100 };
var muted = NSButton.CreateCheckbox("Mute this stream", () => { }); muted.Frame = new CGRect(0, 30, 160, 24);
var noise = NSButton.CreateCheckbox("Receive noise reduction", () => { }); noise.Frame = new CGRect(170, 30, 170, 24);
var view = new NSView(new CGRect(0, 0, 340, 130)); view.AddSubview(stream); view.AddSubview(gain); view.AddSubview(muted); view.AddSubview(noise);
void LoadSelected()
{
if (stream.IndexOfSelectedItem < 0) return;
StreamInfo selected = user.Streams[checked((int)stream.IndexOfSelectedItem)];
(float Gain, bool Muted, bool NoiseReduction)? state = client.Audio.GetRemotePlayback(user.Id, selected.StreamId);
gain.DoubleValue = (state?.Gain ?? 1) * 100; muted.State = state?.Muted == true ? NSCellStateValue.On : NSCellStateValue.Off;
noise.State = state?.NoiseReduction == true ? NSCellStateValue.On : NSCellStateValue.Off;
}
stream.Activated += (_, _) => LoadSelected(); LoadSelected();
var alert = new NSAlert { MessageText = $"Audio — {user.Nickname}", InformativeText = "Stream, volume, mute, and receive noise reduction", AccessoryView = view };
alert.AddButton("Apply"); alert.AddButton("Cancel"); if (alert.RunModal() != 1000) return;
StreamInfo selected = user.Streams[checked((int)stream.IndexOfSelectedItem)]; client.Audio.SetRemotePlayback(user.Id, selected.StreamId, (float)gain.DoubleValue / 100,
muted.State == NSCellStateValue.On, noise.State == NSCellStateValue.On);
}
}
internal static class AccountEditor
{
internal static async Task RunAsync(VoiceCatClient client)
{
IReadOnlyList<AccountEntry> accounts = await client.ListAccountsAsync();
string[] actions = ["Create account", "Reset password", "Delete account", "Close"];
int action = AdministrationWindowController.PromptChoice("Server accounts", string.Join("\n", accounts.Select(a => $"{a.Username}{(a.IsAdmin ? " (admin)" : "")}")), actions);
if (action is < 0 or 3) return;
if (action == 0)
{
string? name = AdministrationWindowController.PromptText("Create account", "Username"); if (string.IsNullOrWhiteSpace(name)) return;
string? password = AdministrationWindowController.PromptText("Create account", "Password", secure: true); if (!string.IsNullOrEmpty(password)) await client.CreateAccountAsync(name.Trim(), password);
return;
}
int selected = AdministrationWindowController.PromptChoice(action == 1 ? "Reset password" : "Delete account", "Account", accounts.Select(a => a.Username).ToArray()); if (selected < 0) return;
if (action == 1) { string? password = AdministrationWindowController.PromptText("Reset password", "New password", secure: true); if (!string.IsNullOrEmpty(password)) await client.ResetPasswordAsync(accounts[selected].Username, password); }
else if (AdministrationWindowController.Confirm("Delete account?", accounts[selected].Username)) await client.DeleteAccountAsync(accounts[selected].Username);
}
}
+22
View File
@@ -0,0 +1,22 @@
using AppKit;
using Foundation;
namespace VoiceCat.Mac;
internal sealed class AppDelegate : NSApplicationDelegate
{
private ConnectWindowController? connect;
public override void DidFinishLaunching(NSNotification notification)
{
NSApplication.SharedApplication.ActivationPolicy = NSApplicationActivationPolicy.Regular;
BuildMenu();
connect = new(); connect.ShowWindow(this);
}
public override bool ApplicationShouldTerminateAfterLastWindowClosed(NSApplication sender) => true;
private static void BuildMenu()
{
var menu = new NSMenu(); var root = new NSMenuItem(); menu.AddItem(root);
var application = new NSMenu(); application.AddItem(new NSMenuItem("Quit VoiceCat", "q", (_, _) => NSApplication.SharedApplication.Terminate(null)));
root.Submenu = application; NSApplication.SharedApplication.MainMenu = menu;
}
}
@@ -0,0 +1,58 @@
using AppKit;
using CoreGraphics;
using Voicecat.V1;
using Channel = Voicecat.V1.Channel;
namespace VoiceCat.Mac;
internal static class ChannelEditor
{
internal static ChannelEdit? Run(Channel? existing, IReadOnlyList<Channel> channels)
{
var view = new NSView(new CGRect(0, 0, 520, 500));
var name = Field(existing?.Name ?? "", 360); var topic = Field(existing?.Topic ?? "", 330);
var parent = Picker(channels.Select(channel => channel.Name).Prepend("No parent").ToArray(), 300);
int parentIndex = existing is null ? 0 : channels.ToList().FindIndex(channel => channel.Id == existing.ParentId) + 1; parent.SelectItem(Math.Max(0, parentIndex));
var type = Picker(["Permanent", "Temporary"], 270); type.SelectItem(existing?.Type == ChannelType.ChannelTemporary ? 1 : 0);
var maximum = Field((existing?.MaxUsers ?? 0).ToString(), 240); var order = Field((existing?.Order ?? 0).ToString(), 210);
var password = new NSSecureTextField(new CGRect(155, 180, 345, 24)) { PlaceholderString = existing?.PasswordProtected == true ? "Leave blank to preserve" : "Optional" };
var mode = Picker(["Mono", "Stereo"], 150); mode.SelectItem(existing?.Audio?.Mode == ChannelMode.ModeStereo ? 1 : 0);
var rate = Picker(["8000", "12000", "16000", "24000", "48000"], 120); Select(rate, (existing?.Audio?.SampleRate ?? 48000).ToString());
var frame = Picker(["5", "10", "20", "40", "60"], 90); Select(frame, (existing?.Audio?.FrameMs ?? 20).ToString());
var application = Picker(["VoIP", "Audio", "Low delay"], 60); application.SelectItem((int)(existing?.Audio?.Application ?? OpusApplication.OpusVoip));
var bitrate = Field((existing?.Audio?.BitrateBps ?? 64000).ToString(), 30);
Add(view, "Name", name, 360); Add(view, "Topic", topic, 330); Add(view, "Parent", parent, 300); Add(view, "Type", type, 270);
Add(view, "Maximum users (0=none)", maximum, 240); Add(view, "Sort order", order, 210); Add(view, "Password", password, 180);
Add(view, "Mode", mode, 150); Add(view, "Sample rate", rate, 120); Add(view, "Frame ms", frame, 90); Add(view, "Application", application, 60); Add(view, "Bitrate", bitrate, 30);
var advanced = new NSView(new CGRect(0, 0, 520, 105));
var fec = Check("In-band FEC", existing?.Audio?.Fec ?? true, 0, 75); var dtx = Check("DTX", existing?.Audio?.Dtx ?? true, 130, 75);
var dred = Check("Deep redundancy", existing?.Audio?.Dred ?? false, 230, 75);
var loss = Field((existing?.Audio?.ExpectedPacketLoss ?? 5).ToString(), 40); loss.Frame = new CGRect(155, 40, 100, 24);
var complexity = Field((existing?.Audio?.Complexity ?? 10).ToString(), 10); complexity.Frame = new CGRect(155, 10, 100, 24);
advanced.AddSubview(fec); advanced.AddSubview(dtx); advanced.AddSubview(dred); Add(advanced, "Packet loss %", loss, 40); Add(advanced, "Complexity 010", complexity, 10);
var container = new NSView(new CGRect(0, 0, 520, 620)); view.Frame = new CGRect(0, 110, 520, 500); container.AddSubview(view); container.AddSubview(advanced);
var alert = new NSAlert { MessageText = existing is null ? "Create channel" : "Edit channel", AccessoryView = container };
alert.AddButton(existing is null ? "Create" : "Save"); alert.AddButton("Cancel"); if (alert.RunModal() != 1000) return null;
if (string.IsNullOrWhiteSpace(name.StringValue) || !uint.TryParse(maximum.StringValue, out uint max) || !int.TryParse(order.StringValue, out int sort) ||
!uint.TryParse(rate.TitleOfSelectedItem, out uint sampleRate) || !uint.TryParse(frame.TitleOfSelectedItem, out uint frameMs) ||
!uint.TryParse(bitrate.StringValue, out uint bitrateValue) || !uint.TryParse(loss.StringValue, out uint packetLoss) ||
!uint.TryParse(complexity.StringValue, out uint complexityValue) || packetLoss > 100 || complexityValue > 10) return null;
uint parentId = parent.IndexOfSelectedItem <= 0 ? 0 : channels[checked((int)parent.IndexOfSelectedItem - 1)].Id;
return new(new Channel
{
Id = existing?.Id ?? 0, ParentId = parentId, Name = name.StringValue.Trim(), Topic = topic.StringValue.Trim(), MaxUsers = max, Order = sort,
Type = type.IndexOfSelectedItem == 1 ? ChannelType.ChannelTemporary : ChannelType.ChannelPermanent,
Audio = new AudioConfig { Codec = 0, Mode = mode.IndexOfSelectedItem == 1 ? ChannelMode.ModeStereo : ChannelMode.ModeMono,
SampleRate = sampleRate, FrameMs = frameMs, Application = (OpusApplication)(int)application.IndexOfSelectedItem, BitrateBps = bitrateValue,
Fec = On(fec), Dtx = On(dtx), Dred = On(dred), ExpectedPacketLoss = packetLoss, Complexity = complexityValue }
}, password.StringValue);
}
private static NSTextField Field(string value, double y) => new(new CGRect(155, y, 345, 24)) { StringValue = value };
private static NSPopUpButton Picker(string[] values, double y) { var picker = new NSPopUpButton(new CGRect(155, y, 345, 26), false); picker.AddItems(values); return picker; }
private static NSButton Check(string title, bool value, double x, double y) { NSButton check = NSButton.CreateCheckbox(title, () => { }); check.Frame = new CGRect(x, y, 125, 24); check.State = value ? NSCellStateValue.On : NSCellStateValue.Off; return check; }
private static bool On(NSButton value) => value.State == NSCellStateValue.On;
private static void Add(NSView view, string label, NSView control, double y) { var text = NSTextField.CreateLabel(label); text.Frame = new CGRect(0, y + 2, 145, 20); view.AddSubview(text); view.AddSubview(control); ((INSAccessibility)control).AccessibilityLabel = label; }
private static void Select(NSPopUpButton picker, string value) { for (nint i = 0; i < picker.ItemCount; i++) if (picker.ItemTitle(i) == value) { picker.SelectItem(i); return; } }
}
@@ -0,0 +1,217 @@
using AppKit;
using CoreGraphics;
using VoiceCat.Core;
using VoiceCat.Crypto;
namespace VoiceCat.Mac;
internal sealed class ConnectWindowController : NSWindowController
{
private static readonly string SupportDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "VoiceCat");
private readonly ServerProfileStore profileStore = new(Path.Combine(SupportDirectory, "servers.json"));
private readonly MacKeychainPasswordStore keychain = new();
private readonly NSPopUpButton profiles = new(new CGRect(120, 315, 330, 28), false);
private readonly NSTextField host = new(new CGRect(120, 270, 330, 26)) { StringValue = "127.0.0.1:8384", PlaceholderString = "Host:port" };
private readonly NSPopUpButton authentication = new(new CGRect(120, 225, 180, 28), false);
private readonly NSTextField identity = new(new CGRect(120, 180, 330, 26)) { StringValue = Environment.UserName };
private readonly NSTextField identityLabel;
private readonly NSSecureTextField password = new(new CGRect(120, 135, 330, 26)) { PlaceholderString = "Password" };
private readonly NSButton rememberPassword = NSButton.CreateCheckbox("Remember in Keychain", () => { });
private readonly NSTextField status = NSTextField.CreateLabel("Ready to connect");
private readonly NSButton save = new(new CGRect(120, 75, 90, 32)) { Title = "Save" };
private readonly NSButton remove = new(new CGRect(220, 75, 90, 32)) { Title = "Remove" };
private readonly NSButton connect = new(new CGRect(360, 75, 90, 32)) { Title = "Connect", BezelStyle = NSBezelStyle.Rounded };
private List<ServerProfile> saved = [];
private Guid? editingId;
private VoiceCatClient? client;
private MainWindowController? main;
internal ConnectWindowController() : base(new NSWindow(new CGRect(0, 0, 570, 390), NSWindowStyle.Titled | NSWindowStyle.Closable,
NSBackingStore.Buffered, false))
{
Window!.Title = "Connect to VoiceCat"; Window.Center();
var view = Window.ContentView!;
AddLabel(view, "Saved server", 25, 320); AddLabel(view, "Server", 25, 275); AddLabel(view, "Authentication", 25, 230);
identityLabel = AddLabel(view, "Nickname", 25, 185); AddLabel(view, "Password", 25, 140);
authentication.AddItem("Guest"); authentication.AddItem("Account"); authentication.Activated += AuthenticationChanged;
profiles.Activated += ProfileChanged;
((INSAccessibility)profiles).AccessibilityLabel = "Saved servers";
((INSAccessibility)host).AccessibilityLabel = "Server hostname and port";
((INSAccessibility)authentication).AccessibilityLabel = "Authentication mode";
((INSAccessibility)identity).AccessibilityLabel = "Guest nickname";
((INSAccessibility)password).AccessibilityLabel = "Account password";
rememberPassword.Frame = new CGRect(310, 225, 180, 24);
((INSAccessibility)rememberPassword).AccessibilityLabel = "Remember account password in Keychain";
view.AddSubview(profiles); view.AddSubview(host); view.AddSubview(authentication); view.AddSubview(identity); view.AddSubview(password); view.AddSubview(rememberPassword);
status.Frame = new CGRect(25, 30, 520, 24); status.AccessibilityLabel = "Connection status"; view.AddSubview(status);
((INSAccessibility)save).AccessibilityLabel = "Save server profile"; save.Activated += SaveProfile; view.AddSubview(save);
((INSAccessibility)remove).AccessibilityLabel = "Remove server profile"; remove.Activated += RemoveProfile; view.AddSubview(remove);
((INSAccessibility)connect).AccessibilityLabel = "Connect to server"; connect.Activated += Connect; view.AddSubview(connect);
Window.DefaultButtonCell = connect.Cell;
ReloadProfiles(); UpdateAuthentication();
}
private static NSTextField AddLabel(NSView view, string text, double x, double y)
{
var label = NSTextField.CreateLabel(text); label.Frame = new CGRect(x, y, 90, 20); view.AddSubview(label); return label;
}
private void ReloadProfiles(Guid? select = null)
{
saved = profileStore.Load().ToList();
profiles.RemoveAllItems(); profiles.AddItem("New server…");
foreach (ServerProfile profile in saved) profiles.AddItem(profile.DisplayName);
int index = select is null ? (saved.Count == 0 ? 0 : 1) : saved.FindIndex(item => item.Id == select) + 1;
profiles.SelectItem(Math.Max(0, index));
ApplySelectedProfile();
}
private void ProfileChanged(object? sender, EventArgs args) => ApplySelectedProfile();
private void ApplySelectedProfile()
{
int index = checked((int)profiles.IndexOfSelectedItem) - 1;
if (index < 0 || index >= saved.Count)
{
editingId = null; remove.Enabled = false; password.StringValue = "";
rememberPassword.State = NSCellStateValue.Off; UpdateAuthentication(); return;
}
ServerProfile profile = saved[index]; editingId = profile.Id; remove.Enabled = true;
host.StringValue = FormatEndpoint(profile.Host, profile.Port);
authentication.SelectItem(profile.Authentication == ServerAuthentication.Guest ? 0 : 1);
identity.StringValue = profile.Authentication == ServerAuthentication.Guest ? profile.Nickname ?? Environment.UserName : profile.Username ?? "";
string? storedPassword = profile.Authentication == ServerAuthentication.Account
? keychain.Load(profile.Id) ?? keychain.LoadLegacy(profile.LegacyKeychainTag) : null;
password.StringValue = storedPassword ?? "";
rememberPassword.State = storedPassword is null ? NSCellStateValue.Off : NSCellStateValue.On;
UpdateAuthentication();
}
private void AuthenticationChanged(object? sender, EventArgs args) => UpdateAuthentication();
private void UpdateAuthentication()
{
bool account = authentication.IndexOfSelectedItem == 1;
identityLabel.StringValue = account ? "Username" : "Nickname";
identity.PlaceholderString = account ? "Username" : "Nickname";
((INSAccessibility)identity).AccessibilityLabel = account ? "Account username" : "Guest nickname";
password.Enabled = account;
rememberPassword.Enabled = account && editingId is not null;
}
private void SaveProfile(object? sender, EventArgs args)
{
try
{
ServerProfile profile = ReadProfile();
string pendingPassword = password.StringValue;
bool shouldRemember = rememberPassword.State == NSCellStateValue.On;
int index = saved.FindIndex(item => item.Id == profile.Id);
if (index < 0) saved.Add(profile); else saved[index] = profile;
if (profile.Authentication == ServerAuthentication.Guest || !shouldRemember) keychain.Remove(profile.Id);
profileStore.Save(saved); ReloadProfiles(profile.Id);
if (profile.Authentication == ServerAuthentication.Account && shouldRemember)
{
password.StringValue = pendingPassword;
rememberPassword.State = NSCellStateValue.On;
}
status.StringValue = "Server profile saved. Passwords are stored only after successful login.";
}
catch (Exception exception) { status.StringValue = exception.Message; }
}
private void RemoveProfile(object? sender, EventArgs args)
{
if (editingId is not { } id) return;
ServerProfile? profile = saved.FirstOrDefault(item => item.Id == id); if (profile is null) return;
var alert = new NSAlert { MessageText = "Remove server?", InformativeText = profile.DisplayName, AlertStyle = NSAlertStyle.Warning };
alert.AddButton("Remove"); alert.AddButton("Cancel"); if (alert.RunModal() != 1000) return;
keychain.Remove(profile.Id); saved.Remove(profile); profileStore.Save(saved); editingId = null; ReloadProfiles(); status.StringValue = "Server profile removed.";
}
private async void Connect(object? sender, EventArgs args)
{
if (client is not null) return;
try
{
SetBusy(true); status.StringValue = "Connecting…";
ServerProfile profile = ReadProfile();
string pins = Path.Combine(SupportDirectory, "tofu_pins.txt");
client = new("VoiceCat macOS", "0.1.0", pins);
await client.ConnectAsync(profile.Host, profile.Port, ConfirmIdentity);
string accountPassword = password.StringValue;
if (profile.Authentication == ServerAuthentication.Account && accountPassword.Length == 0)
accountPassword = keychain.Load(profile.Id) ?? keychain.LoadLegacy(profile.LegacyKeychainTag) ?? "";
if (profile.Authentication == ServerAuthentication.Account && accountPassword.Length == 0) throw new ArgumentException("Enter the account password.");
password.StringValue = "";
var result = profile.Authentication == ServerAuthentication.Guest
? await client.AuthenticateGuestAsync(profile.Nickname ?? Environment.UserName)
: await client.AuthenticateUserAsync(profile.Username!, accountPassword);
if (!result.Ok) throw new InvalidOperationException(result.Error);
try
{
if (profile.Authentication == ServerAuthentication.Account && rememberPassword.State == NSCellStateValue.On) keychain.Save(profile.Id, accountPassword);
else keychain.Remove(profile.Id);
}
catch (Exception exception) { ShowKeychainWarning(exception.Message); }
main = new(client, result.Self.Id, result.Self.Nickname);
client = null; main.ShowWindow(this); Window.Close();
}
catch (Exception exception)
{
if (client is not null) await client.DisposeAsync(); client = null;
status.StringValue = exception.Message; SetBusy(false);
}
}
private ServerProfile ReadProfile()
{
(string serverHost, ushort port) = ParseEndpoint(host.StringValue);
bool account = authentication.IndexOfSelectedItem == 1;
string name = identity.StringValue.Trim();
return ServerProfile.Create(serverHost, port, account ? ServerAuthentication.Account : ServerAuthentication.Guest,
username: account ? name : null, nickname: account ? null : name, id: editingId);
}
private void SetBusy(bool busy)
{
profiles.Enabled = host.Enabled = authentication.Enabled = identity.Enabled = save.Enabled = connect.Enabled = !busy;
remove.Enabled = !busy && editingId is not null;
password.Enabled = !busy && authentication.IndexOfSelectedItem == 1;
rememberPassword.Enabled = !busy && authentication.IndexOfSelectedItem == 1 && editingId is not null;
}
private ValueTask<bool> ConfirmIdentity(ServerIdentityChallenge challenge, CancellationToken cancellationToken)
{
var completion = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
NSApplication.SharedApplication.InvokeOnMainThread(() =>
{
var alert = new NSAlert { MessageText = challenge.Status == TofuStatus.FirstConnect ? "Trust this VoiceCat server?" : "Server identity changed",
InformativeText = $"{challenge.Host}:{challenge.Port}\n\nCertificate SHA-256:\n{challenge.CertificateFingerprint}", AlertStyle = challenge.Status == TofuStatus.Mismatch ? NSAlertStyle.Critical : NSAlertStyle.Informational };
alert.AddButton("Trust and connect"); alert.AddButton("Cancel"); completion.TrySetResult(alert.RunModal() == 1000);
});
return new(completion.Task.WaitAsync(cancellationToken));
}
private static void ShowKeychainWarning(string message)
{
var alert = new NSAlert
{
MessageText = "Password storage failed",
InformativeText = message + " You are still signed in, but the Keychain setting was not changed.",
AlertStyle = NSAlertStyle.Warning
};
alert.RunModal();
}
private static (string Host, ushort Port) ParseEndpoint(string value)
{
int separator = value.LastIndexOf(':');
if (separator <= 0 || !ushort.TryParse(value[(separator + 1)..], out ushort port) || port == 0) throw new ArgumentException("Enter a server as host:port.");
string parsedHost = value[..separator].Trim().Trim('[', ']');
if (parsedHost.Length == 0) throw new ArgumentException("Enter a server as host:port.");
return (parsedHost, port);
}
private static string FormatEndpoint(string serverHost, ushort port) => serverHost.Contains(':') ? $"[{serverHost}]:{port}" : $"{serverHost}:{port}";
}
@@ -0,0 +1,113 @@
using System.Runtime.InteropServices;
using Foundation;
using ObjCRuntime;
using VoiceCat.Audio;
namespace VoiceCat.Mac;
internal static class CoreAudioDevices
{
private const string CoreAudio = "/System/Library/Frameworks/CoreAudio.framework/CoreAudio";
private const uint SystemObject = 1;
private const uint Devices = 0x64657623; // 'dev#'
private const uint DefaultInput = 0x64496e20; // 'dIn '
private const uint DefaultOutput = 0x644f7574; // 'dOut'
private const uint StreamConfiguration = 0x736c6179; // 'slay'
private const uint ObjectName = 0x6c6e616d; // 'lnam'
private const uint ScopeGlobal = 0x676c6f62; // 'glob'
private const uint ScopeInput = 0x696e7074; // 'inpt'
private const uint ScopeOutput = 0x6f757470; // 'outp'
[StructLayout(LayoutKind.Sequential)]
private struct PropertyAddress(uint selector, uint scope)
{
public uint Selector = selector;
public uint Scope = scope;
public uint Element;
}
[DllImport(CoreAudio)]
private static extern int AudioObjectGetPropertyDataSize(uint objectId, ref PropertyAddress address,
uint qualifierDataSize, nint qualifierData, out uint dataSize);
[DllImport(CoreAudio)]
private static extern int AudioObjectGetPropertyData(uint objectId, ref PropertyAddress address,
uint qualifierDataSize, nint qualifierData, ref uint dataSize, nint data);
internal static IReadOnlyList<AudioDeviceInfo> List(bool input)
{
uint defaultId = ReadUInt(SystemObject, input ? DefaultInput : DefaultOutput, ScopeGlobal);
var devices = new List<AudioDeviceInfo>();
foreach (uint id in ReadUIntArray(SystemObject, Devices, ScopeGlobal))
{
if (ChannelCount(id, input ? ScopeInput : ScopeOutput) == 0) continue;
string name = ReadString(id, ObjectName, ScopeGlobal) ?? $"Core Audio device {id}";
devices.Add(new(id.ToString(System.Globalization.CultureInfo.InvariantCulture), name, id == defaultId));
}
return devices.OrderByDescending(device => device.IsDefault).ThenBy(device => device.Name, StringComparer.CurrentCultureIgnoreCase).ToArray();
}
internal static uint ParseId(string? value)
{
if (!uint.TryParse(value, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out uint id) || id == 0)
throw new ArgumentException("The selected Core Audio device is no longer available.", nameof(value));
return id;
}
private static uint ReadUInt(uint objectId, uint selector, uint scope)
{
var address = new PropertyAddress(selector, scope);
uint size = sizeof(uint);
nint memory = Marshal.AllocHGlobal(sizeof(uint));
try { return AudioObjectGetPropertyData(objectId, ref address, 0, 0, ref size, memory) == 0 ? unchecked((uint)Marshal.ReadInt32(memory)) : 0; }
finally { Marshal.FreeHGlobal(memory); }
}
private static uint[] ReadUIntArray(uint objectId, uint selector, uint scope)
{
var address = new PropertyAddress(selector, scope);
if (AudioObjectGetPropertyDataSize(objectId, ref address, 0, 0, out uint size) != 0 || size < sizeof(uint)) return [];
nint memory = Marshal.AllocHGlobal(checked((int)size));
try
{
if (AudioObjectGetPropertyData(objectId, ref address, 0, 0, ref size, memory) != 0) return [];
var result = new uint[size / sizeof(uint)];
for (int i = 0; i < result.Length; i++) result[i] = unchecked((uint)Marshal.ReadInt32(memory, i * sizeof(uint)));
return result;
}
finally { Marshal.FreeHGlobal(memory); }
}
private static int ChannelCount(uint deviceId, uint scope)
{
var address = new PropertyAddress(StreamConfiguration, scope);
if (AudioObjectGetPropertyDataSize(deviceId, ref address, 0, 0, out uint size) != 0 || size < 8) return 0;
nint memory = Marshal.AllocHGlobal(checked((int)size));
try
{
if (AudioObjectGetPropertyData(deviceId, ref address, 0, 0, ref size, memory) != 0) return 0;
int count = Marshal.ReadInt32(memory);
int first = IntPtr.Size == 8 ? 8 : 4;
int stride = IntPtr.Size == 8 ? 16 : 12;
int channels = 0;
for (int i = 0; i < count && first + i * stride + sizeof(uint) <= size; i++)
channels += Marshal.ReadInt32(memory, first + i * stride);
return channels;
}
finally { Marshal.FreeHGlobal(memory); }
}
private static string? ReadString(uint objectId, uint selector, uint scope)
{
var address = new PropertyAddress(selector, scope);
uint size = checked((uint)IntPtr.Size);
nint memory = Marshal.AllocHGlobal(IntPtr.Size);
try
{
if (AudioObjectGetPropertyData(objectId, ref address, 0, 0, ref size, memory) != 0) return null;
nint handle = Marshal.ReadIntPtr(memory);
return handle == 0 ? null : Runtime.GetNSObject<NSString>(handle, owns: false)?.ToString();
}
finally { Marshal.FreeHGlobal(memory); }
}
}
@@ -0,0 +1,49 @@
using AVFoundation;
using Foundation;
namespace VoiceCat.Mac;
internal enum SoundEvent { ChannelJoin, ChannelLeave, ChannelReceived, ChannelSent, PrivateReceived, PrivateSent, Login, Logout, ConnectionLost, VoiceOn, VoiceOff, VoiceStart, VoiceStop, PushToTalk }
internal sealed class EventFeedback : IDisposable
{
private readonly MacSettings settings;
private readonly AVSpeechSynthesizer speech = new();
private readonly Dictionary<SoundEvent, AVAudioPlayer> players = [];
internal EventFeedback(MacSettings settings) => this.settings = settings;
internal void Play(SoundEvent sound)
{
if (!settings.EventSounds || settings.EventVolume <= 0 ||
(sound is SoundEvent.VoiceStart or SoundEvent.VoiceStop && !settings.SelfTalkSounds) ||
(sound == SoundEvent.PushToTalk && !settings.PushToTalkSound)) return;
if (!players.TryGetValue(sound, out AVAudioPlayer? player))
{
string path = Path.Combine(NSBundle.MainBundle.ResourcePath ?? "", "Sounds", FileName(sound) + ".wav");
if (!File.Exists(path)) return;
player = AVAudioPlayer.FromUrl(NSUrl.FromFilename(path));
if (player is null) return;
player.PrepareToPlay(); players[sound] = player;
}
player.Volume = settings.EventVolume; player.CurrentTime = 0; player.Play();
}
internal void Speak(string text)
{
if (!settings.SpokenEvents || string.IsNullOrWhiteSpace(text)) return;
speech.SpeakUtterance(new AVSpeechUtterance(text.Trim()));
}
private static string FileName(SoundEvent value) => value switch
{
SoundEvent.ChannelJoin => "channel_join", SoundEvent.ChannelLeave => "channel_leave",
SoundEvent.ChannelReceived => "channel_recv", SoundEvent.ChannelSent => "channel_sent",
SoundEvent.PrivateReceived => "pm_recv", SoundEvent.PrivateSent => "pm_sent",
SoundEvent.Login => "login", SoundEvent.Logout => "logout", SoundEvent.ConnectionLost => "connection_lost",
SoundEvent.VoiceOn => "voice_on", SoundEvent.VoiceOff => "voice_off", SoundEvent.VoiceStart => "va_start",
SoundEvent.VoiceStop => "va_stop", _ => "ptt"
};
public void Dispose() { foreach (AVAudioPlayer player in players.Values) player.Dispose(); speech.Dispose(); }
}
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>CFBundleDisplayName</key><string>VoiceCat</string>
<key>CFBundleIdentifier</key><string>net.iamtalon.voicecat</string>
<key>CFBundleName</key><string>VoiceCat</string>
<key>CFBundleVersion</key><string>1</string>
<key>CFBundleShortVersionString</key><string>0.1.0</string>
<key>LSMinimumSystemVersion</key><string>14.0</string>
<key>NSMicrophoneUsageDescription</key><string>VoiceCat uses the microphone when you join voice and enable a microphone stream.</string>
<key>NSScreenCaptureUsageDescription</key><string>VoiceCat uses ScreenCaptureKit only when you share desktop or application audio.</string>
<key>NSPrincipalClass</key><string>NSApplication</string>
</dict></plist>
@@ -0,0 +1,243 @@
using System.Runtime.InteropServices;
using AudioUnit;
using AVFoundation;
using Foundation;
using VoiceCat.Audio;
namespace VoiceCat.Mac;
internal sealed class MacAudioBackend : IAudioDeviceBackend
{
private Exception? failure;
internal Exception? Failure => Volatile.Read(ref failure);
public IReadOnlyList<AudioDeviceInfo> Enumerate(bool input) => CoreAudioDevices.List(input);
public IAudioCapture OpenCapture(string? deviceId, bool loopback, CapturePcmHandler pcm)
{
if (loopback) throw new NotSupportedException("Screen audio uses ScreenCaptureKit, not microphone capture.");
return new Capture(deviceId, pcm, ReportFailure);
}
public IAudioPlayback OpenPlayback(string? deviceId = null)
{
return new Playback(deviceId, 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(string? deviceId, CapturePcmHandler handler, Action<Exception> reportFailure)
{
this.handler = handler;
this.reportFailure = reportFailure;
AVAudioInputNode input = engine.InputNode;
if (!string.IsNullOrEmpty(deviceId))
{
AudioUnitStatus status = input.AudioUnit!.SetCurrentDevice(CoreAudioDevices.ParseId(deviceId), AudioUnitScopeType.Global, 0);
if (status != AudioUnitStatus.NoError) throw new InvalidOperationException($"Core Audio input selection failed ({status}).");
}
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;
NSError? tapError = null;
if (OperatingSystem.IsMacOSVersionAtLeast(27))
input.InstallTapOnBus(0, 960, inputFormat, out tapError, Convert);
else
input.InstallTapOnBus(0, 960, inputFormat, Convert);
if (tapError is not null)
{
converted.Dispose();
converter.Dispose();
outputFormat.Dispose();
engine.Dispose();
throw new InvalidOperationException("Core Audio capture tap could not be installed: " + tapError.LocalizedDescription);
}
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 AdaptivePcmBuffer pcm = new(2);
private readonly short[] renderScratch = new short[8_192];
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(string? deviceId, Action<Exception> reportFailure)
{
this.reportFailure = reportFailure;
if (!string.IsNullOrEmpty(deviceId))
{
AudioUnitStatus status = engine.OutputNode.AudioUnit!.SetCurrentDevice(CoreAudioDevices.ParseId(deviceId), AudioUnitScopeType.Global, 0);
if (status != AudioUnitStatus.NoError) throw new InvalidOperationException($"Core Audio output selection failed ({status}).");
}
// AVAudioEngine's native mixer path is planar Float32. Convert from the managed
// Int16 ring directly in this allocation-free callback, avoiding an extra graph
// converter and matching the established Swift/VPIO renderer.
format = new(AVAudioCommonFormat.PCMFloat32, 48_000, 2, false);
source = new(format, Render);
engine.AttachNode(source);
NSError? connectionError = null;
if (OperatingSystem.IsMacOSVersionAtLeast(27))
engine.Connect(source, engine.MainMixerNode, format, out connectionError);
else
engine.Connect(source, engine.MainMixerNode, format);
if (connectionError is not null)
{
engine.DetachNode(source);
source.Dispose();
format.Dispose();
engine.Dispose();
throw new InvalidOperationException("Core Audio playback could not be connected: " + connectionError.LocalizedDescription);
}
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);
public int BufferMilliseconds { get => pcm.BufferMilliseconds; set => pcm.BufferMilliseconds = value; }
private unsafe int Render(IntPtr isSilence, IntPtr _, uint frameCount, IntPtr outputData)
{
try
{
int bufferCount = Marshal.ReadInt32(outputData);
int firstBuffer = IntPtr.Size == 8 ? 8 : 4;
const int bufferStride64 = 16;
int stride = IntPtr.Size == 8 ? bufferStride64 : 12;
int frames = checked((int)frameCount);
int requested = checked(frames * 2);
if (bufferCount != 2 || requested > renderScratch.Length) return Fail("Core Audio returned an invalid planar stereo playback buffer.");
Span<short> source = renderScratch.AsSpan(0, requested);
int read = pcm.Read(source);
source[read..].Clear();
for (int channel = 0; channel < 2; channel++)
{
int offset = firstBuffer + channel * stride;
int channels = Marshal.ReadInt32(outputData, offset);
int byteCount = Marshal.ReadInt32(outputData, offset + 4);
nint data = Marshal.ReadIntPtr(outputData, offset + 8);
if (channels != 1 || data == 0 || byteCount < frames * sizeof(float)) return Fail("Core Audio returned an invalid Float32 playback channel.");
var output = new Span<float>((void*)data, frames);
for (int frame = 0; frame < frames; frame++) output[frame] = source[frame * 2 + channel] * (1.0f / 32_768.0f);
}
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();
}
}
}
@@ -0,0 +1,72 @@
using System.Security.Cryptography;
using System.Text;
using Foundation;
using Security;
namespace VoiceCat.Mac;
internal sealed class MacKeychainPasswordStore
{
private const string Service = "net.iamtalon.voicecat";
internal string? Load(Guid profileId)
{
using var query = Query(profileId);
using SecRecord? result = SecKeyChain.QueryAsRecord(query, out SecStatusCode status);
if (status != SecStatusCode.Success || result?.ValueData is not { } data) return null;
byte[] encoded = data.ToArray();
try { return Encoding.UTF8.GetString(encoded); }
finally { CryptographicOperations.ZeroMemory(encoded); }
}
internal string? LoadLegacy(string? keychainTag)
{
if (string.IsNullOrWhiteSpace(keychainTag)) return null;
using var query = new SecRecord(SecKind.GenericPassword) { Service = "cat.voice.VoiceCatMac", Account = keychainTag };
using SecRecord? result = SecKeyChain.QueryAsRecord(query, out SecStatusCode status);
if (status != SecStatusCode.Success || result?.ValueData is not { } data) return null;
byte[] encoded = data.ToArray();
try { return Encoding.UTF8.GetString(encoded); }
finally { CryptographicOperations.ZeroMemory(encoded); }
}
internal void Save(Guid profileId, string password)
{
ArgumentException.ThrowIfNullOrEmpty(password);
byte[] encoded = Encoding.UTF8.GetBytes(password);
try
{
using var data = NSData.FromArray(encoded);
using var query = Query(profileId);
using var attributes = new SecRecord
{
Label = "VoiceCat server password",
Accessible = SecAccessible.WhenUnlocked,
ValueData = data
};
SecStatusCode status = SecKeyChain.Update(query, attributes);
if (status == SecStatusCode.ItemNotFound)
{
using var record = Query(profileId);
record.Label = attributes.Label; record.Accessible = attributes.Accessible; record.ValueData = data;
status = SecKeyChain.Add(record);
}
if (status != SecStatusCode.Success) throw new InvalidOperationException($"Keychain save failed ({status}).");
}
finally { CryptographicOperations.ZeroMemory(encoded); }
}
internal void Remove(Guid profileId)
{
using var query = Query(profileId);
SecStatusCode status = SecKeyChain.Remove(query);
if (status is not (SecStatusCode.Success or SecStatusCode.ItemNotFound))
throw new InvalidOperationException($"Keychain removal failed ({status}).");
}
private static SecRecord Query(Guid profileId) => new(SecKind.GenericPassword)
{
Service = Service,
Account = profileId.ToString("D")
};
}
+71
View File
@@ -0,0 +1,71 @@
using Foundation;
using VoiceCat.Audio;
namespace VoiceCat.Mac;
internal sealed class MacSettings
{
private readonly NSUserDefaults values = NSUserDefaults.StandardUserDefaults;
internal AudioInputMode InputMode { get; set; } = AudioInputMode.VoiceActivation;
internal float VadThreshold { get; set; } = 0.05f;
internal float InputGain { get; set; } = 1f;
internal float OutputGain { get; set; } = 0.8f;
internal bool InputNoiseReduction { get; set; }
internal bool StereoMicrophone { get; set; }
internal ushort PushToTalkKeyCode { get; set; } = 0x60;
internal string? InputDeviceId { get; set; }
internal string? OutputDeviceId { get; set; }
internal bool AuxiliaryEnabled { get; set; }
internal string? AuxiliaryDeviceId { get; set; }
internal float AuxiliaryGain { get; set; } = 1f;
internal int AudioBufferMilliseconds { get; set; } = 40;
internal bool EventSounds { get; set; } = true;
internal bool SpokenEvents { get; set; }
internal float EventVolume { get; set; } = 1f;
internal bool SelfTalkSounds { get; set; }
internal bool PushToTalkSound { get; set; }
internal void Load()
{
NSString[] keys = [(NSString)"voice.inputMode", (NSString)"voice.vadThreshold", (NSString)"voice.inputGain", (NSString)"voice.outputGain",
(NSString)"voice.pttKeyCode", (NSString)"voice.auxGain", (NSString)"voice.audioBufferMs", (NSString)"feedback.sounds", (NSString)"feedback.volume"];
NSObject[] defaults = [NSNumber.FromInt32((int)AudioInputMode.VoiceActivation), NSNumber.FromFloat(0.05f),
NSNumber.FromFloat(1f), NSNumber.FromFloat(0.8f), NSNumber.FromInt32(0x60), NSNumber.FromFloat(1f), NSNumber.FromInt32(40),
NSNumber.FromBoolean(true), NSNumber.FromFloat(1f)];
values.RegisterDefaults(new NSDictionary<NSString, NSObject>(keys, defaults));
InputMode = Enum.IsDefined(typeof(AudioInputMode), (int)values.IntForKey("voice.inputMode"))
? (AudioInputMode)(int)values.IntForKey("voice.inputMode") : AudioInputMode.VoiceActivation;
VadThreshold = Math.Clamp(values.FloatForKey("voice.vadThreshold"), 0f, 1f);
InputGain = Math.Clamp(values.FloatForKey("voice.inputGain"), 0f, 4f);
OutputGain = Math.Clamp(values.FloatForKey("voice.outputGain"), 0f, 1f);
InputNoiseReduction = values.BoolForKey("voice.inputNoiseReduction");
StereoMicrophone = values.BoolForKey("voice.stereoMic");
PushToTalkKeyCode = checked((ushort)Math.Clamp(values.IntForKey("voice.pttKeyCode"), 0, ushort.MaxValue));
InputDeviceId = EmptyToNull(values.StringForKey("voice.inputDevice"));
OutputDeviceId = EmptyToNull(values.StringForKey("voice.outputDevice"));
AuxiliaryEnabled = values.BoolForKey("voice.auxEnabled");
AuxiliaryDeviceId = EmptyToNull(values.StringForKey("voice.auxDeviceUID"));
AuxiliaryGain = Math.Clamp(values.FloatForKey("voice.auxGain"), 0f, 4f);
int audioBuffer = checked((int)values.IntForKey("voice.audioBufferMs")); AudioBufferMilliseconds = audioBuffer is 20 or 40 or 60 ? audioBuffer : 40;
EventSounds = values.BoolForKey("feedback.sounds");
SpokenEvents = values.BoolForKey("feedback.speech");
EventVolume = Math.Clamp(values.FloatForKey("feedback.volume"), 0f, 1f);
SelfTalkSounds = values.BoolForKey("feedback.selfTalk");
PushToTalkSound = values.BoolForKey("feedback.ptt");
}
internal void Save()
{
values.SetInt((int)InputMode, "voice.inputMode"); values.SetFloat(VadThreshold, "voice.vadThreshold");
values.SetFloat(InputGain, "voice.inputGain"); values.SetFloat(OutputGain, "voice.outputGain");
values.SetBool(InputNoiseReduction, "voice.inputNoiseReduction"); values.SetBool(StereoMicrophone, "voice.stereoMic");
values.SetInt(PushToTalkKeyCode, "voice.pttKeyCode"); Set("voice.inputDevice", InputDeviceId); Set("voice.outputDevice", OutputDeviceId);
values.SetBool(AuxiliaryEnabled, "voice.auxEnabled"); Set("voice.auxDeviceUID", AuxiliaryDeviceId); values.SetFloat(AuxiliaryGain, "voice.auxGain"); values.SetInt(AudioBufferMilliseconds, "voice.audioBufferMs");
values.SetBool(EventSounds, "feedback.sounds"); values.SetBool(SpokenEvents, "feedback.speech"); values.SetFloat(EventVolume, "feedback.volume");
values.SetBool(SelfTalkSounds, "feedback.selfTalk"); values.SetBool(PushToTalkSound, "feedback.ptt"); values.Synchronize();
}
private void Set(string key, string? value) { if (value is null) values.RemoveObject(key); else values.SetString(value, key); }
private static string? EmptyToNull(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
}
@@ -0,0 +1,486 @@
using AppKit;
using AVFoundation;
using CoreGraphics;
using Foundation;
using VoiceCat.Audio;
using VoiceCat.Core;
using Voicecat.V1;
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, 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);
private readonly NSTextField compose = new(new CGRect(400, 55, 230, 28)) { PlaceholderString = "Message" };
private readonly NSButton send = new(new CGRect(640, 53, 90, 32)) { Title = "Send" };
private readonly NSButton voice = new(new CGRect(630, 510, 100, 32)) { Title = "Join voice" };
private readonly NSButton settingsButton = new(new CGRect(740, 510, 90, 32)) { Title = "Settings" };
private readonly NSButton screenAudioButton = new(new CGRect(840, 510, 130, 32)) { Title = "Share audio" };
private readonly NSButton administrationButton = new(new CGRect(840, 20, 130, 28)) { Title = "Administration" };
private readonly NSButton privateMessageButton = new(new CGRect(700, 20, 130, 28)) { Title = "Private chat" };
private readonly NSButton muteButton = NSButton.CreateCheckbox("Mute", () => { });
private readonly NSButton deafenButton = NSButton.CreateCheckbox("Deafen", () => { });
private readonly NSTextField status = NSTextField.CreateLabel("Connected");
private readonly MacSettings settings = new();
private readonly EventFeedback feedback;
private readonly NSTimer timer;
private uint currentChannel = 1;
private uint microphoneStreamId;
private uint auxiliaryStreamId;
private uint screenAudioStreamId;
private bool joinedVoice;
private bool changingChannel;
private IAudioCapture? microphone;
private IAudioCapture? auxiliary;
private IAudioPlayback? playback;
private ScreenAudioCapture? screenAudio;
private ScreenAudioSelection screenAudioSelection = ScreenAudioSelection.Default;
private SettingsWindowController? settingsWindow;
private AdministrationWindowController? administrationWindow;
private readonly Dictionary<uint, PrivateMessageWindowController> privateWindows = [];
private readonly HashSet<uint> talkingUsers = [];
private bool lastTalking;
private string? activeInputDevice;
private string? activeOutputDevice;
private string? activeAuxiliaryDevice;
private bool activeStereo;
private NSObject? pushToTalkMonitor;
private bool pushToTalkEngaged;
private long lastRemoteAudioTick;
internal MainWindowController(VoiceCatClient client, uint selfId, string nickname) : base(new NSWindow(new CGRect(0, 0, 1000, 570),
NSWindowStyle.Titled | NSWindowStyle.Closable | NSWindowStyle.Resizable | NSWindowStyle.Miniaturizable, NSBackingStore.Buffered, false))
{
this.client = client; this.selfId = selfId; settings.Load(); feedback = new(settings);
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);
((INSAccessibility)settingsButton).AccessibilityLabel = "Open audio and notification settings"; settingsButton.Activated += OpenSettings; content.AddSubview(settingsButton);
((INSAccessibility)screenAudioButton).AccessibilityLabel = "Start or stop sharing screen audio"; screenAudioButton.Activated += ToggleScreenAudio; content.AddSubview(screenAudioButton);
((INSAccessibility)administrationButton).AccessibilityLabel = "Open server moderation and administration"; administrationButton.Activated += OpenAdministration; content.AddSubview(administrationButton);
((INSAccessibility)privateMessageButton).AccessibilityLabel = "Open a private conversation with the selected message recipient"; privateMessageButton.Activated += OpenPrivateMessage; content.AddSubview(privateMessageButton);
muteButton.Frame = new CGRect(275, 490, 75, 22); deafenButton.Frame = new CGRect(355, 490, 85, 22);
((INSAccessibility)muteButton).AccessibilityLabel = "Mute microphone"; ((INSAccessibility)deafenButton).AccessibilityLabel = "Deafen playback";
muteButton.Activated += SelfAudioChanged; deafenButton.Activated += SelfAudioChanged; content.AddSubview(muteButton); content.AddSubview(deafenButton);
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);
((INSAccessibility)messageTarget).AccessibilityLabel = "Message recipient"; content.AddSubview(messageTarget);
((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, 660, 22); status.AccessibilityLabel = "Connection status"; content.AddSubview(status);
timer = NSTimer.CreateRepeatingScheduledTimer(TimeSpan.FromMilliseconds(50), _ => Pump());
ApplyEngineSettings(); feedback.Play(SoundEvent.Login); feedback.Speak("Connected");
pushToTalkMonitor = NSEvent.AddLocalMonitorForEventsMatchingMask(NSEventMask.KeyDown | NSEventMask.KeyUp, value => HandlePushToTalk(value)!);
RefreshState();
}
private NSEvent? HandlePushToTalk(NSEvent value)
{
if (settings.InputMode != AudioInputMode.PushToTalk || value.KeyCode != settings.PushToTalkKeyCode) return value;
bool engaged = value.Type == NSEventType.KeyDown;
client.Audio.PushToTalk = engaged;
if (engaged && !pushToTalkEngaged) feedback.Play(SoundEvent.PushToTalk);
pushToTalkEngaged = engaged; return null;
}
private void Pump()
{
while (client.TryReadEvent(out Envelope? envelope))
{
if (envelope!.TextMessage is { } text)
{
if (text.Scope == TextScope.TextPrivate)
{
uint other = text.SenderId == selfId ? text.TargetId : text.SenderId;
GetPrivateWindow(other).Append(text, Name(text.SenderId));
feedback.Play(text.SenderId == selfId ? SoundEvent.PrivateSent : SoundEvent.PrivateReceived);
if (text.SenderId != selfId) feedback.Speak($"Private message from {Name(text.SenderId)}: {text.Body}");
}
else { Append(FormatMessage(text)); feedback.Play(text.SenderId == selfId ? SoundEvent.ChannelSent : SoundEvent.ChannelReceived); }
}
if (envelope.StreamState is { } streamState)
{
if (streamState.Talking) talkingUsers.Add(streamState.UserId); else talkingUsers.Remove(streamState.UserId);
RefreshState();
}
if (envelope.UserEvent is { } userEvent)
{
string name = userEvent.User?.Nickname ?? Name(userEvent.LeftId);
if (userEvent.Kind == UserEvent.Types.Kind.Joined && userEvent.User?.ChannelId == currentChannel && userEvent.User.Id != selfId)
{ AppendActivity($"{name} joined the channel"); feedback.Play(SoundEvent.ChannelJoin); feedback.Speak($"{name} joined"); }
else if (userEvent.Kind == UserEvent.Types.Kind.Left)
{ talkingUsers.Remove(userEvent.LeftId); AppendActivity($"{name} left the server"); feedback.Play(SoundEvent.ChannelLeave); }
}
if (envelope.ServerState is not null || envelope.ChannelEvent is not null || envelope.UserEvent is not null) RefreshState();
if (envelope.Disconnect is { } disconnected)
{
StopAudioDevices(); joinedVoice = false; voice.Title = "Join voice";
status.StringValue = "Disconnected: " + disconnected.Reason; voice.Enabled = send.Enabled = messageTarget.Enabled = false;
feedback.Play(SoundEvent.ConnectionLost); feedback.Speak("Connection lost"); Announce(status.StringValue);
}
}
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);
if (talking != lastTalking)
{
lastTalking = talking; client.PublishStreamState(microphoneStreamId, talking);
feedback.Play(talking ? SoundEvent.VoiceStart : SoundEvent.VoiceStop);
}
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 OpenSettings(object? sender, EventArgs args)
{
settingsWindow ??= new(this, settings, audioBackend);
settingsWindow.ShowWindow(this); settingsWindow.Window?.MakeKeyAndOrderFront(this);
}
private void OpenAdministration(object? sender, EventArgs args)
{
administrationWindow ??= new(client, selfId); administrationWindow.ShowWindow(this); administrationWindow.Window?.MakeKeyAndOrderFront(this);
}
private void OpenPrivateMessage(object? sender, EventArgs args)
{
uint target = uint.TryParse(messageTarget.SelectedItem?.RepresentedObject?.ToString(), out uint selected) ? selected : 0;
if (target == 0) { status.StringValue = "Choose a private message recipient first."; return; }
PrivateMessageWindowController window = GetPrivateWindow(target); window.ShowWindow(this); window.Window?.MakeKeyAndOrderFront(this);
}
private PrivateMessageWindowController GetPrivateWindow(uint userId)
{
if (privateWindows.TryGetValue(userId, out PrivateMessageWindowController? existing)) return existing;
var created = new PrivateMessageWindowController(client, userId, selfId, Name(userId)); privateWindows[userId] = created; return created;
}
private void SelfAudioChanged(object? sender, EventArgs args)
{
bool muted = muteButton.State == NSCellStateValue.On;
bool deafened = deafenButton.State == NSCellStateValue.On;
client.SetSelfAudioState(muted, deafened);
status.StringValue = deafened ? "Deafened" : muted ? "Microphone muted" : "Voice active";
}
private async void ToggleScreenAudio(object? sender, EventArgs args)
{
try
{
screenAudioButton.Enabled = false;
if (screenAudioStreamId != 0) { StopScreenAudio(); return; }
if (!joinedVoice) throw new InvalidOperationException("Join voice before sharing screen audio.");
ScreenAudioSelection? selection = await ScreenAudioPicker.ChooseAsync(screenAudioSelection);
if (selection is null) return;
screenAudioSelection = selection;
StreamInfo stream = await client.StartStreamAsync(StreamKind.StreamScreenAudio, "Desktop audio", 2);
screenAudioStreamId = stream.StreamId;
int channelCount = stream.Audio.Mode == ChannelMode.ModeStereo ? 2 : 1;
var capture = new ScreenAudioCapture(channelCount, screenAudioSelection,
(pcm, channels) => { uint id = screenAudioStreamId; if (id != 0) client.Audio.FeedPcm(id, pcm, channels); });
capture.Failed += exception => NSApplication.SharedApplication.InvokeOnMainThread(() => { status.StringValue = exception.Message; StopScreenAudio(); });
screenAudio = capture; await capture.StartAsync(); screenAudioButton.Title = "Stop sharing";
status.StringValue = "Sharing desktop audio";
}
catch (Exception exception) { StopScreenAudio(); status.StringValue = exception.Message; }
finally { screenAudioButton.Enabled = true; }
}
private void StopScreenAudio()
{
Interlocked.Exchange(ref screenAudio, null)?.Dispose();
uint id = screenAudioStreamId; screenAudioStreamId = 0;
TryStopStream(id);
screenAudioButton.Title = "Share audio";
}
private void ApplyEngineSettings()
{
client.Audio.InputMode = settings.InputMode; client.Audio.VadThreshold = settings.VadThreshold;
if (settings.InputMode != AudioInputMode.PushToTalk) { client.Audio.PushToTalk = false; pushToTalkEngaged = false; }
client.Audio.InputGain = settings.InputGain; client.Audio.OutputGain = settings.OutputGain;
client.Audio.InputNoiseReduction = settings.InputNoiseReduction;
client.Audio.DeviceBufferMilliseconds = settings.AudioBufferMilliseconds;
if (playback is not null) playback.BufferMilliseconds = settings.AudioBufferMilliseconds;
if (auxiliaryStreamId != 0) client.Audio.SetLocalGain(auxiliaryStreamId, settings.AuxiliaryGain);
}
internal async Task ApplySettingsAsync()
{
ApplyEngineSettings();
if (!joinedVoice) return;
bool restart = activeInputDevice != settings.InputDeviceId || activeOutputDevice != settings.OutputDeviceId || activeStereo != settings.StereoMicrophone;
if (restart) { await LeaveVoice(); await JoinVoice(); return; }
if (settings.AuxiliaryEnabled && auxiliaryStreamId == 0) await StartAuxiliaryAsync();
else if (!settings.AuxiliaryEnabled && auxiliaryStreamId != 0) StopAuxiliary();
else if (settings.AuxiliaryEnabled && activeAuxiliaryDevice != settings.AuxiliaryDeviceId) { StopAuxiliary(); await StartAuxiliaryAsync(); }
}
private void RefreshState()
{
uint previousTarget = uint.TryParse(messageTarget.SelectedItem?.RepresentedObject?.ToString(), out uint selectedTarget) ? selectedTarget : 0;
channels.RemoveAllItems();
IReadOnlyList<(Channel Channel, int Depth)> hierarchy = FlattenChannels(client.Channels);
foreach ((Channel channel, int depth) in hierarchy)
{
int members = client.Users.Count(user => user.ChannelId == channel.Id);
channels.AddItem(new string(' ', depth * 3) + channel.Name + $" ({members})" + (channel.PasswordProtected ? " [password]" : ""));
channels.LastItem!.RepresentedObject = new NSString(channel.Id.ToString());
channels.LastItem!.ToolTip = channel.Topic;
}
currentChannel = client.Users.FirstOrDefault(u => u.Id == selfId)?.ChannelId ?? currentChannel;
int selectedIndex = hierarchy.ToList().FindIndex(item => item.Channel.Id == currentChannel); if (selectedIndex >= 0) channels.SelectItem(selectedIndex);
users.Value = string.Join("\n", client.Users.Where(u => u.ChannelId == currentChannel).OrderBy(u => u.Nickname).Select(u =>
(talkingUsers.Contains(u.Id) ? "Speaking — " : "") + (u.Id == selfId ? "You — " : "") + u.Nickname +
(u.ServerDeafened ? " [server deafened]" : u.ServerMuted ? " [server muted]" : u.SelfDeafened ? " [deafened]" : u.SelfMicMuted ? " [muted]" : "")));
messageTarget.RemoveAllItems(); messageTarget.AddItem("Current channel"); messageTarget.LastItem!.RepresentedObject = new NSString("0");
foreach (var user in client.Users.Where(user => user.Id != selfId).OrderBy(user => user.Nickname))
{
messageTarget.AddItem("Private: " + user.Nickname);
messageTarget.LastItem!.RepresentedObject = new NSString(user.Id.ToString());
}
int targetIndex = client.Users.Where(user => user.Id != selfId).OrderBy(user => user.Nickname).ToList().FindIndex(user => user.Id == previousTarget) + 1;
messageTarget.SelectItem(Math.Max(0, targetIndex));
status.StringValue = $"Connected · {client.Users.Count} users";
}
private static IReadOnlyList<(Channel Channel, int Depth)> FlattenChannels(IReadOnlyList<Channel> values)
{
var result = new List<(Channel, int)>(); var visited = new HashSet<uint>();
void AddChildren(uint parent, int depth)
{
foreach (Channel channel in values.Where(channel => channel.ParentId == parent).OrderBy(channel => channel.Order).ThenBy(channel => channel.Name))
if (visited.Add(channel.Id)) { result.Add((channel, depth)); AddChildren(channel.Id, depth + 1); }
}
AddChildren(0, 0);
foreach (Channel channel in values.Where(channel => !visited.Contains(channel.Id)).OrderBy(channel => channel.Order).ThenBy(channel => channel.Name)) result.Add((channel, 0));
return result;
}
private async void ChangeChannel(object? sender, EventArgs args)
{
if (changingChannel || !uint.TryParse(channels.SelectedItem?.RepresentedObject?.ToString(), out uint id) || id == currentChannel) return;
var channel = client.Channels.FirstOrDefault(item => item.Id == id);
if (channel is null) { RefreshState(); return; }
string? password = channel.PasswordProtected ? PromptForChannelPassword(channel.Name) : "";
if (password is null) { RefreshState(); return; }
bool resumeVoice = joinedVoice;
try
{
changingChannel = true; channels.Enabled = false;
if (resumeVoice) await LeaveVoice();
var result = (await client.RequestAsync(new() { JoinChannel = new() { ChannelId = id, Password = password } })).JoinChannelResult;
if (result.Ok) currentChannel = id;
if (resumeVoice) await JoinVoice();
RefreshState();
status.StringValue = result.Ok ? $"Joined {channel.Name}" : result.Error;
}
catch (Exception exception)
{
string message = exception.Message;
if (resumeVoice && !joinedVoice)
{
try { await JoinVoice(); }
catch (Exception restoreException) { message += " Voice could not be restored: " + restoreException.Message; }
}
RefreshState(); status.StringValue = message;
}
finally { changingChannel = false; channels.Enabled = true; }
}
private string? PromptForChannelPassword(string channelName)
{
var field = new NSSecureTextField(new CGRect(0, 0, 320, 26)) { PlaceholderString = "Channel password" };
((INSAccessibility)field).AccessibilityLabel = $"Password for {channelName}";
var alert = new NSAlert
{
MessageText = $"Join {channelName}",
InformativeText = "Enter the channel password.",
AlertStyle = NSAlertStyle.Informational,
AccessoryView = field
};
alert.AddButton("Join"); alert.AddButton("Cancel");
if (alert.RunModal() != 1000) { field.StringValue = ""; return null; }
string value = field.StringValue; field.StringValue = ""; return value;
}
private void Send(object? sender, EventArgs args)
{
string body = compose.StringValue.Trim(); if (body.Length == 0) return;
uint target = uint.TryParse(messageTarget.SelectedItem?.RepresentedObject?.ToString(), out uint selectedTarget) ? selectedTarget : 0;
try { client.Send(new() { TextMessage = new() { Scope = target == 0 ? TextScope.TextChannel : TextScope.TextPrivate, TargetId = target == 0 ? currentChannel : target, Body = body, ClientMsgId = Guid.NewGuid().ToString("N") } }); compose.StringValue = ""; }
catch (Exception exception) { status.StringValue = exception.Message; }
}
private async void ToggleVoice(object? sender, EventArgs args)
{
try
{
voice.Enabled = false;
if (!joinedVoice) await JoinVoice();
else await LeaveVoice();
}
catch (Exception exception) { status.StringValue = exception.Message; }
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
{
ApplyEngineSettings();
activeInputDevice = settings.InputDeviceId ?? SelectedDevice(inputDevice);
activeOutputDevice = settings.OutputDeviceId ?? SelectedDevice(outputDevice);
activeStereo = settings.StereoMicrophone;
playback = audioBackend.OpenPlayback(activeOutputDevice);
playback.BufferMilliseconds = settings.AudioBufferMilliseconds;
client.Audio.MixedPcm += PlayMixedPcm;
StreamInfo stream = await client.StartStreamAsync(StreamKind.StreamMic, "Microphone", settings.StereoMicrophone ? 2 : 1);
microphoneStreamId = stream.StreamId;
microphone = audioBackend.OpenCapture(activeInputDevice, false, FeedMicrophone);
if (settings.AuxiliaryEnabled) await StartAuxiliaryAsync();
joinedVoice = true; voice.Title = "Leave voice"; status.StringValue = "Voice connected";
feedback.Play(SoundEvent.VoiceOn);
}
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";
feedback.Play(SoundEvent.VoiceOff);
}
private async Task StartAuxiliaryAsync()
{
if (auxiliaryStreamId != 0) return;
StreamInfo stream = await client.StartStreamAsync(StreamKind.StreamAuxDevice, "Aux device", 2);
auxiliaryStreamId = stream.StreamId; client.Audio.SetLocalGain(stream.StreamId, settings.AuxiliaryGain);
try { activeAuxiliaryDevice = settings.AuxiliaryDeviceId; auxiliary = audioBackend.OpenCapture(activeAuxiliaryDevice, false, FeedAuxiliary); }
catch { client.StopStream(auxiliaryStreamId); auxiliaryStreamId = 0; throw; }
}
private void StopAuxiliary()
{
Interlocked.Exchange(ref auxiliary, null)?.Dispose();
activeAuxiliaryDevice = null;
uint id = auxiliaryStreamId; auxiliaryStreamId = 0;
TryStopStream(id);
}
private void FeedAuxiliary(ReadOnlySpan<short> pcm, int channels)
{
uint id = auxiliaryStreamId; if (id != 0) client.Audio.FeedPcm(id, pcm, channels);
}
private void FeedMicrophone(ReadOnlySpan<short> pcm, int channels)
{
uint streamId = microphoneStreamId;
if (streamId != 0) client.Audio.FeedPcm(streamId, pcm, channels);
}
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()
{
StopScreenAudio();
StopAuxiliary();
microphoneStreamId = 0;
Volatile.Write(ref lastRemoteAudioTick, 0);
Interlocked.Exchange(ref microphone, null)?.Dispose();
client.Audio.MixedPcm -= PlayMixedPcm;
Interlocked.Exchange(ref playback, null)?.Dispose();
activeInputDevice = activeOutputDevice = null; lastTalking = false;
}
private void TryStopStream(uint id)
{
if (id == 0 || client.State != ClientConnectionState.Connected) return;
try { client.StopStream(id); }
catch (InvalidOperationException) { }
catch (IOException) { }
}
private string Name(uint id) => client.Users.FirstOrDefault(u => u.Id == id)?.Nickname ?? $"User {id}";
private string FormatMessage(TextMessage text)
{
string prefix = text.Scope switch
{
TextScope.TextPrivate => $"[private: {Name(text.SenderId == selfId ? text.TargetId : text.SenderId)}] ",
TextScope.TextServer => "[server] ",
_ => ""
};
return $"[{DateTime.Now:t}] {prefix}{Name(text.SenderId)}: {text.Body}";
}
private void Append(string line)
{
chat.Value = chat.Value.Length == 0 ? line : chat.Value + "\n" + line;
chat.ScrollRangeToVisible(new NSRange(chat.Value.Length, 0));
}
private void AppendActivity(string line) => Append($"[{DateTime.Now:t}] — {line}");
private void Announce(string value)
{
NSString[] keys = [NSAccessibilityNotificationUserInfoKeys.AnnouncementKey, NSAccessibilityNotificationUserInfoKeys.PriorityKey];
NSObject[] values = [(NSString)value, NSNumber.FromInt32(1)];
NSAccessibility.PostNotification(status, NSView.AnnouncementRequestedNotification, new NSDictionary<NSString, NSObject>(keys, values));
}
protected override void Dispose(bool disposing)
{
if (disposing) { timer.Invalidate(); if (pushToTalkMonitor is not null) NSEvent.RemoveMonitor(pushToTalkMonitor); client.Audio.PushToTalk = false; settingsWindow?.Close(); administrationWindow?.Close(); foreach (PrivateMessageWindowController window in privateWindows.Values) window.Close(); privateWindows.Clear(); StopAudioDevices(); feedback.Dispose(); client.DisposeAsync().AsTask().GetAwaiter().GetResult(); }
base.Dispose(disposing);
}
}
@@ -0,0 +1,43 @@
using AppKit;
using CoreGraphics;
using VoiceCat.Core;
using Voicecat.V1;
namespace VoiceCat.Mac;
internal sealed class PrivateMessageWindowController : NSWindowController
{
private readonly VoiceCatClient client;
private readonly uint otherUserId;
private readonly uint selfId;
private readonly NSTextView transcript = new(new CGRect(0, 0, 430, 300)) { Editable = false, Selectable = true };
private readonly NSTextField compose = new(new CGRect(20, 20, 350, 28)) { PlaceholderString = "Private message" };
internal PrivateMessageWindowController(VoiceCatClient client, uint otherUserId, uint selfId, string nickname) : base(new NSWindow(
new CGRect(0, 0, 480, 400), NSWindowStyle.Titled | NSWindowStyle.Closable | NSWindowStyle.Resizable,
NSBackingStore.Buffered, false))
{
this.client = client; this.otherUserId = otherUserId; this.selfId = selfId;
Window!.Title = $"Private message — {nickname}"; Window.Center();
NSView view = Window.ContentView!;
var scroll = new NSScrollView(new CGRect(20, 65, 440, 315)) { DocumentView = transcript, HasVerticalScroller = true };
scroll.AccessibilityLabel = $"Private conversation with {nickname}"; view.AddSubview(scroll);
((INSAccessibility)compose).AccessibilityLabel = $"Message to {nickname}"; compose.Activated += Send; view.AddSubview(compose);
var button = new NSButton(new CGRect(380, 18, 80, 32)) { Title = "Send" }; button.Activated += Send; view.AddSubview(button);
}
private void Send(object? sender, EventArgs args)
{
string value = compose.StringValue.Trim(); if (value.Length == 0) return;
client.Send(new() { TextMessage = new() { Scope = TextScope.TextPrivate, TargetId = otherUserId, Body = value, ClientMsgId = Guid.NewGuid().ToString("N") } });
compose.StringValue = "";
}
internal void Append(TextMessage message, string sender)
{
string line = $"[{DateTime.Now:t}] {(message.SenderId == selfId ? "You" : sender)}: {message.Body}";
transcript.Value = transcript.Value.Length == 0 ? line : transcript.Value + "\n" + line;
transcript.ScrollRangeToVisible(new Foundation.NSRange(transcript.Value.Length, 0));
Window?.MakeKeyAndOrderFront(this);
}
}
+5
View File
@@ -0,0 +1,5 @@
using AppKit;
NSApplication.Init();
NSApplication.SharedApplication.Delegate = new VoiceCat.Mac.AppDelegate();
NSApplication.SharedApplication.Run();
@@ -0,0 +1,156 @@
using System.Runtime.InteropServices;
using CoreFoundation;
using CoreMedia;
using Foundation;
using ObjCRuntime;
using ScreenCaptureKit;
using VoiceCat.Audio;
namespace VoiceCat.Mac;
internal enum ScreenAudioScope { EntireDesktop, OnlyApplications, AllExceptApplications }
internal sealed record ScreenAudioSelection(ScreenAudioScope Scope, IReadOnlySet<string> BundleIdentifiers, bool ExcludeScreenReader)
{
internal static ScreenAudioSelection Default { get; } = new(ScreenAudioScope.EntireDesktop, new HashSet<string>(), false);
}
internal sealed class ScreenAudioCapture : NSObject, ISCStreamOutput
{
private static readonly HashSet<string> ScreenReaders = ["com.apple.VoiceOver", "com.apple.VoiceOver4", "com.apple.speech.speechsynthesisd"];
private readonly int channels;
private readonly CapturePcmHandler onPcm;
private readonly ScreenAudioSelection selection;
private readonly DispatchQueue queue = new("net.iamtalon.voicecat.screen-audio");
private readonly CaptureDelegate streamDelegate;
private SCStream? stream;
private int disposed;
internal ScreenAudioCapture(int channels, ScreenAudioSelection selection, CapturePcmHandler onPcm)
{
this.channels = Math.Clamp(channels, 1, 2); this.selection = selection; this.onPcm = onPcm;
streamDelegate = new(error => Failed?.Invoke(error));
}
internal event Action<Exception>? Failed;
internal static async Task<IReadOnlyList<ScreenApplication>> GetApplicationsAsync(CancellationToken cancellationToken = default)
{
SCShareableContent content = await GetContentAsync(cancellationToken).ConfigureAwait(false);
return content.Applications.Where(application => !string.IsNullOrWhiteSpace(application.BundleIdentifier) &&
!ScreenReaders.Contains(application.BundleIdentifier) && application.BundleIdentifier != NSBundle.MainBundle.BundleIdentifier)
.GroupBy(application => application.BundleIdentifier, StringComparer.Ordinal).Select(group => group.First())
.Select(application => new ScreenApplication(string.IsNullOrWhiteSpace(application.ApplicationName) ? application.BundleIdentifier : application.ApplicationName,
application.BundleIdentifier)).OrderBy(application => application.Name, StringComparer.CurrentCultureIgnoreCase).ToArray();
}
internal async Task StartAsync(CancellationToken cancellationToken = default)
{
SCShareableContent content = await GetContentAsync(cancellationToken).ConfigureAwait(false);
SCDisplay display = content.Displays.FirstOrDefault() ?? throw new InvalidOperationException("No display is available for screen audio capture.");
SCRunningApplication[] matching = content.Applications.Where(application => Included(application.BundleIdentifier)).ToArray();
SCContentFilterOption option = selection.Scope == ScreenAudioScope.OnlyApplications ? SCContentFilterOption.Include : SCContentFilterOption.Exclude;
var filter = new SCContentFilter(display, matching, [], option);
var configuration = new SCStreamConfiguration
{
CapturesAudio = true, ExcludesCurrentProcessAudio = true, SampleRate = 48_000,
ChannelCount = channels, Width = 2, Height = 2, QueueDepth = 6,
MinimumFrameInterval = new CMTime(1, 1)
};
var created = new SCStream(filter, configuration, streamDelegate);
if (!created.AddStreamOutput(this, SCStreamOutputType.Audio, queue, out NSError? outputError))
throw new InvalidOperationException(outputError?.LocalizedDescription ?? "Could not attach the screen-audio output.");
stream = created;
var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
created.StartCapture(error => { if (error is null) started.TrySetResult(); else started.TrySetException(new InvalidOperationException(error.LocalizedDescription)); });
await started.Task.WaitAsync(TimeSpan.FromSeconds(15), cancellationToken).ConfigureAwait(false);
}
private bool Included(string bundleIdentifier)
{
bool selected = selection.BundleIdentifiers.Contains(bundleIdentifier);
bool reader = selection.ExcludeScreenReader && ScreenReaders.Contains(bundleIdentifier);
return selection.Scope switch
{
ScreenAudioScope.OnlyApplications => selected,
ScreenAudioScope.AllExceptApplications => selected || reader,
_ => reader
};
}
[Export("stream:didOutputSampleBuffer:ofType:")]
public unsafe void DidOutputSampleBuffer(SCStream captureStream, CMSampleBuffer sampleBuffer, SCStreamOutputType type)
{
if (type != SCStreamOutputType.Audio || !sampleBuffer.DataIsReady || disposed != 0) return;
int frames = checked((int)sampleBuffer.NumSamples);
if (frames <= 0 || frames > 8192) return;
Span<byte> listStorage = stackalloc byte[40];
fixed (byte* list = listStorage)
{
int status = CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer(sampleBuffer.Handle, out _, (IntPtr)list,
(nuint)listStorage.Length, IntPtr.Zero, IntPtr.Zero, 0, out IntPtr blockBuffer);
if (status != 0) return;
try
{
uint count = *(uint*)list;
if (count is 0 or > 2) return;
Span<short> converted = stackalloc short[frames * channels];
NativeAudioBuffer* first = (NativeAudioBuffer*)(list + 8);
bool planar = count > 1;
int sourceChannels = planar ? checked((int)count) : checked((int)Math.Max(1, first->Channels));
for (int frame = 0; frame < frames; frame++)
{
float left = planar ? ((float*)first[0].Data)[frame] : ((float*)first[0].Data)[frame * sourceChannels];
float right = sourceChannels > 1
? planar ? ((float*)first[1].Data)[frame] : ((float*)first[0].Data)[frame * sourceChannels + 1]
: left;
if (channels == 1) converted[frame] = ToInt16(sourceChannels > 1 ? (left + right) * 0.5f : left);
else { converted[frame * 2] = ToInt16(left); converted[frame * 2 + 1] = ToInt16(right); }
}
onPcm(converted, channels);
}
finally { if (blockBuffer != IntPtr.Zero) CFRelease(blockBuffer); }
}
}
private static short ToInt16(float value) => (short)Math.Clamp((int)MathF.Round(Math.Clamp(value, -1, 1) * 32767), short.MinValue, short.MaxValue);
private static Task<SCShareableContent> GetContentAsync(CancellationToken cancellationToken)
{
var completion = new TaskCompletionSource<SCShareableContent>(TaskCreationOptions.RunContinuationsAsynchronously);
SCShareableContent.GetShareableContent((content, error) =>
{
if (error is not null) completion.TrySetException(new UnauthorizedAccessException(error.LocalizedDescription));
else if (content is null) completion.TrySetException(new InvalidOperationException("ScreenCaptureKit returned no shareable content."));
else completion.TrySetResult(content);
});
cancellationToken.Register(() => completion.TrySetCanceled(cancellationToken));
return completion.Task;
}
protected override void Dispose(bool disposing)
{
if (disposing && Interlocked.Exchange(ref disposed, 1) == 0)
{
SCStream? previous = Interlocked.Exchange(ref stream, null);
if (previous is null) { streamDelegate.Dispose(); queue.Dispose(); }
else previous.StopCapture(_ => { previous.Dispose(); streamDelegate.Dispose(); queue.Dispose(); });
}
base.Dispose(disposing);
}
[StructLayout(LayoutKind.Sequential)]
private struct NativeAudioBuffer { internal uint Channels, DataByteSize; internal IntPtr Data; }
private sealed class CaptureDelegate(Action<Exception> failed) : SCStreamDelegate
{
public override void DidStop(SCStream stream, NSError error) => failed(new IOException(error.LocalizedDescription));
}
[DllImport("/System/Library/Frameworks/CoreMedia.framework/CoreMedia")]
private static extern int CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer(IntPtr sampleBuffer,
out nuint bufferListSizeNeeded, IntPtr bufferList, nuint bufferListSize, IntPtr structureAllocator,
IntPtr blockAllocator, uint flags, out IntPtr blockBuffer);
[DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")]
private static extern void CFRelease(IntPtr value);
}
@@ -0,0 +1,49 @@
using AppKit;
using CoreGraphics;
using Foundation;
using ScreenCaptureKit;
namespace VoiceCat.Mac;
internal sealed record ScreenApplication(string Name, string BundleIdentifier);
internal static class ScreenAudioPicker
{
internal static async Task<ScreenAudioSelection?> ChooseAsync(ScreenAudioSelection initial, CancellationToken cancellationToken = default)
{
IReadOnlyList<ScreenApplication> applications = await ScreenAudioCapture.GetApplicationsAsync(cancellationToken);
var mode = new NSPopUpButton(new CGRect(0, 270, 390, 28), false);
mode.AddItems(["Everything", "Only selected applications", "All except selected applications"]);
mode.SelectItem((int)initial.Scope);
((INSAccessibility)mode).AccessibilityLabel = "Screen audio sharing mode";
var source = new ApplicationSource(applications);
var table = new NSTableView(new CGRect(0, 0, 390, 225)) { DataSource = source, Delegate = source, AllowsMultipleSelection = true, HeaderView = null };
table.AddColumn(new NSTableColumn("application") { Title = "Application", Width = 370 });
((INSAccessibility)table).AccessibilityLabel = "Applications to include or exclude";
uint[] selected = applications.Select((application, index) => initial.BundleIdentifiers.Contains(application.BundleIdentifier) ? (uint?)index : null)
.Where(index => index.HasValue).Select(index => index!.Value).ToArray();
if (selected.Length > 0) table.SelectRows(NSIndexSet.FromArray(selected), false);
var scroll = new NSScrollView(new CGRect(0, 38, 390, 225)) { DocumentView = table, HasVerticalScroller = true, BorderType = NSBorderType.BezelBorder };
var exclude = NSButton.CreateCheckbox("Exclude VoiceOver and speech-synthesis audio", () => { });
exclude.Frame = new CGRect(0, 5, 390, 24); exclude.State = initial.ExcludeScreenReader ? NSCellStateValue.On : NSCellStateValue.Off;
((INSAccessibility)exclude).AccessibilityLabel = "Exclude screen reader audio";
var accessory = new NSView(new CGRect(0, 0, 390, 305)); accessory.AddSubview(mode); accessory.AddSubview(scroll); accessory.AddSubview(exclude);
var alert = new NSAlert { MessageText = "Choose screen audio", InformativeText = "Select what other people will hear.", AccessoryView = accessory };
alert.AddButton("Share"); alert.AddButton("Cancel");
if (alert.RunModal() != 1000) return null;
var bundleIdentifiers = table.SelectedRows.ToArray().Select(index => applications[checked((int)index)].BundleIdentifier).ToHashSet(StringComparer.Ordinal);
return new((ScreenAudioScope)(int)mode.IndexOfSelectedItem, bundleIdentifiers, exclude.State == NSCellStateValue.On);
}
private sealed class ApplicationSource(IReadOnlyList<ScreenApplication> applications) : NSTableViewDataSource, INSTableViewDelegate
{
public override nint GetRowCount(NSTableView tableView) => applications.Count;
[Export("tableView:viewForTableColumn:row:")]
public NSView GetViewForItem(NSTableView tableView, NSTableColumn? tableColumn, nint row)
{
var value = NSTextField.CreateLabel(applications[checked((int)row)].Name);
value.LineBreakMode = AppKit.NSLineBreakMode.TruncatingTail; return value;
}
}
}
@@ -0,0 +1,126 @@
using AppKit;
using CoreGraphics;
using VoiceCat.Audio;
namespace VoiceCat.Mac;
internal sealed class SettingsWindowController : NSWindowController
{
private readonly MainWindowController owner;
private readonly MacSettings settings;
private readonly MacAudioBackend audio;
private readonly NSPopUpButton mode = new(new CGRect(160, 455, 260, 28), false);
private readonly NSSlider vad = new(new CGRect(160, 415, 260, 24)) { MinValue = 0, MaxValue = 100 };
private readonly NSSlider inputGain = new(new CGRect(160, 375, 260, 24)) { MinValue = 0, MaxValue = 400 };
private readonly NSButton noiseReduction = NSButton.CreateCheckbox("Noise reduction (RNNoise)", () => { });
private readonly NSButton stereo = NSButton.CreateCheckbox("Stereo microphone", () => { });
private readonly NSPopUpButton input = new(new CGRect(160, 285, 260, 28), false);
private readonly NSPopUpButton output = new(new CGRect(160, 245, 260, 28), false);
private readonly NSSlider outputGain = new(new CGRect(160, 205, 260, 24)) { MinValue = 0, MaxValue = 100 };
private readonly NSButton auxiliary = NSButton.CreateCheckbox("Aux input stream", () => { });
private readonly NSPopUpButton auxiliaryDevice = new(new CGRect(160, 125, 260, 28), false);
private readonly NSSlider auxiliaryGain = new(new CGRect(160, 85, 260, 24)) { MinValue = 0, MaxValue = 400 };
private readonly NSButton sounds = NSButton.CreateCheckbox("Event sounds", () => { });
private readonly NSButton speech = NSButton.CreateCheckbox("Speak events", () => { });
private readonly NSButton selfTalk = NSButton.CreateCheckbox("Own voice-activity sounds", () => { });
private readonly NSButton pttSound = NSButton.CreateCheckbox("Push-to-talk cue", () => { });
private readonly NSButton pttKey = new(new CGRect(220, 12, 200, 32)) { Title = "Change PTT key…" };
private readonly NSSlider eventVolume = new(new CGRect(160, 505, 260, 24)) { MinValue = 0, MaxValue = 100 };
private readonly NSPopUpButton audioBuffer = new(new CGRect(160, 545, 260, 28), false);
internal SettingsWindowController(MainWindowController owner, MacSettings settings, MacAudioBackend audio) : base(
new NSWindow(new CGRect(0, 0, 460, 660), NSWindowStyle.Titled | NSWindowStyle.Closable,
NSBackingStore.Buffered, false))
{
this.owner = owner; this.settings = settings; this.audio = audio;
Window!.Title = "VoiceCat Settings"; Window.Center();
NSView view = Window.ContentView!;
mode.AddItems(["Voice activation", "Push to talk", "Always on"]);
audioBuffer.AddItems(["Low latency (20 ms)", "Balanced (40 ms)", "Stable (60 ms)"]);
Add(view, "Audio buffering", audioBuffer, 555);
Add(view, "Sound volume", eventVolume, 515);
Add(view, "Input mode", mode, 465); Add(view, "VAD sensitivity", vad, 425); Add(view, "Microphone volume", inputGain, 385);
noiseReduction.Frame = new CGRect(160, 340, 260, 24); stereo.Frame = new CGRect(160, 315, 260, 24);
view.AddSubview(noiseReduction); view.AddSubview(stereo);
Add(view, "Input device", input, 295); Add(view, "Output device", output, 255); Add(view, "Output volume", outputGain, 215);
auxiliary.Frame = new CGRect(160, 165, 260, 24); view.AddSubview(auxiliary);
Add(view, "Aux device", auxiliaryDevice, 135); Add(view, "Aux volume", auxiliaryGain, 95);
sounds.Frame = new CGRect(25, 45, 110, 24); speech.Frame = new CGRect(145, 45, 110, 24);
selfTalk.Frame = new CGRect(265, 45, 180, 24); pttSound.Frame = new CGRect(25, 18, 180, 24);
view.AddSubview(sounds); view.AddSubview(speech); view.AddSubview(selfTalk); view.AddSubview(pttSound); view.AddSubview(pttKey);
foreach (NSControl control in new NSControl[] { mode, vad, inputGain, noiseReduction, stereo, input, output,
outputGain, auxiliary, auxiliaryDevice, auxiliaryGain, sounds, speech, selfTalk, pttSound, eventVolume, audioBuffer }) control.Activated += Changed;
pttKey.Activated += CapturePushToTalkKey;
Populate(input, true, settings.InputDeviceId); Populate(output, false, settings.OutputDeviceId); Populate(auxiliaryDevice, true, settings.AuxiliaryDeviceId);
LoadValues(); SetAccessibility();
}
private static void Add(NSView view, string label, NSView control, double y)
{
NSTextField text = NSTextField.CreateLabel(label); text.Frame = new CGRect(25, y, 125, 22); view.AddSubview(text); view.AddSubview(control);
}
private void Populate(NSPopUpButton picker, bool capture, string? selected)
{
picker.RemoveAllItems(); IReadOnlyList<AudioDeviceInfo> devices = audio.Enumerate(capture);
foreach (AudioDeviceInfo device in devices) { picker.AddItem(device.Name + (device.IsDefault ? " (default)" : "")); picker.LastItem!.RepresentedObject = new Foundation.NSString(device.Id); }
int index = devices.ToList().FindIndex(device => device.Id == selected);
if (index < 0) index = devices.ToList().FindIndex(device => device.IsDefault);
if (index >= 0) picker.SelectItem(index); picker.Enabled = devices.Count > 0;
}
private void LoadValues()
{
mode.SelectItem((int)settings.InputMode); vad.DoubleValue = settings.VadThreshold * 100;
inputGain.DoubleValue = settings.InputGain * 100; outputGain.DoubleValue = settings.OutputGain * 100;
noiseReduction.State = State(settings.InputNoiseReduction); stereo.State = State(settings.StereoMicrophone);
auxiliary.State = State(settings.AuxiliaryEnabled); auxiliaryGain.DoubleValue = settings.AuxiliaryGain * 100;
sounds.State = State(settings.EventSounds); speech.State = State(settings.SpokenEvents);
eventVolume.DoubleValue = settings.EventVolume * 100;
selfTalk.State = State(settings.SelfTalkSounds); pttSound.State = State(settings.PushToTalkSound);
pttKey.Title = $"PTT key code: {settings.PushToTalkKeyCode}";
audioBuffer.SelectItem(settings.AudioBufferMilliseconds switch { 20 => 0, 60 => 2, _ => 1 });
UpdateEnabled();
}
private void CapturePushToTalkKey(object? sender, EventArgs args)
{
var alert = new NSAlert { MessageText = "Set push-to-talk key", InformativeText = "Press the key to use while VoiceCat is focused." };
alert.AddButton("Cancel"); NSObject? monitor = null;
monitor = NSEvent.AddLocalMonitorForEventsMatchingMask(NSEventMask.KeyDown, value =>
{
settings.PushToTalkKeyCode = value.KeyCode; settings.Save(); pttKey.Title = $"PTT key code: {value.KeyCode}";
NSApplication.SharedApplication.AbortModal(); return null!;
});
try { alert.RunModal(); }
finally { if (monitor is not null) NSEvent.RemoveMonitor(monitor); }
}
private async void Changed(object? sender, EventArgs args)
{
settings.InputMode = (AudioInputMode)mode.IndexOfSelectedItem; settings.VadThreshold = (float)vad.DoubleValue / 100;
settings.InputGain = (float)inputGain.DoubleValue / 100; settings.OutputGain = (float)outputGain.DoubleValue / 100;
settings.InputNoiseReduction = On(noiseReduction); settings.StereoMicrophone = On(stereo);
settings.InputDeviceId = Selected(input); settings.OutputDeviceId = Selected(output);
settings.AuxiliaryEnabled = On(auxiliary); settings.AuxiliaryDeviceId = Selected(auxiliaryDevice);
settings.AuxiliaryGain = (float)auxiliaryGain.DoubleValue / 100; settings.EventSounds = On(sounds);
settings.EventVolume = (float)eventVolume.DoubleValue / 100; settings.SpokenEvents = On(speech);
settings.SelfTalkSounds = On(selfTalk); settings.PushToTalkSound = On(pttSound);
settings.AudioBufferMilliseconds = audioBuffer.IndexOfSelectedItem switch { 0 => 20, 2 => 60, _ => 40 };
settings.Save(); UpdateEnabled(); await owner.ApplySettingsAsync();
}
private void UpdateEnabled() { vad.Enabled = settings.InputMode == AudioInputMode.VoiceActivation; auxiliaryDevice.Enabled = auxiliaryGain.Enabled = settings.AuxiliaryEnabled; }
private void SetAccessibility()
{
((INSAccessibility)mode).AccessibilityLabel = "Microphone input mode"; ((INSAccessibility)vad).AccessibilityLabel = "Voice activation sensitivity";
((INSAccessibility)inputGain).AccessibilityLabel = "Microphone volume"; ((INSAccessibility)input).AccessibilityLabel = "Microphone device";
((INSAccessibility)output).AccessibilityLabel = "Output device"; ((INSAccessibility)outputGain).AccessibilityLabel = "Output volume";
((INSAccessibility)auxiliaryDevice).AccessibilityLabel = "Auxiliary input device"; ((INSAccessibility)auxiliaryGain).AccessibilityLabel = "Auxiliary input volume";
((INSAccessibility)eventVolume).AccessibilityLabel = "Event sound volume";
((INSAccessibility)audioBuffer).AccessibilityLabel = "Audio buffering";
}
private static bool On(NSButton button) => button.State == NSCellStateValue.On;
private static NSCellStateValue State(bool value) => value ? NSCellStateValue.On : NSCellStateValue.Off;
private static string? Selected(NSPopUpButton picker) => picker.SelectedItem?.RepresentedObject?.ToString();
}
@@ -0,0 +1,46 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0-macos27.0</TargetFramework>
<RuntimeIdentifier>osx-arm64</RuntimeIdentifier>
<SupportedOSPlatformVersion>14.0</SupportedOSPlatformVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<ApplicationTitle>VoiceCat</ApplicationTitle>
<ApplicationId>net.iamtalon.voicecat</ApplicationId>
<UseHardenedRuntime Condition="'$(Configuration)' == 'Release'">true</UseHardenedRuntime>
<UseHardenedRuntime Condition="'$(Configuration)' != 'Release'">false</UseHardenedRuntime>
<ApplicationManifest>Info.plist</ApplicationManifest>
<CodesignEntitlements>VoiceCat.Mac.entitlements</CodesignEntitlements>
<NoWarn>$(NoWarn);XCODE_27_0_PREVIEW</NoWarn>
<VoiceCatNativeMediaPath>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../../../artifacts/native/runtimes/osx-arm64/native/libvoicecat_media.dylib'))</VoiceCatNativeMediaPath>
<VoiceCatNativeLicenseDirectory>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../../../artifacts/native/licenses'))</VoiceCatNativeLicenseDirectory>
<_ComputePublishLocationDependsOn>VoiceCatPrepareNativeAssets;$(_ComputePublishLocationDependsOn)</_ComputePublishLocationDependsOn>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../../src/VoiceCat.Core/VoiceCat.Core.csproj" />
<BundleResource Include="../../../assets/sounds/*.wav" Link="Sounds/%(Filename)%(Extension)" />
</ItemGroup>
<!-- Every managed media project stages the same dylib for ordinary .NET consumers.
The macOS bundler preserves those transitive items separately and otherwise runs
install_name_tool against the same temporary file concurrently. Collapse them to
the single native reference the application actually ships. -->
<Target Name="VoiceCatPrepareNativeAssets">
<ItemGroup>
<ResolvedFileToPublish Remove="@(ResolvedFileToPublish)" Condition="'%(Filename)%(Extension)' == 'libvoicecat_media.dylib'" />
<ResolvedFileToPublish Remove="@(ResolvedFileToPublish)" Condition="'%(Filename)%(Extension)' == 'NOTICE.txt' or '%(Filename)%(Extension)' == 'Opus.txt' or '%(Filename)%(Extension)' == 'RNNoise.txt'" />
<ResolvedFileToPublish Include="$(VoiceCatNativeMediaPath)" Condition="Exists('$(VoiceCatNativeMediaPath)')">
<RelativePath>libvoicecat_media.dylib</RelativePath>
<PublishFolderType>DynamicLibrary</PublishFolderType>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</ResolvedFileToPublish>
<ResolvedFileToPublish Include="$(VoiceCatNativeLicenseDirectory)/*.txt">
<RelativePath>licenses/%(Filename)%(Extension)</RelativePath>
<PublishFolderType>Assembly</PublishFolderType>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</ResolvedFileToPublish>
</ItemGroup>
</Target>
</Project>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>com.apple.security.app-sandbox</key><true/>
<key>com.apple.security.network.client</key><true/>
<key>com.apple.security.device.audio-input</key><true/>
<key>com.apple.security.files.user-selected.read-write</key><true/>
</dict></plist>