using MeetingAssistant.Recording; using MeetingAssistant.Transcription; using Microsoft.Extensions.Logging; using NAudio.Wave; namespace MeetingAssistant.Tests; public sealed class SpeakerAudioSampleCollectorTests { [Fact] public void CollectorWaitsForConfiguredUninterruptedSpeechDuration() { var collector = new SpeakerAudioSampleCollector( TimeSpan.FromMinutes(2), maxSamplesPerSpeaker: 3, minimumUninterruptedSpeechDuration: TimeSpan.FromSeconds(30), maximumSegmentGap: TimeSpan.FromSeconds(1)); 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, minimumUninterruptedSpeechDuration: TimeSpan.FromSeconds(30), maximumSegmentGap: TimeSpan.FromSeconds(1)); 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 CollectorLogsWhenSampleIsDiscardedBecauseSpeechIsTooShort() { var logger = new CapturingLogger(); var collector = new SpeakerAudioSampleCollector( TimeSpan.FromMinutes(2), maxSamplesPerSpeaker: 3, minimumUninterruptedSpeechDuration: TimeSpan.FromSeconds(30), maximumSegmentGap: TimeSpan.FromSeconds(1), 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); } 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)); } } }