Public Access
222 lines
9.4 KiB
C#
222 lines
9.4 KiB
C#
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using MeetingAssistant.Recording;
|
|
using MeetingAssistant.Speakers;
|
|
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);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SpeakerIdentityMergeDiagnosticEndpointUsesRecentDaysParameter()
|
|
{
|
|
var mergeService = new CapturingSpeakerIdentityMergeService();
|
|
await using var factory = new WebApplicationFactory<Program>()
|
|
.WithWebHostBuilder(builder =>
|
|
{
|
|
builder.ConfigureAppConfiguration((_, configuration) =>
|
|
{
|
|
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["MeetingAssistant:FunAsr:Backend:Enabled"] = "false"
|
|
});
|
|
});
|
|
builder.ConfigureTestServices(services =>
|
|
{
|
|
services.RemoveAll<ISpeakerIdentityMergeService>();
|
|
services.AddSingleton<ISpeakerIdentityMergeService>(mergeService);
|
|
});
|
|
});
|
|
using var client = factory.CreateClient();
|
|
|
|
using var response = await client.PostAsJsonAsync(
|
|
"/diagnostics/speaker-identities/merge",
|
|
new { recentDays = 7 });
|
|
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
Assert.Equal(TimeSpan.FromDays(7), mergeService.RecentIdentityAge);
|
|
}
|
|
|
|
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,
|
|
SpeechRecognitionPipelineOptions options,
|
|
[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,
|
|
SpeechRecognitionPipelineOptions options,
|
|
[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);
|
|
|
|
private sealed class CapturingSpeakerIdentityMergeService : ISpeakerIdentityMergeService
|
|
{
|
|
public TimeSpan? RecentIdentityAge { get; private set; }
|
|
|
|
public Task<SpeakerIdentityMergeResult> MergeRecentIdentitiesAsync(
|
|
TimeSpan? recentIdentityAge,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
RecentIdentityAge = recentIdentityAge;
|
|
return Task.FromResult(new SpeakerIdentityMergeResult(0, 0, 0, 0));
|
|
}
|
|
}
|
|
}
|
|
|