Keep recording startup independent of dictation words
PR and Push Build/Test / build-and-test (push) Failing after 8m52s

This commit is contained in:
DEEUSEW\DESCMANU
2026-06-02 13:27:10 +02:00
parent 0e9d525b63
commit 8a0b302f1d
8 changed files with 191 additions and 12 deletions
@@ -1001,7 +1001,8 @@ public sealed class RecordingCoordinatorTests
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions()),
NullLogger<MeetingRecordingCoordinator>.Instance,
dictationWordStore: new FixedDictationWordStore(["PBI", "Product Backlog Item"]));
dictationWordProvider: CreateRecordingDictationWordProvider(
new FixedDictationWordStore(["PBI", "Product Backlog Item"])));
await coordinator.StartAsync(CancellationToken.None);
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
@@ -1012,6 +1013,55 @@ public sealed class RecordingCoordinatorTests
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task StartContinuesWithoutDictationWordsWhenStoreCannotBeRead()
{
await StartContinuesWithoutDictationWordsAsync(new ThrowingDictationWordStore());
}
[Fact]
public async Task StartContinuesWithoutDictationWordsWhenStoreReadTimesOut()
{
await StartContinuesWithoutDictationWordsAsync(
new HangingDictationWordStore(),
TimeSpan.FromMilliseconds(20));
}
private static async Task StartContinuesWithoutDictationWordsAsync(
IDictationWordStore dictationWordStore,
TimeSpan? dictationWordsReadTimeout = null)
{
var audioSource = new ControlledAudioSource();
var provider = new CapturingPipelineOptionsProvider();
var options = new MeetingAssistantOptions();
if (dictationWordsReadTimeout is not null)
{
options.Recording.DictationWordsReadTimeout = dictationWordsReadTimeout.Value;
}
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(provider),
new InMemoryTranscriptStore(),
new InMemoryMeetingNoteStore(),
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(options),
NullLogger<MeetingRecordingCoordinator>.Instance,
dictationWordProvider: CreateRecordingDictationWordProvider(dictationWordStore));
var status = await coordinator.StartAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5));
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
await provider.OptionsObserved.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.True(status.IsRecording);
Assert.Empty(provider.Options?.DictationWords ?? []);
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task StopRewritesTranscriptWithFinalDiarizedSegmentsWhenAvailable()
{
@@ -3333,6 +3383,41 @@ public sealed class RecordingCoordinatorTests
}
}
private static RecordingDictationWordProvider CreateRecordingDictationWordProvider(
IDictationWordStore dictationWordStore)
{
return new RecordingDictationWordProvider(
dictationWordStore,
NullLogger<RecordingDictationWordProvider>.Instance);
}
private sealed class ThrowingDictationWordStore : IDictationWordStore
{
public Task<IReadOnlyList<string>> ReadWordsAsync(CancellationToken cancellationToken)
{
throw new IOException("dictation words unavailable");
}
public Task<IReadOnlyList<string>> AddWordAsync(string word, CancellationToken cancellationToken)
{
throw new IOException("dictation words unavailable");
}
}
private sealed class HangingDictationWordStore : IDictationWordStore
{
public async Task<IReadOnlyList<string>> ReadWordsAsync(CancellationToken cancellationToken)
{
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
return [];
}
public Task<IReadOnlyList<string>> AddWordAsync(string word, CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<string>>([]);
}
}
private sealed class FixedAttendeeCanonicalizer : ISpeakerIdentityAttendeeCanonicalizer
{
private readonly IReadOnlyList<string> attendees;
@@ -130,6 +130,8 @@ public sealed class RecordingOptions
public TimeSpan OpenMeetingNoteDelay { get; set; } = TimeSpan.FromSeconds(1);
public TimeSpan DictationWordsReadTimeout { get; set; } = TimeSpan.FromSeconds(2);
public string TemporaryRecordingsFolder { get; set; } = @"%LOCALAPPDATA%\MeetingAssistant\Recordings";
public RecordingInactivitySafeguardOptions InactivitySafeguard { get; set; } = new();
+1
View File
@@ -44,6 +44,7 @@ builder.Services.AddSingleton<IMeetingInactivityPromptService, WindowsMeetingIna
#else
builder.Services.AddSingleton<IMeetingInactivityPromptService, NoopMeetingInactivityPromptService>();
#endif
builder.Services.AddSingleton<IRecordingDictationWordProvider, RecordingDictationWordProvider>();
#if WINDOWS
builder.Services.AddSingleton<IActiveWindowScreenshotCapture, ActiveWindowScreenshotCapture>();
#else
@@ -24,7 +24,7 @@ public sealed class MeetingRecordingCoordinator
private readonly IMeetingSummaryPipeline summaryPipeline;
private readonly ISpeakerIdentificationService? speakerIdentificationService;
private readonly ISpeakerIdentityAttendeeCanonicalizer attendeeCanonicalizer;
private readonly IDictationWordStore dictationWordStore;
private readonly IRecordingDictationWordProvider dictationWordProvider;
private readonly ILaunchProfileOptionsProvider? launchProfiles;
private readonly IMeetingWorkflowEngine meetingWorkflowEngine;
private readonly IMeetingScreenshotService screenshotService;
@@ -52,7 +52,7 @@ public sealed class MeetingRecordingCoordinator
ILogger<MeetingRecordingCoordinator> logger,
IMeetingMetadataProvider? meetingMetadataProvider = null,
ISpeakerIdentificationService? speakerIdentificationService = null,
IDictationWordStore? dictationWordStore = null,
IRecordingDictationWordProvider? dictationWordProvider = null,
ISpeakerIdentityAttendeeCanonicalizer? attendeeCanonicalizer = null,
ILaunchProfileOptionsProvider? launchProfiles = null,
IMeetingWorkflowEngine? meetingWorkflowEngine = null,
@@ -70,7 +70,7 @@ public sealed class MeetingRecordingCoordinator
this.meetingMetadataProvider = meetingMetadataProvider ?? new NoopMeetingMetadataProvider();
this.recordedAudioStore = recordedAudioStore;
this.summaryPipeline = summaryPipeline;
this.dictationWordStore = dictationWordStore ?? new EmptyDictationWordStore();
this.dictationWordProvider = dictationWordProvider ?? EmptyRecordingDictationWordProvider.Instance;
this.speakerIdentificationService = speakerIdentificationService;
this.attendeeCanonicalizer = attendeeCanonicalizer ?? PassthroughSpeakerIdentityAttendeeCanonicalizer.Instance;
this.launchProfiles = launchProfiles;
@@ -1413,7 +1413,7 @@ public sealed class MeetingRecordingCoordinator
string? meetingNotePath,
CancellationToken cancellationToken)
{
var dictationWords = await dictationWordStore.ReadWordsAsync(runOptions, cancellationToken);
var dictationWords = await dictationWordProvider.ReadOptionalWordsAsync(runOptions, cancellationToken);
if (string.IsNullOrWhiteSpace(meetingNotePath))
{
return new SpeechRecognitionPipelineOptions(DictationWords: dictationWords);
@@ -1980,17 +1980,16 @@ public sealed class MeetingRecordingCoordinator
IReadOnlyDictionary<string, string> KnownSpeakerMappings);
}
private sealed class EmptyDictationWordStore : IDictationWordStore
private sealed class EmptyRecordingDictationWordProvider : IRecordingDictationWordProvider
{
public Task<IReadOnlyList<string>> ReadWordsAsync(CancellationToken cancellationToken)
public static readonly EmptyRecordingDictationWordProvider Instance = new();
public Task<IReadOnlyList<string>> ReadOptionalWordsAsync(
MeetingAssistantOptions runOptions,
CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<string>>([]);
}
public Task<IReadOnlyList<string>> AddWordAsync(string word, CancellationToken cancellationToken)
{
return ReadWordsAsync(cancellationToken);
}
}
}
@@ -0,0 +1,86 @@
using MeetingAssistant.Transcription;
namespace MeetingAssistant.Recording;
public interface IRecordingDictationWordProvider
{
Task<IReadOnlyList<string>> ReadOptionalWordsAsync(
MeetingAssistantOptions runOptions,
CancellationToken cancellationToken);
}
public sealed class RecordingDictationWordProvider : IRecordingDictationWordProvider
{
private readonly IDictationWordStore dictationWordStore;
private readonly ILogger<RecordingDictationWordProvider> logger;
public RecordingDictationWordProvider(
IDictationWordStore dictationWordStore,
ILogger<RecordingDictationWordProvider> logger)
{
this.dictationWordStore = dictationWordStore;
this.logger = logger;
}
public async Task<IReadOnlyList<string>> ReadOptionalWordsAsync(
MeetingAssistantOptions runOptions,
CancellationToken cancellationToken)
{
var timeout = runOptions.Recording.DictationWordsReadTimeout;
if (timeout <= TimeSpan.Zero)
{
return await ReadWithoutFallbackTimeoutAsync(runOptions, cancellationToken);
}
try
{
using var readCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var readTask = dictationWordStore.ReadWordsAsync(runOptions, readCancellation.Token);
try
{
return await readTask.WaitAsync(timeout, cancellationToken);
}
catch (TimeoutException exception)
{
readCancellation.Cancel();
logger.LogWarning(
exception,
"Reading dictation words timed out after {Timeout}; recording will continue without dictation-word hints",
timeout);
return [];
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
logger.LogWarning(
exception,
"Could not read dictation words; recording will continue without dictation-word hints");
return [];
}
}
private async Task<IReadOnlyList<string>> ReadWithoutFallbackTimeoutAsync(
MeetingAssistantOptions runOptions,
CancellationToken cancellationToken)
{
try
{
return await dictationWordStore.ReadWordsAsync(runOptions, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
logger.LogWarning(
exception,
"Could not read dictation words; recording will continue without dictation-word hints");
return [];
}
}
}
+1
View File
@@ -25,6 +25,7 @@
"BackgroundMetadataLookupTimeout": "00:01:00",
"MaxMetadataAttendeeImportCount": 30,
"OpenMeetingNoteDelay": "00:00:01",
"DictationWordsReadTimeout": "00:00:02",
"TemporaryRecordingsFolder": "%LOCALAPPDATA%\\MeetingAssistant\\Recordings",
"InactivitySafeguard": {
"Enabled": true,
+1
View File
@@ -118,6 +118,7 @@ The default profile is always named `default`. Non-default profile hotkeys are r
| `BackgroundMetadataLookupTimeout` | Longer background timeout for metadata that can continue after transcription starts. |
| `MaxMetadataAttendeeImportCount` | Maximum Outlook attendee count that will be copied into meeting frontmatter. |
| `OpenMeetingNoteDelay` | Delay before opening the newly created Obsidian note, giving the file system and app time to settle. |
| `DictationWordsReadTimeout` | Maximum time to wait for optional dictation-word hints before starting without them. |
| `TemporaryRecordingsFolder` | Folder for temporary mixed WAV files while a run is active. |
| `InactivitySafeguard` | Controls prompts and automatic normal stop for active meetings with no new transcript text. |
+4
View File
@@ -33,6 +33,10 @@ The dictation words location SHALL point to a word-list artifact that speech rec
- **WHEN** the configured speech recognition pipeline supports vocabulary hints
- **THEN** Meeting Assistant can provide words from the configured dictation words path without changing the other vault locations
#### Scenario: Dictation words are unavailable
- **WHEN** Meeting Assistant starts recording and the configured dictation words path cannot be read within the configured timeout
- **THEN** Meeting Assistant logs the failure and starts recording without dictation-word hints
### Requirement: Meeting note template is coded and round-trippable
Meeting Assistant SHALL link the note files generated for one meeting run through frontmatter.