Public Access
Keep recording startup independent of dictation words
PR and Push Build/Test / build-and-test (push) Failing after 8m52s
PR and Push Build/Test / build-and-test (push) Failing after 8m52s
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user