@@ -19,6 +19,16 @@ import Combine
1919import Foundation
2020import TranscriptedCore
2121
22+ /// Result of a stop request. `didTimeOut == true` means we did not receive
23+ /// `Audio.onRecordingComplete` within `meetingStopTimeout`, so the WAV files
24+ /// at the returned URLs may not be fully finalized — the controller should
25+ /// route the audio to the failed queue rather than enqueuing for transcription.
26+ struct CaptureStopResult {
27+ let micURL : URL ?
28+ let systemURL : URL ?
29+ let didTimeOut : Bool
30+ }
31+
2232@available ( macOS 14 . 0 , * )
2333@MainActor
2434final class MeetingCaptureBridge : ObservableObject {
@@ -42,7 +52,7 @@ final class MeetingCaptureBridge: ObservableObject {
4252
4353 /// Fulfilled when Core's Audio reports recording completed. Cleared each
4454 /// time `startRecording` runs so back-to-back sessions do not leak state.
45- private var completionContinuation : CheckedContinuation < ( micURL : URL ? , systemURL : URL ? ) , Never > ?
55+ private var completionContinuation : CheckedContinuation < CaptureStopResult , Never > ?
4656 private var completionAttemptID : UUID ?
4757 private var completionTimeoutTask : Task < Void , Never > ?
4858 private var startContinuation : CheckedContinuation < Bool , Never > ?
@@ -59,7 +69,11 @@ final class MeetingCaptureBridge: ObservableObject {
5969 startTimeoutTask? . cancel ( )
6070 startContinuation? . resume ( returning: false )
6171 completionTimeoutTask? . cancel ( )
62- completionContinuation? . resume ( returning: ( audio. micAudioFileURL, audio. systemAudioFileURL) )
72+ completionContinuation? . resume ( returning: CaptureStopResult (
73+ micURL: audio. micAudioFileURL,
74+ systemURL: audio. systemAudioFileURL,
75+ didTimeOut: false
76+ ) )
6377 // Combine cancellables auto-release. Audio's own deinit tears down CoreAudio.
6478 }
6579
@@ -73,7 +87,11 @@ final class MeetingCaptureBridge: ObservableObject {
7387 completionAttemptID = nil
7488 completionTimeoutTask? . cancel ( )
7589 completionTimeoutTask = nil
76- continuation. resume ( returning: ( audio. micAudioFileURL, audio. systemAudioFileURL) )
90+ continuation. resume ( returning: CaptureStopResult (
91+ micURL: audio. micAudioFileURL,
92+ systemURL: audio. systemAudioFileURL,
93+ didTimeOut: false
94+ ) )
7795 }
7896 if audio. isRecording { return true }
7997
@@ -109,11 +127,18 @@ final class MeetingCaptureBridge: ObservableObject {
109127 }
110128
111129 /// Stop the current recording and wait for Core's Audio to finish writing
112- /// the mic + system WAV files to disk. Returns the URLs of both files (system
113- /// may be nil if capture failed or was disabled).
114- func stopAndAwaitFiles( ) async -> ( micURL: URL ? , systemURL: URL ? ) {
130+ /// the mic + system WAV files to disk. Returns a result that distinguishes
131+ /// natural completion (`didTimeOut == false`) from `meetingStopTimeout`
132+ /// expiry (`didTimeOut == true`). On timeout the WAV header may not be
133+ /// fully patched, so the caller should treat the audio as failed-but-
134+ /// recoverable rather than enqueuing it for transcription directly.
135+ func stopAndAwaitFiles( ) async -> CaptureStopResult {
115136 guard audio. isRecording else {
116- return ( audio. micAudioFileURL, audio. systemAudioFileURL)
137+ return CaptureStopResult (
138+ micURL: audio. micAudioFileURL,
139+ systemURL: audio. systemAudioFileURL,
140+ didTimeOut: false
141+ )
117142 }
118143
119144 return await withCheckedContinuation { continuation in
@@ -142,19 +167,23 @@ final class MeetingCaptureBridge: ObservableObject {
142167 " system_file_available " : " \( self . audio. systemAudioFileURL != nil ) " ,
143168 ]
144169 )
145- continuation. resume ( returning: ( self . audio. micAudioFileURL, self . audio. systemAudioFileURL) )
170+ continuation. resume ( returning: CaptureStopResult (
171+ micURL: self . audio. micAudioFileURL,
172+ systemURL: self . audio. systemAudioFileURL,
173+ didTimeOut: true
174+ ) )
146175 }
147176 }
148177 }
149178
150179 /// Stop the current recording, wait for file handles to close, then remove
151180 /// the just-captured scratch audio instead of handing it to transcription.
152181 func stopAndDiscardFiles( ) async -> ( micURL: URL ? , systemURL: URL ? ) {
153- let files = await stopAndAwaitFiles ( )
154- MeetingRecordingCleanup . discardFiles ( micURL: files . micURL, systemURL: files . systemURL)
182+ let result = await stopAndAwaitFiles ( )
183+ MeetingRecordingCleanup . discardFiles ( micURL: result . micURL, systemURL: result . systemURL)
155184 audio. micAudioFileURL = nil
156185 audio. systemAudioFileURL = nil
157- return files
186+ return ( result . micURL , result . systemURL )
158187 }
159188
160189 /// Snapshot of Core's recording health metadata for transcript frontmatter.
@@ -227,7 +256,11 @@ final class MeetingCaptureBridge: ObservableObject {
227256 self . completionAttemptID = nil
228257 self . completionTimeoutTask? . cancel ( )
229258 self . completionTimeoutTask = nil
230- continuation? . resume ( returning: ( micURL, systemURL) )
259+ continuation? . resume ( returning: CaptureStopResult (
260+ micURL: micURL,
261+ systemURL: systemURL,
262+ didTimeOut: false
263+ ) )
231264 }
232265 }
233266 }
0 commit comments