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 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 CaptureAsync(CancellationToken cancellationToken) { return CaptureAsync(new MeetingAssistantOptions(), cancellationToken); } public async IAsyncEnumerable 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 logger) : base(MacOsAudioCaptureKind.Microphone, processFactory, logger) { } } internal sealed class MacOsSystemAudioSource : MacOsMeetingAudioSource { public MacOsSystemAudioSource( IMacOsAudioCaptureProcessFactory processFactory, ILogger logger) : base(MacOsAudioCaptureKind.System, processFactory, logger) { } } internal sealed class MacOsAudioCaptureProcessFactory : IMacOsAudioCaptureProcessFactory { private readonly ILogger logger; public MacOsAudioCaptureProcessFactory(ILogger 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 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; } }