diff --git a/MeetingAssistant.Tests/RecordingCoordinatorTests.cs b/MeetingAssistant.Tests/RecordingCoordinatorTests.cs index 15d594d..6cefde5 100644 --- a/MeetingAssistant.Tests/RecordingCoordinatorTests.cs +++ b/MeetingAssistant.Tests/RecordingCoordinatorTests.cs @@ -1001,7 +1001,8 @@ public sealed class RecordingCoordinatorTests new CapturingMeetingSummaryPipeline(), Options.Create(new MeetingAssistantOptions()), NullLogger.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.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.Instance); + } + + private sealed class ThrowingDictationWordStore : IDictationWordStore + { + public Task> ReadWordsAsync(CancellationToken cancellationToken) + { + throw new IOException("dictation words unavailable"); + } + + public Task> AddWordAsync(string word, CancellationToken cancellationToken) + { + throw new IOException("dictation words unavailable"); + } + } + + private sealed class HangingDictationWordStore : IDictationWordStore + { + public async Task> ReadWordsAsync(CancellationToken cancellationToken) + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return []; + } + + public Task> AddWordAsync(string word, CancellationToken cancellationToken) + { + return Task.FromResult>([]); + } + } + private sealed class FixedAttendeeCanonicalizer : ISpeakerIdentityAttendeeCanonicalizer { private readonly IReadOnlyList attendees; diff --git a/MeetingAssistant/MeetingAssistantOptions.cs b/MeetingAssistant/MeetingAssistantOptions.cs index 1f923b9..6954a9d 100644 --- a/MeetingAssistant/MeetingAssistantOptions.cs +++ b/MeetingAssistant/MeetingAssistantOptions.cs @@ -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(); diff --git a/MeetingAssistant/Program.cs b/MeetingAssistant/Program.cs index 9bad58e..973d651 100644 --- a/MeetingAssistant/Program.cs +++ b/MeetingAssistant/Program.cs @@ -44,6 +44,7 @@ builder.Services.AddSingleton(); #endif +builder.Services.AddSingleton(); #if WINDOWS builder.Services.AddSingleton(); #else diff --git a/MeetingAssistant/Recording/MeetingRecordingCoordinator.cs b/MeetingAssistant/Recording/MeetingRecordingCoordinator.cs index 3324e67..1cc8dd5 100644 --- a/MeetingAssistant/Recording/MeetingRecordingCoordinator.cs +++ b/MeetingAssistant/Recording/MeetingRecordingCoordinator.cs @@ -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 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 KnownSpeakerMappings); } - private sealed class EmptyDictationWordStore : IDictationWordStore + private sealed class EmptyRecordingDictationWordProvider : IRecordingDictationWordProvider { - public Task> ReadWordsAsync(CancellationToken cancellationToken) + public static readonly EmptyRecordingDictationWordProvider Instance = new(); + + public Task> ReadOptionalWordsAsync( + MeetingAssistantOptions runOptions, + CancellationToken cancellationToken) { return Task.FromResult>([]); } - - public Task> AddWordAsync(string word, CancellationToken cancellationToken) - { - return ReadWordsAsync(cancellationToken); - } } } diff --git a/MeetingAssistant/Recording/RecordingDictationWordProvider.cs b/MeetingAssistant/Recording/RecordingDictationWordProvider.cs new file mode 100644 index 0000000..ce5e5ae --- /dev/null +++ b/MeetingAssistant/Recording/RecordingDictationWordProvider.cs @@ -0,0 +1,86 @@ +using MeetingAssistant.Transcription; + +namespace MeetingAssistant.Recording; + +public interface IRecordingDictationWordProvider +{ + Task> ReadOptionalWordsAsync( + MeetingAssistantOptions runOptions, + CancellationToken cancellationToken); +} + +public sealed class RecordingDictationWordProvider : IRecordingDictationWordProvider +{ + private readonly IDictationWordStore dictationWordStore; + private readonly ILogger logger; + + public RecordingDictationWordProvider( + IDictationWordStore dictationWordStore, + ILogger logger) + { + this.dictationWordStore = dictationWordStore; + this.logger = logger; + } + + public async Task> 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> 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 []; + } + } +} diff --git a/MeetingAssistant/appsettings.json b/MeetingAssistant/appsettings.json index 549168b..00b221e 100644 --- a/MeetingAssistant/appsettings.json +++ b/MeetingAssistant/appsettings.json @@ -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, diff --git a/docs/meeting-assistant-configuration.md b/docs/meeting-assistant-configuration.md index c1c0b45..de18916 100644 --- a/docs/meeting-assistant-configuration.md +++ b/docs/meeting-assistant-configuration.md @@ -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. | diff --git a/openspec/specs/meeting-session/spec.md b/openspec/specs/meeting-session/spec.md index 4eddc7e..d32d205 100644 --- a/openspec/specs/meeting-session/spec.md +++ b/openspec/specs/meeting-session/spec.md @@ -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.