Files
meeting-assistant/MeetingAssistant/Recording/MacOsMeetingAudioSource.cs
T

269 lines
8.3 KiB
C#

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;
}
}