From d7e1645782c2d41f53432f2bbbf4ffdbd2a6582a Mon Sep 17 00:00:00 2001 From: Roman Marinsky Date: Mon, 1 Jun 2026 22:46:57 +0300 Subject: [PATCH 1/8] prepare 1.14.3 release --- Diduny.xcodeproj/project.pbxproj | 48 +++- .../xcschemes/Diduny TEST.xcscheme | 25 ++ Diduny/App/AppDelegate+Hotkeys.swift | 20 ++ Diduny/App/AppDelegate+MeetingRecording.swift | 20 ++ ...Delegate+MeetingTranslationRecording.swift | 20 ++ Diduny/App/AppDelegate.swift | 152 +++++++++++ Diduny/Core/Models/Recording.swift | 17 ++ Diduny/Core/Protocols/ServiceProtocols.swift | 1 + Diduny/Core/Services/AuthService.swift | 2 +- .../Core/Services/MeetingChunkStitcher.swift | 214 +++++++++++++++ .../Services/MeetingRecorderService.swift | 257 ++++++++++++++++-- Diduny/Core/Services/PushToTalkService.swift | 62 ++++- .../Core/Services/SleepFlushCoordinator.swift | 79 ++++++ .../Services/SystemAudioCaptureService.swift | 129 ++++++++- .../Storage/InProgressRecordingManifest.swift | 50 ++++ .../Storage/InProgressRecordingStore.swift | 113 ++++++++ .../Storage/RecordingsLibraryStorage.swift | 3 +- Diduny/Core/Storage/SettingsStorage.swift | 52 +++- .../Settings/ShortcutsSettingsView.swift | 69 ++++- .../InProgressRecordingStoreTests.swift | 160 +++++++++++ DidunyTests/MeetingChunkStitcherTests.swift | 156 +++++++++++ DidunyTests/PushToTalkServiceTests.swift | 92 +++++++ .../RecordingModelMigrationTests.swift | 169 ++++++++++++ DidunyTests/SleepFlushCoordinatorTests.swift | 150 ++++++++++ project.yml | 4 +- 25 files changed, 2012 insertions(+), 52 deletions(-) create mode 100644 Diduny/Core/Services/MeetingChunkStitcher.swift create mode 100644 Diduny/Core/Services/SleepFlushCoordinator.swift create mode 100644 Diduny/Core/Storage/InProgressRecordingManifest.swift create mode 100644 Diduny/Core/Storage/InProgressRecordingStore.swift create mode 100644 DidunyTests/InProgressRecordingStoreTests.swift create mode 100644 DidunyTests/MeetingChunkStitcherTests.swift create mode 100644 DidunyTests/PushToTalkServiceTests.swift create mode 100644 DidunyTests/RecordingModelMigrationTests.swift create mode 100644 DidunyTests/SleepFlushCoordinatorTests.swift diff --git a/Diduny.xcodeproj/project.pbxproj b/Diduny.xcodeproj/project.pbxproj index 607d7ac..0228dad 100644 --- a/Diduny.xcodeproj/project.pbxproj +++ b/Diduny.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 007C689901A82C26D1AFD661 /* NotchManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12B40184B3E0CE6E0736EE82 /* NotchManagerTests.swift */; }; 03386BD1EB00ACDF8DD60759 /* EscapeCancelServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46453045F6E830B4B5B48D4 /* EscapeCancelServiceTests.swift */; }; 03EBBAC2178925692C5B0F28 /* OnboardingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A7E2F21047194B8A5423BF5 /* OnboardingManager.swift */; }; + A1B2C3D4E5F60718293A4B5C /* PushToTalkServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2C3D4E5F60718293A4B5C6D /* PushToTalkServiceTests.swift */; }; 0B16463025082AB0F9EF90A5 /* LiveTranscriptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54C08AEA7B3D9495385E0990 /* LiveTranscriptView.swift */; }; 0CD34EAB840AD6CB69BF6ECB /* JobModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78ADA0228DC80DA0ACD8CE17 /* JobModels.swift */; }; 0FFD1A4567A90C61047FD763 /* SettingsStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = F91E893AF3E6C20B492D3CB7 /* SettingsStorage.swift */; }; @@ -30,6 +31,7 @@ 3FDACDAE1B8CF7A54595BE9D /* AudioPlaybackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39F37FBC8AD6C2935F89C66D /* AudioPlaybackService.swift */; }; 416BAE722E64C33169DE1E7F /* Supabase in Frameworks */ = {isa = PBXBuildFile; productRef = 1682C3BCEB8B3B3CE61588C9 /* Supabase */; }; 438C177832C516A8BD571C36 /* WhisperModelManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71FCAF64D600F8EFA8B20A2 /* WhisperModelManager.swift */; }; + 45F7A5C71CE4990E10BCFCA0 /* InProgressRecordingStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22ECCCBC345EE579CFE2AD5F /* InProgressRecordingStoreTests.swift */; }; 474BEF9A6D5E57064A709DFD /* NotchStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D34BB7F6DB9091D30035DAA0 /* NotchStateTests.swift */; }; 4845580D664C6BE034CF6835 /* MeetingChapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09A53A5FD4196FA12DF76141 /* MeetingChapter.swift */; }; 48D234DA0A725C503B5DEA75 /* HistoryPaletteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EED41A85ABE10582EF485F5D /* HistoryPaletteView.swift */; }; @@ -66,13 +68,17 @@ 948ADBEBD5ED5D02955F7E28 /* AudioRecorderService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BBD93A73F0292748E989CA5 /* AudioRecorderService.swift */; }; 951FD5A8D4F78E536BAB7740 /* OfflineModelsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40E7786C88C96F6C70862A1E /* OfflineModelsSettingsView.swift */; }; 9876807751B2F948878C2BDF /* NotchManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38BE9CBB73A253BFF5530CBD /* NotchManager.swift */; }; + 9C3AB3F7109D41F8F612797B /* RecordingModelMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44E5C44735AFF2B3983C06A1 /* RecordingModelMigrationTests.swift */; }; 9CBD3C0A924505FE999313E1 /* DictationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9220D86836586019D3FE253D /* DictationSettingsView.swift */; }; 9FD5B6627D4A5BE503A607FF /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91E1CBBAFEDD499C3BD8C811 /* AppState.swift */; }; A09E73F9CCF5D1023A1E3D2F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52166362DE73BAC6891FA206 /* AppDelegate.swift */; }; A546AE88A1817EF822FFEAE1 /* AudioCompressionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 741C4E6B6495E48C5D14392E /* AudioCompressionService.swift */; }; ACAD1265239FA9A32565242B /* AccountSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7074B6A737CE72D77BD9AE8F /* AccountSettingsView.swift */; }; B11EF2458AEAF401E1A03FD2 /* HistoryPaletteWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9357A98372102BF3B285A2AD /* HistoryPaletteWindowController.swift */; }; + B14B5AD4A7F63B34298C0946 /* MeetingChunkStitcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 893211B2A0981542B494E03B /* MeetingChunkStitcher.swift */; }; + B62B0933F7CA028942582A14 /* SleepFlushCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24A8CC23A7C474D5EFDFBAC1 /* SleepFlushCoordinator.swift */; }; BB39149383C71119B82FE3AD /* TranscriptCleanupService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEA00E234538355F573DD94 /* TranscriptCleanupService.swift */; }; + BBADC47A055725DC6323CDF5 /* InProgressRecordingManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = F94036B3B62E2E2464BB3750 /* InProgressRecordingManifest.swift */; }; BF3538E2F19CF3EB9D232E7A /* ObjCExceptionCatcher.m in Sources */ = {isa = PBXBuildFile; fileRef = 22385D60DC75174064B0DF0F /* ObjCExceptionCatcher.m */; }; BF50A7600E9D175A63D987E7 /* AuthServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A90DC34399C728FB2BD9C292 /* AuthServiceTests.swift */; }; C24B6F611448AF8CAD8E3DB9 /* MeetingSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA64DC6EAB5D1FB38CF7F517 /* MeetingSettingsView.swift */; }; @@ -85,6 +91,7 @@ CAA3AF319715F9DD611CCC8B /* PermissionsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52C8A25D3FCD6DE742A1274E /* PermissionsSettingsView.swift */; }; CBD76325F1DE55DA51EDD348 /* DeviceLevelMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 792A94C41C191C4A22150F2F /* DeviceLevelMonitor.swift */; }; CC8A0AB7ADA44B998EC3E45D /* ServiceProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26809CA162E096F4EA668768 /* ServiceProtocols.swift */; }; + CF97708F72740E5C035B3479 /* SleepFlushCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C84CF4471CC20033174713CB /* SleepFlushCoordinatorTests.swift */; }; D52AFEB3B25B529860D14116 /* AudioDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 118CE8BCA0160E31FD916B10 /* AudioDevice.swift */; }; D6203B3BADF3AA7E40186F11 /* HotkeyService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0491E26240E167BDB5DCACEE /* HotkeyService.swift */; }; D88F0858310A14339ADE4178 /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E4ECCACC4D64216B39204947 /* Metal.framework */; }; @@ -96,10 +103,12 @@ DFE3D7FFABE00254D2B2F164 /* RecordingQueueService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E819431AE3C66AA9B422954A /* RecordingQueueService.swift */; }; E2A1278EF4D20EC10319BBF4 /* Recording.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5D4FC33778F42CC48FF44C5 /* Recording.swift */; }; E3591F430A848CDAB8D090B4 /* SupabaseService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22F3DF3DBD7AB2A6B8F1B8D1 /* SupabaseService.swift */; }; + E49E98100BDACA9804603BC6 /* InProgressRecordingStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C38884243E804534AE077F9 /* InProgressRecordingStore.swift */; }; E4DA7F7432AC5A880F8D5CE8 /* GeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B850C5D55AE7652A88CE36F5 /* GeneralSettingsView.swift */; }; E52ED7C098FF6FA9C0554C28 /* RecordingsLibraryStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 865E86F8079EAE7A9BFB178E /* RecordingsLibraryStorage.swift */; }; E5A43EA1048E2701A2B0498B /* RecordingRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30A4F862A41E4EC48E5CAEFB /* RecordingRowView.swift */; }; E6C0A2BB2B96403F5AD765B3 /* KeyboardShortcuts in Frameworks */ = {isa = PBXBuildFile; productRef = 2E72BE3A05C906A8B16DBE4E /* KeyboardShortcuts */; }; + E7F1B035104E1A79711068B2 /* MeetingChunkStitcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5E0CC22FD7FC0DA86702AF /* MeetingChunkStitcherTests.swift */; }; E8268E59FC06679FC0BABA3D /* RecordingsLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2217BBA473011B6506284B3 /* RecordingsLibraryView.swift */; }; E90C0EBCE28E780D183A2C0E /* WhisperModelUnloadPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B14415E4A90B0A80E5B44F3B /* WhisperModelUnloadPolicy.swift */; }; EBD6CE2EED71E6F9BC63E53E /* MenuBarContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06C864D23789DFC70C7FE0D3 /* MenuBarContentView.swift */; }; @@ -137,8 +146,10 @@ 1999FC4728C79F46A514A1E4 /* AppDelegate+MeetingTranslationRecording.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+MeetingTranslationRecording.swift"; sourceTree = ""; }; 1A7E2F21047194B8A5423BF5 /* OnboardingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingManager.swift; sourceTree = ""; }; 22385D60DC75174064B0DF0F /* ObjCExceptionCatcher.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ObjCExceptionCatcher.m; sourceTree = ""; }; + 22ECCCBC345EE579CFE2AD5F /* InProgressRecordingStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InProgressRecordingStoreTests.swift; sourceTree = ""; }; 22F3DF3DBD7AB2A6B8F1B8D1 /* SupabaseService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupabaseService.swift; sourceTree = ""; }; 2430641841403AB68C61B536 /* KeychainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainManager.swift; sourceTree = ""; }; + 24A8CC23A7C474D5EFDFBAC1 /* SleepFlushCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SleepFlushCoordinator.swift; sourceTree = ""; }; 26809CA162E096F4EA668768 /* ServiceProtocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceProtocols.swift; sourceTree = ""; }; 2A69C86E608DDD0D38D8AB54 /* WhisperBridge.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = WhisperBridge.h; sourceTree = ""; }; 2ABF3F1FBD81398469047BB4 /* DidunyTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DidunyTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -153,11 +164,13 @@ 3E35E1F13A5F9761C79FB488 /* AppDelegate+Recording.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+Recording.swift"; sourceTree = ""; }; 3F9F609481B3D9169BF4B1E8 /* WhisperContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhisperContext.swift; sourceTree = ""; }; 40E7786C88C96F6C70862A1E /* OfflineModelsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineModelsSettingsView.swift; sourceTree = ""; }; + 44E5C44735AFF2B3983C06A1 /* RecordingModelMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingModelMigrationTests.swift; sourceTree = ""; }; 459B59FAABA91EB9AD4EB4EF /* ClipboardService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardService.swift; sourceTree = ""; }; 459DCE179DA235DE6208C013 /* SupportedLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportedLanguage.swift; sourceTree = ""; }; 4852162851F73B8352C95E25 /* RemoteConfigService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfigService.swift; sourceTree = ""; }; 4C5A21F6255692712508A475 /* Diduny.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Diduny.app; sourceTree = BUILT_PRODUCTS_DIR; }; 4CDE81820CACD577C91281C9 /* PushToTalkService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushToTalkService.swift; sourceTree = ""; }; + 4E5E0CC22FD7FC0DA86702AF /* MeetingChunkStitcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingChunkStitcherTests.swift; sourceTree = ""; }; 52166362DE73BAC6891FA206 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 52C8A25D3FCD6DE742A1274E /* PermissionsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsSettingsView.swift; sourceTree = ""; }; 54C08AEA7B3D9495385E0990 /* LiveTranscriptView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTranscriptView.swift; sourceTree = ""; }; @@ -181,6 +194,7 @@ 7D2CC58C907B4985941DCDA9 /* AppDelegate+Hotkeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+Hotkeys.swift"; sourceTree = ""; }; 865E86F8079EAE7A9BFB178E /* RecordingsLibraryStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingsLibraryStorage.swift; sourceTree = ""; }; 86F4203EEA094B3353F01444 /* AboutSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutSettingsView.swift; sourceTree = ""; }; + 893211B2A0981542B494E03B /* MeetingChunkStitcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingChunkStitcher.swift; sourceTree = ""; }; 8A2A0045FAC17572590BE16F /* EscapeCancelService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EscapeCancelService.swift; sourceTree = ""; }; 8D1A1D461E4C3229A9A8A486 /* AudioDeviceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioDeviceManager.swift; sourceTree = ""; }; 8E688172624B1B937A789D9E /* TranscriptionWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptionWindowController.swift; sourceTree = ""; }; @@ -188,10 +202,12 @@ 9220D86836586019D3FE253D /* DictationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictationSettingsView.swift; sourceTree = ""; }; 924F66A90537891F57F1CF0D /* PermissionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionManager.swift; sourceTree = ""; }; 9357A98372102BF3B285A2AD /* HistoryPaletteWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryPaletteWindowController.swift; sourceTree = ""; }; + 9C38884243E804534AE077F9 /* InProgressRecordingStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InProgressRecordingStore.swift; sourceTree = ""; }; A5A6F95E2FA37D325086B2BC /* OnboardingComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingComponents.swift; sourceTree = ""; }; A71FCAF64D600F8EFA8B20A2 /* WhisperModelManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhisperModelManager.swift; sourceTree = ""; }; A90DC34399C728FB2BD9C292 /* AuthServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthServiceTests.swift; sourceTree = ""; }; AB584F0BDA9252D1D71CE50D /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; + B2C3D4E5F60718293A4B5C6D /* PushToTalkServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushToTalkServiceTests.swift; sourceTree = ""; }; AF8E52BB2FE2DF4133F56731 /* Accelerate.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Accelerate.framework; path = System/Library/Frameworks/Accelerate.framework; sourceTree = SDKROOT; }; B14415E4A90B0A80E5B44F3B /* WhisperModelUnloadPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhisperModelUnloadPolicy.swift; sourceTree = ""; }; B2217BBA473011B6506284B3 /* RecordingsLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingsLibraryView.swift; sourceTree = ""; }; @@ -205,6 +221,7 @@ BED4AC73DF15E8FB1B784BE2 /* LiveTranscriptStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTranscriptStore.swift; sourceTree = ""; }; C1AFB9C5F772A874A404885C /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; C46453045F6E830B4B5B48D4 /* EscapeCancelServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EscapeCancelServiceTests.swift; sourceTree = ""; }; + C84CF4471CC20033174713CB /* SleepFlushCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SleepFlushCoordinatorTests.swift; sourceTree = ""; }; D052DF5E0E01BC0AD2742150 /* AudioSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSettingsView.swift; sourceTree = ""; }; D1B0622318D71BD3A1EEA701 /* ShortcutsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsSettingsView.swift; sourceTree = ""; }; D34BB7F6DB9091D30035DAA0 /* NotchStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchStateTests.swift; sourceTree = ""; }; @@ -219,6 +236,7 @@ F54AB0D8602B537FB2845507 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; F6206FCF30AD9AFE3530CB50 /* WhisperTranscriptionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhisperTranscriptionService.swift; sourceTree = ""; }; F91E893AF3E6C20B492D3CB7 /* SettingsStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsStorage.swift; sourceTree = ""; }; + F94036B3B62E2E2464BB3750 /* InProgressRecordingManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InProgressRecordingManifest.swift; sourceTree = ""; }; FB2D4C4C36B3BB89D1A2E8BD /* CloudTranscriptionServiceE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTranscriptionServiceE2ETests.swift; sourceTree = ""; }; FDED8DC543F3EEDBCAC5A5D7 /* RecordingsLibraryWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingsLibraryWindowController.swift; sourceTree = ""; }; FE4A0D0C3F98F775DE8EE4AA /* TranslationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationSettingsView.swift; sourceTree = ""; }; @@ -352,10 +370,12 @@ 792A94C41C191C4A22150F2F /* DeviceLevelMonitor.swift */, 8A2A0045FAC17572590BE16F /* EscapeCancelService.swift */, 0491E26240E167BDB5DCACEE /* HotkeyService.swift */, + 893211B2A0981542B494E03B /* MeetingChunkStitcher.swift */, FE81A3506184A31D459CC1DA /* MeetingRecorderService.swift */, 4CDE81820CACD577C91281C9 /* PushToTalkService.swift */, E819431AE3C66AA9B422954A /* RecordingQueueService.swift */, 4852162851F73B8352C95E25 /* RemoteConfigService.swift */, + 24A8CC23A7C474D5EFDFBAC1 /* SleepFlushCoordinator.swift */, 22F3DF3DBD7AB2A6B8F1B8D1 /* SupabaseService.swift */, 70DDD786E73A1532AE91FC20 /* SystemAudioCaptureService.swift */, FEEA00E234538355F573DD94 /* TranscriptCleanupService.swift */, @@ -438,6 +458,8 @@ 9383F20E88F2F41999886C12 /* Storage */ = { isa = PBXGroup; children = ( + F94036B3B62E2E2464BB3750 /* InProgressRecordingManifest.swift */, + 9C38884243E804534AE077F9 /* InProgressRecordingStore.swift */, 2430641841403AB68C61B536 /* KeychainManager.swift */, 924F66A90537891F57F1CF0D /* PermissionManager.swift */, 52C8A25D3FCD6DE742A1274E /* PermissionsSettingsView.swift */, @@ -499,8 +521,13 @@ A90DC34399C728FB2BD9C292 /* AuthServiceTests.swift */, FB2D4C4C36B3BB89D1A2E8BD /* CloudTranscriptionServiceE2ETests.swift */, C46453045F6E830B4B5B48D4 /* EscapeCancelServiceTests.swift */, + 22ECCCBC345EE579CFE2AD5F /* InProgressRecordingStoreTests.swift */, + 4E5E0CC22FD7FC0DA86702AF /* MeetingChunkStitcherTests.swift */, 12B40184B3E0CE6E0736EE82 /* NotchManagerTests.swift */, D34BB7F6DB9091D30035DAA0 /* NotchStateTests.swift */, + B2C3D4E5F60718293A4B5C6D /* PushToTalkServiceTests.swift */, + 44E5C44735AFF2B3983C06A1 /* RecordingModelMigrationTests.swift */, + C84CF4471CC20033174713CB /* SleepFlushCoordinatorTests.swift */, DCC34E52E29A97B99C122898 /* TranscriptCleanupServiceTests.swift */, ); path = DidunyTests; @@ -682,6 +709,8 @@ 48D234DA0A725C503B5DEA75 /* HistoryPaletteView.swift in Sources */, B11EF2458AEAF401E1A03FD2 /* HistoryPaletteWindowController.swift in Sources */, D6203B3BADF3AA7E40186F11 /* HotkeyService.swift in Sources */, + BBADC47A055725DC6323CDF5 /* InProgressRecordingManifest.swift in Sources */, + E49E98100BDACA9804603BC6 /* InProgressRecordingStore.swift in Sources */, 0CD34EAB840AD6CB69BF6ECB /* JobModels.swift in Sources */, 4EB7E17EB03BFF9E631E38A9 /* KeychainManager.swift in Sources */, DDA86150D6B671266D004BEE /* LiveTranscriptStore.swift in Sources */, @@ -689,6 +718,7 @@ 4A3450320A622E433A5E4907 /* Logger.swift in Sources */, 3230AC685182CD2F888DF3BC /* MeetingAudioSource.swift in Sources */, 4845580D664C6BE034CF6835 /* MeetingChapter.swift in Sources */, + B14B5AD4A7F63B34298C0946 /* MeetingChunkStitcher.swift in Sources */, 56700AFF9E4935DDDDA72D7D /* MeetingRecorderService.swift in Sources */, C24B6F611448AF8CAD8E3DB9 /* MeetingSettingsView.swift in Sources */, EBD6CE2EED71E6F9BC63E53E /* MenuBarContentView.swift in Sources */, @@ -719,6 +749,7 @@ 0FFD1A4567A90C61047FD763 /* SettingsStorage.swift in Sources */, C7A8368E38269D419E0C0832 /* SettingsView.swift in Sources */, 1FC13B68C69A30C9FD13E2F0 /* ShortcutsSettingsView.swift in Sources */, + B62B0933F7CA028942582A14 /* SleepFlushCoordinator.swift in Sources */, E3591F430A848CDAB8D090B4 /* SupabaseService.swift in Sources */, 8864F470B8EFDFF965ABAA76 /* SupportedLanguage.swift in Sources */, 8E61D4628397B11E5BCEE03D /* SystemAudioCaptureService.swift in Sources */, @@ -745,8 +776,13 @@ BF50A7600E9D175A63D987E7 /* AuthServiceTests.swift in Sources */, 154F9E85CEDA37FE7E88EE36 /* CloudTranscriptionServiceE2ETests.swift in Sources */, 03386BD1EB00ACDF8DD60759 /* EscapeCancelServiceTests.swift in Sources */, + 45F7A5C71CE4990E10BCFCA0 /* InProgressRecordingStoreTests.swift in Sources */, + E7F1B035104E1A79711068B2 /* MeetingChunkStitcherTests.swift in Sources */, 007C689901A82C26D1AFD661 /* NotchManagerTests.swift in Sources */, 474BEF9A6D5E57064A709DFD /* NotchStateTests.swift in Sources */, + A1B2C3D4E5F60718293A4B5C /* PushToTalkServiceTests.swift in Sources */, + 9C3AB3F7109D41F8F612797B /* RecordingModelMigrationTests.swift in Sources */, + CF97708F72740E5C035B3479 /* SleepFlushCoordinatorTests.swift in Sources */, D9A94E2B88149920B8BA2B9E /* TranscriptCleanupServiceTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -825,7 +861,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = 8JL9TM5WLG; ENABLE_HARDENED_RUNTIME = YES; ENABLE_NS_ASSERTIONS = NO; @@ -839,7 +875,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.14.1; + MARKETING_VERSION = 1.14.3; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = NO; @@ -946,7 +982,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = 8JL9TM5WLG; ENABLE_HARDENED_RUNTIME = YES; ENABLE_NS_ASSERTIONS = NO; @@ -960,7 +996,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.14.1; + MARKETING_VERSION = 1.14.3; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = NO; @@ -1008,7 +1044,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = 8JL9TM5WLG; ENABLE_HARDENED_RUNTIME = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -1026,7 +1062,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.14.1; + MARKETING_VERSION = 1.14.3; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; diff --git a/Diduny.xcodeproj/xcshareddata/xcschemes/Diduny TEST.xcscheme b/Diduny.xcodeproj/xcshareddata/xcschemes/Diduny TEST.xcscheme index 700b774..e2474e0 100644 --- a/Diduny.xcodeproj/xcshareddata/xcschemes/Diduny TEST.xcscheme +++ b/Diduny.xcodeproj/xcshareddata/xcschemes/Diduny TEST.xcscheme @@ -21,6 +21,20 @@ ReferencedContainer = "container:Diduny.xcodeproj"> + + + + + + + + ? var translationPipelineTask: Task? @@ -119,6 +124,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate { object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(pushToTalkHoldStartDelayChanged(_:)), + name: .pushToTalkHoldStartDelayChanged, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(translationPushToTalkHoldStartDelayChanged(_:)), + name: .translationPushToTalkHoldStartDelayChanged, + object: nil + ) + // Permission-gate: evaluate live permission state before deciding what to show. Task { @MainActor [weak self] in guard let self else { return } @@ -160,6 +179,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { setupPushToTalk() setupTranslationPushToTalk() + // Setup sleep handling for all recording modes (RLR-M2). + // Must be registered before OrphanedRecordingDetector (M5a) to ensure the + // coordinator is active if a recording is started immediately after onboarding. + setupSleepHandling() + // Check for orphaned recordings from previous crash checkForOrphanedRecordings() @@ -190,6 +214,128 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } + // MARK: - Sleep Handling (RLR-M2) + + private func setupSleepHandling() { + let coordinator = SleepFlushCoordinator() + + coordinator.flushCurrentChunk = { [weak self] in + guard let self else { return true } + return self.flushActiveRecordingForSleep() + } + + coordinator.onWake = { [weak self] in + self?.handleWakeAfterRecordingInterrupt() + } + + sleepFlushCoordinator = coordinator + Log.app.info("[Sleep] SleepFlushCoordinator registered for willSleep / didWake") + } + + /// Flushes the active meeting recording synchronously on the willSleep thread. + /// Voice/translation recordings hold audio in memory until stop() is called; their + /// recovery state is already persisted on disk so there is nothing extra to flush. + /// Returns true if all flushes completed cleanly. + private func flushActiveRecordingForSleep() -> Bool { + // Determine which recording mode is active (MainActor state read from background thread). + // We access the service directly — meetingRecorderService is safe to read from any thread + // (isRecording is a plain Bool, not actor-isolated). + let meetingActive = meetingRecorderService.isRecording + + guard meetingActive else { + Log.recording.info("[Sleep] flushActiveRecordingForSleep: no active meeting recording") + recordingWasInterruptedBySleep = false + return true + } + + Log.recording.info("[Sleep] flushActiveRecordingForSleep: flushing meeting recording chunk") + + // Synchronously flush the audio file. AVAudioFile.close() is synchronous. + let flushedURL = meetingRecorderService.synchronousFlushForSleep() + let ok = flushedURL != nil + + // Mark that sleep interrupted a recording so didWake can surface the message. + recordingWasInterruptedBySleep = true + + // Capture IDs for the async manifest update (spawned after handler returns). + let recordingId = meetingRecorderService.currentRecordingId + + // Spawn async Task to update manifest with recordingInterruptedBySleep = true. + // This may not complete before the system sleeps — that is acceptable. + // The OrphanedRecordingDetector (M5a) can recover from a missing or stale manifest. + if let recordingId { + Task { + do { + let store = InProgressRecordingStore.shared + if var manifest = try await store.readManifest(for: recordingId) { + manifest.recordingInterruptedBySleep = true + manifest.lastWriteAt = Date() + // Mark the last chunk as closed (or incomplete on nil flushedURL). + if !manifest.chunks.isEmpty { + let closeTime: Date? = ok ? Date() : nil + manifest.chunks[manifest.chunks.count - 1].closedAt = closeTime + if let url = flushedURL, + let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), + let size = attrs[.size] as? Int64 + { + manifest.chunks[manifest.chunks.count - 1].byteCount = size + } + } + try await store.writeManifest(manifest, for: recordingId) + Log.recording.info("[Sleep] manifest updated: recordingInterruptedBySleep=true, chunk closedAt=\(ok ? "set" : "nil")") + } + } catch { + Log.recording.error("[Sleep] Failed to update manifest: \(error.localizedDescription)") + } + } + } + + // Release the activity token so the OS can proceed with sleep cleanly. + // Both meeting and meeting-translation may hold tokens; release the active one. + DispatchQueue.main.async { [weak self] in + guard let self else { return } + if let token = self.meetingActivityToken { + ProcessInfo.processInfo.endActivity(token) + self.meetingActivityToken = nil + } + if let token = self.meetingTranslationActivityToken { + ProcessInfo.processInfo.endActivity(token) + self.meetingTranslationActivityToken = nil + } + } + + return ok + } + + /// Called on the didWake notification background thread. + /// Dispatches UI updates to main. + private func handleWakeAfterRecordingInterrupt() { + guard recordingWasInterruptedBySleep else { return } + recordingWasInterruptedBySleep = false + + Log.recording.info("[Sleep] wake after recording interrupt — surfacing notch message") + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + NotchManager.shared.showInfo( + message: "Recording stopped. Open Recordings to recover audio.", + duration: 5.0 + ) + // Transition recording states to idle so the UI is consistent. + // The in-progress directory is left intact for OrphanedRecordingDetector (M5a). + if self.appState.meetingRecordingState == .recording { + self.appState.meetingRecordingState = .idle + self.appState.meetingRecordingStartTime = nil + self.handleMeetingStateChange(.idle) + } + if self.appState.meetingTranslationRecordingState == .recording { + self.appState.meetingTranslationRecordingState = .idle + self.appState.meetingTranslationRecordingStartTime = nil + self.handleMeetingTranslationStateChange(.idle) + } + } + } + private func showRecoveryAlert(for state: RecoveryState) { let alert = NSAlert() alert.messageText = "Recover Previous Recording?" @@ -279,6 +425,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { pushToTalkService.stop() translationPushToTalkService.stop() NotificationCenter.default.removeObserver(self) + // Release all activity tokens on termination to avoid leaking wake locks. + if let token = recordingActivityToken { ProcessInfo.processInfo.endActivity(token) } + if let token = translationActivityToken { ProcessInfo.processInfo.endActivity(token) } + if let token = meetingActivityToken { ProcessInfo.processInfo.endActivity(token) } + if let token = meetingTranslationActivityToken { ProcessInfo.processInfo.endActivity(token) } + sleepFlushCoordinator = nil } func loadAudioData(from url: URL) async throws -> Data { diff --git a/Diduny/Core/Models/Recording.swift b/Diduny/Core/Models/Recording.swift index 5c86923..1f553b5 100644 --- a/Diduny/Core/Models/Recording.swift +++ b/Diduny/Core/Models/Recording.swift @@ -9,6 +9,15 @@ struct RecordingDeviceInfo: Codable, Equatable { let wasDefaultRoute: Bool } +/// Describes how a recording entered the library via a non-normal stop path. +/// `nil` on `Recording.recoverySource` means the recording was stopped normally. +enum RecoverySource: String, Codable, Sendable { + /// The recording was assembled from an orphaned in-progress session directory + /// (e.g. after a crash, force-quit, or sleep interruption). + case orphanedSession + // Future cases: .importedFile, .crashRecovery — out of scope for M0. +} + struct Recording: Identifiable, Codable, Equatable { let id: UUID let createdAt: Date @@ -22,6 +31,10 @@ struct Recording: Identifiable, Codable, Equatable { var processedAt: Date? var chapters: [MeetingChapter]? let sourceDevice: RecordingDeviceInfo? + /// Non-nil when this recording was saved via a recovery path rather than a normal stop. + /// Drives the "Recovered" badge in the library and the detail-view notice. + /// Set once at recovery-save time; never cleared. + var recoverySource: RecoverySource? /// Nested to avoid conflict with RecoveryState.RecordingType enum RecordingType: String, Codable, CaseIterable { @@ -73,6 +86,9 @@ struct Recording: Identifiable, Codable, Equatable { case transcribed case translated case failed + /// Audio was recovered from an interrupted session and one or more chunks + /// were unreadable. The reported duration reflects only the intact chunks. + case partiallyRecovered var displayName: String { switch self { @@ -81,6 +97,7 @@ struct Recording: Identifiable, Codable, Equatable { case .transcribed: "Transcribed" case .translated: "Translated" case .failed: "Failed" + case .partiallyRecovered: "Partially Recovered" } } } diff --git a/Diduny/Core/Protocols/ServiceProtocols.swift b/Diduny/Core/Protocols/ServiceProtocols.swift index 6e39c82..dd903ec 100644 --- a/Diduny/Core/Protocols/ServiceProtocols.swift +++ b/Diduny/Core/Protocols/ServiceProtocols.swift @@ -71,6 +71,7 @@ protocol AudioDeviceManagerProtocol: AnyObject { protocol PushToTalkServiceProtocol: AnyObject { var selectedKey: PushToTalkKey { get set } var toggleTapCount: Int { get set } + var holdStartDelaySeconds: TimeInterval { get set } var onKeyDown: (() -> Void)? { get set } var onKeyUp: (() -> Void)? { get set } /// Called when recording should toggle (for hands-free mode) diff --git a/Diduny/Core/Services/AuthService.swift b/Diduny/Core/Services/AuthService.swift index 8037a08..a528680 100644 --- a/Diduny/Core/Services/AuthService.swift +++ b/Diduny/Core/Services/AuthService.swift @@ -139,7 +139,7 @@ final class AuthService { return token } #endif - await supabase.currentAccessToken + return await supabase.currentAccessToken } /// Attaches `Authorization: Bearer ` to a URLRequest. diff --git a/Diduny/Core/Services/MeetingChunkStitcher.swift b/Diduny/Core/Services/MeetingChunkStitcher.swift new file mode 100644 index 0000000..4631705 --- /dev/null +++ b/Diduny/Core/Services/MeetingChunkStitcher.swift @@ -0,0 +1,214 @@ +import AVFoundation +import Foundation +import os + +/// Stitches an ordered list of WAV chunk files into a single WAV file. +/// +/// **RLR-M4:** post-rotation assembler. Called by `MeetingRecorderService.stopRecording()` +/// after all chunks are closed and by the future M5a recovery flow to materialize +/// recovered orphan sessions. +/// +/// **Format assumption:** all chunks share the same sample rate / channel layout, which is +/// true for chunks produced by `SystemAudioCaptureService` (16 kHz mono 16-bit). The first +/// readable chunk determines the output format; later chunks with mismatched formats are +/// skipped (and listed in `Result.skippedChunks`). ADR-0009 OQ-2B (heterogeneous chunks) +/// is out of scope for M3b/M4 — `SystemAudioCaptureService` only emits one format. +/// +/// **Performance:** sequential `AVAudioFile` read/write. PoC ballpark for 24 × 5-min chunks: +/// median 1480 ms, p95 2127 ms. Caller should run on a background queue. +enum MeetingChunkStitcher { + + // MARK: - Types + + struct Result { + /// The stitched output file. Matches the URL passed to `stitch`. + let outputURL: URL + /// Total audio duration written (seconds, derived from frames / sampleRate). + let totalDurationSeconds: Double + /// 1-based indices of chunks that could not be read or had an incompatible format. + /// Empty when stitching was perfect. + let skippedChunks: [Int] + /// Number of chunks that were successfully appended. + let appendedChunkCount: Int + } + + enum StitchError: LocalizedError { + case noChunks + case allChunksUnreadable + case writerSetupFailed(Error) + + var errorDescription: String? { + switch self { + case .noChunks: "No chunks were provided for stitching." + case .allChunksUnreadable: "None of the recorded chunks could be read." + case let .writerSetupFailed(error): "Failed to open output audio file: \(error.localizedDescription)" + } + } + } + + // MARK: - Public API + + /// Stitches `chunkURLs` into `outputURL` in order. Throws if no chunk is readable. + /// + /// Fast path: when `chunkURLs.count == 1`, the lone chunk is copied to `outputURL` + /// (no decode/encode cycle) — this is the common case for short meetings that did + /// not cross the rotation boundary. + static func stitch(chunkURLs: [URL], outputURL: URL) throws -> Result { + guard !chunkURLs.isEmpty else { throw StitchError.noChunks } + + // Pre-clean output if it exists from a previous attempt. + try? FileManager.default.removeItem(at: outputURL) + + // Single-chunk fast path: copy instead of decode/encode. + if chunkURLs.count == 1 { + return try stitchSingleChunk(chunkURLs[0], outputURL: outputURL) + } + + return try stitchMultiple(chunkURLs: chunkURLs, outputURL: outputURL) + } + + // MARK: - Single-Chunk Path + + private static func stitchSingleChunk(_ chunkURL: URL, outputURL: URL) throws -> Result { + // Try to compute duration before deciding whether to copy or signal empty. + var duration: Double = 0 + if let file = try? AVAudioFile(forReading: chunkURL), file.fileFormat.sampleRate > 0 { + duration = Double(file.length) / file.fileFormat.sampleRate + } + + let fm = FileManager.default + guard fm.fileExists(atPath: chunkURL.path) else { + throw StitchError.allChunksUnreadable + } + try fm.copyItem(at: chunkURL, to: outputURL) + + return Result( + outputURL: outputURL, + totalDurationSeconds: duration, + skippedChunks: duration > 0 ? [] : [1], + appendedChunkCount: duration > 0 ? 1 : 0 + ) + } + + // MARK: - Multi-Chunk Path + + private static func stitchMultiple(chunkURLs: [URL], outputURL: URL) throws -> Result { + var skipped: [Int] = [] + var firstReadable: AVAudioFile? + var firstReadableIndex = 0 + + // Locate first readable chunk; it defines output format. + for (idx, url) in chunkURLs.enumerated() { + if let file = try? AVAudioFile(forReading: url), file.length > 0 { + firstReadable = file + firstReadableIndex = idx + break + } + skipped.append(idx + 1) + Log.audio.warning("[Stitch] chunk \(idx + 1) at \(url.lastPathComponent) unreadable or empty") + } + + guard let firstFile = firstReadable else { + throw StitchError.allChunksUnreadable + } + + let outputSettings: [String: Any] = [ + AVFormatIDKey: kAudioFormatLinearPCM, + AVSampleRateKey: firstFile.fileFormat.sampleRate, + AVNumberOfChannelsKey: Int(firstFile.fileFormat.channelCount), + AVLinearPCMBitDepthKey: 16, + AVLinearPCMIsFloatKey: false, + AVLinearPCMIsBigEndianKey: false, + AVLinearPCMIsNonInterleaved: false + ] + + let outputFile: AVAudioFile + do { + outputFile = try AVAudioFile(forWriting: outputURL, settings: outputSettings) + } catch { + throw StitchError.writerSetupFailed(error) + } + + var totalFrames: AVAudioFramePosition = 0 + var appendedCount = 0 + + // Append the first readable chunk. + do { + try appendFile(firstFile, into: outputFile, totalFrames: &totalFrames) + appendedCount += 1 + } catch { + Log.audio.warning("[Stitch] chunk \(firstReadableIndex + 1) read failed mid-append: \(error.localizedDescription)") + skipped.append(firstReadableIndex + 1) + } + + // Append the rest. + if firstReadableIndex + 1 < chunkURLs.count { + for idx in (firstReadableIndex + 1) ..< chunkURLs.count { + let url = chunkURLs[idx] + let chunkNumber = idx + 1 + do { + let file = try AVAudioFile(forReading: url) + guard file.length > 0 else { + skipped.append(chunkNumber) + Log.audio.warning("[Stitch] chunk \(chunkNumber) is empty") + continue + } + guard file.fileFormat.sampleRate == firstFile.fileFormat.sampleRate, + file.fileFormat.channelCount == firstFile.fileFormat.channelCount + else { + skipped.append(chunkNumber) + Log.audio.warning( + "[Stitch] chunk \(chunkNumber) format mismatch (\(file.fileFormat) vs \(firstFile.fileFormat))" + ) + continue + } + try appendFile(file, into: outputFile, totalFrames: &totalFrames) + appendedCount += 1 + } catch { + skipped.append(chunkNumber) + Log.audio.warning("[Stitch] chunk \(chunkNumber) failed: \(error.localizedDescription)") + } + } + } + + let totalDuration = outputFile.fileFormat.sampleRate > 0 + ? Double(totalFrames) / outputFile.fileFormat.sampleRate + : 0 + + Log.audio.info( + "[Stitch] wrote \(appendedCount)/\(chunkURLs.count) chunks (\(String(format: "%.1f", totalDuration))s) skipped=\(skipped)" + ) + + return Result( + outputURL: outputURL, + totalDurationSeconds: totalDuration, + skippedChunks: skipped, + appendedChunkCount: appendedCount + ) + } + + // MARK: - Helpers + + /// Streams `source` into `destination` in fixed-size buffers; updates `totalFrames` running counter. + private static func appendFile( + _ source: AVAudioFile, + into destination: AVAudioFile, + totalFrames: inout AVAudioFramePosition + ) throws { + let bufferFrames: AVAudioFrameCount = 32_768 + let processingFormat = source.processingFormat + guard let buffer = AVAudioPCMBuffer(pcmFormat: processingFormat, frameCapacity: bufferFrames) else { + return + } + + while source.framePosition < source.length { + let remaining = source.length - source.framePosition + let toRead = min(AVAudioFrameCount(remaining), bufferFrames) + buffer.frameLength = 0 + try source.read(into: buffer, frameCount: toRead) + if buffer.frameLength == 0 { break } + try destination.write(from: buffer) + totalFrames += AVAudioFramePosition(buffer.frameLength) + } + } +} diff --git a/Diduny/Core/Services/MeetingRecorderService.swift b/Diduny/Core/Services/MeetingRecorderService.swift index 270ceb4..f140991 100644 --- a/Diduny/Core/Services/MeetingRecorderService.swift +++ b/Diduny/Core/Services/MeetingRecorderService.swift @@ -9,6 +9,15 @@ final class MeetingRecorderService: NSObject, MeetingRecorderServiceProtocol { private var systemAudioService: SystemAudioCaptureService? private var outputURL: URL? private var startTime: Date? + /// UUID of the active in-progress recording directory (nil when not recording). + private(set) var currentRecordingId: UUID? + /// Per-recording in-progress directory under Application Support. Owned by the store. + private var inProgressDirectoryURL: URL? + /// Ordered list of chunk URLs for the active recording (closed + currently writing). + /// Used at stop time to drive `MeetingChunkStitcher`. + private var chunkURLs: [URL] = [] + /// Chunk-rotation interval forwarded to `SystemAudioCaptureService`. Tests override; production uses default. + var chunkDurationSeconds: TimeInterval = 300 private(set) var isRecording = false @@ -56,9 +65,22 @@ final class MeetingRecorderService: NSObject, MeetingRecorderServiceProtocol { Log.recording.info("Starting meeting recording, source: \(self.audioSource.displayName)") - let fileName = "meeting_\(Date().timeIntervalSince1970).wav" - let tempDir = FileManager.default.temporaryDirectory - outputURL = tempDir.appendingPathComponent(fileName) + // Allocate a stable per-recording directory under Application Support. + // This replaces the old temporaryDirectory approach (RLR-M1). + let recordingId = UUID() + let directoryURL: URL + let firstChunkURL: URL + do { + directoryURL = try await InProgressRecordingStore.shared.directoryURL(for: recordingId) + firstChunkURL = directoryURL.appendingPathComponent(Self.chunkFilename(forIndex: 1)) + } catch { + Log.recording.error("Failed to create in-progress recording directory: \(error)") + throw MeetingRecorderError.fileCreationFailed + } + currentRecordingId = recordingId + inProgressDirectoryURL = directoryURL + chunkURLs = [firstChunkURL] + outputURL = firstChunkURL guard let outputURL else { throw MeetingRecorderError.fileCreationFailed @@ -91,6 +113,22 @@ final class MeetingRecorderService: NSObject, MeetingRecorderServiceProtocol { self?.onStatusMessage?(message) } + // Chunk rotation wiring (RLR-M3b). + service.chunkDurationSeconds = chunkDurationSeconds + service.chunkURLProvider = { [directoryURL] index in + directoryURL.appendingPathComponent(Self.chunkFilename(forIndex: index)) + } + service.onChunkRotated = { [weak self] closedIndex, closedURL, closedAt, byteCount, durationSeconds in + self?.handleChunkRotated( + closedIndex: closedIndex, + closedURL: closedURL, + closedAt: closedAt, + byteCount: byteCount, + durationSeconds: durationSeconds, + directoryURL: directoryURL + ) + } + service.onError = { [weak self] error in Log.recording.error("Audio capture error: \(error.localizedDescription)") self?.onError?(error) @@ -117,6 +155,33 @@ final class MeetingRecorderService: NSObject, MeetingRecorderServiceProtocol { isRecording = true startTime = Date() + // Write initial manifest so recovery can find this session immediately. + // Chunks are 16 kHz mono 16-bit regardless of capture mode (mic+system are mixed to mono). + let initialManifest = InProgressRecordingManifest( + id: recordingId, + schemaVersion: 1, + type: .meeting, + startedAt: startTime!, + sourceDevice: nil, + audioConfig: InProgressRecordingManifest.AudioConfig( + sampleRate: 16000, + channels: 1, + bitDepth: 16 + ), + chunks: [ + InProgressRecordingManifest.ChunkEntry( + index: 1, + filename: Self.chunkFilename(forIndex: 1), + byteCount: 0, + durationSeconds: 0, + closedAt: nil + ) + ], + lastWriteAt: startTime!, + recordingInterruptedBySleep: false + ) + try? await InProgressRecordingStore.shared.writeManifest(initialManifest, for: recordingId) + Log.recording.info("Recording started (captureMicrophone=\(service.captureMicrophone))") onRecordingStarted?() } @@ -131,25 +196,115 @@ final class MeetingRecorderService: NSObject, MeetingRecorderServiceProtocol { Log.recording.info("Stopping meeting recording...") - let savedURL = try await systemAudioService?.stopCapture() + let capturedRecordingId = currentRecordingId + let capturedChunkURLs = chunkURLs + let capturedDirectoryURL = inProgressDirectoryURL + _ = try await systemAudioService?.stopCapture() systemAudioService = nil isRecording = false - let duration = startTime.map { Date().timeIntervalSince($0) } ?? 0 + let stopTime = Date() + let duration = startTime.map { stopTime.timeIntervalSince($0) } ?? 0 startTime = nil + currentRecordingId = nil + inProgressDirectoryURL = nil + chunkURLs = [] + + Log.recording.info( + "Recording stopped, duration: \(String(format: "%.2f", duration))s across \(capturedChunkURLs.count) chunks" + ) + + // Stitch all chunks into a single output WAV (RLR-M4). Sits alongside the in-progress + // directory so the directory cleanup downstream removes it together. The stitched file + // lives at /stitched.wav until the AppDelegate hands it off to the library. + let stitchedURL: URL? + let stitchResult: MeetingChunkStitcher.Result? + if let dir = capturedDirectoryURL, !capturedChunkURLs.isEmpty { + let target = dir.appendingPathComponent("stitched.wav") + do { + let result = try MeetingChunkStitcher.stitch(chunkURLs: capturedChunkURLs, outputURL: target) + stitchResult = result + stitchedURL = result.outputURL + Log.recording.info( + "Stitch ok: appended=\(result.appendedChunkCount) skipped=\(result.skippedChunks) totalDur=\(String(format: "%.2f", result.totalDurationSeconds))s" + ) + } catch { + Log.recording.error("Stitch FAILED: \(error.localizedDescription) — falling back to last chunk only") + stitchResult = nil + stitchedURL = capturedChunkURLs.last + } + } else { + stitchResult = nil + stitchedURL = capturedChunkURLs.last + } - Log.recording.info("Recording stopped, duration: \(String(format: "%.2f", duration)) seconds") - - if let url = savedURL, + var stitchedSize: Int64 = 0 + if let url = stitchedURL, let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), - let fileSize = attrs[.size] as? Int64 + let size = attrs[.size] as? Int64 + { + stitchedSize = size + Log.recording.info("Stitched file size: \(String(format: "%.2f", Double(size) / 1_000_000.0)) MB") + } + _ = stitchedSize + + // Update manifest: mark the last (currently-writing) chunk as cleanly closed. + // Earlier chunks already have closedAt set by handleChunkRotated; nothing to update there. + if let recordingId = capturedRecordingId, + var manifest = try? await InProgressRecordingStore.shared.readManifest(for: recordingId), + !manifest.chunks.isEmpty, + let lastChunkURL = capturedChunkURLs.last { - let sizeMB = Double(fileSize) / 1_000_000.0 - Log.recording.info("Output file size: \(String(format: "%.2f", sizeMB)) MB") + let lastIdx = manifest.chunks.count - 1 + var lastByteCount: Int64 = 0 + if let attrs = try? FileManager.default.attributesOfItem(atPath: lastChunkURL.path), + let size = attrs[.size] as? Int64 + { + lastByteCount = size + } + let lastDuration: Double + if let stitch = stitchResult { + // Subtract durations of preceding (closed) chunks from total. + let priorDurations = manifest.chunks.prefix(manifest.chunks.count - 1) + .reduce(0.0) { $0 + $1.durationSeconds } + lastDuration = max(0, stitch.totalDurationSeconds - priorDurations) + } else { + // Stitch unavailable (no rotation happened): the whole recording is in chunk_001. + lastDuration = duration + } + manifest.chunks[lastIdx].closedAt = stopTime + manifest.chunks[lastIdx].byteCount = lastByteCount + manifest.chunks[lastIdx].durationSeconds = lastDuration + manifest.lastWriteAt = stopTime + try? await InProgressRecordingStore.shared.writeManifest(manifest, for: recordingId) } - onRecordingStopped?(savedURL) - return savedURL + onRecordingStopped?(stitchedURL) + return stitchedURL + } + + // MARK: - Sleep Flush + + /// Synchronously flushes and closes the current audio file chunk for the + /// `willSleepNotification` handler. + /// + /// Called on a power-management background thread (NOT MainActor / not async). + /// The `AVAudioFile` close inside `SystemAudioCaptureService.synchronousFlushForSleep()` + /// is already synchronous — no `DispatchSemaphore` is needed. + /// + /// After calling this, `isRecording` remains true at the service level until the caller + /// updates AppState. The manifest `recordingInterruptedBySleep` flag is set by the caller + /// (AppDelegate) via an async `Task` spawned after this method returns. + /// + /// Returns the URL of the flushed audio file, or nil if not recording. + func synchronousFlushForSleep() -> URL? { + guard isRecording, let service = systemAudioService else { + Log.recording.info("[Sleep] synchronousFlushForSleep: not recording, skipping") + return nil + } + let url = service.synchronousFlushForSleep() + Log.recording.info("[Sleep] synchronousFlushForSleep: chunk closed at \(url?.path ?? "nil")") + return url } // MARK: - Cancel Recording @@ -159,20 +314,86 @@ final class MeetingRecorderService: NSObject, MeetingRecorderServiceProtocol { Log.recording.info("Canceling meeting recording...") + let capturedRecordingId = currentRecordingId _ = try? await systemAudioService?.stopCapture() systemAudioService = nil - - if let url = outputURL { - try? FileManager.default.removeItem(at: url) - } outputURL = nil - isRecording = false startTime = nil + currentRecordingId = nil + inProgressDirectoryURL = nil + chunkURLs = [] + + // Remove the entire in-progress directory (chunks + manifest + any stitched output). + if let recordingId = capturedRecordingId { + try? await InProgressRecordingStore.shared.cleanup(recordingId: recordingId) + } Log.recording.info("Meeting recording canceled") } + // MARK: - Chunk Rotation Handling (RLR-M3b) + + /// Fired by `SystemAudioCaptureService` on `fileWriteQueue` whenever a chunk rotates. + /// Spawns an async Task to (a) append a new ChunkEntry for the freshly-opened chunk and + /// (b) finalize the closed chunk's metadata. Manifest writes are best-effort; failures + /// are logged but do not abort the recording. + private func handleChunkRotated( + closedIndex: Int, + closedURL: URL, + closedAt: Date, + byteCount: Int64, + durationSeconds: Double, + directoryURL: URL + ) { + let newIndex = closedIndex + 1 + let newURL = directoryURL.appendingPathComponent(Self.chunkFilename(forIndex: newIndex)) + + // Mutate our chunkURLs list synchronously on whatever queue this fires from. + // SystemAudioCaptureService fires from fileWriteQueue; appending to chunkURLs is safe + // because every other reader (stopRecording / cancelRecording) only runs after the + // capture service is shut down. Concurrent writers do not exist. + chunkURLs.append(newURL) + + guard let recordingId = currentRecordingId else { return } + Task { [weak self] in + guard self != nil else { return } + let store = InProgressRecordingStore.shared + do { + guard var manifest = try await store.readManifest(for: recordingId) else { return } + // Update closed entry (1-based → 0-based index) + let closedIdx0 = closedIndex - 1 + if manifest.chunks.indices.contains(closedIdx0) { + manifest.chunks[closedIdx0].closedAt = closedAt + manifest.chunks[closedIdx0].byteCount = byteCount + manifest.chunks[closedIdx0].durationSeconds = durationSeconds + } + // Append entry for the new (now active) chunk, idempotently + if !manifest.chunks.contains(where: { $0.index == newIndex }) { + manifest.chunks.append( + InProgressRecordingManifest.ChunkEntry( + index: newIndex, + filename: Self.chunkFilename(forIndex: newIndex), + byteCount: 0, + durationSeconds: 0, + closedAt: nil + ) + ) + } + manifest.lastWriteAt = Date() + try await store.writeManifest(manifest, for: recordingId) + } catch { + Log.recording.warning("[ChunkRotate] manifest update failed: \(error.localizedDescription)") + } + } + } + + /// Stable filename format for chunk files. Padded to 3 digits to keep + /// `contentsOfDirectory` order stable up to 999 chunks (= 83 h at 5 min). + static func chunkFilename(forIndex index: Int) -> String { + String(format: "chunk_%03d.wav", index) + } + // MARK: - Get Recording Data func getRecordingData() throws -> Data? { diff --git a/Diduny/Core/Services/PushToTalkService.swift b/Diduny/Core/Services/PushToTalkService.swift index a2c6bf2..ea675d6 100644 --- a/Diduny/Core/Services/PushToTalkService.swift +++ b/Diduny/Core/Services/PushToTalkService.swift @@ -9,6 +9,9 @@ final class PushToTalkService: PushToTalkServiceProtocol { private var isKeyPressed = false private var isReady = false private var startTime: Date? + private var pendingHoldStartTask: Task? + private var hasStartedAfterHold = false + private var sanitizedHoldStartDelaySeconds: TimeInterval = 0.2 // Multi-tap detection for toggle mode private var lastToggleTapTime: TimeInterval? @@ -18,6 +21,10 @@ final class PushToTalkService: PushToTalkServiceProtocol { var selectedKey: PushToTalkKey = .none var toggleTapCount: Int = 3 + var holdStartDelaySeconds: TimeInterval { + get { sanitizedHoldStartDelaySeconds } + set { sanitizedHoldStartDelaySeconds = Self.sanitizedHoldStartDelaySeconds(newValue) } + } var onKeyDown: (() -> Void)? var onKeyUp: (() -> Void)? @@ -74,6 +81,8 @@ final class PushToTalkService: PushToTalkServiceProtocol { isKeyPressed = false isReady = false startTime = nil + cancelPendingHoldStart() + hasStartedAfterHold = false isHandsFreeMode = false lastToggleTapTime = nil consecutiveToggleTapCount = 0 @@ -82,6 +91,8 @@ final class PushToTalkService: PushToTalkServiceProtocol { /// Reset hands-free mode (call when recording is cancelled externally) func resetHandsFreeMode() { + cancelPendingHoldStart() + hasStartedAfterHold = false isHandsFreeMode = false lastToggleTapTime = nil consecutiveToggleTapCount = 0 @@ -106,7 +117,7 @@ final class PushToTalkService: PushToTalkServiceProtocol { } // Handle modifier keys with configurable multi-tap detection - handleModifierKeyEvent(isPressed: isPressed, eventTime: eventTime) + processModifierKeyEvent(isPressed: isPressed, eventTime: eventTime) } private func handleCapsLockEvent(keyCode: UInt16, eventTime: TimeInterval) { @@ -134,12 +145,13 @@ final class PushToTalkService: PushToTalkServiceProtocol { } } - private func handleModifierKeyEvent(isPressed: Bool, eventTime: TimeInterval) { + func processModifierKeyEvent(isPressed: Bool, eventTime: TimeInterval) { guard isPressed != isKeyPressed else { return } if isPressed { // Key down isKeyPressed = true + hasStartedAfterHold = false // Check for configured tap count (toggle mode enabled) if isToggleModeEnabled { @@ -147,25 +159,59 @@ final class PushToTalkService: PushToTalkServiceProtocol { return } - // Hold-to-record mode: start recording on key down - Log.app.info("\(self.selectedKey.displayName) pressed - starting recording") - onKeyDown?() + // Hold-to-record mode: start recording only after the configured hold delay. + scheduleHoldStart(keyLabel: selectedKey.displayName) } else { // Key up isKeyPressed = false + cancelPendingHoldStart() if isToggleModeEnabled { // In toggle mode: don't stop on release return } + guard hasStartedAfterHold else { + Log.app.info("\(self.selectedKey.displayName) released before hold threshold - ignoring") + return + } + // Hold-to-record mode: stop recording on release + hasStartedAfterHold = false Log.app.info("\(self.selectedKey.displayName) released - stopping recording") onKeyUp?() } } + private func scheduleHoldStart(keyLabel: String) { + cancelPendingHoldStart() + + let delay = holdStartDelaySeconds + Log.app.info("\(keyLabel) pressed - waiting \(String(format: "%.1f", delay))s before starting recording") + + pendingHoldStartTask = Task { @MainActor [weak self] in + let milliseconds = Int((delay * 1000).rounded()) + try? await Task.sleep(for: .milliseconds(milliseconds)) + + guard !Task.isCancelled, + let self, + self.isKeyPressed, + !self.hasStartedAfterHold + else { return } + + self.hasStartedAfterHold = true + self.pendingHoldStartTask = nil + Log.app.info("\(keyLabel) held for \(String(format: "%.1f", delay))s - starting recording") + self.onKeyDown?() + } + } + + private func cancelPendingHoldStart() { + pendingHoldStartTask?.cancel() + pendingHoldStartTask = nil + } + private func handleToggleTap(eventTime: TimeInterval, keyLabel: String) { let requiredTapCount = sanitizedToggleTapCount @@ -196,6 +242,12 @@ final class PushToTalkService: PushToTalkServiceProtocol { min(max(toggleTapCount, 2), 3) } + private static func sanitizedHoldStartDelaySeconds(_ value: TimeInterval) -> TimeInterval { + guard value.isFinite else { return 0.2 } + let clamped = min(max(value, 0.2), 1.0) + return (clamped * 10).rounded() / 10 + } + private func isKeyCurrentlyPressed(keyCode: UInt16, flags: NSEvent.ModifierFlags) -> Bool { switch selectedKey { case .none: diff --git a/Diduny/Core/Services/SleepFlushCoordinator.swift b/Diduny/Core/Services/SleepFlushCoordinator.swift new file mode 100644 index 0000000..135d208 --- /dev/null +++ b/Diduny/Core/Services/SleepFlushCoordinator.swift @@ -0,0 +1,79 @@ +import AppKit +import Foundation +import os + +// MARK: - SleepFlushCoordinator + +/// Coordinates flush-on-sleep for active recordings. +/// +/// Registered as an observer of `NSWorkspace.willSleepNotification` and +/// `NSWorkspace.didWakeNotification`. +/// +/// Per ADR-0009 §D4b: handlers are registered with `queue: nil` so notifications +/// are delivered synchronously on the posting thread (a Foundation background thread, +/// NOT MainActor). The `flushCurrentChunk` closure is called on that background thread +/// and must return before the notification handler returns — the system's willSleep ACK +/// is implicit in the handler return. Do NOT dispatch to MainActor inside the handler +/// (that hop is asynchronous and the system won't wait for it). +/// +/// **Threading danger (why @MainActor is absent):** +/// `NSWorkspace.willSleepNotification` is posted on a background system thread, not the +/// main thread. If this class were `@MainActor`, the `@objc` methods would need a hop to +/// the main actor, but that hop is asynchronous — the notification handler would return +/// before the hop completes and we would lose the sync flush guarantee. The class is +/// therefore NOT `@MainActor`. Callers that need main-actor work (e.g., updating AppState) +/// must dispatch explicitly inside `onWake` using `DispatchQueue.main.async`. +final class SleepFlushCoordinator { + + // MARK: - Public Closures + + /// Called synchronously on the willSleep notification thread. + /// Must complete (or time out) within ~250 ms. + /// Returns `true` if the flush completed cleanly, `false` on timeout or error. + /// Nil means no active recording — treated as success. + var flushCurrentChunk: (() -> Bool)? + + /// Called on the didWake notification thread (also a background thread). + /// Receivers should dispatch to main if they need to update UI. + var onWake: (() -> Void)? + + // MARK: - Init + + init() { + let nc = NSWorkspace.shared.notificationCenter + nc.addObserver( + self, + selector: #selector(handleWillSleep(_:)), + name: NSWorkspace.willSleepNotification, + object: nil + ) + nc.addObserver( + self, + selector: #selector(handleDidWake(_:)), + name: NSWorkspace.didWakeNotification, + object: nil + ) + } + + deinit { + NSWorkspace.shared.notificationCenter.removeObserver(self) + } + + // MARK: - Handlers + + /// Runs synchronously on the power-management background thread. + /// The system does NOT proceed to sleep until this method returns. + @objc private func handleWillSleep(_ note: Notification) { + Log.recording.info("[Sleep] willSleepNotification received — flushing active recording") + let start = Date() + let ok = flushCurrentChunk?() ?? true + let elapsed = Int(Date().timeIntervalSince(start) * 1000) + Log.recording.info("[Sleep] flush completed=\(ok) elapsed=\(elapsed)ms") + } + + /// Runs synchronously on the power-management background thread. + @objc private func handleDidWake(_ note: Notification) { + Log.recording.info("[Sleep] didWakeNotification received") + onWake?() + } +} diff --git a/Diduny/Core/Services/SystemAudioCaptureService.swift b/Diduny/Core/Services/SystemAudioCaptureService.swift index c064994..b4c00c1 100644 --- a/Diduny/Core/Services/SystemAudioCaptureService.swift +++ b/Diduny/Core/Services/SystemAudioCaptureService.swift @@ -34,6 +34,15 @@ final class SystemAudioCaptureService: NSObject { private let maxMicrophoneRecoveryAttempts = 3 private let initialRecoveryDelay: TimeInterval = 0.75 + // MARK: - Chunk Rotation State (accessed only on fileWriteQueue) + + /// 1-based index of the chunk currently being written. + private var currentChunkIndex: Int = 1 + /// Wall-clock time when the current chunk file was opened. + private var currentChunkStartedAt: Date = .init() + /// Frames written into the current chunk; used to compute durationSeconds on rotation. + private var currentChunkFrameCount: Int = 0 + // MARK: - Public Configuration /// Enable microphone capture alongside system audio. @@ -48,6 +57,21 @@ final class SystemAudioCaptureService: NSObject { /// Gain applied to system audio samples before mixing with microphone (0–2, default 0.3). var systemGain: Float = 0.3 + /// Wall-clock duration of a single chunk file before rotation (RLR-M3b). + /// Default 300 s (5 min) per ADR-0009 §D3. Tests may shrink this. + var chunkDurationSeconds: TimeInterval = 300 + + /// Supplies the URL for chunk `index` (1-based). + /// When `nil`, rotation is disabled and the service writes the entire capture to the URL + /// passed to `startCapture(to:)` (legacy single-file mode used by non-meeting modes / tests). + var chunkURLProvider: ((Int) -> URL)? + + /// Fired on the fileWriteQueue when a chunk file is closed (rotation boundary). + /// Params: closedIndex, closedURL, closedAt, byteCount, durationSeconds. + /// The caller is expected to spawn an async Task to update the on-disk manifest; + /// the callback itself must not block. + var onChunkRotated: ((Int, URL, Date, Int64, Double) -> Void)? + var onError: ((Error) -> Void)? var onCaptureStarted: (() -> Void)? @@ -122,6 +146,11 @@ final class SystemAudioCaptureService: NSObject { try setupAudioFile(at: outputURL) + // Reset chunk rotation state (RLR-M3b) + currentChunkIndex = 1 + currentChunkStartedAt = Date() + currentChunkFrameCount = 0 + // Reset mixer state systemBuffer.removeAll() micBuffer.removeAll() @@ -397,6 +426,30 @@ final class SystemAudioCaptureService: NSObject { // MARK: - Stop Capture + /// Synchronously flushes and closes the audio file for the willSleep notification handler. + /// + /// Called from a power-management background thread (NOT MainActor). Does NOT stop the + /// SCStream (that requires async); it only closes the `AVAudioFile` so all buffered audio + /// is flushed to disk before the system sleeps. + /// + /// The `AVAudioFile` close is synchronous (OS file close with header update). The + /// `synchronizedTeardown` uses `mixerQueue.sync { fileWriteQueue.sync { ... } }` which + /// drains any in-flight audio buffers before closing. No semaphore needed — already sync. + /// + /// Returns the URL of the closed file (or nil if not capturing). + func synchronousFlushForSleep() -> URL? { + guard isCapturing else { return nil } + let url = outputURL + // Cancel recovery tasks to prevent them from re-opening the stream. + cancelRecoveryTasks() + // Flush remaining buffers and close AVAudioFile synchronously. + synchronizedTeardown(flushPendingAudio: true) + // Mark as not capturing so further SCStream callbacks are dropped. + isCapturing = false + Log.audio.info("[Sleep] synchronousFlushForSleep: file closed at \(url?.path ?? "nil")") + return url + } + func stopCapture() async throws -> URL? { guard isCapturing else { Log.audio.warning("Not capturing") @@ -644,6 +697,7 @@ final class SystemAudioCaptureService: NSObject { /// Flush any remaining buffered audio directly to file. /// IMPORTANT: Called from `fileWriteQueue.sync` inside `mixerQueue.sync` — /// must NOT dispatch to either queue (would deadlock or drop data). + /// Pass `allowRotation: false` to keep the final flush in a single chunk. private func flushRemainingBuffers() { guard audioFile != nil else { return } @@ -657,18 +711,18 @@ final class SystemAudioCaptureService: NSObject { } systemBuffer.removeFirst(mixCount) micBuffer.removeFirst(mixCount) - writeSamples(mixed) + writeSamples(mixed, allowRotation: false) } if !systemBuffer.isEmpty { - writeSamples(systemBuffer) + writeSamples(systemBuffer, allowRotation: false) systemBuffer.removeAll() } if !micBuffer.isEmpty { - writeSamples(micBuffer) + writeSamples(micBuffer, allowRotation: false) micBuffer.removeAll() } } else if !systemBuffer.isEmpty { - writeSamples(systemBuffer) + writeSamples(systemBuffer, allowRotation: false) systemBuffer.removeAll() } } @@ -830,7 +884,12 @@ extension SystemAudioCaptureService: SCStreamOutput { /// Write Float samples to the audio file. AVAudioFile accepts Float32 buffers matching /// its `processingFormat` and internally converts to the file's Int16 format. - private func writeSamples(_ samples: [Float]) { + /// + /// When `allowRotation` is true (default), checks the 5-min boundary after writing and + /// rotates to the next chunk if `chunkURLProvider` is set. Teardown paths + /// (`flushRemainingBuffers`) pass `allowRotation: false` to keep the final flush + /// in a single chunk file. + private func writeSamples(_ samples: [Float], allowRotation: Bool = true) { guard let audioFile else { return } let processingFormat = audioFile.processingFormat @@ -848,6 +907,7 @@ extension SystemAudioCaptureService: SCStreamOutput { do { try audioFile.write(from: buffer) + currentChunkFrameCount += samples.count let now = Date() if now.timeIntervalSince(lastFlushTime) >= self.flushInterval { @@ -857,6 +917,62 @@ extension SystemAudioCaptureService: SCStreamOutput { } catch { Log.audio.error("Error writing audio: \(error)") } + + if allowRotation { + rotateChunkIfNeededOnFileWriteQueue() + } + } + + // MARK: - Chunk Rotation (RLR-M3b) + + /// Closes the current chunk and opens the next one when wall-clock elapsed since + /// chunk-open exceeds `chunkDurationSeconds`. Must only be called on `fileWriteQueue`. + /// + /// No-op when `chunkURLProvider` is nil (legacy single-file mode). + private func rotateChunkIfNeededOnFileWriteQueue() { + guard let provider = chunkURLProvider else { return } + guard audioFile != nil else { return } + let elapsed = Date().timeIntervalSince(currentChunkStartedAt) + guard elapsed >= chunkDurationSeconds else { return } + + let closedIndex = currentChunkIndex + guard let closedURL = outputURL else { return } + let closedAt = Date() + let closedFrameCount = currentChunkFrameCount + let sampleRate = outputFormat?.sampleRate ?? 16000 + let durationSeconds = sampleRate > 0 ? Double(closedFrameCount) / sampleRate : 0 + + // Close current AVAudioFile — write of header trailer happens on dealloc. + // AVAudioFile close is synchronous; rotation gap is sub-ms (PoC: no-writer p95 = 6.0 ms). + audioFile = nil + + var byteCount: Int64 = 0 + if let attrs = try? FileManager.default.attributesOfItem(atPath: closedURL.path), + let size = attrs[.size] as? Int64 + { + byteCount = size + } + + let newIndex = closedIndex + 1 + let newURL = provider(newIndex) + do { + try setupAudioFile(at: newURL) + outputURL = newURL + currentChunkIndex = newIndex + currentChunkStartedAt = Date() + currentChunkFrameCount = 0 + + Log.audio.info( + "[ChunkRotate] closed chunk \(closedIndex) (\(byteCount) B, \(String(format: "%.1f", durationSeconds))s); opened chunk \(newIndex) at \(newURL.lastPathComponent)" + ) + + onChunkRotated?(closedIndex, closedURL, closedAt, byteCount, durationSeconds) + } catch { + Log.audio.error("[ChunkRotate] FAILED to open chunk \(newIndex) at \(newURL.path): \(error.localizedDescription)") + // Recording is now broken — subsequent writeSamples will drop because audioFile is nil. + // Surface to caller so it can transition state and persist whatever chunks already closed. + onError?(SystemAudioError.chunkRotationFailed(error.localizedDescription)) + } } // MARK: - Sample Extraction @@ -1006,6 +1122,7 @@ enum SystemAudioError: LocalizedError { case permissionDenied case microphoneFormatInvalid case microphoneStartFailed(String) + case chunkRotationFailed(String) var errorDescription: String? { switch self { @@ -1021,6 +1138,8 @@ enum SystemAudioError: LocalizedError { "Invalid microphone audio format" case let .microphoneStartFailed(reason): reason + case let .chunkRotationFailed(reason): + "Failed to rotate recording chunk: \(reason)" } } } diff --git a/Diduny/Core/Storage/InProgressRecordingManifest.swift b/Diduny/Core/Storage/InProgressRecordingManifest.swift new file mode 100644 index 0000000..dfcc032 --- /dev/null +++ b/Diduny/Core/Storage/InProgressRecordingManifest.swift @@ -0,0 +1,50 @@ +import Foundation + +/// On-disk manifest for a single in-progress recording session. +/// Matches ADR-0009 §D2 schema. For M1 `chunks` has at most one entry; +/// M3 will rotate and append additional entries. +struct InProgressRecordingManifest: Codable, Sendable { + let id: UUID + /// Schema version for forward-compat reads. Current: 1. + let schemaVersion: Int + let type: RecordingTypeKind + let startedAt: Date + let sourceDevice: SourceDeviceInfo? + let audioConfig: AudioConfig + var chunks: [ChunkEntry] + var lastWriteAt: Date + /// Set to true when the recording was interrupted by macOS sleep (M2 will flip this). + /// M1 always writes `false`. + var recordingInterruptedBySleep: Bool + + enum RecordingTypeKind: String, Codable, Sendable { + case voice + case meeting + case translation + case meetingTranslation + } + + struct SourceDeviceInfo: Codable, Sendable { + let uid: String + let name: String + let transportType: String + let sampleRate: Double + let channelCount: Int + let wasDefaultRoute: Bool + } + + struct AudioConfig: Codable, Sendable { + let sampleRate: Double + let channels: Int + let bitDepth: Int + } + + struct ChunkEntry: Codable, Sendable { + let index: Int + let filename: String + var byteCount: Int64 + var durationSeconds: Double + /// nil means the chunk did not close cleanly (writer crashed or was killed mid-write). + var closedAt: Date? + } +} diff --git a/Diduny/Core/Storage/InProgressRecordingStore.swift b/Diduny/Core/Storage/InProgressRecordingStore.swift new file mode 100644 index 0000000..b279aae --- /dev/null +++ b/Diduny/Core/Storage/InProgressRecordingStore.swift @@ -0,0 +1,113 @@ +import Foundation +import os + +/// Manages the `InProgressRecordings/` directory under Application Support. +/// +/// Owns all chunk files and `manifest.json` for in-progress meeting recordings. +/// `RecordingsLibraryStorage` only receives finalized, stitched files — it is never +/// written to while a recording is in flight. +/// +/// **M1 scope:** single chunk per recording (`chunk_001.wav`). +/// Chunk rotation is M3; orphan detection is M5a; sleep handling is M2. +actor InProgressRecordingStore { + static let shared = InProgressRecordingStore() + + private let baseDirectory: URL + private let fileManager: FileManager + + // MARK: - Init + + init(baseDirectory: URL? = nil, fileManager: FileManager = .default) { + self.fileManager = fileManager + let bundleID = Bundle.main.bundleIdentifier ?? "Diduny" + let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + self.baseDirectory = baseDirectory ?? appSupport + .appendingPathComponent(bundleID) + .appendingPathComponent("InProgressRecordings") + try? fileManager.createDirectory(at: self.baseDirectory, withIntermediateDirectories: true) + } + + // MARK: - Directory + + /// Returns (creating if absent) the per-recording subdirectory. + func directoryURL(for recordingId: UUID) throws -> URL { + let dir = baseDirectory.appendingPathComponent(recordingId.uuidString) + try fileManager.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + // MARK: - Chunk URL + + /// Returns the path for a chunk file. + /// In M1 this is always `chunk_001.wav` (index defaults to 1). + /// M3 will call with incrementing indices on rotation. + func chunkURL(for recordingId: UUID, index: Int = 1) throws -> URL { + let dir = try directoryURL(for: recordingId) + return dir.appendingPathComponent(String(format: "chunk_%03d.wav", index)) + } + + // MARK: - Manifest + + /// Writes `manifest.json` atomically via temp-file rename, then fsyncs. + func writeManifest(_ manifest: InProgressRecordingManifest, for recordingId: UUID) throws { + let dir = try directoryURL(for: recordingId) + let manifestURL = dir.appendingPathComponent("manifest.json") + let tempURL = dir.appendingPathComponent("manifest.json.tmp") + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(manifest) + + // Write to temp first + try data.write(to: tempURL, options: .atomic) + + // Atomic rename into place + if fileManager.fileExists(atPath: manifestURL.path) { + _ = try fileManager.replaceItemAt(manifestURL, withItemAt: tempURL) + } else { + try fileManager.moveItem(at: tempURL, to: manifestURL) + } + + // fsync the manifest so the kernel flushes to disk + if let handle = try? FileHandle(forReadingFrom: manifestURL) { + try handle.synchronize() + try handle.close() + } + } + + /// Reads `manifest.json` for the given recording ID. + /// Returns `nil` (not throws) when no directory or manifest exists — callers + /// treat absence as "no in-progress recording with that ID." + func readManifest(for recordingId: UUID) throws -> InProgressRecordingManifest? { + let manifestURL = baseDirectory + .appendingPathComponent(recordingId.uuidString) + .appendingPathComponent("manifest.json") + guard fileManager.fileExists(atPath: manifestURL.path) else { return nil } + let data = try Data(contentsOf: manifestURL) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(InProgressRecordingManifest.self, from: data) + } + + // MARK: - Discovery + + /// Returns UUIDs for which a directory exists under `InProgressRecordings/`. + /// Used by the orphan detector (M5a). Directories with non-UUID names are silently ignored. + func allInProgressRecordingIDs() throws -> [UUID] { + guard fileManager.fileExists(atPath: baseDirectory.path) else { return [] } + let entries = try fileManager.contentsOfDirectory(atPath: baseDirectory.path) + return entries.compactMap { UUID(uuidString: $0) } + } + + // MARK: - Cleanup + + /// Removes the per-recording directory after the file has been handed off to + /// `RecordingsLibraryStorage` (or discarded by the user). + func cleanup(recordingId: UUID) throws { + let dir = baseDirectory.appendingPathComponent(recordingId.uuidString) + if fileManager.fileExists(atPath: dir.path) { + try fileManager.removeItem(at: dir) + } + } +} diff --git a/Diduny/Core/Storage/RecordingsLibraryStorage.swift b/Diduny/Core/Storage/RecordingsLibraryStorage.swift index 432187c..31a39a4 100644 --- a/Diduny/Core/Storage/RecordingsLibraryStorage.swift +++ b/Diduny/Core/Storage/RecordingsLibraryStorage.swift @@ -290,7 +290,8 @@ final class RecordingsLibraryStorage { errorMessage: recording.errorMessage, processedAt: recording.processedAt, chapters: recording.chapters, - sourceDevice: recording.sourceDevice + sourceDevice: recording.sourceDevice, + recoverySource: recording.recoverySource ) saveMetadata() diff --git a/Diduny/Core/Storage/SettingsStorage.swift b/Diduny/Core/Storage/SettingsStorage.swift index 197a26e..710af1e 100644 --- a/Diduny/Core/Storage/SettingsStorage.swift +++ b/Diduny/Core/Storage/SettingsStorage.swift @@ -41,11 +41,13 @@ final class SettingsStorage { case launchAtLogin case pushToTalkKey case pushToTalkToggleTapCount + case pushToTalkHoldStartDelaySeconds case meetingAudioSource case meetingMicGain case meetingSystemGain case translationPushToTalkKey case translationPushToTalkToggleTapCount + case translationPushToTalkHoldStartDelaySeconds case handsFreeModeEnabled case recordingHotkeyPressCount case translationHotkeyPressCount @@ -113,14 +115,14 @@ final class SettingsStorage { defaults.removeObject(forKey: Key.selectedDeviceUID.rawValue) } - /// One-time migration: users who had the v1 proxy URL explicitly saved → upgrade to v2. - /// Users who never touched the setting will get v2 via the updated defaultProxyBaseURL. + /// One-time migration: users who had the temporary v2 proxy URL explicitly saved → canonical host. + /// Users who never touched the setting will get the canonical host via defaultProxyBaseURL. private func migrateProxyURLIfNeeded() { - let v1URL = "https://diduny-ears-proxy.fly.dev" + let canonicalURL = "https://diduny-ears-proxy.fly.dev" let v2URL = "https://diduny-ears-proxy-v2.fly.dev" guard let stored = defaults.string(forKey: Key.proxyBaseURL.rawValue), - stored == v1URL else { return } - defaults.set(v2URL, forKey: Key.proxyBaseURL.rawValue) + stored == v2URL else { return } + defaults.set(canonicalURL, forKey: Key.proxyBaseURL.rawValue) } /// Migrate old provider rawValues: "soniox" → "cloud", "whisper_local" → "local" @@ -286,6 +288,21 @@ final class SettingsStorage { } } + /// Hold duration required before dictation starts from a modifier key. + var pushToTalkHoldStartDelaySeconds: TimeInterval { + get { + let stored = defaults.object(forKey: Key.pushToTalkHoldStartDelaySeconds.rawValue) as? TimeInterval + ?? Self.defaultHoldStartDelaySeconds + return Self.sanitizedHoldStartDelaySeconds(stored) + } + set { + defaults.set( + Self.sanitizedHoldStartDelaySeconds(newValue), + forKey: Key.pushToTalkHoldStartDelaySeconds.rawValue + ) + } + } + // MARK: - Meeting Recording var meetingAudioSource: MeetingAudioSource { @@ -350,6 +367,21 @@ final class SettingsStorage { } } + /// Hold duration required before translation starts from a modifier key. + var translationPushToTalkHoldStartDelaySeconds: TimeInterval { + get { + let stored = defaults.object(forKey: Key.translationPushToTalkHoldStartDelaySeconds.rawValue) as? TimeInterval + ?? Self.defaultHoldStartDelaySeconds + return Self.sanitizedHoldStartDelaySeconds(stored) + } + set { + defaults.set( + Self.sanitizedHoldStartDelaySeconds(newValue), + forKey: Key.translationPushToTalkHoldStartDelaySeconds.rawValue + ) + } + } + // MARK: - Hands-Free Mode (Multi-Tap Toggle) /// When enabled, multi-tap starts/stops recording (toggle mode) @@ -657,12 +689,20 @@ final class SettingsStorage { return min(max(value, 2), 3) } + private static let defaultHoldStartDelaySeconds: TimeInterval = 0.2 + + private static func sanitizedHoldStartDelaySeconds(_ value: TimeInterval) -> TimeInterval { + guard value.isFinite else { return defaultHoldStartDelaySeconds } + let clamped = min(max(value, 0.2), 1.0) + return (clamped * 10).rounded() / 10 + } + // MARK: - Proxy Settings #if DEV_BUILD private static let defaultProxyBaseURL = "http://localhost:3000" #else - private static let defaultProxyBaseURL = "https://diduny-ears-proxy-v2.fly.dev" + private static let defaultProxyBaseURL = "https://diduny-ears-proxy.fly.dev" #endif var proxyBaseURL: String { diff --git a/Diduny/Features/Settings/ShortcutsSettingsView.swift b/Diduny/Features/Settings/ShortcutsSettingsView.swift index 9e8e724..eec4461 100644 --- a/Diduny/Features/Settings/ShortcutsSettingsView.swift +++ b/Diduny/Features/Settings/ShortcutsSettingsView.swift @@ -5,8 +5,11 @@ import SwiftUI struct ShortcutsSettingsView: View { @State private var pushToTalkKey = SettingsStorage.shared.pushToTalkKey @State private var pushToTalkTapCount = SettingsStorage.shared.pushToTalkToggleTapCount + @State private var pushToTalkHoldStartDelay = SettingsStorage.shared.pushToTalkHoldStartDelaySeconds @State private var translationPushToTalkKey = SettingsStorage.shared.translationPushToTalkKey @State private var translationPushToTalkTapCount = SettingsStorage.shared.translationPushToTalkToggleTapCount + @State private var translationPushToTalkHoldStartDelay = + SettingsStorage.shared.translationPushToTalkHoldStartDelaySeconds @State private var handsFreeModeEnabled = SettingsStorage.shared.handsFreeModeEnabled @State private var recordingHotkeyPressCount = SettingsStorage.shared.recordingHotkeyPressCount @State private var translationHotkeyPressCount = SettingsStorage.shared.translationHotkeyPressCount @@ -72,7 +75,7 @@ struct ShortcutsSettingsView: View { Text( handsFreeModeEnabled ? "Use the selected tap count to start and stop recording from the modifier key." - : "Hold the key down while speaking, release to transcribe." + : "Hold the selected key until the delay passes. Release earlier to ignore." ) .font(.caption) .foregroundColor(.secondary) @@ -81,6 +84,7 @@ struct ShortcutsSettingsView: View { title: "Dictation:", key: $pushToTalkKey, tapCount: $pushToTalkTapCount, + holdStartDelay: $pushToTalkHoldStartDelay, keyStore: { SettingsStorage.shared.pushToTalkKey = $0 NotificationCenter.default.post(name: .pushToTalkKeyChanged, object: $0) @@ -88,6 +92,10 @@ struct ShortcutsSettingsView: View { tapCountStore: { SettingsStorage.shared.pushToTalkToggleTapCount = $0 NotificationCenter.default.post(name: .pushToTalkTapCountChanged, object: $0) + }, + holdStartDelayStore: { + SettingsStorage.shared.pushToTalkHoldStartDelaySeconds = $0 + NotificationCenter.default.post(name: .pushToTalkHoldStartDelayChanged, object: $0) } ) @@ -95,6 +103,7 @@ struct ShortcutsSettingsView: View { title: "Translation:", key: $translationPushToTalkKey, tapCount: $translationPushToTalkTapCount, + holdStartDelay: $translationPushToTalkHoldStartDelay, keyStore: { SettingsStorage.shared.translationPushToTalkKey = $0 NotificationCenter.default.post(name: .translationPushToTalkKeyChanged, object: $0) @@ -102,6 +111,10 @@ struct ShortcutsSettingsView: View { tapCountStore: { SettingsStorage.shared.translationPushToTalkToggleTapCount = $0 NotificationCenter.default.post(name: .translationPushToTalkTapCountChanged, object: $0) + }, + holdStartDelayStore: { + SettingsStorage.shared.translationPushToTalkHoldStartDelaySeconds = $0 + NotificationCenter.default.post(name: .translationPushToTalkHoldStartDelayChanged, object: $0) } ) } @@ -195,8 +208,10 @@ struct ShortcutsSettingsView: View { title: String, key: Binding, tapCount: Binding, + holdStartDelay: Binding, keyStore: @escaping (PushToTalkKey) -> Void, - tapCountStore: @escaping (Int) -> Void + tapCountStore: @escaping (Int) -> Void, + holdStartDelayStore: @escaping (TimeInterval) -> Void ) -> some View { VStack(alignment: .leading, spacing: 8) { HStack(alignment: .center) { @@ -212,19 +227,28 @@ struct ShortcutsSettingsView: View { Spacer(minLength: 12) - pressCountPicker( - title: "Toggle after", - selection: tapCount, - options: toggleTapCountOptions - ) + if handsFreeModeEnabled { + pressCountPicker( + title: "Toggle after", + selection: tapCount, + options: toggleTapCountOptions + ) .disabled(!handsFreeModeEnabled) .opacity(handsFreeModeEnabled ? 1.0 : 0.45) + } else { + holdDelaySlider( + title: "Start after", + selection: holdStartDelay + ) + .disabled(key.wrappedValue == .none) + .opacity(key.wrappedValue == .none ? 0.45 : 1.0) + } } Text( handsFreeModeEnabled ? "Use \(tapCount.wrappedValue)x press\(tapCount.wrappedValue == 1 ? "" : "es") on the selected key to toggle." - : "Tap count applies only in Toggle mode." + : "Start after \(formattedHoldDelay(holdStartDelay.wrappedValue)) hold. Shorter presses are ignored." ) .font(.caption) .foregroundColor(.secondary) @@ -236,6 +260,9 @@ struct ShortcutsSettingsView: View { .onChange(of: tapCount.wrappedValue) { _, newValue in tapCountStore(newValue) } + .onChange(of: holdStartDelay.wrappedValue) { _, newValue in + holdStartDelayStore(newValue) + } } private func pressCountPicker(title: String, selection: Binding, options: [Int]) -> some View { @@ -254,6 +281,29 @@ struct ShortcutsSettingsView: View { } .frame(width: 150, alignment: .leading) } + + private func holdDelaySlider(title: String, selection: Binding) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.caption) + .foregroundColor(.secondary) + + HStack(spacing: 8) { + Slider(value: selection, in: 0.2...1.0, step: 0.1) + .accessibilityLabel("Start recording after hold duration") + .accessibilityValue(formattedHoldDelay(selection.wrappedValue)) + + Text(formattedHoldDelay(selection.wrappedValue)) + .monospacedDigit() + .frame(width: 42, alignment: .leading) + } + } + .frame(width: 190, alignment: .leading) + } + + private func formattedHoldDelay(_ value: TimeInterval) -> String { + String(format: "%.1f s", value) + } } extension Notification.Name { @@ -261,6 +311,9 @@ extension Notification.Name { static let translationPushToTalkKeyChanged = Notification.Name("translationPushToTalkKeyChanged") static let pushToTalkTapCountChanged = Notification.Name("pushToTalkTapCountChanged") static let translationPushToTalkTapCountChanged = Notification.Name("translationPushToTalkTapCountChanged") + static let pushToTalkHoldStartDelayChanged = Notification.Name("pushToTalkHoldStartDelayChanged") + static let translationPushToTalkHoldStartDelayChanged = + Notification.Name("translationPushToTalkHoldStartDelayChanged") } #Preview { diff --git a/DidunyTests/InProgressRecordingStoreTests.swift b/DidunyTests/InProgressRecordingStoreTests.swift new file mode 100644 index 0000000..24e2466 --- /dev/null +++ b/DidunyTests/InProgressRecordingStoreTests.swift @@ -0,0 +1,160 @@ +import XCTest +@testable import Diduny + +/// Tests for `InProgressRecordingStore` (RLR-M1). +/// +/// Each test uses an isolated temp directory as `baseDirectory:` so the user's +/// Application Support is never polluted. +final class InProgressRecordingStoreTests: XCTestCase { + + // MARK: - Helpers + + /// Creates an isolated store backed by a fresh temp directory. + private func makeStore() -> (InProgressRecordingStore, URL) { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + let store = InProgressRecordingStore(baseDirectory: dir) + return (store, dir) + } + + private func sampleManifest(id: UUID = UUID()) -> InProgressRecordingManifest { + InProgressRecordingManifest( + id: id, + schemaVersion: 1, + type: .meeting, + startedAt: Date(timeIntervalSince1970: 1_748_000_000), + sourceDevice: nil, + audioConfig: InProgressRecordingManifest.AudioConfig( + sampleRate: 48000, + channels: 1, + bitDepth: 16 + ), + chunks: [ + InProgressRecordingManifest.ChunkEntry( + index: 1, + filename: "chunk_001.wav", + byteCount: 0, + durationSeconds: 0, + closedAt: nil + ) + ], + lastWriteAt: Date(timeIntervalSince1970: 1_748_000_000), + recordingInterruptedBySleep: false + ) + } + + // MARK: - 1. beginRecording_createsDirectory + + func test_chunkURL_createsDirectory() async throws { + let (store, _) = makeStore() + let id = UUID() + + let chunkURL = try await store.chunkURL(for: id) + + let dirURL = chunkURL.deletingLastPathComponent() + XCTAssertTrue( + FileManager.default.fileExists(atPath: dirURL.path), + "In-progress recording directory should exist after requesting chunkURL" + ) + XCTAssertEqual(chunkURL.lastPathComponent, "chunk_001.wav") + } + + // MARK: - 2. writeManifest_roundTrip + + func test_writeManifest_roundTrip() async throws { + let (store, _) = makeStore() + let id = UUID() + var manifest = sampleManifest(id: id) + manifest.chunks[0].byteCount = 28_800_000 + manifest.chunks[0].durationSeconds = 300.0 + manifest.chunks[0].closedAt = Date(timeIntervalSince1970: 1_748_000_300) + + try await store.writeManifest(manifest, for: id) + let read = try await store.readManifest(for: id) + + let decoded = try XCTUnwrap(read, "readManifest should return the written manifest") + XCTAssertEqual(decoded.id, id) + XCTAssertEqual(decoded.schemaVersion, 1) + XCTAssertEqual(decoded.type, .meeting) + XCTAssertEqual(decoded.chunks.count, 1) + XCTAssertEqual(decoded.chunks[0].byteCount, 28_800_000) + XCTAssertEqual(decoded.chunks[0].durationSeconds, 300.0, accuracy: 0.001) + XCTAssertFalse(decoded.recordingInterruptedBySleep) + // closedAt round-trip (ISO-8601 truncates to seconds) + let closedAt = try XCTUnwrap(decoded.chunks[0].closedAt) + XCTAssertEqual( + closedAt.timeIntervalSince1970, + 1_748_000_300, + accuracy: 1.0, + "closedAt should survive ISO-8601 round-trip within 1 second" + ) + } + + // MARK: - 3. writeManifest_atomic_temp_cleaned + + func test_writeManifest_noTempFileRemains() async throws { + let (store, _) = makeStore() + let id = UUID() + let manifest = sampleManifest(id: id) + + try await store.writeManifest(manifest, for: id) + let dir = try await store.directoryURL(for: id) + + let contents = try FileManager.default.contentsOfDirectory(atPath: dir.path) + let hasTmp = contents.contains { $0.hasSuffix(".tmp") } + XCTAssertFalse(hasTmp, "No .tmp file should remain after a successful writeManifest") + } + + // MARK: - 4. readManifest_missing_returnsNil + + func test_readManifest_missingID_returnsNil() async throws { + let (store, _) = makeStore() + let unknownID = UUID() + + // Should not throw, should return nil + let result = try await store.readManifest(for: unknownID) + XCTAssertNil(result, "readManifest for a UUID with no directory should return nil") + } + + // MARK: - 5. cleanup_removesDirectory + + func test_cleanup_removesDirectory() async throws { + let (store, _) = makeStore() + let id = UUID() + + // Write something so the directory exists + try await store.writeManifest(sampleManifest(id: id), for: id) + let dir = try await store.directoryURL(for: id) + XCTAssertTrue(FileManager.default.fileExists(atPath: dir.path), "Directory should exist before cleanup") + + try await store.cleanup(recordingId: id) + + XCTAssertFalse( + FileManager.default.fileExists(atPath: dir.path), + "Directory should be removed after cleanup" + ) + } + + // MARK: - 6. allInProgressRecordingIDs_listsExistingDirs + + func test_allInProgressRecordingIDs_filtersNonUUIDs() async throws { + let (store, baseDir) = makeStore() + + let id1 = UUID() + let id2 = UUID() + + // Seed two valid UUID directories + try await store.writeManifest(sampleManifest(id: id1), for: id1) + try await store.writeManifest(sampleManifest(id: id2), for: id2) + + // Seed one garbage-named directory (should be ignored) + let garbageDir = baseDir.appendingPathComponent("not-a-uuid") + try FileManager.default.createDirectory(at: garbageDir, withIntermediateDirectories: true) + + let ids = try await store.allInProgressRecordingIDs() + + XCTAssertEqual(ids.count, 2, "Should return exactly 2 UUID entries, ignoring non-UUID dirs") + XCTAssertTrue(ids.contains(id1), "Should contain id1") + XCTAssertTrue(ids.contains(id2), "Should contain id2") + } +} diff --git a/DidunyTests/MeetingChunkStitcherTests.swift b/DidunyTests/MeetingChunkStitcherTests.swift new file mode 100644 index 0000000..7396247 --- /dev/null +++ b/DidunyTests/MeetingChunkStitcherTests.swift @@ -0,0 +1,156 @@ +import AVFoundation +import XCTest +@testable import Diduny + +/// Tests for `MeetingChunkStitcher` (RLR-M4). +/// +/// Strategy: synthesize short mono 16 kHz 16-bit WAV files via `AVAudioFile`, stitch them, +/// then verify duration and frame counts in the output. Each test writes into an isolated +/// temp directory so the user's filesystem stays clean. +final class MeetingChunkStitcherTests: XCTestCase { + + private var tmpDir: URL! + + override func setUp() { + super.setUp() + tmpDir = FileManager.default.temporaryDirectory + .appendingPathComponent("MeetingChunkStitcherTests-\(UUID().uuidString)") + try? FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) + } + + override func tearDown() { + try? FileManager.default.removeItem(at: tmpDir) + tmpDir = nil + super.tearDown() + } + + // MARK: - Helpers + + /// Writes a WAV chunk filled with constant-amplitude samples; returns its URL. + private func writeChunk(name: String, durationSeconds: Double, sampleRate: Double = 16000) throws -> URL { + let url = tmpDir.appendingPathComponent(name) + let settings: [String: Any] = [ + AVFormatIDKey: kAudioFormatLinearPCM, + AVSampleRateKey: sampleRate, + AVNumberOfChannelsKey: 1, + AVLinearPCMBitDepthKey: 16, + AVLinearPCMIsFloatKey: false, + AVLinearPCMIsBigEndianKey: false, + AVLinearPCMIsNonInterleaved: false + ] + let file = try AVAudioFile(forWriting: url, settings: settings) + let frameCount = AVAudioFrameCount(durationSeconds * sampleRate) + guard let buffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: frameCount) else { + XCTFail("Failed to create PCM buffer") + return url + } + buffer.frameLength = frameCount + if let data = buffer.floatChannelData?[0] { + for i in 0 ..< Int(frameCount) { + data[i] = 0.05 // small constant amplitude so silence detectors don't kick in + } + } + try file.write(from: buffer) + return url + } + + /// Creates a zero-byte file masquerading as a chunk (corrupt / empty). + private func writeEmptyFile(name: String) throws -> URL { + let url = tmpDir.appendingPathComponent(name) + try Data().write(to: url) + return url + } + + // MARK: - 1. noChunks throws + + func test_stitch_emptyList_throws() { + let target = tmpDir.appendingPathComponent("out.wav") + XCTAssertThrowsError(try MeetingChunkStitcher.stitch(chunkURLs: [], outputURL: target)) { error in + guard case MeetingChunkStitcher.StitchError.noChunks = error else { + XCTFail("Expected .noChunks, got \(error)") + return + } + } + } + + // MARK: - 2. single-chunk fast path copies + + func test_stitch_singleChunk_copiesAndReturnsDuration() throws { + let chunk = try writeChunk(name: "chunk_001.wav", durationSeconds: 1.0) + let target = tmpDir.appendingPathComponent("out.wav") + + let result = try MeetingChunkStitcher.stitch(chunkURLs: [chunk], outputURL: target) + + XCTAssertTrue(FileManager.default.fileExists(atPath: target.path)) + XCTAssertEqual(result.appendedChunkCount, 1) + XCTAssertTrue(result.skippedChunks.isEmpty) + XCTAssertEqual(result.totalDurationSeconds, 1.0, accuracy: 0.05) + + // Source must still exist (we copy, not move). + XCTAssertTrue(FileManager.default.fileExists(atPath: chunk.path)) + } + + // MARK: - 3. multi-chunk stitch sums duration + + func test_stitch_threeChunks_durationIsSum() throws { + let c1 = try writeChunk(name: "chunk_001.wav", durationSeconds: 1.0) + let c2 = try writeChunk(name: "chunk_002.wav", durationSeconds: 0.5) + let c3 = try writeChunk(name: "chunk_003.wav", durationSeconds: 0.7) + let target = tmpDir.appendingPathComponent("out.wav") + + let result = try MeetingChunkStitcher.stitch(chunkURLs: [c1, c2, c3], outputURL: target) + + XCTAssertEqual(result.appendedChunkCount, 3) + XCTAssertTrue(result.skippedChunks.isEmpty) + XCTAssertEqual(result.totalDurationSeconds, 2.2, accuracy: 0.1) + + // Verify the output is a readable WAV with the expected frame count. + let outFile = try AVAudioFile(forReading: target) + let expectedFrames = AVAudioFramePosition(2.2 * 16000) + XCTAssertEqual(Int(outFile.length), Int(expectedFrames), accuracy: 1600) // ±0.1s tolerance + } + + // MARK: - 4. skip empty chunk in the middle + + func test_stitch_skipsEmptyChunk() throws { + let c1 = try writeChunk(name: "chunk_001.wav", durationSeconds: 0.5) + let cBad = try writeEmptyFile(name: "chunk_002.wav") + let c3 = try writeChunk(name: "chunk_003.wav", durationSeconds: 0.5) + let target = tmpDir.appendingPathComponent("out.wav") + + let result = try MeetingChunkStitcher.stitch(chunkURLs: [c1, cBad, c3], outputURL: target) + + XCTAssertEqual(result.appendedChunkCount, 2) + XCTAssertEqual(result.skippedChunks, [2]) + XCTAssertEqual(result.totalDurationSeconds, 1.0, accuracy: 0.1) + } + + // MARK: - 5. allChunksUnreadable error + + func test_stitch_allChunksEmpty_throwsAllUnreadable() throws { + let bad1 = try writeEmptyFile(name: "chunk_001.wav") + let bad2 = try writeEmptyFile(name: "chunk_002.wav") + let target = tmpDir.appendingPathComponent("out.wav") + + XCTAssertThrowsError(try MeetingChunkStitcher.stitch(chunkURLs: [bad1, bad2], outputURL: target)) { error in + guard case MeetingChunkStitcher.StitchError.allChunksUnreadable = error else { + XCTFail("Expected .allChunksUnreadable, got \(error)") + return + } + } + } + + // MARK: - 6. skip leading empty chunk + + func test_stitch_leadingEmptyChunk_skippedFirstReadableDefinesFormat() throws { + let bad = try writeEmptyFile(name: "chunk_001.wav") + let c2 = try writeChunk(name: "chunk_002.wav", durationSeconds: 0.5) + let target = tmpDir.appendingPathComponent("out.wav") + + let result = try MeetingChunkStitcher.stitch(chunkURLs: [bad, c2], outputURL: target) + + XCTAssertEqual(result.appendedChunkCount, 1) + XCTAssertEqual(result.skippedChunks, [1]) + XCTAssertEqual(result.totalDurationSeconds, 0.5, accuracy: 0.1) + } +} diff --git a/DidunyTests/PushToTalkServiceTests.swift b/DidunyTests/PushToTalkServiceTests.swift new file mode 100644 index 0000000..1949027 --- /dev/null +++ b/DidunyTests/PushToTalkServiceTests.swift @@ -0,0 +1,92 @@ +import Testing + +@testable import Diduny + +@Suite("PushToTalkService Hold Delay") +@MainActor +struct PushToTalkServiceTests { + init() { + SettingsStorage.shared.handsFreeModeEnabled = false + SettingsStorage.shared.pushToTalkHoldStartDelaySeconds = 0.2 + SettingsStorage.shared.translationPushToTalkHoldStartDelaySeconds = 0.2 + } + + @Test("Short hold before threshold does not start or stop recording") + func shortHoldBeforeThresholdDoesNotStart() async { + let sut = PushToTalkService() + sut.selectedKey = .rightShift + sut.holdStartDelaySeconds = 0.2 + + var starts = 0 + var stops = 0 + sut.onKeyDown = { starts += 1 } + sut.onKeyUp = { stops += 1 } + + sut.processModifierKeyEvent(isPressed: true, eventTime: 0) + try? await Task.sleep(for: .milliseconds(100)) + sut.processModifierKeyEvent(isPressed: false, eventTime: 0.1) + try? await Task.sleep(for: .milliseconds(150)) + + #expect(starts == 0) + #expect(stops == 0) + } + + @Test("Hold past threshold starts once and release stops once") + func holdPastThresholdStartsAndReleaseStops() async { + let sut = PushToTalkService() + sut.selectedKey = .rightShift + sut.holdStartDelaySeconds = 0.2 + + var starts = 0 + var stops = 0 + sut.onKeyDown = { starts += 1 } + sut.onKeyUp = { stops += 1 } + + sut.processModifierKeyEvent(isPressed: true, eventTime: 0) + try? await Task.sleep(for: .milliseconds(250)) + + #expect(starts == 1) + #expect(stops == 0) + + sut.processModifierKeyEvent(isPressed: false, eventTime: 0.25) + try? await Task.sleep(for: .milliseconds(50)) + + #expect(starts == 1) + #expect(stops == 1) + } + + @Test("Hands-free toggle ignores hold delay") + func handsFreeToggleIgnoresHoldDelay() async { + SettingsStorage.shared.handsFreeModeEnabled = true + + let sut = PushToTalkService() + sut.selectedKey = .rightShift + sut.toggleTapCount = 2 + sut.holdStartDelaySeconds = 1.0 + + var toggles = 0 + var starts = 0 + sut.onToggle = { toggles += 1 } + sut.onKeyDown = { starts += 1 } + + sut.processModifierKeyEvent(isPressed: true, eventTime: 0) + sut.processModifierKeyEvent(isPressed: false, eventTime: 0.05) + sut.processModifierKeyEvent(isPressed: true, eventTime: 0.1) + try? await Task.sleep(for: .milliseconds(50)) + + #expect(toggles == 1) + #expect(starts == 0) + } + + @Test("Settings clamp hold start delay range") + func settingsClampHoldStartDelayRange() { + SettingsStorage.shared.pushToTalkHoldStartDelaySeconds = 0.05 + #expect(SettingsStorage.shared.pushToTalkHoldStartDelaySeconds == 0.2) + + SettingsStorage.shared.pushToTalkHoldStartDelaySeconds = 1.5 + #expect(SettingsStorage.shared.pushToTalkHoldStartDelaySeconds == 1.0) + + SettingsStorage.shared.translationPushToTalkHoldStartDelaySeconds = 0.55 + #expect(SettingsStorage.shared.translationPushToTalkHoldStartDelaySeconds == 0.6) + } +} diff --git a/DidunyTests/RecordingModelMigrationTests.swift b/DidunyTests/RecordingModelMigrationTests.swift new file mode 100644 index 0000000..41718a1 --- /dev/null +++ b/DidunyTests/RecordingModelMigrationTests.swift @@ -0,0 +1,169 @@ +import XCTest +@testable import Diduny + +/// Tests for the RLR-M0 data-model additions: +/// - `RecoverySource` enum + `Recording.recoverySource` property +/// - `Recording.ProcessingStatus.partiallyRecovered` case +/// +/// The primary concerns are: +/// 1. Backward compatibility — JSON written before M0 (no `recoverySource` key, +/// no `partiallyRecovered` status) must decode without error. +/// 2. Round-trip fidelity for the new fields. +/// 3. Exhaustive switch coverage for `ProcessingStatus` (compiler-enforced). +final class RecordingModelMigrationTests: XCTestCase { + + // MARK: - Helpers + + private let iso8601: JSONDecoder = { + let d = JSONDecoder() + d.dateDecodingStrategy = .iso8601 + return d + }() + + private let iso8601Encoder: JSONEncoder = { + let e = JSONEncoder() + e.dateEncodingStrategy = .iso8601 + return e + }() + + // A minimal JSON object that represents a Recording saved before RLR-M0. + // It intentionally omits `recoverySource` and uses only pre-M0 status values. + private let legacyJSON = """ + [ + { + "id": "12345678-1234-1234-1234-123456789ABC", + "createdAt": "2025-11-01T10:00:00Z", + "type": "meeting", + "audioFileName": "12345678-1234-1234-1234-123456789ABC.wav", + "durationSeconds": 3600.0, + "fileSizeBytes": 675000000, + "status": "transcribed", + "transcriptionText": "Hello world.", + "processedAt": "2025-11-01T11:00:00Z" + } + ] + """ + + // MARK: - 1. Backward compatibility + + func test_legacyJSON_decodesWithoutError() throws { + let data = try XCTUnwrap(legacyJSON.data(using: .utf8)) + let recordings = try iso8601.decode([Recording].self, from: data) + XCTAssertEqual(recordings.count, 1) + } + + func test_legacyJSON_recoverySourceIsNil() throws { + let data = try XCTUnwrap(legacyJSON.data(using: .utf8)) + let recordings = try iso8601.decode([Recording].self, from: data) + XCTAssertNil(recordings[0].recoverySource, + "recordings from before M0 must have recoverySource == nil") + } + + func test_legacyJSON_originalFieldsIntact() throws { + let data = try XCTUnwrap(legacyJSON.data(using: .utf8)) + let r = try iso8601.decode([Recording].self, from: data)[0] + + XCTAssertEqual(r.id.uuidString, "12345678-1234-1234-1234-123456789ABC") + XCTAssertEqual(r.type, .meeting) + XCTAssertEqual(r.audioFileName, "12345678-1234-1234-1234-123456789ABC.wav") + XCTAssertEqual(r.durationSeconds, 3600.0, accuracy: 0.001) + XCTAssertEqual(r.fileSizeBytes, 675_000_000) + XCTAssertEqual(r.status, .transcribed) + XCTAssertEqual(r.transcriptionText, "Hello world.") + } + + // MARK: - 2. Round-trip + + func test_roundTrip_orphanedSession_partiallyRecovered() throws { + let original = Recording( + id: UUID(uuidString: "AABBCCDD-AABB-CCDD-AABB-CCDDAABBCCDD")!, + createdAt: Date(timeIntervalSince1970: 1_700_000_000), + type: .meeting, + audioFileName: "AABBCCDD-AABB-CCDD-AABB-CCDDAABBCCDD.flac", + durationSeconds: 2400.0, + fileSizeBytes: 48_000_000, + status: .partiallyRecovered, + transcriptionText: nil, + errorMessage: nil, + processedAt: nil, + chapters: nil, + sourceDevice: nil, + recoverySource: .orphanedSession + ) + + let data = try iso8601Encoder.encode([original]) + let decoded = try iso8601.decode([Recording].self, from: data)[0] + + XCTAssertEqual(decoded.id, original.id) + XCTAssertEqual(decoded.status, .partiallyRecovered) + XCTAssertEqual(decoded.recoverySource, .orphanedSession) + XCTAssertEqual(decoded.durationSeconds, original.durationSeconds, accuracy: 0.001) + XCTAssertNil(decoded.transcriptionText) + } + + func test_roundTrip_nilRecoverySource_normalStop() throws { + let original = Recording( + id: UUID(), + createdAt: Date(timeIntervalSince1970: 1_700_100_000), + type: .voice, + audioFileName: "voice.wav", + durationSeconds: 12.5, + fileSizeBytes: 220_500, + status: .transcribed, + transcriptionText: "Test text.", + errorMessage: nil, + processedAt: Date(timeIntervalSince1970: 1_700_100_015), + chapters: nil, + sourceDevice: nil, + recoverySource: nil + ) + + let data = try iso8601Encoder.encode([original]) + let decoded = try iso8601.decode([Recording].self, from: data)[0] + + XCTAssertEqual(decoded.status, .transcribed) + XCTAssertNil(decoded.recoverySource) + } + + // MARK: - 3. Exhaustive switch coverage (compiler-enforced) + + /// This test's body must enumerate every `ProcessingStatus` case. + /// If a future PR adds a case without updating this switch the compiler + /// will fail the build — that is the intended behavior. + func test_processingStatus_switchIsExhaustive() { + let allCases: [Recording.ProcessingStatus] = [ + .unprocessed, + .processing, + .transcribed, + .translated, + .failed, + .partiallyRecovered, + ] + + for status in allCases { + switch status { + case .unprocessed: + _ = status + case .processing: + _ = status + case .transcribed: + _ = status + case .translated: + _ = status + case .failed: + _ = status + case .partiallyRecovered: + _ = status + } + } + // If this compiles, all cases are handled. + XCTAssertEqual(allCases.count, 6) + } + + // MARK: - 4. RecoverySource raw-value stability + + func test_recoverySource_rawValues() { + // Raw-value strings are persisted to disk — must never change. + XCTAssertEqual(RecoverySource.orphanedSession.rawValue, "orphanedSession") + } +} diff --git a/DidunyTests/SleepFlushCoordinatorTests.swift b/DidunyTests/SleepFlushCoordinatorTests.swift new file mode 100644 index 0000000..a8958e3 --- /dev/null +++ b/DidunyTests/SleepFlushCoordinatorTests.swift @@ -0,0 +1,150 @@ +import XCTest +@testable import Diduny + +/// Tests for `SleepFlushCoordinator` (RLR-M2). +/// +/// These tests verify the synchronous notification delivery contract: +/// - `willSleepNotification` calls `flushCurrentChunk` synchronously before `post` returns. +/// - `didWakeNotification` calls `onWake` synchronously before `post` returns. +/// - A slow `flushCurrentChunk` closure still completes before the notification handler returns +/// (i.e., the coordinator does not internally dispatch the closure to another queue). +/// +/// The tests post notifications directly to `NSWorkspace.shared.notificationCenter` using the +/// `queue: nil` registration path that `SleepFlushCoordinator` uses. Because the posting thread +/// blocks until all synchronously-registered observers return, the assertions below are valid +/// immediately after the `post` call returns. +final class SleepFlushCoordinatorTests: XCTestCase { + + // MARK: - Helpers + + private let workspaceNC = NSWorkspace.shared.notificationCenter + + private func postWillSleep() { + workspaceNC.post(name: NSWorkspace.willSleepNotification, object: nil) + } + + private func postDidWake() { + workspaceNC.post(name: NSWorkspace.didWakeNotification, object: nil) + } + + // MARK: - 1. willSleep dispatches flushCurrentChunk synchronously + + /// After `willSleepNotification` is posted, `flushCurrentChunk` must have been called + /// before the `post` call returns (synchronous delivery via `queue: nil`). + func test_willSleep_callsFlushSynchronously() { + let coordinator = SleepFlushCoordinator() + var flushCalled = false + + coordinator.flushCurrentChunk = { + flushCalled = true + return true + } + + postWillSleep() + + // If delivery were async, this assertion would fail intermittently. + XCTAssertTrue(flushCalled, "flushCurrentChunk must be called synchronously on willSleepNotification") + } + + // MARK: - 2. didWake dispatches onWake synchronously + + /// After `didWakeNotification` is posted, `onWake` must have been called + /// before the `post` call returns. + func test_didWake_callsOnWakeSynchronously() { + let coordinator = SleepFlushCoordinator() + var wakeCalled = false + + coordinator.onWake = { + wakeCalled = true + } + + postDidWake() + + XCTAssertTrue(wakeCalled, "onWake must be called synchronously on didWakeNotification") + } + + // MARK: - 3. flushCurrentChunk return value is forwarded correctly + + /// The coordinator passes through the return value of `flushCurrentChunk` to its own + /// log/internal state. We test both true and false returns to ensure the closure is + /// actually invoked and is not short-circuited. + func test_willSleep_flushReturnFalse_doesNotHang() { + let coordinator = SleepFlushCoordinator() + var flushResult: Bool? = nil + + coordinator.flushCurrentChunk = { + flushResult = false + return false + } + + // This must return without hanging (the coordinator must not retry or wait). + postWillSleep() + + XCTAssertEqual(flushResult, false, "flushCurrentChunk returning false should complete without hanging") + } + + // MARK: - 4. Coordinator runs when no closures are set (nil safety) + + /// When `flushCurrentChunk` and `onWake` are nil, the coordinator must not crash. + func test_willSleep_noClosureSet_doesNotCrash() { + let coordinator = SleepFlushCoordinator() + // Intentionally leaving flushCurrentChunk as nil + XCTAssertNoThrow(postWillSleep()) + _ = coordinator // keep alive + } + + func test_didWake_noClosureSet_doesNotCrash() { + let coordinator = SleepFlushCoordinator() + // Intentionally leaving onWake as nil + XCTAssertNoThrow(postDidWake()) + _ = coordinator // keep alive + } + + // MARK: - 5. Slow flush still completes before handler returns + + /// A closure that takes non-trivial time (100 ms) must still complete synchronously — + /// the coordinator must NOT dispatch the closure to another queue. + /// If the handler dispatched asynchronously, `flushCalled` would be false here. + func test_willSleep_slowFlush_completesBeforeHandlerReturns() { + let coordinator = SleepFlushCoordinator() + var flushCalled = false + + coordinator.flushCurrentChunk = { + // Simulate a non-trivial but fast flush (well within 250 ms budget) + Thread.sleep(forTimeInterval: 0.1) + flushCalled = true + return true + } + + postWillSleep() + + XCTAssertTrue( + flushCalled, + "flushCurrentChunk must complete synchronously even when it takes 100 ms — " + + "coordinator must not dispatch to another queue" + ) + } + + // MARK: - 6. Deinit removes observers (no double-fire after dealloc) + + /// After the coordinator is deallocated, posting willSleep must not fire the old closure. + /// This guards against a use-after-free / dangling observer scenario. + func test_deinit_removesObservers() { + var flushCallCount = 0 + + autoreleasepool { + let coordinator = SleepFlushCoordinator() + coordinator.flushCurrentChunk = { + flushCallCount += 1 + return true + } + postWillSleep() + // coordinator deallocated here when autoreleasepool drains + } + + // At this point coordinator is gone. Post again — the closure must not fire. + postWillSleep() + + XCTAssertEqual(flushCallCount, 1, "Observer must be removed on deinit — closure fired \(flushCallCount) times") + } +} diff --git a/project.yml b/project.yml index e21090f..581d9d9 100644 --- a/project.yml +++ b/project.yml @@ -29,8 +29,8 @@ configs: settings: base: - MARKETING_VERSION: "1.14.1" - CURRENT_PROJECT_VERSION: "3" + MARKETING_VERSION: "1.14.3" + CURRENT_PROJECT_VERSION: "4" DEVELOPMENT_TEAM: "8JL9TM5WLG" CODE_SIGN_STYLE: Automatic ARCHS: "arm64 x86_64" From e22c98be70b8f5cc08c2ce83721be5319d5c75bd Mon Sep 17 00:00:00 2001 From: Roman Marinsky Date: Mon, 1 Jun 2026 22:53:19 +0300 Subject: [PATCH 2/8] set release placeholder to 1.14.4 --- Diduny.xcodeproj/project.pbxproj | 6 +++--- project.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Diduny.xcodeproj/project.pbxproj b/Diduny.xcodeproj/project.pbxproj index 0228dad..a7eca7b 100644 --- a/Diduny.xcodeproj/project.pbxproj +++ b/Diduny.xcodeproj/project.pbxproj @@ -875,7 +875,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.14.3; + MARKETING_VERSION = 1.14.4; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = NO; @@ -996,7 +996,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.14.3; + MARKETING_VERSION = 1.14.4; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = NO; @@ -1062,7 +1062,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.14.3; + MARKETING_VERSION = 1.14.4; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; diff --git a/project.yml b/project.yml index 581d9d9..38faa24 100644 --- a/project.yml +++ b/project.yml @@ -29,7 +29,7 @@ configs: settings: base: - MARKETING_VERSION: "1.14.3" + MARKETING_VERSION: "1.14.4" CURRENT_PROJECT_VERSION: "4" DEVELOPMENT_TEAM: "8JL9TM5WLG" CODE_SIGN_STYLE: Automatic From b35623a7bb4b73b98ad9cd40dc95ee9a422b6d9c Mon Sep 17 00:00:00 2001 From: Roman Marinsky Date: Mon, 1 Jun 2026 22:57:27 +0300 Subject: [PATCH 3/8] drop accidental test scheme change --- .../xcschemes/Diduny TEST.xcscheme | 25 ------------------- 1 file changed, 25 deletions(-) diff --git a/Diduny.xcodeproj/xcshareddata/xcschemes/Diduny TEST.xcscheme b/Diduny.xcodeproj/xcshareddata/xcschemes/Diduny TEST.xcscheme index e2474e0..700b774 100644 --- a/Diduny.xcodeproj/xcshareddata/xcschemes/Diduny TEST.xcscheme +++ b/Diduny.xcodeproj/xcshareddata/xcschemes/Diduny TEST.xcscheme @@ -21,20 +21,6 @@ ReferencedContainer = "container:Diduny.xcodeproj"> - - - - - - - - Date: Mon, 1 Jun 2026 23:20:38 +0300 Subject: [PATCH 4/8] Address PR review feedback --- Diduny/App/AppDelegate+MeetingRecording.swift | 10 +- ...Delegate+MeetingTranslationRecording.swift | 10 +- Diduny/App/AppDelegate.swift | 231 ++++++++++-------- .../Core/Services/MeetingChunkStitcher.swift | 38 ++- .../Services/MeetingRecorderService.swift | 18 +- Diduny/Core/Services/PushToTalkService.swift | 46 ++-- .../Services/SystemAudioCaptureService.swift | 102 ++++---- .../Storage/InProgressRecordingStore.swift | 37 ++- .../InProgressRecordingStoreTests.swift | 29 ++- DidunyTests/MeetingChunkStitcherTests.swift | 37 ++- DidunyTests/PushToTalkServiceTests.swift | 24 +- .../RecordingModelMigrationTests.swift | 43 ++-- 12 files changed, 380 insertions(+), 245 deletions(-) diff --git a/Diduny/App/AppDelegate+MeetingRecording.swift b/Diduny/App/AppDelegate+MeetingRecording.swift index 9788b38..3f74645 100644 --- a/Diduny/App/AppDelegate+MeetingRecording.swift +++ b/Diduny/App/AppDelegate+MeetingRecording.swift @@ -64,7 +64,9 @@ extension AppDelegate { ) // Library has taken ownership — remove the in-progress directory (RLR-M1). if let ipId = cancelInProgressRecordingId { - try? await InProgressRecordingStore.shared.cleanup(recordingId: ipId) + if let store = try? InProgressRecordingStore.sharedStore() { + try? await store.cleanup(recordingId: ipId) + } } try? FileManager.default.removeItem(at: audioURL) Log.app.info("cancelMeetingRecording: audio saved after cancel") @@ -383,7 +385,11 @@ extension AppDelegate { func cleanupInProgressDirectory() { if let ipId = inProgressRecordingId { - Task { try? await InProgressRecordingStore.shared.cleanup(recordingId: ipId) } + Task { + if let store = try? InProgressRecordingStore.sharedStore() { + try? await store.cleanup(recordingId: ipId) + } + } } } diff --git a/Diduny/App/AppDelegate+MeetingTranslationRecording.swift b/Diduny/App/AppDelegate+MeetingTranslationRecording.swift index d024753..485be3b 100644 --- a/Diduny/App/AppDelegate+MeetingTranslationRecording.swift +++ b/Diduny/App/AppDelegate+MeetingTranslationRecording.swift @@ -66,7 +66,9 @@ extension AppDelegate { ) // Library has taken ownership — remove the in-progress directory (RLR-M1). if let ipId = cancelInProgressRecordingId { - try? await InProgressRecordingStore.shared.cleanup(recordingId: ipId) + if let store = try? InProgressRecordingStore.sharedStore() { + try? await store.cleanup(recordingId: ipId) + } } try? FileManager.default.removeItem(at: audioURL) Log.app.info("cancelMeetingTranslationRecording: audio saved after cancel") @@ -415,7 +417,11 @@ extension AppDelegate { func cleanupInProgressDirectory() { if let ipId = inProgressRecordingId { - Task { try? await InProgressRecordingStore.shared.cleanup(recordingId: ipId) } + Task { + if let store = try? InProgressRecordingStore.sharedStore() { + try? await store.cleanup(recordingId: ipId) + } + } } } diff --git a/Diduny/App/AppDelegate.swift b/Diduny/App/AppDelegate.swift index f131164..54d0395 100644 --- a/Diduny/App/AppDelegate.swift +++ b/Diduny/App/AppDelegate.swift @@ -19,6 +19,84 @@ enum RecordingKind { } } +private final class SleepRecordingFlushBridge { + private let meetingRecorderService: MeetingRecorderService + private let stateLock = NSLock() + private var recordingWasInterruptedBySleep = false + + var releaseActivityTokens: (() -> Void)? + + init(meetingRecorderService: MeetingRecorderService) { + self.meetingRecorderService = meetingRecorderService + } + + /// Flushes the active meeting recording synchronously on the willSleep thread. + /// Voice/translation recordings hold audio in memory until stop() is called; their + /// recovery state is already persisted on disk so there is nothing extra to flush. + func flushActiveRecordingForSleep() -> Bool { + let meetingActive = meetingRecorderService.isRecording + + guard meetingActive else { + Log.recording.info("[Sleep] flushActiveRecordingForSleep: no active meeting recording") + setRecordingWasInterruptedBySleep(false) + return true + } + + Log.recording.info("[Sleep] flushActiveRecordingForSleep: flushing meeting recording chunk") + + let flushedURL = meetingRecorderService.synchronousFlushForSleep() + let flushSucceeded = flushedURL != nil + setRecordingWasInterruptedBySleep(true) + + let recordingId = meetingRecorderService.currentRecordingId + if let recordingId { + Task { + do { + let store = try InProgressRecordingStore.sharedStore() + if var manifest = try await store.readManifest(for: recordingId) { + manifest.recordingInterruptedBySleep = true + manifest.lastWriteAt = Date() + if !manifest.chunks.isEmpty { + let closeTime: Date? = flushSucceeded ? Date() : nil + manifest.chunks[manifest.chunks.count - 1].closedAt = closeTime + if let url = flushedURL, + let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), + let size = attrs[.size] as? Int64 + { + manifest.chunks[manifest.chunks.count - 1].byteCount = size + } + } + try await store.writeManifest(manifest, for: recordingId) + Log.recording + .info( + "[Sleep] manifest updated: recordingInterruptedBySleep=true, chunk closedAt=\(flushSucceeded ? "set" : "nil")" + ) + } + } catch { + Log.recording.error("[Sleep] Failed to update manifest: \(error.localizedDescription)") + } + } + } + + releaseActivityTokens?() + return flushSucceeded + } + + func consumeRecordingWasInterruptedBySleep() -> Bool { + stateLock.lock() + let value = recordingWasInterruptedBySleep + recordingWasInterruptedBySleep = false + stateLock.unlock() + return value + } + + private func setRecordingWasInterruptedBySleep(_ value: Bool) { + stateLock.lock() + recordingWasInterruptedBySleep = value + stateLock.unlock() + } +} + @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { // MARK: - Properties @@ -36,8 +114,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Sleep handling (RLR-M2) private var sleepFlushCoordinator: SleepFlushCoordinator? - /// True when sleep interrupted an active recording; cleared after wake message is shown. - private var recordingWasInterruptedBySleep = false + private var sleepRecordingFlushBridge: SleepRecordingFlushBridge? // Pipeline Tasks (stored so cancel can abort them) var voicePipelineTask: Task? @@ -144,9 +221,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let action = await OnboardingManager.shared.computeStartupAction() switch action { case .skipOnboarding: - self.setupAfterOnboarding() + setupAfterOnboarding() - case .showFullTour(let jumpStep): + case let .showFullTour(jumpStep): OnboardingManager.shared.setupDefaultsForNewUser() if let jump = jumpStep { OnboardingManager.shared.currentStep = jump @@ -156,7 +233,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self.setupAfterOnboarding() } - case .showMiniFlow(let steps): + case let .showMiniFlow(steps): try? await Task.sleep(for: .milliseconds(120)) OnboardingManager.shared.currentStep = steps.first ?? .microphonePermission OnboardingWindowController.shared.showOnboarding(miniFlow: steps) { @@ -218,121 +295,59 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private func setupSleepHandling() { let coordinator = SleepFlushCoordinator() + let bridge = SleepRecordingFlushBridge(meetingRecorderService: meetingRecorderService) + + bridge.releaseActivityTokens = { [weak self] in + Task { @MainActor [weak self] in + self?.releaseMeetingSleepActivityTokens() + } + } - coordinator.flushCurrentChunk = { [weak self] in - guard let self else { return true } - return self.flushActiveRecordingForSleep() + coordinator.flushCurrentChunk = { [weak bridge] in + bridge?.flushActiveRecordingForSleep() ?? true } - coordinator.onWake = { [weak self] in - self?.handleWakeAfterRecordingInterrupt() + coordinator.onWake = { [weak bridge, weak self] in + guard bridge?.consumeRecordingWasInterruptedBySleep() == true else { return } + Task { @MainActor [weak self] in + self?.showWakeAfterRecordingInterrupt() + } } + sleepRecordingFlushBridge = bridge sleepFlushCoordinator = coordinator Log.app.info("[Sleep] SleepFlushCoordinator registered for willSleep / didWake") } - /// Flushes the active meeting recording synchronously on the willSleep thread. - /// Voice/translation recordings hold audio in memory until stop() is called; their - /// recovery state is already persisted on disk so there is nothing extra to flush. - /// Returns true if all flushes completed cleanly. - private func flushActiveRecordingForSleep() -> Bool { - // Determine which recording mode is active (MainActor state read from background thread). - // We access the service directly — meetingRecorderService is safe to read from any thread - // (isRecording is a plain Bool, not actor-isolated). - let meetingActive = meetingRecorderService.isRecording - - guard meetingActive else { - Log.recording.info("[Sleep] flushActiveRecordingForSleep: no active meeting recording") - recordingWasInterruptedBySleep = false - return true - } - - Log.recording.info("[Sleep] flushActiveRecordingForSleep: flushing meeting recording chunk") - - // Synchronously flush the audio file. AVAudioFile.close() is synchronous. - let flushedURL = meetingRecorderService.synchronousFlushForSleep() - let ok = flushedURL != nil - - // Mark that sleep interrupted a recording so didWake can surface the message. - recordingWasInterruptedBySleep = true - - // Capture IDs for the async manifest update (spawned after handler returns). - let recordingId = meetingRecorderService.currentRecordingId - - // Spawn async Task to update manifest with recordingInterruptedBySleep = true. - // This may not complete before the system sleeps — that is acceptable. - // The OrphanedRecordingDetector (M5a) can recover from a missing or stale manifest. - if let recordingId { - Task { - do { - let store = InProgressRecordingStore.shared - if var manifest = try await store.readManifest(for: recordingId) { - manifest.recordingInterruptedBySleep = true - manifest.lastWriteAt = Date() - // Mark the last chunk as closed (or incomplete on nil flushedURL). - if !manifest.chunks.isEmpty { - let closeTime: Date? = ok ? Date() : nil - manifest.chunks[manifest.chunks.count - 1].closedAt = closeTime - if let url = flushedURL, - let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), - let size = attrs[.size] as? Int64 - { - manifest.chunks[manifest.chunks.count - 1].byteCount = size - } - } - try await store.writeManifest(manifest, for: recordingId) - Log.recording.info("[Sleep] manifest updated: recordingInterruptedBySleep=true, chunk closedAt=\(ok ? "set" : "nil")") - } - } catch { - Log.recording.error("[Sleep] Failed to update manifest: \(error.localizedDescription)") - } - } + private func releaseMeetingSleepActivityTokens() { + if let token = meetingActivityToken { + ProcessInfo.processInfo.endActivity(token) + meetingActivityToken = nil } - - // Release the activity token so the OS can proceed with sleep cleanly. - // Both meeting and meeting-translation may hold tokens; release the active one. - DispatchQueue.main.async { [weak self] in - guard let self else { return } - if let token = self.meetingActivityToken { - ProcessInfo.processInfo.endActivity(token) - self.meetingActivityToken = nil - } - if let token = self.meetingTranslationActivityToken { - ProcessInfo.processInfo.endActivity(token) - self.meetingTranslationActivityToken = nil - } + if let token = meetingTranslationActivityToken { + ProcessInfo.processInfo.endActivity(token) + meetingTranslationActivityToken = nil } - - return ok } - /// Called on the didWake notification background thread. - /// Dispatches UI updates to main. - private func handleWakeAfterRecordingInterrupt() { - guard recordingWasInterruptedBySleep else { return } - recordingWasInterruptedBySleep = false - + private func showWakeAfterRecordingInterrupt() { Log.recording.info("[Sleep] wake after recording interrupt — surfacing notch message") - DispatchQueue.main.async { [weak self] in - guard let self else { return } - NotchManager.shared.showInfo( - message: "Recording stopped. Open Recordings to recover audio.", - duration: 5.0 - ) - // Transition recording states to idle so the UI is consistent. - // The in-progress directory is left intact for OrphanedRecordingDetector (M5a). - if self.appState.meetingRecordingState == .recording { - self.appState.meetingRecordingState = .idle - self.appState.meetingRecordingStartTime = nil - self.handleMeetingStateChange(.idle) - } - if self.appState.meetingTranslationRecordingState == .recording { - self.appState.meetingTranslationRecordingState = .idle - self.appState.meetingTranslationRecordingStartTime = nil - self.handleMeetingTranslationStateChange(.idle) - } + NotchManager.shared.showInfo( + message: "Recording stopped. Open Recordings to recover audio.", + duration: 5.0 + ) + // Transition recording states to idle so the UI is consistent. + // The in-progress directory is left intact for OrphanedRecordingDetector (M5a). + if appState.meetingRecordingState == .recording { + appState.meetingRecordingState = .idle + appState.meetingRecordingStartTime = nil + handleMeetingStateChange(.idle) + } + if appState.meetingTranslationRecordingState == .recording { + appState.meetingTranslationRecordingState = .idle + appState.meetingTranslationRecordingStartTime = nil + handleMeetingTranslationStateChange(.idle) } } @@ -749,9 +764,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } var translationPairLabel: String { - let a = SettingsStorage.shared.translationLanguageA.uppercased() - let b = SettingsStorage.shared.translationLanguageB.uppercased() - return "\(a) <-> \(b)" + let firstLanguage = SettingsStorage.shared.translationLanguageA.uppercased() + let secondLanguage = SettingsStorage.shared.translationLanguageB.uppercased() + return "\(firstLanguage) <-> \(secondLanguage)" } func handleTranslationStateChange(_ state: RecordingState) { diff --git a/Diduny/Core/Services/MeetingChunkStitcher.swift b/Diduny/Core/Services/MeetingChunkStitcher.swift index 4631705..0447af0 100644 --- a/Diduny/Core/Services/MeetingChunkStitcher.swift +++ b/Diduny/Core/Services/MeetingChunkStitcher.swift @@ -17,7 +17,6 @@ import os /// **Performance:** sequential `AVAudioFile` read/write. PoC ballpark for 24 × 5-min chunks: /// median 1480 ms, p95 2127 ms. Caller should run on a background queue. enum MeetingChunkStitcher { - // MARK: - Types struct Result { @@ -70,23 +69,35 @@ enum MeetingChunkStitcher { // MARK: - Single-Chunk Path private static func stitchSingleChunk(_ chunkURL: URL, outputURL: URL) throws -> Result { - // Try to compute duration before deciding whether to copy or signal empty. - var duration: Double = 0 - if let file = try? AVAudioFile(forReading: chunkURL), file.fileFormat.sampleRate > 0 { - duration = Double(file.length) / file.fileFormat.sampleRate + let fileManager = FileManager.default + guard fileManager.fileExists(atPath: chunkURL.path) else { + throw StitchError.allChunksUnreadable + } + + let file: AVAudioFile + do { + file = try AVAudioFile(forReading: chunkURL) + } catch { + Log.audio + .warning( + "[Stitch] single chunk at \(chunkURL.lastPathComponent) unreadable: \(error.localizedDescription)" + ) + throw StitchError.allChunksUnreadable } - let fm = FileManager.default - guard fm.fileExists(atPath: chunkURL.path) else { + guard file.length > 0, file.fileFormat.sampleRate > 0 else { + Log.audio.warning("[Stitch] single chunk at \(chunkURL.lastPathComponent) empty or invalid") throw StitchError.allChunksUnreadable } - try fm.copyItem(at: chunkURL, to: outputURL) + + let duration = Double(file.length) / file.fileFormat.sampleRate + try fileManager.copyItem(at: chunkURL, to: outputURL) return Result( outputURL: outputURL, totalDurationSeconds: duration, - skippedChunks: duration > 0 ? [] : [1], - appendedChunkCount: duration > 0 ? 1 : 0 + skippedChunks: [], + appendedChunkCount: 1 ) } @@ -137,7 +148,10 @@ enum MeetingChunkStitcher { try appendFile(firstFile, into: outputFile, totalFrames: &totalFrames) appendedCount += 1 } catch { - Log.audio.warning("[Stitch] chunk \(firstReadableIndex + 1) read failed mid-append: \(error.localizedDescription)") + Log.audio + .warning( + "[Stitch] chunk \(firstReadableIndex + 1) read failed mid-append: \(error.localizedDescription)" + ) skipped.append(firstReadableIndex + 1) } @@ -195,7 +209,7 @@ enum MeetingChunkStitcher { into destination: AVAudioFile, totalFrames: inout AVAudioFramePosition ) throws { - let bufferFrames: AVAudioFrameCount = 32_768 + let bufferFrames: AVAudioFrameCount = 32768 let processingFormat = source.processingFormat guard let buffer = AVAudioPCMBuffer(pcmFormat: processingFormat, frameCapacity: bufferFrames) else { return diff --git a/Diduny/Core/Services/MeetingRecorderService.swift b/Diduny/Core/Services/MeetingRecorderService.swift index f140991..5fb8e64 100644 --- a/Diduny/Core/Services/MeetingRecorderService.swift +++ b/Diduny/Core/Services/MeetingRecorderService.swift @@ -71,7 +71,8 @@ final class MeetingRecorderService: NSObject, MeetingRecorderServiceProtocol { let directoryURL: URL let firstChunkURL: URL do { - directoryURL = try await InProgressRecordingStore.shared.directoryURL(for: recordingId) + let store = try InProgressRecordingStore.sharedStore() + directoryURL = try await store.directoryURL(for: recordingId) firstChunkURL = directoryURL.appendingPathComponent(Self.chunkFilename(forIndex: 1)) } catch { Log.recording.error("Failed to create in-progress recording directory: \(error)") @@ -180,7 +181,9 @@ final class MeetingRecorderService: NSObject, MeetingRecorderServiceProtocol { lastWriteAt: startTime!, recordingInterruptedBySleep: false ) - try? await InProgressRecordingStore.shared.writeManifest(initialManifest, for: recordingId) + if let store = try? InProgressRecordingStore.sharedStore() { + try? await store.writeManifest(initialManifest, for: recordingId) + } Log.recording.info("Recording started (captureMicrophone=\(service.captureMicrophone))") onRecordingStarted?() @@ -251,7 +254,8 @@ final class MeetingRecorderService: NSObject, MeetingRecorderServiceProtocol { // Update manifest: mark the last (currently-writing) chunk as cleanly closed. // Earlier chunks already have closedAt set by handleChunkRotated; nothing to update there. if let recordingId = capturedRecordingId, - var manifest = try? await InProgressRecordingStore.shared.readManifest(for: recordingId), + let store = try? InProgressRecordingStore.sharedStore(), + var manifest = try? await store.readManifest(for: recordingId), !manifest.chunks.isEmpty, let lastChunkURL = capturedChunkURLs.last { @@ -276,7 +280,7 @@ final class MeetingRecorderService: NSObject, MeetingRecorderServiceProtocol { manifest.chunks[lastIdx].byteCount = lastByteCount manifest.chunks[lastIdx].durationSeconds = lastDuration manifest.lastWriteAt = stopTime - try? await InProgressRecordingStore.shared.writeManifest(manifest, for: recordingId) + try? await store.writeManifest(manifest, for: recordingId) } onRecordingStopped?(stitchedURL) @@ -326,7 +330,9 @@ final class MeetingRecorderService: NSObject, MeetingRecorderServiceProtocol { // Remove the entire in-progress directory (chunks + manifest + any stitched output). if let recordingId = capturedRecordingId { - try? await InProgressRecordingStore.shared.cleanup(recordingId: recordingId) + if let store = try? InProgressRecordingStore.sharedStore() { + try? await store.cleanup(recordingId: recordingId) + } } Log.recording.info("Meeting recording canceled") @@ -358,8 +364,8 @@ final class MeetingRecorderService: NSObject, MeetingRecorderServiceProtocol { guard let recordingId = currentRecordingId else { return } Task { [weak self] in guard self != nil else { return } - let store = InProgressRecordingStore.shared do { + let store = try InProgressRecordingStore.sharedStore() guard var manifest = try await store.readManifest(for: recordingId) else { return } // Update closed entry (1-based → 0-based index) let closedIdx0 = closedIndex - 1 diff --git a/Diduny/Core/Services/PushToTalkService.swift b/Diduny/Core/Services/PushToTalkService.swift index ea675d6..a9cdc07 100644 --- a/Diduny/Core/Services/PushToTalkService.swift +++ b/Diduny/Core/Services/PushToTalkService.swift @@ -25,6 +25,7 @@ final class PushToTalkService: PushToTalkServiceProtocol { get { sanitizedHoldStartDelaySeconds } set { sanitizedHoldStartDelaySeconds = Self.sanitizedHoldStartDelaySeconds(newValue) } } + var onKeyDown: (() -> Void)? var onKeyUp: (() -> Void)? @@ -66,7 +67,8 @@ final class PushToTalkService: PushToTalkServiceProtocol { Log.app.info("Push-to-talk ready for \(self?.selectedKey.displayName ?? "unknown")") } - Log.app.info("Started monitoring for \(self.selectedKey.displayName)") + let selectedKeyLabel = selectedKey.displayName + Log.app.info("Started monitoring for \(selectedKeyLabel)") } func stop() { @@ -91,8 +93,12 @@ final class PushToTalkService: PushToTalkServiceProtocol { /// Reset hands-free mode (call when recording is cancelled externally) func resetHandsFreeMode() { - cancelPendingHoldStart() - hasStartedAfterHold = false + if !hasStartedAfterHold { + cancelPendingHoldStart() + } + if !isKeyPressed { + hasStartedAfterHold = false + } isHandsFreeMode = false lastToggleTapTime = nil consecutiveToggleTapCount = 0 @@ -173,13 +179,15 @@ final class PushToTalkService: PushToTalkServiceProtocol { } guard hasStartedAfterHold else { - Log.app.info("\(self.selectedKey.displayName) released before hold threshold - ignoring") + let selectedKeyLabel = selectedKey.displayName + Log.app.info("\(selectedKeyLabel) released before hold threshold - ignoring") return } // Hold-to-record mode: stop recording on release hasStartedAfterHold = false - Log.app.info("\(self.selectedKey.displayName) released - stopping recording") + let selectedKeyLabel = selectedKey.displayName + Log.app.info("\(selectedKeyLabel) released - stopping recording") onKeyUp?() } } @@ -196,14 +204,14 @@ final class PushToTalkService: PushToTalkServiceProtocol { guard !Task.isCancelled, let self, - self.isKeyPressed, + isKeyPressed, !self.hasStartedAfterHold else { return } - self.hasStartedAfterHold = true - self.pendingHoldStartTask = nil + hasStartedAfterHold = true + pendingHoldStartTask = nil Log.app.info("\(keyLabel) held for \(String(format: "%.1f", delay))s - starting recording") - self.onKeyDown?() + onKeyDown?() } } @@ -251,25 +259,25 @@ final class PushToTalkService: PushToTalkServiceProtocol { private func isKeyCurrentlyPressed(keyCode: UInt16, flags: NSEvent.ModifierFlags) -> Bool { switch selectedKey { case .none: - return false + false case .capsLock: - return keyCode == 57 && flags.contains(.capsLock) + keyCode == 57 && flags.contains(.capsLock) case .leftShift: - return keyCode == 56 && flags.contains(.shift) + keyCode == 56 && flags.contains(.shift) case .leftOption: - return keyCode == 58 && flags.contains(.option) + keyCode == 58 && flags.contains(.option) case .leftCommand: - return keyCode == 55 && flags.contains(.command) + keyCode == 55 && flags.contains(.command) case .leftControl: - return keyCode == 59 && flags.contains(.control) + keyCode == 59 && flags.contains(.control) case .rightShift: - return keyCode == 60 && flags.contains(.shift) + keyCode == 60 && flags.contains(.shift) case .rightOption: - return keyCode == 61 && flags.contains(.option) + keyCode == 61 && flags.contains(.option) case .rightCommand: - return keyCode == 54 && flags.contains(.command) + keyCode == 54 && flags.contains(.command) case .rightControl: - return keyCode == 62 && flags.contains(.control) + keyCode == 62 && flags.contains(.control) } } diff --git a/Diduny/Core/Services/SystemAudioCaptureService.swift b/Diduny/Core/Services/SystemAudioCaptureService.swift index b4c00c1..c942dad 100644 --- a/Diduny/Core/Services/SystemAudioCaptureService.swift +++ b/Diduny/Core/Services/SystemAudioCaptureService.swift @@ -142,7 +142,8 @@ final class SystemAudioCaptureService: NSObject { isRecoveringSystem = false isRecoveringMicrophone = false - Log.audio.info("Starting system audio capture (captureMicrophone=\(self.captureMicrophone))...") + let captureMicrophoneAtStart = captureMicrophone + Log.audio.info("Starting system audio capture (captureMicrophone=\(captureMicrophoneAtStart))...") try setupAudioFile(at: outputURL) @@ -188,7 +189,8 @@ final class SystemAudioCaptureService: NSObject { } } - Log.audio.info("Capture started (16kHz mono, captureMicrophone=\(self.captureMicrophone))") + let captureMicrophoneAfterStart = captureMicrophone + Log.audio.info("Capture started (16kHz mono, captureMicrophone=\(captureMicrophoneAfterStart))") NSLog("[AudioCapture] Capture started — waiting for delegate callbacks...") onCaptureStarted?() } @@ -282,8 +284,10 @@ final class SystemAudioCaptureService: NSObject { throw SystemAudioError.microphoneFormatInvalid } + let hardwareFormatDescription = formatDescription(hardwareInputFormat) + let tapFormatDescription = formatDescription(tapFormat) Log.audio.info( - "Meeting mic format resolved: input=\(self.formatDescription(hardwareInputFormat)), output=\(self.formatDescription(tapFormat)), explicitBinding=\(didBindExplicitDevice)" + "Meeting mic format resolved: input=\(hardwareFormatDescription), output=\(tapFormatDescription), explicitBinding=\(didBindExplicitDevice)" ) NSLog( "[AudioCapture] Mic input format: sampleRate=%.0f, channels=%d", @@ -316,8 +320,10 @@ final class SystemAudioCaptureService: NSObject { } } } catch { + let hardwareFormatDescription = formatDescription(hardwareInputFormat) + let tapFormatDescription = formatDescription(tapFormat) Log.audio.error( - "Meeting mic tap install failed. tapFormat=\(self.formatDescription(tapFormat)), nodeOutputFormat=\(self.formatDescription(hardwareInputFormat)), error=\(error.localizedDescription)" + "Meeting mic tap install failed. tapFormat=\(tapFormatDescription), nodeOutputFormat=\(hardwareFormatDescription), error=\(error.localizedDescription)" ) throw SystemAudioError.microphoneStartFailed( "Could not start meeting microphone with the current route. Try reconnecting AirPods or choosing System Default." @@ -365,8 +371,10 @@ final class SystemAudioCaptureService: NSObject { private func handleMicrophoneConfigurationChange() { let currentInputFormat = micEngine?.inputNode.inputFormat(forBus: 0) let currentOutputFormat = micEngine?.inputNode.outputFormat(forBus: 0) + let currentInputDescription = formatDescription(currentInputFormat) + let currentOutputDescription = formatDescription(currentOutputFormat) Log.audio.info( - "Meeting mic configuration changed - input=\(self.formatDescription(currentInputFormat)), output=\(self.formatDescription(currentOutputFormat))" + "Meeting mic configuration changed - input=\(currentInputDescription), output=\(currentOutputDescription)" ) guard captureMicrophone, @@ -385,6 +393,7 @@ final class SystemAudioCaptureService: NSObject { /// Convert mic buffer to 16kHz mono Float32 and dispatch to mixer queue. private func processMicBuffer(_ buffer: AVAudioPCMBuffer) { + guard isCapturing, !isStoppingCapture else { return } guard let converter = micConverter else { return } let inputFormat = buffer.format @@ -440,12 +449,13 @@ final class SystemAudioCaptureService: NSObject { func synchronousFlushForSleep() -> URL? { guard isCapturing else { return nil } let url = outputURL + isStoppingCapture = true + isCapturing = false // Cancel recovery tasks to prevent them from re-opening the stream. cancelRecoveryTasks() + stopMicrophoneCapture() // Flush remaining buffers and close AVAudioFile synchronously. synchronizedTeardown(flushPendingAudio: true) - // Mark as not capturing so further SCStream callbacks are dropped. - isCapturing = false Log.audio.info("[Sleep] synchronousFlushForSleep: file closed at \(url?.path ?? "nil")") return url } @@ -474,13 +484,14 @@ final class SystemAudioCaptureService: NSObject { if let stream { try await stream.stopCapture() } - self.stream = nil + stream = nil isCapturing = false synchronizedTeardown(flushPendingAudio: true) - Log.audio.info("Capture stopped, file saved to: \(self.outputURL?.path ?? "nil")") - return outputURL + let capturedOutputURL = outputURL + Log.audio.info("Capture stopped, file saved to: \(capturedOutputURL?.path ?? "nil")") + return capturedOutputURL } private func cleanupFailedStart(removeOutputFile: Bool) async throws { @@ -546,35 +557,35 @@ final class SystemAudioCaptureService: NSObject { self.systemRecoveryTask = nil } - for attempt in 1 ... self.maxSystemRecoveryAttempts { + for attempt in 1 ... maxSystemRecoveryAttempts { if attempt == 1 { - try? await Task.sleep(for: .seconds(self.initialRecoveryDelay)) + try? await Task.sleep(for: .seconds(initialRecoveryDelay)) } else { - try? await Task.sleep(for: .seconds(self.recoveryDelay(for: attempt))) + try? await Task.sleep(for: .seconds(recoveryDelay(for: attempt))) } - guard !Task.isCancelled, self.isCapturing, !self.isStoppingCapture else { return } + guard !Task.isCancelled, isCapturing, !isStoppingCapture else { return } do { - try await self.createAndStartSystemStream() + try await createAndStartSystemStream() Log.audio.info("System audio recovered on attempt \(attempt)") - if self.captureMicrophone { + if captureMicrophone { do { - try self.startMicrophoneCapture() - self.microphoneCaptureStarted = true + try startMicrophoneCapture() + microphoneCaptureStarted = true } catch { Log.audio.warning( "Microphone restart after system recovery failed: \(error.localizedDescription)" ) - self.scheduleMicrophoneRecovery( + scheduleMicrophoneRecovery( reason: "system audio recovered", allowDuringSystemRecovery: true ) } } - self.emitStatusMessage("System audio reconnected") + emitStatusMessage("System audio reconnected") return } catch { Log.audio.warning( @@ -586,7 +597,7 @@ final class SystemAudioCaptureService: NSObject { let recoveryError = SystemAudioError.streamRecoveryFailed( "System audio capture was interrupted and could not reconnect." ) - self.failCapture(recoveryError) + failCapture(recoveryError) } } @@ -597,7 +608,7 @@ final class SystemAudioCaptureService: NSObject { guard captureMicrophone, isCapturing, !isStoppingCapture, - (!isRecoveringSystem || allowDuringSystemRecovery), + !isRecoveringSystem || allowDuringSystemRecovery, !isRecoveringMicrophone else { return } @@ -615,32 +626,32 @@ final class SystemAudioCaptureService: NSObject { var triedDefaultFallback = false - for attempt in 1 ... self.maxMicrophoneRecoveryAttempts { - try? await Task.sleep(for: .seconds(self.recoveryDelay(for: attempt))) - guard !Task.isCancelled, self.isCapturing, !self.isStoppingCapture else { return } + for attempt in 1 ... maxMicrophoneRecoveryAttempts { + try? await Task.sleep(for: .seconds(recoveryDelay(for: attempt))) + guard !Task.isCancelled, isCapturing, !isStoppingCapture else { return } do { - try self.startMicrophoneCapture() - self.microphoneCaptureStarted = true + try startMicrophoneCapture() + microphoneCaptureStarted = true Log.audio.info("Microphone recovered after \(reason) on attempt \(attempt)") - self.emitStatusMessage("Microphone reconnected") + emitStatusMessage("Microphone reconnected") return } catch { Log.audio.warning( "Microphone recovery attempt \(attempt) after \(reason) failed: \(error.localizedDescription)" ) - if !triedDefaultFallback, self.microphoneDevice != nil { - let failedDeviceName = self.microphoneDevice?.name ?? "selected microphone" - self.microphoneDevice = nil + if !triedDefaultFallback, microphoneDevice != nil { + let failedDeviceName = microphoneDevice?.name ?? "selected microphone" + microphoneDevice = nil triedDefaultFallback = true - self.emitStatusMessage("\(failedDeviceName) unavailable. Using System Default…") + emitStatusMessage("\(failedDeviceName) unavailable. Using System Default…") } } } - self.captureMicrophone = false - self.emitStatusMessage("Microphone unavailable. Continuing with system audio") + captureMicrophone = false + emitStatusMessage("Microphone unavailable. Continuing with system audio") } } @@ -732,7 +743,7 @@ final class SystemAudioCaptureService: NSObject { extension SystemAudioCaptureService: SCStreamDelegate { func stream(_ stream: SCStream, didStopWithError error: Error) { - guard let activeStream = self.stream, activeStream === stream else { return } + guard let activeStream = self.stream, activeStream === stream, isCapturing, !isStoppingCapture else { return } Log.audio.error("Stream stopped with error: \(error)") scheduleSystemRecovery(after: error) } @@ -742,7 +753,7 @@ extension SystemAudioCaptureService: SCStreamDelegate { extension SystemAudioCaptureService: SCStreamOutput { func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) { - guard let activeStream = self.stream, activeStream === stream, isCapturing else { return } + guard let activeStream = self.stream, activeStream === stream, isCapturing, !isStoppingCapture else { return } delegateCallCount += 1 if delegateCallCount <= 5 { NSLog("[AudioCapture] delegate called #%d, type=%d (.screen=0, .audio=1)", delegateCallCount, type.rawValue) @@ -910,8 +921,9 @@ extension SystemAudioCaptureService: SCStreamOutput { currentChunkFrameCount += samples.count let now = Date() - if now.timeIntervalSince(lastFlushTime) >= self.flushInterval { - Log.audio.info("Audio buffer auto-flush (every \(self.flushInterval)s)") + let currentFlushInterval = flushInterval + if now.timeIntervalSince(lastFlushTime) >= currentFlushInterval { + Log.audio.info("Audio buffer auto-flush (every \(currentFlushInterval)s)") lastFlushTime = now } } catch { @@ -968,7 +980,10 @@ extension SystemAudioCaptureService: SCStreamOutput { onChunkRotated?(closedIndex, closedURL, closedAt, byteCount, durationSeconds) } catch { - Log.audio.error("[ChunkRotate] FAILED to open chunk \(newIndex) at \(newURL.path): \(error.localizedDescription)") + Log.audio + .error( + "[ChunkRotate] FAILED to open chunk \(newIndex) at \(newURL.path): \(error.localizedDescription)" + ) // Recording is now broken — subsequent writeSamples will drop because audioFile is nil. // Surface to caller so it can transition state and persist whatever chunks already closed. onError?(SystemAudioError.chunkRotationFailed(error.localizedDescription)) @@ -1009,10 +1024,11 @@ extension SystemAudioCaptureService: SCStreamOutput { } guard frameCount > 0 else { return nil } - if self.sampleCount <= 3 { + let currentSampleCount = sampleCount + if currentSampleCount <= 3 { Log.audio .info( - "Audio sample \(self.sampleCount): frames=\(frameCount), sampleRate=\(asbd.pointee.mSampleRate), ch=\(channelCount), bits=\(bitsPerChannel), float=\(isFloat)" + "Audio sample \(currentSampleCount): frames=\(frameCount), sampleRate=\(asbd.pointee.mSampleRate), ch=\(channelCount), bits=\(bitsPerChannel), float=\(isFloat)" ) } @@ -1061,8 +1077,8 @@ extension SystemAudioCaptureService: SCStreamOutput { private func rms(_ samples: [Float]) -> Float { guard !samples.isEmpty else { return 0 } var sum: Float = 0 - for s in samples { - sum += s * s + for sample in samples { + sum += sample * sample } return sqrt(sum / Float(samples.count)) } diff --git a/Diduny/Core/Storage/InProgressRecordingStore.swift b/Diduny/Core/Storage/InProgressRecordingStore.swift index b279aae..9701f83 100644 --- a/Diduny/Core/Storage/InProgressRecordingStore.swift +++ b/Diduny/Core/Storage/InProgressRecordingStore.swift @@ -10,21 +10,31 @@ import os /// **M1 scope:** single chunk per recording (`chunk_001.wav`). /// Chunk rotation is M3; orphan detection is M5a; sleep handling is M2. actor InProgressRecordingStore { - static let shared = InProgressRecordingStore() + private static let sharedResult = Result { try InProgressRecordingStore() } + + static func sharedStore() throws -> InProgressRecordingStore { + try sharedResult.get() + } private let baseDirectory: URL private let fileManager: FileManager // MARK: - Init - init(baseDirectory: URL? = nil, fileManager: FileManager = .default) { + init(baseDirectory: URL? = nil, fileManager: FileManager = .default) throws { self.fileManager = fileManager - let bundleID = Bundle.main.bundleIdentifier ?? "Diduny" - let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! - self.baseDirectory = baseDirectory ?? appSupport - .appendingPathComponent(bundleID) - .appendingPathComponent("InProgressRecordings") - try? fileManager.createDirectory(at: self.baseDirectory, withIntermediateDirectories: true) + if let baseDirectory { + self.baseDirectory = baseDirectory + } else { + let bundleID = Bundle.main.bundleIdentifier ?? "Diduny" + guard let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + throw InProgressRecordingStoreError.applicationSupportDirectoryUnavailable + } + self.baseDirectory = appSupport + .appendingPathComponent(bundleID) + .appendingPathComponent("InProgressRecordings") + } + try fileManager.createDirectory(at: self.baseDirectory, withIntermediateDirectories: true) } // MARK: - Directory @@ -111,3 +121,14 @@ actor InProgressRecordingStore { } } } + +enum InProgressRecordingStoreError: LocalizedError { + case applicationSupportDirectoryUnavailable + + var errorDescription: String? { + switch self { + case .applicationSupportDirectoryUnavailable: + "Application Support directory is unavailable." + } + } +} diff --git a/DidunyTests/InProgressRecordingStoreTests.swift b/DidunyTests/InProgressRecordingStoreTests.swift index 24e2466..0a79b21 100644 --- a/DidunyTests/InProgressRecordingStoreTests.swift +++ b/DidunyTests/InProgressRecordingStoreTests.swift @@ -1,19 +1,18 @@ -import XCTest @testable import Diduny +import XCTest /// Tests for `InProgressRecordingStore` (RLR-M1). /// /// Each test uses an isolated temp directory as `baseDirectory:` so the user's /// Application Support is never polluted. final class InProgressRecordingStoreTests: XCTestCase { - // MARK: - Helpers /// Creates an isolated store backed by a fresh temp directory. - private func makeStore() -> (InProgressRecordingStore, URL) { + private func makeStore() throws -> (InProgressRecordingStore, URL) { let dir = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString) - let store = InProgressRecordingStore(baseDirectory: dir) + let store = try InProgressRecordingStore(baseDirectory: dir) return (store, dir) } @@ -46,7 +45,7 @@ final class InProgressRecordingStoreTests: XCTestCase { // MARK: - 1. beginRecording_createsDirectory func test_chunkURL_createsDirectory() async throws { - let (store, _) = makeStore() + let (store, _) = try makeStore() let id = UUID() let chunkURL = try await store.chunkURL(for: id) @@ -62,7 +61,7 @@ final class InProgressRecordingStoreTests: XCTestCase { // MARK: - 2. writeManifest_roundTrip func test_writeManifest_roundTrip() async throws { - let (store, _) = makeStore() + let (store, _) = try makeStore() let id = UUID() var manifest = sampleManifest(id: id) manifest.chunks[0].byteCount = 28_800_000 @@ -93,7 +92,7 @@ final class InProgressRecordingStoreTests: XCTestCase { // MARK: - 3. writeManifest_atomic_temp_cleaned func test_writeManifest_noTempFileRemains() async throws { - let (store, _) = makeStore() + let (store, _) = try makeStore() let id = UUID() let manifest = sampleManifest(id: id) @@ -108,7 +107,7 @@ final class InProgressRecordingStoreTests: XCTestCase { // MARK: - 4. readManifest_missing_returnsNil func test_readManifest_missingID_returnsNil() async throws { - let (store, _) = makeStore() + let (store, _) = try makeStore() let unknownID = UUID() // Should not throw, should return nil @@ -119,7 +118,7 @@ final class InProgressRecordingStoreTests: XCTestCase { // MARK: - 5. cleanup_removesDirectory func test_cleanup_removesDirectory() async throws { - let (store, _) = makeStore() + let (store, _) = try makeStore() let id = UUID() // Write something so the directory exists @@ -138,7 +137,7 @@ final class InProgressRecordingStoreTests: XCTestCase { // MARK: - 6. allInProgressRecordingIDs_listsExistingDirs func test_allInProgressRecordingIDs_filtersNonUUIDs() async throws { - let (store, baseDir) = makeStore() + let (store, baseDir) = try makeStore() let id1 = UUID() let id2 = UUID() @@ -157,4 +156,14 @@ final class InProgressRecordingStoreTests: XCTestCase { XCTAssertTrue(ids.contains(id1), "Should contain id1") XCTAssertTrue(ids.contains(id2), "Should contain id2") } + + func test_init_throwsWhenBaseDirectoryCannotBeCreated() throws { + let blockedFile = FileManager.default.temporaryDirectory + .appendingPathComponent("InProgressRecordingStoreTests-\(UUID().uuidString)") + try Data("not a directory".utf8).write(to: blockedFile) + defer { try? FileManager.default.removeItem(at: blockedFile) } + + let impossibleDirectory = blockedFile.appendingPathComponent("child") + XCTAssertThrowsError(try InProgressRecordingStore(baseDirectory: impossibleDirectory)) + } } diff --git a/DidunyTests/MeetingChunkStitcherTests.swift b/DidunyTests/MeetingChunkStitcherTests.swift index 7396247..da60285 100644 --- a/DidunyTests/MeetingChunkStitcherTests.swift +++ b/DidunyTests/MeetingChunkStitcherTests.swift @@ -1,6 +1,6 @@ import AVFoundation -import XCTest @testable import Diduny +import XCTest /// Tests for `MeetingChunkStitcher` (RLR-M4). /// @@ -8,7 +8,6 @@ import XCTest /// then verify duration and frame counts in the output. Each test writes into an isolated /// temp directory so the user's filesystem stays clean. final class MeetingChunkStitcherTests: XCTestCase { - private var tmpDir: URL! override func setUp() { @@ -90,15 +89,31 @@ final class MeetingChunkStitcherTests: XCTestCase { XCTAssertTrue(FileManager.default.fileExists(atPath: chunk.path)) } + func test_stitch_singleEmptyChunk_throwsAllUnreadable() throws { + let bad = try writeEmptyFile(name: "chunk_001.wav") + let target = tmpDir.appendingPathComponent("out.wav") + + XCTAssertThrowsError(try MeetingChunkStitcher.stitch(chunkURLs: [bad], outputURL: target)) { error in + guard case MeetingChunkStitcher.StitchError.allChunksUnreadable = error else { + XCTFail("Expected .allChunksUnreadable, got \(error)") + return + } + } + XCTAssertFalse(FileManager.default.fileExists(atPath: target.path)) + } + // MARK: - 3. multi-chunk stitch sums duration func test_stitch_threeChunks_durationIsSum() throws { - let c1 = try writeChunk(name: "chunk_001.wav", durationSeconds: 1.0) - let c2 = try writeChunk(name: "chunk_002.wav", durationSeconds: 0.5) - let c3 = try writeChunk(name: "chunk_003.wav", durationSeconds: 0.7) + let firstChunk = try writeChunk(name: "chunk_001.wav", durationSeconds: 1.0) + let secondChunk = try writeChunk(name: "chunk_002.wav", durationSeconds: 0.5) + let thirdChunk = try writeChunk(name: "chunk_003.wav", durationSeconds: 0.7) let target = tmpDir.appendingPathComponent("out.wav") - let result = try MeetingChunkStitcher.stitch(chunkURLs: [c1, c2, c3], outputURL: target) + let result = try MeetingChunkStitcher.stitch( + chunkURLs: [firstChunk, secondChunk, thirdChunk], + outputURL: target + ) XCTAssertEqual(result.appendedChunkCount, 3) XCTAssertTrue(result.skippedChunks.isEmpty) @@ -113,12 +128,12 @@ final class MeetingChunkStitcherTests: XCTestCase { // MARK: - 4. skip empty chunk in the middle func test_stitch_skipsEmptyChunk() throws { - let c1 = try writeChunk(name: "chunk_001.wav", durationSeconds: 0.5) + let firstChunk = try writeChunk(name: "chunk_001.wav", durationSeconds: 0.5) let cBad = try writeEmptyFile(name: "chunk_002.wav") - let c3 = try writeChunk(name: "chunk_003.wav", durationSeconds: 0.5) + let thirdChunk = try writeChunk(name: "chunk_003.wav", durationSeconds: 0.5) let target = tmpDir.appendingPathComponent("out.wav") - let result = try MeetingChunkStitcher.stitch(chunkURLs: [c1, cBad, c3], outputURL: target) + let result = try MeetingChunkStitcher.stitch(chunkURLs: [firstChunk, cBad, thirdChunk], outputURL: target) XCTAssertEqual(result.appendedChunkCount, 2) XCTAssertEqual(result.skippedChunks, [2]) @@ -144,10 +159,10 @@ final class MeetingChunkStitcherTests: XCTestCase { func test_stitch_leadingEmptyChunk_skippedFirstReadableDefinesFormat() throws { let bad = try writeEmptyFile(name: "chunk_001.wav") - let c2 = try writeChunk(name: "chunk_002.wav", durationSeconds: 0.5) + let secondChunk = try writeChunk(name: "chunk_002.wav", durationSeconds: 0.5) let target = tmpDir.appendingPathComponent("out.wav") - let result = try MeetingChunkStitcher.stitch(chunkURLs: [bad, c2], outputURL: target) + let result = try MeetingChunkStitcher.stitch(chunkURLs: [bad, secondChunk], outputURL: target) XCTAssertEqual(result.appendedChunkCount, 1) XCTAssertEqual(result.skippedChunks, [1]) diff --git a/DidunyTests/PushToTalkServiceTests.swift b/DidunyTests/PushToTalkServiceTests.swift index 1949027..cd4d73d 100644 --- a/DidunyTests/PushToTalkServiceTests.swift +++ b/DidunyTests/PushToTalkServiceTests.swift @@ -1,6 +1,5 @@ -import Testing - @testable import Diduny +import Testing @Suite("PushToTalkService Hold Delay") @MainActor @@ -55,6 +54,27 @@ struct PushToTalkServiceTests { #expect(stops == 1) } + @Test("Reset while hold is active preserves release stop") + func resetHandsFreeModeDuringActiveHoldPreservesReleaseStop() async { + let sut = PushToTalkService() + sut.selectedKey = .rightShift + sut.holdStartDelaySeconds = 0.2 + + var starts = 0 + var stops = 0 + sut.onKeyDown = { starts += 1 } + sut.onKeyUp = { stops += 1 } + + sut.processModifierKeyEvent(isPressed: true, eventTime: 0) + try? await Task.sleep(for: .milliseconds(250)) + + sut.resetHandsFreeMode() + sut.processModifierKeyEvent(isPressed: false, eventTime: 0.3) + + #expect(starts == 1) + #expect(stops == 1) + } + @Test("Hands-free toggle ignores hold delay") func handsFreeToggleIgnoresHoldDelay() async { SettingsStorage.shared.handsFreeModeEnabled = true diff --git a/DidunyTests/RecordingModelMigrationTests.swift b/DidunyTests/RecordingModelMigrationTests.swift index 41718a1..3500994 100644 --- a/DidunyTests/RecordingModelMigrationTests.swift +++ b/DidunyTests/RecordingModelMigrationTests.swift @@ -1,5 +1,5 @@ -import XCTest @testable import Diduny +import XCTest /// Tests for the RLR-M0 data-model additions: /// - `RecoverySource` enum + `Recording.recoverySource` property @@ -11,23 +11,22 @@ import XCTest /// 2. Round-trip fidelity for the new fields. /// 3. Exhaustive switch coverage for `ProcessingStatus` (compiler-enforced). final class RecordingModelMigrationTests: XCTestCase { - // MARK: - Helpers private let iso8601: JSONDecoder = { - let d = JSONDecoder() - d.dateDecodingStrategy = .iso8601 - return d + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder }() private let iso8601Encoder: JSONEncoder = { - let e = JSONEncoder() - e.dateEncodingStrategy = .iso8601 - return e + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return encoder }() - // A minimal JSON object that represents a Recording saved before RLR-M0. - // It intentionally omits `recoverySource` and uses only pre-M0 status values. + /// A minimal JSON object that represents a Recording saved before RLR-M0. + /// It intentionally omits `recoverySource` and uses only pre-M0 status values. private let legacyJSON = """ [ { @@ -61,22 +60,22 @@ final class RecordingModelMigrationTests: XCTestCase { func test_legacyJSON_originalFieldsIntact() throws { let data = try XCTUnwrap(legacyJSON.data(using: .utf8)) - let r = try iso8601.decode([Recording].self, from: data)[0] - - XCTAssertEqual(r.id.uuidString, "12345678-1234-1234-1234-123456789ABC") - XCTAssertEqual(r.type, .meeting) - XCTAssertEqual(r.audioFileName, "12345678-1234-1234-1234-123456789ABC.wav") - XCTAssertEqual(r.durationSeconds, 3600.0, accuracy: 0.001) - XCTAssertEqual(r.fileSizeBytes, 675_000_000) - XCTAssertEqual(r.status, .transcribed) - XCTAssertEqual(r.transcriptionText, "Hello world.") + let recording = try iso8601.decode([Recording].self, from: data)[0] + + XCTAssertEqual(recording.id.uuidString, "12345678-1234-1234-1234-123456789ABC") + XCTAssertEqual(recording.type, .meeting) + XCTAssertEqual(recording.audioFileName, "12345678-1234-1234-1234-123456789ABC.wav") + XCTAssertEqual(recording.durationSeconds, 3600.0, accuracy: 0.001) + XCTAssertEqual(recording.fileSizeBytes, 675_000_000) + XCTAssertEqual(recording.status, .transcribed) + XCTAssertEqual(recording.transcriptionText, "Hello world.") } // MARK: - 2. Round-trip func test_roundTrip_orphanedSession_partiallyRecovered() throws { - let original = Recording( - id: UUID(uuidString: "AABBCCDD-AABB-CCDD-AABB-CCDDAABBCCDD")!, + let original = try Recording( + id: XCTUnwrap(UUID(uuidString: "AABBCCDD-AABB-CCDD-AABB-CCDDAABBCCDD")), createdAt: Date(timeIntervalSince1970: 1_700_000_000), type: .meeting, audioFileName: "AABBCCDD-AABB-CCDD-AABB-CCDDAABBCCDD.flac", @@ -137,7 +136,7 @@ final class RecordingModelMigrationTests: XCTestCase { .transcribed, .translated, .failed, - .partiallyRecovered, + .partiallyRecovered ] for status in allCases { From 2f3a49617b5862b4280ba9ae183474578b38cfa5 Mon Sep 17 00:00:00 2001 From: Roman Marinsky Date: Thu, 4 Jun 2026 15:18:28 +0300 Subject: [PATCH 5/8] fix(meeting): fall back to async jobs when realtime transcript is partial stopMeetingRecording previously used any non-empty realtime transcript. For longer meetings that stop before Soniox emits an explicit finished frame, this could surface a truncated transcript instead of the complete async result. Add shouldAcceptRealtimeTranscript: accept the realtime text when it was explicitly finalized, or for short (<30s) recordings; otherwise require a substantial transcript (>=120 chars) before trusting an unfinalized one, falling back to the async jobs pipeline when in doubt. Log when a partial transcript is discarded. Co-Authored-By: Claude Opus 4.8 (1M context) --- Diduny/App/AppDelegate+MeetingRecording.swift | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/Diduny/App/AppDelegate+MeetingRecording.swift b/Diduny/App/AppDelegate+MeetingRecording.swift index 3f74645..b9fe98f 100644 --- a/Diduny/App/AppDelegate+MeetingRecording.swift +++ b/Diduny/App/AppDelegate+MeetingRecording.swift @@ -346,8 +346,9 @@ extension AppDelegate { // Finalize and disconnect real-time transcription (if active) let hasRealtimeSession = await MainActor.run { appState.liveTranscriptStore != nil } + var didReceiveRealtimeFinalization = true if hasRealtimeSession { - _ = await realtimeTranscriptionService.finalize() + didReceiveRealtimeFinalization = await realtimeTranscriptionService.finalize() await realtimeTranscriptionService.disconnect() meetingRecorderService.onRealtimeAudioData = nil } @@ -370,6 +371,7 @@ extension AppDelegate { var capturedAudioURL: URL? var originalWavURL: URL? let stopTime = Date() + let duration = recordingStartTime.map { stopTime.timeIntervalSince($0) } ?? 0 let recordingId = UUID() // Capture in-progress recording ID before stopRecording() clears it (RLR-M1). let inProgressRecordingId = meetingRecorderService.currentRecordingId @@ -411,12 +413,23 @@ extension AppDelegate { let realtimeText = await MainActor.run { store?.finalTranscriptText ?? "" } let cloudModeEnabled = SettingsStorage.shared.effectiveMeetingRealtimeTranscriptionEnabled + let shouldUseRealtimeText = shouldAcceptRealtimeTranscript( + realtimeText, + duration: duration, + didReceiveFinalization: didReceiveRealtimeFinalization + ) let rawText: String? - if !realtimeText.isEmpty { + if shouldUseRealtimeText { rawText = realtimeText Log.app.info("Using real-time transcript (\(realtimeText.count) chars)") } else if cloudModeEnabled { + if !realtimeText.isEmpty { + Log.app + .warning( + "Ignoring partial real-time transcript (\(realtimeText.count) chars, finalized=\(didReceiveRealtimeFinalization)); falling back to async jobs API" + ) + } Log.app.info("No real-time transcript, falling back to async jobs API...") let audioData = try await loadAudioData(from: compressedURL) Log.app.info("Meeting recording size = \(audioData.count) bytes") @@ -521,7 +534,6 @@ extension AppDelegate { } Log.app.info("stopMeetingRecording: SUCCESS") - let duration = recordingStartTime.map { stopTime.timeIntervalSince($0) } ?? 0 RecordingsLibraryStorage.shared.saveRecording( id: recordingId, audioURL: compressedURL, @@ -596,6 +608,21 @@ extension AppDelegate { Log.app.info("stopMeetingRecording: END") } + private func shouldAcceptRealtimeTranscript( + _ text: String, + duration: TimeInterval, + didReceiveFinalization: Bool + ) -> Bool { + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false } + if didReceiveFinalization { return true } + + // Short recordings often stop before Soniox emits an explicit finished frame. + // For longer meetings, a tiny unfinalized transcript is usually partial and + // should fall back to the async jobs pipeline for a complete result. + guard duration >= 30 else { return true } + return text.count >= 120 + } + // MARK: - Escape Cancel Handler private func setupMeetingEscapeCancelHandler() { From d2bd3cce39711f5e7d355490bcf923317e5cb50d Mon Sep 17 00:00:00 2001 From: Roman Marinsky Date: Thu, 4 Jun 2026 16:11:42 +0300 Subject: [PATCH 6/8] fix(realtime): handle proxy 402 on WS upgrade; trim partial-transcript check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proxy now refuses realtime WS upgrades with HTTP 402 once a user is over the usage quota. Previously a 402 (when the local usage pre-check was nil or stale) surfaced as a failed receive → generic reconnect storm (2s/4s/8s) and a "Connection lost" message instead of the real reason. - Detect a 402 upgrade response in handleDisconnect (synchronously, to stop the reconnect) and on the initial config send, mapping it to a typed usageLimitExceeded error + a usage refresh. - shouldAcceptRealtimeTranscript: measure trimmed length for the 120-char substantiality threshold so a whitespace-padded transcript can't masquerade as substantial. Co-Authored-By: Claude Opus 4.8 (1M context) --- Diduny/App/AppDelegate+MeetingRecording.swift | 9 ++-- .../Core/Services/CloudRealtimeService.swift | 47 ++++++++++++++++++- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/Diduny/App/AppDelegate+MeetingRecording.swift b/Diduny/App/AppDelegate+MeetingRecording.swift index b9fe98f..3ae3486 100644 --- a/Diduny/App/AppDelegate+MeetingRecording.swift +++ b/Diduny/App/AppDelegate+MeetingRecording.swift @@ -613,14 +613,17 @@ extension AppDelegate { duration: TimeInterval, didReceiveFinalization: Bool ) -> Bool { - guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } if didReceiveFinalization { return true } // Short recordings often stop before Soniox emits an explicit finished frame. // For longer meetings, a tiny unfinalized transcript is usually partial and - // should fall back to the async jobs pipeline for a complete result. + // should fall back to the async jobs pipeline for a complete result. Measure + // visible content (trimmed) so a whitespace-padded transcript can't masquerade + // as substantial. guard duration >= 30 else { return true } - return text.count >= 120 + return trimmed.count >= 120 } // MARK: - Escape Cancel Handler diff --git a/Diduny/Core/Services/CloudRealtimeService.swift b/Diduny/Core/Services/CloudRealtimeService.swift index c4d6846..e0d7028 100644 --- a/Diduny/Core/Services/CloudRealtimeService.swift +++ b/Diduny/Core/Services/CloudRealtimeService.swift @@ -150,7 +150,17 @@ final class CloudRealtimeService: NSObject, @unchecked Sendable { let configString = String(data: configData, encoding: .utf8) ?? "{}" NSLog("[Cloud RT] Sending config: %@", configString) - try await task.send(.string(configString)) + do { + try await task.send(.string(configString)) + } catch { + // A refused upgrade (e.g. HTTP 402 usage limit) surfaces as the first + // send/receive throwing. Map 402 to a typed usage error so the caller + // shows "limit reached" instead of a generic connection failure. + if let usageError = await usageLimitUpgradeError() { + throw usageError + } + throw error + } NSLog("[Cloud RT] Config sent successfully, WebSocket connected") isConnected = true @@ -417,6 +427,21 @@ final class CloudRealtimeService: NSObject, @unchecked Sendable { // MARK: - Reconnect + /// If the last WS upgrade was refused with HTTP 402, map it to a typed usage + /// error (using the best usage numbers we have) and kick off a refresh so the + /// UI shows accurate figures shortly. Returns nil for any other status. + private func usageLimitUpgradeError() async -> RealtimeTranscriptionError? { + guard (webSocketTask?.response as? HTTPURLResponse)?.statusCode == 402 else { + return nil + } + let usage = await UsageService.shared.cachedUsage + await UsageService.shared.refresh() + return .usageLimitExceeded( + usedHours: usage?.usedHours ?? 0, + limitHours: usage?.limitHours ?? 5 + ) + } + /// Called when the receive loop exits due to an error or a server-initiated close. /// /// ADR-0004 edge cases handled here: @@ -428,6 +453,26 @@ final class CloudRealtimeService: NSObject, @unchecked Sendable { guard isConnected else { return } isConnected = false + // A refused WS upgrade (HTTP 402 usage limit) lands here via the receive + // loop with no close code. Reconnecting is futile — the server will keep + // refusing — and would surface a generic "Connection lost" instead of the + // real reason. Detect it synchronously to stop the reconnect, then surface + // the typed usage error with the best numbers we have. + if (webSocketTask?.response as? HTTPURLResponse)?.statusCode == 402 { + Log.transcription.warning("Cloud RT: WS upgrade returned 402 — usage limit, not reconnecting") + Task { [weak self] in + guard let self else { return } + let usage = await UsageService.shared.cachedUsage + await UsageService.shared.refresh() + self.onError?(RealtimeTranscriptionError.usageLimitExceeded( + usedHours: usage?.usedHours ?? 0, + limitHours: usage?.limitHours ?? 5 + )) + self.onConnectionStatusChanged?(.failed("Cloud usage limit reached")) + } + return + } + // 1001 Going Away — proxy-initiated graceful close (8h cap or rolling restart). // Per ADR-0004: save partial transcript, show non-error UI, do NOT reconnect. if closeCode?.rawValue == 1001 { From 3b012d83c0d0ab69ab7894b6a9751502ebb7bf32 Mon Sep 17 00:00:00 2001 From: Roman Marinsky Date: Thu, 4 Jun 2026 16:49:41 +0300 Subject: [PATCH 7/8] fix(review): address CodeRabbit findings on PR #39 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AppDelegate (sleep flush): persist the in-progress manifest synchronously before flushActiveRecordingForSleep() returns. The write was deferred to a Task{} and could be lost if the app suspended first, leaving recovery with stale state. Block on a detached task (no MainActor deadlock) with a 2s guard. - PushToTalkService: distinguish left/right modifiers via device-dependent masks (NX_DEVICE*KEYMASK) instead of family flags (.shift/.option/...), so a side-specific key detects its own key-up while the opposite side is held — previously hold-to-record could stick. - SleepFlushCoordinatorTests: import AppKit (uses NSWorkspace) so the test target compiles under CI. - project.yml: reset MARKETING_VERSION/CURRENT_PROJECT_VERSION to neutral placeholders — CI derives the real version from the git tag (per CLAUDE.md); hand-editing risked tag/artifact mismatch. - Recording.recoverySource: correct the doc comment to reality (no save path sets it yet) + TODO, instead of claiming an unimplemented contract. Build + full DidunyTests suite pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- Diduny/App/AppDelegate.swift | 14 +++++++- Diduny/Core/Models/Recording.swift | 12 +++++-- Diduny/Core/Services/PushToTalkService.swift | 37 ++++++++++++++------ DidunyTests/SleepFlushCoordinatorTests.swift | 1 + project.yml | 6 ++-- 5 files changed, 54 insertions(+), 16 deletions(-) diff --git a/Diduny/App/AppDelegate.swift b/Diduny/App/AppDelegate.swift index 54d0395..41b9a4f 100644 --- a/Diduny/App/AppDelegate.swift +++ b/Diduny/App/AppDelegate.swift @@ -50,7 +50,15 @@ private final class SleepRecordingFlushBridge { let recordingId = meetingRecorderService.currentRecordingId if let recordingId { - Task { + // Persist the manifest synchronously before returning: the app can be + // suspended the instant this sleep-flush returns, so a deferred async + // write could be lost and leave recovery reading stale state after + // wake/crash. Block on a detached task (detached → not MainActor-bound, + // so waiting on the main thread can't deadlock the actor) with a short + // timeout so a wedged store can't hang the sleep transition. + let sem = DispatchSemaphore(value: 0) + Task.detached(priority: .userInitiated) { + defer { sem.signal() } do { let store = try InProgressRecordingStore.sharedStore() if var manifest = try await store.readManifest(for: recordingId) { @@ -76,6 +84,10 @@ private final class SleepRecordingFlushBridge { Log.recording.error("[Sleep] Failed to update manifest: \(error.localizedDescription)") } } + if sem.wait(timeout: .now() + 2) == .timedOut { + Log.recording + .error("[Sleep] manifest update timed out (2s) — proceeding without confirmed persist") + } } releaseActivityTokens?() diff --git a/Diduny/Core/Models/Recording.swift b/Diduny/Core/Models/Recording.swift index 1f553b5..59917c2 100644 --- a/Diduny/Core/Models/Recording.swift +++ b/Diduny/Core/Models/Recording.swift @@ -31,9 +31,15 @@ struct Recording: Identifiable, Codable, Equatable { var processedAt: Date? var chapters: [MeetingChapter]? let sourceDevice: RecordingDeviceInfo? - /// Non-nil when this recording was saved via a recovery path rather than a normal stop. - /// Drives the "Recovered" badge in the library and the detail-view notice. - /// Set once at recovery-save time; never cleared. + /// Marks a recording that originated from a recovery path rather than a normal + /// stop; intended to drive the "Recovered" badge in the library and the + /// detail-view notice. Once set it is preserved (never cleared), including + /// across `RecordingsLibraryStorage.replaceStoredAudioFile`. + /// + /// NOTE: no production save path sets this yet — `saveRecording(...)` doesn't + /// accept it and `recoverRecording(from:)` transcribes then discards without + /// creating a library entry. So in practice this is currently always nil. + /// TODO: populate it when the recovery-save-to-library flow is implemented. var recoverySource: RecoverySource? /// Nested to avoid conflict with RecoveryState.RecordingType diff --git a/Diduny/Core/Services/PushToTalkService.swift b/Diduny/Core/Services/PushToTalkService.swift index a9cdc07..26e0365 100644 --- a/Diduny/Core/Services/PushToTalkService.swift +++ b/Diduny/Core/Services/PushToTalkService.swift @@ -256,28 +256,45 @@ final class PushToTalkService: PushToTalkServiceProtocol { return (clamped * 10).rounded() / 10 } + // Device-dependent modifier masks (NX_DEVICE*KEYMASK). NSEvent.ModifierFlags + // family bits (.shift/.option/.command/.control) don't tell left from right, + // so a side-specific key can't detect its own key-up while the opposite-side + // key is still held. These raw masks distinguish the physical side. + private enum DeviceModifierMask { + static let leftControl: UInt = 0x0000_0001 + static let leftShift: UInt = 0x0000_0002 + static let rightShift: UInt = 0x0000_0004 + static let leftCommand: UInt = 0x0000_0008 + static let rightCommand: UInt = 0x0000_0010 + static let leftOption: UInt = 0x0000_0020 + static let rightOption: UInt = 0x0000_0040 + static let rightControl: UInt = 0x0000_2000 + } + private func isKeyCurrentlyPressed(keyCode: UInt16, flags: NSEvent.ModifierFlags) -> Bool { + func has(_ mask: UInt) -> Bool { flags.rawValue & mask != 0 } switch selectedKey { case .none: - false + return false case .capsLock: - keyCode == 57 && flags.contains(.capsLock) + // Caps Lock has no left/right variant; the family flag is correct here. + return keyCode == 57 && flags.contains(.capsLock) case .leftShift: - keyCode == 56 && flags.contains(.shift) + return keyCode == 56 && has(DeviceModifierMask.leftShift) case .leftOption: - keyCode == 58 && flags.contains(.option) + return keyCode == 58 && has(DeviceModifierMask.leftOption) case .leftCommand: - keyCode == 55 && flags.contains(.command) + return keyCode == 55 && has(DeviceModifierMask.leftCommand) case .leftControl: - keyCode == 59 && flags.contains(.control) + return keyCode == 59 && has(DeviceModifierMask.leftControl) case .rightShift: - keyCode == 60 && flags.contains(.shift) + return keyCode == 60 && has(DeviceModifierMask.rightShift) case .rightOption: - keyCode == 61 && flags.contains(.option) + return keyCode == 61 && has(DeviceModifierMask.rightOption) case .rightCommand: - keyCode == 54 && flags.contains(.command) + return keyCode == 54 && has(DeviceModifierMask.rightCommand) case .rightControl: - keyCode == 62 && flags.contains(.control) + return keyCode == 62 && has(DeviceModifierMask.rightControl) } } diff --git a/DidunyTests/SleepFlushCoordinatorTests.swift b/DidunyTests/SleepFlushCoordinatorTests.swift index a8958e3..6069ac6 100644 --- a/DidunyTests/SleepFlushCoordinatorTests.swift +++ b/DidunyTests/SleepFlushCoordinatorTests.swift @@ -1,3 +1,4 @@ +import AppKit import XCTest @testable import Diduny diff --git a/project.yml b/project.yml index 38faa24..daed1f3 100644 --- a/project.yml +++ b/project.yml @@ -29,8 +29,10 @@ configs: settings: base: - MARKETING_VERSION: "1.14.4" - CURRENT_PROJECT_VERSION: "4" + # Placeholder — CI overrides both from the git tag / commit count at release + # time (see CLAUDE.md "Release process"). Do not hand-edit to a release version. + MARKETING_VERSION: "0.0.0" + CURRENT_PROJECT_VERSION: "1" DEVELOPMENT_TEAM: "8JL9TM5WLG" CODE_SIGN_STYLE: Automatic ARCHS: "arm64 x86_64" From 874a5cf80a13e7f1cf499cdfdda3ab5b431423e8 Mon Sep 17 00:00:00 2001 From: Roman Marinsky Date: Mon, 8 Jun 2026 19:02:54 +0300 Subject: [PATCH 8/8] fix(async-jobs): keep waiting after SSE disconnect --- .../AsyncTranscriptionJobService.swift | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/Diduny/Core/Services/AsyncTranscriptionJobService.swift b/Diduny/Core/Services/AsyncTranscriptionJobService.swift index 472b778..824259d 100644 --- a/Diduny/Core/Services/AsyncTranscriptionJobService.swift +++ b/Diduny/Core/Services/AsyncTranscriptionJobService.swift @@ -6,7 +6,7 @@ final class AsyncTranscriptionJobService { SettingsStorage.shared.proxyBaseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) } - private let maxRetries = 3 + private let maxJobWaitSeconds: TimeInterval = 7200 private let maxAudioBytesForSpeechPrecheck = 25 * 1024 * 1024 private let longRunningSessionBodyThresholdBytes = 10 * 1024 * 1024 private let strictSpeechPrecheck = false @@ -167,9 +167,10 @@ final class AsyncTranscriptionJobService { try Task.checkCancellation() let submission = try await submitJob(audioData: audioData, config: config) - var retries = 0 + var sseFailures = 0 + let deadline = Date().addingTimeInterval(maxJobWaitSeconds) - while retries < self.maxRetries { + while Date() < deadline { try Task.checkCancellation() do { let result = try await streamJobResult(jobId: submission.jobId, onUpdate: onUpdate) @@ -177,8 +178,8 @@ final class AsyncTranscriptionJobService { } catch is CancellationError { throw CancellationError() } catch { - retries += 1 - Log.transcription.warning("SSE stream failed (attempt \(retries)/\(self.maxRetries)): \(error)") + sseFailures += 1 + Log.transcription.warning("SSE stream failed (attempt \(sseFailures)): \(error)") // Check if job finished while disconnected let status = try await getJobStatus(jobId: submission.jobId) @@ -188,14 +189,19 @@ final class AsyncTranscriptionJobService { if status.status == "error" { throw TranscriptionError.apiError(status.error ?? "Transcription failed") } + if let parsed = JobStatus(rawValue: status.status) { + onUpdate(parsed) + } - // Still in progress — backoff and retry SSE + // Still in progress. SSE is best-effort; keep polling/retrying until + // the server-side job reaches a terminal state or the long job timeout. try Task.checkCancellation() - try await Task.sleep(nanoseconds: UInt64(retries) * 2_000_000_000) + let delaySeconds = min(Double(max(sseFailures, 1)) * 2, 30) + try await Task.sleep(nanoseconds: UInt64(delaySeconds * 1_000_000_000)) } } - throw TranscriptionError.apiError("Failed to get transcription result after \(self.maxRetries) retries") + throw TranscriptionError.apiError("Timed out waiting for transcription result") } // MARK: - Upload Preparation @@ -405,8 +411,13 @@ final class AsyncTranscriptionJobService { guard let jsonData = data.data(using: .utf8) else { throw TranscriptionError.invalidResponse } - let result = try JSONDecoder().decode(JobTranscriptionResult.self, from: jsonData) - return JobResult(text: result.text) + if let wrapped = try? JSONDecoder().decode(JobStatusResponse.self, from: jsonData), + let result = wrapped.result + { + return JobResult(text: result.text) + } + let direct = try JSONDecoder().decode(JobTranscriptionResult.self, from: jsonData) + return JobResult(text: direct.text) } private func parseErrorMessage(_ data: String) -> String {