Public Access
feat: add macOS meeting audio capture
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
internal enum MacOsAudioCaptureKind
|
||||
{
|
||||
Microphone,
|
||||
System
|
||||
}
|
||||
|
||||
internal sealed record MacOsAudioCaptureRequest(
|
||||
MacOsAudioCaptureKind Kind,
|
||||
int SampleRate,
|
||||
int Channels);
|
||||
|
||||
internal interface IMacOsAudioCaptureProcessFactory
|
||||
{
|
||||
IMacOsAudioCaptureProcess Start(MacOsAudioCaptureRequest request);
|
||||
}
|
||||
|
||||
internal interface IMacOsAudioCaptureProcess : IAsyncDisposable
|
||||
{
|
||||
Stream StandardOutput { get; }
|
||||
|
||||
int ExitCode { get; }
|
||||
|
||||
bool HasExited { get; }
|
||||
|
||||
Task<string> ReadStandardErrorAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task WaitForExitAsync(CancellationToken cancellationToken);
|
||||
|
||||
void Terminate();
|
||||
}
|
||||
|
||||
internal abstract class MacOsMeetingAudioSource : IMeetingAudioSource
|
||||
{
|
||||
private const int ReadBufferSize = 8 * 1024;
|
||||
private readonly MacOsAudioCaptureKind kind;
|
||||
private readonly IMacOsAudioCaptureProcessFactory processFactory;
|
||||
private readonly ILogger logger;
|
||||
|
||||
protected MacOsMeetingAudioSource(
|
||||
MacOsAudioCaptureKind kind,
|
||||
IMacOsAudioCaptureProcessFactory processFactory,
|
||||
ILogger logger)
|
||||
{
|
||||
this.kind = kind;
|
||||
this.processFactory = processFactory;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<AudioChunk> CaptureAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return CaptureAsync(new MeetingAssistantOptions(), cancellationToken);
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<AudioChunk> CaptureAsync(
|
||||
MeetingAssistantOptions options,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var request = new MacOsAudioCaptureRequest(
|
||||
kind,
|
||||
options.Recording.SampleRate,
|
||||
options.Recording.Channels);
|
||||
await using var process = processFactory.Start(request);
|
||||
var standardErrorTask = process.ReadStandardErrorAsync(CancellationToken.None);
|
||||
var buffer = new byte[ReadBufferSize];
|
||||
byte? pendingByte = null;
|
||||
var hasLoggedFirstChunk = false;
|
||||
|
||||
logger.LogInformation(
|
||||
"Starting macOS {CaptureKind} audio capture at {SampleRate} Hz/{Channels} channel(s)",
|
||||
kind,
|
||||
request.SampleRate,
|
||||
request.Channels);
|
||||
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var bytesRead = await process.StandardOutput.ReadAsync(buffer, cancellationToken);
|
||||
if (bytesRead == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var pcm = CopyCompleteSamples(buffer, bytesRead, ref pendingByte);
|
||||
if (pcm.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!hasLoggedFirstChunk)
|
||||
{
|
||||
hasLoggedFirstChunk = true;
|
||||
logger.LogInformation(
|
||||
"macOS {CaptureKind} audio produced its first PCM chunk: {ByteCount} bytes",
|
||||
kind,
|
||||
pcm.Length);
|
||||
}
|
||||
|
||||
yield return new AudioChunk(pcm, request.SampleRate, request.Channels);
|
||||
}
|
||||
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
var standardError = await standardErrorTask;
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
throw CreateCaptureException(kind, process.ExitCode, standardError);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!process.HasExited)
|
||||
{
|
||||
process.Terminate();
|
||||
await process.WaitForExitAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] CopyCompleteSamples(byte[] buffer, int bytesRead, ref byte? pendingByte)
|
||||
{
|
||||
var totalBytes = bytesRead + (pendingByte.HasValue ? 1 : 0);
|
||||
var completeBytes = totalBytes - totalBytes % sizeof(short);
|
||||
var pcm = new byte[completeBytes];
|
||||
var targetOffset = 0;
|
||||
|
||||
if (pendingByte is { } firstByte)
|
||||
{
|
||||
pcm[0] = firstByte;
|
||||
targetOffset = 1;
|
||||
pendingByte = null;
|
||||
}
|
||||
|
||||
var copyCount = completeBytes - targetOffset;
|
||||
if (copyCount > 0)
|
||||
{
|
||||
Buffer.BlockCopy(buffer, 0, pcm, targetOffset, copyCount);
|
||||
}
|
||||
|
||||
var consumedFromBuffer = copyCount;
|
||||
if (consumedFromBuffer < bytesRead)
|
||||
{
|
||||
pendingByte = buffer[consumedFromBuffer];
|
||||
}
|
||||
|
||||
return pcm;
|
||||
}
|
||||
|
||||
private static InvalidOperationException CreateCaptureException(
|
||||
MacOsAudioCaptureKind kind,
|
||||
int exitCode,
|
||||
string standardError)
|
||||
{
|
||||
var detail = string.IsNullOrWhiteSpace(standardError)
|
||||
? "The native capture helper did not provide an error message."
|
||||
: standardError.Trim();
|
||||
return new InvalidOperationException(
|
||||
$"macOS {kind.ToString().ToLowerInvariant()} audio capture exited with code {exitCode}. {detail}");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class MacOsMicrophoneAudioSource : MacOsMeetingAudioSource
|
||||
{
|
||||
public MacOsMicrophoneAudioSource(
|
||||
IMacOsAudioCaptureProcessFactory processFactory,
|
||||
ILogger<MacOsMicrophoneAudioSource> logger)
|
||||
: base(MacOsAudioCaptureKind.Microphone, processFactory, logger)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class MacOsSystemAudioSource : MacOsMeetingAudioSource
|
||||
{
|
||||
public MacOsSystemAudioSource(
|
||||
IMacOsAudioCaptureProcessFactory processFactory,
|
||||
ILogger<MacOsSystemAudioSource> logger)
|
||||
: base(MacOsAudioCaptureKind.System, processFactory, logger)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class MacOsAudioCaptureProcessFactory : IMacOsAudioCaptureProcessFactory
|
||||
{
|
||||
private readonly ILogger<MacOsAudioCaptureProcessFactory> logger;
|
||||
|
||||
public MacOsAudioCaptureProcessFactory(ILogger<MacOsAudioCaptureProcessFactory> logger)
|
||||
{
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public IMacOsAudioCaptureProcess Start(MacOsAudioCaptureRequest request)
|
||||
{
|
||||
if (!OperatingSystem.IsMacOS())
|
||||
{
|
||||
throw new PlatformNotSupportedException("The macOS audio capture helper can only run on macOS.");
|
||||
}
|
||||
|
||||
var helperPath = Path.Combine(AppContext.BaseDirectory, "Native", "macos-meeting-audio-capture");
|
||||
if (!File.Exists(helperPath))
|
||||
{
|
||||
throw new FileNotFoundException(
|
||||
$"The macOS audio capture helper was not found at '{helperPath}'. Build or publish the application on macOS so the Swift helper is compiled into the Native folder.",
|
||||
helperPath);
|
||||
}
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = helperPath,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
startInfo.ArgumentList.Add(request.Kind == MacOsAudioCaptureKind.Microphone ? "microphone" : "system");
|
||||
startInfo.ArgumentList.Add(request.SampleRate.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
startInfo.ArgumentList.Add(request.Channels.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
|
||||
logger.LogDebug("Starting native macOS audio helper {HelperPath} for {CaptureKind}", helperPath, request.Kind);
|
||||
var process = Process.Start(startInfo)
|
||||
?? throw new InvalidOperationException($"Failed to start the macOS audio capture helper '{helperPath}'.");
|
||||
return new MacOsAudioCaptureProcess(process);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
internal sealed class MacOsAudioCaptureProcess : IMacOsAudioCaptureProcess
|
||||
{
|
||||
private readonly Process process;
|
||||
|
||||
public MacOsAudioCaptureProcess(Process process)
|
||||
{
|
||||
this.process = process;
|
||||
}
|
||||
|
||||
public Stream StandardOutput => process.StandardOutput.BaseStream;
|
||||
|
||||
public int ExitCode => process.ExitCode;
|
||||
|
||||
public bool HasExited => process.HasExited;
|
||||
|
||||
public Task<string> ReadStandardErrorAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return process.StandardError.ReadToEndAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task WaitForExitAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return process.WaitForExitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public void Terminate()
|
||||
{
|
||||
if (!process.HasExited)
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
process.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
internal static class PortableMeetingAudioRegistration
|
||||
{
|
||||
public static void Add(IServiceCollection services)
|
||||
{
|
||||
Add(services, OperatingSystem.IsMacOS());
|
||||
}
|
||||
|
||||
internal static void Add(IServiceCollection services, bool isMacOs)
|
||||
{
|
||||
if (!isMacOs)
|
||||
{
|
||||
services.AddSingleton<IMeetingAudioSource, UnavailableMeetingAudioSource>();
|
||||
return;
|
||||
}
|
||||
|
||||
services.AddSingleton<IMacOsAudioCaptureProcessFactory, MacOsAudioCaptureProcessFactory>();
|
||||
services.AddSingleton<MacOsMicrophoneAudioSource>();
|
||||
services.AddSingleton<MacOsSystemAudioSource>();
|
||||
services.AddSingleton<IAcousticEchoCancellerFactory, AdaptiveFilterAcousticEchoCancellerFactory>();
|
||||
services.AddSingleton<IMeetingAudioSource>(serviceProvider => new CompositeMeetingAudioSource(
|
||||
serviceProvider.GetRequiredService<MacOsMicrophoneAudioSource>(),
|
||||
serviceProvider.GetRequiredService<MacOsSystemAudioSource>(),
|
||||
serviceProvider.GetRequiredService<IAcousticEchoCancellerFactory>(),
|
||||
serviceProvider.GetRequiredService<ILogger<CompositeMeetingAudioSource>>()));
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,6 @@ public sealed class UnavailableMeetingAudioSource : IMeetingAudioSource
|
||||
public IAsyncEnumerable<AudioChunk> CaptureAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
throw new PlatformNotSupportedException(
|
||||
"Meeting audio capture is only implemented for the Windows target in this version.");
|
||||
"Meeting audio capture is supported on Windows and macOS, but is unavailable on this host.");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user