From 299986ab0006a798bf7275969a86951340043e66 Mon Sep 17 00:00:00 2001 From: SuperDooper86 Date: Wed, 26 Aug 2026 16:43:43 +0200 Subject: [PATCH] fix: preserve transcription failures and audio diagnostics --- Meetingnotes.xcodeproj/project.pbxproj | 8 +- meetingnotes/Managers/AudioManager.swift | 208 +++++++++++++++++- .../Managers/RecordingSessionManager.swift | 13 +- meetingnotes/Models/Meeting.swift | 3 + meetingnotes/ProcessTap/ProcessTap.swift | 9 +- .../ViewModels/MeetingViewModel.swift | 19 +- meetingnotes/Views/MeetingListView.swift | 105 +++++++-- 7 files changed, 330 insertions(+), 35 deletions(-) diff --git a/Meetingnotes.xcodeproj/project.pbxproj b/Meetingnotes.xcodeproj/project.pbxproj index 7a9fc11..3041337 100644 --- a/Meetingnotes.xcodeproj/project.pbxproj +++ b/Meetingnotes.xcodeproj/project.pbxproj @@ -276,7 +276,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 40; + CURRENT_PROJECT_VERSION = 41; DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\""; DEVELOPMENT_TEAM = G9LVHZAJNX; ENABLE_HARDENED_RUNTIME = YES; @@ -290,7 +290,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.1.28; + MARKETING_VERSION = 1.1.29; ONLY_ACTIVE_ARCH = NO; OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI"; PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes; @@ -312,7 +312,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 40; + CURRENT_PROJECT_VERSION = 41; DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\""; DEVELOPMENT_TEAM = G9LVHZAJNX; ENABLE_HARDENED_RUNTIME = YES; @@ -326,7 +326,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.1.28; + MARKETING_VERSION = 1.1.29; ONLY_ACTIVE_ARCH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI"; PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes; diff --git a/meetingnotes/Managers/AudioManager.swift b/meetingnotes/Managers/AudioManager.swift index 7108892..c941fc3 100644 --- a/meetingnotes/Managers/AudioManager.swift +++ b/meetingnotes/Managers/AudioManager.swift @@ -20,6 +20,26 @@ private enum RecoveryTranscriptionError: LocalizedError { } } +private struct SystemCaptureDiagnostics { + var tapAdvertisedFormat = "unavailable" + var aggregateInputFormat = "unavailable" + var selectedInputFormat = "unavailable" + var targetFormat = "unavailable" + var selectedInputSampleRate: Double? + var targetSampleRate: Double? + var firstBufferLayout: String? + var callbackCount: UInt64 = 0 + var inputFrameCount: UInt64 = 0 + var outputFrameCount: UInt64 = 0 + var discardedCallbackCount: UInt64 = 0 + var firstSampleTime: Double? + var lastSampleTime: Double? + var firstHostTime: UInt64? + var lastHostTime: UInt64? + var firstCallbackAt: Date? + var lastCallbackAt: Date? +} + /// Captures microphone and system audio locally, then sends completed files to Coder. @MainActor final class AudioManager: NSObject, ObservableObject { @@ -50,6 +70,7 @@ final class AudioManager: NSObject, ObservableObject { private var micAudioURL: URL? private var systemAudioURL: URL? private var recordingStartedAt = Date() + private var systemDiagnostics = SystemCaptureDiagnostics() private override init() { super.init() @@ -67,6 +88,7 @@ final class AudioManager: NSObject, ObservableObject { sessionID = UUID() self.meetingID = meetingID recordingStartedAt = Date() + systemDiagnostics = SystemCaptureDiagnostics() do { try prepareAudioFiles() startMicrophoneTap() @@ -79,6 +101,7 @@ final class AudioManager: NSObject, ObservableObject { func stopRecordingAndTranscribe() async -> [TranscriptChunk] { let completedMeetingID = meetingID + let completedSessionID = sessionID let captureStartedAt = recordingStartedAt let files = stopCaptureAndCloseFiles() isProcessing = true @@ -86,11 +109,20 @@ final class AudioManager: NSObject, ObservableObject { isProcessing = false } - repairHalfDurationSystemWAVIfNeeded(in: files) + let preRepairFileSummaries = files.map(audioFileSummary) + let repairApplied = repairHalfDurationSystemWAVIfNeeded(in: files) let completedFiles = files.compactMap { $0 } let audioFolder = preserveAudioFiles(completedFiles, meetingID: completedMeetingID) let transcriptionFiles = preservedAudioFiles(files, in: audioFolder) lastRecoveryAudioFolderName = audioFolder?.lastPathComponent + writeCaptureDiagnostics( + sessionID: completedSessionID, + captureStartedAt: captureStartedAt, + preRepairFileSummaries: preRepairFileSummaries, + repairedFiles: transcriptionFiles, + repairApplied: repairApplied, + audioFolder: audioFolder + ) if let mismatch = captureDurationMismatch(in: transcriptionFiles) { let recoveryMessage = audioFolder == nil ? " The audio remains in the app's temporary folder." @@ -405,9 +437,13 @@ final class AudioManager: NSObject, ObservableObject { advertisedInputFormat.sampleRate > 0 else { throw NSError(domain: "AudioManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unsupported system audio format"]) } + systemDiagnostics.tapAdvertisedFormat = streamDescriptionSummary(tap.tapAdvertisedStreamDescription) + systemDiagnostics.aggregateInputFormat = streamDescriptionSummary(tap.aggregateInputStreamDescription) + systemDiagnostics.targetFormat = audioFormatSummary(targetFormat) + systemDiagnostics.targetSampleRate = targetFormat.sampleRate var inputFormat: AVAudioFormat? var converter: AVAudioConverter? - try tap.run(on: tapQueue) { [weak self] _, inputData, _, _, _ in + try tap.run(on: tapQueue) { [weak self] inNow, inputData, inInputTime, _, _ in guard let self else { return } if inputFormat == nil { inputFormat = self.inputFormat( @@ -415,6 +451,8 @@ final class AudioManager: NSObject, ObservableObject { advertisedFormat: advertisedInputFormat ) if let inputFormat { + self.systemDiagnostics.selectedInputFormat = self.audioFormatSummary(inputFormat) + self.systemDiagnostics.selectedInputSampleRate = inputFormat.sampleRate converter = AVAudioConverter(from: inputFormat, to: targetFormat) } } @@ -425,7 +463,9 @@ final class AudioManager: NSObject, ObservableObject { { self.copyAudioBuffer(from: inputData, format: inputFormat) }, converter: converter, targetFormat: targetFormat, - source: .system + source: .system, + callbackTimestamp: inInputTime.pointee, + ioTimestamp: inNow.pointee ) } invalidationHandler: { [weak self] _ in guard let self, self.isRecording else { return } @@ -465,6 +505,11 @@ final class AudioManager: NSObject, ObservableObject { let sourceBuffers = UnsafeMutableAudioBufferListPointer( UnsafeMutablePointer(mutating: inputData) ) + if systemDiagnostics.firstBufferLayout == nil { + systemDiagnostics.firstBufferLayout = sourceBuffers.enumerated().map { index, buffer in + "buffer\(index):channels=\(buffer.mNumberChannels),bytes=\(buffer.mDataByteSize)" + }.joined(separator: "; ") + } let frameLengths = sourceBuffers.compactMap { source -> AVAudioFrameCount? in let bytesPerFrame = Int(source.mNumberChannels) * MemoryLayout.size guard bytesPerFrame > 0, @@ -505,20 +550,44 @@ final class AudioManager: NSObject, ObservableObject { _ inputBufferProvider: () -> AVAudioPCMBuffer?, converter: AVAudioConverter, targetFormat: AVAudioFormat, - source: AudioSource + source: AudioSource, + callbackTimestamp: AudioTimeStamp? = nil, + ioTimestamp: AudioTimeStamp? = nil ) { // The system callback copies its borrowed Core Audio memory while this // lock prevents teardown, then conversion operates on the owned copy. audioFileLock.lock() defer { audioFileLock.unlock() } - guard isAcceptingAudio, - let inputBuffer = inputBufferProvider(), - inputBuffer.frameLength > 0 else { return } + guard isAcceptingAudio else { return } + if source == .system { + systemDiagnostics.callbackCount += 1 + let callbackAt = Date() + if systemDiagnostics.firstCallbackAt == nil { systemDiagnostics.firstCallbackAt = callbackAt } + systemDiagnostics.lastCallbackAt = callbackAt + if let callbackTimestamp { + if systemDiagnostics.firstSampleTime == nil { systemDiagnostics.firstSampleTime = callbackTimestamp.mSampleTime } + systemDiagnostics.lastSampleTime = callbackTimestamp.mSampleTime + } + if let ioTimestamp { + if systemDiagnostics.firstHostTime == nil { systemDiagnostics.firstHostTime = ioTimestamp.mHostTime } + systemDiagnostics.lastHostTime = ioTimestamp.mHostTime + } + } + guard let inputBuffer = inputBufferProvider(), inputBuffer.frameLength > 0 else { + if source == .system { systemDiagnostics.discardedCallbackCount += 1 } + return + } + if source == .system { + systemDiagnostics.inputFrameCount += UInt64(inputBuffer.frameLength) + } updateAudioLevel(inputBuffer, source: source) let ratio = targetFormat.sampleRate / inputBuffer.format.sampleRate let capacity = max(1, AVAudioFrameCount(ceil(Double(inputBuffer.frameLength) * ratio))) - guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: capacity) else { return } + guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: capacity) else { + if source == .system { systemDiagnostics.discardedCallbackCount += 1 } + return + } var suppliedInput = false var conversionError: NSError? let status = converter.convert(to: outputBuffer, error: &conversionError) { _, outputStatus in @@ -530,7 +599,10 @@ final class AudioManager: NSObject, ObservableObject { outputStatus.pointee = .haveData return inputBuffer } - guard status != .error, conversionError == nil, outputBuffer.frameLength > 0 else { return } + guard status != .error, conversionError == nil, outputBuffer.frameLength > 0 else { + if source == .system { systemDiagnostics.discardedCallbackCount += 1 } + return + } do { switch source { @@ -538,6 +610,7 @@ final class AudioManager: NSObject, ObservableObject { try micAudioFile?.write(from: outputBuffer) case .system: try systemAudioFile?.write(from: outputBuffer) + systemDiagnostics.outputFrameCount += UInt64(outputBuffer.frameLength) } } catch { DispatchQueue.main.async { [weak self] in @@ -631,15 +704,20 @@ final class AudioManager: NSObject, ObservableObject { return String(format: "mic %.1fs, system %.1fs", micDuration, systemDuration) } - private func repairHalfDurationSystemWAVIfNeeded(in files: [URL?]) { + private func repairHalfDurationSystemWAVIfNeeded(in files: [URL?]) -> Bool { guard files.count >= 2, let micDuration = audioDuration(at: files[0]), let systemURL = files[1], let systemDuration = audioDuration(at: systemURL), micDuration >= 60, systemURL.pathExtension.caseInsensitiveCompare("wav") == .orderedSame, - (0.48...0.52).contains(systemDuration / micDuration) else { return } - try? halveWAVSampleRate(at: systemURL) + (0.48...0.52).contains(systemDuration / micDuration) else { return false } + do { + try halveWAVSampleRate(at: systemURL) + return true + } catch { + return false + } } private func halveWAVSampleRate(at url: URL) throws { @@ -704,6 +782,112 @@ final class AudioManager: NSObject, ObservableObject { file.processingFormat.sampleRate > 0 else { return nil } return Double(file.length) / file.processingFormat.sampleRate } + + private func streamDescriptionSummary(_ description: AudioStreamBasicDescription?) -> String { + guard let description else { return "unavailable" } + return String( + format: "sampleRate=%.3f,formatID=%u,flags=%u,bytesPerPacket=%u,framesPerPacket=%u,bytesPerFrame=%u,channels=%u,bitsPerChannel=%u", + description.mSampleRate, + description.mFormatID, + description.mFormatFlags, + description.mBytesPerPacket, + description.mFramesPerPacket, + description.mBytesPerFrame, + description.mChannelsPerFrame, + description.mBitsPerChannel + ) + } + + private func audioFormatSummary(_ format: AVAudioFormat) -> String { + "sampleRate=\(format.sampleRate),channels=\(format.channelCount),commonFormat=\(format.commonFormat.rawValue),interleaved=\(format.isInterleaved)" + } + + private func audioFileSummary(_ url: URL?) -> String { + guard let url else { return "missing" } + guard let file = try? AVAudioFile(forReading: url), file.processingFormat.sampleRate > 0 else { + return "\(url.lastPathComponent):unreadable" + } + let duration = Double(file.length) / file.processingFormat.sampleRate + return String( + format: "%@:sampleRate=%.3f,channels=%u,frames=%lld,duration=%.6f", + url.lastPathComponent, + file.processingFormat.sampleRate, + file.processingFormat.channelCount, + file.length, + duration + ) + } + + private func writeCaptureDiagnostics( + sessionID: UUID, + captureStartedAt: Date, + preRepairFileSummaries: [String], + repairedFiles: [URL?], + repairApplied: Bool, + audioFolder: URL? + ) { + guard let audioFolder else { return } + let callbackDuration = systemDiagnostics.firstCallbackAt.flatMap { first in + systemDiagnostics.lastCallbackAt.map { $0.timeIntervalSince(first) } + } + let sampleTimeDelta = systemDiagnostics.firstSampleTime.flatMap { first in + systemDiagnostics.lastSampleTime.map { $0 - first } + } + let hostTimeDelta = systemDiagnostics.firstHostTime.flatMap { first in + systemDiagnostics.lastHostTime.map { $0 >= first ? $0 - first : 0 } + } + let inputFrameDuration = systemDiagnostics.selectedInputSampleRate.flatMap { sampleRate in + sampleRate > 0 ? Double(systemDiagnostics.inputFrameCount) / sampleRate : nil + } + let outputFrameDuration = systemDiagnostics.targetSampleRate.flatMap { sampleRate in + sampleRate > 0 ? Double(systemDiagnostics.outputFrameCount) / sampleRate : nil + } + let observedInputRate = callbackDuration.flatMap { duration in + duration > 0 ? Double(systemDiagnostics.inputFrameCount) / duration : nil + } + let preRepairMic = preRepairFileSummaries.indices.contains(0) ? preRepairFileSummaries[0] : "missing" + let preRepairSystem = preRepairFileSummaries.indices.contains(1) ? preRepairFileSummaries[1] : "missing" + let postRepairMic = repairedFiles.indices.contains(0) ? audioFileSummary(repairedFiles[0]) : "missing" + let postRepairSystem = repairedFiles.indices.contains(1) ? audioFileSummary(repairedFiles[1]) : "missing" + let callbackDurationLine = callbackDuration.map { String(format: "callbackWallDuration=%.6f", $0) } ?? "callbackWallDuration=unavailable" + let inputFrameDurationLine = inputFrameDuration.map { String(format: "inputFrameDurationAtSelectedRate=%.6f", $0) } ?? "inputFrameDurationAtSelectedRate=unavailable" + let outputFrameDurationLine = outputFrameDuration.map { String(format: "outputFrameDurationAtTargetRate=%.6f", $0) } ?? "outputFrameDurationAtTargetRate=unavailable" + let observedInputRateLine = observedInputRate.map { String(format: "observedInputFramesPerSecond=%.3f", $0) } ?? "observedInputFramesPerSecond=unavailable" + let sampleTimeDeltaLine = sampleTimeDelta.map { String(format: "sampleTimeDelta=%.6f", $0) } ?? "sampleTimeDelta=unavailable" + let hostTimeDeltaLine = hostTimeDelta.map { "hostTimeDelta=\($0)" } ?? "hostTimeDelta=unavailable" + let lines: [String] = [ + "Meetingnotes system capture diagnostics", + "sessionID=\(sessionID.uuidString)", + "createdAt=\(ISO8601DateFormatter().string(from: Date()))", + String(format: "captureWallDuration=%.6f", Date().timeIntervalSince(captureStartedAt)), + "tapAdvertisedFormat=\(systemDiagnostics.tapAdvertisedFormat)", + "aggregateInputFormat=\(systemDiagnostics.aggregateInputFormat)", + "selectedInputFormat=\(systemDiagnostics.selectedInputFormat)", + "targetFormat=\(systemDiagnostics.targetFormat)", + "firstBufferLayout=\(systemDiagnostics.firstBufferLayout ?? "unavailable")", + "callbackCount=\(systemDiagnostics.callbackCount)", + "inputFrameCount=\(systemDiagnostics.inputFrameCount)", + "outputFrameCount=\(systemDiagnostics.outputFrameCount)", + "discardedCallbackCount=\(systemDiagnostics.discardedCallbackCount)", + callbackDurationLine, + inputFrameDurationLine, + outputFrameDurationLine, + observedInputRateLine, + sampleTimeDeltaLine, + hostTimeDeltaLine, + "repairApplied=\(repairApplied)", + "preRepairMic=\(preRepairMic)", + "preRepairSystem=\(preRepairSystem)", + "postRepairMic=\(postRepairMic)", + "postRepairSystem=\(postRepairSystem)" + ] + let diagnosticsURL = audioFolder.appendingPathComponent("audio-diagnostics-\(sessionID.uuidString).txt") + try? lines.joined(separator: "\n").appending("\n").write( + to: diagnosticsURL, + atomically: true, + encoding: .utf8 + ) + } private func resetAudioLevels() { micAudioLevel = 0 diff --git a/meetingnotes/Managers/RecordingSessionManager.swift b/meetingnotes/Managers/RecordingSessionManager.swift index 4ffe0dd..1b4eb13 100644 --- a/meetingnotes/Managers/RecordingSessionManager.swift +++ b/meetingnotes/Managers/RecordingSessionManager.swift @@ -96,7 +96,11 @@ class RecordingSessionManager: ObservableObject { let chunks = await audioManager.stopRecordingAndTranscribe() activeRecordingTranscriptChunks = chunks activeRecordingTranscriptChunksUpdated = chunks - updateActiveMeetingTranscript(meetingId: meetingId, chunks: chunks) + updateActiveMeetingTranscript( + meetingId: meetingId, + chunks: chunks, + transcriptionError: audioManager.errorMessage + ) activeMeetingId = nil activeRecordingTranscriptChunks = [] return chunks @@ -117,7 +121,11 @@ class RecordingSessionManager: ObservableObject { audioManager.lastRecoveryAudioFolderName } - private func updateActiveMeetingTranscript(meetingId: UUID, chunks: [TranscriptChunk]) { + private func updateActiveMeetingTranscript( + meetingId: UUID, + chunks: [TranscriptChunk], + transcriptionError: String? = nil + ) { // Load all meetings var meetings = LocalStorageManager.shared.loadMeetings() @@ -127,6 +135,7 @@ class RecordingSessionManager: ObservableObject { if let recoveryAudioFolderName = lastRecoveryAudioFolderName { meetings[index].recoveryAudioFolderName = recoveryAudioFolderName } + meetings[index].transcriptionError = transcriptionError // Save the updated meeting let success = LocalStorageManager.shared.saveMeeting(meetings[index]) diff --git a/meetingnotes/Models/Meeting.swift b/meetingnotes/Models/Meeting.swift index 741986e..dd48bf4 100644 --- a/meetingnotes/Models/Meeting.swift +++ b/meetingnotes/Models/Meeting.swift @@ -99,6 +99,7 @@ struct Meeting: Codable, Identifiable, Hashable { var generatedNotes: String var templateId: UUID? // Add property to track per-meeting template var recoveryAudioFolderName: String? + var transcriptionError: String? // MARK: - Data versioning /// Version of this Meeting record on disk. Useful for migration. var dataVersion: Int @@ -113,6 +114,7 @@ struct Meeting: Codable, Identifiable, Hashable { generatedNotes: String = "", templateId: UUID? = nil, recoveryAudioFolderName: String? = nil, + transcriptionError: String? = nil, dataVersion: Int = Meeting.currentDataVersion) { self.id = id self.date = date @@ -122,6 +124,7 @@ struct Meeting: Codable, Identifiable, Hashable { self.generatedNotes = generatedNotes self.templateId = templateId self.recoveryAudioFolderName = recoveryAudioFolderName + self.transcriptionError = transcriptionError self.dataVersion = dataVersion } diff --git a/meetingnotes/ProcessTap/ProcessTap.swift b/meetingnotes/ProcessTap/ProcessTap.swift index 7ad82fc..526a2ba 100644 --- a/meetingnotes/ProcessTap/ProcessTap.swift +++ b/meetingnotes/ProcessTap/ProcessTap.swift @@ -63,6 +63,10 @@ final class ProcessTap { @ObservationIgnored private(set) var tapStreamDescription: AudioStreamBasicDescription? @ObservationIgnored + private(set) var tapAdvertisedStreamDescription: AudioStreamBasicDescription? + @ObservationIgnored + private(set) var aggregateInputStreamDescription: AudioStreamBasicDescription? + @ObservationIgnored private var invalidationHandler: InvalidationHandler? @ObservationIgnored @@ -249,6 +253,7 @@ final class ProcessTap { do { logger.debug("Attempting to read audio tap stream basic description for tapID #\(tapID)...") let advertisedDescription = try tapID.readAudioTapStreamBasicDescription() + self.tapAdvertisedStreamDescription = advertisedDescription // The aggregate device may adapt the tap to the active hardware's // sample rate. Its input stream is the format actually delivered @@ -256,7 +261,9 @@ final class ProcessTap { // advertisement. Using the latter can halve the written duration // when, for example, a 48 kHz tap is delivered at 24 kHz. do { - self.tapStreamDescription = try aggregateDeviceID.readInputStreamBasicDescription() + let aggregateDescription = try aggregateDeviceID.readInputStreamBasicDescription() + self.aggregateInputStreamDescription = aggregateDescription + self.tapStreamDescription = aggregateDescription logger.info("Using aggregate input stream description: \(String(describing: self.tapStreamDescription), privacy: .public); tap advertised: \(String(describing: advertisedDescription), privacy: .public)") } catch { self.tapStreamDescription = advertisedDescription diff --git a/meetingnotes/ViewModels/MeetingViewModel.swift b/meetingnotes/ViewModels/MeetingViewModel.swift index 94d906e..d50d8b4 100644 --- a/meetingnotes/ViewModels/MeetingViewModel.swift +++ b/meetingnotes/ViewModels/MeetingViewModel.swift @@ -142,12 +142,23 @@ class MeetingViewModel: ObservableObject { .dropFirst() .sink { [weak self] updatedChunks in guard let self = self else { return } - // Only update if this meeting is the active recording - if recordingSessionManager.isRecordingMeeting(self.meeting.id) { + // Stop publishes the final chunks after capture has ended but + // before the active meeting is cleared. + if recordingSessionManager.activeMeetingId == self.meeting.id { self.meeting.transcriptChunks = updatedChunks } } .store(in: &cancellables) + + NotificationCenter.default.publisher(for: .meetingSaved) + .compactMap { $0.object as? Meeting } + .filter { [weak self] in $0.id == self?.meeting.id } + .sink { [weak self] savedMeeting in + guard let self, !self.isDeleted, self.meeting != savedMeeting else { return } + self.meeting = savedMeeting + self.refreshRecoveryAudioFolder() + } + .store(in: &cancellables) @@ -215,6 +226,7 @@ class MeetingViewModel: ObservableObject { let chunks = await recordingSessionManager.stopRecording() meeting.transcriptChunks = chunks meeting.recoveryAudioFolderName = recordingSessionManager.lastRecoveryAudioFolderName + meeting.transcriptionError = recordingSessionManager.errorMessage refreshRecoveryAudioFolder() saveMeeting() if !meeting.formattedTranscript.isEmpty { @@ -238,6 +250,7 @@ class MeetingViewModel: ObservableObject { ) meeting.transcriptChunks = chunks meeting.recoveryAudioFolderName = recoveryAudioFolderURL.lastPathComponent + meeting.transcriptionError = nil selectedTab = .transcript guard saveMeeting() else { @@ -247,6 +260,8 @@ class MeetingViewModel: ObservableObject { await generateNotes() } catch { errorMessage = error.localizedDescription + meeting.transcriptionError = error.localizedDescription + _ = saveMeeting() print("Retry transcription failed: \(error)") } } diff --git a/meetingnotes/Views/MeetingListView.swift b/meetingnotes/Views/MeetingListView.swift index 7f94cb5..e1aef5e 100644 --- a/meetingnotes/Views/MeetingListView.swift +++ b/meetingnotes/Views/MeetingListView.swift @@ -6,6 +6,8 @@ struct MeetingListView: View { @StateObject private var recordingSessionManager = RecordingSessionManager.shared @State private var selectedMeeting: Meeting? @State private var navigationPath = NavigationPath() + @State private var recordingFailureMessage: String? + @State private var failedMeetingID: UUID? var body: some View { NavigationSplitView { @@ -23,6 +25,38 @@ struct MeetingListView: View { .background(Color.clear) } } + .onReceive(recordingSessionManager.$activeMeetingId.compactMap { $0 }) { meetingID in + if let meeting = LocalStorageManager.shared.loadMeetings().first(where: { $0.id == meetingID }) { + selectedMeeting = meeting + } + } + .onReceive(NotificationCenter.default.publisher(for: .meetingSaved)) { notification in + guard let savedMeeting = notification.object as? Meeting, + selectedMeeting?.id == savedMeeting.id else { return } + selectedMeeting = savedMeeting + } + .onReceive(recordingSessionManager.$errorMessage.compactMap { $0 }) { message in + failedMeetingID = recordingSessionManager.activeMeetingId + recordingFailureMessage = message + } + .alert("Transcription Failed", isPresented: Binding( + get: { recordingFailureMessage != nil }, + set: { if !$0 { recordingFailureMessage = nil } } + )) { + if failedMeetingID != nil { + Button("View Meeting") { + selectFailedMeeting() + recordingFailureMessage = nil + recordingSessionManager.errorMessage = nil + } + } + Button("OK") { + recordingFailureMessage = nil + recordingSessionManager.errorMessage = nil + } + } message: { + Text(recordingFailureMessage ?? "") + } } private var sidebarContent: some View { @@ -161,6 +195,14 @@ struct MeetingListView: View { return DayGroup(day: dayString, date: date, meetings: meetings.sorted { $0.date > $1.date }) }.sorted { $0.date > $1.date } } + + private func selectFailedMeeting() { + guard let failedMeetingID, + let meeting = LocalStorageManager.shared.loadMeetings().first(where: { $0.id == failedMeetingID }) else { + return + } + selectedMeeting = meeting + } } struct DayGroup { @@ -182,7 +224,14 @@ struct MeetingRowView: View { .foregroundColor(.red) .font(.headline) } - Text(meeting.title.isEmpty ? "Untitled meeting" : meeting.title) + if meeting.transcriptionError != nil { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + .accessibilityLabel("Transcription failed") + } + Text(meeting.title.isEmpty + ? (meeting.transcriptionError == nil ? "Untitled meeting" : "Transcription failed") + : meeting.title) .font(.headline) .lineLimit(1) } @@ -542,24 +591,52 @@ struct MeetingDetailContentView: View { } private var transcriptView: some View { - ScrollView { - if viewModel.meeting.collapsedTranscriptChunks.isEmpty { - Text("Transcript will appear here...") - .frame(maxWidth: .infinity, alignment: .leading) - .padding() - .foregroundColor(.secondary) - } else { - LazyVStack(alignment: .leading, spacing: 4) { - ForEach(viewModel.meeting.collapsedTranscriptChunks) { chunk in - CollapsedTranscriptChunkView(chunk: chunk) + VStack(alignment: .leading, spacing: 10) { + if let transcriptionError = viewModel.meeting.transcriptionError { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + VStack(alignment: .leading, spacing: 4) { + Text("Transcription failed") + .font(.headline) + Text(transcriptionError) + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + if viewModel.recoveryAudioFolderURL != nil { + Button("Retry") { + viewModel.retryTranscription() + } + .disabled(!viewModel.canRetryTranscription) } } .padding() + .background(Color.orange.opacity(0.08)) + .cornerRadius(8) } + + ScrollView { + if viewModel.meeting.collapsedTranscriptChunks.isEmpty { + Text(viewModel.meeting.transcriptionError == nil + ? "Transcript will appear here..." + : "No transcript was produced. The recording was kept and can be retried.") + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .foregroundColor(.secondary) + } else { + LazyVStack(alignment: .leading, spacing: 4) { + ForEach(viewModel.meeting.collapsedTranscriptChunks) { chunk in + CollapsedTranscriptChunkView(chunk: chunk) + } + } + .padding() + } + } + .frame(maxHeight: .infinity) + .background(Color.gray.opacity(0.05)) + .cornerRadius(8) } - .frame(maxHeight: .infinity) - .background(Color.gray.opacity(0.05)) - .cornerRadius(8) } private var enhancedNotesView: some View {