Files
meeting-assistant/MeetingAssistant.Tests/MacOsMeetingAudioSourceTests.cs
T
dh 84dc696ce9
PR and Push Build/Test / build-and-test (pull_request) Failing after 11m18s
fix(macos): preserve audio when transcription fails
2026-09-01 11:55:15 +02:00

444 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.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();
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));
}
}
}