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 new file mode 100644 index 00000000..9e95d98c --- /dev/null +++ b/Sources/Meeting/MeetingAudioStorageManager.swift @@ -0,0 +1,491 @@ +import AVFoundation +import Foundation + +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) + guard let session = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetAppleM4A) else { + throw MeetingAudioStorageError.exportSessionUnavailable + } + + session.shouldOptimizeForNetworkUse = false + + try await session.export(to: destinationURL, as: .m4a) + } +} + +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 + case emptyConvertedFile +} + +struct MeetingAudioStorageMaintenanceResult: Equatable { + let scannedDirectories: Int + let convertedFiles: Int + let prunedDirectories: Int +} + +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( + in meetingsFolder: URL, + retentionWindow: AudioRetentionWindow = AudioStoragePreferences.deleteAudioAfter(), + now: Date = Date(), + fileManager: FileManager = .default, + converter: MeetingAudioFileConverting = AVFoundationMeetingAudioConverter(), + validator: MeetingAudioFileValidating = AVFoundationMeetingAudioValidator() + ) 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, + now: now, + fileManager: fileManager, + converter: converter, + validator: validator + ) + } + + return MeetingAudioStorageMaintenanceResult( + scannedDirectories: directories.count, + convertedFiles: convertedFiles, + prunedDirectories: prunedDirectories + ) + } + + static func processSavedTranscript( + at transcriptURL: URL, + retentionWindow: AudioRetentionWindow = AudioStoragePreferences.deleteAudioAfter(), + now: Date = Date(), + fileManager: FileManager = .default, + converter: MeetingAudioFileConverting = AVFoundationMeetingAudioConverter(), + validator: MeetingAudioFileValidating = AVFoundationMeetingAudioValidator() + ) async { + let audioDirectory = audioDirectoryURL(forTranscript: transcriptURL) + await compressWAVAudio( + in: audioDirectory, + now: now, + fileManager: fileManager, + converter: converter, + validator: validator + ) + + 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 directories = audioArchiveDirectoriesWithTranscripts( + in: meetingsFolder, + fileManager: fileManager + ) + + var removedCount = 0 + for directory in directories { + guard let transcript = transcriptInfo( + forAudioDirectory: directory, + meetingsFolder: meetingsFolder, + fileManager: fileManager + ), 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: file) + removedAny = true + } catch { + continue + } + } + + 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 + static func compressWAVAudio( + in audioDirectory: URL, + now: Date = Date(), + fileManager: FileManager = .default, + 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( + at: audioDirectory, + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], + options: [.skipsHiddenFiles] + ) else { + return 0 + } + + restrictRetainedM4AFiles(files, fileManager: fileManager) + + var convertedCount = 0 + 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 + } + + let tempURL = audioDirectory + .appendingPathComponent(".\(sourceURL.deletingPathExtension().lastPathComponent)-\(UUID().uuidString)") + .appendingPathExtension("m4a") + + do { + try await converter.convertWAVToM4A(sourceURL: sourceURL, destinationURL: tempURL) + guard validator.isUsableAudioFile(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) + fileManager.restrictFileToOwnerOnly(at: destinationURL) + try fileManager.removeItem(at: sourceURL) + convertedCount += 1 + } catch { + try? fileManager.removeItem(at: tempURL) + continue + } + } + + return convertedCount + } + + @discardableResult + static func removeStaleTemporaryM4AFiles( + in audioDirectory: URL, + now: Date = Date(), + 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], + options: [] + ) else { + return 0 + } + + var removedCount = 0 + for file in files where isStaleTemporaryM4AFile( + file, + now: now, + minimumAge: minimumAge, + fileManager: fileManager + ) { + do { + try fileManager.removeItem(at: file) + removedCount += 1 + } catch { + continue + } + } + return removedCount + } + + private static func audioArchiveDirectoriesWithTranscripts( + in meetingsFolder: URL, + 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, .isSymbolicLinkKey, .contentModificationDateKey], + options: [.skipsHiddenFiles] + ) else { + return [] + } + + return directories.filter { directory in + isAudioArchiveDirectory(directory, fileManager: fileManager) + && transcriptInfo( + forAudioDirectory: directory, + meetingsFolder: meetingsFolder, + fileManager: fileManager + ) != nil + } + } + + 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 } + return isSafeNonSymlinkDirectory(url, fileManager: fileManager) + } + + private struct TranscriptInfo { + let referenceDate: Date + } + + private static func transcriptInfo( + forAudioDirectory audioDirectory: URL, + meetingsFolder: URL, + fileManager: FileManager + ) -> 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 } + 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 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, + 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 + } + 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 } + 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/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/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/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/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/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/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 eef5a148..404958ce 100644 --- a/Sources/UI/Settings/TranscriptedSettingsView.swift +++ b/Sources/UI/Settings/TranscriptedSettingsView.swift @@ -60,6 +60,8 @@ 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() + @State private var pendingAudioRetentionWindow: AudioRetentionWindow? @StateObject private var homeViewModel = HomeViewModel() @State private var homeActivityTab: HomeActivityTab = .meetings @State private var homeHeroMode: HomeHeroMode = .meeting @@ -413,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 { @@ -1352,6 +1364,46 @@ 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) + + Text("Choosing 7 or 30 days asks before deleting old replay audio.") + .font(.caption) + .foregroundStyle(.secondary) + } + SettingsSection( title: "Support Folders", detail: "Logs, cache, app state, and temporary audio." @@ -1925,6 +1977,28 @@ struct TranscriptedSettingsView: View { captureLibraryURL = FileManager.default.transcriptedCaptureLibraryDir } + 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) + Task.detached(priority: .utility) { + await MeetingAudioStorageManager.processExistingRetainedAudio( + 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..ce46c9ba --- /dev/null +++ b/Tests/MeetingAudioStorageManagerTests.swift @@ -0,0 +1,553 @@ +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") + let m4aURL = audioDirectory.appendingPathComponent("system_audio.m4a") + try! Data("wav".utf8).write(to: wavURL) + + let converted = await MeetingAudioStorageManager.compressWAVAudio( + in: audioDirectory, + converter: FakeMeetingAudioConverter(), + validator: FakeMeetingAudioValidator() + ) + + 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: 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") { + 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), + validator: FakeMeetingAudioValidator() + ) + + 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" + ) + } + + 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") + let m4aURL = audioDirectory.appendingPathComponent("recording.m4a") + try! Data("wav".utf8).write(to: wavURL) + try! Data("m4a".utf8).write(to: m4aURL) + try! FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: m4aURL.path) + + 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") + 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") { + 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) } + + 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 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) } + + 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") + } + + 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(), + validator: FakeMeetingAudioValidator() + ) + + 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(), + validator: FakeMeetingAudioValidator() + ) + + 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(), + validator: FakeMeetingAudioValidator() + ) + + 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 { + var shouldFail = false + var output = Data("m4a".utf8) + + func convertWAVToM4A(sourceURL: URL, destinationURL: URL) async throws { + if shouldFail { + throw MeetingAudioStorageError.conversionFailed + } + 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) + } +} + +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") + 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() + .appendingPathComponent("audio", isDirectory: true) + .appendingPathComponent("\(transcriptURL.deletingPathExtension().lastPathComponent)_audio", isDirectory: true) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory +} 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") diff --git a/docs/storage-paths.md b/docs/storage-paths.md index cfc1065e..07e2dc5b 100644 --- a/docs/storage-paths.md +++ b/docs/storage-paths.md @@ -35,6 +35,17 @@ 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. + +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` 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"