From 43e3a6c833bef67e770044706ce6f3a6e58cbbf2 Mon Sep 17 00:00:00 2001 From: r3dbars Date: Mon, 4 May 2026 14:36:41 -0500 Subject: [PATCH 1/5] Add meeting audio storage retention --- .../Meeting/MeetingAudioStorageManager.swift | 178 ++++++++++++++++++ .../Meeting/MeetingSessionController.swift | 4 + Sources/Support/AudioStoragePreferences.swift | 66 +++++++ .../Settings/TranscriptedSettingsView.swift | 49 +++++ Tests/AudioStoragePreferencesTests.swift | 47 +++++ Tests/FastTests.manifest | 2 + Tests/MeetingAudioStorageManagerTests.swift | 123 ++++++++++++ docs/storage-paths.md | 6 + scripts/entrypoints/run-tests.sh | 2 + 9 files changed, 477 insertions(+) create mode 100644 Sources/Meeting/MeetingAudioStorageManager.swift create mode 100644 Sources/Support/AudioStoragePreferences.swift create mode 100644 Tests/AudioStoragePreferencesTests.swift create mode 100644 Tests/MeetingAudioStorageManagerTests.swift diff --git a/Sources/Meeting/MeetingAudioStorageManager.swift b/Sources/Meeting/MeetingAudioStorageManager.swift new file mode 100644 index 00000000..f84edf92 --- /dev/null +++ b/Sources/Meeting/MeetingAudioStorageManager.swift @@ -0,0 +1,178 @@ +import AVFoundation +import Foundation + +protocol MeetingAudioFileConverting { + func convertWAVToM4A(sourceURL: URL, destinationURL: URL) async throws +} + +struct AVFoundationMeetingAudioConverter: MeetingAudioFileConverting { + func convertWAVToM4A(sourceURL: URL, destinationURL: URL) async throws { + let asset = AVURLAsset(url: sourceURL) + guard let session = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetAppleM4A) else { + throw MeetingAudioStorageError.exportSessionUnavailable + } + + session.shouldOptimizeForNetworkUse = false + + try await session.export(to: destinationURL, as: .m4a) + } +} + +enum MeetingAudioStorageError: Error { + case exportSessionUnavailable + case conversionFailed + case emptyConvertedFile +} + +enum MeetingAudioStorageManager { + static func processSavedTranscript( + at transcriptURL: URL, + retentionWindow: AudioRetentionWindow = AudioStoragePreferences.deleteAudioAfter(), + now: Date = Date(), + fileManager: FileManager = .default, + converter: MeetingAudioFileConverting = AVFoundationMeetingAudioConverter() + ) async { + let audioDirectory = audioDirectoryURL(forTranscript: transcriptURL) + await compressWAVAudio( + in: audioDirectory, + fileManager: fileManager, + converter: converter + ) + + pruneRetainedAudio( + in: transcriptURL.deletingLastPathComponent(), + retentionWindow: retentionWindow, + now: now, + fileManager: fileManager + ) + } + + @discardableResult + static func pruneRetainedAudio( + in meetingsFolder: URL, + retentionWindow: AudioRetentionWindow = AudioStoragePreferences.deleteAudioAfter(), + now: Date = Date(), + fileManager: FileManager = .default + ) -> Int { + guard let days = retentionWindow.days else { return 0 } + guard let cutoff = Calendar.current.date(byAdding: .day, value: -days, to: now) else { return 0 } + + let audioRoot = meetingsFolder.appendingPathComponent("audio", isDirectory: true) + guard let directories = try? fileManager.contentsOfDirectory( + at: audioRoot, + includingPropertiesForKeys: [.isDirectoryKey, .contentModificationDateKey], + options: [.skipsHiddenFiles] + ) else { + return 0 + } + + var removedCount = 0 + for directory in directories where isAudioArchiveDirectory(directory, fileManager: fileManager) { + let referenceDate = transcriptDate( + forAudioDirectory: directory, + meetingsFolder: meetingsFolder, + fileManager: fileManager + ) ?? modificationDate(for: directory) + + guard let referenceDate, referenceDate < cutoff else { continue } + + do { + try fileManager.removeItem(at: directory) + removedCount += 1 + } catch { + continue + } + } + + return removedCount + } + + @discardableResult + static func compressWAVAudio( + in audioDirectory: URL, + fileManager: FileManager = .default, + converter: MeetingAudioFileConverting = AVFoundationMeetingAudioConverter() + ) async -> Int { + guard let files = try? fileManager.contentsOfDirectory( + at: audioDirectory, + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], + options: [.skipsHiddenFiles] + ) else { + return 0 + } + + var convertedCount = 0 + for sourceURL in files where isWAVFile(sourceURL, fileManager: fileManager) { + let destinationURL = sourceURL.deletingPathExtension().appendingPathExtension("m4a") + + if hasNonEmptyFile(at: destinationURL, fileManager: fileManager) { + try? fileManager.removeItem(at: sourceURL) + continue + } + + let tempURL = audioDirectory + .appendingPathComponent(".\(sourceURL.deletingPathExtension().lastPathComponent)-\(UUID().uuidString)") + .appendingPathExtension("m4a") + + do { + try await converter.convertWAVToM4A(sourceURL: sourceURL, destinationURL: tempURL) + guard hasNonEmptyFile(at: tempURL, fileManager: fileManager) else { + throw MeetingAudioStorageError.emptyConvertedFile + } + if fileManager.fileExists(atPath: destinationURL.path) { + try fileManager.removeItem(at: destinationURL) + } + try fileManager.moveItem(at: tempURL, to: destinationURL) + try fileManager.removeItem(at: sourceURL) + convertedCount += 1 + } catch { + try? fileManager.removeItem(at: tempURL) + continue + } + } + + return convertedCount + } + + private static func audioDirectoryURL(forTranscript transcriptURL: URL) -> URL { + transcriptURL + .deletingLastPathComponent() + .appendingPathComponent("audio", isDirectory: true) + .appendingPathComponent("\(transcriptURL.deletingPathExtension().lastPathComponent)_audio", isDirectory: true) + } + + private static func isAudioArchiveDirectory(_ url: URL, fileManager: FileManager) -> Bool { + guard url.lastPathComponent.hasSuffix("_audio") else { return false } + var isDirectory: ObjCBool = false + return fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) && isDirectory.boolValue + } + + private static func transcriptDate( + forAudioDirectory audioDirectory: URL, + meetingsFolder: URL, + fileManager: FileManager + ) -> Date? { + let name = audioDirectory.lastPathComponent + guard name.hasSuffix("_audio") else { return nil } + let stem = String(name.dropLast("_audio".count)) + let transcriptURL = meetingsFolder.appendingPathComponent(stem).appendingPathExtension("md") + guard fileManager.fileExists(atPath: transcriptURL.path) else { return nil } + return modificationDate(for: transcriptURL) + } + + private static func modificationDate(for url: URL) -> Date? { + (try? url.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate + } + + private static func isWAVFile(_ url: URL, fileManager: FileManager) -> Bool { + guard url.pathExtension.localizedCaseInsensitiveCompare("wav") == .orderedSame else { return false } + let values = try? url.resourceValues(forKeys: [.isRegularFileKey]) + return values?.isRegularFile == true + } + + private static func hasNonEmptyFile(at url: URL, fileManager: FileManager) -> Bool { + guard fileManager.fileExists(atPath: url.path) else { return false } + let values = try? url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]) + return values?.isRegularFile == true && (values?.fileSize ?? 0) > 0 + } +} diff --git a/Sources/Meeting/MeetingSessionController.swift b/Sources/Meeting/MeetingSessionController.swift index 9f330c68..b1a6df10 100644 --- a/Sources/Meeting/MeetingSessionController.swift +++ b/Sources/Meeting/MeetingSessionController.swift @@ -1054,6 +1054,10 @@ final class MeetingSessionController: ObservableObject { let styled = MeetingTranscriptStyler.restyleTranscript(at: url) self.lastSavedTranscriptURL = styled.url self.lastSavedTitle = styled.title + let transcriptURL = styled.url + Task.detached(priority: .utility) { + await MeetingAudioStorageManager.processSavedTranscript(at: transcriptURL) + } DiagnosticsTrail.record( engine: "meeting", event: "meeting_transcript_artifact_ready", diff --git a/Sources/Support/AudioStoragePreferences.swift b/Sources/Support/AudioStoragePreferences.swift new file mode 100644 index 00000000..a55ba213 --- /dev/null +++ b/Sources/Support/AudioStoragePreferences.swift @@ -0,0 +1,66 @@ +import Foundation + +enum AudioRetentionWindow: String, CaseIterable, Identifiable { + case sevenDays = "7_days" + case thirtyDays = "30_days" + case never + + var id: String { rawValue } + + var days: Int? { + switch self { + case .sevenDays: + return 7 + case .thirtyDays: + return 30 + case .never: + return nil + } + } + + var title: String { + switch self { + case .sevenDays: + return "7 days" + case .thirtyDays: + return "30 days" + case .never: + return "Never" + } + } + + var detail: String { + switch self { + case .sevenDays: + return "Retained audio is removed after one week. Markdown transcripts stay." + case .thirtyDays: + return "Retained audio is removed after one month. Markdown transcripts stay." + case .never: + return "Retained compressed audio stays until you delete it." + } + } +} + +enum AudioStoragePreferences { + static let deleteAudioAfterKey = "meeting-audio-delete-after" + + static func deleteAudioAfter(userDefaults: UserDefaults = .standard) -> AudioRetentionWindow { + guard let rawValue = userDefaults.string(forKey: deleteAudioAfterKey), + let window = AudioRetentionWindow(rawValue: rawValue) else { + return .never + } + return window + } + + static func setDeleteAudioAfter( + _ window: AudioRetentionWindow, + userDefaults: UserDefaults = .standard + ) { + userDefaults.set(window.rawValue, forKey: deleteAudioAfterKey) + NotificationCenter.default.post(name: .audioStoragePreferencesDidChange, object: nil) + } +} + +extension Notification.Name { + static let audioStoragePreferencesDidChange = Notification.Name("audioStoragePreferencesDidChange") +} diff --git a/Sources/UI/Settings/TranscriptedSettingsView.swift b/Sources/UI/Settings/TranscriptedSettingsView.swift index eef5a148..b92f8185 100644 --- a/Sources/UI/Settings/TranscriptedSettingsView.swift +++ b/Sources/UI/Settings/TranscriptedSettingsView.swift @@ -60,6 +60,7 @@ struct TranscriptedSettingsView: View { @State private var showSupportFolders = false @State private var copiedAgentMeetingID: String? @State private var meetingVoiceProcessingEnabled = MicrophoneProcessingPreferences.isVoiceProcessingEnabled() + @State private var audioRetentionWindow = AudioStoragePreferences.deleteAudioAfter() @StateObject private var homeViewModel = HomeViewModel() @State private var homeActivityTab: HomeActivityTab = .meetings @State private var homeHeroMode: HomeHeroMode = .meeting @@ -1352,6 +1353,42 @@ struct TranscriptedSettingsView: View { .foregroundStyle(.secondary) } + SettingsSection( + title: "Audio Storage", + detail: "Transcripted keeps transcripts and shrinks retained meeting audio." + ) { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "waveform.badge.magnifyingglass") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: 24) + + VStack(alignment: .leading, spacing: 4) { + Text("Compress WAV to M4A automatically") + .font(.subheadline.weight(.medium)) + Text("After a transcript is saved, Transcripted keeps replay audio in a smaller format and removes the original WAV only after conversion succeeds.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + Picker("Delete audio after", selection: Binding( + get: { audioRetentionWindow }, + set: { updateAudioRetentionWindow($0) } + )) { + ForEach(AudioRetentionWindow.allCases) { window in + Text(window.title).tag(window) + } + } + .pickerStyle(.segmented) + .frame(maxWidth: 320) + + Text(audioRetentionWindow.detail) + .font(.caption) + .foregroundStyle(.secondary) + } + SettingsSection( title: "Support Folders", detail: "Logs, cache, app state, and temporary audio." @@ -1925,6 +1962,18 @@ struct TranscriptedSettingsView: View { captureLibraryURL = FileManager.default.transcriptedCaptureLibraryDir } + private func updateAudioRetentionWindow(_ window: AudioRetentionWindow) { + audioRetentionWindow = window + trackSettingsAction("audio_retention_changed", page: .storage) + AudioStoragePreferences.setDeleteAudioAfter(window) + Task.detached(priority: .utility) { + MeetingAudioStorageManager.pruneRetainedAudio( + in: MeetingStoragePaths.transcriptsFolder, + retentionWindow: window + ) + } + } + private func refreshRecentCaptures() { recentCaptureRefreshTask?.cancel() recentCaptureRefreshTask = nil diff --git a/Tests/AudioStoragePreferencesTests.swift b/Tests/AudioStoragePreferencesTests.swift new file mode 100644 index 00000000..6c167f5f --- /dev/null +++ b/Tests/AudioStoragePreferencesTests.swift @@ -0,0 +1,47 @@ +import Foundation + +func testAudioStoragePreferences() { + runSuite("AudioStoragePreferences defaults to keeping compressed audio") { + let (defaults, suiteName) = makeAudioStorageDefaults() + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + + assertEqual( + AudioStoragePreferences.deleteAudioAfter(userDefaults: defaults), + .never, + "audio retention should default to never deleting retained compressed audio" + ) + } + + runSuite("AudioStoragePreferences persists delete-after window") { + let (defaults, suiteName) = makeAudioStorageDefaults() + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + + AudioStoragePreferences.setDeleteAudioAfter(.sevenDays, userDefaults: defaults) + + assertEqual( + AudioStoragePreferences.deleteAudioAfter(userDefaults: defaults), + .sevenDays, + "explicit retention window should round-trip" + ) + } + + runSuite("AudioStoragePreferences falls back from unknown values") { + let (defaults, suiteName) = makeAudioStorageDefaults() + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + + defaults.set("soonish", forKey: AudioStoragePreferences.deleteAudioAfterKey) + + assertEqual( + AudioStoragePreferences.deleteAudioAfter(userDefaults: defaults), + .never, + "unknown retention values should fail closed to keeping audio" + ) + } +} + +private func makeAudioStorageDefaults() -> (UserDefaults, String) { + let suiteName = "AudioStoragePreferencesTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return (defaults, suiteName) +} diff --git a/Tests/FastTests.manifest b/Tests/FastTests.manifest index d2345163..5e4a01f7 100644 --- a/Tests/FastTests.manifest +++ b/Tests/FastTests.manifest @@ -3,6 +3,7 @@ ActivationPolicyControllerTests.swift:testActivationPolicyController AgentConnectionGuideTests.swift:testAgentConnectionGuide +AudioStoragePreferencesTests.swift:testAudioStoragePreferences ClaudeDesktopIntegrationInstallerTests.swift:testClaudeDesktopIntegrationInstaller AnalyticsEventPolicyTests.swift:testAnalyticsEventPolicy UpdateFailureKindTests.swift:testUpdateFailureKind @@ -21,6 +22,7 @@ ObservabilityLogWriterTests.swift:testObservabilityLogWriter ReliabilityPacketRecorderTests.swift:testReliabilityPacketRecorder RuntimeDiagnosticsStoreTests.swift:testRuntimeDiagnosticsStore SupportDiagnosticsBundleTests.swift:testSupportDiagnosticsBundle +MeetingAudioStorageManagerTests.swift:testMeetingAudioStorageManager MeetingTranscriptStylerTests.swift:testMeetingTranscriptStyler MeetingAudioArchiveResolverTests.swift:testMeetingAudioArchiveResolver MeetingPromptDetectorTests.swift:testMeetingPromptDetector diff --git a/Tests/MeetingAudioStorageManagerTests.swift b/Tests/MeetingAudioStorageManagerTests.swift new file mode 100644 index 00000000..54771329 --- /dev/null +++ b/Tests/MeetingAudioStorageManagerTests.swift @@ -0,0 +1,123 @@ +import Foundation + +func testMeetingAudioStorageManager() async { + await runSuite("MeetingAudioStorageManager converts WAVs to M4A before deleting originals") { + let directory = makeMeetingAudioStorageTestDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let transcriptURL = try! makeTranscript(named: "Customer Call", in: directory, ageDays: 1) + let audioDirectory = makeAudioDirectory(for: transcriptURL) + let wavURL = audioDirectory.appendingPathComponent("system_audio.wav") + try! Data("wav".utf8).write(to: wavURL) + + let converted = await MeetingAudioStorageManager.compressWAVAudio( + in: audioDirectory, + converter: FakeMeetingAudioConverter() + ) + + assertEqual(converted, 1, "one WAV file should be converted") + assertFalse(FileManager.default.fileExists(atPath: wavURL.path), "original WAV should be removed after conversion") + assertTrue( + FileManager.default.fileExists(atPath: audioDirectory.appendingPathComponent("system_audio.m4a").path), + "compressed M4A should exist" + ) + } + + await runSuite("MeetingAudioStorageManager keeps WAVs when conversion fails") { + let directory = makeMeetingAudioStorageTestDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let transcriptURL = try! makeTranscript(named: "Risky Call", in: directory, ageDays: 1) + let audioDirectory = makeAudioDirectory(for: transcriptURL) + let wavURL = audioDirectory.appendingPathComponent("microphone.wav") + try! Data("wav".utf8).write(to: wavURL) + + let converted = await MeetingAudioStorageManager.compressWAVAudio( + in: audioDirectory, + converter: FakeMeetingAudioConverter(shouldFail: true) + ) + + assertEqual(converted, 0, "failed conversion should not count as converted") + assertTrue(FileManager.default.fileExists(atPath: wavURL.path), "WAV should remain when conversion fails") + assertFalse( + FileManager.default.fileExists(atPath: audioDirectory.appendingPathComponent("microphone.m4a").path), + "failed conversion should not leave a final M4A" + ) + } + + runSuite("MeetingAudioStorageManager prunes retained audio by transcript age") { + let directory = makeMeetingAudioStorageTestDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let oldTranscript = try! makeTranscript(named: "Old Call", in: directory, ageDays: 8) + let newTranscript = try! makeTranscript(named: "New Call", in: directory, ageDays: 2) + let oldAudio = makeAudioDirectory(for: oldTranscript) + let newAudio = makeAudioDirectory(for: newTranscript) + try! Data("m4a".utf8).write(to: oldAudio.appendingPathComponent("recording.m4a")) + try! Data("m4a".utf8).write(to: newAudio.appendingPathComponent("recording.m4a")) + + let removed = MeetingAudioStorageManager.pruneRetainedAudio( + in: directory, + retentionWindow: .sevenDays, + now: Date() + ) + + assertEqual(removed, 1, "only old retained audio should be deleted") + assertFalse(FileManager.default.fileExists(atPath: oldAudio.path), "old audio directory should be removed") + assertTrue(FileManager.default.fileExists(atPath: oldTranscript.path), "old transcript should stay") + assertTrue(FileManager.default.fileExists(atPath: newAudio.path), "new audio directory should stay") + } + + runSuite("MeetingAudioStorageManager never window does not prune") { + let directory = makeMeetingAudioStorageTestDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let transcriptURL = try! makeTranscript(named: "Archive Call", in: directory, ageDays: 90) + let audioDirectory = makeAudioDirectory(for: transcriptURL) + try! Data("m4a".utf8).write(to: audioDirectory.appendingPathComponent("recording.m4a")) + + let removed = MeetingAudioStorageManager.pruneRetainedAudio( + in: directory, + retentionWindow: .never, + now: Date() + ) + + assertEqual(removed, 0, "never should not delete retained audio") + assertTrue(FileManager.default.fileExists(atPath: audioDirectory.path), "audio directory should remain") + } +} + +private struct FakeMeetingAudioConverter: MeetingAudioFileConverting { + var shouldFail = false + + func convertWAVToM4A(sourceURL: URL, destinationURL: URL) async throws { + if shouldFail { + throw MeetingAudioStorageError.conversionFailed + } + try Data("m4a".utf8).write(to: destinationURL) + } +} + +private func makeMeetingAudioStorageTestDirectory() -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("MeetingAudioStorageManagerTests-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory +} + +private func makeTranscript(named name: String, in directory: URL, ageDays: Int) throws -> URL { + let url = directory.appendingPathComponent(name).appendingPathExtension("md") + try "# \(name)\n".write(to: url, atomically: true, encoding: .utf8) + let date = Calendar.current.date(byAdding: .day, value: -ageDays, to: Date())! + try FileManager.default.setAttributes([.modificationDate: date], ofItemAtPath: url.path) + return url +} + +private func makeAudioDirectory(for transcriptURL: URL) -> URL { + let directory = transcriptURL + .deletingLastPathComponent() + .appendingPathComponent("audio", isDirectory: true) + .appendingPathComponent("\(transcriptURL.deletingPathExtension().lastPathComponent)_audio", isDirectory: true) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory +} diff --git a/docs/storage-paths.md b/docs/storage-paths.md index cfc1065e..56126415 100644 --- a/docs/storage-paths.md +++ b/docs/storage-paths.md @@ -35,6 +35,12 @@ The meetings capture folder contains user-facing artifacts: - markdown transcripts: `/meetings/*.md` - retained recording audio: `/meetings/audio/*_audio/` +After a successful transcript save, app-managed retained `.wav` audio is +converted to `.m4a` and the original `.wav` is removed only after conversion +succeeds. The Storage settings page controls whether retained audio is deleted +after 7 days, 30 days, or never. Markdown transcripts are not removed by audio +retention cleanup. + App-owned meeting state is stored separately under: - speaker DB: `~/Library/Application Support/Transcripted/state/speakers.sqlite` diff --git a/scripts/entrypoints/run-tests.sh b/scripts/entrypoints/run-tests.sh index 3ec10001..386de9e7 100755 --- a/scripts/entrypoints/run-tests.sh +++ b/scripts/entrypoints/run-tests.sh @@ -124,6 +124,7 @@ APP_SOURCES=( "Sources/Support/CustomDictionaryPreferences.swift" "Sources/Support/DockVisibilityPreferences.swift" "Sources/Support/MicrophoneProcessingPreferences.swift" + "Sources/Support/AudioStoragePreferences.swift" "Sources/Support/TranscriptionModelPreferences.swift" "Sources/Support/DictationAutoSendPreferences.swift" "Sources/Support/ClipboardRestoringTextPaster.swift" @@ -150,6 +151,7 @@ APP_SOURCES=( "Sources/Meeting/MeetingPromptDetector.swift" "Sources/Meeting/MeetingPromptHeuristics.swift" "Sources/Meeting/MeetingAudioInactivityDetector.swift" + "Sources/Meeting/MeetingAudioStorageManager.swift" "Sources/Meeting/MeetingRecordingCleanup.swift" "Sources/Meeting/MeetingSessionUIPolicy.swift" "Sources/Meeting/MeetingWarmupStatusPolicy.swift" From 9a558d547f13fbe42c135fad7a342f4500c1a8ae Mon Sep 17 00:00:00 2001 From: r3dbars Date: Mon, 4 May 2026 14:42:27 -0500 Subject: [PATCH 2/5] Backfill existing meeting audio compression --- .../Meeting/MeetingAudioStorageManager.swift | 87 ++++++++++++++++--- Sources/TranscriptedAppState.swift | 14 +++ .../Settings/TranscriptedSettingsView.swift | 2 +- Tests/MeetingAudioStorageManagerTests.swift | 78 +++++++++++++++++ docs/storage-paths.md | 5 ++ 5 files changed, 172 insertions(+), 14 deletions(-) diff --git a/Sources/Meeting/MeetingAudioStorageManager.swift b/Sources/Meeting/MeetingAudioStorageManager.swift index f84edf92..6f9afdc0 100644 --- a/Sources/Meeting/MeetingAudioStorageManager.swift +++ b/Sources/Meeting/MeetingAudioStorageManager.swift @@ -24,7 +24,49 @@ enum MeetingAudioStorageError: Error { case emptyConvertedFile } +struct MeetingAudioStorageMaintenanceResult: Equatable { + let scannedDirectories: Int + let convertedFiles: Int + let prunedDirectories: Int +} + enum MeetingAudioStorageManager { + @discardableResult + static func processExistingRetainedAudio( + in meetingsFolder: URL, + retentionWindow: AudioRetentionWindow = AudioStoragePreferences.deleteAudioAfter(), + now: Date = Date(), + fileManager: FileManager = .default, + converter: MeetingAudioFileConverting = AVFoundationMeetingAudioConverter() + ) async -> MeetingAudioStorageMaintenanceResult { + let prunedDirectories = pruneRetainedAudio( + in: meetingsFolder, + retentionWindow: retentionWindow, + now: now, + fileManager: fileManager + ) + + let directories = audioArchiveDirectoriesWithTranscripts( + in: meetingsFolder, + fileManager: fileManager + ) + + var convertedFiles = 0 + for directory in directories { + convertedFiles += await compressWAVAudio( + in: directory, + fileManager: fileManager, + converter: converter + ) + } + + return MeetingAudioStorageMaintenanceResult( + scannedDirectories: directories.count, + convertedFiles: convertedFiles, + prunedDirectories: prunedDirectories + ) + } + static func processSavedTranscript( at transcriptURL: URL, retentionWindow: AudioRetentionWindow = AudioStoragePreferences.deleteAudioAfter(), @@ -57,24 +99,20 @@ enum MeetingAudioStorageManager { guard let days = retentionWindow.days else { return 0 } guard let cutoff = Calendar.current.date(byAdding: .day, value: -days, to: now) else { return 0 } - let audioRoot = meetingsFolder.appendingPathComponent("audio", isDirectory: true) - guard let directories = try? fileManager.contentsOfDirectory( - at: audioRoot, - includingPropertiesForKeys: [.isDirectoryKey, .contentModificationDateKey], - options: [.skipsHiddenFiles] - ) else { - return 0 - } + let directories = audioArchiveDirectoriesWithTranscripts( + in: meetingsFolder, + fileManager: fileManager + ) var removedCount = 0 - for directory in directories where isAudioArchiveDirectory(directory, fileManager: fileManager) { - let referenceDate = transcriptDate( + for directory in directories { + guard let referenceDate = transcriptDate( forAudioDirectory: directory, meetingsFolder: meetingsFolder, fileManager: fileManager - ) ?? modificationDate(for: directory) - - guard let referenceDate, referenceDate < cutoff else { continue } + ), referenceDate < cutoff else { + continue + } do { try fileManager.removeItem(at: directory) @@ -134,6 +172,29 @@ enum MeetingAudioStorageManager { return convertedCount } + private static func audioArchiveDirectoriesWithTranscripts( + in meetingsFolder: URL, + fileManager: FileManager + ) -> [URL] { + let audioRoot = meetingsFolder.appendingPathComponent("audio", isDirectory: true) + guard let directories = try? fileManager.contentsOfDirectory( + at: audioRoot, + includingPropertiesForKeys: [.isDirectoryKey, .contentModificationDateKey], + options: [.skipsHiddenFiles] + ) else { + return [] + } + + return directories.filter { directory in + isAudioArchiveDirectory(directory, fileManager: fileManager) + && transcriptDate( + forAudioDirectory: directory, + meetingsFolder: meetingsFolder, + fileManager: fileManager + ) != nil + } + } + private static func audioDirectoryURL(forTranscript transcriptURL: URL) -> URL { transcriptURL .deletingLastPathComponent() diff --git a/Sources/TranscriptedAppState.swift b/Sources/TranscriptedAppState.swift index 3c5858ea..ea4032fa 100644 --- a/Sources/TranscriptedAppState.swift +++ b/Sources/TranscriptedAppState.swift @@ -23,6 +23,7 @@ class TranscriptedAppState: ObservableObject { private var promptsObserver: NSObjectProtocol? private var runtimeReadinessTask: Task? + private var audioStorageMaintenanceTask: Task? private var isInitialized = false private lazy var wakeRecoveryCoordinator = WakeRecoveryCoordinator( hotkeyRetryAttempts: Self.wakeHotkeyRetryAttempts, @@ -77,6 +78,7 @@ class TranscriptedAppState: ObservableObject { // Kick off shared runtime prep once; wake recovery can await or reuse it. startRuntimeReadinessIfNeeded() + startAudioStorageMaintenanceIfNeeded() logger.log("APP LAUNCHED | modes: dictation + meetings") AnalyticsReporter.track("app_launched") @@ -142,6 +144,8 @@ class TranscriptedAppState: ObservableObject { wakeRecoveryCoordinator.cancel() runtimeReadinessTask?.cancel() runtimeReadinessTask = nil + audioStorageMaintenanceTask?.cancel() + audioStorageMaintenanceTask = nil sttRouter.cleanup() contextCapture.unregisterHotkey() if let observer = promptsObserver { @@ -174,6 +178,16 @@ class TranscriptedAppState: ObservableObject { } } + private func startAudioStorageMaintenanceIfNeeded() { + guard audioStorageMaintenanceTask == nil else { return } + + audioStorageMaintenanceTask = Task.detached(priority: .utility) { + await MeetingAudioStorageManager.processExistingRetainedAudio( + in: MeetingStoragePaths.transcriptsFolder + ) + } + } + private func waitForRuntimeReadiness() async { startRuntimeReadinessIfNeeded() guard let runtimeReadinessTask else { return } diff --git a/Sources/UI/Settings/TranscriptedSettingsView.swift b/Sources/UI/Settings/TranscriptedSettingsView.swift index b92f8185..e15f2d81 100644 --- a/Sources/UI/Settings/TranscriptedSettingsView.swift +++ b/Sources/UI/Settings/TranscriptedSettingsView.swift @@ -1967,7 +1967,7 @@ struct TranscriptedSettingsView: View { trackSettingsAction("audio_retention_changed", page: .storage) AudioStoragePreferences.setDeleteAudioAfter(window) Task.detached(priority: .utility) { - MeetingAudioStorageManager.pruneRetainedAudio( + await MeetingAudioStorageManager.processExistingRetainedAudio( in: MeetingStoragePaths.transcriptsFolder, retentionWindow: window ) diff --git a/Tests/MeetingAudioStorageManagerTests.swift b/Tests/MeetingAudioStorageManagerTests.swift index 54771329..15fce8c6 100644 --- a/Tests/MeetingAudioStorageManagerTests.swift +++ b/Tests/MeetingAudioStorageManagerTests.swift @@ -85,6 +85,84 @@ func testMeetingAudioStorageManager() async { assertEqual(removed, 0, "never should not delete retained audio") assertTrue(FileManager.default.fileExists(atPath: audioDirectory.path), "audio directory should remain") } + + await runSuite("MeetingAudioStorageManager backfills existing transcript audio") { + let directory = makeMeetingAudioStorageTestDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let transcriptURL = try! makeTranscript(named: "Existing Call", in: directory, ageDays: 1) + let audioDirectory = makeAudioDirectory(for: transcriptURL) + let wavURL = audioDirectory.appendingPathComponent("recording.wav") + try! Data("wav".utf8).write(to: wavURL) + + let result = await MeetingAudioStorageManager.processExistingRetainedAudio( + in: directory, + retentionWindow: .never, + converter: FakeMeetingAudioConverter() + ) + + assertEqual( + result, + MeetingAudioStorageMaintenanceResult(scannedDirectories: 1, convertedFiles: 1, prunedDirectories: 0), + "existing transcript audio should be scanned and compressed" + ) + assertFalse(FileManager.default.fileExists(atPath: wavURL.path), "old WAV should be removed after backfill conversion") + assertTrue( + FileManager.default.fileExists(atPath: audioDirectory.appendingPathComponent("recording.m4a").path), + "backfill should leave compressed audio" + ) + } + + await runSuite("MeetingAudioStorageManager skips failed audio without a transcript") { + let directory = makeMeetingAudioStorageTestDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let failedAudioDirectory = directory + .appendingPathComponent("audio", isDirectory: true) + .appendingPathComponent("Failed_2026-05-04_audio", isDirectory: true) + try? FileManager.default.createDirectory(at: failedAudioDirectory, withIntermediateDirectories: true) + let wavURL = failedAudioDirectory.appendingPathComponent("recording.wav") + try! Data("wav".utf8).write(to: wavURL) + + let result = await MeetingAudioStorageManager.processExistingRetainedAudio( + in: directory, + retentionWindow: .sevenDays, + now: Date(), + converter: FakeMeetingAudioConverter() + ) + + assertEqual( + result, + MeetingAudioStorageMaintenanceResult(scannedDirectories: 0, convertedFiles: 0, prunedDirectories: 0), + "failed or orphaned audio should not be managed by transcript retention" + ) + assertTrue(FileManager.default.fileExists(atPath: wavURL.path), "failed WAV should remain for the retry/delete flow") + } + + await runSuite("MeetingAudioStorageManager prunes old WAVs before backfill conversion") { + let directory = makeMeetingAudioStorageTestDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let transcriptURL = try! makeTranscript(named: "Old WAV Call", in: directory, ageDays: 31) + let audioDirectory = makeAudioDirectory(for: transcriptURL) + let wavURL = audioDirectory.appendingPathComponent("recording.wav") + try! Data("wav".utf8).write(to: wavURL) + + let result = await MeetingAudioStorageManager.processExistingRetainedAudio( + in: directory, + retentionWindow: .thirtyDays, + now: Date(), + converter: FakeMeetingAudioConverter() + ) + + assertEqual( + result, + MeetingAudioStorageMaintenanceResult(scannedDirectories: 0, convertedFiles: 0, prunedDirectories: 1), + "old retained WAV audio should be deleted directly instead of compressed first" + ) + assertFalse(FileManager.default.fileExists(atPath: audioDirectory.path), "old audio directory should be removed") + assertTrue(FileManager.default.fileExists(atPath: transcriptURL.path), "old transcript should stay") + } } private struct FakeMeetingAudioConverter: MeetingAudioFileConverting { diff --git a/docs/storage-paths.md b/docs/storage-paths.md index 56126415..07e2dc5b 100644 --- a/docs/storage-paths.md +++ b/docs/storage-paths.md @@ -41,6 +41,11 @@ succeeds. The Storage settings page controls whether retained audio is deleted after 7 days, 30 days, or never. Markdown transcripts are not removed by audio retention cleanup. +On launch, Transcripted also performs the same best-effort compression pass for +existing retained audio folders that already have matching Markdown transcripts. +Failed or orphaned audio without a saved transcript is left alone for the +failed-meeting retry/delete flow. + App-owned meeting state is stored separately under: - speaker DB: `~/Library/Application Support/Transcripted/state/speakers.sqlite` From f1f61187567286b7a81ff37ee050d5b120950f60 Mon Sep 17 00:00:00 2001 From: r3dbars Date: Mon, 4 May 2026 14:49:34 -0500 Subject: [PATCH 3/5] Harden meeting audio conversion validation --- .../Meeting/MeetingAudioStorageManager.swift | 40 +++++++-- Tests/MeetingAudioStorageManagerTests.swift | 90 +++++++++++++++++-- 2 files changed, 117 insertions(+), 13 deletions(-) diff --git a/Sources/Meeting/MeetingAudioStorageManager.swift b/Sources/Meeting/MeetingAudioStorageManager.swift index 6f9afdc0..15f69ecf 100644 --- a/Sources/Meeting/MeetingAudioStorageManager.swift +++ b/Sources/Meeting/MeetingAudioStorageManager.swift @@ -5,6 +5,10 @@ protocol MeetingAudioFileConverting { func convertWAVToM4A(sourceURL: URL, destinationURL: URL) async throws } +protocol MeetingAudioFileValidating { + func isUsableAudioFile(at url: URL, fileManager: FileManager) -> Bool +} + struct AVFoundationMeetingAudioConverter: MeetingAudioFileConverting { func convertWAVToM4A(sourceURL: URL, destinationURL: URL) async throws { let asset = AVURLAsset(url: sourceURL) @@ -18,6 +22,23 @@ struct AVFoundationMeetingAudioConverter: MeetingAudioFileConverting { } } +struct AVFoundationMeetingAudioValidator: MeetingAudioFileValidating { + func isUsableAudioFile(at url: URL, fileManager: FileManager) -> Bool { + guard hasNonEmptyFile(at: url, fileManager: fileManager), + let file = try? AVAudioFile(forReading: url) else { + return false + } + + return file.length > 0 && file.fileFormat.sampleRate > 0 + } + + private func hasNonEmptyFile(at url: URL, fileManager: FileManager) -> Bool { + guard fileManager.fileExists(atPath: url.path) else { return false } + let values = try? url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]) + return values?.isRegularFile == true && (values?.fileSize ?? 0) > 0 + } +} + enum MeetingAudioStorageError: Error { case exportSessionUnavailable case conversionFailed @@ -37,7 +58,8 @@ enum MeetingAudioStorageManager { retentionWindow: AudioRetentionWindow = AudioStoragePreferences.deleteAudioAfter(), now: Date = Date(), fileManager: FileManager = .default, - converter: MeetingAudioFileConverting = AVFoundationMeetingAudioConverter() + converter: MeetingAudioFileConverting = AVFoundationMeetingAudioConverter(), + validator: MeetingAudioFileValidating = AVFoundationMeetingAudioValidator() ) async -> MeetingAudioStorageMaintenanceResult { let prunedDirectories = pruneRetainedAudio( in: meetingsFolder, @@ -56,7 +78,8 @@ enum MeetingAudioStorageManager { convertedFiles += await compressWAVAudio( in: directory, fileManager: fileManager, - converter: converter + converter: converter, + validator: validator ) } @@ -72,13 +95,15 @@ enum MeetingAudioStorageManager { retentionWindow: AudioRetentionWindow = AudioStoragePreferences.deleteAudioAfter(), now: Date = Date(), fileManager: FileManager = .default, - converter: MeetingAudioFileConverting = AVFoundationMeetingAudioConverter() + converter: MeetingAudioFileConverting = AVFoundationMeetingAudioConverter(), + validator: MeetingAudioFileValidating = AVFoundationMeetingAudioValidator() ) async { let audioDirectory = audioDirectoryURL(forTranscript: transcriptURL) await compressWAVAudio( in: audioDirectory, fileManager: fileManager, - converter: converter + converter: converter, + validator: validator ) pruneRetainedAudio( @@ -129,7 +154,8 @@ enum MeetingAudioStorageManager { static func compressWAVAudio( in audioDirectory: URL, fileManager: FileManager = .default, - converter: MeetingAudioFileConverting = AVFoundationMeetingAudioConverter() + converter: MeetingAudioFileConverting = AVFoundationMeetingAudioConverter(), + validator: MeetingAudioFileValidating = AVFoundationMeetingAudioValidator() ) async -> Int { guard let files = try? fileManager.contentsOfDirectory( at: audioDirectory, @@ -143,7 +169,7 @@ enum MeetingAudioStorageManager { for sourceURL in files where isWAVFile(sourceURL, fileManager: fileManager) { let destinationURL = sourceURL.deletingPathExtension().appendingPathExtension("m4a") - if hasNonEmptyFile(at: destinationURL, fileManager: fileManager) { + if validator.isUsableAudioFile(at: destinationURL, fileManager: fileManager) { try? fileManager.removeItem(at: sourceURL) continue } @@ -154,7 +180,7 @@ enum MeetingAudioStorageManager { do { try await converter.convertWAVToM4A(sourceURL: sourceURL, destinationURL: tempURL) - guard hasNonEmptyFile(at: tempURL, fileManager: fileManager) else { + guard validator.isUsableAudioFile(at: tempURL, fileManager: fileManager) else { throw MeetingAudioStorageError.emptyConvertedFile } if fileManager.fileExists(atPath: destinationURL.path) { diff --git a/Tests/MeetingAudioStorageManagerTests.swift b/Tests/MeetingAudioStorageManagerTests.swift index 15fce8c6..215c42b7 100644 --- a/Tests/MeetingAudioStorageManagerTests.swift +++ b/Tests/MeetingAudioStorageManagerTests.swift @@ -12,7 +12,8 @@ func testMeetingAudioStorageManager() async { let converted = await MeetingAudioStorageManager.compressWAVAudio( in: audioDirectory, - converter: FakeMeetingAudioConverter() + converter: FakeMeetingAudioConverter(), + validator: FakeMeetingAudioValidator() ) assertEqual(converted, 1, "one WAV file should be converted") @@ -34,7 +35,8 @@ func testMeetingAudioStorageManager() async { let converted = await MeetingAudioStorageManager.compressWAVAudio( in: audioDirectory, - converter: FakeMeetingAudioConverter(shouldFail: true) + converter: FakeMeetingAudioConverter(shouldFail: true), + validator: FakeMeetingAudioValidator() ) assertEqual(converted, 0, "failed conversion should not count as converted") @@ -45,6 +47,71 @@ func testMeetingAudioStorageManager() async { ) } + await runSuite("MeetingAudioStorageManager does not trust unusable existing M4A files") { + let directory = makeMeetingAudioStorageTestDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let transcriptURL = try! makeTranscript(named: "Invalid Existing Audio", in: directory, ageDays: 1) + let audioDirectory = makeAudioDirectory(for: transcriptURL) + let wavURL = audioDirectory.appendingPathComponent("recording.wav") + let m4aURL = audioDirectory.appendingPathComponent("recording.m4a") + try! Data("wav".utf8).write(to: wavURL) + try! Data("not audio".utf8).write(to: m4aURL) + + let converted = await MeetingAudioStorageManager.compressWAVAudio( + in: audioDirectory, + converter: FakeMeetingAudioConverter(), + validator: FakeMeetingAudioValidator() + ) + + assertEqual(converted, 1, "invalid existing M4A should be replaced by a fresh conversion") + assertFalse(FileManager.default.fileExists(atPath: wavURL.path), "WAV should be removed only after replacement succeeds") + assertEqual(try? Data(contentsOf: m4aURL), Data("m4a".utf8), "existing invalid M4A should be replaced") + } + + await runSuite("MeetingAudioStorageManager keeps WAVs when converted M4A is unusable") { + let directory = makeMeetingAudioStorageTestDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let transcriptURL = try! makeTranscript(named: "Bad Conversion", in: directory, ageDays: 1) + let audioDirectory = makeAudioDirectory(for: transcriptURL) + let wavURL = audioDirectory.appendingPathComponent("recording.wav") + try! Data("wav".utf8).write(to: wavURL) + + let converted = await MeetingAudioStorageManager.compressWAVAudio( + in: audioDirectory, + converter: FakeMeetingAudioConverter(output: Data("bad".utf8)), + validator: FakeMeetingAudioValidator() + ) + + assertEqual(converted, 0, "unusable converted audio should not count as converted") + assertTrue(FileManager.default.fileExists(atPath: wavURL.path), "WAV should remain when validation fails") + assertFalse( + FileManager.default.fileExists(atPath: audioDirectory.appendingPathComponent("recording.m4a").path), + "bad temp output should not be promoted to final M4A" + ) + } + + await runSuite("MeetingAudioStorageManager removes WAV when existing M4A is already usable") { + let directory = makeMeetingAudioStorageTestDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let transcriptURL = try! makeTranscript(named: "Already Converted", in: directory, ageDays: 1) + let audioDirectory = makeAudioDirectory(for: transcriptURL) + let wavURL = audioDirectory.appendingPathComponent("recording.wav") + try! Data("wav".utf8).write(to: wavURL) + try! Data("m4a".utf8).write(to: audioDirectory.appendingPathComponent("recording.m4a")) + + let converted = await MeetingAudioStorageManager.compressWAVAudio( + in: audioDirectory, + converter: FakeMeetingAudioConverter(shouldFail: true), + validator: FakeMeetingAudioValidator() + ) + + assertEqual(converted, 0, "already converted audio should not run conversion again") + assertFalse(FileManager.default.fileExists(atPath: wavURL.path), "duplicate WAV should be removed when M4A is usable") + } + runSuite("MeetingAudioStorageManager prunes retained audio by transcript age") { let directory = makeMeetingAudioStorageTestDirectory() defer { try? FileManager.default.removeItem(at: directory) } @@ -98,7 +165,8 @@ func testMeetingAudioStorageManager() async { let result = await MeetingAudioStorageManager.processExistingRetainedAudio( in: directory, retentionWindow: .never, - converter: FakeMeetingAudioConverter() + converter: FakeMeetingAudioConverter(), + validator: FakeMeetingAudioValidator() ) assertEqual( @@ -128,7 +196,8 @@ func testMeetingAudioStorageManager() async { in: directory, retentionWindow: .sevenDays, now: Date(), - converter: FakeMeetingAudioConverter() + converter: FakeMeetingAudioConverter(), + validator: FakeMeetingAudioValidator() ) assertEqual( @@ -152,7 +221,8 @@ func testMeetingAudioStorageManager() async { in: directory, retentionWindow: .thirtyDays, now: Date(), - converter: FakeMeetingAudioConverter() + converter: FakeMeetingAudioConverter(), + validator: FakeMeetingAudioValidator() ) assertEqual( @@ -167,12 +237,20 @@ func testMeetingAudioStorageManager() async { private struct FakeMeetingAudioConverter: MeetingAudioFileConverting { var shouldFail = false + var output = Data("m4a".utf8) func convertWAVToM4A(sourceURL: URL, destinationURL: URL) async throws { if shouldFail { throw MeetingAudioStorageError.conversionFailed } - try Data("m4a".utf8).write(to: destinationURL) + try output.write(to: destinationURL) + } +} + +private struct FakeMeetingAudioValidator: MeetingAudioFileValidating { + func isUsableAudioFile(at url: URL, fileManager: FileManager) -> Bool { + guard fileManager.fileExists(atPath: url.path) else { return false } + return (try? Data(contentsOf: url)) == Data("m4a".utf8) } } From b5854bed81b0fa4977e36579b4141b7e86bf1071 Mon Sep 17 00:00:00 2001 From: r3dbars Date: Mon, 4 May 2026 15:11:25 -0500 Subject: [PATCH 4/5] Clean stale meeting audio temp files --- .../Meeting/MeetingAudioStorageManager.swift | 63 +++++++++++++++++++ Tests/MeetingAudioStorageManagerTests.swift | 42 +++++++++++++ 2 files changed, 105 insertions(+) diff --git a/Sources/Meeting/MeetingAudioStorageManager.swift b/Sources/Meeting/MeetingAudioStorageManager.swift index 15f69ecf..2d50bb33 100644 --- a/Sources/Meeting/MeetingAudioStorageManager.swift +++ b/Sources/Meeting/MeetingAudioStorageManager.swift @@ -52,6 +52,8 @@ struct MeetingAudioStorageMaintenanceResult: Equatable { } enum MeetingAudioStorageManager { + private static let staleTemporaryM4AAge: TimeInterval = 10 * 60 + @discardableResult static func processExistingRetainedAudio( in meetingsFolder: URL, @@ -77,6 +79,7 @@ enum MeetingAudioStorageManager { for directory in directories { convertedFiles += await compressWAVAudio( in: directory, + now: now, fileManager: fileManager, converter: converter, validator: validator @@ -101,6 +104,7 @@ enum MeetingAudioStorageManager { let audioDirectory = audioDirectoryURL(forTranscript: transcriptURL) await compressWAVAudio( in: audioDirectory, + now: now, fileManager: fileManager, converter: converter, validator: validator @@ -153,10 +157,13 @@ enum MeetingAudioStorageManager { @discardableResult static func compressWAVAudio( in audioDirectory: URL, + now: Date = Date(), fileManager: FileManager = .default, converter: MeetingAudioFileConverting = AVFoundationMeetingAudioConverter(), validator: MeetingAudioFileValidating = AVFoundationMeetingAudioValidator() ) async -> Int { + removeStaleTemporaryM4AFiles(in: audioDirectory, now: now, fileManager: fileManager) + guard let files = try? fileManager.contentsOfDirectory( at: audioDirectory, includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], @@ -198,6 +205,37 @@ enum MeetingAudioStorageManager { return convertedCount } + @discardableResult + static func removeStaleTemporaryM4AFiles( + in audioDirectory: URL, + now: Date = Date(), + minimumAge: TimeInterval = staleTemporaryM4AAge, + fileManager: FileManager = .default + ) -> Int { + guard let files = try? fileManager.contentsOfDirectory( + at: audioDirectory, + includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey], + options: [] + ) else { + return 0 + } + + var removedCount = 0 + for file in files where isStaleTemporaryM4AFile( + file, + now: now, + minimumAge: minimumAge + ) { + do { + try fileManager.removeItem(at: file) + removedCount += 1 + } catch { + continue + } + } + return removedCount + } + private static func audioArchiveDirectoriesWithTranscripts( in meetingsFolder: URL, fileManager: FileManager @@ -257,6 +295,31 @@ enum MeetingAudioStorageManager { return values?.isRegularFile == true } + private static func isStaleTemporaryM4AFile( + _ url: URL, + now: Date, + minimumAge: TimeInterval + ) -> Bool { + guard isTranscriptedTemporaryM4AFileName(url) else { return false } + let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .contentModificationDateKey]) + guard values?.isRegularFile == true, let modified = values?.contentModificationDate else { + return false + } + return now.timeIntervalSince(modified) >= max(0, minimumAge) + } + + private static func isTranscriptedTemporaryM4AFileName(_ url: URL) -> Bool { + guard url.pathExtension.localizedCaseInsensitiveCompare("m4a") == .orderedSame else { return false } + let hiddenStem = url.deletingPathExtension().lastPathComponent + guard hiddenStem.hasPrefix(".") else { return false } + let stem = String(hiddenStem.dropFirst()) + guard stem.count > 37 else { return false } + let separatorIndex = stem.index(stem.endIndex, offsetBy: -37) + guard stem[separatorIndex] == "-" else { return false } + let uuidString = String(stem.suffix(36)) + return UUID(uuidString: uuidString) != nil + } + private static func hasNonEmptyFile(at url: URL, fileManager: FileManager) -> Bool { guard fileManager.fileExists(atPath: url.path) else { return false } let values = try? url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]) diff --git a/Tests/MeetingAudioStorageManagerTests.swift b/Tests/MeetingAudioStorageManagerTests.swift index 215c42b7..c57434d3 100644 --- a/Tests/MeetingAudioStorageManagerTests.swift +++ b/Tests/MeetingAudioStorageManagerTests.swift @@ -112,6 +112,48 @@ func testMeetingAudioStorageManager() async { assertFalse(FileManager.default.fileExists(atPath: wavURL.path), "duplicate WAV should be removed when M4A is usable") } + await runSuite("MeetingAudioStorageManager removes only stale Transcripted temp M4A files") { + let directory = makeMeetingAudioStorageTestDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let now = Date() + let transcriptURL = try! makeTranscript(named: "Temp Cleanup", in: directory, ageDays: 1) + let audioDirectory = makeAudioDirectory(for: transcriptURL) + let finalM4A = audioDirectory.appendingPathComponent("recording.m4a") + let staleTemp = audioDirectory.appendingPathComponent(".recording-092B8B54-B598-4796-9573-00E0D9FC9EE1.m4a") + let freshTemp = audioDirectory.appendingPathComponent(".recording-3F5531EE-C352-429F-A10F-EC978BBC2927.m4a") + let unrelatedHiddenFile = audioDirectory.appendingPathComponent(".user-note.m4a") + try! Data("m4a".utf8).write(to: finalM4A) + try! Data("stale".utf8).write(to: staleTemp) + try! Data("fresh".utf8).write(to: freshTemp) + try! Data("user".utf8).write(to: unrelatedHiddenFile) + try! FileManager.default.setAttributes( + [.modificationDate: now.addingTimeInterval(-11 * 60)], + ofItemAtPath: staleTemp.path + ) + try! FileManager.default.setAttributes( + [.modificationDate: now.addingTimeInterval(-60)], + ofItemAtPath: freshTemp.path + ) + try! FileManager.default.setAttributes( + [.modificationDate: now.addingTimeInterval(-11 * 60)], + ofItemAtPath: unrelatedHiddenFile.path + ) + + let converted = await MeetingAudioStorageManager.compressWAVAudio( + in: audioDirectory, + now: now, + converter: FakeMeetingAudioConverter(), + validator: FakeMeetingAudioValidator() + ) + + assertEqual(converted, 0, "temp cleanup should not count as a WAV conversion") + assertFalse(FileManager.default.fileExists(atPath: staleTemp.path), "stale app-owned temp file should be removed") + assertTrue(FileManager.default.fileExists(atPath: freshTemp.path), "recent temp file should not be removed") + assertTrue(FileManager.default.fileExists(atPath: unrelatedHiddenFile.path), "unrelated hidden M4A should not be removed") + assertTrue(FileManager.default.fileExists(atPath: finalM4A.path), "final M4A should stay") + } + runSuite("MeetingAudioStorageManager prunes retained audio by transcript age") { let directory = makeMeetingAudioStorageTestDirectory() defer { try? FileManager.default.removeItem(at: directory) } From 178437e05dfded2d00b781c99467ec8b51aa891e Mon Sep 17 00:00:00 2001 From: r3dbars Date: Mon, 4 May 2026 15:58:51 -0500 Subject: [PATCH 5/5] Harden meeting audio storage cleanup --- Sources/Meeting/CLAUDE.md | 10 +- .../Meeting/MeetingAudioStorageManager.swift | 197 +++++++++++++-- Sources/Meeting/MeetingTranscriptStyler.swift | 18 +- Sources/Support/CLAUDE.md | 5 +- .../TranscriptionPipelineRunner.swift | 10 +- .../Pipeline/TranscriptionTaskManager.swift | 35 ++- .../Services/FailedTranscriptionManager.swift | 33 +++ .../Settings/TranscriptedSettingsView.swift | 25 ++ Tests/MeetingAudioStorageManagerTests.swift | 238 +++++++++++++++++- Tests/MeetingTranscriptStylerTests.swift | 30 +++ ...ranscriptionTaskManagerMetadataTests.swift | 31 +++ 11 files changed, 586 insertions(+), 46 deletions(-) diff --git a/Sources/Meeting/CLAUDE.md b/Sources/Meeting/CLAUDE.md index e5458727..5a05d35d 100644 --- a/Sources/Meeting/CLAUDE.md +++ b/Sources/Meeting/CLAUDE.md @@ -7,6 +7,7 @@ ## Files - `FailedMeetingPresentation.swift` — maps `FailedTranscription` into `FailedMeetingItem` view-models with human-readable titles and retry metadata +- `MeetingAudioStorageManager.swift` — compresses retained meeting WAVs to M4A, applies audio-retention cleanup, and backfills existing retained audio after launch or Settings changes - `MeetingAudioInactivityDetector.swift` — detects prolonged audio silence during meetings and emits warning/cleared events so the UI can prompt the user to confirm the recording is still needed - `MeetingCaptureBridge.swift` — `@MainActor` wrapper around core `Audio` that converts start/stop into async flows, waits for both live capture and system-audio-file readiness, and mirrors live levels for the UI - `MeetingCaptureBridge+LivePreview.swift` — bridge extension for recording health snapshots plus mic/system live-preview buffer forwarding @@ -40,8 +41,9 @@ 10. `MeetingSessionController.importAudioFile(...)` routes standalone recordings through `MeetingImportedAudioPreparer` and into the same save / naming / restyling pipeline used by live captures. 11. `TranscriptionTaskManager` runs one diarize → transcribe → save pipeline at a time. When `LocalSpeakerPreferences` is enabled, queued meeting work also asks the core pipeline to diarize the local mic channel instead of treating it as a single "You" speaker. 12. A subscription on `taskManager.$lastSavedTranscriptURL` calls `MeetingTranscriptStyler.restyleTranscript(...)` and updates the recent-meetings UI state. -13. If the speaker review sheet shows multiple local speakers, the user can either name them individually or collapse them back to a single "You" track via the UI's "Keep as You" path. -14. Failed meetings can be retried, deleted, or dismissed from the Settings meetings page, with `MeetingFailureKind` providing stable failure categories and `MeetingFailureCopy` keeping error copy consistent across retryable and non-retryable states. +13. After a transcript is saved, `MeetingAudioStorageManager` compresses retained WAV audio to M4A and applies the user's retention setting. Launch and Settings changes also run a backfill pass over existing Transcripted meeting transcripts. +14. If the speaker review sheet shows multiple local speakers, the user can either name them individually or collapse them back to a single "You" track via the UI's "Keep as You" path. +15. Failed meetings can be retried, deleted, or dismissed from the Settings meetings page, with `MeetingFailureKind` providing stable failure categories and `MeetingFailureCopy` keeping error copy consistent across retryable and non-retryable states. ## Key invariants @@ -49,6 +51,7 @@ - `MeetingSTTAdapter.cleanup()` only clears the prepared meeting model. `TranscriptedAppState` owns STT engine lifecycle for the whole app. - Meeting captures should follow the current capture library, while databases, logs, and temp recordings stay under the app-owned Transcripted Application Support folders. - Imported meeting audio should be copied into app-controlled scratch space before transcription so later cleanup and metadata writes stay consistent with live captures. +- Retained-audio maintenance must only manage Transcripted meeting transcripts and app-owned retained audio filenames. A transcript is only storage-owned when its frontmatter has `capture_type: meeting` and a valid `capture_id` or `transcript_id`. Be very conservative with deletion: Markdown transcripts stay, unrelated files in capture folders stay, symlinked audio folders are ignored, and converted or pre-existing M4A files should be owner-only. - Meeting recording cancellation must be explicit, visible, and confirmed because discard deletes the captured audio. Do not wire Escape to meeting cancellation. - `MeetingPromptDetector` can prompt from either upcoming calendar events or recently active supported meeting apps (Zoom, Teams, Webex, FaceTime, plus browser-hosted providers like Google Meet). - Prompt dismissals are provider- and source-aware: runtime-only prompts can remind sooner, calendar-linked prompts can stay suppressed until the next relevant window, and Teams gets a longer minimum dismiss interval. @@ -66,6 +69,8 @@ Meeting capture artifacts live under `/meetings/`: - `*.md` - `audio/*_audio/` retained mic/system audio copied from successful meeting captures +- retained audio is compressed from WAV to M4A after transcript save; retention cleanup uses Transcripted transcript frontmatter date, not Markdown edit time +- retained-audio backfill skips orphaned, failed, non-Transcripted, and symlinked audio folders instead of guessing ownership App-owned meeting state lives under `~/Library/Application Support/Transcripted/state/`: @@ -106,6 +111,7 @@ Relevant direct coverage: - `Tests/MeetingAudioInactivityDetectorTests.swift` - `Tests/MeetingPromptDetectorTests.swift` - `Tests/MeetingSessionUIPolicyTests.swift` +- `Tests/MeetingAudioStorageManagerTests.swift` - `Tests/MeetingTranscriptStylerTests.swift` - `Tests/SpeakerNamingPolicyTests.swift` - `Tests/Integration/AppCoreIntegrationSmoke.swift` diff --git a/Sources/Meeting/MeetingAudioStorageManager.swift b/Sources/Meeting/MeetingAudioStorageManager.swift index 2d50bb33..9e95d98c 100644 --- a/Sources/Meeting/MeetingAudioStorageManager.swift +++ b/Sources/Meeting/MeetingAudioStorageManager.swift @@ -52,7 +52,15 @@ struct MeetingAudioStorageMaintenanceResult: Equatable { } enum MeetingAudioStorageManager { + private static let frontmatterPreviewByteLimit = 64 * 1024 private static let staleTemporaryM4AAge: TimeInterval = 10 * 60 + private static let managedAudioStems = ["microphone", "system_audio", "recording", "playback"] + private static let transcriptDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + return formatter + }() @discardableResult static func processExistingRetainedAudio( @@ -135,23 +143,60 @@ enum MeetingAudioStorageManager { var removedCount = 0 for directory in directories { - guard let referenceDate = transcriptDate( + guard let transcript = transcriptInfo( forAudioDirectory: directory, meetingsFolder: meetingsFolder, fileManager: fileManager - ), referenceDate < cutoff else { + ), transcript.referenceDate < cutoff else { continue } + guard pruneManagedAudioFiles(in: directory, fileManager: fileManager) else { + continue + } + removedCount += 1 + + if isDirectoryEmpty(directory, fileManager: fileManager) { + try? fileManager.removeItem(at: directory) + } + } + + return removedCount + } + + private static func pruneManagedAudioFiles(in audioDirectory: URL, fileManager: FileManager) -> Bool { + guard isSafeNonSymlinkDirectory(audioDirectory, fileManager: fileManager) else { return false } + guard let files = try? fileManager.contentsOfDirectory( + at: audioDirectory, + includingPropertiesForKeys: [.isRegularFileKey], + options: [] + ) else { + return false + } + + var removedAny = false + for file in files where isManagedRetainedAudioFile(file, fileManager: fileManager) + || isTranscriptedTemporaryM4AFileName(file) { do { - try fileManager.removeItem(at: directory) - removedCount += 1 + try fileManager.removeItem(at: file) + removedAny = true } catch { continue } } - return removedCount + return removedAny + } + + private static func isDirectoryEmpty(_ directory: URL, fileManager: FileManager) -> Bool { + guard isSafeNonSymlinkDirectory(directory, fileManager: fileManager) else { return false } + guard let remaining = try? fileManager.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil + ) else { + return false + } + return remaining.isEmpty } @discardableResult @@ -162,6 +207,7 @@ enum MeetingAudioStorageManager { converter: MeetingAudioFileConverting = AVFoundationMeetingAudioConverter(), validator: MeetingAudioFileValidating = AVFoundationMeetingAudioValidator() ) async -> Int { + guard isSafeNonSymlinkDirectory(audioDirectory, fileManager: fileManager) else { return 0 } removeStaleTemporaryM4AFiles(in: audioDirectory, now: now, fileManager: fileManager) guard let files = try? fileManager.contentsOfDirectory( @@ -172,11 +218,15 @@ enum MeetingAudioStorageManager { return 0 } + restrictRetainedM4AFiles(files, fileManager: fileManager) + var convertedCount = 0 - for sourceURL in files where isWAVFile(sourceURL, fileManager: fileManager) { + for sourceURL in files where isWAVFile(sourceURL, fileManager: fileManager) + && isManagedRetainedAudioFile(sourceURL, fileManager: fileManager) { let destinationURL = sourceURL.deletingPathExtension().appendingPathExtension("m4a") if validator.isUsableAudioFile(at: destinationURL, fileManager: fileManager) { + fileManager.restrictFileToOwnerOnly(at: destinationURL) try? fileManager.removeItem(at: sourceURL) continue } @@ -194,6 +244,7 @@ enum MeetingAudioStorageManager { try fileManager.removeItem(at: destinationURL) } try fileManager.moveItem(at: tempURL, to: destinationURL) + fileManager.restrictFileToOwnerOnly(at: destinationURL) try fileManager.removeItem(at: sourceURL) convertedCount += 1 } catch { @@ -212,6 +263,7 @@ enum MeetingAudioStorageManager { minimumAge: TimeInterval = staleTemporaryM4AAge, fileManager: FileManager = .default ) -> Int { + guard isSafeNonSymlinkDirectory(audioDirectory, fileManager: fileManager) else { return 0 } guard let files = try? fileManager.contentsOfDirectory( at: audioDirectory, includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey], @@ -224,7 +276,8 @@ enum MeetingAudioStorageManager { for file in files where isStaleTemporaryM4AFile( file, now: now, - minimumAge: minimumAge + minimumAge: minimumAge, + fileManager: fileManager ) { do { try fileManager.removeItem(at: file) @@ -241,9 +294,10 @@ enum MeetingAudioStorageManager { fileManager: FileManager ) -> [URL] { let audioRoot = meetingsFolder.appendingPathComponent("audio", isDirectory: true) + guard isSafeNonSymlinkDirectory(audioRoot, fileManager: fileManager) else { return [] } guard let directories = try? fileManager.contentsOfDirectory( at: audioRoot, - includingPropertiesForKeys: [.isDirectoryKey, .contentModificationDateKey], + includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey, .contentModificationDateKey], options: [.skipsHiddenFiles] ) else { return [] @@ -251,7 +305,7 @@ enum MeetingAudioStorageManager { return directories.filter { directory in isAudioArchiveDirectory(directory, fileManager: fileManager) - && transcriptDate( + && transcriptInfo( forAudioDirectory: directory, meetingsFolder: meetingsFolder, fileManager: fileManager @@ -268,39 +322,130 @@ enum MeetingAudioStorageManager { private static func isAudioArchiveDirectory(_ url: URL, fileManager: FileManager) -> Bool { guard url.lastPathComponent.hasSuffix("_audio") else { return false } - var isDirectory: ObjCBool = false - return fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) && isDirectory.boolValue + return isSafeNonSymlinkDirectory(url, fileManager: fileManager) + } + + private struct TranscriptInfo { + let referenceDate: Date } - private static func transcriptDate( + private static func transcriptInfo( forAudioDirectory audioDirectory: URL, meetingsFolder: URL, fileManager: FileManager - ) -> Date? { + ) -> TranscriptInfo? { let name = audioDirectory.lastPathComponent guard name.hasSuffix("_audio") else { return nil } let stem = String(name.dropLast("_audio".count)) let transcriptURL = meetingsFolder.appendingPathComponent(stem).appendingPathExtension("md") guard fileManager.fileExists(atPath: transcriptURL.path) else { return nil } - return modificationDate(for: transcriptURL) + guard let raw = try? previewString(at: transcriptURL), + isTranscriptedMeetingTranscript(raw) else { + return nil + } + + return TranscriptInfo(referenceDate: transcriptReferenceDate(for: transcriptURL, raw: raw)) + } + + private static func previewString(at url: URL) throws -> String { + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + let data = try handle.read(upToCount: frontmatterPreviewByteLimit) ?? Data() + return String(decoding: data, as: UTF8.self) + } + + private static func isTranscriptedMeetingTranscript(_ raw: String) -> Bool { + guard raw.contains("\n## Full Transcript") || raw.contains("\n## Transcript"), + let values = frontmatterValues(in: raw), + values["capture_type"]?.lowercased() == "meeting" else { + return false + } + + return isValidTranscriptIdentifier(values["transcript_id"]) + || isValidTranscriptIdentifier(values["capture_id"]) + } + + private static func transcriptReferenceDate(for url: URL, raw: String) -> Date { + if let frontmatterDate = frontmatterRecordedDate(in: raw) { + return frontmatterDate + } + + let values = try? url.resourceValues(forKeys: [.creationDateKey, .contentModificationDateKey]) + return values?.creationDate ?? values?.contentModificationDate ?? Date() + } + + private static func frontmatterRecordedDate(in raw: String) -> Date? { + guard let values = frontmatterValues(in: raw) else { return nil } + guard let date = values["date"], let time = values["time"] else { return nil } + return transcriptDateFormatter.date(from: "\(date) \(time)") } - private static func modificationDate(for url: URL) -> Date? { - (try? url.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate + private static func frontmatterValues(in raw: String) -> [String: String]? { + guard let lines = frontmatterLines(in: raw) else { return nil } + var values: [String: String] = [:] + for line in lines { + let parts = line.split(separator: ":", maxSplits: 1).map(String.init) + guard parts.count == 2 else { continue } + values[parts[0].trimmingCharacters(in: .whitespaces)] = parts[1] + .trimmingCharacters(in: .whitespaces) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } + return values + } + + private static func isValidTranscriptIdentifier(_ value: String?) -> Bool { + guard let value else { return false } + return UUID(uuidString: value) != nil + } + + private static func frontmatterLines(in raw: String) -> [String]? { + guard raw.hasPrefix("---\n"), + let endRange = raw.range( + of: "\n---\n", + range: raw.index(raw.startIndex, offsetBy: 4).. Bool { guard url.pathExtension.localizedCaseInsensitiveCompare("wav") == .orderedSame else { return false } + guard !isSymbolicLink(url, fileManager: fileManager) else { return false } let values = try? url.resourceValues(forKeys: [.isRegularFileKey]) return values?.isRegularFile == true } + private static func isManagedRetainedAudioFile(_ url: URL, fileManager: FileManager) -> Bool { + guard ["wav", "m4a"].contains(where: { url.pathExtension.localizedCaseInsensitiveCompare($0) == .orderedSame }) else { + return false + } + guard !isSymbolicLink(url, fileManager: fileManager) else { return false } + let values = try? url.resourceValues(forKeys: [.isRegularFileKey]) + guard values?.isRegularFile == true else { return false } + let stem = url.deletingPathExtension().lastPathComponent + return managedAudioStems.contains { managedStem in + stem == managedStem || stem.range(of: #"^\#(managedStem)-[0-9]+$"#, options: .regularExpression) != nil + } + } + + private static func restrictRetainedM4AFiles(_ files: [URL], fileManager: FileManager) { + for file in files where file.pathExtension.localizedCaseInsensitiveCompare("m4a") == .orderedSame + && isManagedRetainedAudioFile(file, fileManager: fileManager) { + fileManager.restrictFileToOwnerOnly(at: file) + } + } + private static func isStaleTemporaryM4AFile( _ url: URL, now: Date, - minimumAge: TimeInterval + minimumAge: TimeInterval, + fileManager: FileManager ) -> Bool { guard isTranscriptedTemporaryM4AFileName(url) else { return false } + guard !isSymbolicLink(url, fileManager: fileManager) else { return false } let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .contentModificationDateKey]) guard values?.isRegularFile == true, let modified = values?.contentModificationDate else { return false @@ -322,7 +467,25 @@ enum MeetingAudioStorageManager { private static func hasNonEmptyFile(at url: URL, fileManager: FileManager) -> Bool { guard fileManager.fileExists(atPath: url.path) else { return false } + guard !isSymbolicLink(url, fileManager: fileManager) else { return false } let values = try? url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]) return values?.isRegularFile == true && (values?.fileSize ?? 0) > 0 } + + private static func isSafeNonSymlinkDirectory(_ url: URL, fileManager: FileManager) -> Bool { + guard !isSymbolicLink(url, fileManager: fileManager) else { return false } + var isDirectory: ObjCBool = false + return fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) && isDirectory.boolValue + } + + private static func isSymbolicLink(_ url: URL, fileManager: FileManager) -> Bool { + if let attributes = try? fileManager.attributesOfItem(atPath: url.path), + let type = attributes[.type] as? FileAttributeType, + type == .typeSymbolicLink { + return true + } + + let values = try? url.resourceValues(forKeys: [.isSymbolicLinkKey]) + return values?.isSymbolicLink == true + } } diff --git a/Sources/Meeting/MeetingTranscriptStyler.swift b/Sources/Meeting/MeetingTranscriptStyler.swift index 9af95ac4..770ddb66 100644 --- a/Sources/Meeting/MeetingTranscriptStyler.swift +++ b/Sources/Meeting/MeetingTranscriptStyler.swift @@ -488,9 +488,9 @@ enum MeetingTranscriptStyler { event: "meeting_transcript_rename_failed", message: "Failed to rename styled transcript", context: [ - "from": url.lastPathComponent, - "to": targetURL.lastPathComponent, - "error": error.localizedDescription + "sourceExists": "\(fm.fileExists(atPath: url.path))", + "targetExists": "\(fm.fileExists(atPath: targetURL.path))", + "errorType": "\(type(of: error))" ] ) return url @@ -517,9 +517,9 @@ enum MeetingTranscriptStyler { event: "meeting_audio_directory_rename_failed", message: "Failed to rename retained meeting audio", context: [ - "from": sourceURL.lastPathComponent, - "to": finalURL.lastPathComponent, - "error": error.localizedDescription + "sourceExists": "\(fm.fileExists(atPath: sourceURL.path))", + "targetExists": "\(fm.fileExists(atPath: finalURL.path))", + "errorType": "\(type(of: error))" ] ) } @@ -573,12 +573,16 @@ enum MeetingTranscriptStyler { let fm = FileManager.default var candidateStem = preferredStem var suffix = 2 + let originalAudioDirectory = audioDirectoryURL(for: originalURL) while suffix <= 999 { let candidateURL = directory.appendingPathComponent(candidateStem).appendingPathExtension("md") let markdownTaken = candidateURL != originalURL && fm.fileExists(atPath: candidateURL.path) + let candidateAudioDirectory = audioDirectoryURL(for: candidateURL) + let audioTaken = candidateAudioDirectory != originalAudioDirectory + && fm.fileExists(atPath: candidateAudioDirectory.path) - if !markdownTaken { + if !markdownTaken && !audioTaken { return candidateURL } diff --git a/Sources/Support/CLAUDE.md b/Sources/Support/CLAUDE.md index acef21d1..bb918481 100644 --- a/Sources/Support/CLAUDE.md +++ b/Sources/Support/CLAUDE.md @@ -4,9 +4,10 @@ `Sources/Support/` holds app-wide helpers that do not belong to a single UI or pipeline surface. These types mostly wrap persisted preferences, shared constants, permission access, storage paths, or low-level paste / launch behavior used across dictation and meetings. -## Files (19 Swift files) +## Files (20 Swift files) - `ActivationPolicyController.swift` — combines the Dock toggle with live-recording safety so Transcripted can idle as menu-bar-only but still surface itself in the macOS force-quit dialog during active capture +- `AudioStoragePreferences.swift` — persisted meeting-audio retention window for Settings and background retained-audio maintenance - `ClaudeDesktopIntegrationInstaller.swift` — installs the bundled read-only MCP helper for Claude Desktop, safely merges Claude's config JSON, and runs the helper self-test - `ClipboardRestoringTextPaster.swift` — paste helper that preserves clipboard contents while inserting the latest dictation into the target app - `CustomDictionaryPreferences.swift` — persisted custom spoken-term replacements plus text post-processing helpers @@ -39,6 +40,7 @@ - `DockVisibilityPreferences` is the canonical storage layer for the General Dock toggle. Keep the key and notification stable so upgrades preserve the setting. - `ActivationPolicyController` is the canonical place for the app's force-quit visibility policy. Keep Dock/icon activation-policy switching out of recording controllers and UI views. - `MicrophoneProcessingPreferences` is the canonical switch for meeting-mic cleanup mode. Default behavior is software AGC without playback ducking; Apple voice processing stays opt-in because it can duck other apps during recording. +- `AudioStoragePreferences` only stores the retention choice. Destructive cleanup behavior belongs in `Sources/Meeting/MeetingAudioStorageManager.swift` and should stay conservative: the Settings UI should ask before switching into a destructive 7-day or 30-day cleanup window. ## Verification @@ -53,6 +55,7 @@ Relevant direct coverage includes: - `Tests/ClaudeDesktopIntegrationInstallerTests.swift` - `Tests/ActivationPolicyControllerTests.swift` +- `Tests/AudioStoragePreferencesTests.swift` - `Tests/ClipboardRestoringTextPasterTests.swift` - `Tests/CustomDictionaryPreferencesTests.swift` - `Tests/DictationAutoSendPreferencesTests.swift` diff --git a/Sources/TranscriptedCore/Pipeline/TranscriptionPipelineRunner.swift b/Sources/TranscriptedCore/Pipeline/TranscriptionPipelineRunner.swift index 0e1be24a..3f6dec66 100644 --- a/Sources/TranscriptedCore/Pipeline/TranscriptionPipelineRunner.swift +++ b/Sources/TranscriptedCore/Pipeline/TranscriptionPipelineRunner.swift @@ -435,15 +435,15 @@ extension TranscriptionTaskManager { archiveRoot: retainedAudioDirectory ) AppLogger.pipeline.info("Retained meeting audio files", [ - "directory": retainedAudio.directory.lastPathComponent, - "mic": retainedAudio.micURL?.lastPathComponent ?? "none", - "system": retainedAudio.systemURL?.lastPathComponent ?? "none" + "hasMic": "\(retainedAudio.micURL != nil)", + "hasSystem": "\(retainedAudio.systemURL != nil)" ]) return true } catch { AppLogger.pipeline.warning("Failed to retain meeting audio; leaving scratch files in place", [ - "transcript": savedURL.lastPathComponent, - "error": error.localizedDescription + "hasMic": "\(micURL != nil)", + "hasSystem": "true", + "errorType": "\(type(of: error))" ]) return false } diff --git a/Sources/TranscriptedCore/Pipeline/TranscriptionTaskManager.swift b/Sources/TranscriptedCore/Pipeline/TranscriptionTaskManager.swift index fc6f91a3..2a71e3ba 100644 --- a/Sources/TranscriptedCore/Pipeline/TranscriptionTaskManager.swift +++ b/Sources/TranscriptedCore/Pipeline/TranscriptionTaskManager.swift @@ -89,13 +89,17 @@ public class TranscriptionTaskManager: ObservableObject { guard systemURL != nil else { let errorMessage = PipelineError.missingSystemAudio.localizedDescription AppLogger.pipeline.warning("Rejecting transcription — system audio capture is missing") - archiveFailedRecordingAudioIfConfigured( + let retainedAudio = archiveFailedRecordingAudioIfConfigured( micURL: micURL, systemURL: nil, taskId: UUID() ) + let failedMicURL = retainedAudio?.micURL ?? micURL + if retainedAudio?.micURL != nil { + removeManagedCleanupFile(micURL, label: "archived failed mic scratch") + } failedTranscriptionManager.addFailedTranscription( - micAudioURL: micURL, + micAudioURL: failedMicURL, systemAudioURL: nil, errorMessage: errorMessage ) @@ -186,15 +190,23 @@ public class TranscriptionTaskManager: ObservableObject { AppLogger.pipeline.error("Transcription task failed", ["taskId": "\(task.id)", "error": "\(error.localizedDescription)"]) await MainActor.run { - self.archiveFailedRecordingAudioIfConfigured( + let retainedAudio = self.archiveFailedRecordingAudioIfConfigured( micURL: micURL, systemURL: systemURL, taskId: task.id ) + let failedMicURL = retainedAudio?.micURL ?? micURL + let failedSystemURL = retainedAudio?.systemURL ?? systemURL + if retainedAudio?.micURL != nil { + self.removeManagedCleanupFile(micURL, label: "archived failed mic scratch") + } + if retainedAudio?.systemURL != nil { + self.removeManagedCleanupFile(systemURL, label: "archived failed system scratch") + } self.displayStatus = .failed(message: "Transcription failed") self.failedTranscriptionManager.addFailedTranscription( - micAudioURL: micURL, - systemAudioURL: systemURL, + micAudioURL: failedMicURL, + systemAudioURL: failedSystemURL, errorMessage: error.localizedDescription ) self.sendFailureNotification(errorMessage: error.localizedDescription) @@ -542,8 +554,8 @@ public class TranscriptionTaskManager: ObservableObject { micURL: URL?, systemURL: URL?, taskId: UUID - ) { - guard let retainedAudioDirectory = resolvedRetainedAudioDirectory() else { return } + ) -> RetainedRecordingAudio? { + guard let retainedAudioDirectory = resolvedRetainedAudioDirectory() else { return nil } let failedStem = "Failed_\(DateFormattingHelper.formatFilename(Date()))_\(String(taskId.uuidString.prefix(8)))" let placeholderTranscriptURL = retainedAudioDirectory @@ -558,15 +570,16 @@ public class TranscriptionTaskManager: ObservableObject { archiveRoot: retainedAudioDirectory ) AppLogger.pipeline.info("Retained failed meeting audio files", [ - "directory": retainedAudio.directory.lastPathComponent, - "mic": retainedAudio.micURL?.lastPathComponent ?? "none", - "system": retainedAudio.systemURL?.lastPathComponent ?? "none" + "hasMic": "\(retainedAudio.micURL != nil)", + "hasSystem": "\(retainedAudio.systemURL != nil)" ]) + return retainedAudio } catch { AppLogger.pipeline.warning("Failed to retain failed meeting audio", [ "taskId": taskId.uuidString, - "error": error.localizedDescription + "errorType": "\(type(of: error))" ]) + return nil } } } diff --git a/Sources/TranscriptedCore/Services/FailedTranscriptionManager.swift b/Sources/TranscriptedCore/Services/FailedTranscriptionManager.swift index dc510127..63b9cccd 100644 --- a/Sources/TranscriptedCore/Services/FailedTranscriptionManager.swift +++ b/Sources/TranscriptedCore/Services/FailedTranscriptionManager.swift @@ -158,6 +158,10 @@ public class FailedTranscriptionManager: ObservableObject { if let systemURL = failed.systemAudioURL { removeAudioFile(systemURL, label: "system audio") } + removeEmptyAudioArchiveDirectoryIfNeeded(containing: failed.micAudioURL) + if let systemURL = failed.systemAudioURL { + removeEmptyAudioArchiveDirectoryIfNeeded(containing: systemURL) + } // Remove from queue removeFailedTranscription(id: id) @@ -199,6 +203,10 @@ public class FailedTranscriptionManager: ObservableObject { if let systemURL = failure.systemAudioURL { removeAudioFile(systemURL, label: "cleanup system audio") } + removeEmptyAudioArchiveDirectoryIfNeeded(containing: failure.micAudioURL) + if let systemURL = failure.systemAudioURL { + removeEmptyAudioArchiveDirectoryIfNeeded(containing: systemURL) + } } let removedIds = Set(toRemove.map { $0.id }) @@ -224,6 +232,10 @@ public class FailedTranscriptionManager: ObservableObject { if let systemURL = failure.systemAudioURL { removeAudioFile(systemURL, label: "old failure system audio") } + removeEmptyAudioArchiveDirectoryIfNeeded(containing: failure.micAudioURL) + if let systemURL = failure.systemAudioURL { + removeEmptyAudioArchiveDirectoryIfNeeded(containing: systemURL) + } } let removedIds = Set(oldFailures.map { $0.id }) @@ -259,6 +271,27 @@ public class FailedTranscriptionManager: ObservableObject { } } + private func removeEmptyAudioArchiveDirectoryIfNeeded(containing url: URL) { + let directory = url.deletingLastPathComponent() + guard directory.lastPathComponent.hasSuffix("_audio"), + isSafeAudioURL(directory), + let remaining = try? FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil + ), + remaining.isEmpty else { + return + } + + do { + try FileManager.default.removeItem(at: directory) + } catch { + AppLogger.pipeline.warning("Failed to remove empty failed-audio directory", [ + "errorType": "\(type(of: error))" + ]) + } + } + private func isSafeAudioURL(_ url: URL) -> Bool { let canonicalURL = Self.canonicalFileURL(url) return allowedAudioRoots.contains { root in diff --git a/Sources/UI/Settings/TranscriptedSettingsView.swift b/Sources/UI/Settings/TranscriptedSettingsView.swift index e15f2d81..404958ce 100644 --- a/Sources/UI/Settings/TranscriptedSettingsView.swift +++ b/Sources/UI/Settings/TranscriptedSettingsView.swift @@ -61,6 +61,7 @@ struct TranscriptedSettingsView: View { @State private var copiedAgentMeetingID: String? @State private var meetingVoiceProcessingEnabled = MicrophoneProcessingPreferences.isVoiceProcessingEnabled() @State private var audioRetentionWindow = AudioStoragePreferences.deleteAudioAfter() + @State private var pendingAudioRetentionWindow: AudioRetentionWindow? @StateObject private var homeViewModel = HomeViewModel() @State private var homeActivityTab: HomeActivityTab = .meetings @State private var homeHeroMode: HomeHeroMode = .meeting @@ -414,6 +415,16 @@ struct TranscriptedSettingsView: View { dismissButton: .default(Text("OK")) ) } + .alert(item: $pendingAudioRetentionWindow) { window in + Alert( + title: Text("Delete old replay audio?"), + message: Text("Transcripted will keep your Markdown transcripts, but retained replay audio older than \(window.title) will be permanently removed now and cleaned up automatically later."), + primaryButton: .destructive(Text("Delete Old Audio")) { + applyAudioRetentionWindow(window) + }, + secondaryButton: .cancel() + ) + } } private var settingsContentTopPadding: CGFloat { @@ -1387,6 +1398,10 @@ struct TranscriptedSettingsView: View { Text(audioRetentionWindow.detail) .font(.caption) .foregroundStyle(.secondary) + + Text("Choosing 7 or 30 days asks before deleting old replay audio.") + .font(.caption) + .foregroundStyle(.secondary) } SettingsSection( @@ -1963,6 +1978,16 @@ struct TranscriptedSettingsView: View { } private func updateAudioRetentionWindow(_ window: AudioRetentionWindow) { + guard window != audioRetentionWindow else { return } + guard window.days == nil else { + pendingAudioRetentionWindow = window + return + } + + applyAudioRetentionWindow(window) + } + + private func applyAudioRetentionWindow(_ window: AudioRetentionWindow) { audioRetentionWindow = window trackSettingsAction("audio_retention_changed", page: .storage) AudioStoragePreferences.setDeleteAudioAfter(window) diff --git a/Tests/MeetingAudioStorageManagerTests.swift b/Tests/MeetingAudioStorageManagerTests.swift index c57434d3..ce46c9ba 100644 --- a/Tests/MeetingAudioStorageManagerTests.swift +++ b/Tests/MeetingAudioStorageManagerTests.swift @@ -8,6 +8,7 @@ func testMeetingAudioStorageManager() async { let transcriptURL = try! makeTranscript(named: "Customer Call", in: directory, ageDays: 1) let audioDirectory = makeAudioDirectory(for: transcriptURL) let wavURL = audioDirectory.appendingPathComponent("system_audio.wav") + let m4aURL = audioDirectory.appendingPathComponent("system_audio.m4a") try! Data("wav".utf8).write(to: wavURL) let converted = await MeetingAudioStorageManager.compressWAVAudio( @@ -19,9 +20,10 @@ func testMeetingAudioStorageManager() async { assertEqual(converted, 1, "one WAV file should be converted") assertFalse(FileManager.default.fileExists(atPath: wavURL.path), "original WAV should be removed after conversion") assertTrue( - FileManager.default.fileExists(atPath: audioDirectory.appendingPathComponent("system_audio.m4a").path), + FileManager.default.fileExists(atPath: m4aURL.path), "compressed M4A should exist" ) + assertEqual(posixPermissions(at: m4aURL), 0o600, "converted audio should be restricted to the owner") } await runSuite("MeetingAudioStorageManager keeps WAVs when conversion fails") { @@ -99,8 +101,10 @@ func testMeetingAudioStorageManager() async { let transcriptURL = try! makeTranscript(named: "Already Converted", in: directory, ageDays: 1) let audioDirectory = makeAudioDirectory(for: transcriptURL) let wavURL = audioDirectory.appendingPathComponent("recording.wav") + let m4aURL = audioDirectory.appendingPathComponent("recording.m4a") try! Data("wav".utf8).write(to: wavURL) - try! Data("m4a".utf8).write(to: audioDirectory.appendingPathComponent("recording.m4a")) + try! Data("m4a".utf8).write(to: m4aURL) + try! FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: m4aURL.path) let converted = await MeetingAudioStorageManager.compressWAVAudio( in: audioDirectory, @@ -110,6 +114,55 @@ func testMeetingAudioStorageManager() async { assertEqual(converted, 0, "already converted audio should not run conversion again") assertFalse(FileManager.default.fileExists(atPath: wavURL.path), "duplicate WAV should be removed when M4A is usable") + assertEqual(posixPermissions(at: m4aURL), 0o600, "existing retained M4A should be tightened before deleting WAV") + } + + await runSuite("MeetingAudioStorageManager tightens M4A-only retained audio") { + let directory = makeMeetingAudioStorageTestDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let transcriptURL = try! makeTranscript(named: "M4A Only", in: directory, ageDays: 1) + let audioDirectory = makeAudioDirectory(for: transcriptURL) + let m4aURL = audioDirectory.appendingPathComponent("recording.m4a") + try! Data("m4a".utf8).write(to: m4aURL) + try! FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: m4aURL.path) + + let result = await MeetingAudioStorageManager.processExistingRetainedAudio( + in: directory, + retentionWindow: .never, + converter: FakeMeetingAudioConverter(shouldFail: true), + validator: FakeMeetingAudioValidator() + ) + + assertEqual( + result, + MeetingAudioStorageMaintenanceResult(scannedDirectories: 1, convertedFiles: 0, prunedDirectories: 0), + "M4A-only archives should still be scanned for permission hardening" + ) + assertEqual(posixPermissions(at: m4aURL), 0o600, "existing retained M4A should be owner-only") + } + + await runSuite("MeetingAudioStorageManager ignores unrelated WAV files") { + let directory = makeMeetingAudioStorageTestDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let transcriptURL = try! makeTranscript(named: "Mixed Audio", in: directory, ageDays: 1) + let audioDirectory = makeAudioDirectory(for: transcriptURL) + let unrelatedWAV = audioDirectory.appendingPathComponent("voice-memo.wav") + try! Data("wav".utf8).write(to: unrelatedWAV) + + let converted = await MeetingAudioStorageManager.compressWAVAudio( + in: audioDirectory, + converter: FakeMeetingAudioConverter(), + validator: FakeMeetingAudioValidator() + ) + + assertEqual(converted, 0, "unrelated WAVs should not be treated as app-owned retained audio") + assertTrue(FileManager.default.fileExists(atPath: unrelatedWAV.path), "unrelated WAV should stay in place") + assertFalse( + FileManager.default.fileExists(atPath: audioDirectory.appendingPathComponent("voice-memo.m4a").path), + "unrelated WAV should not be converted" + ) } await runSuite("MeetingAudioStorageManager removes only stale Transcripted temp M4A files") { @@ -177,6 +230,147 @@ func testMeetingAudioStorageManager() async { assertTrue(FileManager.default.fileExists(atPath: newAudio.path), "new audio directory should stay") } + runSuite("MeetingAudioStorageManager prunes by transcript frontmatter date") { + let directory = makeMeetingAudioStorageTestDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let oldTranscript = try! makeTranscript(named: "Edited Old Call", in: directory, ageDays: 31) + let oldAudio = makeAudioDirectory(for: oldTranscript) + try! Data("m4a".utf8).write(to: oldAudio.appendingPathComponent("recording.m4a")) + try! FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: oldTranscript.path) + + let removed = MeetingAudioStorageManager.pruneRetainedAudio( + in: directory, + retentionWindow: .thirtyDays, + now: Date() + ) + + assertEqual(removed, 1, "frontmatter date should drive retention even when markdown mtime is fresh") + assertFalse(FileManager.default.fileExists(atPath: oldAudio.path), "old frontmatter date should prune audio") + assertTrue(FileManager.default.fileExists(atPath: oldTranscript.path), "transcript should stay") + } + + runSuite("MeetingAudioStorageManager leaves non-Transcripted markdown audio alone") { + let directory = makeMeetingAudioStorageTestDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let noteURL = directory.appendingPathComponent("Notes").appendingPathExtension("md") + try! "# Notes\n\nPlain user markdown.".write(to: noteURL, atomically: true, encoding: .utf8) + try! FileManager.default.setAttributes( + [.modificationDate: Calendar.current.date(byAdding: .day, value: -31, to: Date())!], + ofItemAtPath: noteURL.path + ) + let audioDirectory = makeAudioDirectory(for: noteURL) + let userAudio = audioDirectory.appendingPathComponent("recording.wav") + try! Data("wav".utf8).write(to: userAudio) + + let removed = MeetingAudioStorageManager.pruneRetainedAudio( + in: directory, + retentionWindow: .sevenDays, + now: Date() + ) + + assertEqual(removed, 0, "plain markdown should not qualify a folder for retention cleanup") + assertTrue(FileManager.default.fileExists(atPath: userAudio.path), "user audio should stay") + } + + await runSuite("MeetingAudioStorageManager ignores forged meeting-like markdown") { + let directory = makeMeetingAudioStorageTestDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let date = Calendar.current.date(byAdding: .day, value: -31, to: Date())! + let dateText = transcriptDateFormatter.string(from: date) + let timeText = transcriptTimeFormatter.string(from: date) + let noteURL = directory.appendingPathComponent("Forged Meeting").appendingPathExtension("md") + try! """ + --- + capture_type: meeting + date: "\(dateText)" + time: "\(timeText)" + duration: "1:00" + total_word_count: "4" + --- + + ## Full Transcript + + User-owned transcript-shaped note. + """.write(to: noteURL, atomically: true, encoding: .utf8) + let audioDirectory = makeAudioDirectory(for: noteURL) + let userAudio = audioDirectory.appendingPathComponent("recording.wav") + try! Data("wav".utf8).write(to: userAudio) + + let result = await MeetingAudioStorageManager.processExistingRetainedAudio( + in: directory, + retentionWindow: .thirtyDays, + now: Date(), + converter: FakeMeetingAudioConverter(), + validator: FakeMeetingAudioValidator() + ) + + assertEqual( + result, + MeetingAudioStorageMaintenanceResult(scannedDirectories: 0, convertedFiles: 0, prunedDirectories: 0), + "markdown needs Transcripted meeting IDs before storage maintenance owns its audio" + ) + assertTrue(FileManager.default.fileExists(atPath: userAudio.path), "user audio should stay") + assertFalse( + FileManager.default.fileExists(atPath: audioDirectory.appendingPathComponent("recording.m4a").path), + "forged meeting-like markdown should not trigger backfill conversion" + ) + } + + runSuite("MeetingAudioStorageManager ignores symlinked audio directories") { + let directory = makeMeetingAudioStorageTestDirectory() + let externalDirectory = makeMeetingAudioStorageTestDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + defer { try? FileManager.default.removeItem(at: externalDirectory) } + + let transcriptURL = try! makeTranscript(named: "Symlink Call", in: directory, ageDays: 31) + let audioRoot = transcriptURL + .deletingLastPathComponent() + .appendingPathComponent("audio", isDirectory: true) + try! FileManager.default.createDirectory(at: audioRoot, withIntermediateDirectories: true) + let externalAudioDirectory = externalDirectory.appendingPathComponent("Symlink Call_audio", isDirectory: true) + try! FileManager.default.createDirectory(at: externalAudioDirectory, withIntermediateDirectories: true) + let externalAudio = externalAudioDirectory.appendingPathComponent("recording.m4a") + try! Data("m4a".utf8).write(to: externalAudio) + let symlinkURL = audioRoot.appendingPathComponent("Symlink Call_audio", isDirectory: true) + try! FileManager.default.createSymbolicLink(at: symlinkURL, withDestinationURL: externalAudioDirectory) + + let removed = MeetingAudioStorageManager.pruneRetainedAudio( + in: directory, + retentionWindow: .thirtyDays, + now: Date() + ) + + assertEqual(removed, 0, "symlinked audio directories should never be pruned") + assertTrue(FileManager.default.fileExists(atPath: externalAudio.path), "external symlink target should stay untouched") + assertTrue(FileManager.default.fileExists(atPath: symlinkURL.path), "symlink should stay untouched") + } + + runSuite("MeetingAudioStorageManager prunes only managed files from mixed directories") { + let directory = makeMeetingAudioStorageTestDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let transcriptURL = try! makeTranscript(named: "Mixed Retention", in: directory, ageDays: 31) + let audioDirectory = makeAudioDirectory(for: transcriptURL) + let managedAudio = audioDirectory.appendingPathComponent("recording.m4a") + let unrelatedAudio = audioDirectory.appendingPathComponent("voice-memo.wav") + try! Data("m4a".utf8).write(to: managedAudio) + try! Data("wav".utf8).write(to: unrelatedAudio) + + let removed = MeetingAudioStorageManager.pruneRetainedAudio( + in: directory, + retentionWindow: .thirtyDays, + now: Date() + ) + + assertEqual(removed, 1, "managed audio should be pruned") + assertFalse(FileManager.default.fileExists(atPath: managedAudio.path), "managed audio should be removed") + assertTrue(FileManager.default.fileExists(atPath: unrelatedAudio.path), "unrelated audio should stay") + assertTrue(FileManager.default.fileExists(atPath: audioDirectory.path), "mixed directory should stay for unrelated files") + } + runSuite("MeetingAudioStorageManager never window does not prune") { let directory = makeMeetingAudioStorageTestDirectory() defer { try? FileManager.default.removeItem(at: directory) } @@ -305,12 +499,50 @@ private func makeMeetingAudioStorageTestDirectory() -> URL { private func makeTranscript(named name: String, in directory: URL, ageDays: Int) throws -> URL { let url = directory.appendingPathComponent(name).appendingPathExtension("md") - try "# \(name)\n".write(to: url, atomically: true, encoding: .utf8) let date = Calendar.current.date(byAdding: .day, value: -ageDays, to: Date())! + let dateText = transcriptDateFormatter.string(from: date) + let timeText = transcriptTimeFormatter.string(from: date) + try """ + --- + capture_id: "\(UUID().uuidString)" + capture_type: meeting + transcript_id: "\(UUID().uuidString)" + date: "\(dateText)" + time: "\(timeText)" + duration: "1:00" + total_word_count: "4" + mic_utterances: "1" + system_utterances: "0" + --- + + ## Full Transcript + + **[00:00] [Mic/You]** + Test transcript body. + """.write(to: url, atomically: true, encoding: .utf8) try FileManager.default.setAttributes([.modificationDate: date], ofItemAtPath: url.path) return url } +private func posixPermissions(at url: URL) -> Int? { + let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) + return (attributes?[.posixPermissions] as? NSNumber)?.intValue +} + +private let transcriptDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + return formatter +}() + +private let transcriptTimeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "HH:mm:ss" + return formatter +}() + private func makeAudioDirectory(for transcriptURL: URL) -> URL { let directory = transcriptURL .deletingLastPathComponent() diff --git a/Tests/MeetingTranscriptStylerTests.swift b/Tests/MeetingTranscriptStylerTests.swift index ce34416d..7cab0345 100644 --- a/Tests/MeetingTranscriptStylerTests.swift +++ b/Tests/MeetingTranscriptStylerTests.swift @@ -10,6 +10,7 @@ func testMeetingTranscriptStyler() { testMeetingTranscriptStylerPreviewReadsBoundedMeetingMetadata() testMeetingTranscriptStylerPreviewRejectsPlainMarkdown() testMeetingTranscriptStylerRenamesRetainedAudioDirectory() + testMeetingTranscriptStylerAvoidsAudioDirectoryCollisions() testMeetingTranscriptStylerPreservesObsidianSpeakerLinks() } } @@ -152,6 +153,35 @@ private func testMeetingTranscriptStylerRenamesRetainedAudioDirectory() { assertFalse(FileManager.default.fileExists(atPath: audioDirectory.path), "Original retained audio directory should be replaced") } +private func testMeetingTranscriptStylerAvoidsAudioDirectoryCollisions() { + let directory = makeTemporaryTestDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let originalStem = "Call_2026-04-07_09-14-00" + let transcriptURL = directory.appendingPathComponent("\(originalStem).md") + let audioRoot = directory.appendingPathComponent("audio", isDirectory: true) + let originalAudioDirectory = audioRoot.appendingPathComponent("\(originalStem)_audio", isDirectory: true) + let existingAudioDirectory = audioRoot.appendingPathComponent("Meeting with Alex_audio", isDirectory: true) + let movedAudioDirectory = audioRoot.appendingPathComponent("Meeting with Alex 2_audio", isDirectory: true) + try? FileManager.default.createDirectory(at: originalAudioDirectory, withIntermediateDirectories: true) + try? FileManager.default.createDirectory(at: existingAudioDirectory, withIntermediateDirectories: true) + FileManager.default.createFile( + atPath: originalAudioDirectory.appendingPathComponent("microphone.wav").path, + contents: Data("mic".utf8) + ) + try? sampleMeetingTranscript().write(to: transcriptURL, atomically: true, encoding: .utf8) + + let styled = MeetingTranscriptStyler.restyleTranscript(at: transcriptURL) + + assertEqual(styled.url.lastPathComponent, "Meeting with Alex 2.md", "Styler should avoid transcript/audio collisions together") + assertTrue(FileManager.default.fileExists(atPath: movedAudioDirectory.path), "Audio directory should keep the matching transcript stem") + assertTrue( + FileManager.default.fileExists(atPath: movedAudioDirectory.appendingPathComponent("microphone.wav").path), + "Retained audio should move to the collision-free matching directory" + ) + assertTrue(FileManager.default.fileExists(atPath: existingAudioDirectory.path), "Existing audio directory should not be overwritten") +} + private func testMeetingTranscriptStylerPreservesObsidianSpeakerLinks() { let directory = makeTemporaryTestDirectory() defer { try? FileManager.default.removeItem(at: directory) } diff --git a/Tests/TranscriptedCoreTests/TranscriptionTaskManagerMetadataTests.swift b/Tests/TranscriptedCoreTests/TranscriptionTaskManagerMetadataTests.swift index cd73f595..bbaade91 100644 --- a/Tests/TranscriptedCoreTests/TranscriptionTaskManagerMetadataTests.swift +++ b/Tests/TranscriptedCoreTests/TranscriptionTaskManagerMetadataTests.swift @@ -101,6 +101,37 @@ final class TranscriptionTaskManagerMetadataTests: XCTestCase { XCTAssertEqual(message, "System audio required") } + func testMissingSystemAudioQueuesRetainedArchiveAndRemovesScratch() throws { + let retainedAudioDirectory = tempDirectory + .appendingPathComponent("transcripts", isDirectory: true) + .appendingPathComponent("audio", isDirectory: true) + let manager = makeManager(retainedAudioDirectory: retainedAudioDirectory) + let micScratchDirectory = tempDirectory.appendingPathComponent("audio") + try FileManager.default.createDirectory(at: micScratchDirectory, withIntermediateDirectories: true) + let micURL = micScratchDirectory.appendingPathComponent("mic.wav") + try writeMonoWAV(to: micURL, duration: 2.5) + + manager.startTranscription( + micURL: micURL, + systemURL: nil, + outputFolder: tempDirectory.appendingPathComponent("transcripts") + ) + + let failed = try XCTUnwrap(manager.failedTranscriptionManager.failedTranscriptions.first) + XCTAssertTrue( + failed.micAudioURL.path.hasPrefix(retainedAudioDirectory.path + "/"), + "failed queue should point at retained archive audio, not scratch audio" + ) + XCTAssertTrue(FileManager.default.fileExists(atPath: failed.micAudioURL.path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: micURL.path), "scratch mic audio should be removed after archiving") + + let archivedDirectory = failed.micAudioURL.deletingLastPathComponent() + manager.failedTranscriptionManager.deleteFailedTranscription(id: failed.id) + + XCTAssertFalse(FileManager.default.fileExists(atPath: failed.micAudioURL.path), "delete should remove archived failed audio") + XCTAssertFalse(FileManager.default.fileExists(atPath: archivedDirectory.path), "delete should remove the empty failed-audio directory") + } + func testStartImportedTranscriptionDoesNotDeleteOutOfSandboxFileWhenRejected() throws { let manager = makeManager() let externalURL = tempDirectory.appendingPathComponent("outside.wav")