using MeetingAssistant.Recording; using MeetingAssistant.Transcription; using Microsoft.Extensions.Logging; using NAudio.Wave; namespace MeetingAssistant.Tests; public sealed class SpeakerAudioSampleCollectorTests { [Fact] public void DefaultSampleDurationsAreTenAndSixtySeconds() { var options = new SpeakerIdentificationOptions(); Assert.Equal(TimeSpan.FromSeconds(10), options.MinimumSampleSpeechDuration); Assert.Equal(TimeSpan.FromSeconds(60), options.MaximumSampleDuration); } [Fact] public void CollectorWaitsForConfiguredSpeakerAudioDuration() { var collector = new SpeakerAudioSampleCollector( TimeSpan.FromMinutes(2), maxSamplesPerSpeaker: 3, minimumSampleSpeechDuration: TimeSpan.FromSeconds(30), maximumSampleDuration: TimeSpan.FromSeconds(60)); collector.AppendAudio(CreateAudio(TimeSpan.FromSeconds(35))); collector.TryAdd(Segment(0, 12, "Guest01", "one two three four five.")); collector.TryAdd(Segment(12, 24, "Guest01", "six seven eight nine ten.")); Assert.Empty(collector.Snapshot()); collector.TryAdd(Segment(24, 31, "Guest01", "eleven twelve thirteen fourteen fifteen.")); var sample = Assert.Single(collector.Snapshot()); Assert.Equal("Guest01", sample.Speaker); Assert.Equal(TimeSpan.Zero, sample.Segment.Start); Assert.Equal(TimeSpan.FromSeconds(31), sample.Segment.End); Assert.True(ReadDuration(sample.WavBytes) >= TimeSpan.FromSeconds(30)); } [Fact] public void CollectorDoesNotCombineSpeechAcrossDifferentSpeakerInterruption() { var collector = new SpeakerAudioSampleCollector( TimeSpan.FromMinutes(2), maxSamplesPerSpeaker: 3, minimumSampleSpeechDuration: TimeSpan.FromSeconds(30), maximumSampleDuration: TimeSpan.FromSeconds(60)); collector.AppendAudio(CreateAudio(TimeSpan.FromSeconds(50))); collector.TryAdd(Segment(0, 20, "Guest01", "one two three four five.")); collector.TryAdd(Segment(20, 22, "Guest02", "interrupting now.")); collector.TryAdd(Segment(22, 35, "Guest01", "six seven eight nine ten.")); Assert.DoesNotContain(collector.Snapshot(), sample => sample.Speaker == "Guest01"); } [Fact] public void CollectorCombinesConsecutiveSameSpeakerSegmentsAcrossProviderPauses() { var collector = new SpeakerAudioSampleCollector( TimeSpan.FromMinutes(2), maxSamplesPerSpeaker: 3, minimumSampleSpeechDuration: TimeSpan.FromSeconds(10), maximumSampleDuration: TimeSpan.FromSeconds(60)); collector.AppendAudio(CreateAudio(TimeSpan.FromSeconds(20))); collector.TryAdd(Segment(0, 4, "Guest01", "one two three four.")); collector.TryAdd(Segment(7, 13, "Guest01", "five six seven eight.")); var sample = Assert.Single(collector.Snapshot()); Assert.Equal(TimeSpan.Zero, sample.Segment.Start); Assert.Equal(TimeSpan.FromSeconds(13), sample.Segment.End); } [Fact] public void CollectorDoesNotCountProviderPausesAsSpeakerAudio() { var collector = new SpeakerAudioSampleCollector( TimeSpan.FromMinutes(2), maxSamplesPerSpeaker: 3, minimumSampleSpeechDuration: TimeSpan.FromSeconds(10), maximumSampleDuration: TimeSpan.FromSeconds(60)); collector.AppendAudio(CreateAudio(TimeSpan.FromSeconds(30))); collector.TryAdd(Segment(0, 4, "Guest01", "one two three four.")); collector.TryAdd(Segment(20, 25, "Guest01", "five six seven eight.")); Assert.Empty(collector.Snapshot()); } [Fact] public void CollectorCapsRecognitionWavAtConfiguredMaximumDuration() { var collector = new SpeakerAudioSampleCollector( TimeSpan.FromMinutes(2), maxSamplesPerSpeaker: 3, minimumSampleSpeechDuration: TimeSpan.FromSeconds(10), maximumSampleDuration: TimeSpan.FromSeconds(60)); collector.AppendAudio(CreateAudio(TimeSpan.FromSeconds(75))); var sample = collector.TryAdd(Segment( 0, 70, "Guest01", "one two three four five six seven eight nine ten.")); Assert.NotNull(sample); Assert.Equal(TimeSpan.FromSeconds(60), sample.Segment.End); Assert.True(ReadDuration(sample.WavBytes) <= TimeSpan.FromSeconds(60)); } [Fact] public void CollectorLogsWhenSampleIsDiscardedBecauseSpeechIsTooShort() { var logger = new CapturingLogger(); var collector = new SpeakerAudioSampleCollector( TimeSpan.FromMinutes(2), maxSamplesPerSpeaker: 3, minimumSampleSpeechDuration: TimeSpan.FromSeconds(30), maximumSampleDuration: TimeSpan.FromSeconds(60), logger: logger); collector.AppendAudio(CreateAudio(TimeSpan.FromSeconds(35))); collector.TryAdd(Segment(0, 12, "Guest01", "one two three four five.")); var message = Assert.Single( logger.Messages, message => message.Contains("Discarding speaker identity sample for Guest01", StringComparison.Ordinal)); Assert.Contains("duration", message); Assert.Contains("minimum duration", message); } [Fact] public void CollectorCanResetThePendingSpanAfterEachAcceptedSample() { var collector = new SpeakerAudioSampleCollector( TimeSpan.FromMinutes(2), maxSamplesPerSpeaker: 5, minimumSampleSpeechDuration: TimeSpan.FromSeconds(2), maximumSampleDuration: TimeSpan.FromSeconds(60), requireNonOverlappingSamples: true); collector.AppendAudio(CreateAudio(TimeSpan.FromSeconds(6))); collector.TryAdd(Segment(0, 2, "Guest01", "one two three.")); collector.TryAdd(Segment(2, 4, "Guest01", "four five six.")); collector.TryAdd(Segment(4, 6, "Guest01", "seven eight nine.")); var samples = collector.Snapshot().OrderBy(sample => sample.Segment.Start).ToList(); Assert.Collection( samples, sample => Assert.Equal((TimeSpan.Zero, TimeSpan.FromSeconds(2)), (sample.Segment.Start, sample.Segment.End)), sample => Assert.Equal((TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(4)), (sample.Segment.Start, sample.Segment.End)), sample => Assert.Equal((TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(6)), (sample.Segment.Start, sample.Segment.End))); } [Fact] public void ResettingCollectorRejectsASegmentThatOverlapsAnAcceptedSample() { var collector = new SpeakerAudioSampleCollector( TimeSpan.FromMinutes(2), maxSamplesPerSpeaker: 5, minimumSampleSpeechDuration: TimeSpan.FromSeconds(2), maximumSampleDuration: TimeSpan.FromSeconds(60), requireNonOverlappingSamples: true); collector.AppendAudio(CreateAudio(TimeSpan.FromSeconds(4))); collector.TryAdd(Segment(0, 2, "Guest01", "one two three.")); collector.TryAdd(Segment(1, 3, "Guest01", "four five six.")); Assert.Single(collector.Snapshot()); } private static TranscriptionSegment Segment( double start, double end, string speaker, string text) { return new TranscriptionSegment( TimeSpan.FromSeconds(start), TimeSpan.FromSeconds(end), speaker, text); } private static AudioChunk CreateAudio(TimeSpan duration) { const int sampleRate = 16000; const int channels = 1; var bytes = new byte[(int)(duration.TotalSeconds * sampleRate * channels * sizeof(short))]; return new AudioChunk(bytes, sampleRate, channels); } private static TimeSpan ReadDuration(byte[] wavBytes) { using var reader = new WaveFileReader(new MemoryStream(wavBytes)); return reader.TotalTime; } private sealed class CapturingLogger : ILogger { public List Messages { get; } = []; public IDisposable? BeginScope(TState state) where TState : notnull { return null; } public bool IsEnabled(LogLevel logLevel) { return true; } public void Log( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { Messages.Add(formatter(state, exception)); } } }