forked from Manuel/meeting-assistant
275 lines
8.4 KiB
C#
275 lines
8.4 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",
|
|
"MeetingAssistantAudioCapture.app",
|
|
"Contents",
|
|
"MacOS",
|
|
"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;
|
|
}
|
|
}
|