Complete managed macOS platform bring-up
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / apple-client (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / apple-client (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s
This commit is contained in:
@@ -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); }
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using AudioUnit;
|
||||
using AVFoundation;
|
||||
using Foundation;
|
||||
using VoiceCat.Audio;
|
||||
@@ -7,25 +8,21 @@ namespace VoiceCat.Mac;
|
||||
|
||||
internal sealed class MacAudioBackend : IAudioDeviceBackend
|
||||
{
|
||||
private static readonly AudioDeviceInfo[] DefaultInput = [new("", "System default microphone", true)];
|
||||
private static readonly AudioDeviceInfo[] DefaultOutput = [new("", "System default output", true)];
|
||||
private Exception? failure;
|
||||
|
||||
internal Exception? Failure => Volatile.Read(ref failure);
|
||||
|
||||
public IReadOnlyList<AudioDeviceInfo> Enumerate(bool input) => input ? DefaultInput : DefaultOutput;
|
||||
public 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.");
|
||||
if (!string.IsNullOrEmpty(deviceId)) throw new NotSupportedException("Selecting a non-default Core Audio input is not implemented yet.");
|
||||
return new Capture(pcm, ReportFailure);
|
||||
return new Capture(deviceId, pcm, ReportFailure);
|
||||
}
|
||||
|
||||
public IAudioPlayback OpenPlayback(string? deviceId = null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(deviceId)) throw new NotSupportedException("Selecting a non-default Core Audio output is not implemented yet.");
|
||||
return new Playback(ReportFailure);
|
||||
return new Playback(deviceId, ReportFailure);
|
||||
}
|
||||
|
||||
private void ReportFailure(Exception exception) => Interlocked.CompareExchange(ref failure, exception, null);
|
||||
@@ -43,11 +40,16 @@ internal sealed class MacAudioBackend : IAudioDeviceBackend
|
||||
private bool inputProvided;
|
||||
private int disposed;
|
||||
|
||||
internal Capture(CapturePcmHandler handler, Action<Exception> reportFailure)
|
||||
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);
|
||||
@@ -55,7 +57,19 @@ internal sealed class MacAudioBackend : IAudioDeviceBackend
|
||||
uint outputCapacity = checked((uint)Math.Ceiling(4_096 * 48_000 / inputFormat.SampleRate) + 64);
|
||||
converted = new(outputFormat, outputCapacity);
|
||||
inputProvider = ProvideInput;
|
||||
input.InstallTapOnBus(0, 960, inputFormat, Convert);
|
||||
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))
|
||||
{
|
||||
@@ -126,6 +140,7 @@ internal sealed class MacAudioBackend : IAudioDeviceBackend
|
||||
private sealed class Playback : IAudioPlayback
|
||||
{
|
||||
private readonly PcmRing pcm = new(32_768);
|
||||
private readonly short[] renderScratch = new short[8_192];
|
||||
private readonly AVAudioEngine engine = new();
|
||||
private readonly AVAudioFormat format;
|
||||
private readonly AVAudioSourceNode source;
|
||||
@@ -133,13 +148,33 @@ internal sealed class MacAudioBackend : IAudioDeviceBackend
|
||||
private int callbackFailed;
|
||||
private int disposed;
|
||||
|
||||
internal Playback(Action<Exception> reportFailure)
|
||||
internal Playback(string? deviceId, Action<Exception> reportFailure)
|
||||
{
|
||||
this.reportFailure = reportFailure;
|
||||
format = new(AVAudioCommonFormat.PCMInt16, 48_000, 2, true);
|
||||
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);
|
||||
engine.Connect(source, engine.MainMixerNode, format);
|
||||
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))
|
||||
{
|
||||
@@ -159,17 +194,26 @@ internal sealed class MacAudioBackend : IAudioDeviceBackend
|
||||
{
|
||||
int bufferCount = Marshal.ReadInt32(outputData);
|
||||
int firstBuffer = IntPtr.Size == 8 ? 8 : 4;
|
||||
if (bufferCount != 1) return Fail("Core Audio returned a non-interleaved playback buffer.");
|
||||
int channels = Marshal.ReadInt32(outputData, firstBuffer);
|
||||
int byteCount = Marshal.ReadInt32(outputData, firstBuffer + 4);
|
||||
nint data = Marshal.ReadIntPtr(outputData, firstBuffer + 8);
|
||||
int requested = checked((int)frameCount * 2);
|
||||
if (channels != 2 || data == 0 || byteCount < requested * sizeof(short)) return Fail("Core Audio returned an invalid stereo playback buffer.");
|
||||
var output = new Span<short>((void*)data, requested);
|
||||
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> discard = stackalloc short[1_920];
|
||||
while (pcm.Count > 11_520) pcm.Read(discard[..Math.Min(discard.Length, pcm.Count - 11_520)]);
|
||||
int read = pcm.Read(output);
|
||||
output[read..].Clear();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using AppKit;
|
||||
using AVFoundation;
|
||||
using CoreGraphics;
|
||||
using Foundation;
|
||||
using VoiceCat.Audio;
|
||||
@@ -12,7 +13,9 @@ internal sealed class MainWindowController : NSWindowController
|
||||
private readonly VoiceCatClient client;
|
||||
private readonly MacAudioBackend audioBackend = new();
|
||||
private readonly uint selfId;
|
||||
private readonly NSPopUpButton channels = new(new CGRect(20, 515, 300, 28), false);
|
||||
private readonly NSPopUpButton channels = new(new CGRect(20, 515, 245, 28), false);
|
||||
private readonly NSPopUpButton inputDevice = new(new CGRect(275, 515, 165, 28), false);
|
||||
private readonly NSPopUpButton outputDevice = new(new CGRect(450, 515, 165, 28), false);
|
||||
private readonly NSTextView users = new(new CGRect(0, 0, 190, 430)) { Editable = false, Selectable = true };
|
||||
private readonly NSTextView chat = new(new CGRect(0, 0, 510, 390)) { Editable = false, Selectable = true };
|
||||
private readonly NSPopUpButton messageTarget = new(new CGRect(230, 55, 160, 28), false);
|
||||
@@ -27,6 +30,7 @@ internal sealed class MainWindowController : NSWindowController
|
||||
private bool changingChannel;
|
||||
private IAudioCapture? microphone;
|
||||
private IAudioPlayback? playback;
|
||||
private long lastRemoteAudioTick;
|
||||
|
||||
internal MainWindowController(VoiceCatClient client, uint selfId, string nickname) : base(new NSWindow(new CGRect(0, 0, 760, 570),
|
||||
NSWindowStyle.Titled | NSWindowStyle.Closable | NSWindowStyle.Resizable | NSWindowStyle.Miniaturizable, NSBackingStore.Buffered, false))
|
||||
@@ -35,6 +39,9 @@ internal sealed class MainWindowController : NSWindowController
|
||||
Window!.Title = $"VoiceCat — {nickname}"; Window.Center(); Window.MinSize = new CGSize(680, 480);
|
||||
var content = Window.ContentView!;
|
||||
((INSAccessibility)channels).AccessibilityLabel = "Channel"; channels.Activated += ChangeChannel; content.AddSubview(channels);
|
||||
PopulateAudioDevices(inputDevice, true); PopulateAudioDevices(outputDevice, false);
|
||||
((INSAccessibility)inputDevice).AccessibilityLabel = "Microphone input device"; inputDevice.Activated += ChangeAudioDevice; content.AddSubview(inputDevice);
|
||||
((INSAccessibility)outputDevice).AccessibilityLabel = "Audio output device"; outputDevice.Activated += ChangeAudioDevice; content.AddSubview(outputDevice);
|
||||
((INSAccessibility)voice).AccessibilityLabel = "Join or leave voice"; voice.Activated += ToggleVoice; content.AddSubview(voice);
|
||||
var userScroll = new NSScrollView(new CGRect(20, 90, 190, 410)) { HasVerticalScroller = true, DocumentView = users }; userScroll.AccessibilityLabel = "Users in channel"; content.AddSubview(userScroll);
|
||||
var chatScroll = new NSScrollView(new CGRect(230, 90, 500, 410)) { HasVerticalScroller = true, DocumentView = chat }; chatScroll.AccessibilityLabel = "Channel messages"; content.AddSubview(chatScroll);
|
||||
@@ -58,8 +65,14 @@ internal sealed class MainWindowController : NSWindowController
|
||||
status.StringValue = "Disconnected: " + disconnected.Reason; voice.Enabled = send.Enabled = messageTarget.Enabled = false;
|
||||
}
|
||||
}
|
||||
if (client.Audio.Failure is { } failure) status.StringValue = "Audio stopped: " + failure.Message;
|
||||
if (audioBackend.Failure is { } deviceFailure) status.StringValue = "Audio device stopped: " + deviceFailure.Message;
|
||||
if (client.Audio.Failure is { } failure) { status.StringValue = "Audio stopped: " + failure.Message; return; }
|
||||
if (audioBackend.Failure is { } deviceFailure) { status.StringValue = "Audio device stopped: " + deviceFailure.Message; return; }
|
||||
if (joinedVoice)
|
||||
{
|
||||
(float level, bool talking) = client.Audio.GetLocalLevel(microphoneStreamId);
|
||||
bool receiving = Environment.TickCount64 - Volatile.Read(ref lastRemoteAudioTick) < 1_000;
|
||||
status.StringValue = $"Voice connected · Mic {(talking ? "sending" : "idle")} {level:P0} · Remote audio {(receiving ? "active" : "idle")}";
|
||||
}
|
||||
}
|
||||
private void RefreshState()
|
||||
{
|
||||
@@ -148,17 +161,46 @@ internal sealed class MainWindowController : NSWindowController
|
||||
finally { voice.Enabled = true; }
|
||||
}
|
||||
|
||||
private void PopulateAudioDevices(NSPopUpButton menu, bool input)
|
||||
{
|
||||
IReadOnlyList<AudioDeviceInfo> devices = audioBackend.Enumerate(input);
|
||||
foreach (AudioDeviceInfo device in devices)
|
||||
{
|
||||
menu.AddItem(device.Name + (device.IsDefault ? " (default)" : ""));
|
||||
menu.LastItem!.RepresentedObject = new NSString(device.Id);
|
||||
}
|
||||
int selected = devices.ToList().FindIndex(device => device.IsDefault);
|
||||
if (selected >= 0) menu.SelectItem(selected);
|
||||
menu.Enabled = devices.Count > 0;
|
||||
}
|
||||
|
||||
private async void ChangeAudioDevice(object? sender, EventArgs args)
|
||||
{
|
||||
if (!joinedVoice) return;
|
||||
try { await LeaveVoice(); await JoinVoice(); }
|
||||
catch (Exception exception) { status.StringValue = exception.Message; }
|
||||
}
|
||||
|
||||
private async Task JoinVoice()
|
||||
{
|
||||
AVAuthorizationStatus permission = AVCaptureDevice.GetAuthorizationStatus(AVAuthorizationMediaType.Audio);
|
||||
if (permission == AVAuthorizationStatus.NotDetermined)
|
||||
{
|
||||
bool granted = await AVCaptureDevice.RequestAccessForMediaTypeAsync(AVAuthorizationMediaType.Audio);
|
||||
permission = granted ? AVAuthorizationStatus.Authorized : AVAuthorizationStatus.Denied;
|
||||
}
|
||||
if (permission != AVAuthorizationStatus.Authorized)
|
||||
throw new UnauthorizedAccessException("Microphone access is disabled. Enable VoiceCat in System Settings → Privacy & Security → Microphone, then join voice again.");
|
||||
|
||||
var result = await client.SubscribeVoiceAsync();
|
||||
if (!result.Ok) throw new InvalidOperationException(result.Error);
|
||||
try
|
||||
{
|
||||
playback = audioBackend.OpenPlayback();
|
||||
playback = audioBackend.OpenPlayback(SelectedDevice(outputDevice));
|
||||
client.Audio.MixedPcm += PlayMixedPcm;
|
||||
StreamInfo stream = await client.StartStreamAsync(StreamKind.StreamMic, "Microphone");
|
||||
microphoneStreamId = stream.StreamId;
|
||||
microphone = audioBackend.OpenCapture(null, false, FeedMicrophone);
|
||||
microphone = audioBackend.OpenCapture(SelectedDevice(inputDevice), false, FeedMicrophone);
|
||||
joinedVoice = true; voice.Title = "Leave voice"; status.StringValue = "Voice connected";
|
||||
}
|
||||
catch
|
||||
@@ -186,11 +228,21 @@ internal sealed class MainWindowController : NSWindowController
|
||||
if (streamId != 0) client.Audio.FeedPcm(streamId, pcm, channels);
|
||||
}
|
||||
|
||||
private void PlayMixedPcm(ReadOnlySpan<short> pcm) => Volatile.Read(ref playback)?.Write(pcm);
|
||||
private static string? SelectedDevice(NSPopUpButton menu) => menu.SelectedItem?.RepresentedObject?.ToString();
|
||||
|
||||
private void PlayMixedPcm(ReadOnlySpan<short> pcm)
|
||||
{
|
||||
bool signal = false;
|
||||
foreach (short sample in pcm)
|
||||
if (sample is > 64 or < -64) { signal = true; break; }
|
||||
if (signal) Volatile.Write(ref lastRemoteAudioTick, Environment.TickCount64);
|
||||
Volatile.Read(ref playback)?.Write(pcm);
|
||||
}
|
||||
|
||||
private void StopAudioDevices()
|
||||
{
|
||||
microphoneStreamId = 0;
|
||||
Volatile.Write(ref lastRemoteAudioTick, 0);
|
||||
Interlocked.Exchange(ref microphone, null)?.Dispose();
|
||||
client.Audio.MixedPcm -= PlayMixedPcm;
|
||||
Interlocked.Exchange(ref playback, null)?.Dispose();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0-macos</TargetFramework>
|
||||
<TargetFramework>net10.0-macos27.0</TargetFramework>
|
||||
<RuntimeIdentifier>osx-arm64</RuntimeIdentifier>
|
||||
<SupportedOSPlatformVersion>14.0</SupportedOSPlatformVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
@@ -9,11 +9,37 @@
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<ApplicationTitle>VoiceCat</ApplicationTitle>
|
||||
<ApplicationId>net.iamtalon.voicecat</ApplicationId>
|
||||
<UseHardenedRuntime>true</UseHardenedRuntime>
|
||||
<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)../../../../dotnet/artifacts/native/runtimes/osx-arm64/native/libvoicecat_media.dylib'))</VoiceCatNativeMediaPath>
|
||||
<VoiceCatNativeLicenseDirectory>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../../../../dotnet/artifacts/native/licenses'))</VoiceCatNativeLicenseDirectory>
|
||||
<_ComputePublishLocationDependsOn>VoiceCatPrepareNativeAssets;$(_ComputePublishLocationDependsOn)</_ComputePublishLocationDependsOn>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../../dotnet/src/VoiceCat.Core/VoiceCat.Core.csproj" />
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user