From 628587ae92de428473a7604082ee0d3d405dca1b Mon Sep 17 00:00:00 2001 From: superdooper86 Date: Mon, 20 Jul 2026 13:13:15 +0200 Subject: [PATCH] feat: retry failed transcriptions --- Meetingnotes.xcodeproj/project.pbxproj | 8 +- meetingnotes/Managers/AudioManager.swift | 117 ++++++++++++++---- .../Managers/LocalStorageManager.swift | 101 +++++++++++++++ .../Managers/RecordingSessionManager.swift | 4 + meetingnotes/Models/Meeting.swift | 3 + meetingnotes/Services/LocalAPIServer.swift | 1 + .../ViewModels/MeetingViewModel.swift | 58 ++++++++- meetingnotes/Views/MeetingListView.swift | 11 ++ 8 files changed, 270 insertions(+), 33 deletions(-) diff --git a/Meetingnotes.xcodeproj/project.pbxproj b/Meetingnotes.xcodeproj/project.pbxproj index a91855a..a96d641 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 = 23; + CURRENT_PROJECT_VERSION = 24; 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.11; + MARKETING_VERSION = 1.1.12; 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 = 23; + CURRENT_PROJECT_VERSION = 24; 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.11; + MARKETING_VERSION = 1.1.12; 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 007c6ff..10b6df9 100644 --- a/meetingnotes/Managers/AudioManager.swift +++ b/meetingnotes/Managers/AudioManager.swift @@ -3,6 +3,23 @@ import Combine import Foundation import SwiftUI +private enum RecoveryTranscriptionError: LocalizedError { + case noAudioFiles + case noSpeech + case requestFailed(String) + + var errorDescription: String? { + switch self { + case .noAudioFiles: + return "The saved recovery audio could not be found." + case .noSpeech: + return "No speech was detected in the saved recovery audio." + case .requestFailed(let details): + return "Retry transcription failed for \(details)" + } + } +} + /// Captures microphone and system audio locally, then sends completed files to Coder. @MainActor final class AudioManager: NSObject, ObservableObject { @@ -14,6 +31,7 @@ final class AudioManager: NSObject, ObservableObject { @Published var errorMessage: String? @Published var micAudioLevel: Float = 0 @Published var systemAudioLevel: Float = 0 + private(set) var lastRecoveryAudioFolderName: String? private var audioEngine = AVAudioEngine() private var sessionID = UUID() @@ -55,6 +73,7 @@ final class AudioManager: NSObject, ObservableObject { func startRecording() { errorMessage = nil + lastRecoveryAudioFolderName = nil cancelCapture(removeFiles: true) sessionID = UUID() recordingStartedAt = Date() @@ -83,7 +102,77 @@ final class AudioManager: NSObject, ObservableObject { let (micTranscription, systemTranscription) = await (micResult, systemResult) let results = [micTranscription, systemTranscription] - var updated = transcriptChunks.filter(\.isFinal) + let (updated, failures) = buildTranscriptChunks( + from: results, + captureStartedAt: captureStartedAt, + existingChunks: transcriptChunks.filter(\.isFinal) + ) + transcriptChunks = updated + let completedFiles = files.compactMap { $0 } + if failures.isEmpty { + lastRecoveryAudioFolderName = nil + removeAudioFiles(completedFiles) + } else { + let recoveryFolder = preserveAudioFiles(completedFiles, sessionID: completedSessionID) + lastRecoveryAudioFolderName = recoveryFolder?.lastPathComponent + let recoveryMessage = recoveryFolder == nil + ? " The audio remains in the app's temporary folder." + : " Audio was saved in Documents/Meetingnotes-Recovery/\(completedSessionID.uuidString)." + errorMessage = "Transcription failed for " + failures.joined(separator: "; ") + recoveryMessage + } + return updated + } + + func transcribeRecoveryAudio(in folder: URL, captureStartedAt: Date) async throws -> [TranscriptChunk] { + let recoveryFiles = LocalStorageManager.shared.recoveryAudioFiles(in: folder) + guard !recoveryFiles.isEmpty else { + throw RecoveryTranscriptionError.noAudioFiles + } + + isProcessing = true + defer { isProcessing = false } + let model = UserDefaultsManager.shared.transcriptionModel + let micURL = recoveryFiles.first(where: { $0.source == .mic })?.url + let systemURL = recoveryFiles.first(where: { $0.source == .system })?.url + async let micResult = transcribe(micURL, model: model) + async let systemResult = transcribe(systemURL, model: model) + let (micTranscription, systemTranscription) = await (micResult, systemResult) + let results = [micTranscription, systemTranscription] + let (chunks, failures) = buildTranscriptChunks( + from: results, + captureStartedAt: captureStartedAt, + existingChunks: [] + ) + + if !failures.isEmpty { + throw RecoveryTranscriptionError.requestFailed(failures.joined(separator: "; ")) + } + guard !chunks.isEmpty else { + throw RecoveryTranscriptionError.noSpeech + } + return chunks + } + + func cancelRecording() { + cancelCapture(removeFiles: true) + lastRecoveryAudioFolderName = nil + } + + private func transcribe(_ fileURL: URL?, model: String) async -> Result? { + guard let fileURL else { return nil } + do { + return .success(try await CoderAPIClient.shared.transcribe(fileURL: fileURL, model: model)) + } catch { + return .failure(error) + } + } + + private func buildTranscriptChunks( + from results: [Result?], + captureStartedAt: Date, + existingChunks: [TranscriptChunk] + ) -> ([TranscriptChunk], [String]) { + var updated = existingChunks var failures: [String] = [] for (source, result) in zip([AudioSource.mic, .system], results) { guard let result else { continue } @@ -114,31 +203,7 @@ final class AudioManager: NSObject, ObservableObject { if $0.timestamp != $1.timestamp { return $0.timestamp < $1.timestamp } return $0.source.rawValue < $1.source.rawValue } - transcriptChunks = updated - let completedFiles = files.compactMap { $0 } - if failures.isEmpty { - removeAudioFiles(completedFiles) - } else { - let recoveryFolder = preserveAudioFiles(completedFiles, sessionID: completedSessionID) - let recoveryMessage = recoveryFolder == nil - ? " The audio remains in the app's temporary folder." - : " Audio was saved in Documents/Meetingnotes-Recovery/\(completedSessionID.uuidString)." - errorMessage = "Transcription failed for " + failures.joined(separator: "; ") + recoveryMessage - } - return updated - } - - func cancelRecording() { - cancelCapture(removeFiles: true) - } - - private func transcribe(_ fileURL: URL?, model: String) async -> Result? { - guard let fileURL else { return nil } - do { - return .success(try await CoderAPIClient.shared.transcribe(fileURL: fileURL, model: model)) - } catch { - return .failure(error) - } + return (updated, failures) } private func prepareAudioFiles() throws { diff --git a/meetingnotes/Managers/LocalStorageManager.swift b/meetingnotes/Managers/LocalStorageManager.swift index c190ab4..a8cdb86 100644 --- a/meetingnotes/Managers/LocalStorageManager.swift +++ b/meetingnotes/Managers/LocalStorageManager.swift @@ -15,6 +15,7 @@ class LocalStorageManager { private let documentsDirectory: URL private let meetingsDirectory: URL private let templatesDirectory: URL + private let recoveryDirectory: URL private init() { // Get the app's documents directory @@ -26,12 +27,16 @@ class LocalStorageManager { // Create templates subdirectory templatesDirectory = documentsDirectory.appendingPathComponent("Templates") + + recoveryDirectory = documentsDirectory.appendingPathComponent("Meetingnotes-Recovery") // Ensure directories exist try? FileManager.default.createDirectory(at: meetingsDirectory, withIntermediateDirectories: true) try? FileManager.default.createDirectory(at: templatesDirectory, withIntermediateDirectories: true) + try? FileManager.default.createDirectory(at: recoveryDirectory, + withIntermediateDirectories: true) } // MARK: - Meeting Management @@ -144,6 +149,102 @@ class LocalStorageManager { } } + // MARK: - Recovery Audio + + func recoveryAudioFolder(named name: String) -> URL? { + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedName.isEmpty, + URL(fileURLWithPath: trimmedName).lastPathComponent == trimmedName else { + return nil + } + let folder = recoveryDirectory.appendingPathComponent(trimmedName, isDirectory: true) + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: folder.path, isDirectory: &isDirectory), + isDirectory.boolValue, + !recoveryAudioFiles(in: folder).isEmpty else { + return nil + } + return folder + } + + func recoveryAudioFiles(in folder: URL) -> [(url: URL, source: AudioSource)] { + guard let files = try? FileManager.default.contentsOfDirectory( + at: folder, + includingPropertiesForKeys: [.creationDateKey, .contentModificationDateKey, .isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { + return [] + } + + return files.compactMap { url in + let name = url.lastPathComponent.lowercased() + guard ["m4a", "mp3", "wav", "flac", "webm", "mp4"].contains(url.pathExtension.lowercased()) else { + return nil + } + if name.contains("-mic.") { + return (url, .mic) + } + if name.contains("-system.") { + return (url, .system) + } + return nil + } + } + + func findRecoveryAudioFolder(for meeting: Meeting) -> URL? { + if let name = meeting.recoveryAudioFolderName, + let folder = recoveryAudioFolder(named: name) { + return folder + } + + let claimedFolderNames = Set( + loadMeetings() + .filter { $0.id != meeting.id } + .compactMap(\.recoveryAudioFolderName) + ) + guard let folders = try? FileManager.default.contentsOfDirectory( + at: recoveryDirectory, + includingPropertiesForKeys: [.isDirectoryKey, .creationDateKey, .contentModificationDateKey], + options: [.skipsHiddenFiles] + ) else { + return nil + } + + let candidates = folders.compactMap { folder -> (url: URL, distance: TimeInterval)? in + let folderValues = try? folder.resourceValues( + forKeys: [.isDirectoryKey, .creationDateKey, .contentModificationDateKey] + ) + let files = recoveryAudioFiles(in: folder) + guard folderValues?.isDirectory == true, + !claimedFolderNames.contains(folder.lastPathComponent), + !files.isEmpty else { + return nil + } + let dates = files.compactMap { file -> Date? in + let values = try? file.url.resourceValues(forKeys: [.creationDateKey, .contentModificationDateKey]) + return values?.creationDate ?? values?.contentModificationDate + } + let referenceDate = dates.min() + ?? folderValues?.creationDate + ?? folderValues?.contentModificationDate + guard let referenceDate else { return nil } + return (folder, abs(referenceDate.timeIntervalSince(meeting.date))) + } + + // This fallback links recovery files created by older app versions. + return candidates + .filter { $0.distance <= 12 * 60 * 60 } + .min(by: { $0.distance < $1.distance })? + .url + } + + func deleteRecoveryAudioFolder(_ folder: URL) { + guard folder.deletingLastPathComponent().standardizedFileURL == recoveryDirectory.standardizedFileURL else { + return + } + try? FileManager.default.removeItem(at: folder) + } + /// Imports meeting JSON files from a folder selected by the user. func importMeetings(from directory: URL) throws -> MeetingImportResult { let didStartAccess = directory.startAccessingSecurityScopedResource() diff --git a/meetingnotes/Managers/RecordingSessionManager.swift b/meetingnotes/Managers/RecordingSessionManager.swift index 2269a8e..a958c10 100644 --- a/meetingnotes/Managers/RecordingSessionManager.swift +++ b/meetingnotes/Managers/RecordingSessionManager.swift @@ -112,6 +112,10 @@ class RecordingSessionManager: ObservableObject { func isRecordingMeeting(_ meetingId: UUID) -> Bool { return isRecording && activeMeetingId == meetingId } + + var lastRecoveryAudioFolderName: String? { + audioManager.lastRecoveryAudioFolderName + } private func updateActiveMeetingTranscript(meetingId: UUID, chunks: [TranscriptChunk]) { // Load all meetings diff --git a/meetingnotes/Models/Meeting.swift b/meetingnotes/Models/Meeting.swift index d43bc96..3de19db 100644 --- a/meetingnotes/Models/Meeting.swift +++ b/meetingnotes/Models/Meeting.swift @@ -82,6 +82,7 @@ struct Meeting: Codable, Identifiable, Hashable { var userNotes: String var generatedNotes: String var templateId: UUID? // Add property to track per-meeting template + var recoveryAudioFolderName: String? // MARK: - Data versioning /// Version of this Meeting record on disk. Useful for migration. var dataVersion: Int @@ -95,6 +96,7 @@ struct Meeting: Codable, Identifiable, Hashable { userNotes: String = "", generatedNotes: String = "", templateId: UUID? = nil, + recoveryAudioFolderName: String? = nil, dataVersion: Int = Meeting.currentDataVersion) { self.id = id self.date = date @@ -103,6 +105,7 @@ struct Meeting: Codable, Identifiable, Hashable { self.userNotes = userNotes self.generatedNotes = generatedNotes self.templateId = templateId + self.recoveryAudioFolderName = recoveryAudioFolderName self.dataVersion = dataVersion } diff --git a/meetingnotes/Services/LocalAPIServer.swift b/meetingnotes/Services/LocalAPIServer.swift index c7c33d2..a6e966f 100644 --- a/meetingnotes/Services/LocalAPIServer.swift +++ b/meetingnotes/Services/LocalAPIServer.swift @@ -286,6 +286,7 @@ private final class LocalRecordingController { } meeting.transcriptChunks = chunks + meeting.recoveryAudioFolderName = recordingManager.lastRecoveryAudioFolderName let templates = LocalStorageManager.shared.loadTemplates() if meeting.templateId == nil { meeting.templateId = LocalStorageManager.shared.preferredTemplateID(in: templates) diff --git a/meetingnotes/ViewModels/MeetingViewModel.swift b/meetingnotes/ViewModels/MeetingViewModel.swift index 13f1ed5..a3ea4af 100644 --- a/meetingnotes/ViewModels/MeetingViewModel.swift +++ b/meetingnotes/ViewModels/MeetingViewModel.swift @@ -25,6 +25,8 @@ class MeetingViewModel: ObservableObject { @Published private var recordingStateChanged = false // Trigger SwiftUI updates @Published var isValidatingKey = false // Indicates API key validation in progress @Published var isStartingRecording = false // Indicates recording start in progress + @Published var isRetryingTranscription = false + @Published private(set) var recoveryAudioFolderURL: URL? // Computed property to determine if Generate button should animate var shouldAnimateGenerateButton: Bool { @@ -44,7 +46,13 @@ class MeetingViewModel: ObservableObject { } var isProcessing: Bool { - return recordingSessionManager.isProcessing && recordingSessionManager.activeMeetingId == meeting.id + return isRetryingTranscription || + (recordingSessionManager.isProcessing && recordingSessionManager.activeMeetingId == meeting.id) + } + + var canRetryTranscription: Bool { + recoveryAudioFolderURL != nil && + !isRecording && !isProcessing && !isStartingRecording && !isValidatingKey } @Published var selectedTab: MeetingViewTab = .transcript // Default to transcript tab @@ -76,6 +84,7 @@ class MeetingViewModel: ObservableObject { // Load templates and selected template loadTemplates() + refreshRecoveryAudioFolder() // Observe template selection: save to meeting and regenerate notes on changes (skip initial) $selectedTemplateId .dropFirst() @@ -205,6 +214,8 @@ class MeetingViewModel: ObservableObject { Task { let chunks = await recordingSessionManager.stopRecording() meeting.transcriptChunks = chunks + meeting.recoveryAudioFolderName = recordingSessionManager.lastRecoveryAudioFolderName + refreshRecoveryAudioFolder() saveMeeting() if !meeting.formattedTranscript.isEmpty { await generateNotes() @@ -212,6 +223,45 @@ class MeetingViewModel: ObservableObject { isStartingRecording = false } } + + func retryTranscription() { + guard let recoveryAudioFolderURL, canRetryTranscription else { return } + + isRetryingTranscription = true + errorMessage = nil + Task { + defer { isRetryingTranscription = false } + do { + let chunks = try await AudioManager.shared.transcribeRecoveryAudio( + in: recoveryAudioFolderURL, + captureStartedAt: meeting.date + ) + meeting.transcriptChunks = chunks + meeting.recoveryAudioFolderName = recoveryAudioFolderURL.lastPathComponent + selectedTab = .transcript + + guard saveMeeting() else { + throw CocoaError(.fileWriteUnknown) + } + + LocalStorageManager.shared.deleteRecoveryAudioFolder(recoveryAudioFolderURL) + meeting.recoveryAudioFolderName = nil + self.recoveryAudioFolderURL = nil + saveMeeting() + await generateNotes() + } catch { + errorMessage = error.localizedDescription + print("Retry transcription failed: \(error)") + } + } + } + + private func refreshRecoveryAudioFolder() { + recoveryAudioFolderURL = LocalStorageManager.shared.findRecoveryAudioFolder(for: meeting) + if let recoveryAudioFolderURL { + meeting.recoveryAudioFolderName = recoveryAudioFolderURL.lastPathComponent + } + } func loadTemplates() { templates = LocalStorageManager.shared.loadTemplates() @@ -284,14 +334,16 @@ class MeetingViewModel: ObservableObject { } } - func saveMeeting() { - if isDeleted { return } + @discardableResult + func saveMeeting() -> Bool { + if isDeleted { return false } print("💾 Saving meeting: \(meeting.id)") let success = LocalStorageManager.shared.saveMeeting(meeting) print("💾 Save result: \(success ? "SUCCESS" : "FAILED")") if success { NotificationCenter.default.post(name: .meetingSaved, object: meeting) } + return success } func copyCurrentTabContent() { diff --git a/meetingnotes/Views/MeetingListView.swift b/meetingnotes/Views/MeetingListView.swift index c5f5de5..6b5a18c 100644 --- a/meetingnotes/Views/MeetingListView.swift +++ b/meetingnotes/Views/MeetingListView.swift @@ -259,6 +259,17 @@ struct MeetingDetailContentView: View { // Ellipsis menu Menu { + if viewModel.recoveryAudioFolderURL != nil { + Button { + viewModel.retryTranscription() + } label: { + Label("Retry Transcription", systemImage: "arrow.clockwise") + } + .disabled(!viewModel.canRetryTranscription) + + Divider() + } + Button("Delete Meeting", role: .destructive) { showDeleteAlert = true }