Implement meeting assistant v1

This commit is contained in:
2026-05-20 02:06:16 +02:00
parent 90df1edc03
commit 0297bcc0f6
120 changed files with 11883 additions and 180 deletions
@@ -0,0 +1,174 @@
using System.Net;
using System.Net.Http.Json;
using MeetingAssistant.Recording;
using MeetingAssistant.Transcription;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace MeetingAssistant.Tests;
public sealed class AsrDiagnosticEndpointTests
{
[Fact]
public async Task EndpointTranscribesLocalWavThroughConfiguredProvider()
{
await using var factory = CreateFactory<EndpointFakeTranscriptionProvider>();
using var client = factory.CreateClient();
var wavPath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "sample-16khz-mono.wav");
using var response = await client.PostAsJsonAsync("/asr/transcribe-file", new { path = wavPath });
var body = await response.Content.ReadFromJsonAsync<AsrDiagnosticResponse>();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(wavPath, body?.Path);
var segment = Assert.Single(body?.Segments ?? []);
Assert.Equal("Unknown", segment.Speaker);
Assert.StartsWith("endpoint-bytes:", segment.Text, StringComparison.Ordinal);
}
[Fact]
public async Task EndpointReportsProviderFailuresAsBadGateway()
{
await using var factory = CreateFactory<FailingTranscriptionProvider>();
using var client = factory.CreateClient();
var wavPath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "sample-16khz-mono.wav");
using var response = await client.PostAsJsonAsync("/asr/transcribe-file", new { path = wavPath });
Assert.Equal(HttpStatusCode.BadGateway, response.StatusCode);
}
[Fact]
public async Task EndpointRunsFinalDiarizationForLocalWav()
{
SpeechRecognitionPipelineOptions? capturedOptions = null;
await using var factory = CreateFactory<EndpointFakeTranscriptionProvider>(
(audioPath, liveSegments, options, cancellationToken) =>
{
capturedOptions = options;
return Task.FromResult<IReadOnlyList<TranscriptionSegment>>(
liveSegments
.Select(segment => segment with { Speaker = "Speaker 0", Text = $"diarized:{segment.Text}" })
.ToList());
});
using var client = factory.CreateClient();
var wavPath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "sample-16khz-mono.wav");
using var response = await client.PostAsJsonAsync("/asr/diarize-file", new { path = wavPath, numSpeakers = 5 });
var body = await response.Content.ReadFromJsonAsync<AsrDiagnosticResponse>();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(5, capturedOptions?.NumSpeakers);
Assert.Equal(wavPath, body?.Path);
var segment = Assert.Single(body?.Segments ?? []);
Assert.Equal("Speaker 0", segment.Speaker);
Assert.StartsWith("diarized:endpoint-bytes:", segment.Text, StringComparison.Ordinal);
}
private static WebApplicationFactory<Program> CreateFactory<TProvider>()
where TProvider : class, IStreamingTranscriptionProvider
{
return CreateFactory<TProvider>((_, _, _, _) => Task.FromResult<IReadOnlyList<TranscriptionSegment>>([]));
}
private static WebApplicationFactory<Program> CreateFactory<TProvider>(
Func<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>> finalize)
where TProvider : class, IStreamingTranscriptionProvider
{
return new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, configuration) =>
{
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["MeetingAssistant:FunAsr:Backend:Enabled"] = "false"
});
});
builder.ConfigureTestServices(services =>
{
services.RemoveAll<ISpeechRecognitionPipelineFactory>();
services.AddSingleton<ISpeechRecognitionPipelineFactory>(
new TestSpeechRecognitionPipelineFactory<TProvider>(finalize));
});
});
}
private sealed class EndpointFakeTranscriptionProvider : IStreamingTranscriptionProvider
{
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
IAsyncEnumerable<AudioChunk> audio,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
var bytes = 0;
await foreach (var chunk in audio.WithCancellation(cancellationToken))
{
bytes += chunk.Pcm.Length;
}
yield return new TranscriptionSegment(TimeSpan.Zero, TimeSpan.Zero, "Unknown", $"endpoint-bytes:{bytes}");
}
}
private sealed class FailingTranscriptionProvider : IStreamingTranscriptionProvider
{
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
IAsyncEnumerable<AudioChunk> audio,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.Yield();
throw new InvalidOperationException("backend unavailable");
#pragma warning disable CS0162
yield break;
#pragma warning restore CS0162
}
}
private sealed class TestSpeechRecognitionPipelineFactory<TProvider> : ISpeechRecognitionPipelineFactory
where TProvider : class, IStreamingTranscriptionProvider
{
private readonly Func<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>> finalize;
public TestSpeechRecognitionPipelineFactory(
Func<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>> finalize)
{
this.finalize = finalize;
}
public ISpeechRecognitionPipeline Create()
{
return new TestSpeechRecognitionPipeline(
Activator.CreateInstance<TProvider>(),
finalize);
}
}
private sealed class TestSpeechRecognitionPipeline : StreamingSpeechRecognitionPipeline
{
private readonly Func<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>> finalize;
public TestSpeechRecognitionPipeline(
IStreamingTranscriptionProvider provider,
Func<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>> finalize)
: base(provider)
{
this.finalize = finalize;
}
protected override Task<IReadOnlyList<TranscriptionSegment>> BuildFinishedTranscriptAsync(
string audioPath,
IReadOnlyList<TranscriptionSegment> liveSegments,
SpeechRecognitionPipelineOptions options,
CancellationToken cancellationToken)
{
return finalize(audioPath, liveSegments, options, cancellationToken);
}
}
private sealed record AsrDiagnosticResponse(string Path, IReadOnlyList<AsrDiagnosticSegment> Segments);
private sealed record AsrDiagnosticSegment(string Speaker, string Text);
}
@@ -0,0 +1,79 @@
using MeetingAssistant.Recording;
using MeetingAssistant.Transcription;
namespace MeetingAssistant.Tests;
public sealed class AsrDiagnosticTests
{
[Fact]
public async Task DiagnosticStreamsPcmWavThroughConfiguredProvider()
{
var provider = new CapturingTranscriptionProvider();
var diagnostics = new AsrDiagnosticService(
new TestSpeechRecognitionPipelineFactory(provider));
var wavPath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "sample-16khz-mono.wav");
var result = await diagnostics.TranscribeWavAsync(wavPath, CancellationToken.None);
Assert.Equal(wavPath, result.Path);
Assert.Equal(16000, provider.Chunks.Single().SampleRate);
Assert.Equal(1, provider.Chunks.Single().Channels);
Assert.NotEmpty(provider.Chunks.Single().Pcm);
var segment = Assert.Single(result.Segments);
Assert.Equal("Unknown", segment.Speaker);
Assert.Equal("bytes:" + provider.Chunks.Single().Pcm.Length, segment.Text);
}
private sealed class CapturingTranscriptionProvider : IStreamingTranscriptionProvider
{
public List<AudioChunk> Chunks { get; } = [];
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
IAsyncEnumerable<AudioChunk> audio,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await foreach (var chunk in audio.WithCancellation(cancellationToken))
{
Chunks.Add(chunk);
}
yield return new TranscriptionSegment(
TimeSpan.Zero,
TimeSpan.Zero,
"Unknown",
"bytes:" + Chunks.Sum(chunk => chunk.Pcm.Length));
}
}
private sealed class TestSpeechRecognitionPipelineFactory : ISpeechRecognitionPipelineFactory
{
private readonly IStreamingTranscriptionProvider provider;
public TestSpeechRecognitionPipelineFactory(IStreamingTranscriptionProvider provider)
{
this.provider = provider;
}
public ISpeechRecognitionPipeline Create()
{
return new TestSpeechRecognitionPipeline(provider);
}
}
private sealed class TestSpeechRecognitionPipeline : StreamingSpeechRecognitionPipeline
{
public TestSpeechRecognitionPipeline(IStreamingTranscriptionProvider provider)
: base(provider)
{
}
protected override Task<IReadOnlyList<TranscriptionSegment>> BuildFinishedTranscriptAsync(
string audioPath,
IReadOnlyList<TranscriptionSegment> liveSegments,
SpeechRecognitionPipelineOptions options,
CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<TranscriptionSegment>>([]);
}
}
}
@@ -0,0 +1,71 @@
using MeetingAssistant.Recording;
using Microsoft.Extensions.Logging.Abstractions;
namespace MeetingAssistant.Tests;
public sealed class AudioMixingTests
{
[Fact]
public async Task CompositeAudioSourceMixesMicrophoneAndSystemAudioIntoOnePcmStream()
{
var microphone = new FixedAudioSource(Pcm16(10_000));
var system = new FixedAudioSource(Pcm16(20_000));
var source = new CompositeMeetingAudioSource(
microphone,
system,
NullLogger<CompositeMeetingAudioSource>.Instance);
var chunks = await ReadChunks(source);
Assert.Single(chunks);
Assert.Equal(30_000, BitConverter.ToInt16(chunks[0].Pcm));
}
[Fact]
public async Task CompositeAudioSourceClampsMixedSamples()
{
var microphone = new FixedAudioSource(Pcm16(30_000));
var system = new FixedAudioSource(Pcm16(10_000));
var source = new CompositeMeetingAudioSource(
microphone,
system,
NullLogger<CompositeMeetingAudioSource>.Instance);
var chunks = await ReadChunks(source);
Assert.Equal(short.MaxValue, BitConverter.ToInt16(chunks[0].Pcm));
}
private static async Task<IReadOnlyList<AudioChunk>> ReadChunks(IMeetingAudioSource source)
{
var chunks = new List<AudioChunk>();
await foreach (var chunk in source.CaptureAsync(CancellationToken.None))
{
chunks.Add(chunk);
}
return chunks;
}
private static byte[] Pcm16(short sample)
{
return BitConverter.GetBytes(sample);
}
private sealed class FixedAudioSource : IMeetingAudioSource
{
private readonly AudioChunk chunk;
public FixedAudioSource(byte[] pcm)
{
chunk = new AudioChunk(pcm, 16000, 1);
}
public async IAsyncEnumerable<AudioChunk> CaptureAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.Yield();
yield return chunk;
}
}
}
@@ -0,0 +1,62 @@
using MeetingAssistant.Recording;
using MeetingAssistant.Transcription;
namespace MeetingAssistant.Tests;
public sealed class AzureSpeechRecognitionPipelineTests
{
[Fact]
public async Task FinishedTranscriptUsesLiveDiarizedAzureSegments()
{
await using var pipeline = new AzureSpeechRecognitionPipeline(
new StaticTranscriptionProvider(
[
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Guest-1", "hello"),
new TranscriptionSegment(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2), "Guest-2", "there")
]));
await pipeline.InitializeAsync(CancellationToken.None);
await pipeline.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
await pipeline.CompleteAsync(CancellationToken.None);
var liveSegments = new List<TranscriptionSegment>();
await foreach (var segment in pipeline.ReadLiveTranscriptAsync(CancellationToken.None))
{
liveSegments.Add(segment);
}
var finishedSegments = await pipeline.ReadFinishedTranscriptAsync(
"ignored.wav",
new SpeechRecognitionPipelineOptions(5),
CancellationToken.None);
Assert.Equal(liveSegments, finishedSegments);
Assert.Collection(
finishedSegments,
first => Assert.Equal("Guest-1", first.Speaker),
second => Assert.Equal("Guest-2", second.Speaker));
}
private sealed class StaticTranscriptionProvider : IStreamingTranscriptionProvider
{
private readonly IReadOnlyList<TranscriptionSegment> segments;
public StaticTranscriptionProvider(IReadOnlyList<TranscriptionSegment> segments)
{
this.segments = segments;
}
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
IAsyncEnumerable<AudioChunk> audio,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await foreach (var _ in audio.WithCancellation(cancellationToken))
{
}
foreach (var segment in segments)
{
yield return segment;
}
}
}
}
@@ -0,0 +1,39 @@
using MeetingAssistant.Recording;
using MeetingAssistant.Transcription;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.Tests;
public sealed class AzureSpeechStreamingTranscriptionProviderTests
{
[Fact]
public async Task TranscribeFailsClearlyWhenKeyIsMissing()
{
var provider = new AzureSpeechStreamingTranscriptionProvider(
Options.Create(new MeetingAssistantOptions
{
AzureSpeech = new AzureSpeechOptions
{
Region = "germanywestcentral",
KeyEnv = $"MEETING_ASSISTANT_MISSING_AZURE_KEY_{Guid.NewGuid():N}"
}
}),
NullLogger<AzureSpeechStreamingTranscriptionProvider>.Instance);
var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await foreach (var _ in provider.TranscribeAsync(ReadSingleChunk(), CancellationToken.None))
{
}
});
Assert.Contains("Azure Speech key is not configured", exception.Message);
}
private static async IAsyncEnumerable<AudioChunk> ReadSingleChunk()
{
await Task.Yield();
yield return new AudioChunk(new byte[320], 16000, 1);
}
}
@@ -0,0 +1,34 @@
using MeetingAssistant.Transcription;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.Tests;
public sealed class ConfiguredSpeechRecognitionPipelineFactoryTests
{
[Fact]
public async Task FactoryCreatesAzureSpeechPipelineWhenConfigured()
{
var services = new ServiceCollection();
services.AddSingleton(Options.Create(new MeetingAssistantOptions
{
Recording = new RecordingOptions
{
TranscriptionProvider = "azure-speech"
}
}));
services.AddSingleton<ILogger<AzureSpeechStreamingTranscriptionProvider>>(
NullLogger<AzureSpeechStreamingTranscriptionProvider>.Instance);
services.AddTransient<AzureSpeechStreamingTranscriptionProvider>();
var provider = services.BuildServiceProvider();
var factory = new ConfiguredSpeechRecognitionPipelineFactory(
provider,
provider.GetRequiredService<IOptions<MeetingAssistantOptions>>());
await using var pipeline = factory.Create();
Assert.IsType<AzureSpeechRecognitionPipeline>(pipeline);
}
}
@@ -0,0 +1,203 @@
using MeetingAssistant.Transcription;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.Tests;
public sealed class FunAsrBackendLifecycleTests
{
[Fact]
public async Task LifecycleInstallsStartsAndStopsManagedDockerContainer()
{
var runner = new CapturingCommandRunner();
var readinessProbe = new CapturingReadinessProbe();
var modelsFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var lifecycle = new FunAsrDockerBackendLifecycle(
runner,
readinessProbe,
Options.Create(new MeetingAssistantOptions
{
FunAsr = new FunAsrOptions
{
Endpoint = "ws://127.0.0.1:10095",
Backend = new FunAsrBackendOptions
{
Enabled = true,
Image = "funasr:test",
ContainerName = "meeting-assistant-funasr-test",
ModelsFolder = modelsFolder,
StartupTimeout = TimeSpan.Zero
}
}
}),
NullLogger<FunAsrDockerBackendLifecycle>.Instance);
await lifecycle.EnsureStartedAsync(CancellationToken.None);
await lifecycle.EnsureStartedAsync(CancellationToken.None);
await lifecycle.DisposeAsync();
Assert.Equal(new Uri("ws://127.0.0.1:10095"), readinessProbe.Endpoint);
Assert.Equal(TimeSpan.Zero, readinessProbe.Timeout);
Assert.Collection(
runner.Commands,
command =>
{
Assert.Equal("docker", command.FileName);
Assert.Equal(["pull", "funasr:test"], command.Arguments);
},
command =>
{
Assert.Equal(["rm", "-f", "meeting-assistant-funasr-test"], command.Arguments);
},
command =>
{
Assert.Equal("docker", command.FileName);
Assert.Contains("run", command.Arguments);
Assert.Contains("-d", command.Arguments);
Assert.Contains("--privileged=true", command.Arguments);
Assert.Contains("--name", command.Arguments);
Assert.Contains("meeting-assistant-funasr-test", command.Arguments);
Assert.Contains("-p", command.Arguments);
Assert.Contains("10095:10095", command.Arguments);
Assert.Contains($"{modelsFolder}:/workspace/models", command.Arguments);
Assert.Contains("funasr:test", command.Arguments);
},
command =>
{
Assert.Equal(["stop", "meeting-assistant-funasr-test"], command.Arguments);
});
Assert.True(File.Exists(Path.Combine(modelsFolder, "hotwords.txt")));
}
[Fact]
public async Task DisabledLifecycleDoesNotRunDockerCommands()
{
var runner = new CapturingCommandRunner();
var lifecycle = new FunAsrDockerBackendLifecycle(
runner,
new CapturingReadinessProbe(),
Options.Create(new MeetingAssistantOptions
{
FunAsr = new FunAsrOptions
{
Backend = new FunAsrBackendOptions { Enabled = false }
}
}),
NullLogger<FunAsrDockerBackendLifecycle>.Instance);
await lifecycle.EnsureStartedAsync(CancellationToken.None);
await lifecycle.DisposeAsync();
Assert.Empty(runner.Commands);
}
[Fact]
public async Task LifecycleFailsWhenDockerCommandTimesOut()
{
var runner = new HangingCommandRunner();
var lifecycle = new FunAsrDockerBackendLifecycle(
runner,
new CapturingReadinessProbe(),
Options.Create(new MeetingAssistantOptions
{
FunAsr = new FunAsrOptions
{
Backend = new FunAsrBackendOptions
{
Enabled = true,
CommandTimeout = TimeSpan.FromMilliseconds(1)
}
}
}),
NullLogger<FunAsrDockerBackendLifecycle>.Instance);
var exception = await Assert.ThrowsAsync<TimeoutException>(
() => lifecycle.EnsureStartedAsync(CancellationToken.None));
Assert.Contains("timed out", exception.Message);
}
[Fact]
public async Task LifecycleWaitsForWebSocketReadinessBeforeMarkingStarted()
{
var runner = new CapturingCommandRunner();
var readinessProbe = new CapturingReadinessProbe();
var lifecycle = new FunAsrDockerBackendLifecycle(
runner,
readinessProbe,
Options.Create(new MeetingAssistantOptions
{
FunAsr = new FunAsrOptions
{
Endpoint = "ws://127.0.0.1:10095",
Backend = new FunAsrBackendOptions
{
Enabled = true,
Image = "funasr:test",
ContainerName = "meeting-assistant-funasr-test",
ModelsFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")),
StartupTimeout = TimeSpan.FromMinutes(3)
}
}
}),
NullLogger<FunAsrDockerBackendLifecycle>.Instance);
await lifecycle.EnsureStartedAsync(CancellationToken.None);
Assert.Equal(1, readinessProbe.CallCount);
Assert.Equal(new Uri("ws://127.0.0.1:10095"), readinessProbe.Endpoint);
Assert.Equal(TimeSpan.FromMinutes(3), readinessProbe.Timeout);
Assert.Equal(3, runner.Commands.Count);
await lifecycle.DisposeAsync();
}
private sealed class CapturingCommandRunner : ICommandRunner
{
public List<CapturedCommand> Commands { get; } = [];
public Task<CommandResult> RunAsync(
string fileName,
IReadOnlyList<string> arguments,
CancellationToken cancellationToken,
IReadOnlyDictionary<string, string>? environment = null)
{
Commands.Add(new CapturedCommand(fileName, arguments.ToArray()));
return Task.FromResult(new CommandResult(0, "", ""));
}
}
private sealed record CapturedCommand(string FileName, IReadOnlyList<string> Arguments);
private sealed class HangingCommandRunner : ICommandRunner
{
public async Task<CommandResult> RunAsync(
string fileName,
IReadOnlyList<string> arguments,
CancellationToken cancellationToken,
IReadOnlyDictionary<string, string>? environment = null)
{
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
return new CommandResult(0, "", "");
}
}
private sealed class CapturingReadinessProbe : IFunAsrBackendReadinessProbe
{
public int CallCount { get; private set; }
public Uri? Endpoint { get; private set; }
public TimeSpan Timeout { get; private set; }
public Task WaitUntilReadyAsync(
Uri endpoint,
TimeSpan timeout,
CancellationToken cancellationToken)
{
CallCount++;
Endpoint = endpoint;
Timeout = timeout;
return Task.CompletedTask;
}
}
}
@@ -0,0 +1,184 @@
using System.Text.Json;
using MeetingAssistant.Recording;
using MeetingAssistant.Transcription;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.Tests;
public sealed class FunAsrStreamingTranscriptionProviderTests
{
[Fact]
public async Task ProviderStreamsPcmChunksAndEmitsSpeakerAttributedSegments()
{
var connection = new FakeFunAsrWebSocketConnection(
"""
{"mode":"2pass-offline","wav_name":"meeting","text":"hello from mike","is_final":true,"spk_name":"Mike","spk_score":0.92,"timestamp":[[1000,1800]]}
""");
var provider = new FunAsrStreamingTranscriptionProvider(
new FakeFunAsrWebSocketConnectionFactory(connection),
new NoopFunAsrBackendLifecycle(),
Options.Create(new MeetingAssistantOptions
{
FunAsr = new FunAsrOptions
{
Endpoint = "ws://localhost:10095",
Mode = "2pass",
ChunkSize = [5, 10, 5],
ChunkInterval = 10,
FinalResultTimeout = TimeSpan.FromSeconds(1)
}
}),
NullLogger<FunAsrStreamingTranscriptionProvider>.Instance);
var chunks = new[]
{
new AudioChunk([1, 0, 2, 0], 16000, 1),
new AudioChunk([3, 0, 4, 0], 16000, 1)
};
var segments = await CollectAsync(provider.TranscribeAsync(ToAsyncEnumerable(chunks), CancellationToken.None));
Assert.Equal(new Uri("ws://localhost:10095"), connection.ConnectedEndpoint);
Assert.Equal(2, connection.BinaryMessages.Count);
Assert.Equal(chunks[0].Pcm, connection.BinaryMessages[0]);
Assert.Equal(chunks[1].Pcm, connection.BinaryMessages[1]);
Assert.Equal("Mike", Assert.Single(segments).Speaker);
Assert.Equal("hello from mike", segments[0].Text);
Assert.Equal(TimeSpan.FromSeconds(1), segments[0].Start);
Assert.Equal(TimeSpan.FromMilliseconds(1800), segments[0].End);
using var firstMessage = JsonDocument.Parse(connection.TextMessages[0]);
Assert.Equal("2pass", firstMessage.RootElement.GetProperty("mode").GetString());
Assert.Equal("pcm", firstMessage.RootElement.GetProperty("wav_format").GetString());
Assert.Equal(16000, firstMessage.RootElement.GetProperty("audio_fs").GetInt32());
Assert.True(firstMessage.RootElement.GetProperty("is_speaking").GetBoolean());
using var finalMessage = JsonDocument.Parse(connection.TextMessages[^1]);
Assert.False(finalMessage.RootElement.GetProperty("is_speaking").GetBoolean());
}
[Fact]
public async Task ProviderUsesSentenceSpeakerFieldsAndUnknownFallback()
{
var connection = new FakeFunAsrWebSocketConnection(
"""
{"mode":"2pass-offline","wav_name":"meeting","text":"ignored","is_final":true,"sentence_info":[{"text":"first sentence","spk":0,"start":100,"end":600},{"text":"second sentence","start":700,"end":1200}]}
""");
var provider = new FunAsrStreamingTranscriptionProvider(
new FakeFunAsrWebSocketConnectionFactory(connection),
new NoopFunAsrBackendLifecycle(),
Options.Create(new MeetingAssistantOptions
{
FunAsr = new FunAsrOptions { FinalResultTimeout = TimeSpan.FromSeconds(1) }
}),
NullLogger<FunAsrStreamingTranscriptionProvider>.Instance);
var segments = await CollectAsync(provider.TranscribeAsync(
ToAsyncEnumerable([new AudioChunk([1, 0], 16000, 1)]),
CancellationToken.None));
Assert.Collection(
segments,
first =>
{
Assert.Equal("Speaker 0", first.Speaker);
Assert.Equal("first sentence", first.Text);
Assert.Equal(TimeSpan.FromMilliseconds(100), first.Start);
Assert.Equal(TimeSpan.FromMilliseconds(600), first.End);
},
second =>
{
Assert.Equal("Unknown", second.Speaker);
Assert.Equal("second sentence", second.Text);
Assert.Equal(TimeSpan.FromMilliseconds(700), second.Start);
Assert.Equal(TimeSpan.FromMilliseconds(1200), second.End);
});
}
private static async IAsyncEnumerable<AudioChunk> ToAsyncEnumerable(IEnumerable<AudioChunk> chunks)
{
foreach (var chunk in chunks)
{
yield return chunk;
await Task.Yield();
}
}
private static async Task<List<TranscriptionSegment>> CollectAsync(IAsyncEnumerable<TranscriptionSegment> segments)
{
var collected = new List<TranscriptionSegment>();
await foreach (var segment in segments)
{
collected.Add(segment);
}
return collected;
}
private sealed class FakeFunAsrWebSocketConnectionFactory : IFunAsrWebSocketConnectionFactory
{
private readonly FakeFunAsrWebSocketConnection connection;
public FakeFunAsrWebSocketConnectionFactory(FakeFunAsrWebSocketConnection connection)
{
this.connection = connection;
}
public Task<IFunAsrWebSocketConnection> ConnectAsync(Uri endpoint, CancellationToken cancellationToken)
{
connection.ConnectedEndpoint = endpoint;
return Task.FromResult<IFunAsrWebSocketConnection>(connection);
}
}
private sealed class NoopFunAsrBackendLifecycle : IFunAsrBackendLifecycle
{
public Task EnsureStartedAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
private sealed class FakeFunAsrWebSocketConnection : IFunAsrWebSocketConnection
{
private readonly Queue<string> responses;
public FakeFunAsrWebSocketConnection(params string[] responses)
{
this.responses = new Queue<string>(responses);
}
public Uri? ConnectedEndpoint { get; set; }
public List<string> TextMessages { get; } = [];
public List<byte[]> BinaryMessages { get; } = [];
public Task SendTextAsync(string message, CancellationToken cancellationToken)
{
TextMessages.Add(message);
return Task.CompletedTask;
}
public Task SendBinaryAsync(ReadOnlyMemory<byte> message, CancellationToken cancellationToken)
{
BinaryMessages.Add(message.ToArray());
return Task.CompletedTask;
}
public Task<string?> ReceiveTextAsync(CancellationToken cancellationToken)
{
return Task.FromResult(responses.Count == 0 ? null : responses.Dequeue());
}
public Task CloseOutputAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public ValueTask DisposeAsync()
{
return ValueTask.CompletedTask;
}
}
}
@@ -0,0 +1,109 @@
using MeetingAssistant;
using MeetingAssistant.Transcription;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.Tests;
public sealed class FunAsrTranscriptFinalizerTests
{
[Fact]
public async Task FinalizerRunsFunAsrAutoModelAndReturnsSentenceSpeakerSegments()
{
var audioPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "meeting.wav");
Directory.CreateDirectory(Path.GetDirectoryName(audioPath)!);
await File.WriteAllBytesAsync(audioPath, [1, 2, 3, 4]);
var commandRunner = new CapturingCommandRunner(
"""
setup noise
__MEETING_ASSISTANT_DIARIZATION_JSON_START__
[{"key":"input","sentence_info":[{"text":" hello","start":410,"end":4810,"spk":0},{"text":" yes","start":22550,"end":22770,"spk":1}]}]
__MEETING_ASSISTANT_DIARIZATION_JSON_END__
""");
var finalizer = new FunAsrTranscriptFinalizer(
commandRunner,
Options.Create(new MeetingAssistantOptions
{
FunAsr = new FunAsrOptions
{
Backend = new FunAsrBackendOptions
{
DockerCommand = "docker",
Image = "funasr:test",
ModelsFolder = "C:\\Models"
},
Diarization = new FunAsrDiarizationOptions
{
Enabled = true,
CommandTimeout = TimeSpan.FromMinutes(1)
}
}
}),
NullLogger<FunAsrTranscriptFinalizer>.Instance);
var segments = await finalizer.FinalizeAsync(audioPath, [], CancellationToken.None);
Assert.Contains("run", commandRunner.Arguments);
Assert.Contains("funasr:test", commandRunner.Arguments);
Assert.Contains($"{audioPath}:/workspace/input.wav:ro", commandRunner.Arguments);
Assert.Collection(
segments,
first =>
{
Assert.Equal(TimeSpan.FromMilliseconds(410), first.Start);
Assert.Equal(TimeSpan.FromMilliseconds(4810), first.End);
Assert.Equal("Speaker 0", first.Speaker);
Assert.Equal("hello", first.Text);
},
second =>
{
Assert.Equal(TimeSpan.FromMilliseconds(22550), second.Start);
Assert.Equal(TimeSpan.FromMilliseconds(22770), second.End);
Assert.Equal("Speaker 1", second.Speaker);
Assert.Equal("yes", second.Text);
});
}
[Fact]
public async Task FinalizerReturnsNoSegmentsWhenDiarizationIsDisabled()
{
var commandRunner = new CapturingCommandRunner("");
var finalizer = new FunAsrTranscriptFinalizer(
commandRunner,
Options.Create(new MeetingAssistantOptions
{
FunAsr = new FunAsrOptions
{
Diarization = new FunAsrDiarizationOptions { Enabled = false }
}
}),
NullLogger<FunAsrTranscriptFinalizer>.Instance);
var segments = await finalizer.FinalizeAsync("C:\\Recordings\\meeting.wav", [], CancellationToken.None);
Assert.Empty(segments);
Assert.Empty(commandRunner.Arguments);
}
private sealed class CapturingCommandRunner : ICommandRunner
{
private readonly string output;
public CapturingCommandRunner(string output)
{
this.output = output;
}
public IReadOnlyList<string> Arguments { get; private set; } = [];
public Task<CommandResult> RunAsync(
string fileName,
IReadOnlyList<string> arguments,
CancellationToken cancellationToken,
IReadOnlyDictionary<string, string>? environment = null)
{
Arguments = arguments;
return Task.FromResult(new CommandResult(0, output, ""));
}
}
}
+52 -1
View File
@@ -1,6 +1,12 @@
using System.Net;
using System.Net.Http.Json;
using MeetingAssistant.Recording;
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;
namespace MeetingAssistant.Tests;
@@ -10,7 +16,16 @@ public sealed class HealthEndpointTests : IClassFixture<WebApplicationFactory<Pr
public HealthEndpointTests(WebApplicationFactory<Program> factory)
{
this.factory = factory;
this.factory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, configuration) =>
{
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["MeetingAssistant:FunAsr:Backend:Enabled"] = "false"
});
});
});
}
[Fact]
@@ -26,5 +41,41 @@ public sealed class HealthEndpointTests : IClassFixture<WebApplicationFactory<Pr
Assert.Equal("ok", body?.Status);
}
[Fact]
public async Task ApplicationStartupDeletesStaleTemporaryRecordings()
{
await using var cleanupFactory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IRecordedAudioStore>();
services.AddSingleton<IRecordedAudioStore, StartupCleanupRecordedAudioStore>();
});
});
using var client = cleanupFactory.CreateClient();
using var response = await client.GetAsync("/health");
var store = cleanupFactory.Services.GetRequiredService<IRecordedAudioStore>() as StartupCleanupRecordedAudioStore;
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.True(store?.StaleRecordingsDeleted);
}
private sealed record HealthResponse(string Service, string Status);
private sealed class StartupCleanupRecordedAudioStore : IRecordedAudioStore
{
public bool StaleRecordingsDeleted { get; private set; }
public Task<IRecordedAudioSink> CreateSessionAsync(CancellationToken cancellationToken)
{
throw new NotSupportedException();
}
public Task DeleteStaleRecordingsAsync(CancellationToken cancellationToken)
{
StaleRecordingsDeleted = true;
return Task.CompletedTask;
}
}
}
@@ -0,0 +1,15 @@
using MeetingAssistant.Hotkeys;
namespace MeetingAssistant.Tests;
public sealed class HotkeyDefinitionTests
{
[Fact]
public void HotkeyDefinitionParsesConfiguredToggleShortcut()
{
var definition = HotkeyDefinition.Parse("Ctrl+Alt+M");
Assert.Equal(HotkeyModifiers.Control | HotkeyModifiers.Alt, definition.Modifiers);
Assert.Equal((uint)'M', definition.VirtualKey);
}
}
@@ -0,0 +1,335 @@
using MeetingAssistant.Summary;
using Microsoft.Extensions.AI;
using System.Net;
namespace MeetingAssistant.Tests;
public sealed class LiteLlmResponsesChatClientTests
{
[Fact]
public void ParserIgnoresReasoningItemsWithNullStatusAndReadsText()
{
const string json = """
{
"id": "resp_test",
"created_at": 1779147100,
"model": "gpt-5.5-2026-04-23",
"output": [
{
"type": "reasoning",
"summary": [],
"status": null
},
{
"type": "message",
"role": "assistant",
"status": "completed",
"content": [
{
"type": "output_text",
"text": "OK"
}
]
}
]
}
""";
var response = LiteLlmResponsesChatClient.ParseResponseJson(json);
Assert.Equal("OK", response.Text);
Assert.Equal("resp_test", response.ResponseId);
Assert.Equal("gpt-5.5-2026-04-23", response.ModelId);
}
[Fact]
public void ParserReadsFunctionCalls()
{
const string json = """
{
"output": [
{
"type": "function_call",
"call_id": "call_1",
"name": "write_summary",
"arguments": "{\"markdown\":\"# Summary\\nDone\"}",
"status": "completed"
}
]
}
""";
var response = LiteLlmResponsesChatClient.ParseResponseJson(json);
var call = Assert.IsType<FunctionCallContent>(Assert.Single(response.Messages[0].Contents));
Assert.Equal("call_1", call.CallId);
Assert.Equal("write_summary", call.Name);
Assert.NotNull(call.Arguments);
Assert.Equal("# Summary\nDone", call.Arguments["markdown"]);
}
[Fact]
public void ParserReadsUsage()
{
const string json = """
{
"usage": {
"input_tokens": 123,
"output_tokens": 45,
"total_tokens": 168
},
"output": [
{
"type": "message",
"content": [
{
"type": "output_text",
"text": "OK"
}
]
}
]
}
""";
var response = LiteLlmResponsesChatClient.ParseResponseJson(json);
Assert.NotNull(response.Usage);
Assert.Equal(123, response.Usage.InputTokenCount);
Assert.Equal(45, response.Usage.OutputTokenCount);
Assert.Equal(168, response.Usage.TotalTokenCount);
}
[Fact]
public async Task ClientRetriesTransientServerFailure()
{
var handler = new SequencedHttpMessageHandler(
new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent("Internal Server Error")
},
new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""
{
"output": [
{
"type": "message",
"content": [
{
"type": "output_text",
"text": "Done."
}
]
}
]
}
""")
});
using var client = CreateClient(handler, reconnectionAttempts: 1);
var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "write summary")]);
Assert.Equal("Done.", response.Text);
Assert.Equal(2, handler.RequestCount);
}
[Fact]
public async Task ClientDoesNotRetryNonTransientClientFailure()
{
var handler = new SequencedHttpMessageHandler(
new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent("bad request")
});
using var client = CreateClient(handler, reconnectionAttempts: 3);
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
() => client.GetResponseAsync([new ChatMessage(ChatRole.User, "write summary")]));
Assert.Contains("400", exception.Message);
Assert.Equal(1, handler.RequestCount);
}
[Fact]
public async Task ClientUsesResponsesCompactionEndpointWhenContextThresholdIsReached()
{
var handler = new RecordingHttpMessageHandler(request =>
{
if (request.RequestUri?.PathAndQuery == "/v1/responses/compact")
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""
{
"input": [
{
"role": "user",
"content": "compacted"
}
]
}
""")
};
}
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""
{
"output": [
{
"type": "message",
"content": [
{
"type": "output_text",
"text": "Done."
}
]
}
]
}
""")
};
});
using var client = CreateClient(
handler,
reconnectionAttempts: 0,
new LiteLlmResponsesCompactionOptions
{
Enabled = true,
ContextWindowTokens = 100,
MaxOutputTokens = 10,
RemainingRatio = 0.10,
CompactPath = "responses/compact"
});
var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, new string('x', 800))]);
Assert.Equal("Done.", response.Text);
Assert.Equal(["/v1/responses/compact", "/v1/responses"], handler.RequestPaths);
Assert.Contains("\"content\":\"compacted\"", handler.RequestBodies[1]);
}
[Fact]
public async Task ClientFallsBackToAgentFrameworkCompactionWhenResponsesCompactionFails()
{
var handler = new RecordingHttpMessageHandler(request =>
{
if (request.RequestUri?.PathAndQuery == "/v1/responses/compact")
{
return new HttpResponseMessage(HttpStatusCode.NotFound)
{
Content = new StringContent("missing")
};
}
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""
{
"output": [
{
"type": "message",
"content": [
{
"type": "output_text",
"text": "Done."
}
]
}
]
}
""")
};
});
using var client = CreateClient(
handler,
reconnectionAttempts: 0,
new LiteLlmResponsesCompactionOptions
{
Enabled = true,
ContextWindowTokens = 100,
MaxOutputTokens = 10,
RemainingRatio = 0.10,
CompactPath = "responses/compact",
FallbackStrategy = OpenAiMeetingSummaryAgentPipeline.CreateFallbackCompactionStrategyForTests(
contextWindowTokens: 100,
maxOutputTokens: 10,
remainingRatio: 0.10,
summaryClient: null)
});
var response = await client.GetResponseAsync(
Enumerable.Range(0, 8)
.Select(index => new ChatMessage(ChatRole.User, $"turn {index} {new string('x', 200)}")));
Assert.Equal("Done.", response.Text);
Assert.Equal(["/v1/responses/compact", "/v1/responses"], handler.RequestPaths);
Assert.DoesNotContain("turn 0", handler.RequestBodies[1]);
}
private static LiteLlmResponsesChatClient CreateClient(
HttpMessageHandler handler,
int reconnectionAttempts,
LiteLlmResponsesCompactionOptions? compactionOptions = null)
{
return new LiteLlmResponsesChatClient(
new HttpClient(handler)
{
BaseAddress = new Uri("https://litellm.example/v1/")
},
"test-key",
"test-model",
enableThinking: false,
reasoningEffort: "none",
reconnectionAttempts,
TimeSpan.Zero,
compactionOptions);
}
private sealed class SequencedHttpMessageHandler : HttpMessageHandler
{
private readonly Queue<HttpResponseMessage> responses;
public SequencedHttpMessageHandler(params HttpResponseMessage[] responses)
{
this.responses = new Queue<HttpResponseMessage>(responses);
}
public int RequestCount { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
RequestCount++;
return Task.FromResult(responses.Dequeue());
}
}
private sealed class RecordingHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> handler;
public RecordingHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> handler)
{
this.handler = handler;
}
public List<string> RequestPaths { get; } = [];
public List<string> RequestBodies { get; } = [];
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
RequestPaths.Add(request.RequestUri?.PathAndQuery ?? string.Empty);
RequestBodies.Add(request.Content is null
? string.Empty
: await request.Content.ReadAsStringAsync(cancellationToken));
return handler(request);
}
}
}
@@ -0,0 +1,91 @@
using MeetingAssistant.MeetingNotes;
using Microsoft.Extensions.Logging.Abstractions;
namespace MeetingAssistant.Tests;
public sealed class MeetingArtifactStoreTests
{
[Fact]
public async Task StoreCreatesAssistantContextNoteLinkedToMeetingArtifacts()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var contextPath = Path.Combine(root, "Meetings", "Assistant Context", "20260519-context.md");
var artifactStore = new MarkdownMeetingArtifactStore(NullLogger<MarkdownMeetingArtifactStore>.Instance);
var artifacts = new MeetingSessionArtifacts(
MeetingNotePath: Path.Combine(root, "Meetings", "Notes", "20260519-meeting.md"),
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "20260519-transcript.md"),
AssistantContextPath: contextPath,
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "20260519-summary.md"));
await artifactStore.CreateAssistantContextAsync(
artifacts,
"Review previous decisions\nAgree next steps",
DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"),
CancellationToken.None);
var content = await File.ReadAllTextAsync(contextPath);
Assert.Contains("# Assistant Context", content);
Assert.StartsWith("---", content, StringComparison.Ordinal);
Assert.Contains("state: transcribing", content);
Assert.Contains("agenda: |-", content);
Assert.Contains(" Review previous decisions", content);
Assert.Contains(" Agree next steps", content);
Assert.Contains("scheduled_end: \"2026-05-19T11:00:00.0000000+02:00\"", content);
Assert.Contains("meeting: \"[[../Notes/20260519-meeting|Meeting Note]]\"", content);
Assert.Contains("transcript: \"[[../Transcripts/20260519-transcript|Transcript]]\"", content);
Assert.Contains("summary: \"[[../Summaries/20260519-summary|Summary]]\"", content);
Assert.Contains("[[../Notes/20260519-meeting|Meeting Note]]", content);
Assert.Contains("[[../Transcripts/20260519-transcript|Transcript]]", content);
Assert.Contains("[[../Summaries/20260519-summary|Summary]]", content);
}
[Fact]
public async Task StoreUpdatesAssistantContextStateAndPreservesBody()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var contextPath = Path.Combine(root, "Meetings", "Assistant Context", "20260519-context.md");
var artifactStore = new MarkdownMeetingArtifactStore(NullLogger<MarkdownMeetingArtifactStore>.Instance);
var artifacts = new MeetingSessionArtifacts(
MeetingNotePath: Path.Combine(root, "Meetings", "Notes", "20260519-meeting.md"),
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "20260519-transcript.md"),
AssistantContextPath: contextPath,
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "20260519-summary.md"));
await artifactStore.CreateAssistantContextAsync(
artifacts,
"Initial agenda",
DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"),
CancellationToken.None);
await File.AppendAllTextAsync(contextPath, "Observed context line.");
await artifactStore.UpdateAssistantContextStateAsync(
artifacts,
AssistantContextState.Summarizing,
CancellationToken.None);
var content = await File.ReadAllTextAsync(contextPath);
Assert.Contains("state: summarizing", content);
Assert.Contains("agenda: |-", content);
Assert.Contains(" Initial agenda", content);
Assert.Contains("scheduled_end: \"2026-05-19T11:00:00.0000000+02:00\"", content);
Assert.Contains("Observed context line.", content);
}
[Fact]
public async Task StoreOmitsScheduledEndWhenNoTeamsMeetingWasDetected()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var contextPath = Path.Combine(root, "Meetings", "Assistant Context", "20260519-context.md");
var artifactStore = new MarkdownMeetingArtifactStore(NullLogger<MarkdownMeetingArtifactStore>.Instance);
var artifacts = new MeetingSessionArtifacts(
MeetingNotePath: Path.Combine(root, "Meetings", "Notes", "20260519-meeting.md"),
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "20260519-transcript.md"),
AssistantContextPath: contextPath,
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "20260519-summary.md"));
await artifactStore.CreateAssistantContextAsync(artifacts, "", null, CancellationToken.None);
var content = await File.ReadAllTextAsync(contextPath);
Assert.DoesNotContain("scheduled_end:", content);
}
}
@@ -23,4 +23,8 @@
<ProjectReference Include="..\MeetingAssistant\MeetingAssistant.csproj" />
</ItemGroup>
</Project>
<ItemGroup>
<Content Include="Fixtures\sample-16khz-mono.wav" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
@@ -0,0 +1,127 @@
using MeetingAssistant.MeetingNotes;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.Tests;
public sealed class MeetingNoteStoreTests
{
[Fact]
public async Task StoreWritesTemplateFrontmatterAndReadsUserNotesBody()
{
var (vaultRoot, store) = CreateStore();
var note = MeetingNoteTemplate.Create(
title: "Leadership Sync",
startTime: DateTimeOffset.Parse("2026-05-19T10:00:00+02:00"),
endTime: DateTimeOffset.Parse("2026-05-19T10:30:00+02:00"),
attendees:
[
"Mike <m.ike@ibm.com>",
"Ada"
],
projects:
[
"Meeting Assistant",
"Alaric"
],
transcriptPath: Path.Combine(vaultRoot, "Meetings", "Transcripts", "20260519-transcript.md"),
assistantContextPath: Path.Combine(vaultRoot, "Meetings", "Assistant Context", "20260519-context.md"),
summaryPath: Path.Combine(vaultRoot, "Meetings", "Summaries", "20260519-summary.md"),
userNotes: "Discuss rollout risks.");
var saved = await store.SaveAsync(note, CancellationToken.None);
var content = await File.ReadAllTextAsync(saved.Path);
var loaded = await store.ReadAsync(saved.Path, CancellationToken.None);
Assert.EndsWith(".md", saved.Path, StringComparison.Ordinal);
Assert.Contains("attendees:", content);
Assert.Contains("- Mike <m.ike@ibm.com>", content);
Assert.Contains("- Ada", content);
Assert.Contains("projects:", content);
Assert.Contains("- Meeting Assistant", content);
Assert.DoesNotContain("purpose:", content);
Assert.Contains("start_time: \"2026-05-19T10:00:00.0000000+02:00\"", content);
Assert.Contains("end_time: \"2026-05-19T10:30:00.0000000+02:00\"", content);
Assert.Contains("transcript: \"[[../Transcripts/20260519-transcript|Transcript]]\"", content);
Assert.Contains("assistant_context: \"[[../Assistant Context/20260519-context|Assistant Context]]\"", content);
Assert.Contains("summary: \"[[../Summaries/20260519-summary|Summary]]\"", content);
Assert.DoesNotContain("decisions:", content);
Assert.DoesNotContain("next_steps:", content);
Assert.Equal("Discuss rollout risks.", loaded.UserNotes);
Assert.Equal(["Mike <m.ike@ibm.com>", "Ada"], loaded.Frontmatter.Attendees);
Assert.Equal(["Meeting Assistant", "Alaric"], loaded.Frontmatter.Projects);
Assert.Equal(DateTimeOffset.Parse("2026-05-19T10:00:00+02:00"), loaded.Frontmatter.StartTime);
Assert.Equal(DateTimeOffset.Parse("2026-05-19T10:30:00+02:00"), loaded.Frontmatter.EndTime);
Assert.Equal("[[../Transcripts/20260519-transcript|Transcript]]", loaded.Frontmatter.Transcript);
Assert.Equal("[[../Assistant Context/20260519-context|Assistant Context]]", loaded.Frontmatter.AssistantContext);
Assert.Equal("[[../Summaries/20260519-summary|Summary]]", loaded.Frontmatter.Summary);
}
[Fact]
public async Task FrontmatterUpdatePreservesExistingUserNotesBody()
{
var (vaultRoot, store) = CreateStore();
var userNotes = """
## User notes
- First point
- Second point
```text
Keep this block exactly.
```
""";
var saved = await store.SaveAsync(
MeetingNoteTemplate.Create(
title: "Body Preservation",
attendees: [],
projects: [],
transcriptPath: Path.Combine(vaultRoot, "Meetings", "Transcripts", "20260519-transcript.md"),
assistantContextPath: Path.Combine(vaultRoot, "Meetings", "Assistant Context", "20260519-context.md"),
summaryPath: Path.Combine(vaultRoot, "Meetings", "Summaries", "20260519-summary.md"),
userNotes: userNotes),
CancellationToken.None);
var loaded = await store.ReadAsync(saved.Path, CancellationToken.None);
loaded.Frontmatter.Title = "Updated title";
await store.SaveAsync(loaded, CancellationToken.None);
var reloaded = await store.ReadAsync(saved.Path, CancellationToken.None);
Assert.Equal("Updated title", reloaded.Frontmatter.Title);
Assert.Equal(userNotes, reloaded.UserNotes);
}
[Fact]
public void ActionLinkEscapesSummaryFileName()
{
var link = MeetingNoteActionLinks.CreateSummaryRetryLink(
"http://localhost:5090/",
"C:\\Vault\\Meetings\\Summaries\\summary with spaces.md");
Assert.Equal(
"[Retry summary generation](http://localhost:5090/meetings/summary/retry?summaryPath=summary%20with%20spaces.md)",
link);
}
private static (string VaultRoot, MarkdownMeetingNoteStore Store) CreateStore()
{
var vaultRoot = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var store = new MarkdownMeetingNoteStore(
Options.Create(new MeetingAssistantOptions
{
Vault = new VaultOptions
{
MeetingNotesFolder = Path.Combine(vaultRoot, "Meetings", "Notes"),
TranscriptsFolder = Path.Combine(vaultRoot, "Meetings", "Transcripts"),
AssistantContextFolder = Path.Combine(vaultRoot, "Meetings", "Assistant Context"),
SummariesFolder = Path.Combine(vaultRoot, "Meetings", "Summaries"),
ProjectsFolder = Path.Combine(vaultRoot, "Projects"),
DictationWordsPath = Path.Combine(vaultRoot, "Meetings", "dictation-words.md")
}
}),
NullLogger<MarkdownMeetingNoteStore>.Instance);
return (vaultRoot, store);
}
}
@@ -0,0 +1,76 @@
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Summary;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.Tests;
public sealed class MeetingSummaryArtifactResolverTests
{
[Fact]
public async Task ResolverFindsMeetingArtifactsForSummaryPath()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var notes = Path.Combine(root, "Meetings", "Notes");
var summaries = Path.Combine(root, "Meetings", "Summaries");
Directory.CreateDirectory(notes);
Directory.CreateDirectory(summaries);
var notePath = Path.Combine(notes, "meeting.md");
await File.WriteAllTextAsync(
notePath,
"""
---
title: Meeting
attendees:
projects:
transcript: "[[../Transcripts/transcript|Transcript]]"
assistant_context: "[[../Assistant Context/context|Assistant Context]]"
summary: "[[../Summaries/summary|Summary]]"
---
User notes.
""");
var options = Options.Create(new MeetingAssistantOptions
{
Vault = new VaultOptions
{
MeetingNotesFolder = notes,
SummariesFolder = summaries
}
});
var resolver = new MeetingSummaryArtifactResolver(
options,
new MarkdownMeetingNoteStore(options, NullLogger<MarkdownMeetingNoteStore>.Instance));
var artifacts = await resolver.ResolveBySummaryPathAsync("summary.md", CancellationToken.None);
Assert.NotNull(artifacts);
Assert.Equal(notePath, artifacts.MeetingNotePath);
Assert.Equal(Path.Combine(root, "Meetings", "Transcripts", "transcript.md"), artifacts.TranscriptPath);
Assert.Equal(Path.Combine(root, "Meetings", "Assistant Context", "context.md"), artifacts.AssistantContextPath);
Assert.Equal(Path.Combine(summaries, "summary.md"), artifacts.SummaryPath);
}
[Fact]
public async Task ResolverRejectsSummaryPathOutsideConfiguredSummaryFolder()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var options = Options.Create(new MeetingAssistantOptions
{
Vault = new VaultOptions
{
MeetingNotesFolder = Path.Combine(root, "Notes"),
SummariesFolder = Path.Combine(root, "Summaries")
}
});
var resolver = new MeetingSummaryArtifactResolver(
options,
new MarkdownMeetingNoteStore(options, NullLogger<MarkdownMeetingNoteStore>.Instance));
var artifacts = await resolver.ResolveBySummaryPathAsync(
Path.Combine(root, "Other", "summary.md"),
CancellationToken.None);
Assert.Null(artifacts);
}
}
@@ -0,0 +1,65 @@
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Summary;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.Tests;
public sealed class MeetingSummaryFailureWriterTests
{
[Fact]
public async Task WriterOverwritesSummaryWithFailureDetails()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var artifacts = new MeetingSessionArtifacts(
Path.Combine(root, "Notes", "meeting.md"),
Path.Combine(root, "Transcripts", "transcript.md"),
Path.Combine(root, "Assistant Context", "context.md"),
Path.Combine(root, "Summaries", "summary.md"));
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.SummaryPath)!);
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
await File.WriteAllTextAsync(
artifacts.MeetingNotePath,
"""
---
title: Failure Meeting
start_time: "2026-05-20T10:00:00.0000000+02:00"
end_time: "2026-05-20T10:30:00.0000000+02:00"
attendees: []
projects: []
transcript: "[[../Transcripts/transcript|Transcript]]"
assistant_context: "[[../Assistant Context/context|Assistant Context]]"
summary: "[[../Summaries/summary|Summary]]"
---
""");
await File.WriteAllTextAsync(artifacts.SummaryPath, "# Old Summary");
var writer = new MeetingSummaryFailureWriter(
Options.Create(new MeetingAssistantOptions
{
Api = new ApiOptions { PublicBaseUrl = "http://localhost:5090" }
}),
new MarkdownMeetingNoteStore(
Options.Create(new MeetingAssistantOptions
{
Vault = new VaultOptions { MeetingNotesFolder = Path.GetDirectoryName(artifacts.MeetingNotePath)! }
}),
Microsoft.Extensions.Logging.Abstractions.NullLogger<MarkdownMeetingNoteStore>.Instance));
var result = await writer.WriteAsync(
artifacts,
new InvalidOperationException("LiteLLM Responses request failed with 500"),
CancellationToken.None);
var content = await File.ReadAllTextAsync(artifacts.SummaryPath);
Assert.False(result.Succeeded);
Assert.Equal(artifacts.SummaryPath, result.SummaryPath);
Assert.Contains("Summary Generation Failed", content);
Assert.Contains("title: Failure Meeting", content);
Assert.Contains("meeting: \"[[../Notes/meeting|Meeting Note]]\"", content);
Assert.Contains(
"[Retry summary generation](http://localhost:5090/meetings/summary/retry?summaryPath=summary.md)",
content);
Assert.Contains("LiteLLM Responses request failed with 500", content);
Assert.Contains(artifacts.TranscriptPath, content);
Assert.DoesNotContain("# Old Summary", content);
}
}
@@ -0,0 +1,145 @@
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Summary;
namespace MeetingAssistant.Tests;
public sealed class MeetingSummaryToolTests
{
[Fact]
public async Task ToolsReadMeetingInputsAndWriteSummaryNote()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var artifacts = new MeetingSessionArtifacts(
MeetingNotePath: Path.Combine(root, "Meetings", "Notes", "meeting.md"),
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "transcript.md"),
AssistantContextPath: Path.Combine(root, "Meetings", "Assistant Context", "context.md"),
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "summary.md"));
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.TranscriptPath)!);
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
await File.WriteAllTextAsync(
artifacts.MeetingNotePath,
"""
---
title: Meeting
start_time: "2026-05-20T10:00:00.0000000+02:00"
end_time: "2026-05-20T10:30:00.0000000+02:00"
attendees:
- Ada
projects:
- Meeting Assistant
transcript: "[[../Transcripts/transcript|Transcript]]"
assistant_context: "[[../Assistant Context/context|Assistant Context]]"
summary: "[[../Summaries/summary|Summary]]"
---
User note line.
""");
await File.WriteAllTextAsync(artifacts.TranscriptPath, "Ada: Transcript line.");
await File.WriteAllTextAsync(artifacts.AssistantContextPath, "Context line.");
var tools = new MeetingSummaryTools(artifacts);
Assert.Equal("Ada: Transcript line.", await tools.ReadTranscript());
Assert.Contains("title: Meeting", await tools.ReadMeetingNote());
Assert.Equal("Context line.", await tools.ReadContext());
Assert.Equal("User note line.", await tools.ReadUserNotes());
Assert.Equal("", await tools.ReadGlossary());
var result = await tools.WriteSummary("# Summary\n\n- Done.");
Assert.Equal(artifacts.SummaryPath, result);
var summary = await File.ReadAllTextAsync(artifacts.SummaryPath);
Assert.Contains("title: Meeting", summary);
Assert.Contains("start_time: \"2026-05-20T10:00:00.0000000+02:00\"", summary);
Assert.Contains("end_time: \"2026-05-20T10:30:00.0000000+02:00\"", summary);
Assert.Contains("meeting: \"[[../Notes/meeting|Meeting Note]]\"", summary);
Assert.Contains("transcript: \"[[../Transcripts/transcript|Transcript]]\"", summary);
Assert.Contains("assistant_context: \"[[../Assistant Context/context|Assistant Context]]\"", summary);
Assert.Contains("# Summary\n\n- Done.", summary);
}
[Fact]
public async Task ReadToolsSupportClampedLineRanges()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var artifacts = new MeetingSessionArtifacts(
MeetingNotePath: Path.Combine(root, "Meetings", "Notes", "meeting.md"),
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "transcript.md"),
AssistantContextPath: Path.Combine(root, "Meetings", "Assistant Context", "context.md"),
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "summary.md"));
var glossaryPath = Path.Combine(root, "Meetings", "dictation-words.md");
var projectRoot = Path.Combine(root, "Projects", "Project A");
var projectFile = Path.Combine(projectRoot, "notes.md");
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.TranscriptPath)!);
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
Directory.CreateDirectory(Path.GetDirectoryName(glossaryPath)!);
Directory.CreateDirectory(projectRoot);
await File.WriteAllTextAsync(
artifacts.MeetingNotePath,
"""
---
projects:
- Project A
---
note one
note two
note three
""");
await File.WriteAllTextAsync(artifacts.TranscriptPath, "transcript one\ntranscript two\ntranscript three");
await File.WriteAllTextAsync(artifacts.AssistantContextPath, "context one\ncontext two\ncontext three");
await File.WriteAllTextAsync(glossaryPath, "word one\nword two\nword three");
await File.WriteAllTextAsync(projectFile, "project one\nproject two\nproject three");
var tools = new MeetingSummaryTools(
artifacts,
new MeetingAssistantOptions
{
Vault =
{
DictationWordsPath = glossaryPath,
ProjectsFolder = Path.Combine(root, "Projects")
}
});
Assert.Equal("transcript two", await tools.ReadTranscript(from: 2, to: 2));
Assert.Equal("context two\ncontext three", await tools.ReadContext(from: 2, to: 9));
Assert.Equal("note two\nnote three", await tools.ReadUserNotes(from: 2, to: 99));
Assert.Equal("word two", await tools.ReadGlossary(from: 2, to: 2));
Assert.Equal("project two", await tools.ReadProjectFile("Project A", "notes.md", from: 2, to: 2));
Assert.Equal("", await tools.ReadTranscript(from: 3, to: 2));
}
[Fact]
public async Task ToolsWriteAssistantContextBodyWithLineModes()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var artifacts = new MeetingSessionArtifacts(
MeetingNotePath: Path.Combine(root, "Meetings", "Notes", "meeting.md"),
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "transcript.md"),
AssistantContextPath: Path.Combine(root, "Meetings", "Assistant Context", "context.md"),
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "summary.md"));
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
await File.WriteAllTextAsync(
artifacts.AssistantContextPath,
"""
---
meeting: "[[../Notes/meeting|Meeting Note]]"
transcript: "[[../Transcripts/transcript|Transcript]]"
summary: "[[../Summaries/summary|Summary]]"
state: summarizing
---
line one
line two
""");
var tools = new MeetingSummaryTools(artifacts);
await tools.WriteContext("inserted", insert: 2);
await tools.WriteContext("replacement", from: 1, to: 1);
var context = await File.ReadAllTextAsync(artifacts.AssistantContextPath);
Assert.Contains("state: summarizing", context);
Assert.Contains("replacement\ninserted\nline two", context);
}
}
@@ -0,0 +1,25 @@
using MeetingAssistant.MeetingNotes;
namespace MeetingAssistant.Tests;
#if WINDOWS
public sealed class OutlookClassicMeetingMetadataProviderTests
{
[Fact]
public void ExtractAgendaStopsBeforeTeamsJoinInformation()
{
var agenda = OutlookClassicMeetingMetadataProvider.ExtractAgenda(
"""
Review current prototype
Decide next backend
________________________________________________________________________________
Microsoft Teams Need help?
Join the meeting now
https://teams.microsoft.com/l/meetup-join/...
""");
Assert.Equal("Review current prototype\nDecide next backend", agenda.Replace("\r\n", "\n", StringComparison.Ordinal));
}
}
#endif
@@ -0,0 +1,72 @@
using MeetingAssistant.MeetingNotes;
namespace MeetingAssistant.Tests;
public sealed class OutlookMeetingCandidateSelectorTests
{
[Fact]
public void SelectIgnoresOverlappingMeetingThatEndsInLessThanFiveMinutes()
{
var now = new DateTime(2026, 5, 20, 10, 0, 0);
var selected = OutlookMeetingCandidateSelector.Select(
[
new Candidate(now.AddMinutes(-25), now.AddMinutes(4))
],
now,
candidate => candidate.Start,
candidate => candidate.End);
Assert.Null(selected);
}
[Fact]
public void SelectPrefersSingleGoodOverlappingMeetingOverUpcomingMeeting()
{
var now = new DateTime(2026, 5, 20, 10, 0, 0);
var overlap = new Candidate(now.AddMinutes(-10), now.AddMinutes(30));
var upcoming = new Candidate(now.AddMinutes(3), now.AddMinutes(33));
var selected = OutlookMeetingCandidateSelector.Select(
[upcoming, overlap],
now,
candidate => candidate.Start,
candidate => candidate.End);
Assert.Same(overlap, selected);
}
[Fact]
public void SelectUsesSingleTeamsMeetingStartingWithinFiveMinutesWhenNoGoodOverlapExists()
{
var now = new DateTime(2026, 5, 20, 10, 0, 0);
var endingOverlap = new Candidate(now.AddMinutes(-25), now.AddMinutes(2));
var upcoming = new Candidate(now.AddMinutes(5), now.AddMinutes(35));
var selected = OutlookMeetingCandidateSelector.Select(
[endingOverlap, upcoming],
now,
candidate => candidate.Start,
candidate => candidate.End);
Assert.Same(upcoming, selected);
}
[Fact]
public void SelectRejectsAmbiguousUpcomingMeetings()
{
var now = new DateTime(2026, 5, 20, 10, 0, 0);
var selected = OutlookMeetingCandidateSelector.Select(
[
new Candidate(now.AddMinutes(2), now.AddMinutes(32)),
new Candidate(now.AddMinutes(4), now.AddMinutes(34))
],
now,
candidate => candidate.Start,
candidate => candidate.End);
Assert.Null(selected);
}
private sealed record Candidate(DateTime Start, DateTime End);
}
@@ -0,0 +1,125 @@
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Summary;
namespace MeetingAssistant.Tests;
public sealed class ProjectKnowledgeToolTests
{
[Fact]
public async Task ToolsOperateOnProjectsBoundInMeetingFrontmatter()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var projectsRoot = Path.Combine(root, "Projects");
var meetingAssistantRoot = Path.Combine(projectsRoot, "MeetingAssistant");
var ignoredRoot = Path.Combine(projectsRoot, "IgnoredProject");
Directory.CreateDirectory(Path.Combine(meetingAssistantRoot, "notes"));
Directory.CreateDirectory(ignoredRoot);
await File.WriteAllTextAsync(
Path.Combine(meetingAssistantRoot, "README.md"),
"First line\nSecond alpha line\nThird beta line");
await File.WriteAllTextAsync(Path.Combine(meetingAssistantRoot, "notes", "context.md"), "Project context");
await File.WriteAllTextAsync(Path.Combine(ignoredRoot, "ignored.md"), "alpha should not be searched");
var artifacts = CreateArtifacts(root);
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
await File.WriteAllTextAsync(
artifacts.MeetingNotePath,
"""
---
title: Project Meeting
attendees: []
projects:
- MeetingAssistant
- MissingProject
transcript: ""
assistant_context: ""
summary: ""
---
User notes.
""");
var tools = new MeetingSummaryTools(
artifacts,
new MeetingAssistantOptions
{
Vault =
{
ProjectsFolder = projectsRoot
}
});
Assert.Equal("MeetingAssistant", await tools.ListProjects());
Assert.Equal("README.md\nnotes/context.md", await tools.ListProjectFiles("MeetingAssistant"));
Assert.Equal("Second alpha line\nThird beta line", await tools.ReadProjectFile("MeetingAssistant", "README.md", 2, 99));
var writeResult = await tools.WriteProjectFile("MeetingAssistant", "notes/summary.md", "# Project Update");
Assert.Equal("MeetingAssistant/notes/summary.md", writeResult);
Assert.Equal("# Project Update", await File.ReadAllTextAsync(Path.Combine(meetingAssistantRoot, "notes", "summary.md")));
Assert.Equal("IgnoredProject/ignored.md", await tools.WriteProjectFile("IgnoredProject", "ignored.md", "changed"));
Assert.Equal("changed", await File.ReadAllTextAsync(Path.Combine(ignoredRoot, "ignored.md")));
Assert.Equal(
"README.md:2 Second alpha line",
await tools.Search("alpha"));
}
[Fact]
public async Task WriteProjectFileSupportsOverwriteReplaceInsertAndCreate()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var projectsRoot = Path.Combine(root, "Projects");
var projectRoot = Path.Combine(projectsRoot, "MeetingAssistant");
Directory.CreateDirectory(projectRoot);
var projectFile = Path.Combine(projectRoot, "notes.md");
await File.WriteAllTextAsync(projectFile, "one\ntwo\nthree\nfour");
var tools = new MeetingSummaryTools(
CreateArtifacts(root),
new MeetingAssistantOptions
{
Vault =
{
ProjectsFolder = projectsRoot
}
});
await tools.WriteProjectFile("MeetingAssistant", "notes.md", "TWO\nTHREE", from: 2, to: 3);
Assert.Equal("one\nTWO\nTHREE\nfour", await File.ReadAllTextAsync(projectFile));
await tools.WriteProjectFile("MeetingAssistant", "notes.md", "inserted", insert: 2);
Assert.Equal("one\ninserted\nTWO\nTHREE\nfour", await File.ReadAllTextAsync(projectFile));
await tools.WriteProjectFile("MeetingAssistant", "created/new.md", "created content");
Assert.Equal("created content", await File.ReadAllTextAsync(Path.Combine(projectRoot, "created", "new.md")));
}
[Fact]
public async Task WriteProjectFileRefusesMissingProjectsEscapingPathsAndAmbiguousLineArguments()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var projectsRoot = Path.Combine(root, "Projects");
Directory.CreateDirectory(Path.Combine(projectsRoot, "MeetingAssistant"));
var tools = new MeetingSummaryTools(
CreateArtifacts(root),
new MeetingAssistantOptions
{
Vault =
{
ProjectsFolder = projectsRoot
}
});
Assert.StartsWith("Refused:", await tools.WriteProjectFile("MissingProject", "notes.md", "content"));
Assert.StartsWith("Refused:", await tools.WriteProjectFile("MeetingAssistant", "../outside.md", "content"));
Assert.StartsWith("Refused:", await tools.WriteProjectFile("MeetingAssistant", "notes.md", "content", from: 1));
Assert.StartsWith("Refused:", await tools.WriteProjectFile("MeetingAssistant", "notes.md", "content", from: 1, to: 1, insert: 1));
}
private static MeetingSessionArtifacts CreateArtifacts(string root)
{
return new MeetingSessionArtifacts(
MeetingNotePath: Path.Combine(root, "Meetings", "Notes", "meeting.md"),
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "transcript.md"),
AssistantContextPath: Path.Combine(root, "Meetings", "Assistant Context", "context.md"),
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "summary.md"));
}
}
@@ -0,0 +1,265 @@
using MeetingAssistant;
using MeetingAssistant.Transcription;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.Tests;
public sealed class PyannoteTranscriptFinalizerTests
{
[Fact]
public async Task FinalizerAssignsPyannoteSpeakersToLiveWhisperSegmentsByOverlap()
{
var audioPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "meeting.wav");
Directory.CreateDirectory(Path.GetDirectoryName(audioPath)!);
await File.WriteAllBytesAsync(audioPath, [1, 2, 3, 4]);
var commandRunner = new CapturingCommandRunner(
"""
install noise
__MEETING_ASSISTANT_PYANNOTE_JSON_START__
[{"start":0.0,"end":1.5,"speaker":"SPEAKER_00"},{"start":1.5,"end":3.0,"speaker":"SPEAKER_01"}]
__MEETING_ASSISTANT_PYANNOTE_JSON_END__
""");
var finalizer = CreateFinalizer(
commandRunner,
token: "hf_test",
alignmentMode: PyannoteAlignmentMode.BestOverlap);
var liveSegments = new[]
{
new TranscriptionSegment(TimeSpan.FromSeconds(0.2), TimeSpan.FromSeconds(1.2), "Unknown", "hello"),
new TranscriptionSegment(TimeSpan.FromSeconds(1.7), TimeSpan.FromSeconds(2.4), "Unknown", "there")
};
var segments = await finalizer.FinalizeAsync(
audioPath,
liveSegments,
SpeechRecognitionPipelineOptions.Default,
CancellationToken.None);
Assert.Contains(commandRunner.Commands, command => command.Arguments.Contains("inspect"));
Assert.Contains(commandRunner.Commands, command => command.Arguments.Contains("--format"));
Assert.Contains(commandRunner.Commands, command => command.Arguments.Contains("build"));
Assert.Contains(commandRunner.Commands, command => command.Arguments.Contains("meeting-assistant-pyannote:local"));
Assert.Equal("docker", commandRunner.Commands.Last().FileName);
Assert.Contains("run", commandRunner.Commands.Last().Arguments);
Assert.Contains("meeting-assistant-pyannote:local", commandRunner.Commands.Last().Arguments);
Assert.Contains("HF_TOKEN", commandRunner.Commands.Last().Arguments);
Assert.DoesNotContain("HF_TOKEN=hf_test", commandRunner.Commands.Last().Arguments);
Assert.Equal("hf_test", commandRunner.Environment["HF_TOKEN"]);
Assert.Contains("sh", commandRunner.Commands.Last().Arguments);
Assert.DoesNotContain("bash", commandRunner.Commands.Last().Arguments);
Assert.DoesNotContain(commandRunner.Commands.Last().Arguments, argument => argument.Contains("pip install", StringComparison.Ordinal));
Assert.DoesNotContain(commandRunner.Commands.Last().Arguments, argument => argument.Contains("apt-get install", StringComparison.Ordinal));
Assert.Contains(commandRunner.Commands.Last().Arguments, argument => argument.Contains("PIP_CACHE_DIR=/workspace/cache/pip", StringComparison.Ordinal));
Assert.Contains(commandRunner.Commands.Last().Arguments, argument => argument.Contains("HF_HOME=/workspace/cache/huggingface", StringComparison.Ordinal));
Assert.Contains(commandRunner.Commands.Last().Arguments, argument => argument.Contains("TORCH_HOME=/workspace/cache/torch", StringComparison.Ordinal));
Assert.Contains(commandRunner.Commands.Last().Arguments, argument => argument.Contains("speaker_diarization", StringComparison.Ordinal));
Assert.Contains(commandRunner.Commands.Last().Arguments, argument => argument.Contains("token=os.environ.get('HF_TOKEN')", StringComparison.Ordinal));
Assert.DoesNotContain(commandRunner.Commands.Last().Arguments, argument => argument.Contains("use_auth_token", StringComparison.Ordinal));
Assert.Collection(
segments,
first =>
{
Assert.Equal("SPEAKER_00", first.Speaker);
Assert.Equal("hello", first.Text);
},
second =>
{
Assert.Equal("SPEAKER_01", second.Speaker);
Assert.Equal("there", second.Text);
});
}
[Fact]
public async Task FinalizerCanBuildTranscriptSegmentsFromPyannoteTurns()
{
var audioPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "meeting.wav");
Directory.CreateDirectory(Path.GetDirectoryName(audioPath)!);
await File.WriteAllBytesAsync(audioPath, [1, 2, 3, 4]);
var commandRunner = new CapturingCommandRunner(
"""
__MEETING_ASSISTANT_PYANNOTE_JSON_START__
[{"start":0.0,"end":1.0,"speaker":"SPEAKER_00"},{"start":1.0,"end":2.0,"speaker":"SPEAKER_01"}]
__MEETING_ASSISTANT_PYANNOTE_JSON_END__
""");
var finalizer = CreateFinalizer(
commandRunner,
token: "hf_test",
annotationSource: PyannoteAnnotationSource.ExclusiveSpeakerDiarization,
alignmentMode: PyannoteAlignmentMode.PyannoteTurns);
var liveSegments = new[]
{
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(2), "Unknown", "alpha beta gamma delta")
};
var segments = await finalizer.FinalizeAsync(
audioPath,
liveSegments,
SpeechRecognitionPipelineOptions.Default,
CancellationToken.None);
Assert.Contains(commandRunner.Commands.Last().Arguments, argument => argument.Contains("exclusive_speaker_diarization", StringComparison.Ordinal));
Assert.Collection(
segments,
first =>
{
Assert.Equal(TimeSpan.Zero, first.Start);
Assert.Equal(TimeSpan.FromSeconds(1), first.End);
Assert.Equal("SPEAKER_00", first.Speaker);
Assert.Equal("alpha beta", first.Text);
},
second =>
{
Assert.Equal(TimeSpan.FromSeconds(1), second.Start);
Assert.Equal(TimeSpan.FromSeconds(2), second.End);
Assert.Equal("SPEAKER_01", second.Speaker);
Assert.Equal("gamma delta", second.Text);
});
}
[Fact]
public async Task FinalizerPassesConfiguredNumSpeakersToPyannote()
{
var audioPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "meeting.wav");
Directory.CreateDirectory(Path.GetDirectoryName(audioPath)!);
await File.WriteAllBytesAsync(audioPath, [1, 2, 3, 4]);
var commandRunner = new CapturingCommandRunner(
"""
__MEETING_ASSISTANT_PYANNOTE_JSON_START__
[{"start":0.0,"end":1.0,"speaker":"SPEAKER_00"}]
__MEETING_ASSISTANT_PYANNOTE_JSON_END__
""");
var finalizer = CreateFinalizer(commandRunner, token: "hf_test");
await finalizer.FinalizeAsync(
audioPath,
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Unknown", "hello")],
new SpeechRecognitionPipelineOptions(5),
CancellationToken.None);
Assert.Contains(commandRunner.Commands.Last().Arguments, argument => argument.Contains("num_speakers=5", StringComparison.Ordinal));
}
[Fact]
public async Task FinalizerCanUseExplicitDiarizationOptions()
{
var audioPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "meeting.wav");
Directory.CreateDirectory(Path.GetDirectoryName(audioPath)!);
await File.WriteAllBytesAsync(audioPath, [1, 2, 3, 4]);
var commandRunner = new CapturingCommandRunner(
"""
__MEETING_ASSISTANT_PYANNOTE_JSON_START__
[{"start":0.0,"end":1.0,"speaker":"SPEAKER_00"}]
__MEETING_ASSISTANT_PYANNOTE_JSON_END__
""");
var finalizer = new PyannoteTranscriptFinalizer(
commandRunner,
Options.Create(new MeetingAssistantOptions()),
NullLogger<PyannoteTranscriptFinalizer>.Instance);
var explicitDiarization = new PyannoteDiarizationOptions
{
Enabled = true,
DockerCommand = "docker",
BaseImage = "python:3.11-slim",
Image = "meeting-assistant-pyannote-azure:local",
ModelsFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "models"),
Model = "custom/diarization",
Token = "hf_azure",
TokenEnv = "",
CommandTimeout = TimeSpan.FromMinutes(1)
};
await finalizer.FinalizeAsync(
audioPath,
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Unknown", "hello")],
explicitDiarization,
SpeechRecognitionPipelineOptions.Default,
CancellationToken.None);
Assert.Contains(commandRunner.Commands, command => command.Arguments.Contains("meeting-assistant-pyannote-azure:local"));
Assert.Contains(commandRunner.Commands.Last().Arguments, argument => argument.Contains("custom/diarization", StringComparison.Ordinal));
Assert.Equal("hf_azure", commandRunner.Environment["HF_TOKEN"]);
}
[Fact]
public async Task FinalizerSkipsPyannoteWhenTokenIsMissing()
{
var commandRunner = new CapturingCommandRunner("");
var finalizer = CreateFinalizer(commandRunner, token: null);
var audioPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "meeting.wav");
Directory.CreateDirectory(Path.GetDirectoryName(audioPath)!);
await File.WriteAllBytesAsync(audioPath, [1, 2, 3, 4]);
var segments = await finalizer.FinalizeAsync(
audioPath,
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Unknown", "hello")],
SpeechRecognitionPipelineOptions.Default,
CancellationToken.None);
Assert.Empty(segments);
Assert.Empty(commandRunner.Commands);
}
private static PyannoteTranscriptFinalizer CreateFinalizer(
CapturingCommandRunner commandRunner,
string? token,
PyannoteAnnotationSource annotationSource = PyannoteAnnotationSource.SpeakerDiarization,
PyannoteAlignmentMode alignmentMode = PyannoteAlignmentMode.PyannoteTurns)
{
return new PyannoteTranscriptFinalizer(
commandRunner,
Options.Create(new MeetingAssistantOptions
{
WhisperLocal = new WhisperLocalOptions
{
Diarization = new PyannoteDiarizationOptions
{
Enabled = true,
DockerCommand = "docker",
BaseImage = "python:3.11-slim",
Image = "meeting-assistant-pyannote:local",
ModelsFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "models"),
AnnotationSource = annotationSource,
AlignmentMode = alignmentMode,
Token = token,
TokenEnv = "",
CommandTimeout = TimeSpan.FromMinutes(1)
}
}
}),
NullLogger<PyannoteTranscriptFinalizer>.Instance);
}
private sealed class CapturingCommandRunner : ICommandRunner
{
private readonly string output;
public CapturingCommandRunner(string output)
{
this.output = output;
}
public IReadOnlyList<CapturedCommand> Commands { get; private set; } = [];
public IReadOnlyDictionary<string, string> Environment { get; private set; } =
new Dictionary<string, string>();
public Task<CommandResult> RunAsync(
string fileName,
IReadOnlyList<string> arguments,
CancellationToken cancellationToken,
IReadOnlyDictionary<string, string>? environment = null)
{
Commands = Commands.Append(new CapturedCommand(fileName, arguments)).ToList();
Environment = environment ?? new Dictionary<string, string>();
if (arguments.Contains("inspect"))
{
return Task.FromResult(new CommandResult(1, "", "image missing"));
}
return Task.FromResult(new CommandResult(0, output, ""));
}
}
private sealed record CapturedCommand(string FileName, IReadOnlyList<string> Arguments);
}
@@ -0,0 +1,930 @@
using MeetingAssistant.Recording;
using MeetingAssistant.Transcription;
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Summary;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using System.Threading.Channels;
namespace MeetingAssistant.Tests;
public sealed class RecordingCoordinatorTests
{
[Fact]
public async Task ToggleStartsStreamingTranscriptionAndSecondToggleStopsIt()
{
var audioSource = new ControlledAudioSource();
var provider = new EchoStreamingTranscriptionProvider();
var store = new InMemoryTranscriptStore();
var noteStore = new InMemoryMeetingNoteStore();
var noteOpener = new CapturingMeetingNoteOpener();
var artifactStore = new InMemoryMeetingArtifactStore();
var audioArchive = new InMemoryRecordedAudioStore();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(provider),
store,
noteStore,
noteOpener,
artifactStore,
audioArchive,
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions()),
NullLogger<MeetingRecordingCoordinator>.Instance);
var started = await coordinator.ToggleAsync(CancellationToken.None);
await audioSource.WriteAsync(new AudioChunk(new byte[] { 1, 0 }, 16000, 1), CancellationToken.None);
await store.WaitForTextAsync("chunk:2");
var stopped = await coordinator.ToggleAsync(CancellationToken.None);
Assert.True(started.IsRecording);
Assert.Equal(noteStore.SavedNote?.Path, noteOpener.OpenedPath);
Assert.Equal(started.MeetingNotePath, noteOpener.OpenedPath);
Assert.Equal(started.MeetingNotePath, artifactStore.CreatedArtifacts?.MeetingNotePath);
Assert.True(provider.FirstChunkWasObservedBeforeSourceCompleted);
Assert.False(stopped.IsRecording);
}
[Fact]
public async Task StartCreatesMeetingNoteLinkedToTranscriptAndOpensIt()
{
var audioSource = new ControlledAudioSource();
var transcriptStore = new InMemoryTranscriptStore("C:\\Vault\\Meetings\\Transcripts\\20260519-transcript.md");
var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md");
var noteOpener = new CapturingMeetingNoteOpener();
var artifactStore = new InMemoryMeetingArtifactStore();
var audioArchive = new InMemoryRecordedAudioStore();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
transcriptStore,
noteStore,
noteOpener,
artifactStore,
audioArchive,
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions
{
Vault = new VaultOptions
{
AssistantContextFolder = "C:\\Vault\\Meetings\\Assistant Context",
SummariesFolder = "C:\\Vault\\Meetings\\Summaries"
}
}),
NullLogger<MeetingRecordingCoordinator>.Instance);
var status = await coordinator.StartAsync(CancellationToken.None);
Assert.True(status.IsRecording);
Assert.Equal("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md", status.MeetingNotePath);
Assert.Equal("C:\\Vault\\Meetings\\Transcripts\\20260519-transcript.md", noteStore.SavedNote?.Frontmatter.Transcript);
Assert.StartsWith("C:\\Vault\\Meetings\\Assistant Context\\", noteStore.SavedNote?.Frontmatter.AssistantContext, StringComparison.Ordinal);
Assert.EndsWith("-assistant-context.md", noteStore.SavedNote?.Frontmatter.AssistantContext, StringComparison.Ordinal);
Assert.StartsWith("C:\\Vault\\Meetings\\Summaries\\", noteStore.SavedNote?.Frontmatter.Summary, StringComparison.Ordinal);
Assert.EndsWith("-summary.md", noteStore.SavedNote?.Frontmatter.Summary, StringComparison.Ordinal);
Assert.Equal("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md", noteOpener.OpenedPath);
Assert.Equal("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md", artifactStore.CreatedArtifacts?.MeetingNotePath);
Assert.Equal("C:\\Vault\\Meetings\\Transcripts\\20260519-transcript.md", artifactStore.CreatedArtifacts?.TranscriptPath);
Assert.StartsWith("C:\\Vault\\Meetings\\Assistant Context\\", artifactStore.CreatedArtifacts?.AssistantContextPath, StringComparison.Ordinal);
Assert.StartsWith("C:\\Vault\\Meetings\\Summaries\\", artifactStore.CreatedArtifacts?.SummaryPath, StringComparison.Ordinal);
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task StartUsesCurrentOutlookMeetingMetadataWhenAvailable()
{
var audioSource = new ControlledAudioSource();
var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\metadata-meeting.md");
var artifactStore = new InMemoryMeetingArtifactStore();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
new InMemoryTranscriptStore(),
noteStore,
new CapturingMeetingNoteOpener(),
artifactStore,
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions()),
NullLogger<MeetingRecordingCoordinator>.Instance,
new FixedMeetingMetadataProvider(new MeetingMetadata(
"Architecture Sync",
["Ada <ada@example.com>", "Grace"],
"Review API shape",
DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"))));
await coordinator.StartAsync(CancellationToken.None);
Assert.Equal("Architecture Sync", noteStore.SavedNote?.Frontmatter.Title);
Assert.Equal(["Ada <ada@example.com>", "Grace"], noteStore.SavedNote?.Frontmatter.Attendees);
Assert.Equal("Review API shape", artifactStore.Agenda);
Assert.Equal(DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"), artifactStore.ScheduledEnd);
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task StopCompletesAudioCaptureAndDrainsFinalTranscriptionWindow()
{
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk(new byte[] { 1, 0 }, 16000, 1));
var provider = new FinalSegmentOnAudioCompletionProvider();
var store = new InMemoryTranscriptStore();
var noteStore = new InMemoryMeetingNoteStore();
var noteOpener = new CapturingMeetingNoteOpener();
var artifactStore = new InMemoryMeetingArtifactStore();
var audioArchive = new InMemoryRecordedAudioStore();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(provider),
store,
noteStore,
noteOpener,
artifactStore,
audioArchive,
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions()),
NullLogger<MeetingRecordingCoordinator>.Instance);
await coordinator.StartAsync(CancellationToken.None);
await audioSource.WaitUntilCapturedAsync();
var stopped = await coordinator.StopAsync(CancellationToken.None);
Assert.False(stopped.IsRecording);
await store.WaitForTextAsync("final:2");
}
[Fact]
public async Task CaptureStartsEvenWhenTranscriptionProviderIsStillWarmingUp()
{
var audioSource = new ControlledAudioSource();
var provider = new BlockingBeforeTranscriptionProvider();
var store = new InMemoryTranscriptStore();
var audioArchive = new InMemoryRecordedAudioStore();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(provider),
store,
new InMemoryMeetingNoteStore(),
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
audioArchive,
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions()),
NullLogger<MeetingRecordingCoordinator>.Instance);
await coordinator.StartAsync(CancellationToken.None);
await provider.WaitUntilWaitingForBackendAsync();
await audioSource.WriteAsync(new AudioChunk(new byte[] { 1, 0, 2, 0 }, 16000, 1), CancellationToken.None);
await audioArchive.WaitForAppendAsync();
Assert.Equal([4], audioArchive.AppendedChunkSizes);
provider.MarkBackendReady();
await store.WaitForTextAsync("chunk:4");
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task StopRewritesTranscriptWithFinalDiarizedSegmentsWhenAvailable()
{
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk(new byte[] { 1, 0, 2, 0 }, 16000, 1));
var transcriptStore = new InMemoryTranscriptStore();
var finalizer = new CapturingTranscriptFinalizer(
[
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Speaker 0", "hello"),
new TranscriptionSegment(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2), "Speaker 1", "there")
]);
var audioArchive = new InMemoryRecordedAudioStore("memory-recording.wav");
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider(), finalizer.FinalizeAsync),
transcriptStore,
new InMemoryMeetingNoteStore(),
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
audioArchive,
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions()),
NullLogger<MeetingRecordingCoordinator>.Instance);
await coordinator.StartAsync(CancellationToken.None);
await audioSource.WaitUntilCapturedAsync();
await coordinator.StopAsync(CancellationToken.None);
Assert.Equal("memory-recording.wav", finalizer.AudioPath);
Assert.Collection(
finalizer.LiveSegments,
segment => Assert.Equal("final:4", segment.Text));
Assert.Equal([4], audioArchive.AppendedChunkSizes);
Assert.True(audioArchive.Completed);
Assert.True(audioArchive.Deleted);
Assert.Collection(
transcriptStore.ReplacedSegments,
first =>
{
Assert.Equal("Speaker 0", first.Speaker);
Assert.Equal("hello", first.Text);
},
second =>
{
Assert.Equal("Speaker 1", second.Speaker);
Assert.Equal("there", second.Text);
});
}
[Theory]
[InlineData(0, null)]
[InlineData(1, null)]
[InlineData(2, 2)]
[InlineData(5, 5)]
public async Task StopUsesMeetingNoteAttendeeCountAsSpeakerHintWhenThereAreMultipleAttendees(
int attendeeCount,
int? expectedNumSpeakers)
{
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1));
var noteStore = new InMemoryMeetingNoteStore();
var finalizer = new CapturingTranscriptFinalizer(
[
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Speaker 0", "hello")
]);
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider(), finalizer.FinalizeAsync),
new InMemoryTranscriptStore(),
noteStore,
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions()),
NullLogger<MeetingRecordingCoordinator>.Instance);
await coordinator.StartAsync(CancellationToken.None);
noteStore.UpdateAttendees(Enumerable.Range(1, attendeeCount).Select(index => $"Person {index}"));
await audioSource.WaitUntilCapturedAsync();
await coordinator.StopAsync(CancellationToken.None);
Assert.Equal(expectedNumSpeakers, finalizer.Options?.NumSpeakers);
}
[Fact]
public async Task StopAddsMeetingEndTimeAndRunsSummaryAfterFinishedTranscript()
{
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1));
var noteStore = new InMemoryMeetingNoteStore();
var artifactStore = new InMemoryMeetingArtifactStore();
var summaryPipeline = new CapturingMeetingSummaryPipeline();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider()),
new InMemoryTranscriptStore(),
noteStore,
new CapturingMeetingNoteOpener(),
artifactStore,
new InMemoryRecordedAudioStore(),
summaryPipeline,
Options.Create(new MeetingAssistantOptions()),
NullLogger<MeetingRecordingCoordinator>.Instance);
await coordinator.StartAsync(CancellationToken.None);
var startTime = noteStore.SavedNote?.Frontmatter.StartTime;
await audioSource.WaitUntilCapturedAsync();
await coordinator.StopAsync(CancellationToken.None);
Assert.NotNull(startTime);
Assert.NotNull(noteStore.SavedNote?.Frontmatter.EndTime);
Assert.True(noteStore.SavedNote?.Frontmatter.EndTime >= startTime);
Assert.Equal(artifactStore.CreatedArtifacts, summaryPipeline.Artifacts);
}
[Fact]
public async Task StopUpdatesTranscriptMetadataWithMeetingEndTime()
{
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1));
var transcriptStore = new InMemoryTranscriptStore();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider()),
transcriptStore,
new InMemoryMeetingNoteStore(),
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions()),
NullLogger<MeetingRecordingCoordinator>.Instance);
await coordinator.StartAsync(CancellationToken.None);
await audioSource.WaitUntilCapturedAsync();
await coordinator.StopAsync(CancellationToken.None);
Assert.NotNull(transcriptStore.MetadataMeetingNote?.Frontmatter.EndTime);
}
[Fact]
public async Task StopUpdatesAssistantContextStateThroughSummaryLifecycle()
{
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1));
var artifactStore = new InMemoryMeetingArtifactStore();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider()),
new InMemoryTranscriptStore(),
new InMemoryMeetingNoteStore(),
new CapturingMeetingNoteOpener(),
artifactStore,
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(CreateOptionsWithoutFinalizer()),
NullLogger<MeetingRecordingCoordinator>.Instance);
await coordinator.StartAsync(CancellationToken.None);
await audioSource.WaitUntilCapturedAsync();
await coordinator.StopAsync(CancellationToken.None);
Assert.Equal(
[AssistantContextState.Summarizing, AssistantContextState.Finished],
artifactStore.States);
}
[Fact]
public async Task StopMarksAssistantContextErrorWhenSummaryFails()
{
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1));
var artifactStore = new InMemoryMeetingArtifactStore();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider()),
new InMemoryTranscriptStore(),
new InMemoryMeetingNoteStore(),
new CapturingMeetingNoteOpener(),
artifactStore,
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(succeeded: false),
Options.Create(CreateOptionsWithoutFinalizer()),
NullLogger<MeetingRecordingCoordinator>.Instance);
await coordinator.StartAsync(CancellationToken.None);
await audioSource.WaitUntilCapturedAsync();
await coordinator.StopAsync(CancellationToken.None);
Assert.Equal(
[AssistantContextState.Summarizing, AssistantContextState.Error],
artifactStore.States);
}
[Fact]
public async Task StopMarksSpeakerRecognitionBeforeSummaryWhenFinalizerIsConfigured()
{
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1));
var artifactStore = new InMemoryMeetingArtifactStore();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider()),
new InMemoryTranscriptStore(),
new InMemoryMeetingNoteStore(),
new CapturingMeetingNoteOpener(),
artifactStore,
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions
{
Recording = new RecordingOptions { TranscriptionProvider = "whisper-local" },
WhisperLocal = new WhisperLocalOptions
{
Diarization = new PyannoteDiarizationOptions { Enabled = true }
}
}),
NullLogger<MeetingRecordingCoordinator>.Instance);
await coordinator.StartAsync(CancellationToken.None);
await audioSource.WaitUntilCapturedAsync();
await coordinator.StopAsync(CancellationToken.None);
Assert.Equal(
[AssistantContextState.SpeakerRecognition, AssistantContextState.Summarizing, AssistantContextState.Finished],
artifactStore.States);
}
[Fact]
public async Task VaultTranscriptStoreCreatesConfiguredFolderAndAppendsSegments()
{
var vaultFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var store = new VaultTranscriptStore(
Options.Create(new MeetingAssistantOptions
{
Vault = new VaultOptions { TranscriptsFolder = vaultFolder }
}),
NullLogger<VaultTranscriptStore>.Instance);
var session = await store.CreateSessionAsync(CancellationToken.None);
await store.AppendAsync(
session,
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Unknown", "hello vault"),
CancellationToken.None);
Assert.True(Directory.Exists(vaultFolder));
Assert.EndsWith(".md", session.TranscriptPath, StringComparison.Ordinal);
Assert.Contains("hello vault", await File.ReadAllTextAsync(session.TranscriptPath));
}
[Fact]
public async Task TemporaryRecordedAudioStoreCreatesConfiguredFolderAndWritesPcmWav()
{
var recordingFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var store = new TemporaryRecordedAudioStore(
Options.Create(new MeetingAssistantOptions
{
Recording = new RecordingOptions
{
SampleRate = 16000,
Channels = 1,
TemporaryRecordingsFolder = recordingFolder
}
}),
NullLogger<TemporaryRecordedAudioStore>.Instance);
await using var session = await store.CreateSessionAsync(CancellationToken.None);
await session.AppendAsync(new AudioChunk([1, 0, 2, 0], 16000, 1), CancellationToken.None);
await session.CompleteAsync(CancellationToken.None);
Assert.True(Directory.Exists(recordingFolder));
Assert.EndsWith(".wav", session.AudioPath, StringComparison.Ordinal);
Assert.True(new FileInfo(session.AudioPath).Length > 44);
}
[Fact]
public async Task TemporaryRecordedAudioStoreDeletesStaleRecordingsOnStartup()
{
var recordingFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(recordingFolder);
var staleRecording = Path.Combine(recordingFolder, "stale.wav");
var unrelatedFile = Path.Combine(recordingFolder, "keep.txt");
await File.WriteAllTextAsync(staleRecording, "stale");
await File.WriteAllTextAsync(unrelatedFile, "keep");
var store = new TemporaryRecordedAudioStore(
Options.Create(new MeetingAssistantOptions
{
Recording = new RecordingOptions { TemporaryRecordingsFolder = recordingFolder }
}),
NullLogger<TemporaryRecordedAudioStore>.Instance);
await store.DeleteStaleRecordingsAsync(CancellationToken.None);
Assert.False(File.Exists(staleRecording));
Assert.True(File.Exists(unrelatedFile));
}
private static MeetingAssistantOptions CreateOptionsWithoutFinalizer()
{
return new MeetingAssistantOptions
{
Recording = new RecordingOptions { TranscriptionProvider = "whisper-local" },
WhisperLocal = new WhisperLocalOptions
{
Diarization = new PyannoteDiarizationOptions { Enabled = false }
}
};
}
private sealed class InMemoryTranscriptStore : ITranscriptStore
{
private readonly List<TranscriptionSegment> segments = [];
private readonly TaskCompletionSource segmentWritten = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly string transcriptPath;
public InMemoryTranscriptStore(string transcriptPath = "memory-transcript.md")
{
this.transcriptPath = transcriptPath;
}
public Task<TranscriptSession> CreateSessionAsync(CancellationToken cancellationToken)
{
return Task.FromResult(new TranscriptSession(transcriptPath));
}
public Task AppendAsync(TranscriptSession session, TranscriptionSegment segment, CancellationToken cancellationToken)
{
segments.Add(segment);
segmentWritten.TrySetResult();
return Task.CompletedTask;
}
public async Task WaitForTextAsync(string text)
{
var deadline = DateTimeOffset.UtcNow.AddSeconds(5);
while (DateTimeOffset.UtcNow < deadline)
{
if (segments.Any(segment => segment.Text.Contains(text, StringComparison.Ordinal)))
{
return;
}
await segmentWritten.Task.WaitAsync(TimeSpan.FromMilliseconds(100));
}
throw new TimeoutException($"Segment containing '{text}' was not written.");
}
public IReadOnlyList<TranscriptionSegment> ReplacedSegments { get; private set; } = [];
public MeetingNote? MetadataMeetingNote { get; private set; }
public Task ReplaceAsync(
TranscriptSession session,
IReadOnlyList<TranscriptionSegment> replacementSegments,
CancellationToken cancellationToken)
{
ReplacedSegments = replacementSegments;
return Task.CompletedTask;
}
public Task UpdateMetadataAsync(
TranscriptSession session,
MeetingSessionArtifacts artifacts,
MeetingNote meetingNote,
CancellationToken cancellationToken)
{
MetadataMeetingNote = meetingNote;
return Task.CompletedTask;
}
}
private sealed class InMemoryMeetingNoteStore : IMeetingNoteStore
{
private readonly string notePath;
public InMemoryMeetingNoteStore(string notePath = "memory-meeting.md")
{
this.notePath = notePath;
}
public MeetingNote? SavedNote { get; private set; }
public Task<MeetingNote> SaveAsync(MeetingNote note, CancellationToken cancellationToken)
{
SavedNote = note with { Path = notePath };
return Task.FromResult(SavedNote);
}
public Task<MeetingNote> ReadAsync(string path, CancellationToken cancellationToken)
{
return Task.FromResult(SavedNote ?? throw new FileNotFoundException(path));
}
public void UpdateAttendees(IEnumerable<string> attendees)
{
if (SavedNote is null)
{
throw new InvalidOperationException("No meeting note has been saved.");
}
SavedNote.Frontmatter.Attendees = attendees.ToList();
}
}
private sealed class CapturingMeetingNoteOpener : IMeetingNoteOpener
{
public string? OpenedPath { get; private set; }
public Task OpenAsync(string notePath, CancellationToken cancellationToken)
{
OpenedPath = notePath;
return Task.CompletedTask;
}
}
private sealed class InMemoryMeetingArtifactStore : IMeetingArtifactStore
{
public MeetingSessionArtifacts? CreatedArtifacts { get; private set; }
public List<AssistantContextState> States { get; } = [];
public string? Agenda { get; private set; }
public DateTimeOffset? ScheduledEnd { get; private set; }
public Task CreateAssistantContextAsync(
MeetingSessionArtifacts artifacts,
string agenda,
DateTimeOffset? scheduledEnd,
CancellationToken cancellationToken)
{
CreatedArtifacts = artifacts;
Agenda = agenda;
ScheduledEnd = scheduledEnd;
return Task.CompletedTask;
}
public Task UpdateAssistantContextStateAsync(
MeetingSessionArtifacts artifacts,
AssistantContextState state,
CancellationToken cancellationToken)
{
States.Add(state);
return Task.CompletedTask;
}
}
private sealed class FixedMeetingMetadataProvider : IMeetingMetadataProvider
{
private readonly MeetingMetadata? metadata;
public FixedMeetingMetadataProvider(MeetingMetadata? metadata)
{
this.metadata = metadata;
}
public Task<MeetingMetadata?> GetCurrentMeetingAsync(
DateTimeOffset startedAt,
CancellationToken cancellationToken)
{
return Task.FromResult(metadata);
}
}
private sealed class CapturingMeetingSummaryPipeline : IMeetingSummaryPipeline
{
private readonly bool succeeded;
public CapturingMeetingSummaryPipeline(bool succeeded = true)
{
this.succeeded = succeeded;
}
public MeetingSessionArtifacts? Artifacts { get; private set; }
public Task<MeetingSummaryRunResult> RunAsync(
MeetingSessionArtifacts artifacts,
CancellationToken cancellationToken)
{
Artifacts = artifacts;
return Task.FromResult(new MeetingSummaryRunResult(
artifacts.SummaryPath,
succeeded ? "summary ok" : "summary failed",
succeeded,
succeeded ? null : "error"));
}
}
private sealed class InMemoryRecordedAudioStore : IRecordedAudioStore
{
private readonly string audioPath;
public InMemoryRecordedAudioStore(string audioPath = "memory-recording.wav")
{
this.audioPath = audioPath;
}
public List<int> AppendedChunkSizes { get; } = [];
public bool Completed { get; private set; }
public bool Deleted { get; private set; }
private TaskCompletionSource AppendObserved { get; set; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public Task WaitForAppendAsync()
{
return AppendObserved.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
public Task<IRecordedAudioSink> CreateSessionAsync(CancellationToken cancellationToken)
{
return Task.FromResult<IRecordedAudioSink>(new Sink(this, audioPath));
}
public Task DeleteStaleRecordingsAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
private sealed class Sink : IRecordedAudioSink
{
private readonly InMemoryRecordedAudioStore store;
public Sink(InMemoryRecordedAudioStore store, string audioPath)
{
this.store = store;
AudioPath = audioPath;
}
public string AudioPath { get; }
public Task AppendAsync(AudioChunk chunk, CancellationToken cancellationToken)
{
store.AppendedChunkSizes.Add(chunk.Pcm.Length);
store.AppendObserved.TrySetResult();
return Task.CompletedTask;
}
public Task CompleteAsync(CancellationToken cancellationToken)
{
store.Completed = true;
return Task.CompletedTask;
}
public ValueTask DisposeAsync()
{
return ValueTask.CompletedTask;
}
public Task DeleteAsync(CancellationToken cancellationToken)
{
store.Deleted = true;
return Task.CompletedTask;
}
}
}
private sealed class TestSpeechRecognitionPipelineFactory : ISpeechRecognitionPipelineFactory
{
private readonly IStreamingTranscriptionProvider provider;
private readonly Func<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>> finalize;
public TestSpeechRecognitionPipelineFactory(
IStreamingTranscriptionProvider provider,
Func<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>>? finalize = null)
{
this.provider = provider;
this.finalize = finalize ?? ((_, _, _, _) => Task.FromResult<IReadOnlyList<TranscriptionSegment>>([]));
}
public ISpeechRecognitionPipeline Create()
{
return new TestSpeechRecognitionPipeline(provider, finalize);
}
}
private sealed class TestSpeechRecognitionPipeline : StreamingSpeechRecognitionPipeline
{
private readonly Func<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>> finalize;
public TestSpeechRecognitionPipeline(
IStreamingTranscriptionProvider provider,
Func<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>> finalize)
: base(provider)
{
this.finalize = finalize;
}
protected override Task<IReadOnlyList<TranscriptionSegment>> BuildFinishedTranscriptAsync(
string audioPath,
IReadOnlyList<TranscriptionSegment> liveSegments,
SpeechRecognitionPipelineOptions options,
CancellationToken cancellationToken)
{
return finalize(audioPath, liveSegments, options, cancellationToken);
}
}
private sealed class CapturingTranscriptFinalizer
{
private readonly IReadOnlyList<TranscriptionSegment> segments;
public CapturingTranscriptFinalizer(IReadOnlyList<TranscriptionSegment> segments)
{
this.segments = segments;
}
public string? AudioPath { get; private set; }
public IReadOnlyList<TranscriptionSegment> LiveSegments { get; private set; } = [];
public SpeechRecognitionPipelineOptions? Options { get; private set; }
public Task<IReadOnlyList<TranscriptionSegment>> FinalizeAsync(
string audioPath,
IReadOnlyList<TranscriptionSegment> liveSegments,
SpeechRecognitionPipelineOptions options,
CancellationToken cancellationToken)
{
AudioPath = audioPath;
LiveSegments = liveSegments;
Options = options;
return Task.FromResult(segments);
}
}
private sealed class ControlledAudioSource : IMeetingAudioSource
{
private readonly Channel<AudioChunk> chunks = Channel.CreateUnbounded<AudioChunk>();
public IAsyncEnumerable<AudioChunk> CaptureAsync(CancellationToken cancellationToken)
{
return chunks.Reader.ReadAllAsync(cancellationToken);
}
public ValueTask WriteAsync(AudioChunk chunk, CancellationToken cancellationToken)
{
return chunks.Writer.WriteAsync(chunk, cancellationToken);
}
}
private sealed class CapturedChunkThenCancelAudioSource : IMeetingAudioSource
{
private readonly AudioChunk chunk;
private readonly TaskCompletionSource captured = new(TaskCreationOptions.RunContinuationsAsynchronously);
public CapturedChunkThenCancelAudioSource(AudioChunk chunk)
{
this.chunk = chunk;
}
public async IAsyncEnumerable<AudioChunk> CaptureAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
yield return chunk;
captured.TrySetResult();
try
{
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
}
finally
{
captured.TrySetResult();
}
}
public Task WaitUntilCapturedAsync()
{
return captured.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
}
private sealed class EchoStreamingTranscriptionProvider : IStreamingTranscriptionProvider
{
public bool FirstChunkWasObservedBeforeSourceCompleted { get; private set; }
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
IAsyncEnumerable<AudioChunk> audio,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await foreach (var chunk in audio.WithCancellation(cancellationToken))
{
FirstChunkWasObservedBeforeSourceCompleted = true;
yield return new TranscriptionSegment(TimeSpan.Zero, TimeSpan.Zero, "Unknown", $"chunk:{chunk.Pcm.Length}");
}
}
}
private sealed class FinalSegmentOnAudioCompletionProvider : IStreamingTranscriptionProvider
{
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
IAsyncEnumerable<AudioChunk> audio,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
var byteCount = 0;
await foreach (var chunk in audio.WithCancellation(cancellationToken))
{
byteCount += chunk.Pcm.Length;
}
yield return new TranscriptionSegment(TimeSpan.Zero, TimeSpan.Zero, "Unknown", $"final:{byteCount}");
}
}
private sealed class BlockingBeforeTranscriptionProvider : IStreamingTranscriptionProvider
{
private readonly TaskCompletionSource waitingForBackend =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource backendReady =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public Task WaitUntilWaitingForBackendAsync()
{
return waitingForBackend.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
public void MarkBackendReady()
{
backendReady.TrySetResult();
}
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
IAsyncEnumerable<AudioChunk> audio,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
waitingForBackend.TrySetResult();
await backendReady.Task.WaitAsync(cancellationToken);
await foreach (var chunk in audio.WithCancellation(cancellationToken))
{
yield return new TranscriptionSegment(TimeSpan.Zero, TimeSpan.Zero, "Unknown", $"chunk:{chunk.Pcm.Length}");
}
}
}
}
@@ -0,0 +1,25 @@
namespace MeetingAssistant.Tests;
public sealed class SampleWavFixtureTests
{
[Fact]
public async Task SampleWavFixtureIsSmallMonoPcmAudio()
{
var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", "sample-16khz-mono.wav");
var bytes = await File.ReadAllBytesAsync(path);
Assert.True(bytes.Length < 16_000);
Assert.Equal("RIFF", ReadAscii(bytes, 0, 4));
Assert.Equal("WAVE", ReadAscii(bytes, 8, 4));
Assert.Equal("fmt ", ReadAscii(bytes, 12, 4));
Assert.Equal((short)1, BitConverter.ToInt16(bytes, 20));
Assert.Equal((short)1, BitConverter.ToInt16(bytes, 22));
Assert.Equal(16000, BitConverter.ToInt32(bytes, 24));
Assert.Equal((short)16, BitConverter.ToInt16(bytes, 34));
}
private static string ReadAscii(byte[] bytes, int start, int length)
{
return System.Text.Encoding.ASCII.GetString(bytes, start, length);
}
}
@@ -0,0 +1,128 @@
using MeetingAssistant.Transcription;
using Microsoft.Extensions.Logging.Abstractions;
namespace MeetingAssistant.Tests;
public sealed class SpeechRecognitionPipelineHostedServiceTests
{
[Fact]
public async Task HostedServiceInitializesAndWarmsConfiguredPipelineWithoutBlockingStartup()
{
var pipeline = new CapturingSpeechRecognitionPipeline { BlockReadinessUntilCancelled = true };
var service = new SpeechRecognitionPipelineHostedService(
new CapturingSpeechRecognitionPipelineFactory(pipeline),
NullLogger<SpeechRecognitionPipelineHostedService>.Instance);
var startTask = service.StartAsync(CancellationToken.None);
await startTask.WaitAsync(TimeSpan.FromSeconds(1));
await pipeline.WaitForInitializeAsync();
await pipeline.WaitForReadinessAsync();
await service.StopAsync(CancellationToken.None);
Assert.Equal(1, pipeline.InitializeCount);
Assert.Equal(1, pipeline.ReadinessCount);
Assert.True(pipeline.ReadinessCancellationWasObserved);
Assert.True(pipeline.Disposed);
}
private sealed class CapturingSpeechRecognitionPipelineFactory : ISpeechRecognitionPipelineFactory
{
private readonly ISpeechRecognitionPipeline pipeline;
public CapturingSpeechRecognitionPipelineFactory(ISpeechRecognitionPipeline pipeline)
{
this.pipeline = pipeline;
}
public ISpeechRecognitionPipeline Create()
{
return pipeline;
}
}
private sealed class CapturingSpeechRecognitionPipeline : ISpeechRecognitionPipeline
{
private readonly TaskCompletionSource initialized = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource readinessChecked = new(TaskCreationOptions.RunContinuationsAsynchronously);
public int InitializeCount { get; private set; }
public int ReadinessCount { get; private set; }
public bool BlockReadinessUntilCancelled { get; init; }
public bool ReadinessCancellationWasObserved { get; private set; }
public bool Disposed { get; private set; }
public Task WaitForInitializeAsync()
{
return initialized.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
public Task WaitForReadinessAsync()
{
return readinessChecked.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
public Task InitializeAsync(CancellationToken cancellationToken)
{
InitializeCount++;
initialized.TrySetResult();
return Task.CompletedTask;
}
public async Task WaitUntilReadyAsync(CancellationToken cancellationToken)
{
ReadinessCount++;
readinessChecked.TrySetResult();
if (!BlockReadinessUntilCancelled)
{
return;
}
try
{
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
}
catch (OperationCanceledException)
{
ReadinessCancellationWasObserved = true;
throw;
}
}
public ValueTask WriteAsync(MeetingAssistant.Recording.AudioChunk chunk, CancellationToken cancellationToken)
{
return ValueTask.CompletedTask;
}
public Task CompleteAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public async IAsyncEnumerable<TranscriptionSegment> ReadLiveTranscriptAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.CompletedTask;
yield break;
}
public Task<IReadOnlyList<TranscriptionSegment>> ReadFinishedTranscriptAsync(
string audioPath,
SpeechRecognitionPipelineOptions options,
CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<TranscriptionSegment>>([]);
}
public ValueTask DisposeAsync()
{
Disposed = true;
return ValueTask.CompletedTask;
}
}
}