Public Access
feat: add macOS meeting audio capture
This commit is contained in:
@@ -0,0 +1,415 @@
|
||||
using MeetingAssistant.Recording;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Summary;
|
||||
using MeetingAssistant.Transcription;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class MacOsMeetingAudioSourceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task MacOsMicrophoneStreamsNativePcmUsingRunFormat()
|
||||
{
|
||||
var pcm = Pcm16(1_000, -2_000, 3_000, -4_000);
|
||||
var process = new StubMacOsAudioCaptureProcess(pcm, exitCode: 0);
|
||||
var processFactory = new StubMacOsAudioCaptureProcessFactory(process);
|
||||
IMeetingAudioSource source = new MacOsMicrophoneAudioSource(
|
||||
processFactory,
|
||||
NullLogger<MacOsMicrophoneAudioSource>.Instance);
|
||||
var options = new MeetingAssistantOptions
|
||||
{
|
||||
Recording =
|
||||
{
|
||||
SampleRate = 24_000,
|
||||
Channels = 2
|
||||
}
|
||||
};
|
||||
|
||||
var chunks = await ReadChunksAsync(source, options);
|
||||
|
||||
var chunk = Assert.Single(chunks);
|
||||
Assert.Equal(pcm, chunk.Pcm);
|
||||
Assert.Equal(24_000, chunk.SampleRate);
|
||||
Assert.Equal(2, chunk.Channels);
|
||||
Assert.Equal(
|
||||
new MacOsAudioCaptureRequest(MacOsAudioCaptureKind.Microphone, 24_000, 2),
|
||||
processFactory.Request);
|
||||
Assert.True(process.Disposed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MacOsSystemAudioReportsNativePermissionFailure()
|
||||
{
|
||||
var process = new StubMacOsAudioCaptureProcess(
|
||||
[],
|
||||
exitCode: 77,
|
||||
standardError: "Screen Recording/System Audio permission is required.");
|
||||
IMeetingAudioSource source = new MacOsSystemAudioSource(
|
||||
new StubMacOsAudioCaptureProcessFactory(process),
|
||||
NullLogger<MacOsSystemAudioSource>.Instance);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
await ReadChunksAsync(source, new MeetingAssistantOptions()));
|
||||
|
||||
Assert.Contains("Screen Recording/System Audio permission", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PortableRegistrationUsesMacOsCompositeOnlyForMacOs()
|
||||
{
|
||||
var macServices = new ServiceCollection().AddLogging();
|
||||
var unsupportedServices = new ServiceCollection().AddLogging();
|
||||
|
||||
PortableMeetingAudioRegistration.Add(macServices, isMacOs: true);
|
||||
PortableMeetingAudioRegistration.Add(unsupportedServices, isMacOs: false);
|
||||
|
||||
using var macProvider = macServices.BuildServiceProvider();
|
||||
using var unsupportedProvider = unsupportedServices.BuildServiceProvider();
|
||||
Assert.IsType<CompositeMeetingAudioSource>(macProvider.GetRequiredService<IMeetingAudioSource>());
|
||||
Assert.IsType<UnavailableMeetingAudioSource>(unsupportedProvider.GetRequiredService<IMeetingAudioSource>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordingEndpointsCaptureMixedMacOsAudioAndStopNativeProcesses()
|
||||
{
|
||||
var testRoot = Path.Combine(Path.GetTempPath(), $"meeting-assistant-macos-endpoint-{Guid.NewGuid():N}");
|
||||
var nativeProcesses = new EndpointMacOsAudioCaptureProcessFactory(
|
||||
microphonePcm: Pcm16(2_000),
|
||||
systemPcm: Pcm16(10_000));
|
||||
var speechPipelines = new CapturingSpeechRecognitionPipelineFactory();
|
||||
await using var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["MeetingAssistant:Vault:BaseFolder"] = testRoot,
|
||||
["MeetingAssistant:Vault:TranscriptsFolder"] = "Transcripts",
|
||||
["MeetingAssistant:Vault:MeetingNotesFolder"] = "Notes",
|
||||
["MeetingAssistant:Vault:AssistantContextFolder"] = "Context",
|
||||
["MeetingAssistant:Vault:SummariesFolder"] = "Summaries",
|
||||
["MeetingAssistant:Vault:ProjectsFolder"] = "Projects",
|
||||
["MeetingAssistant:Recording:TemporaryRecordingsFolder"] = Path.Combine(testRoot, "Recordings"),
|
||||
["MeetingAssistant:Recording:InactivitySafeguard:Enabled"] = "false",
|
||||
["MeetingAssistant:SpeakerIdentification:DatabasePath"] = Path.Combine(testRoot, "speakers.db"),
|
||||
["MeetingAssistant:FunAsr:Backend:Enabled"] = "false"
|
||||
});
|
||||
});
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.RemoveAll<IMeetingAudioSource>();
|
||||
services.RemoveAll<IMacOsAudioCaptureProcessFactory>();
|
||||
PortableMeetingAudioRegistration.Add(services, isMacOs: true);
|
||||
services.RemoveAll<IMacOsAudioCaptureProcessFactory>();
|
||||
services.AddSingleton<IMacOsAudioCaptureProcessFactory>(nativeProcesses);
|
||||
services.RemoveAll<ISpeechRecognitionPipelineFactory>();
|
||||
services.AddSingleton<ISpeechRecognitionPipelineFactory>(speechPipelines);
|
||||
services.RemoveAll<IMeetingNoteOpener>();
|
||||
services.AddSingleton<IMeetingNoteOpener, NoopMeetingNoteOpener>();
|
||||
services.RemoveAll<IMeetingSummaryPipeline>();
|
||||
services.AddSingleton<IMeetingSummaryPipeline, NoopMeetingSummaryPipeline>();
|
||||
});
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var startResponse = await client.PostAsync("/recording/start", content: null);
|
||||
var started = await startResponse.Content.ReadFromJsonAsync<RecordingStatus>();
|
||||
var mixedChunk = await speechPipelines.FirstChunk.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.True(startResponse.IsSuccessStatusCode);
|
||||
Assert.True(started?.IsRecording);
|
||||
Assert.Equal(12_000, BitConverter.ToInt16(mixedChunk.Pcm));
|
||||
|
||||
using var stopResponse = await client.PostAsync("/recording/stop", content: null);
|
||||
var stopped = await stopResponse.Content.ReadFromJsonAsync<RecordingStatus>();
|
||||
|
||||
Assert.True(stopResponse.IsSuccessStatusCode);
|
||||
Assert.False(stopped?.IsRecording);
|
||||
Assert.All(nativeProcesses.Processes, process => Assert.True(process.Terminated));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(testRoot))
|
||||
{
|
||||
Directory.Delete(testRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<AudioChunk>> ReadChunksAsync(
|
||||
IMeetingAudioSource source,
|
||||
MeetingAssistantOptions options)
|
||||
{
|
||||
var chunks = new List<AudioChunk>();
|
||||
await foreach (var chunk in source.CaptureAsync(options, CancellationToken.None))
|
||||
{
|
||||
chunks.Add(chunk);
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
private static byte[] Pcm16(params short[] samples)
|
||||
{
|
||||
var pcm = new byte[samples.Length * sizeof(short)];
|
||||
Buffer.BlockCopy(samples, 0, pcm, 0, pcm.Length);
|
||||
return pcm;
|
||||
}
|
||||
|
||||
private sealed class StubMacOsAudioCaptureProcessFactory : IMacOsAudioCaptureProcessFactory
|
||||
{
|
||||
private readonly IMacOsAudioCaptureProcess process;
|
||||
|
||||
public StubMacOsAudioCaptureProcessFactory(IMacOsAudioCaptureProcess process)
|
||||
{
|
||||
this.process = process;
|
||||
}
|
||||
|
||||
public MacOsAudioCaptureRequest? Request { get; private set; }
|
||||
|
||||
public IMacOsAudioCaptureProcess Start(MacOsAudioCaptureRequest request)
|
||||
{
|
||||
Request = request;
|
||||
return process;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubMacOsAudioCaptureProcess : IMacOsAudioCaptureProcess
|
||||
{
|
||||
private readonly MemoryStream standardOutput;
|
||||
private readonly string standardError;
|
||||
|
||||
public StubMacOsAudioCaptureProcess(byte[] pcm, int exitCode, string standardError = "")
|
||||
{
|
||||
standardOutput = new MemoryStream(pcm);
|
||||
this.standardError = standardError;
|
||||
ExitCode = exitCode;
|
||||
}
|
||||
|
||||
public Stream StandardOutput => standardOutput;
|
||||
|
||||
public int ExitCode { get; }
|
||||
|
||||
public bool HasExited => true;
|
||||
|
||||
public bool Disposed { get; private set; }
|
||||
|
||||
public Task<string> ReadStandardErrorAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(standardError);
|
||||
}
|
||||
|
||||
public Task WaitForExitAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Terminate()
|
||||
{
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
Disposed = true;
|
||||
standardOutput.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class EndpointMacOsAudioCaptureProcessFactory : IMacOsAudioCaptureProcessFactory
|
||||
{
|
||||
private readonly IReadOnlyDictionary<MacOsAudioCaptureKind, byte[]> pcmByKind;
|
||||
|
||||
public EndpointMacOsAudioCaptureProcessFactory(byte[] microphonePcm, byte[] systemPcm)
|
||||
{
|
||||
pcmByKind = new Dictionary<MacOsAudioCaptureKind, byte[]>
|
||||
{
|
||||
[MacOsAudioCaptureKind.Microphone] = microphonePcm,
|
||||
[MacOsAudioCaptureKind.System] = systemPcm
|
||||
};
|
||||
}
|
||||
|
||||
public List<BlockingMacOsAudioCaptureProcess> Processes { get; } = [];
|
||||
|
||||
public IMacOsAudioCaptureProcess Start(MacOsAudioCaptureRequest request)
|
||||
{
|
||||
var process = new BlockingMacOsAudioCaptureProcess(pcmByKind[request.Kind]);
|
||||
Processes.Add(process);
|
||||
return process;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BlockingMacOsAudioCaptureProcess : IMacOsAudioCaptureProcess
|
||||
{
|
||||
private readonly TaskCompletionSource terminated = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public BlockingMacOsAudioCaptureProcess(byte[] pcm)
|
||||
{
|
||||
StandardOutput = new InitialPcmThenBlockingStream(pcm, terminated.Task);
|
||||
}
|
||||
|
||||
public Stream StandardOutput { get; }
|
||||
|
||||
public int ExitCode => Terminated ? 137 : throw new InvalidOperationException("Capture is still running.");
|
||||
|
||||
public bool HasExited => Terminated;
|
||||
|
||||
public bool Terminated => terminated.Task.IsCompleted;
|
||||
|
||||
public Task<string> ReadStandardErrorAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult("");
|
||||
}
|
||||
|
||||
public Task WaitForExitAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return terminated.Task.WaitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public void Terminate()
|
||||
{
|
||||
terminated.TrySetResult();
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
StandardOutput.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InitialPcmThenBlockingStream : Stream
|
||||
{
|
||||
private readonly byte[] pcm;
|
||||
private readonly Task processExit;
|
||||
private bool read;
|
||||
|
||||
public InitialPcmThenBlockingStream(byte[] pcm, Task processExit)
|
||||
{
|
||||
this.pcm = pcm;
|
||||
this.processExit = processExit;
|
||||
}
|
||||
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => throw new NotSupportedException();
|
||||
public override long Position
|
||||
{
|
||||
get => throw new NotSupportedException();
|
||||
set => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public override async ValueTask<int> ReadAsync(
|
||||
Memory<byte> buffer,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!read)
|
||||
{
|
||||
read = true;
|
||||
pcm.AsSpan().CopyTo(buffer.Span);
|
||||
return pcm.Length;
|
||||
}
|
||||
|
||||
await processExit.WaitAsync(cancellationToken);
|
||||
return 0;
|
||||
}
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
public override void Flush()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingSpeechRecognitionPipelineFactory : ISpeechRecognitionPipelineFactory
|
||||
{
|
||||
public TaskCompletionSource<AudioChunk> FirstChunk { get; } =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public ISpeechRecognitionPipeline Create()
|
||||
{
|
||||
return new CapturingSpeechRecognitionPipeline(FirstChunk);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingSpeechRecognitionPipeline : ISpeechRecognitionPipeline
|
||||
{
|
||||
private readonly TaskCompletionSource<AudioChunk> firstChunk;
|
||||
private readonly TaskCompletionSource completed = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public CapturingSpeechRecognitionPipeline(TaskCompletionSource<AudioChunk> firstChunk)
|
||||
{
|
||||
this.firstChunk = firstChunk;
|
||||
}
|
||||
|
||||
public Task InitializeAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public Task InitializeAsync(
|
||||
SpeechRecognitionPipelineOptions options,
|
||||
CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public Task WaitUntilReadyAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public ValueTask WriteAsync(AudioChunk chunk, CancellationToken cancellationToken)
|
||||
{
|
||||
firstChunk.TrySetResult(chunk);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public Task CompleteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
completed.TrySetResult();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<TranscriptionSegment> ReadLiveTranscriptAsync(
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await completed.Task.WaitAsync(cancellationToken);
|
||||
yield break;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<TranscriptionSegment>> ReadFinishedTranscriptAsync(
|
||||
string audioPath,
|
||||
SpeechRecognitionPipelineOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<TranscriptionSegment>>([]);
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
completed.TrySetResult();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NoopMeetingNoteOpener : IMeetingNoteOpener
|
||||
{
|
||||
public Task OpenAsync(string notePath, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class NoopMeetingSummaryPipeline : IMeetingSummaryPipeline
|
||||
{
|
||||
public Task<MeetingSummaryRunResult> RunAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new MeetingSummaryRunResult(artifacts.SummaryPath, "", Succeeded: true));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user