Add resilient Azure offline transcription backlog
PR and Push Build/Test / build-and-test (push) Successful in 18m41s

This commit is contained in:
2026-06-15 17:26:34 +02:00
parent b22754ce5d
commit b774ccc375
35 changed files with 2456 additions and 198 deletions
@@ -1,5 +1,6 @@
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Workflow;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
@@ -203,6 +204,71 @@ public sealed class MeetingWorkflowEngineTests
Assert.Equal("[00:00:04] Guest-1: Azure returned [redacted] here.", line);
}
[Fact]
public async Task TranscriptLineRulePreservesUtf8CharactersWhenRenderingRazor()
{
var fixture = await WorkflowFixture.CreateAsync(MeetingWorkflowTestRules.MaskedProfanityRedactionYaml);
var line = await fixture.Engine.TransformTranscriptLineAsync(
MeetingWorkflowEvent.TranscriptLine(
fixture.Artifacts,
"[00:00:57] Guest-1: Das heißt, Schimpfwörter wie ***** müssen als nächstes richtig bleiben.",
"Guest-1"),
fixture.Options,
CancellationToken.None);
Assert.Equal(
"[00:00:57] Guest-1: Das heißt, Schimpfwörter wie [redacted] müssen als nächstes richtig bleiben.",
line);
}
[Fact]
public async Task TriggeredWorkflowRuleLogsStartAndCompletion()
{
var logger = new ListLogger<MeetingWorkflowEngine>();
var fixture = await WorkflowFixture.CreateAsync(
"""
rules:
- name: logged-rule
on:
- created: {}
steps:
- uses: add_project
value: Diagnostics
""",
logger: logger);
await RunCreatedAsync(fixture);
Assert.Contains(logger.Messages, message =>
message.Contains("Triggered meeting workflow rule logged-rule for event Created", StringComparison.Ordinal));
Assert.Contains(logger.Messages, message =>
message.Contains("Completed meeting workflow rule logged-rule for event Created", StringComparison.Ordinal));
}
[Fact]
public async Task WorkflowRuleFailureLogsRuleNameAndEventType()
{
var logger = new ListLogger<MeetingWorkflowEngine>();
var fixture = await WorkflowFixture.CreateAsync(
"""
rules:
- name: broken-rule
on:
- created: {}
steps:
- uses: set_property
property: unsupported
value: Diagnostics
""",
logger: logger);
await Assert.ThrowsAsync<InvalidOperationException>(() => RunCreatedAsync(fixture));
Assert.Contains(logger.Messages, message =>
message.Contains("Meeting workflow rule broken-rule failed for event Created", StringComparison.Ordinal));
}
[Fact]
public async Task RuleCanRemoveAttendeeWhenNestedConditionMatches()
{
@@ -775,7 +841,8 @@ public sealed class MeetingWorkflowEngineTests
string title = "Planning Sync",
IReadOnlyList<string>? attendees = null,
IReadOnlyList<string>? projects = null,
string? rulesPath = null)
string? rulesPath = null,
ILogger<MeetingWorkflowEngine>? logger = null)
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
@@ -837,7 +904,7 @@ public sealed class MeetingWorkflowEngineTests
NullLogger<FileMeetingWorkflowRulesProvider>.Instance),
noteStore,
artifactStore,
NullLogger<MeetingWorkflowEngine>.Instance);
logger ?? NullLogger<MeetingWorkflowEngine>.Instance);
return new WorkflowFixture(options, noteStore, artifacts, engine);
}
@@ -851,4 +918,39 @@ public sealed class MeetingWorkflowEngineTests
return File.ReadAllTextAsync(Artifacts.AssistantContextPath);
}
}
private sealed class ListLogger<T> : ILogger<T>
{
public List<string> Messages { get; } = [];
public IDisposable BeginScope<TState>(TState state)
where TState : notnull
{
return NullScope.Instance;
}
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));
}
private sealed class NullScope : IDisposable
{
public static NullScope Instance { get; } = new();
public void Dispose()
{
}
}
}
}
@@ -172,7 +172,7 @@ public sealed class RecordingCoordinatorTests
var started = await coordinator.StartAsync(CancellationToken.None);
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
await WaitUntilAsync(() => FileContainsText(started.TranscriptPath!, "[redacted]"));
await WaitUntilAsync(() => FileContainsText(started.TranscriptPath!, "Azure returned"));
await coordinator.StopAsync(CancellationToken.None);
var content = await File.ReadAllTextAsync(started.TranscriptPath!);
@@ -180,6 +180,224 @@ public sealed class RecordingCoordinatorTests
Assert.DoesNotContain("*****", content);
}
[Fact]
public async Task TranscriptLineWorkflowFailureKeepsWritingLiveTranscriptSegments()
{
var audioSource = new ControlledAudioSource();
var transcriptStore = new InMemoryTranscriptStore();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new OrderedChunkProvider()),
transcriptStore,
new InMemoryMeetingNoteStore(),
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions()),
NullLogger<MeetingRecordingCoordinator>.Instance,
meetingWorkflowEngine: new FailingFirstTranscriptLineWorkflowEngine());
await coordinator.StartAsync(CancellationToken.None);
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
await audioSource.WriteAsync(new AudioChunk([2, 0], 16000, 1), CancellationToken.None);
await transcriptStore.WaitForTextAsync("first has enough words for identification.");
await transcriptStore.WaitForTextAsync("second has enough words for identification.");
await coordinator.StopAsync(CancellationToken.None);
Assert.Contains(transcriptStore.Segments, segment =>
segment.Text.Contains("first has enough words for identification.", StringComparison.Ordinal));
Assert.Contains(transcriptStore.Segments, segment =>
segment.Text.Contains("second has enough words for identification.", StringComparison.Ordinal));
}
[Fact]
public async Task TranscriptLineWorkflowRewriteRunsAfterOriginalLineIsWritten()
{
var audioSource = new ControlledAudioSource();
var transcriptStore = new InMemoryTranscriptStore();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(
new FixedSegmentStreamingTranscriptionProvider(
new TranscriptionSegment(
TimeSpan.FromSeconds(4),
TimeSpan.FromSeconds(5),
"Guest-1",
"Azure returned ***** here."))),
transcriptStore,
new InMemoryMeetingNoteStore(),
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions()),
NullLogger<MeetingRecordingCoordinator>.Instance,
meetingWorkflowEngine: new ObservingTranscriptRewriteWorkflowEngine(transcriptStore));
await coordinator.StartAsync(CancellationToken.None);
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
await transcriptStore.WaitForTextAsync("Azure returned [redacted] here.");
await coordinator.StopAsync(CancellationToken.None);
Assert.DoesNotContain(transcriptStore.Segments, segment =>
segment.Text.Contains("*****", StringComparison.Ordinal));
Assert.Contains(transcriptStore.Segments, segment =>
segment.Text.Contains("Azure returned [redacted] here.", StringComparison.Ordinal));
}
[Fact]
public async Task PendingTranscriptLineWorkflowRewriteDoesNotRemoveFollowingLine()
{
var audioSource = new ControlledAudioSource();
var transcriptStore = new InMemoryTranscriptStore();
var workflowEngine = new DelayedFirstTranscriptLineWorkflowEngine();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new OrderedChunkProvider()),
transcriptStore,
new InMemoryMeetingNoteStore(),
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions()),
NullLogger<MeetingRecordingCoordinator>.Instance,
meetingWorkflowEngine: workflowEngine);
await coordinator.StartAsync(CancellationToken.None);
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
await transcriptStore.WaitForTextAsync("first has enough words for identification.");
await workflowEngine.WaitUntilFirstLineWorkflowStartedAsync();
await audioSource.WriteAsync(new AudioChunk([2, 0], 16000, 1), CancellationToken.None);
await transcriptStore.WaitForTextAsync("second has enough words for identification.");
workflowEngine.CompleteFirstLineWorkflow();
await transcriptStore.WaitForTextAsync("rewritten first has enough words for identification.");
await coordinator.StopAsync(CancellationToken.None);
var segments = transcriptStore.Segments;
Assert.Equal(
["rewritten first has enough words for identification.", "second has enough words for identification."],
segments.Select(segment => segment.Text).ToArray());
}
[Fact]
public async Task TranscriptReconnectMarkerIsReplacedByNextAzureTranscriptLine()
{
var audioSource = new ControlledAudioSource();
var transcriptStore = new InMemoryTranscriptStore();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new StaticOnAudioCompletionProvider(
[
new TranscriptionSegment(
TimeSpan.Zero,
TimeSpan.Zero,
"System",
"<Reconnecting... 1/5>",
TranscriptionSegmentKind.Marker,
MarkerId: "azure-reconnect"),
new TranscriptionSegment(
TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(2),
"Guest-1",
"speech after reconnect",
ReplacesMarkerId: "azure-reconnect"),
new TranscriptionSegment(
TimeSpan.FromSeconds(3),
TimeSpan.FromSeconds(4),
"Guest-2",
"following speech")
])),
transcriptStore,
new InMemoryMeetingNoteStore(),
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions()),
NullLogger<MeetingRecordingCoordinator>.Instance);
await coordinator.StartAsync(CancellationToken.None);
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
await coordinator.StopAsync(CancellationToken.None);
var segments = transcriptStore.Segments;
Assert.Equal(
["speech after reconnect", "following speech"],
segments.Select(segment => segment.Text).ToArray());
Assert.DoesNotContain(segments, segment =>
segment.Text.Contains("Reconnecting", StringComparison.Ordinal));
}
[Fact]
public async Task TranscriptReconnectMarkerUpdatesReuseSameLineUntilAzureTranscriptResumes()
{
var audioSource = new ControlledAudioSource();
var transcriptStore = new InMemoryTranscriptStore();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new StaticOnAudioCompletionProvider(
[
new TranscriptionSegment(
TimeSpan.Zero,
TimeSpan.Zero,
"System",
"<Reconnecting... 1/5>",
TranscriptionSegmentKind.Marker,
MarkerId: "azure-reconnect"),
new TranscriptionSegment(
TimeSpan.Zero,
TimeSpan.Zero,
"System",
"<Reconnecting... 2/5>",
TranscriptionSegmentKind.Marker,
MarkerId: "azure-reconnect"),
new TranscriptionSegment(
TimeSpan.Zero,
TimeSpan.Zero,
"System",
"<Azure Speech disconnected. The meeting can continue; transcription and summarization will continue once Azure Speech reconnects.>",
TranscriptionSegmentKind.Marker,
MarkerId: "azure-reconnect"),
new TranscriptionSegment(
TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(2),
"Guest-1",
"speech after reconnect",
ReplacesMarkerId: "azure-reconnect"),
new TranscriptionSegment(
TimeSpan.FromSeconds(3),
TimeSpan.FromSeconds(4),
"Guest-2",
"following speech")
])),
transcriptStore,
new InMemoryMeetingNoteStore(),
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions()),
NullLogger<MeetingRecordingCoordinator>.Instance);
await coordinator.StartAsync(CancellationToken.None);
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
await coordinator.StopAsync(CancellationToken.None);
var segments = transcriptStore.Segments;
Assert.Equal(
["speech after reconnect", "following speech"],
segments.Select(segment => segment.Text).ToArray());
Assert.DoesNotContain(segments, segment =>
segment.Text.Contains("Reconnecting", StringComparison.Ordinal)
|| segment.Text.Contains("disconnected", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task StartCreatesMeetingNoteLinkedToTranscriptAndOpensIt()
{
@@ -822,6 +1040,124 @@ public sealed class RecordingCoordinatorTests
await store.WaitForTextAsync("final:2");
}
[Fact]
public async Task StopQueuesAzureBacklogAndAllowsNextMeetingWhenTranscriptionCannotDrain()
{
var audioSource = new ControlledAudioSource();
var provider = new BlockingBeforeTranscriptionProvider();
var transcriptStore = new InMemoryTranscriptStore();
var audioArchive = new InMemoryRecordedAudioStore();
var backlog = new InMemoryOfflineTranscriptionBacklog();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(provider),
transcriptStore,
new InMemoryMeetingNoteStore(),
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
audioArchive,
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions
{
Recording = new RecordingOptions
{
TranscriptionProvider = "azure-speech",
StopProcessingTimeout = TimeSpan.FromMilliseconds(20)
}
}),
NullLogger<MeetingRecordingCoordinator>.Instance,
offlineTranscriptionBacklog: backlog);
await coordinator.StartAsync(CancellationToken.None);
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
await provider.WaitUntilWaitingForBackendAsync();
var stopped = await coordinator.StopAsync(CancellationToken.None);
var restarted = await coordinator.StartAsync(CancellationToken.None);
Assert.False(stopped.IsRecording);
Assert.True(restarted.IsRecording);
var item = Assert.Single(backlog.Items);
Assert.Equal("memory-recording.wav", item.AudioPath);
Assert.Equal(transcriptStore.CreatedSessions[0].TranscriptPath, item.TranscriptPath);
Assert.True(audioArchive.Completed);
Assert.False(audioArchive.Deleted);
}
[Fact]
public async Task OfflineBacklogReplaysQueuedRecordingAndCompletesMeetingArtifacts()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var audioPath = Path.Combine(root, "queued.wav");
Directory.CreateDirectory(root);
using (var writer = new NAudio.Wave.WaveFileWriter(audioPath, new NAudio.Wave.WaveFormat(16000, 16, 1)))
{
writer.Write([1, 0, 2, 0], 0, 4);
}
var transcriptStore = new InMemoryTranscriptStore(Path.Combine(root, "transcript.md"));
var noteStore = new InMemoryMeetingNoteStore(Path.Combine(root, "meeting.md"));
var artifactStore = new InMemoryMeetingArtifactStore();
var summaryPipeline = new CapturingMeetingSummaryPipeline();
var workflowEngine = new CapturingMeetingWorkflowEngine();
var backlog = new InMemoryOfflineTranscriptionBacklog();
var pipelineFactory = new ProfileSwitchSpeechRecognitionPipelineFactory();
var startedAt = DateTimeOffset.Now.AddMinutes(-5);
var stoppedAt = DateTimeOffset.Now.AddMinutes(-1);
var note = new MeetingNote(
Path.Combine(root, "meeting.md"),
new MeetingNoteFrontmatter
{
Title = "Queued meeting",
StartTime = startedAt,
Attendees = ["Ada", "Grace"],
Transcript = Path.Combine(root, "transcript.md"),
AssistantContext = Path.Combine(root, "context.md"),
Summary = Path.Combine(root, "summary.md")
},
"");
noteStore.UpdateSavedNote(note);
await backlog.EnqueueAsync(new OfflineTranscriptionBacklogItem(
"queued",
audioPath,
Path.Combine(root, "transcript.md"),
Path.Combine(root, "meeting.md"),
Path.Combine(root, "context.md"),
Path.Combine(root, "summary.md"),
startedAt,
stoppedAt,
"english"), CancellationToken.None);
var launchProfiles = CreateLaunchProfiles(root, root);
var processor = new OfflineTranscriptionBacklogProcessor(
backlog,
pipelineFactory,
transcriptStore,
noteStore,
artifactStore,
summaryPipeline,
workflowEngine,
CreateRecordingDictationWordProvider(new FixedDictationWordStore(["codex"])),
Options.Create(new MeetingAssistantOptions()),
NullLogger<OfflineTranscriptionBacklogProcessor>.Instance,
launchProfiles);
var processed = await processor.ProcessPendingAsync(CancellationToken.None);
Assert.Equal(1, processed);
Assert.Empty(backlog.Items);
Assert.Equal(["english"], pipelineFactory.ProfileNames);
var segment = Assert.Single(transcriptStore.ReplacedSegments);
Assert.Contains("english chunk:4", segment.Text, StringComparison.Ordinal);
Assert.Equal(stoppedAt, noteStore.SavedNote?.Frontmatter.EndTime);
Assert.True(summaryPipeline.WasRun);
Assert.Equal(
new[] { AssistantContextState.Summarizing, AssistantContextState.Finished },
artifactStore.States);
Assert.Contains(workflowEngine.Events, workflowEvent =>
workflowEvent.Type == MeetingWorkflowEventType.TranscriptLine &&
workflowEvent.TranscriptLineText?.Contains("english chunk:4", StringComparison.Ordinal) == true);
}
[Fact]
public async Task AbortStopsRecordingDeletesArtifactsAndSkipsSummary()
{
@@ -2031,6 +2367,46 @@ public sealed class RecordingCoordinatorTests
Assert.DoesNotContain("transcript:", content);
}
[Fact]
public async Task VaultTranscriptStoreRewritesOneLineWithoutRemovingFollowingLine()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var vaultFolder = Path.Combine(root, "Transcripts");
var store = new VaultTranscriptStore(
Options.Create(new MeetingAssistantOptions
{
Vault = new VaultOptions { TranscriptsFolder = vaultFolder }
}),
NullLogger<VaultTranscriptStore>.Instance);
var session = await store.CreateSessionAsync(CancellationToken.None);
var artifacts = new MeetingSessionArtifacts(
MeetingNotePath: Path.Combine(root, "Notes", "meeting.md"),
TranscriptPath: session.TranscriptPath,
AssistantContextPath: Path.Combine(root, "Context", "context.md"),
SummaryPath: Path.Combine(root, "Summaries", "summary.md"));
await store.UpdateMetadataAsync(
session,
artifacts,
MeetingNoteTemplate.Create("Transcript Rewrite", transcriptPath: session.TranscriptPath),
CancellationToken.None);
var original = "[00:00:01] Guest-1: original workflow text";
var rewritten = "[00:00:01] Guest-1: rewritten workflow text";
var following = "[00:00:02] Guest-2: following transcript text";
var originalReference = await store.AppendLineAsync(session, original, CancellationToken.None);
await store.AppendLineAsync(session, following, CancellationToken.None);
await store.ReplaceLineAsync(session, originalReference, rewritten, CancellationToken.None);
var content = await File.ReadAllTextAsync(session.TranscriptPath);
Assert.Contains(rewritten, content);
Assert.Contains(following, content);
Assert.DoesNotContain(original, content);
Assert.True(
content.IndexOf(rewritten, StringComparison.Ordinal) <
content.IndexOf(following, StringComparison.Ordinal));
Assert.Contains("meeting:", content);
}
[Fact]
public async Task VaultTranscriptStoreNamesGeneratedTranscriptWithMinutePrecision()
{
@@ -2129,6 +2505,40 @@ public sealed class RecordingCoordinatorTests
Assert.True(File.Exists(unrelatedFile));
}
[Fact]
public async Task TemporaryRecordedAudioStoreKeepsQueuedOfflineRecordingsOnStartup()
{
var recordingFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(recordingFolder);
var queuedRecording = Path.Combine(recordingFolder, "queued.wav");
var staleRecording = Path.Combine(recordingFolder, "stale.wav");
await File.WriteAllTextAsync(queuedRecording, "queued");
await File.WriteAllTextAsync(staleRecording, "stale");
var backlog = new InMemoryOfflineTranscriptionBacklog();
await backlog.EnqueueAsync(new OfflineTranscriptionBacklogItem(
Id: "queued",
AudioPath: queuedRecording,
TranscriptPath: "transcript.md",
MeetingNotePath: "meeting.md",
AssistantContextPath: "context.md",
SummaryPath: "summary.md",
StartedAt: DateTimeOffset.Parse("2026-06-15T10:00:00+02:00"),
InferredEndTime: null,
LaunchProfileName: "default"), CancellationToken.None);
var store = new TemporaryRecordedAudioStore(
Options.Create(new MeetingAssistantOptions
{
Recording = new RecordingOptions { TemporaryRecordingsFolder = recordingFolder }
}),
NullLogger<TemporaryRecordedAudioStore>.Instance,
backlog);
await store.DeleteStaleRecordingsAsync(CancellationToken.None);
Assert.True(File.Exists(queuedRecording));
Assert.False(File.Exists(staleRecording));
}
private static MeetingAssistantOptions CreateOptionsWithoutFinalizer()
{
return new MeetingAssistantOptions
@@ -2190,9 +2600,13 @@ public sealed class RecordingCoordinatorTests
this.transcriptPath = transcriptPath;
}
public List<TranscriptSession> CreatedSessions { get; } = [];
public Task<TranscriptSession> CreateSessionAsync(CancellationToken cancellationToken)
{
return Task.FromResult(new TranscriptSession(transcriptPath));
var session = new TranscriptSession(transcriptPath);
CreatedSessions.Add(session);
return Task.FromResult(session);
}
public Task<TranscriptSession> CreateSessionAsync(
@@ -2203,13 +2617,34 @@ public sealed class RecordingCoordinatorTests
return CreateSessionAsync(cancellationToken);
}
public Task AppendLineAsync(TranscriptSession session, string line, CancellationToken cancellationToken)
public Task<TranscriptLineReference> AppendLineAsync(TranscriptSession session, string line, CancellationToken cancellationToken)
{
int index;
lock (gate)
{
index = segments.Count;
segments.Add(ParseTranscriptLine(line));
}
return Task.FromResult(new TranscriptLineReference(session.TranscriptPath, index, line));
}
public Task ReplaceLineAsync(
TranscriptSession session,
TranscriptLineReference lineReference,
string replacementLine,
CancellationToken cancellationToken)
{
var replacement = ParseTranscriptLine(replacementLine);
lock (gate)
{
if (lineReference.BodyLineIndex >= 0 &&
lineReference.BodyLineIndex < segments.Count)
{
segments[lineReference.BodyLineIndex] = replacement;
}
}
return Task.CompletedTask;
}
@@ -2280,11 +2715,12 @@ public sealed class RecordingCoordinatorTests
return CreateSessionAsync(cancellationToken);
}
public Task AppendLineAsync(TranscriptSession session, string line, CancellationToken cancellationToken)
public Task<TranscriptLineReference> AppendLineAsync(TranscriptSession session, string line, CancellationToken cancellationToken)
{
var index = AppendHistory.Count(entry => entry.Session.TranscriptPath == session.TranscriptPath);
AppendHistory.Add(new TranscriptWrite(session, ParseTranscriptLine(line)));
Interlocked.Increment(ref appendCount);
return Task.CompletedTask;
return Task.FromResult(new TranscriptLineReference(session.TranscriptPath, index, line));
}
public Task WaitForAppendCountAsync(int expectedCount)
@@ -2294,6 +2730,16 @@ public sealed class RecordingCoordinatorTests
$"Expected {expectedCount} transcript append(s).");
}
public Task ReplaceLineAsync(
TranscriptSession session,
TranscriptLineReference lineReference,
string replacementLine,
CancellationToken cancellationToken)
{
ReplacementHistory.Add(new TranscriptReplacement(session, [ParseTranscriptLine(replacementLine)]));
return Task.CompletedTask;
}
public Task ReplaceLinesAsync(
TranscriptSession session,
IReadOnlyList<string> replacementLines,
@@ -2420,6 +2866,105 @@ public sealed class RecordingCoordinatorTests
}
}
private sealed class FailingFirstTranscriptLineWorkflowEngine : IMeetingWorkflowEngine
{
public Task RunAsync(
MeetingWorkflowEvent workflowEvent,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task<string> TransformTranscriptLineAsync(
MeetingWorkflowEvent workflowEvent,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
if (workflowEvent.TranscriptLineText?.Contains("first", StringComparison.Ordinal) == true)
{
throw new InvalidOperationException("Workflow rule failed.");
}
return Task.FromResult(workflowEvent.TranscriptLineText ?? "");
}
}
private sealed class ObservingTranscriptRewriteWorkflowEngine : IMeetingWorkflowEngine
{
private readonly InMemoryTranscriptStore transcriptStore;
public ObservingTranscriptRewriteWorkflowEngine(InMemoryTranscriptStore transcriptStore)
{
this.transcriptStore = transcriptStore;
}
public Task RunAsync(
MeetingWorkflowEvent workflowEvent,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task<string> TransformTranscriptLineAsync(
MeetingWorkflowEvent workflowEvent,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
if (!transcriptStore.Segments.Any(segment =>
segment.Text.Contains("Azure returned ***** here.", StringComparison.Ordinal)))
{
throw new InvalidOperationException("Original transcript line was not written before workflow processing.");
}
return Task.FromResult(
(workflowEvent.TranscriptLineText ?? "").Replace("*****", "[redacted]", StringComparison.Ordinal));
}
}
private sealed class DelayedFirstTranscriptLineWorkflowEngine : IMeetingWorkflowEngine
{
private readonly TaskCompletionSource firstLineWorkflowStarted =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource completeFirstLineWorkflow =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public Task RunAsync(
MeetingWorkflowEvent workflowEvent,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public async Task<string> TransformTranscriptLineAsync(
MeetingWorkflowEvent workflowEvent,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
var line = workflowEvent.TranscriptLineText ?? "";
if (!line.Contains("first has enough words for identification.", StringComparison.Ordinal))
{
return line;
}
firstLineWorkflowStarted.TrySetResult();
await completeFirstLineWorkflow.Task.WaitAsync(cancellationToken);
return line.Replace("first", "rewritten first", StringComparison.Ordinal);
}
public Task WaitUntilFirstLineWorkflowStartedAsync()
{
return firstLineWorkflowStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
public void CompleteFirstLineWorkflow()
{
completeFirstLineWorkflow.TrySetResult();
}
}
private sealed class CapturingMeetingScreenshotService : IMeetingScreenshotService
{
public MeetingSessionArtifacts? Artifacts { get; private set; }
@@ -2935,7 +3480,16 @@ public sealed class RecordingCoordinatorTests
MeetingArtifactFileNames.Create(startedAt, MeetingArtifactFileNames.Transcript))));
}
public Task AppendLineAsync(TranscriptSession session, string line, CancellationToken cancellationToken)
public Task<TranscriptLineReference> AppendLineAsync(TranscriptSession session, string line, CancellationToken cancellationToken)
{
return Task.FromResult(new TranscriptLineReference(session.TranscriptPath, 0, line));
}
public Task ReplaceLineAsync(
TranscriptSession session,
TranscriptLineReference lineReference,
string replacementLine,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
@@ -3114,6 +3668,28 @@ public sealed class RecordingCoordinatorTests
}
}
private sealed class InMemoryOfflineTranscriptionBacklog : IOfflineTranscriptionBacklog
{
public List<OfflineTranscriptionBacklogItem> Items { get; } = [];
public Task EnqueueAsync(OfflineTranscriptionBacklogItem item, CancellationToken cancellationToken)
{
Items.Add(item);
return Task.CompletedTask;
}
public Task<IReadOnlyList<OfflineTranscriptionBacklogItem>> ListAsync(CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<OfflineTranscriptionBacklogItem>>(Items.ToList());
}
public Task CompleteAsync(string id, CancellationToken cancellationToken)
{
Items.RemoveAll(item => string.Equals(item.Id, id, StringComparison.Ordinal));
return Task.CompletedTask;
}
}
private sealed class TestSpeechRecognitionPipelineFactory : ISpeechRecognitionPipelineFactory
{
private readonly IStreamingTranscriptionProvider provider;
@@ -3946,6 +4522,31 @@ public sealed class RecordingCoordinatorTests
}
}
private sealed class StaticOnAudioCompletionProvider : IStreamingTranscriptionProvider
{
private readonly IReadOnlyList<TranscriptionSegment> segments;
public StaticOnAudioCompletionProvider(IReadOnlyList<TranscriptionSegment> segments)
{
this.segments = segments;
}
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
IAsyncEnumerable<AudioChunk> audio,
SpeechRecognitionPipelineOptions options,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await foreach (var _ in audio.WithCancellation(cancellationToken))
{
}
foreach (var segment in segments)
{
yield return segment;
}
}
}
private sealed class BlockingBeforeTranscriptionProvider : IStreamingTranscriptionProvider
{
private readonly TaskCompletionSource waitingForBackend =
@@ -83,6 +83,34 @@ public sealed class TaskbarIconTests
Assert.Equal(RecordingProcessState.Recording, menu.State);
}
[Theory]
[InlineData(RecordingProcessState.Idle, false)]
[InlineData(RecordingProcessState.Recording, true)]
[InlineData(RecordingProcessState.Summarizing, false)]
public void MenuAlwaysOffersExit(RecordingProcessState state, bool isRecording)
{
var menu = MeetingTaskbarMenuBuilder.Build(
Status(isRecording: isRecording, state: state, profile: "default"),
[Profile("default"), Profile("english")]);
Assert.Contains(menu.Items, item =>
item.Action == MeetingTaskbarAction.Exit &&
item.Text == "Exit");
}
[Theory]
[InlineData(RecordingProcessState.Idle, false)]
[InlineData(RecordingProcessState.Recording, true)]
[InlineData(RecordingProcessState.Summarizing, true)]
public void ExitRequiresConfirmationWhileRecordingOrProcessing(
RecordingProcessState state,
bool requiresConfirmation)
{
Assert.Equal(
requiresConfirmation,
MeetingTaskbarExitPolicy.RequiresConfirmation(Status(state: state)));
}
[Fact]
[SupportedOSPlatform("windows")]
public void TaskbarIconGlyphsAreVisuallyCentered()