feat: add transcription pause controls

This commit is contained in:
2026-09-02 12:22:11 +02:00
parent 714da456df
commit 70ffaa7357
18 changed files with 1927 additions and 94 deletions
@@ -6,6 +6,8 @@ public interface IMeetingInactivityPromptService
MeetingInactivityPromptRequest request,
Func<MeetingInactivityPromptResponse, CancellationToken, Task> handleResponseAsync,
CancellationToken cancellationToken);
Task DismissAllAsync(CancellationToken cancellationToken);
}
public sealed record MeetingInactivityPromptRequest(
@@ -18,9 +20,32 @@ public enum MeetingInactivityPromptResponse
{
Dismissed,
Continue,
Pause,
Stop
}
internal sealed record MeetingInactivityPromptAction(
string Content,
string ResponseArgument,
MeetingInactivityPromptResponse Response);
internal static class MeetingInactivityPromptActions
{
public static IReadOnlyList<MeetingInactivityPromptAction> All { get; } =
[
new("Yes", "stop", MeetingInactivityPromptResponse.Stop),
new("No", "continue", MeetingInactivityPromptResponse.Continue),
new("Pause transcription", "pause", MeetingInactivityPromptResponse.Pause)
];
public static MeetingInactivityPromptResponse ParseResponse(string? value)
{
return All.FirstOrDefault(action =>
action.ResponseArgument.Equals(value, StringComparison.OrdinalIgnoreCase))
?.Response ?? MeetingInactivityPromptResponse.Continue;
}
}
public sealed class NoopMeetingInactivityPromptService : IMeetingInactivityPromptService
{
public Task ShowStopPromptAsync(
@@ -30,4 +55,9 @@ public sealed class NoopMeetingInactivityPromptService : IMeetingInactivityPromp
{
return Task.CompletedTask;
}
public Task DismissAllAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
@@ -96,7 +96,8 @@ public sealed class MeetingRecordingCoordinator
currentArtifacts?.AssistantContextPath,
currentArtifacts?.SummaryPath,
GetProcessState(currentRun),
currentRun?.LaunchProfileName);
currentRun?.LaunchProfileName,
IsRecording && currentRun?.IsTranscriptionPaused == true);
public MeetingSessionArtifacts? CurrentArtifacts => currentArtifacts;
@@ -292,7 +293,7 @@ public sealed class MeetingRecordingCoordinator
runOptions.SpeakerIdentification.MaxSnippetsPerSpeaker,
logger);
run.Task = Task.Run(() => RecordAsync(run), CancellationToken.None);
if (runOptions.Recording.InactivitySafeguard.Enabled)
if (ShouldRunInactivitySafeguard(runOptions.Recording.InactivitySafeguard))
{
_ = Task.Run(
() => RunInactivitySafeguardAsync(run),
@@ -334,11 +335,14 @@ public sealed class MeetingRecordingCoordinator
public async Task<RecordingStatus> StopAsync(CancellationToken cancellationToken)
{
return await StopAsync(null, cancellationToken);
return await StopAsync(null, null, null, null, cancellationToken);
}
private async Task<RecordingStatus> StopAsync(
DateTimeOffset? inferredEndTime,
RecordingRun? expectedRun,
long? expectedActivityVersion,
DateTimeOffset? expectedPauseStartedAt,
CancellationToken cancellationToken)
{
RecordingRun run;
@@ -347,13 +351,30 @@ public sealed class MeetingRecordingCoordinator
await gate.WaitAsync(cancellationToken);
try
{
if (currentRun is null || currentRun.IsCaptureStopping)
if (currentRun is null ||
currentRun.IsCaptureStopping ||
expectedRun is not null && !ReferenceEquals(currentRun, expectedRun))
{
return CurrentStatus;
}
run = currentRun;
if (inferredEndTime is not null)
var inactivityStopClaimed = expectedActivityVersion is not null;
if (expectedActivityVersion is not null &&
(inferredEndTime is null ||
!run.TryClaimInactivityStop(expectedActivityVersion.Value, inferredEndTime.Value)))
{
return CurrentStatus;
}
var maximumPauseStopClaimed = expectedPauseStartedAt is not null;
if (expectedPauseStartedAt is not null &&
!run.TryClaimMaximumPauseStop(expectedPauseStartedAt.Value))
{
return CurrentStatus;
}
if (!inactivityStopClaimed && !maximumPauseStopClaimed && inferredEndTime is not null)
{
run.SetInferredEndTime(inferredEndTime.Value);
}
@@ -363,7 +384,7 @@ public sealed class MeetingRecordingCoordinator
{
run.Abort();
}
else
else if (!inactivityStopClaimed && !maximumPauseStopClaimed)
{
run.StopCapture();
}
@@ -409,6 +430,65 @@ public sealed class MeetingRecordingCoordinator
return CurrentStatus;
}
public async Task<RecordingStatus> SetTranscriptionPausedAsync(
bool isPaused,
CancellationToken cancellationToken)
{
return await SetTranscriptionPausedAsync(isPaused, null, null, cancellationToken);
}
private async Task<RecordingStatus> SetTranscriptionPausedAsync(
bool isPaused,
RecordingRun? expectedRun,
long? expectedActivityVersion,
CancellationToken cancellationToken)
{
await gate.WaitAsync(cancellationToken);
try
{
var run = currentRun;
if (run is null ||
run.IsCaptureStopping ||
expectedRun is not null && !ReferenceEquals(run, expectedRun))
{
return CurrentStatus;
}
var changed = false;
await run.PipelineGate.WaitAsync(cancellationToken);
try
{
var changedAt = inactivityClock.Now;
changed = run.TrySetTranscriptionPaused(
isPaused,
changedAt,
expectedActivityVersion);
}
finally
{
run.PipelineGate.Release();
}
if (changed && isPaused)
{
await DismissInactivityPromptsAsync(cancellationToken);
}
if (changed)
{
logger.LogInformation(
"Meeting transcription {PauseState}",
isPaused ? "paused" : "unpaused");
}
return CurrentStatus;
}
finally
{
gate.Release();
}
}
public async Task<RecordingStatus> AbortAsync(CancellationToken cancellationToken)
{
RecordingRun run;
@@ -661,8 +741,7 @@ public sealed class MeetingRecordingCoordinator
chunk.Channels);
}
await run.RecordedAudio.AppendAsync(chunk, run.CaptureCancellation);
await run.WriteAudioAsync(chunk, run.CaptureCancellation);
await run.RouteCapturedAudioAsync(chunk, run.CaptureCancellation);
}
logger.LogInformation(
@@ -701,7 +780,6 @@ public sealed class MeetingRecordingCoordinator
run.ResetSpeakerIdentification();
}
run.RecordTranscriptActivity(segment, inactivityClock.Now);
var sample = run.TryAddSpeakerSample(segment);
if (sample is not null)
{
@@ -718,6 +796,47 @@ public sealed class MeetingRecordingCoordinator
var relabeledSegment = run.Relabel(segment);
run.AddLiveSegment(relabeledSegment);
await AppendTranscriptSegmentAsync(run, relabeledSegment, cancellationToken);
run.RecordTranscriptActivity(segment, inactivityClock.Now);
if (!string.IsNullOrWhiteSpace(segment.Text))
{
await DismissInactivityPromptsForRunAsync(run, cancellationToken);
}
}
}
private async Task DismissInactivityPromptsForRunAsync(
RecordingRun run,
CancellationToken cancellationToken)
{
await gate.WaitAsync(cancellationToken);
try
{
if (!ReferenceEquals(currentRun, run) || run.IsCaptureStopping)
{
return;
}
await DismissInactivityPromptsAsync(cancellationToken);
}
finally
{
gate.Release();
}
}
private async Task DismissInactivityPromptsAsync(CancellationToken cancellationToken)
{
try
{
await inactivityPromptService.DismissAllAsync(cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
logger.LogWarning(exception, "Failed to dismiss transcript inactivity prompts");
}
}
@@ -845,7 +964,10 @@ public sealed class MeetingRecordingCoordinator
private async Task RunInactivitySafeguardAsync(RecordingRun run)
{
var safeguardOptions = run.Options.Recording.InactivitySafeguard;
var promptThresholds = GetInactivityPromptThresholds(safeguardOptions);
var promptThresholds = safeguardOptions.Enabled
? GetInactivityPromptThresholds(safeguardOptions)
: [];
var checkInterval = GetInactivitySafeguardCheckInterval(safeguardOptions);
var promptedThresholds = new HashSet<TimeSpan>();
var lastActivityVersion = run.GetTranscriptActivitySnapshot().ActivityVersion;
@@ -853,7 +975,7 @@ public sealed class MeetingRecordingCoordinator
"Recording inactivity safeguard started with prompt thresholds {PromptThresholds}, auto-stop {AutoStopAfter}, check interval {CheckInterval}",
string.Join(", ", promptThresholds),
safeguardOptions.AutoStopAfter,
safeguardOptions.CheckInterval);
checkInterval);
try
{
@@ -869,9 +991,38 @@ public sealed class MeetingRecordingCoordinator
snapshot.LastTranscriptActivityAt);
}
var pauseStartedAt = snapshot.TranscriptionPausedAt;
if (pauseStartedAt is not null)
{
var maximumPauseDuration = safeguardOptions.MaximumPauseDuration;
if (maximumPauseDuration > TimeSpan.Zero &&
inactivityClock.Now - pauseStartedAt.Value >= maximumPauseDuration)
{
logger.LogWarning(
"Recording inactivity safeguard stopping meeting after transcription remained paused for {PauseDuration}",
maximumPauseDuration);
var stopStatus = await StopAsync(
null,
run,
null,
pauseStartedAt,
CancellationToken.None);
if (!stopStatus.IsRecording)
{
return;
}
}
await inactivityClock.DelayAsync(
checkInterval,
run.CaptureCancellation);
continue;
}
var now = inactivityClock.Now;
var inactivityDuration = now - snapshot.LastTranscriptActivityAt;
if (safeguardOptions.AutoStopAfter > TimeSpan.Zero &&
if (safeguardOptions.Enabled &&
safeguardOptions.AutoStopAfter > TimeSpan.Zero &&
inactivityDuration >= safeguardOptions.AutoStopAfter)
{
var inferredEndTime = run.GetInactivitySafeguardEndTime(safeguardOptions.InferredEndPadding);
@@ -879,8 +1030,16 @@ public sealed class MeetingRecordingCoordinator
"Recording inactivity safeguard auto-stopping meeting after {InactivityDuration} without transcript text; inferred end time {InferredEndTime}",
inactivityDuration,
inferredEndTime);
await StopAsync(inferredEndTime, CancellationToken.None);
return;
var stopStatus = await StopAsync(
inferredEndTime,
run,
snapshot.ActivityVersion,
null,
CancellationToken.None);
if (!stopStatus.IsRecording)
{
return;
}
}
foreach (var threshold in promptThresholds)
@@ -897,6 +1056,7 @@ public sealed class MeetingRecordingCoordinator
inactivityDuration,
threshold,
inferredEndTime);
var promptActivityVersion = snapshot.ActivityVersion;
await inactivityPromptService.ShowStopPromptAsync(
new MeetingInactivityPromptRequest(
inactivityDuration,
@@ -912,16 +1072,36 @@ public sealed class MeetingRecordingCoordinator
if (response == MeetingInactivityPromptResponse.Stop)
{
await StopAsync(inferredEndTime, CancellationToken.None);
await StopAsync(
inferredEndTime,
run,
promptActivityVersion,
null,
CancellationToken.None);
}
else if (response == MeetingInactivityPromptResponse.Pause)
{
await SetTranscriptionPausedAsync(
true,
run,
promptActivityVersion,
CancellationToken.None);
}
},
run.CaptureCancellation);
var currentActivity = run.GetTranscriptActivitySnapshot();
if (currentActivity.IsTranscriptionPaused ||
currentActivity.ActivityVersion != promptActivityVersion)
{
await DismissInactivityPromptsAsync(run.CaptureCancellation);
}
break;
}
await inactivityClock.DelayAsync(
GetInactivitySafeguardCheckInterval(safeguardOptions),
checkInterval,
run.CaptureCancellation);
}
}
@@ -1821,6 +2001,11 @@ public sealed class MeetingRecordingCoordinator
.ToList();
}
private static bool ShouldRunInactivitySafeguard(RecordingInactivitySafeguardOptions options)
{
return options.Enabled || options.MaximumPauseDuration > TimeSpan.Zero;
}
private static TimeSpan GetInactivitySafeguardCheckInterval(
RecordingInactivitySafeguardOptions options)
{
@@ -1877,6 +2062,8 @@ public sealed class MeetingRecordingCoordinator
private TimeSpan? lastTranscriptSegmentEnd;
private long transcriptActivityVersion;
private DateTimeOffset? inferredEndTime;
private bool isTranscriptionPaused;
private DateTimeOffset? transcriptionPausedAt;
public RecordingRun(
CancellationTokenSource captureCancellation,
@@ -1960,6 +2147,17 @@ public sealed class MeetingRecordingCoordinator
public bool HasAttachedPromptMetadata { get; private set; }
public bool IsTranscriptionPaused
{
get
{
lock (transcriptActivityGate)
{
return isTranscriptionPaused;
}
}
}
public AssistantContextState ContextState { get; private set; } = AssistantContextState.CollectingMetadata;
public DateTimeOffset? InferredEndTime
@@ -1983,6 +2181,37 @@ public sealed class MeetingRecordingCoordinator
HasAttachedPromptMetadata = true;
}
public bool TrySetTranscriptionPaused(
bool isPaused,
DateTimeOffset changedAt,
long? expectedActivityVersion)
{
lock (transcriptActivityGate)
{
if (expectedActivityVersion is not null &&
transcriptActivityVersion != expectedActivityVersion.Value)
{
return false;
}
if (isTranscriptionPaused == isPaused)
{
return false;
}
isTranscriptionPaused = isPaused;
transcriptionPausedAt = isPaused ? changedAt : null;
if (!isPaused)
{
lastTranscriptActivityAt = changedAt;
}
transcriptActivityVersion++;
return true;
}
}
public void Abort()
{
IsAborted = true;
@@ -2011,13 +2240,17 @@ public sealed class MeetingRecordingCoordinator
return string.Equals(LaunchProfileName, launchProfileName, StringComparison.OrdinalIgnoreCase);
}
public async ValueTask WriteAudioAsync(AudioChunk chunk, CancellationToken cancellationToken)
public async ValueTask RouteCapturedAudioAsync(AudioChunk chunk, CancellationToken cancellationToken)
{
await PipelineGate.WaitAsync(cancellationToken);
try
{
AppendAudio(chunk);
await Pipeline.WriteAsync(chunk, cancellationToken);
var routedChunk = IsTranscriptionPaused
? chunk with { Pcm = new byte[chunk.Pcm.Length] }
: chunk;
await RecordedAudio.AppendAsync(routedChunk, cancellationToken);
AppendAudio(routedChunk);
await Pipeline.WriteAsync(routedChunk, cancellationToken);
}
finally
{
@@ -2201,7 +2434,9 @@ public sealed class MeetingRecordingCoordinator
return new TranscriptActivitySnapshot(
lastTranscriptActivityAt,
lastTranscriptSegmentEnd,
transcriptActivityVersion);
transcriptActivityVersion,
isTranscriptionPaused,
transcriptionPausedAt);
}
}
@@ -2221,6 +2456,35 @@ public sealed class MeetingRecordingCoordinator
}
}
public bool TryClaimInactivityStop(long expectedActivityVersion, DateTimeOffset endTime)
{
lock (transcriptActivityGate)
{
if (isTranscriptionPaused || transcriptActivityVersion != expectedActivityVersion)
{
return false;
}
inferredEndTime = endTime;
StopCapture();
return true;
}
}
public bool TryClaimMaximumPauseStop(DateTimeOffset expectedPauseStartedAt)
{
lock (transcriptActivityGate)
{
if (!isTranscriptionPaused || transcriptionPausedAt != expectedPauseStartedAt)
{
return false;
}
StopCapture();
return true;
}
}
public void AppendAudio(AudioChunk chunk)
{
speakerSampleCollector.AppendAudio(chunk);
@@ -2350,7 +2614,9 @@ public sealed class MeetingRecordingCoordinator
public sealed record TranscriptActivitySnapshot(
DateTimeOffset LastTranscriptActivityAt,
TimeSpan? LastTranscriptSegmentEnd,
long ActivityVersion);
long ActivityVersion,
bool IsTranscriptionPaused,
DateTimeOffset? TranscriptionPausedAt);
private static int StateRank(AssistantContextState state)
{
@@ -2405,7 +2671,8 @@ public sealed record RecordingStatus(
string? AssistantContextPath,
string? SummaryPath,
RecordingProcessState State = RecordingProcessState.Idle,
string? LaunchProfile = null);
string? LaunchProfile = null,
bool IsPaused = false);
public enum RecordingProcessState
{
@@ -11,7 +11,7 @@ public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPr
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 readonly object notificationGate = new();
private bool registered;
public WindowsMeetingInactivityPromptService(ILogger<WindowsMeetingInactivityPromptService> logger)
@@ -37,24 +37,27 @@ public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPr
try
{
EnsureRegistered();
var promptId = Guid.NewGuid().ToString("N");
pendingPrompts[promptId] = new PendingPrompt(handleResponseAsync);
logger.LogInformation(
"Registered native Windows inactivity notification {PromptId} response callback at threshold {Threshold}",
promptId,
request.Threshold);
var notification = BuildNotification(promptId, request);
notification.Show(toast =>
lock (notificationGate)
{
toast.Group = NotificationGroup;
toast.Tag = promptId;
toast.ExpirationTime = MeetingToastExpirationPolicy.StopReminderExpiration(DateTimeOffset.Now);
});
logger.LogInformation(
"Displayed native Windows inactivity notification {PromptId} at threshold {Threshold}",
promptId,
request.Threshold);
EnsureRegistered();
var promptId = Guid.NewGuid().ToString("N");
pendingPrompts[promptId] = new PendingPrompt(handleResponseAsync);
logger.LogInformation(
"Registered native Windows inactivity notification {PromptId} response callback at threshold {Threshold}",
promptId,
request.Threshold);
var notification = BuildNotification(promptId, request);
notification.Show(toast =>
{
toast.Group = NotificationGroup;
toast.Tag = promptId;
toast.ExpirationTime = MeetingToastExpirationPolicy.StopReminderExpiration(DateTimeOffset.Now);
});
logger.LogInformation(
"Displayed native Windows inactivity notification {PromptId} at threshold {Threshold}",
promptId,
request.Threshold);
}
}
catch (Exception exception)
{
@@ -76,6 +79,36 @@ public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPr
return Task.CompletedTask;
}
public Task DismissAllAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
lock (notificationGate)
{
if (pendingPrompts.IsEmpty)
{
return Task.CompletedTask;
}
pendingPrompts.Clear();
if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 17763))
{
return Task.CompletedTask;
}
try
{
ToastNotificationManagerCompat.History.RemoveGroup(NotificationGroup);
logger.LogInformation("Dismissed all native Windows inactivity notifications");
}
catch (Exception exception)
{
logger.LogWarning(exception, "Failed to dismiss native Windows inactivity notifications");
}
return Task.CompletedTask;
}
}
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
@@ -83,29 +116,28 @@ public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPr
public void Dispose()
{
if (!registered)
lock (notificationGate)
{
return;
}
if (!registered)
{
return;
}
try
{
ToastNotificationManagerCompat.OnActivated -= OnNotificationInvoked;
}
catch (Exception exception)
{
logger.LogWarning(exception, "Failed to detach native Windows toast notification activation handler");
try
{
ToastNotificationManagerCompat.OnActivated -= OnNotificationInvoked;
registered = false;
}
catch (Exception exception)
{
logger.LogWarning(exception, "Failed to detach native Windows toast notification activation handler");
}
}
}
private void EnsureRegistered()
{
if (registered)
{
return;
}
lock (registrationGate)
lock (notificationGate)
{
if (registered)
{
@@ -122,29 +154,25 @@ public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPr
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()
var builder = new ToastContentBuilder()
.AddArgument("source", NotificationSource)
.AddArgument("promptId", promptId)
.SetToastScenario(ToastScenario.Reminder)
.SetToastDuration(ToastDuration.Long)
.AddText("Stop meeting?")
.AddText($"No transcript text has arrived for {FormatDuration(request.InactivityDuration)}.")
.AddButton(yesButton)
.AddButton(noButton);
.AddText($"No transcript text has arrived for {FormatDuration(request.InactivityDuration)}.");
foreach (var action in MeetingInactivityPromptActions.All)
{
builder.AddButton(new ToastButton()
.SetContent(action.Content)
.AddArgument("source", NotificationSource)
.AddArgument("promptId", promptId)
.AddArgument("response", action.ResponseArgument)
.SetBackgroundActivation());
}
return builder;
}
private void OnNotificationInvoked(ToastNotificationActivatedEventArgsCompat args)
@@ -177,10 +205,9 @@ public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPr
return;
}
var response = TryGetArgument(arguments, "response", out var responseValue) &&
string.Equals(responseValue, "stop", StringComparison.OrdinalIgnoreCase)
? MeetingInactivityPromptResponse.Stop
: MeetingInactivityPromptResponse.Continue;
var response = TryGetArgument(arguments, "response", out var responseValue)
? MeetingInactivityPromptActions.ParseResponse(responseValue)
: MeetingInactivityPromptResponse.Continue;
_ = Task.Run(async () =>
{
try