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
@@ -0,0 +1,18 @@
namespace MeetingAssistant.Recording;
public interface IMeetingInactivityClock
{
DateTimeOffset Now { get; }
Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken);
}
public sealed class SystemMeetingInactivityClock : IMeetingInactivityClock
{
public DateTimeOffset Now => DateTimeOffset.Now;
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
{
return Task.Delay(delay, cancellationToken);
}
}
@@ -0,0 +1,33 @@
namespace MeetingAssistant.Recording;
public interface IMeetingInactivityPromptService
{
Task ShowStopPromptAsync(
MeetingInactivityPromptRequest request,
Func<MeetingInactivityPromptResponse, CancellationToken, Task> handleResponseAsync,
CancellationToken cancellationToken);
}
public sealed record MeetingInactivityPromptRequest(
TimeSpan InactivityDuration,
DateTimeOffset SuggestedEndTime,
int PromptIndex,
TimeSpan Threshold);
public enum MeetingInactivityPromptResponse
{
Dismissed,
Continue,
Stop
}
public sealed class NoopMeetingInactivityPromptService : IMeetingInactivityPromptService
{
public Task ShowStopPromptAsync(
MeetingInactivityPromptRequest request,
Func<MeetingInactivityPromptResponse, CancellationToken, Task> handleResponseAsync,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
@@ -29,6 +29,8 @@ public sealed class MeetingRecordingCoordinator
private readonly IMeetingWorkflowEngine meetingWorkflowEngine;
private readonly IMeetingScreenshotService screenshotService;
private readonly IMeetingRunArtifactCleaner artifactCleaner;
private readonly IMeetingInactivityPromptService inactivityPromptService;
private readonly IMeetingInactivityClock inactivityClock;
private readonly MeetingAssistantOptions options;
private readonly ILogger<MeetingRecordingCoordinator> logger;
private readonly SemaphoreSlim gate = new(1, 1);
@@ -55,7 +57,9 @@ public sealed class MeetingRecordingCoordinator
ILaunchProfileOptionsProvider? launchProfiles = null,
IMeetingWorkflowEngine? meetingWorkflowEngine = null,
IMeetingScreenshotService? screenshotService = null,
IMeetingRunArtifactCleaner? artifactCleaner = null)
IMeetingRunArtifactCleaner? artifactCleaner = null,
IMeetingInactivityPromptService? inactivityPromptService = null,
IMeetingInactivityClock? inactivityClock = null)
{
this.audioSource = audioSource;
this.speechRecognitionPipelineFactory = speechRecognitionPipelineFactory;
@@ -73,6 +77,8 @@ public sealed class MeetingRecordingCoordinator
this.meetingWorkflowEngine = meetingWorkflowEngine ?? NoopMeetingWorkflowEngine.Instance;
this.screenshotService = screenshotService ?? NoopMeetingScreenshotService.Instance;
this.artifactCleaner = artifactCleaner ?? new MeetingRunArtifactCleaner();
this.inactivityPromptService = inactivityPromptService ?? new NoopMeetingInactivityPromptService();
this.inactivityClock = inactivityClock ?? new SystemMeetingInactivityClock();
this.options = options.Value;
this.logger = logger;
}
@@ -152,7 +158,7 @@ public sealed class MeetingRecordingCoordinator
var runOptions = launchProfile.Options;
currentSession = await transcriptStore.CreateSessionAsync(runOptions, cancellationToken);
var recordedAudio = await recordedAudioStore.CreateSessionAsync(runOptions, cancellationToken);
var startedAt = DateTimeOffset.Now;
var startedAt = inactivityClock.Now;
var assistantContextPath = GetAssistantContextPath(startedAt, runOptions);
var summaryPath = GetSummaryPath(startedAt, runOptions);
currentMeetingNote = await meetingNoteStore.SaveAsync(
@@ -206,8 +212,16 @@ public sealed class MeetingRecordingCoordinator
startedAt,
launchProfile.Name,
runOptions.SpeakerIdentification.LiveSampleBufferDuration,
runOptions.SpeakerIdentification.MaxSnippetsPerSpeaker);
runOptions.SpeakerIdentification.MaxSnippetsPerSpeaker,
logger);
run.Task = Task.Run(() => RecordAsync(run), CancellationToken.None);
if (runOptions.Recording.InactivitySafeguard.Enabled)
{
_ = Task.Run(
() => RunInactivitySafeguardAsync(run),
CancellationToken.None);
}
currentRun = run;
_ = Task.Run(
() => ApplyMeetingMetadataWhenAvailableAsync(startedAt, run),
@@ -231,6 +245,13 @@ public sealed class MeetingRecordingCoordinator
}
public async Task<RecordingStatus> StopAsync(CancellationToken cancellationToken)
{
return await StopAsync(null, cancellationToken);
}
private async Task<RecordingStatus> StopAsync(
DateTimeOffset? inferredEndTime,
CancellationToken cancellationToken)
{
RecordingRun run;
var abortAsTooShort = false;
@@ -244,6 +265,11 @@ public sealed class MeetingRecordingCoordinator
}
run = currentRun;
if (inferredEndTime is not null)
{
run.SetInferredEndTime(inferredEndTime.Value);
}
abortAsTooShort = ShouldAbortRunOnStop(run, DateTimeOffset.Now);
if (abortAsTooShort)
{
@@ -461,6 +487,7 @@ public sealed class MeetingRecordingCoordinator
var completedMeetingNote = await CompleteMeetingNoteAsync(
run.MeetingNotePath,
run.Options,
run.InferredEndTime,
run.TranscriptionCancellation);
if (completedMeetingNote is not null)
{
@@ -547,13 +574,121 @@ public sealed class MeetingRecordingCoordinator
{
await foreach (var segment in pipeline.ReadLiveTranscriptAsync(cancellationToken))
{
run.TryAddSpeakerSample(segment);
run.RecordTranscriptActivity(segment, inactivityClock.Now);
var sample = run.TryAddSpeakerSample(segment);
if (sample is not null)
{
logger.LogInformation(
"Collected speaker identity sample for {Speaker}: start {Start}, end {End}, duration {Duration}, score {Score}, sample bytes {SampleBytes}",
sample.Speaker,
sample.Segment.Start,
sample.Segment.End,
sample.Segment.End - sample.Segment.Start,
sample.Score,
sample.WavBytes.Length);
}
var relabeledSegment = run.Relabel(segment);
run.AddLiveSegment(relabeledSegment);
await transcriptStore.AppendAsync(run.Session, relabeledSegment, cancellationToken);
}
}
private async Task RunInactivitySafeguardAsync(RecordingRun run)
{
var safeguardOptions = run.Options.Recording.InactivitySafeguard;
var promptThresholds = GetInactivityPromptThresholds(safeguardOptions);
var promptedThresholds = new HashSet<TimeSpan>();
var lastActivityVersion = run.GetTranscriptActivitySnapshot().ActivityVersion;
logger.LogInformation(
"Recording inactivity safeguard started with prompt thresholds {PromptThresholds}, auto-stop {AutoStopAfter}, check interval {CheckInterval}",
string.Join(", ", promptThresholds),
safeguardOptions.AutoStopAfter,
safeguardOptions.CheckInterval);
try
{
while (!run.CaptureCancellation.IsCancellationRequested && !run.IsAborted)
{
var snapshot = run.GetTranscriptActivitySnapshot();
if (snapshot.ActivityVersion != lastActivityVersion)
{
promptedThresholds.Clear();
lastActivityVersion = snapshot.ActivityVersion;
logger.LogInformation(
"Recording inactivity safeguard reset after transcript activity at {LastTranscriptActivityAt}",
snapshot.LastTranscriptActivityAt);
}
var now = inactivityClock.Now;
var inactivityDuration = now - snapshot.LastTranscriptActivityAt;
if (safeguardOptions.AutoStopAfter > TimeSpan.Zero &&
inactivityDuration >= safeguardOptions.AutoStopAfter)
{
var inferredEndTime = run.GetInactivitySafeguardEndTime(safeguardOptions.InferredEndPadding);
logger.LogWarning(
"Recording inactivity safeguard auto-stopping meeting after {InactivityDuration} without transcript text; inferred end time {InferredEndTime}",
inactivityDuration,
inferredEndTime);
await StopAsync(inferredEndTime, CancellationToken.None);
return;
}
foreach (var threshold in promptThresholds)
{
if (promptedThresholds.Contains(threshold) || inactivityDuration < threshold)
{
continue;
}
promptedThresholds.Add(threshold);
var inferredEndTime = run.GetInactivitySafeguardEndTime(safeguardOptions.InferredEndPadding);
logger.LogInformation(
"Recording inactivity safeguard prompting after {InactivityDuration} without transcript text at threshold {Threshold}; inferred end time {InferredEndTime}",
inactivityDuration,
threshold,
inferredEndTime);
await inactivityPromptService.ShowStopPromptAsync(
new MeetingInactivityPromptRequest(
inactivityDuration,
inferredEndTime,
promptedThresholds.Count,
threshold),
async (response, cancellationToken) =>
{
logger.LogInformation(
"Recording inactivity safeguard prompt response {Response} at threshold {Threshold}",
response,
threshold);
if (response == MeetingInactivityPromptResponse.Stop)
{
await StopAsync(inferredEndTime, CancellationToken.None);
}
},
run.CaptureCancellation);
break;
}
await inactivityClock.DelayAsync(
GetInactivitySafeguardCheckInterval(safeguardOptions),
run.CaptureCancellation);
}
}
catch (OperationCanceledException) when (run.CaptureCancellation.IsCancellationRequested)
{
}
catch (ObjectDisposedException) when (run.CaptureCancellation.IsCancellationRequested)
{
}
catch (Exception exception)
{
logger.LogError(exception, "Recording inactivity safeguard failed");
}
}
private async Task IdentifyLiveSpeakersAsync(
RecordingRun run,
CancellationToken cancellationToken)
@@ -596,6 +731,10 @@ public sealed class MeetingRecordingCoordinator
var samples = run.GetSpeakerSamplesSnapshot();
if (segments.Count == 0 || samples.Count == 0)
{
logger.LogInformation(
"Skipping live speaker identity matching: {SegmentCount} live segment(s), {SampleCount} collected sample(s)",
segments.Count,
samples.Count);
return;
}
@@ -605,9 +744,15 @@ public sealed class MeetingRecordingCoordinator
var checkpoint = run.CreateLiveIdentificationCheckpoint(meetingNote);
if (checkpoint is null)
{
logger.LogInformation(
"Skipping live speaker identity matching because no new unmapped sample speakers or attendee changes were found");
return;
}
logger.LogInformation(
"Running live speaker identity matching for speakers {Speakers} with {SampleCount} sample(s)",
string.Join(", ", checkpoint.Speakers),
samples.Count);
var result = await speakerIdentificationService!.IdentifyKnownSpeakersAsync(
new SpeakerIdentificationRequest(
"",
@@ -830,11 +975,18 @@ public sealed class MeetingRecordingCoordinator
if (run.HasSwitchedProfile)
{
var liveSegments = run.GetLiveSegmentsSnapshot();
var liveSamples = run.GetSpeakerSamplesSnapshot();
var liveMappings = run.GetSpeakerMappingsSnapshot();
run.SetPendingFinalSpeakerIdentification(
run.RecordedAudio.AudioPath,
liveSegments,
run.GetSpeakerSamplesSnapshot(),
run.GetSpeakerMappingsSnapshot());
liveSamples,
liveMappings);
logger.LogInformation(
"Queued pending final speaker identity processing after profile switch: {SegmentCount} segment(s), {SampleCount} sample(s), {KnownMappingCount} known mapping(s)",
liveSegments.Count,
liveSamples.Count,
liveMappings.Count);
await transcriptStore.ReplaceAsync(run.Session, liveSegments, cancellationToken);
return;
}
@@ -875,6 +1027,11 @@ public sealed class MeetingRecordingCoordinator
finishedSpeakerResult.Segments,
samples,
knownSpeakerMappings);
logger.LogInformation(
"Queued pending final speaker identity processing: {SegmentCount} segment(s), {SampleCount} sample(s), {KnownMappingCount} known mapping(s)",
finishedSpeakerResult.Segments.Count,
samples.Count,
knownSpeakerMappings.Count);
await transcriptStore.ReplaceAsync(run.Session, finishedSpeakerResult.Segments, cancellationToken);
}
@@ -937,6 +1094,11 @@ public sealed class MeetingRecordingCoordinator
try
{
logger.LogInformation(
"Starting pending final speaker identity processing: {SegmentCount} segment(s), {SampleCount} sample(s), {KnownMappingCount} known mapping(s)",
pending.Segments.Count,
pending.Samples.Count,
pending.KnownSpeakerMappings.Count);
var meetingNote = await meetingNoteStore.ReadAsync(run.MeetingNotePath, cancellationToken);
var segments = pending.Segments.ToList();
var knownSpeakerMappings = pending.KnownSpeakerMappings.ToDictionary(
@@ -948,6 +1110,10 @@ public sealed class MeetingRecordingCoordinator
cancellationToken);
foreach (var speakerOverride in speakerOverrides)
{
logger.LogInformation(
"Applying summary speaker override before final identity learning: {SourceSpeaker} -> {TargetSpeaker}",
speakerOverride.From,
speakerOverride.To);
await speakerIdentificationService.ApplySpeakerOverrideAsync(
new SpeakerIdentificationRequest(
pending.AudioPath,
@@ -971,6 +1137,10 @@ public sealed class MeetingRecordingCoordinator
cancellationToken);
foreach (var deletion in identityDeletions)
{
logger.LogInformation(
"Applying summary speaker identity deletion before final identity learning: {Identity} -> {Replacement}",
deletion.Identity,
deletion.Replacement);
await speakerIdentificationService.DeleteSpeakerIdentityAsync(
deletion.Identity,
cancellationToken);
@@ -989,6 +1159,10 @@ public sealed class MeetingRecordingCoordinator
pending.Samples,
knownSpeakerMappings),
cancellationToken);
logger.LogInformation(
"Completed final speaker identity learning: {MappingCount} mapping(s), {SegmentCount} segment(s)",
result.SpeakerMappings.Count,
result.Segments.Count);
await AddIdentifiedSpeakersToMeetingAttendeesAsync(
result.AttendeeMatches,
run.Artifacts,
@@ -1089,6 +1263,7 @@ public sealed class MeetingRecordingCoordinator
private async Task<MeetingNote?> CompleteMeetingNoteAsync(
string meetingNotePath,
MeetingAssistantOptions runOptions,
DateTimeOffset? endTime,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(meetingNotePath))
@@ -1097,7 +1272,7 @@ public sealed class MeetingRecordingCoordinator
}
var meetingNote = await meetingNoteStore.ReadAsync(meetingNotePath, cancellationToken);
meetingNote.Frontmatter.EndTime = DateTimeOffset.Now;
meetingNote.Frontmatter.EndTime = endTime ?? DateTimeOffset.Now;
var savedMeetingNote = await meetingNoteStore.SaveAsync(meetingNote, runOptions, cancellationToken);
if (string.Equals(currentMeetingNote?.Path, savedMeetingNote.Path, StringComparison.OrdinalIgnoreCase))
{
@@ -1294,6 +1469,26 @@ public sealed class MeetingRecordingCoordinator
return minimumDuration > TimeSpan.Zero && stoppedAt - run.StartedAt < minimumDuration;
}
private static IReadOnlyList<TimeSpan> GetInactivityPromptThresholds(
RecordingInactivitySafeguardOptions options)
{
return new[] { options.FirstPromptAfter }
.Concat(options.ReminderPromptAfter ?? [])
.Where(threshold => threshold > TimeSpan.Zero)
.Where(threshold => options.AutoStopAfter <= TimeSpan.Zero || threshold < options.AutoStopAfter)
.Distinct()
.Order()
.ToList();
}
private static TimeSpan GetInactivitySafeguardCheckInterval(
RecordingInactivitySafeguardOptions options)
{
return options.CheckInterval > TimeSpan.Zero
? options.CheckInterval
: TimeSpan.FromSeconds(15);
}
private async Task CompleteRunAsync(RecordingRun run)
{
if (!run.TryComplete())
@@ -1326,6 +1521,7 @@ public sealed class MeetingRecordingCoordinator
private readonly object liveSegmentsGate = new();
private readonly object liveTranscriptTasksGate = new();
private readonly object liveIdentificationGate = new();
private readonly object transcriptActivityGate = new();
private readonly List<TranscriptionSegment> liveSegments = [];
private readonly List<Task> liveTranscriptTasks = [];
private readonly List<ISpeechRecognitionPipeline> pipelines = [];
@@ -1333,6 +1529,10 @@ public sealed class MeetingRecordingCoordinator
private readonly SpeakerAudioSampleCollector speakerSampleCollector;
private LiveIdentificationCheckpoint? lastLiveIdentificationCheckpoint;
private FinalSpeakerIdentificationWork? pendingFinalSpeakerIdentification;
private DateTimeOffset lastTranscriptActivityAt;
private TimeSpan? lastTranscriptSegmentEnd;
private long transcriptActivityVersion;
private DateTimeOffset? inferredEndTime;
public RecordingRun(
CancellationTokenSource captureCancellation,
@@ -1347,7 +1547,8 @@ public sealed class MeetingRecordingCoordinator
DateTimeOffset startedAt,
string launchProfileName,
TimeSpan liveSampleBufferDuration,
int maxSpeakerSamples)
int maxSpeakerSamples,
ILogger logger)
{
CaptureCancellationSource = captureCancellation;
TranscriptionCancellationSource = transcriptionCancellation;
@@ -1360,13 +1561,15 @@ public sealed class MeetingRecordingCoordinator
PipelineOptions = pipelineOptions;
Options = options;
StartedAt = startedAt;
lastTranscriptActivityAt = startedAt;
LaunchProfileName = launchProfileName;
pipelines.Add(pipeline);
speakerSampleCollector = new SpeakerAudioSampleCollector(
liveSampleBufferDuration,
maxSpeakerSamples,
options.SpeakerIdentification.MinimumSampleSpeechDuration,
options.SpeakerIdentification.MaximumSampleSegmentGap);
options.SpeakerIdentification.MaximumSampleSegmentGap,
logger);
}
public CancellationTokenSource CaptureCancellationSource { get; }
@@ -1411,6 +1614,17 @@ public sealed class MeetingRecordingCoordinator
public AssistantContextState ContextState { get; private set; } = AssistantContextState.CollectingMetadata;
public DateTimeOffset? InferredEndTime
{
get
{
lock (transcriptActivityGate)
{
return inferredEndTime;
}
}
}
public void StopCapture()
{
CaptureCancellationSource.Cancel();
@@ -1557,14 +1771,59 @@ public sealed class MeetingRecordingCoordinator
}
}
public void RecordTranscriptActivity(TranscriptionSegment segment, DateTimeOffset arrivedAt)
{
if (string.IsNullOrWhiteSpace(segment.Text))
{
return;
}
lock (transcriptActivityGate)
{
lastTranscriptActivityAt = arrivedAt;
var segmentEnd = segment.End > TimeSpan.Zero ? segment.End : segment.Start;
lastTranscriptSegmentEnd = lastTranscriptSegmentEnd is null
? segmentEnd
: TimeSpan.FromTicks(Math.Max(lastTranscriptSegmentEnd.Value.Ticks, segmentEnd.Ticks));
transcriptActivityVersion++;
}
}
public TranscriptActivitySnapshot GetTranscriptActivitySnapshot()
{
lock (transcriptActivityGate)
{
return new TranscriptActivitySnapshot(
lastTranscriptActivityAt,
lastTranscriptSegmentEnd,
transcriptActivityVersion);
}
}
public DateTimeOffset GetInactivitySafeguardEndTime(TimeSpan padding)
{
lock (transcriptActivityGate)
{
return StartedAt + (lastTranscriptSegmentEnd ?? TimeSpan.Zero) + padding;
}
}
public void SetInferredEndTime(DateTimeOffset endTime)
{
lock (transcriptActivityGate)
{
inferredEndTime = endTime;
}
}
public void AppendAudio(AudioChunk chunk)
{
speakerSampleCollector.AppendAudio(chunk);
}
public void TryAddSpeakerSample(TranscriptionSegment segment)
public SpeakerAudioSample? TryAddSpeakerSample(TranscriptionSegment segment)
{
speakerSampleCollector.TryAdd(segment);
return speakerSampleCollector.TryAdd(segment);
}
public IReadOnlyList<TranscriptionSegment> GetLiveSegmentsSnapshot()
@@ -1683,6 +1942,11 @@ public sealed class MeetingRecordingCoordinator
PipelineGate.Dispose();
}
public sealed record TranscriptActivitySnapshot(
DateTimeOffset LastTranscriptActivityAt,
TimeSpan? LastTranscriptSegmentEnd,
long ActivityVersion);
private static int StateRank(AssistantContextState state)
{
return state switch
@@ -1,5 +1,6 @@
using MeetingAssistant.Speakers;
using MeetingAssistant.Transcription;
using Microsoft.Extensions.Logging;
namespace MeetingAssistant.Recording;
@@ -11,6 +12,7 @@ internal sealed class SpeakerAudioSampleCollector
private readonly int maxSamplesPerSpeaker;
private readonly TimeSpan minimumUninterruptedSpeechDuration;
private readonly TimeSpan maximumSegmentGap;
private readonly ILogger? logger;
private PendingSpeakerSpan? pendingSpan;
public SpeakerAudioSampleCollector(TimeSpan bufferDuration, int maxSamplesPerSpeaker)
@@ -18,7 +20,8 @@ internal sealed class SpeakerAudioSampleCollector
bufferDuration,
maxSamplesPerSpeaker,
TimeSpan.FromSeconds(30),
TimeSpan.FromSeconds(1))
TimeSpan.FromSeconds(1),
logger: null)
{
}
@@ -26,7 +29,8 @@ internal sealed class SpeakerAudioSampleCollector
TimeSpan bufferDuration,
int maxSamplesPerSpeaker,
TimeSpan minimumUninterruptedSpeechDuration,
TimeSpan maximumSegmentGap)
TimeSpan maximumSegmentGap,
ILogger? logger = null)
{
audioBuffer = new RollingAudioBuffer(bufferDuration);
this.maxSamplesPerSpeaker = Math.Max(1, maxSamplesPerSpeaker);
@@ -36,6 +40,7 @@ internal sealed class SpeakerAudioSampleCollector
this.maximumSegmentGap = maximumSegmentGap >= TimeSpan.Zero
? maximumSegmentGap
: TimeSpan.Zero;
this.logger = logger;
}
public void AppendAudio(AudioChunk chunk)
@@ -53,32 +58,61 @@ internal sealed class SpeakerAudioSampleCollector
}
}
public void TryAdd(TranscriptionSegment segment)
public SpeakerAudioSample? TryAdd(TranscriptionSegment segment)
{
if (!IsDiarizedSpeaker(segment.Speaker))
{
return;
logger?.LogInformation(
"Discarding speaker identity sample for {Speaker} because the segment has no diarized speaker label",
segment.Speaker);
return null;
}
TranscriptionSegment sampleSegment;
PendingSpanReset? reset;
lock (gate)
{
sampleSegment = ExtendPendingSpan(segment);
(sampleSegment, reset) = ExtendPendingSpan(segment);
}
if (reset is not null)
{
logger?.LogInformation(
"Reset speaker identity sample span from {PreviousSpeaker} to {Speaker}: previous end {PreviousEnd}, next start {NextStart}, gap {Gap}, maximum gap {MaximumGap}",
reset.PreviousSpeaker,
segment.Speaker,
reset.PreviousEnd,
segment.Start,
reset.Gap,
maximumSegmentGap);
}
var score = Score(sampleSegment, minimumUninterruptedSpeechDuration);
if (score <= 0)
if (!score.Accepted)
{
return;
logger?.LogInformation(
"Discarding speaker identity sample for {Speaker} because {Reason}: duration {Duration}, minimum duration {MinimumDuration}, word count {WordCount}",
sampleSegment.Speaker,
score.Reason,
sampleSegment.End - sampleSegment.Start,
minimumUninterruptedSpeechDuration,
score.WordCount);
return null;
}
var wavBytes = audioBuffer.TryExtractWav(sampleSegment.Start, sampleSegment.End);
if (wavBytes.Length == 0)
{
return;
logger?.LogInformation(
"Discarding speaker identity sample for {Speaker} because no audio could be extracted from rolling buffer: start {Start}, end {End}, duration {Duration}",
sampleSegment.Speaker,
sampleSegment.Start,
sampleSegment.End,
sampleSegment.End - sampleSegment.Start);
return null;
}
var sample = new SpeakerAudioSample(sampleSegment.Speaker, sampleSegment, wavBytes, score);
var sample = new SpeakerAudioSample(sampleSegment.Speaker, sampleSegment, wavBytes, score.Value);
lock (gate)
{
if (!samplesBySpeaker.TryGetValue(sampleSegment.Speaker, out var samples))
@@ -88,13 +122,27 @@ internal sealed class SpeakerAudioSampleCollector
}
samples.Add(sample);
var beforeCount = samples.Count;
var bestSamples = samples
.OrderByDescending(candidate => candidate.Score)
.Take(maxSamplesPerSpeaker)
.ToList();
var retained = bestSamples.Contains(sample);
samples.Clear();
samples.AddRange(bestSamples);
if (!retained)
{
logger?.LogInformation(
"Discarding speaker identity sample for {Speaker} because it was not among the best {MaxSamplesPerSpeaker} retained sample(s): score {Score}, candidate count {CandidateCount}",
sample.Speaker,
maxSamplesPerSpeaker,
sample.Score,
beforeCount);
return null;
}
}
return sample;
}
public IReadOnlyList<SpeakerAudioSample> Snapshot()
@@ -115,39 +163,43 @@ internal sealed class SpeakerAudioSampleCollector
!string.Equals(speaker, "Unknown", StringComparison.OrdinalIgnoreCase);
}
private TranscriptionSegment ExtendPendingSpan(TranscriptionSegment segment)
private (TranscriptionSegment Segment, PendingSpanReset? Reset) ExtendPendingSpan(TranscriptionSegment segment)
{
if (pendingSpan is null ||
!SpeakerSampleSpanSelector.CanExtend(pendingSpan.Speaker, pendingSpan.End, segment, maximumSegmentGap))
{
var reset = pendingSpan is null
? null
: new PendingSpanReset(
pendingSpan.Speaker,
pendingSpan.End,
segment.Start - pendingSpan.End);
pendingSpan = new PendingSpeakerSpan(
segment.Speaker,
segment.Start,
segment.End,
[segment.Text]);
return pendingSpan.ToSegment();
return (pendingSpan.ToSegment(), reset);
}
pendingSpan = pendingSpan.Extend(segment);
return pendingSpan.ToSegment();
return (pendingSpan.ToSegment(), null);
}
private static double Score(
private static SampleScore Score(
TranscriptionSegment segment,
TimeSpan minimumUninterruptedSpeechDuration)
{
var durationSeconds = (segment.End - segment.Start).TotalSeconds;
if (durationSeconds < minimumUninterruptedSpeechDuration.TotalSeconds)
{
return 0;
return new SampleScore(false, 0, "speech duration is below the configured minimum", WordCount(segment.Text));
}
var words = segment.Text
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Length;
var words = WordCount(segment.Text);
if (words < 3)
{
return 0;
return new SampleScore(false, 0, "word count is below the minimum useful sample length", words);
}
var durationScore = Math.Min(durationSeconds / Math.Max(1, minimumUninterruptedSpeechDuration.TotalSeconds), 2);
@@ -157,9 +209,20 @@ internal sealed class SpeakerAudioSampleCollector
segment.Text.TrimEnd().EndsWith('!')
? 5
: 0;
return durationScore * 70 + wordScore * 30 + sentenceBonus;
return new SampleScore(true, durationScore * 70 + wordScore * 30 + sentenceBonus, null, words);
}
private static int WordCount(string text)
{
return text
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Length;
}
private sealed record SampleScore(bool Accepted, double Value, string? Reason, int WordCount);
private sealed record PendingSpanReset(string PreviousSpeaker, TimeSpan PreviousEnd, TimeSpan Gap);
private sealed record PendingSpeakerSpan(
string Speaker,
TimeSpan Start,
@@ -0,0 +1,214 @@
#if WINDOWS
using System.Collections.Concurrent;
using CommunityToolkit.WinUI.Notifications;
namespace MeetingAssistant.Recording;
public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPromptService, IDisposable
{
private const string NotificationSource = "meeting-inactivity-safeguard";
private const string NotificationGroup = "meeting-inactivity";
private readonly ConcurrentDictionary<string, PendingPrompt> pendingPrompts = new(StringComparer.OrdinalIgnoreCase);
private readonly ILogger<WindowsMeetingInactivityPromptService> logger;
private readonly object registrationGate = new();
private bool registered;
public WindowsMeetingInactivityPromptService(ILogger<WindowsMeetingInactivityPromptService> logger)
{
this.logger = logger;
}
public Task ShowStopPromptAsync(
MeetingInactivityPromptRequest request,
Func<MeetingInactivityPromptResponse, CancellationToken, Task> handleResponseAsync,
CancellationToken cancellationToken)
{
if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 17763))
{
logger.LogWarning("Windows app notifications require Windows 10 version 1809 or later");
return Task.CompletedTask;
}
if (cancellationToken.IsCancellationRequested)
{
return Task.FromCanceled(cancellationToken);
}
try
{
EnsureRegistered();
var promptId = Guid.NewGuid().ToString("N");
pendingPrompts[promptId] = new PendingPrompt(handleResponseAsync);
var notification = BuildNotification(promptId, request);
notification.Show(toast =>
{
toast.Group = NotificationGroup;
toast.Tag = promptId;
toast.ExpirationTime = DateTimeOffset.Now.AddMinutes(15);
});
logger.LogInformation(
"Displayed native Windows inactivity notification {PromptId} at threshold {Threshold}",
promptId,
request.Threshold);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to display native Windows inactivity notification");
}
return Task.CompletedTask;
}
public void Dispose()
{
if (!registered)
{
return;
}
try
{
ToastNotificationManagerCompat.OnActivated -= OnNotificationInvoked;
ToastNotificationManagerCompat.Uninstall();
}
catch (Exception exception)
{
logger.LogWarning(exception, "Failed to unregister native Windows toast notifications");
}
}
private void EnsureRegistered()
{
if (registered)
{
return;
}
lock (registrationGate)
{
if (registered)
{
return;
}
ToastNotificationManagerCompat.OnActivated += OnNotificationInvoked;
registered = true;
logger.LogInformation("Registered native Windows toast notification activation handler");
}
}
private static ToastContentBuilder BuildNotification(
string promptId,
MeetingInactivityPromptRequest request)
{
var yesButton = new ToastButton()
.SetContent("Yes")
.AddArgument("source", NotificationSource)
.AddArgument("promptId", promptId)
.AddArgument("response", "stop")
.SetBackgroundActivation();
var noButton = new ToastButton()
.SetContent("No")
.AddArgument("source", NotificationSource)
.AddArgument("promptId", promptId)
.AddArgument("response", "continue")
.SetBackgroundActivation();
return new ToastContentBuilder()
.AddArgument("source", NotificationSource)
.AddArgument("promptId", promptId)
.AddText("Stop meeting?")
.AddText($"No transcript text has arrived for {FormatDuration(request.InactivityDuration)}.")
.AddButton(yesButton)
.AddButton(noButton);
}
private void OnNotificationInvoked(ToastNotificationActivatedEventArgsCompat args)
{
var arguments = ParseArguments(args.Argument);
if (!TryGetArgument(arguments, "source", out var source) ||
!string.Equals(source, NotificationSource, StringComparison.OrdinalIgnoreCase) ||
!TryGetArgument(arguments, "promptId", out var promptId) ||
!pendingPrompts.TryRemove(promptId, out var pendingPrompt))
{
return;
}
var response = TryGetArgument(arguments, "response", out var responseValue) &&
string.Equals(responseValue, "stop", StringComparison.OrdinalIgnoreCase)
? MeetingInactivityPromptResponse.Stop
: MeetingInactivityPromptResponse.Continue;
_ = Task.Run(async () =>
{
try
{
logger.LogInformation(
"Native Windows inactivity notification {PromptId} activated with response {Response}",
promptId,
response);
await pendingPrompt.HandleResponseAsync(response, CancellationToken.None);
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to handle native Windows inactivity notification response {Response}",
response);
}
});
}
private static IReadOnlyDictionary<string, string> ParseArguments(string argumentText)
{
if (string.IsNullOrWhiteSpace(argumentText))
{
return new Dictionary<string, string>();
}
var arguments = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in argumentText.Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var separatorIndex = pair.IndexOf('=');
if (separatorIndex < 0)
{
arguments[Uri.UnescapeDataString(pair)] = "";
continue;
}
var key = Uri.UnescapeDataString(pair[..separatorIndex]);
var value = Uri.UnescapeDataString(pair[(separatorIndex + 1)..]);
arguments[key] = value;
}
return arguments;
}
private static bool TryGetArgument(
IReadOnlyDictionary<string, string> arguments,
string key,
out string value)
{
if (arguments.TryGetValue(key, out value!))
{
return true;
}
value = "";
return false;
}
private static string FormatDuration(TimeSpan duration)
{
if (duration.TotalMinutes >= 1)
{
return $"{Math.Round(duration.TotalMinutes):0} minutes";
}
return $"{Math.Max(1, Math.Round(duration.TotalSeconds)):0} seconds";
}
private sealed record PendingPrompt(
Func<MeetingInactivityPromptResponse, CancellationToken, Task> HandleResponseAsync);
}
#endif