Public Access
feat: add macOS meeting audio capture
This commit is contained in:
@@ -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 "$(MacOsAudioCaptureSource)" -o "$(TargetDir)$(MacOsAudioCaptureOutputPath)"" />
|
||||
</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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user