diff --git a/Sources/TranscriptedCore/Audio/SCKAudioCapture.swift b/Sources/TranscriptedCore/Audio/SCKAudioCapture.swift index 375e6f13..9d91c4b3 100644 --- a/Sources/TranscriptedCore/Audio/SCKAudioCapture.swift +++ b/Sources/TranscriptedCore/Audio/SCKAudioCapture.swift @@ -113,7 +113,7 @@ final class SCKAudioCapture: ObservableObject, SystemAudioCaptureEngine, @unchec } self.bufferCallback = bufferCallback - errorMessage = nil + publishErrorMessage(nil) // Output handler converts CMSampleBuffer → AVAudioPCMBuffer let output = SCKAudioStreamOutput { [weak self] buffer in @@ -148,7 +148,7 @@ final class SCKAudioCapture: ObservableObject, SystemAudioCaptureEngine, @unchec } catch { AppLogger.audioSystem.error("SCKAudioCapture: start failed", ["error": error.localizedDescription]) cleanup() - DispatchQueue.main.async { self.errorMessage = error.localizedDescription } + publishErrorMessage(error.localizedDescription) throw error } } @@ -213,6 +213,16 @@ final class SCKAudioCapture: ObservableObject, SystemAudioCaptureEngine, @unchec _buffersWithData = 0 statsLock.unlock() } + + private func publishErrorMessage(_ message: String?) { + if Thread.isMainThread { + errorMessage = message + } else { + DispatchQueue.main.async { [weak self] in + self?.errorMessage = message + } + } + } } // MARK: - SCStream Audio Output diff --git a/Sources/TranscriptedCore/Audio/SystemAudioCapture.swift b/Sources/TranscriptedCore/Audio/SystemAudioCapture.swift index c8748b4f..28a89628 100644 --- a/Sources/TranscriptedCore/Audio/SystemAudioCapture.swift +++ b/Sources/TranscriptedCore/Audio/SystemAudioCapture.swift @@ -194,7 +194,7 @@ class SystemAudioCapture: ObservableObject, SystemAudioCaptureEngine, @unchecked } self.bufferCallback = bufferCallback - errorMessage = nil + publishErrorMessage(nil) do { // Setup tap if not already prepared @@ -216,7 +216,7 @@ class SystemAudioCapture: ObservableObject, SystemAudioCaptureEngine, @unchecked let errMsg = "Failed to start system audio capture: \(error.localizedDescription)" AppLogger.audioSystem.error("Start failed", ["error": errMsg]) cleanup() - errorMessage = errMsg + publishErrorMessage(errMsg) throw error } } @@ -282,6 +282,16 @@ class SystemAudioCapture: ObservableObject, SystemAudioCaptureEngine, @unchecked cleanup() } + private func publishErrorMessage(_ message: String?) { + if Thread.isMainThread { + errorMessage = message + } else { + DispatchQueue.main.async { [weak self] in + self?.errorMessage = message + } + } + } + deinit { stopWatchdog() cleanup() diff --git a/Sources/TranscriptedCore/Services/FailedTranscriptionManager.swift b/Sources/TranscriptedCore/Services/FailedTranscriptionManager.swift index 17b2ae54..dc510127 100644 --- a/Sources/TranscriptedCore/Services/FailedTranscriptionManager.swift +++ b/Sources/TranscriptedCore/Services/FailedTranscriptionManager.swift @@ -28,7 +28,6 @@ public class FailedTranscriptionManager: ObservableObject { self.allowedAudioRoots = [ paths.audioCaptures, paths.transcripts - .deletingLastPathComponent() .appendingPathComponent("audio", isDirectory: true), ].map(Self.canonicalDirectoryURL) diff --git a/Tests/TranscriptedCoreTests/FailedTranscriptionManagerTests.swift b/Tests/TranscriptedCoreTests/FailedTranscriptionManagerTests.swift index ddf28493..083ee9fe 100644 --- a/Tests/TranscriptedCoreTests/FailedTranscriptionManagerTests.swift +++ b/Tests/TranscriptedCoreTests/FailedTranscriptionManagerTests.swift @@ -120,6 +120,46 @@ final class FailedTranscriptionManagerTests: XCTestCase { XCTAssertFalse(FileManager.default.fileExists(atPath: paths.failedQueue.path)) } + func testAddFailedTranscriptionAcceptsRetainedMeetingAudioArchivePaths() throws { + let paths = makePaths(root: testRoot) + let archiveRoot = paths.transcripts.appendingPathComponent("audio", isDirectory: true) + let archivedDirectory = archiveRoot.appendingPathComponent("Failed_Call_audio", isDirectory: true) + try FileManager.default.createDirectory(at: archivedDirectory, withIntermediateDirectories: true) + + let archivedMicURL = archivedDirectory.appendingPathComponent("microphone.wav") + let archivedSystemURL = archivedDirectory.appendingPathComponent("system_audio.wav") + FileManager.default.createFile(atPath: archivedMicURL.path, contents: Data("mic".utf8)) + FileManager.default.createFile(atPath: archivedSystemURL.path, contents: Data("system".utf8)) + + let manager = FailedTranscriptionManager(paths: paths) + manager.addFailedTranscription( + micAudioURL: archivedMicURL, + systemAudioURL: archivedSystemURL, + errorMessage: "Temporary transcription failure" + ) + + XCTAssertEqual(manager.failedTranscriptions.count, 1) + XCTAssertEqual(manager.failedTranscriptions.first?.micAudioURL, archivedMicURL) + XCTAssertEqual(manager.failedTranscriptions.first?.systemAudioURL, archivedSystemURL) + + let siblingArchiveURL = paths.transcripts + .deletingLastPathComponent() + .appendingPathComponent("audio/sibling.wav") + try FileManager.default.createDirectory( + at: siblingArchiveURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + FileManager.default.createFile(atPath: siblingArchiveURL.path, contents: Data("sibling".utf8)) + + manager.addFailedTranscription( + micAudioURL: siblingArchiveURL, + systemAudioURL: nil, + errorMessage: "Temporary transcription failure" + ) + + XCTAssertEqual(manager.failedTranscriptions.count, 1) + } + func testFailedTranscriptionRetryabilityDoesNotOvermatchGenericMinimumLanguage() { let failure = FailedTranscription( micAudioURL: testRoot.appendingPathComponent("mic.wav"),