From 84dc696ce9596030b843aff1358ca97a2903af69 Mon Sep 17 00:00:00 2001 From: dh Date: Tue, 1 Sep 2026 11:55:15 +0200 Subject: [PATCH] fix(macos): preserve audio when transcription fails --- .../MacOsMeetingAudioSourceTests.cs | 28 ++++++++ .../RecordingCoordinatorTests.cs | 67 ++++++++++++++++++- MeetingAssistant/MeetingAssistant.csproj | 3 +- .../MacOsMeetingAudioCapture/Info.plist | 12 ++++ .../Recording/MeetingRecordingCoordinator.cs | 18 +++++ openspec/specs/meeting-transcription/spec.md | 9 ++- 6 files changed, 132 insertions(+), 5 deletions(-) create mode 100644 MeetingAssistant/Native/MacOsMeetingAudioCapture/Info.plist diff --git a/MeetingAssistant.Tests/MacOsMeetingAudioSourceTests.cs b/MeetingAssistant.Tests/MacOsMeetingAudioSourceTests.cs index 9a987ce..c2f5801 100644 --- a/MeetingAssistant.Tests/MacOsMeetingAudioSourceTests.cs +++ b/MeetingAssistant.Tests/MacOsMeetingAudioSourceTests.cs @@ -15,6 +15,34 @@ namespace MeetingAssistant.Tests; public sealed class MacOsMeetingAudioSourceTests { + [Fact] + public void NativeMicrophoneHelperDeclaresMacOsPrivacyMetadata() + { + if (!OperatingSystem.IsMacOS()) + { + return; + } + + var configuration = new DirectoryInfo(AppContext.BaseDirectory) + .Parent?.Name ?? "Debug"; + var helperPath = Path.GetFullPath(Path.Combine( + AppContext.BaseDirectory, + "..", + "..", + "..", + "..", + "MeetingAssistant", + "bin", + configuration, + "net10.0", + "Native", + "macos-meeting-audio-capture")); + var helperImage = System.Text.Encoding.UTF8.GetString(File.ReadAllBytes(helperPath)); + + Assert.Contains("cloud.schweigert.meeting-assistant.audio-capture", helperImage, StringComparison.Ordinal); + Assert.Contains("NSMicrophoneUsageDescription", helperImage, StringComparison.Ordinal); + } + [Fact] public async Task MacOsMicrophoneStreamsNativePcmUsingRunFormat() { diff --git a/MeetingAssistant.Tests/RecordingCoordinatorTests.cs b/MeetingAssistant.Tests/RecordingCoordinatorTests.cs index e5d0973..902af5c 100644 --- a/MeetingAssistant.Tests/RecordingCoordinatorTests.cs +++ b/MeetingAssistant.Tests/RecordingCoordinatorTests.cs @@ -1378,6 +1378,45 @@ public sealed class RecordingCoordinatorTests Assert.False(audioArchive.Deleted); } + [Fact] + public async Task StopQueuesAzureBacklogAndRetainsAudioWhenTranscriptionFails() + { + var audioSource = new ControlledAudioSource(); + var provider = new FailingAfterFirstAudioProvider(); + var audioArchive = new InMemoryRecordedAudioStore(); + var backlog = new InMemoryOfflineTranscriptionBacklog(); + var coordinator = new MeetingRecordingCoordinator( + audioSource, + new TestSpeechRecognitionPipelineFactory(provider), + new InMemoryTranscriptStore(), + new InMemoryMeetingNoteStore(), + new CapturingMeetingNoteOpener(), + new InMemoryMeetingArtifactStore(), + audioArchive, + new CapturingMeetingSummaryPipeline(), + Options.Create(new MeetingAssistantOptions + { + Recording = new RecordingOptions + { + TranscriptionProvider = "azure-speech" + } + }), + NullLogger.Instance, + offlineTranscriptionBacklog: backlog); + + await coordinator.StartAsync(CancellationToken.None); + await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None); + await provider.WaitUntilFailureObservedAsync(); + + var stopped = await coordinator.StopAsync(CancellationToken.None); + + Assert.False(stopped.IsRecording); + var item = Assert.Single(backlog.Items); + Assert.Equal("memory-recording.wav", item.AudioPath); + Assert.True(audioArchive.Completed); + Assert.False(audioArchive.Deleted); + } + [Fact] public async Task OfflineBacklogReplaysQueuedRecordingAndCompletesMeetingArtifacts() { @@ -4988,6 +5027,31 @@ public sealed class RecordingCoordinatorTests } } + private sealed class FailingAfterFirstAudioProvider : IStreamingTranscriptionProvider + { + private readonly TaskCompletionSource failureObserved = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task WaitUntilFailureObservedAsync() + { + return failureObserved.Task.WaitAsync(TimeSpan.FromSeconds(5)); + } + + public async IAsyncEnumerable TranscribeAsync( + IAsyncEnumerable audio, + SpeechRecognitionPipelineOptions options, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var _ in audio.WithCancellation(cancellationToken)) + { + failureObserved.TrySetResult(); + throw new InvalidOperationException("Configured transcription backend is unavailable."); + } + + yield break; + } + } + private static byte[] Samples(params short[] samples) { var bytes = new byte[samples.Length * sizeof(short)]; @@ -4995,6 +5059,3 @@ public sealed class RecordingCoordinatorTests return bytes; } } - - - diff --git a/MeetingAssistant/MeetingAssistant.csproj b/MeetingAssistant/MeetingAssistant.csproj index 31ea92e..2df711b 100644 --- a/MeetingAssistant/MeetingAssistant.csproj +++ b/MeetingAssistant/MeetingAssistant.csproj @@ -18,6 +18,7 @@ true $(MSBuildProjectDirectory)/Native/MacOsMeetingAudioCapture/main.swift + $(MSBuildProjectDirectory)/Native/MacOsMeetingAudioCapture/Info.plist Native/macos-meeting-audio-capture $(RuntimeIdentifier) $(NETCoreSdkRuntimeIdentifier) @@ -82,7 +83,7 @@ AfterTargets="Build" Condition="'$(MacOsNativeHelpersEnabled)' == 'true'"> - + diff --git a/MeetingAssistant/Native/MacOsMeetingAudioCapture/Info.plist b/MeetingAssistant/Native/MacOsMeetingAudioCapture/Info.plist new file mode 100644 index 0000000..f7086a2 --- /dev/null +++ b/MeetingAssistant/Native/MacOsMeetingAudioCapture/Info.plist @@ -0,0 +1,12 @@ + + + + + CFBundleIdentifier + cloud.schweigert.meeting-assistant.audio-capture + CFBundleName + Meeting Assistant Audio Capture + NSMicrophoneUsageDescription + Meeting Assistant records microphone audio for live meeting transcription. + + diff --git a/MeetingAssistant/Recording/MeetingRecordingCoordinator.cs b/MeetingAssistant/Recording/MeetingRecordingCoordinator.cs index 3279848..7fc9d0b 100644 --- a/MeetingAssistant/Recording/MeetingRecordingCoordinator.cs +++ b/MeetingAssistant/Recording/MeetingRecordingCoordinator.cs @@ -624,6 +624,24 @@ public sealed class MeetingRecordingCoordinator catch (Exception exception) { logger.LogError(exception, "Meeting recording failed"); + if (IsAzureSpeechRun(run) && !run.IsQueuedForOfflineTranscription) + { + logger.LogWarning( + "Azure Speech transcription failed; retaining recorded audio and queueing offline transcription backlog item"); + run.MarkQueuedForOfflineTranscription(); + try + { + await offlineTranscriptionBacklog.EnqueueAsync( + CreateOfflineBacklogItem(run), + CancellationToken.None); + } + catch (Exception backlogException) + { + logger.LogError( + backlogException, + "Could not queue offline transcription backlog item; retaining recorded audio for manual recovery"); + } + } } finally { diff --git a/openspec/specs/meeting-transcription/spec.md b/openspec/specs/meeting-transcription/spec.md index de2211b..cdc4788 100644 --- a/openspec/specs/meeting-transcription/spec.md +++ b/openspec/specs/meeting-transcription/spec.md @@ -137,6 +137,8 @@ After Azure Speech reconnects through a new SDK session, Meeting Assistant SHALL When Azure Speech is still unavailable after recording stops and transcription cannot drain before the configured stop-processing timeout, Meeting Assistant SHALL persist the stopped meeting as a durable transcription backlog item that references the completed mixed WAV and meeting artifacts. +When Azure Speech transcription fails after meeting audio has been captured, Meeting Assistant SHALL retain the completed mixed WAV and persist a durable transcription backlog item instead of deleting the only recoverable recording. + When a stopped meeting is persisted to the durable transcription backlog, Meeting Assistant SHALL release the active recording slot so another meeting can be recorded while the stopped meeting waits for Azure Speech to become available. When Azure Speech becomes available again, Meeting Assistant SHALL retry durable backlog items, rewrite the transcript from the recorded WAV, run the normal post-transcription meeting completion and summary flow, and remove the backlog item after successful completion. @@ -182,6 +184,12 @@ When Meeting Assistant starts, it SHALL preserve WAV files that are referenced b - **AND** keeps the completed mixed WAV referenced by that backlog item - **AND** returns to an idle recording state so another meeting can start +#### Scenario: Azure transcription failure retains captured meeting audio +- **GIVEN** Meeting Assistant captured meeting audio with the `azure-speech` provider +- **WHEN** Azure transcription fails before the transcript is completed +- **THEN** Meeting Assistant persists a durable backlog item for the meeting +- **AND** keeps the completed mixed WAV referenced by that backlog item for retry or manual recovery + #### Scenario: Durable Azure backlog resumes after connectivity returns - **GIVEN** a stopped Azure meeting exists in the durable transcription backlog - **WHEN** Azure Speech can transcribe the recorded WAV @@ -565,4 +573,3 @@ When pyannote secondary validation is enabled, Meeting Assistant SHALL start a n - **WHEN** Meeting Assistant starts - **THEN** it begins preparing the configured pyannote runtime image and model cache without waiting for the first validation request - **AND** application startup is not blocked by the warm-up task -