forked from Manuel/meeting-assistant
fix(macos): preserve audio when transcription fails
This commit is contained in:
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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<MeetingRecordingCoordinator>.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<TranscriptionSegment> TranscribeAsync(
|
||||
IAsyncEnumerable<AudioChunk> 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<PropertyGroup Condition="$([MSBuild]::IsOSPlatform('OSX')) and $([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) != 'windows'">
|
||||
<MacOsNativeHelpersEnabled>true</MacOsNativeHelpersEnabled>
|
||||
<MacOsAudioCaptureSource>$(MSBuildProjectDirectory)/Native/MacOsMeetingAudioCapture/main.swift</MacOsAudioCaptureSource>
|
||||
<MacOsAudioCaptureInfoPlist>$(MSBuildProjectDirectory)/Native/MacOsMeetingAudioCapture/Info.plist</MacOsAudioCaptureInfoPlist>
|
||||
<MacOsAudioCaptureOutputPath>Native/macos-meeting-audio-capture</MacOsAudioCaptureOutputPath>
|
||||
<MacOsAudioCaptureRid Condition="'$(RuntimeIdentifier)' != ''">$(RuntimeIdentifier)</MacOsAudioCaptureRid>
|
||||
<MacOsAudioCaptureRid Condition="'$(MacOsAudioCaptureRid)' == ''">$(NETCoreSdkRuntimeIdentifier)</MacOsAudioCaptureRid>
|
||||
@@ -82,7 +83,7 @@
|
||||
AfterTargets="Build"
|
||||
Condition="'$(MacOsNativeHelpersEnabled)' == 'true'">
|
||||
<MakeDir Directories="$(TargetDir)Native" />
|
||||
<Exec Command="/usr/bin/xcrun swiftc -parse-as-library -O -target $(MacOsAudioCaptureArchitecture)-apple-macos13.0 -framework AVFoundation -framework CoreMedia -framework ScreenCaptureKit "$(MacOsAudioCaptureSource)" -o "$(TargetDir)$(MacOsAudioCaptureOutputPath)"" />
|
||||
<Exec Command="/usr/bin/xcrun swiftc -parse-as-library -O -target $(MacOsAudioCaptureArchitecture)-apple-macos13.0 -framework AVFoundation -framework CoreMedia -framework ScreenCaptureKit -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist -Xlinker "$(MacOsAudioCaptureInfoPlist)" "$(MacOsAudioCaptureSource)" -o "$(TargetDir)$(MacOsAudioCaptureOutputPath)"" />
|
||||
<Exec Command="/usr/bin/xcrun swiftc -parse-as-library -O -target $(MacOsAudioCaptureArchitecture)-apple-macos13.0 -framework AppKit -framework Carbon -framework WebKit "$(MacOsDesktopControlsSource)" -o "$(TargetDir)$(MacOsDesktopControlsOutputPath)"" />
|
||||
<Exec Command="/usr/bin/xcrun swiftc -parse-as-library -O -target $(MacOsAudioCaptureArchitecture)-apple-macos13.0 -framework AppKit -framework CoreGraphics -framework EventKit -framework ImageIO -framework UniformTypeIdentifiers -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist -Xlinker "$(MacOsMeetingIntegrationsInfoPlist)" "$(MacOsMeetingIntegrationsSource)" -o "$(TargetDir)$(MacOsMeetingIntegrationsOutputPath)"" />
|
||||
</Target>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>cloud.schweigert.meeting-assistant.audio-capture</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Meeting Assistant Audio Capture</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Meeting Assistant records microphone audio for live meeting transcription.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user