feat: add macOS meeting audio capture

This commit is contained in:
dh
2026-08-06 08:16:54 +02:00
parent b3aeef24eb
commit 59ba70ca11
15 changed files with 1282 additions and 4 deletions
+26
View File
@@ -15,6 +15,16 @@
<UseWPF>true</UseWPF>
</PropertyGroup>
<PropertyGroup Condition="$([MSBuild]::IsOSPlatform('OSX')) and $([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) != 'windows'">
<MacOsAudioCaptureEnabled>true</MacOsAudioCaptureEnabled>
<MacOsAudioCaptureSource>$(MSBuildProjectDirectory)/Native/MacOsMeetingAudioCapture/main.swift</MacOsAudioCaptureSource>
<MacOsAudioCaptureOutputPath>Native/macos-meeting-audio-capture</MacOsAudioCaptureOutputPath>
<MacOsAudioCaptureRid Condition="'$(RuntimeIdentifier)' != ''">$(RuntimeIdentifier)</MacOsAudioCaptureRid>
<MacOsAudioCaptureRid Condition="'$(MacOsAudioCaptureRid)' == ''">$(NETCoreSdkRuntimeIdentifier)</MacOsAudioCaptureRid>
<MacOsAudioCaptureArchitecture Condition="'$(MacOsAudioCaptureRid)' == 'osx-x64'">x86_64</MacOsAudioCaptureArchitecture>
<MacOsAudioCaptureArchitecture Condition="'$(MacOsAudioCaptureArchitecture)' == ''">arm64</MacOsAudioCaptureArchitecture>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="MeetingAssistant.Tests" />
</ItemGroup>
@@ -61,6 +71,22 @@
<Copy SourceFiles="@(MeetingAssistantRootConfig)" DestinationFolder="$(PublishDir)" SkipUnchangedFiles="true" />
</Target>
<Target
Name="BuildMacOsAudioCaptureHelper"
AfterTargets="Build"
Condition="'$(MacOsAudioCaptureEnabled)' == 'true'">
<MakeDir Directories="$(TargetDir)Native" />
<Exec Command="/usr/bin/xcrun swiftc -parse-as-library -O -target $(MacOsAudioCaptureArchitecture)-apple-macos13.0 -framework AVFoundation -framework CoreMedia -framework ScreenCaptureKit &quot;$(MacOsAudioCaptureSource)&quot; -o &quot;$(TargetDir)$(MacOsAudioCaptureOutputPath)&quot;" />
</Target>
<Target
Name="CopyMacOsAudioCaptureHelperToPublish"
AfterTargets="Publish"
Condition="'$(MacOsAudioCaptureEnabled)' == 'true'">
<MakeDir Directories="$(PublishDir)Native" />
<Copy SourceFiles="$(TargetDir)$(MacOsAudioCaptureOutputPath)" DestinationFolder="$(PublishDir)Native" />
</Target>
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) != 'windows'">
<Compile Remove="Hotkeys\GlobalHotkeyService.cs" />
<Compile Remove="Recording\NaudioCaptureSource.cs" />
@@ -0,0 +1,328 @@
import AVFoundation
import CoreMedia
import Darwin
import Foundation
import ScreenCaptureKit
private enum CaptureFailure: Error, CustomStringConvertible {
case usage
case invalidFormat
case microphonePermission
case noDisplay
case audioConverter(String)
case systemAudio(String)
var description: String {
switch self {
case .usage:
return "Usage: macos-meeting-audio-capture <microphone|system> <sample-rate> <channels>"
case .invalidFormat:
return "Sample rate must be positive and channels must be 1 or 2."
case .microphonePermission:
return "Microphone permission is required. Enable Meeting Assistant under System Settings > Privacy & Security > Microphone."
case .noDisplay:
return "ScreenCaptureKit could not find a display for system-audio capture."
case .audioConverter(let detail):
return "Native PCM conversion failed: \(detail)"
case .systemAudio(let detail):
return "Screen Recording/System Audio permission is required. Enable Meeting Assistant under System Settings > Privacy & Security > Screen & System Audio Recording. \(detail)"
}
}
}
private final class ErrorReporter: @unchecked Sendable {
private let lock = NSLock()
func write(_ message: String) {
lock.lock()
defer { lock.unlock() }
let line = message.hasSuffix("\n") ? message : message + "\n"
if let data = line.data(using: .utf8) {
try? FileHandle.standardError.write(contentsOf: data)
}
}
func exit(with failure: CaptureFailure, code: Int32) -> Never {
write(failure.description)
Darwin.exit(code)
}
}
private final class PcmWriter: @unchecked Sendable {
private let targetFormat: AVAudioFormat
private let output = FileHandle.standardOutput
private let reporter: ErrorReporter
private let lock = NSLock()
private var converter: AVAudioConverter?
init(sampleRate: Int, channels: Int, reporter: ErrorReporter) throws {
guard let format = AVAudioFormat(
commonFormat: .pcmFormatInt16,
sampleRate: Double(sampleRate),
channels: AVAudioChannelCount(channels),
interleaved: true
) else {
throw CaptureFailure.invalidFormat
}
targetFormat = format
self.reporter = reporter
}
func write(_ input: AVAudioPCMBuffer) {
lock.lock()
defer { lock.unlock() }
do {
let outputBuffer = try convert(input)
let buffers = UnsafeMutableAudioBufferListPointer(outputBuffer.mutableAudioBufferList)
guard let audioBuffer = buffers.first, let bytes = audioBuffer.mData else {
return
}
let data = Data(bytes: bytes, count: Int(audioBuffer.mDataByteSize))
try output.write(contentsOf: data)
} catch {
reporter.exit(with: .audioConverter(error.localizedDescription), code: 70)
}
}
private func convert(_ input: AVAudioPCMBuffer) throws -> AVAudioPCMBuffer {
let sourceFormat = input.format
let activeConverter: AVAudioConverter
if let converter {
activeConverter = converter
} else {
guard let created = AVAudioConverter(from: sourceFormat, to: targetFormat) else {
throw CaptureFailure.audioConverter("AVAudioConverter could not convert \(sourceFormat) to \(targetFormat).")
}
converter = created
activeConverter = created
}
let ratio = targetFormat.sampleRate / sourceFormat.sampleRate
let capacity = max(1, AVAudioFrameCount(ceil(Double(input.frameLength) * ratio)) + 32)
guard let converted = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: capacity) else {
throw CaptureFailure.audioConverter("Could not allocate the target PCM buffer.")
}
var suppliedInput = false
var conversionError: NSError?
let status = activeConverter.convert(to: converted, error: &conversionError) { _, inputStatus in
if suppliedInput {
inputStatus.pointee = .noDataNow
return nil
}
suppliedInput = true
inputStatus.pointee = .haveData
return input
}
if status == .error {
throw conversionError ?? CaptureFailure.audioConverter("AVAudioConverter returned an unknown error.")
}
return converted
}
}
private final class MicrophoneCapture: @unchecked Sendable {
private let engine = AVAudioEngine()
private let writer: PcmWriter
init(writer: PcmWriter) {
self.writer = writer
}
func run() async throws -> Never {
let authorized = await withCheckedContinuation { continuation in
AVCaptureDevice.requestAccess(for: .audio) { granted in
continuation.resume(returning: granted)
}
}
guard authorized else {
throw CaptureFailure.microphonePermission
}
let input = engine.inputNode
let inputFormat = input.outputFormat(forBus: 0)
input.installTap(onBus: 0, bufferSize: 1_024, format: inputFormat) { [writer] buffer, _ in
writer.write(buffer)
}
engine.prepare()
try engine.start()
while true {
try await Task.sleep(for: .seconds(3_600))
}
}
}
private final class SystemAudioCapture: NSObject, SCStreamOutput, SCStreamDelegate, @unchecked Sendable {
private let writer: PcmWriter
private let reporter: ErrorReporter
private let sampleQueue = DispatchQueue(label: "meeting-assistant.system-audio")
private var stream: SCStream?
init(writer: PcmWriter, reporter: ErrorReporter) {
self.writer = writer
self.reporter = reporter
}
func run(sampleRate: Int, channels: Int) async throws -> Never {
let content: SCShareableContent
do {
content = try await SCShareableContent.excludingDesktopWindows(
false,
onScreenWindowsOnly: false
)
} catch {
throw CaptureFailure.systemAudio(error.localizedDescription)
}
guard let display = content.displays.first else {
throw CaptureFailure.noDisplay
}
let filter = SCContentFilter(
display: display,
excludingApplications: [],
exceptingWindows: []
)
let configuration = SCStreamConfiguration()
configuration.capturesAudio = true
configuration.excludesCurrentProcessAudio = false
configuration.sampleRate = sampleRate
configuration.channelCount = channels
configuration.width = 2
configuration.height = 2
configuration.showsCursor = false
configuration.minimumFrameInterval = CMTime(value: 1, timescale: 1)
let captureStream = SCStream(filter: filter, configuration: configuration, delegate: self)
try captureStream.addStreamOutput(self, type: .audio, sampleHandlerQueue: sampleQueue)
do {
try await captureStream.startCapture()
} catch {
throw CaptureFailure.systemAudio(error.localizedDescription)
}
stream = captureStream
while true {
try await Task.sleep(for: .seconds(3_600))
}
}
func stream(
_ stream: SCStream,
didOutputSampleBuffer sampleBuffer: CMSampleBuffer,
of outputType: SCStreamOutputType
) {
guard outputType == .audio,
sampleBuffer.isValid,
CMSampleBufferDataIsReady(sampleBuffer),
let description = CMSampleBufferGetFormatDescription(sampleBuffer),
let streamDescription = CMAudioFormatDescriptionGetStreamBasicDescription(description)
else {
return
}
let frameCount = AVAudioFrameCount(CMSampleBufferGetNumSamples(sampleBuffer))
guard frameCount > 0,
let sourceFormat = AVAudioFormat(streamDescription: streamDescription)
else {
return
}
var bufferListSize = 0
var retainedBlockBuffer: CMBlockBuffer?
var status = CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer(
sampleBuffer,
bufferListSizeNeededOut: &bufferListSize,
bufferListOut: nil,
bufferListSize: 0,
blockBufferAllocator: nil,
blockBufferMemoryAllocator: nil,
flags: 0,
blockBufferOut: &retainedBlockBuffer
)
guard status == noErr else {
reporter.exit(
with: .audioConverter("CoreMedia could not describe system audio (OSStatus \(status))."),
code: 71
)
}
let rawBufferList = UnsafeMutableRawPointer.allocate(
byteCount: bufferListSize,
alignment: MemoryLayout<AudioBufferList>.alignment
)
defer { rawBufferList.deallocate() }
let audioBufferList = rawBufferList.bindMemory(to: AudioBufferList.self, capacity: 1)
status = CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer(
sampleBuffer,
bufferListSizeNeededOut: nil,
bufferListOut: audioBufferList,
bufferListSize: bufferListSize,
blockBufferAllocator: nil,
blockBufferMemoryAllocator: nil,
flags: UInt32(kCMSampleBufferFlag_AudioBufferList_Assure16ByteAlignment),
blockBufferOut: &retainedBlockBuffer
)
guard status == noErr,
let buffer = AVAudioPCMBuffer(
pcmFormat: sourceFormat,
bufferListNoCopy: audioBufferList,
deallocator: nil
)
else {
reporter.exit(
with: .audioConverter("CoreMedia could not expose system audio (OSStatus \(status))."),
code: 71
)
}
buffer.frameLength = frameCount
writer.write(buffer)
}
func stream(_ stream: SCStream, didStopWithError error: Error) {
reporter.exit(with: .systemAudio(error.localizedDescription), code: 72)
}
}
@main
private struct MacOsMeetingAudioCapture {
static func main() async {
let reporter = ErrorReporter()
do {
let arguments = CommandLine.arguments
guard arguments.count == 4,
let sampleRate = Int(arguments[2]),
let channels = Int(arguments[3])
else {
throw CaptureFailure.usage
}
guard sampleRate > 0, channels == 1 || channels == 2 else {
throw CaptureFailure.invalidFormat
}
let writer = try PcmWriter(sampleRate: sampleRate, channels: channels, reporter: reporter)
switch arguments[1] {
case "microphone":
try await MicrophoneCapture(writer: writer).run()
case "system":
try await SystemAudioCapture(writer: writer, reporter: reporter)
.run(sampleRate: sampleRate, channels: channels)
default:
throw CaptureFailure.usage
}
} catch let failure as CaptureFailure {
reporter.exit(with: failure, code: 64)
} catch {
reporter.write(error.localizedDescription)
Darwin.exit(1)
}
}
}
+1 -1
View File
@@ -35,7 +35,7 @@ builder.Services.AddSingleton<IMeetingAudioSource>(services => new CompositeMeet
services.GetRequiredService<ILogger<CompositeMeetingAudioSource>>()));
builder.Services.AddSingleton<IMeetingMetadataProvider, OutlookClassicMeetingMetadataProvider>();
#else
builder.Services.AddSingleton<IMeetingAudioSource, UnavailableMeetingAudioSource>();
PortableMeetingAudioRegistration.Add(builder.Services);
builder.Services.AddSingleton<IMeetingMetadataProvider, NoopMeetingMetadataProvider>();
#endif
builder.Services.AddSingleton<ITranscriptStore, VaultTranscriptStore>();
@@ -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.");
}
}