Add inactivity safeguard and speaker diagnostics
PR and Push Build/Test / build-and-test (push) Failing after 8m31s

This commit is contained in:
2026-06-02 13:16:02 +02:00
parent c56ecb6ab3
commit 0e9d525b63
24 changed files with 1780 additions and 117 deletions
@@ -179,6 +179,212 @@ public sealed class RecordingCoordinatorTests
Assert.Equal(started.SummaryPath, artifactCleaner.DeletedArtifacts?.SummaryPath);
}
[Fact]
public async Task InactivitySafeguardStopsNormallyWhenPromptIsAcceptedAndUsesTranscriptEndTime()
{
var clock = new ManualMeetingInactivityClock(DateTimeOffset.Parse("2026-06-02T10:00:00+02:00"));
var promptService = new CapturingMeetingInactivityPromptService(MeetingInactivityPromptResponse.Stop);
var audioSource = new ControlledAudioSource();
var transcriptStore = new InMemoryTranscriptStore();
var noteStore = new InMemoryMeetingNoteStore();
var summaryPipeline = new CapturingMeetingSummaryPipeline();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new FixedSegmentStreamingTranscriptionProvider(
new TranscriptionSegment(
TimeSpan.FromMinutes(3),
TimeSpan.FromMinutes(4),
"Guest-01",
"The latest transcript text before silence."))),
transcriptStore,
noteStore,
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
new InMemoryRecordedAudioStore(),
summaryPipeline,
Options.Create(new MeetingAssistantOptions
{
Recording =
{
InactivitySafeguard =
{
FirstPromptAfter = TimeSpan.FromSeconds(2),
ReminderPromptAfter = [],
AutoStopAfter = TimeSpan.FromMinutes(30),
InferredEndPadding = TimeSpan.FromMinutes(1),
CheckInterval = TimeSpan.FromSeconds(1)
}
}
}),
NullLogger<MeetingRecordingCoordinator>.Instance,
inactivityPromptService: promptService,
inactivityClock: clock);
await coordinator.StartAsync(CancellationToken.None);
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
await WaitUntilAsync(() => transcriptStore.Segments.Any(segment =>
segment.Text.Contains("latest transcript text", StringComparison.Ordinal)));
clock.Advance(TimeSpan.FromSeconds(2));
await promptService.WaitForPromptAsync();
await WaitUntilAsync(() => !coordinator.CurrentStatus.IsRecording);
Assert.Single(promptService.Requests);
Assert.False(coordinator.CurrentStatus.IsRecording);
Assert.True(summaryPipeline.WasRun);
Assert.Equal(
DateTimeOffset.Parse("2026-06-02T10:05:00+02:00"),
noteStore.SavedNote?.Frontmatter.EndTime);
}
[Fact]
public async Task InactivitySafeguardAutoStopsNormallyAndUsesMeetingStartWhenNoTranscriptArrives()
{
var clock = new ManualMeetingInactivityClock(DateTimeOffset.Parse("2026-06-02T11:00:00+02:00"));
var promptService = new CapturingMeetingInactivityPromptService();
var audioSource = new ControlledAudioSource();
var noteStore = new InMemoryMeetingNoteStore();
var summaryPipeline = new CapturingMeetingSummaryPipeline();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
new InMemoryTranscriptStore(),
noteStore,
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
new InMemoryRecordedAudioStore(),
summaryPipeline,
Options.Create(new MeetingAssistantOptions
{
Recording =
{
InactivitySafeguard =
{
FirstPromptAfter = TimeSpan.Zero,
ReminderPromptAfter = [],
AutoStopAfter = TimeSpan.FromSeconds(5),
InferredEndPadding = TimeSpan.FromMinutes(1),
CheckInterval = TimeSpan.FromSeconds(1)
}
}
}),
NullLogger<MeetingRecordingCoordinator>.Instance,
inactivityPromptService: promptService,
inactivityClock: clock);
await coordinator.StartAsync(CancellationToken.None);
clock.Advance(TimeSpan.FromSeconds(5));
await WaitUntilAsync(() => !coordinator.CurrentStatus.IsRecording);
Assert.Empty(promptService.Requests);
Assert.False(coordinator.CurrentStatus.IsRecording);
Assert.True(summaryPipeline.WasRun);
Assert.Equal(
DateTimeOffset.Parse("2026-06-02T11:01:00+02:00"),
noteStore.SavedNote?.Frontmatter.EndTime);
}
[Fact]
public async Task InactivitySafeguardContinuesMonitoringWhenPromptIsIgnored()
{
var clock = new ManualMeetingInactivityClock(DateTimeOffset.Parse("2026-06-02T11:30:00+02:00"));
var promptService = new IgnoringMeetingInactivityPromptService();
var audioSource = new ControlledAudioSource();
var summaryPipeline = new CapturingMeetingSummaryPipeline();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
new InMemoryTranscriptStore(),
new InMemoryMeetingNoteStore(),
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
new InMemoryRecordedAudioStore(),
summaryPipeline,
Options.Create(new MeetingAssistantOptions
{
Recording =
{
InactivitySafeguard =
{
FirstPromptAfter = TimeSpan.FromSeconds(2),
ReminderPromptAfter = [],
AutoStopAfter = TimeSpan.FromSeconds(5),
CheckInterval = TimeSpan.FromSeconds(1)
}
}
}),
NullLogger<MeetingRecordingCoordinator>.Instance,
inactivityPromptService: promptService,
inactivityClock: clock);
await coordinator.StartAsync(CancellationToken.None);
clock.Advance(TimeSpan.FromSeconds(2));
await promptService.WaitForPromptAsync();
await WaitUntilAsync(() => clock.PendingDelayCount > 0);
clock.Advance(TimeSpan.FromSeconds(3));
await WaitUntilAsync(() => !coordinator.CurrentStatus.IsRecording);
Assert.Single(promptService.Requests);
Assert.True(summaryPipeline.WasRun);
}
[Fact]
public async Task InactivitySafeguardResetsPromptScheduleWhenNewTranscriptTextArrives()
{
var clock = new ManualMeetingInactivityClock(DateTimeOffset.Parse("2026-06-02T12:00:00+02:00"));
var promptService = new CapturingMeetingInactivityPromptService(
MeetingInactivityPromptResponse.Continue,
MeetingInactivityPromptResponse.Continue);
var audioSource = new ControlledAudioSource();
var transcriptStore = new InMemoryTranscriptStore();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
transcriptStore,
new InMemoryMeetingNoteStore(),
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions
{
Recording =
{
InactivitySafeguard =
{
FirstPromptAfter = TimeSpan.FromSeconds(2),
ReminderPromptAfter = [TimeSpan.FromSeconds(5)],
AutoStopAfter = TimeSpan.FromMinutes(30),
CheckInterval = TimeSpan.FromSeconds(1)
}
}
}),
NullLogger<MeetingRecordingCoordinator>.Instance,
inactivityPromptService: promptService,
inactivityClock: clock);
await coordinator.StartAsync(CancellationToken.None);
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
await WaitUntilAsync(() => transcriptStore.Segments.Any(segment =>
segment.Text.Contains("chunk:2", StringComparison.Ordinal)));
clock.Advance(TimeSpan.FromSeconds(2));
await WaitUntilAsync(() => promptService.Requests.Count == 1);
await audioSource.WriteAsync(new AudioChunk([1, 0, 2, 0], 16000, 1), CancellationToken.None);
await WaitUntilAsync(() => transcriptStore.Segments.Any(segment =>
segment.Text.Contains("chunk:4", StringComparison.Ordinal)));
clock.Advance(TimeSpan.FromSeconds(2));
await WaitUntilAsync(() => promptService.Requests.Count == 2);
Assert.Equal(
[TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2)],
promptService.Requests.Select(request => request.Threshold).ToArray());
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task StartUsesCurrentOutlookMeetingMetadataWhenAvailable()
{
@@ -3225,6 +3431,150 @@ public sealed class RecordingCoordinatorTests
}
}
private sealed class ManualMeetingInactivityClock : IMeetingInactivityClock
{
private readonly object gate = new();
private readonly List<ScheduledDelay> delays = [];
public ManualMeetingInactivityClock(DateTimeOffset now)
{
Now = now;
}
public DateTimeOffset Now { get; private set; }
public int PendingDelayCount
{
get
{
lock (gate)
{
return delays.Count;
}
}
}
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
{
if (delay <= TimeSpan.Zero)
{
return Task.CompletedTask;
}
lock (gate)
{
if (cancellationToken.IsCancellationRequested)
{
return Task.FromCanceled(cancellationToken);
}
var scheduledDelay = new ScheduledDelay(
Now + delay,
new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously));
var registration = cancellationToken.Register(
static state =>
{
var delay = (ScheduledDelay)state!;
delay.Completion.TrySetCanceled();
},
scheduledDelay);
scheduledDelay.CancellationRegistration = registration;
delays.Add(scheduledDelay);
return scheduledDelay.Completion.Task;
}
}
public void Advance(TimeSpan duration)
{
List<ScheduledDelay> due;
lock (gate)
{
Now += duration;
due = delays.Where(delay => delay.DueAt <= Now).ToList();
foreach (var delay in due)
{
delays.Remove(delay);
}
}
foreach (var delay in due)
{
delay.CancellationRegistration.Dispose();
delay.Completion.TrySetResult();
}
}
private sealed class ScheduledDelay
{
public ScheduledDelay(DateTimeOffset dueAt, TaskCompletionSource completion)
{
DueAt = dueAt;
Completion = completion;
}
public DateTimeOffset DueAt { get; }
public TaskCompletionSource Completion { get; }
public CancellationTokenRegistration CancellationRegistration { get; set; }
}
}
private sealed class CapturingMeetingInactivityPromptService : IMeetingInactivityPromptService
{
private readonly Queue<MeetingInactivityPromptResponse> responses;
private TaskCompletionSource promptObserved =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public CapturingMeetingInactivityPromptService(params MeetingInactivityPromptResponse[] responses)
{
this.responses = new Queue<MeetingInactivityPromptResponse>(responses);
}
public List<MeetingInactivityPromptRequest> Requests { get; } = [];
public async Task ShowStopPromptAsync(
MeetingInactivityPromptRequest request,
Func<MeetingInactivityPromptResponse, CancellationToken, Task> handleResponseAsync,
CancellationToken cancellationToken)
{
Requests.Add(request);
promptObserved.TrySetResult();
var response = responses.Count > 0
? responses.Dequeue()
: MeetingInactivityPromptResponse.Continue;
await handleResponseAsync(response, cancellationToken);
}
public Task WaitForPromptAsync()
{
return promptObserved.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
}
private sealed class IgnoringMeetingInactivityPromptService : IMeetingInactivityPromptService
{
private readonly TaskCompletionSource promptObserved =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public List<MeetingInactivityPromptRequest> Requests { get; } = [];
public Task ShowStopPromptAsync(
MeetingInactivityPromptRequest request,
Func<MeetingInactivityPromptResponse, CancellationToken, Task> handleResponseAsync,
CancellationToken cancellationToken)
{
Requests.Add(request);
promptObserved.TrySetResult();
return Task.CompletedTask;
}
public Task WaitForPromptAsync()
{
return promptObserved.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
}
private sealed class CapturedChunkThenCancelAudioSource : IMeetingAudioSource
{
private readonly AudioChunk chunk;
@@ -3257,6 +3607,27 @@ public sealed class RecordingCoordinatorTests
}
}
private sealed class FixedSegmentStreamingTranscriptionProvider : IStreamingTranscriptionProvider
{
private readonly TranscriptionSegment segment;
public FixedSegmentStreamingTranscriptionProvider(TranscriptionSegment segment)
{
this.segment = segment;
}
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
IAsyncEnumerable<AudioChunk> audio,
SpeechRecognitionPipelineOptions options,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await foreach (var _ in audio.WithCancellation(cancellationToken))
{
yield return segment;
}
}
}
private sealed class EchoStreamingTranscriptionProvider : IStreamingTranscriptionProvider
{
public bool FirstChunkWasObservedBeforeSourceCompleted { get; private set; }
@@ -1,5 +1,6 @@
using MeetingAssistant.Recording;
using MeetingAssistant.Transcription;
using Microsoft.Extensions.Logging;
using NAudio.Wave;
namespace MeetingAssistant.Tests;
@@ -47,6 +48,27 @@ public sealed class SpeakerAudioSampleCollectorTests
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,
@@ -73,4 +95,30 @@ public sealed class SpeakerAudioSampleCollectorTests
using var reader = new WaveFileReader(new MemoryStream(wavBytes));
return reader.TotalTime;
}
private sealed class CapturingLogger : ILogger
{
public List<string> Messages { get; } = [];
public IDisposable? BeginScope<TState>(TState state)
where TState : notnull
{
return null;
}
public bool IsEnabled(LogLevel logLevel)
{
return true;
}
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
Messages.Add(formatter(state, exception));
}
}
}
@@ -3,6 +3,7 @@ using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Speakers;
using MeetingAssistant.Transcription;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
@@ -149,6 +150,28 @@ public sealed class SpeakerIdentityServiceTests
});
}
[Fact]
public async Task RejectedUnmatchedSpeakerSampleIsLoggedWithSpeakerAndCandidateContext()
{
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
fixture.MatchValidator.SampleIsValid = false;
var logger = new CapturingLogger<SpeakerIdentityService>();
var service = fixture.CreateService(logger);
await service.ProcessFinishedTranscriptAsync(
fixture.CreateRequest(
["John", "Mike"],
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest01", "unknown speaker")]),
CancellationToken.None);
var message = Assert.Single(
logger.Messages,
message => message.Contains("Skipping speaker identity candidate for Guest01", StringComparison.Ordinal));
Assert.Contains("Skipping speaker identity candidate for Guest01", message);
Assert.Contains("candidate names John, Mike", message);
Assert.Contains("sample bytes 3", message);
}
[Fact]
public async Task UnmatchedSpeakersExcludeAliasesOfMatchedIdentitiesFromCandidates()
{
@@ -648,14 +671,14 @@ public sealed class SpeakerIdentityServiceTests
return new SpeakerIdentityFixture(tempDirectory, dbPath, context, options);
}
public SpeakerIdentityService CreateService()
public SpeakerIdentityService CreateService(ILogger<SpeakerIdentityService>? logger = null)
{
return new SpeakerIdentityService(
new TestSpeakerIdentityDbContextFactory(dbPath),
SnippetExtractor,
Matcher,
Options.Create(new MeetingAssistantOptions { SpeakerIdentification = options }),
NullLogger<SpeakerIdentityService>.Instance,
logger ?? NullLogger<SpeakerIdentityService>.Instance,
MatchValidator);
}
@@ -836,4 +859,30 @@ public sealed class SpeakerIdentityServiceTests
return Task.FromResult(true);
}
}
private sealed class CapturingLogger<T> : ILogger<T>
{
public List<string> Messages { get; } = [];
public IDisposable? BeginScope<TState>(TState state)
where TState : notnull
{
return null;
}
public bool IsEnabled(LogLevel logLevel)
{
return true;
}
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
Messages.Add(formatter(state, exception));
}
}
}
@@ -1,6 +1,8 @@
using MeetingAssistant.LaunchProfiles;
using MeetingAssistant.Recording;
using MeetingAssistant.Taskbar;
using System.Drawing;
using System.Runtime.Versioning;
namespace MeetingAssistant.Tests;
@@ -81,6 +83,29 @@ public sealed class TaskbarIconTests
Assert.Equal(RecordingProcessState.Recording, menu.State);
}
[Fact]
[SupportedOSPlatform("windows")]
public void TaskbarIconGlyphsAreVisuallyCentered()
{
foreach (var state in new[]
{
RecordingProcessState.Idle,
RecordingProcessState.Recording,
RecordingProcessState.Summarizing
})
{
using var bitmap = TaskbarIconRenderer.RenderBitmap(state);
var bounds = GetVisibleGlyphBounds(bitmap);
Assert.True(
IsCentered(GetCenter(bounds.Left, bounds.Right)),
$"{state} glyph is horizontally off-center: {bounds}");
Assert.True(
IsCentered(GetCenter(bounds.Top, bounds.Bottom)),
$"{state} glyph is vertically off-center: {bounds}");
}
}
private static LaunchProfile Profile(string name, string hotkey = "")
{
return new LaunchProfile(name, new MeetingAssistantOptions
@@ -106,4 +131,42 @@ public sealed class TaskbarIconTests
state,
profile);
}
[SupportedOSPlatform("windows")]
private static Rectangle GetVisibleGlyphBounds(Bitmap bitmap)
{
var left = bitmap.Width;
var top = bitmap.Height;
var right = -1;
var bottom = -1;
for (var y = 0; y < bitmap.Height; y++)
{
for (var x = 0; x < bitmap.Width; x++)
{
var pixel = bitmap.GetPixel(x, y);
if (pixel.A < 32 || pixel.GetBrightness() < 0.58f)
{
continue;
}
left = Math.Min(left, x);
top = Math.Min(top, y);
right = Math.Max(right, x);
bottom = Math.Max(bottom, y);
}
}
Assert.True(right >= left && bottom >= top, "Expected the icon to contain a visible white glyph.");
return Rectangle.FromLTRB(left, top, right, bottom);
}
private static double GetCenter(int start, int end)
{
return (start + end) / 2.0;
}
private static bool IsCentered(double center)
{
return center is >= 7.25 and <= 8.75;
}
}