.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
201 lines
8.5 KiB
C#
201 lines
8.5 KiB
C#
using System.Runtime.InteropServices;
|
|
using AVFoundation;
|
|
using Foundation;
|
|
using VoiceCat.Audio;
|
|
|
|
namespace VoiceCat.Mac;
|
|
|
|
internal sealed class MacAudioBackend : IAudioDeviceBackend
|
|
{
|
|
private static readonly AudioDeviceInfo[] DefaultInput = [new("", "System default microphone", true)];
|
|
private static readonly AudioDeviceInfo[] DefaultOutput = [new("", "System default output", true)];
|
|
private Exception? failure;
|
|
|
|
internal Exception? Failure => Volatile.Read(ref failure);
|
|
|
|
public IReadOnlyList<AudioDeviceInfo> Enumerate(bool input) => input ? DefaultInput : DefaultOutput;
|
|
|
|
public IAudioCapture OpenCapture(string? deviceId, bool loopback, CapturePcmHandler pcm)
|
|
{
|
|
if (loopback) throw new NotSupportedException("Screen audio uses ScreenCaptureKit, not microphone capture.");
|
|
if (!string.IsNullOrEmpty(deviceId)) throw new NotSupportedException("Selecting a non-default Core Audio input is not implemented yet.");
|
|
return new Capture(pcm, ReportFailure);
|
|
}
|
|
|
|
public IAudioPlayback OpenPlayback(string? deviceId = null)
|
|
{
|
|
if (!string.IsNullOrEmpty(deviceId)) throw new NotSupportedException("Selecting a non-default Core Audio output is not implemented yet.");
|
|
return new Playback(ReportFailure);
|
|
}
|
|
|
|
private void ReportFailure(Exception exception) => Interlocked.CompareExchange(ref failure, exception, null);
|
|
|
|
private sealed class Capture : IAudioCapture
|
|
{
|
|
private readonly CapturePcmHandler handler;
|
|
private readonly AVAudioEngine engine = new();
|
|
private readonly AVAudioFormat outputFormat;
|
|
private readonly AVAudioConverter converter;
|
|
private readonly AVAudioPcmBuffer converted;
|
|
private readonly AVAudioConverterInputHandler inputProvider;
|
|
private readonly Action<Exception> reportFailure;
|
|
private AVAudioPcmBuffer? pendingInput;
|
|
private bool inputProvided;
|
|
private int disposed;
|
|
|
|
internal Capture(CapturePcmHandler handler, Action<Exception> reportFailure)
|
|
{
|
|
this.handler = handler;
|
|
this.reportFailure = reportFailure;
|
|
AVAudioInputNode input = engine.InputNode;
|
|
AVAudioFormat inputFormat = input.GetBusOutputFormat(0);
|
|
uint channels = Math.Clamp(inputFormat.ChannelCount, 1u, 2u);
|
|
outputFormat = new(AVAudioCommonFormat.PCMInt16, 48_000, channels, true);
|
|
converter = new(inputFormat, outputFormat);
|
|
uint outputCapacity = checked((uint)Math.Ceiling(4_096 * 48_000 / inputFormat.SampleRate) + 64);
|
|
converted = new(outputFormat, outputCapacity);
|
|
inputProvider = ProvideInput;
|
|
input.InstallTapOnBus(0, 960, inputFormat, Convert);
|
|
engine.Prepare();
|
|
if (!engine.StartAndReturnError(out var error))
|
|
{
|
|
input.RemoveTapOnBus(0);
|
|
converted.Dispose();
|
|
converter.Dispose();
|
|
outputFormat.Dispose();
|
|
engine.Dispose();
|
|
throw new InvalidOperationException("Core Audio capture could not start: " + error.LocalizedDescription);
|
|
}
|
|
}
|
|
|
|
private unsafe void Convert(AVAudioPcmBuffer input, AVAudioTime _time)
|
|
{
|
|
try
|
|
{
|
|
pendingInput = input;
|
|
inputProvided = false;
|
|
converted.FrameLength = 0;
|
|
AVAudioConverterOutputStatus result = converter.ConvertToBuffer(converted, out NSError? error, inputProvider);
|
|
if (result == AVAudioConverterOutputStatus.Error)
|
|
{
|
|
reportFailure(new InvalidOperationException("Core Audio input conversion failed: " + error?.LocalizedDescription));
|
|
return;
|
|
}
|
|
if (converted.FrameLength == 0) return;
|
|
nint samples = Marshal.ReadIntPtr(converted.Int16ChannelData);
|
|
if (samples == 0) return;
|
|
int channels = checked((int)outputFormat.ChannelCount);
|
|
int remainingFrames = checked((int)converted.FrameLength);
|
|
var pcm = new ReadOnlySpan<short>((void*)samples, checked(remainingFrames * channels));
|
|
while (remainingFrames > 0)
|
|
{
|
|
int frames = Math.Min(960, remainingFrames);
|
|
handler(pcm[..(frames * channels)], channels);
|
|
pcm = pcm[(frames * channels)..];
|
|
remainingFrames -= frames;
|
|
}
|
|
}
|
|
catch (Exception exception) { reportFailure(exception); }
|
|
finally { pendingInput = null; }
|
|
}
|
|
|
|
private AVAudioBuffer ProvideInput(uint _, out AVAudioConverterInputStatus status)
|
|
{
|
|
if (!inputProvided && pendingInput is { } input)
|
|
{
|
|
inputProvided = true;
|
|
status = AVAudioConverterInputStatus.HaveData;
|
|
return input;
|
|
}
|
|
status = AVAudioConverterInputStatus.NoDataNow;
|
|
return null!;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (Interlocked.Exchange(ref disposed, 1) != 0) return;
|
|
engine.InputNode.RemoveTapOnBus(0);
|
|
engine.Stop();
|
|
converted.Dispose();
|
|
converter.Dispose();
|
|
outputFormat.Dispose();
|
|
engine.Dispose();
|
|
}
|
|
}
|
|
|
|
private sealed class Playback : IAudioPlayback
|
|
{
|
|
private readonly PcmRing pcm = new(32_768);
|
|
private readonly AVAudioEngine engine = new();
|
|
private readonly AVAudioFormat format;
|
|
private readonly AVAudioSourceNode source;
|
|
private readonly Action<Exception> reportFailure;
|
|
private int callbackFailed;
|
|
private int disposed;
|
|
|
|
internal Playback(Action<Exception> reportFailure)
|
|
{
|
|
this.reportFailure = reportFailure;
|
|
format = new(AVAudioCommonFormat.PCMInt16, 48_000, 2, true);
|
|
source = new(format, Render);
|
|
engine.AttachNode(source);
|
|
engine.Connect(source, engine.MainMixerNode, format);
|
|
engine.Prepare();
|
|
if (!engine.StartAndReturnError(out var error))
|
|
{
|
|
engine.DetachNode(source);
|
|
source.Dispose();
|
|
format.Dispose();
|
|
engine.Dispose();
|
|
throw new InvalidOperationException("Core Audio playback could not start: " + error.LocalizedDescription);
|
|
}
|
|
}
|
|
|
|
public void Write(ReadOnlySpan<short> stereoPcm) => pcm.TryWrite(stereoPcm);
|
|
|
|
private unsafe int Render(IntPtr isSilence, IntPtr _, uint frameCount, IntPtr outputData)
|
|
{
|
|
try
|
|
{
|
|
int bufferCount = Marshal.ReadInt32(outputData);
|
|
int firstBuffer = IntPtr.Size == 8 ? 8 : 4;
|
|
if (bufferCount != 1) return Fail("Core Audio returned a non-interleaved playback buffer.");
|
|
int channels = Marshal.ReadInt32(outputData, firstBuffer);
|
|
int byteCount = Marshal.ReadInt32(outputData, firstBuffer + 4);
|
|
nint data = Marshal.ReadIntPtr(outputData, firstBuffer + 8);
|
|
int requested = checked((int)frameCount * 2);
|
|
if (channels != 2 || data == 0 || byteCount < requested * sizeof(short)) return Fail("Core Audio returned an invalid stereo playback buffer.");
|
|
var output = new Span<short>((void*)data, requested);
|
|
Span<short> discard = stackalloc short[1_920];
|
|
while (pcm.Count > 11_520) pcm.Read(discard[..Math.Min(discard.Length, pcm.Count - 11_520)]);
|
|
int read = pcm.Read(output);
|
|
output[read..].Clear();
|
|
if (isSilence != IntPtr.Zero) Marshal.WriteByte(isSilence, read == 0 ? (byte)1 : (byte)0);
|
|
return 0;
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
if (Interlocked.Exchange(ref callbackFailed, 1) == 0) reportFailure(exception);
|
|
return -1;
|
|
}
|
|
}
|
|
|
|
private int Fail(string message)
|
|
{
|
|
if (Interlocked.Exchange(ref callbackFailed, 1) == 0) reportFailure(new InvalidOperationException(message));
|
|
return -1;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (Interlocked.Exchange(ref disposed, 1) != 0) return;
|
|
engine.Stop();
|
|
engine.DetachNode(source);
|
|
source.Dispose();
|
|
format.Dispose();
|
|
engine.Dispose();
|
|
}
|
|
}
|
|
|
|
}
|