forked from Manuel/meeting-assistant
448 lines
16 KiB
C#
448 lines
16 KiB
C#
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.Data.Sqlite;
|
|
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 void NativeMicrophoneHelperDeclaresMacOsPrivacyMetadata()
|
|
{
|
|
if (!OperatingSystem.IsMacOS())
|
|
{
|
|
return;
|
|
}
|
|
|
|
var configuration = new DirectoryInfo(AppContext.BaseDirectory)
|
|
.Parent?.Name ?? "Debug";
|
|
var helperPath = Path.GetFullPath(Path.Combine(
|
|
AppContext.BaseDirectory,
|
|
"..",
|
|
"..",
|
|
"..",
|
|
"..",
|
|
"MeetingAssistant",
|
|
"bin",
|
|
configuration,
|
|
"net10.0",
|
|
"Native",
|
|
"macos-meeting-audio-capture"));
|
|
var helperImage = System.Text.Encoding.UTF8.GetString(File.ReadAllBytes(helperPath));
|
|
|
|
Assert.Contains("cloud.schweigert.meeting-assistant.audio-capture", helperImage, StringComparison.Ordinal);
|
|
Assert.Contains("NSMicrophoneUsageDescription", helperImage, StringComparison.Ordinal);
|
|
}
|
|
|
|
[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();
|
|
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
|
|
{
|
|
await factory.DisposeAsync();
|
|
using var connection = new SqliteConnection($"Data Source={Path.Combine(testRoot, "speakers.db")}");
|
|
SqliteConnection.ClearPool(connection);
|
|
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));
|
|
}
|
|
}
|
|
}
|