From 2016ea2f95540e59fec4f193269fe1609f426366 Mon Sep 17 00:00:00 2001 From: Gab <123818690+g4bcloud@users.noreply.github.com> Date: Wed, 29 Apr 2026 21:39:52 +0200 Subject: [PATCH 1/5] adding meeting transcriptor --- .vscode/settings.json | 1 + CLAUDE.md | 0 ExportOptions.plist | 0 ExportOptionsHomebrew.plist | 0 LICENSE | 0 Makefile | 0 README.md | 0 artwork/generate_app_icons.swift | 0 artwork/icon-square.svg | 0 artwork/icon.svg | 0 artwork/prompt.md | 0 artwork/screenshots/main-2880x1800.key | Bin .../main-2880x1800/main-2880x1800.001.png | Bin .../main-2880x1800/main-2880x1800.002.png | Bin .../main-2880x1800/main-2880x1800.003.png | Bin artwork/screenshots/menu.png | Bin artwork/screenshots/model-management.png | Bin artwork/screenshots/settings.png | Bin artwork/svg_to_png.swift | 0 artwork/wispr-banner.png | Bin tasks/todo.md | 65 ++++ wispr-cli/WisprCLI.swift | 0 wispr.xcodeproj/project.pbxproj | 18 +- .../contents.xcworkspacedata | 0 .../xcshareddata/swiftpm/Package.resolved | 0 .../xcshareddata/xcschemes/wispr.xcscheme | 0 .../AccentColor.colorset/Contents.json | 0 .../AppIcon.appiconset/AppIcon-128x128@1x.png | Bin .../AppIcon.appiconset/AppIcon-128x128@2x.png | Bin .../AppIcon.appiconset/AppIcon-16x16@1x.png | Bin .../AppIcon.appiconset/AppIcon-16x16@2x.png | Bin .../AppIcon.appiconset/AppIcon-256x256@1x.png | Bin .../AppIcon.appiconset/AppIcon-256x256@2x.png | Bin .../AppIcon.appiconset/AppIcon-32x32@1x.png | Bin .../AppIcon.appiconset/AppIcon-32x32@2x.png | Bin .../AppIcon.appiconset/AppIcon-512x512@1x.png | Bin .../AppIcon.appiconset/AppIcon-512x512@2x.png | Bin .../AppIcon.appiconset/Contents.json | 0 wispr/Assets.xcassets/Contents.json | 0 wispr/ContentView.swift | 0 wispr/Models/AppStateType.swift | 0 wispr/Models/AppUpdateInfo.swift | 0 wispr/Models/AudioInputDevice.swift | 0 wispr/Models/CorrectionStyle.swift | 0 wispr/Models/DownloadProgress.swift | 0 wispr/Models/MeetingTranscript.swift | 63 ++++ wispr/Models/ModelInfo.swift | 0 wispr/Models/ModelStatus.swift | 0 wispr/Models/OnboardingStep.swift | 0 wispr/Models/PermissionStatus.swift | 0 wispr/Models/SemanticVersion.swift | 0 wispr/Models/TranscriptionLanguage.swift | 0 wispr/Models/TranscriptionResult.swift | 0 wispr/Models/WisprError.swift | 0 wispr/Resources/Sounds/RecordingStarted.aiff | Bin wispr/Resources/Sounds/RecordingStopped.aiff | Bin wispr/Services/AudioEngine.swift | 0 wispr/Services/AudioFileDecoder.swift | 0 .../CompositeTranscriptionEngine.swift | 0 wispr/Services/HotkeyMonitor.swift | 0 wispr/Services/MeetingAudioEngine.swift | 356 ++++++++++++++++++ wispr/Services/MeetingStateManager.swift | 311 +++++++++++++++ wispr/Services/ParakeetService.swift | 0 wispr/Services/PermissionManager.swift | 20 +- wispr/Services/SettingsStore.swift | 6 + wispr/Services/SoundFeedbackService.swift | 0 wispr/Services/StateManager.swift | 0 wispr/Services/TextCorrectionService.swift | 0 wispr/Services/TextInsertionService.swift | 0 wispr/Services/TranscriptionEngine.swift | 0 wispr/Services/UpdateChecker.swift | 0 wispr/Services/WhisperService.swift | 0 wispr/UI/CLIInstallDialog.swift | 0 wispr/UI/Components/ModelRowView.swift | 0 wispr/UI/Components/SuffixEditorView.swift | 0 wispr/UI/Meeting/MeetingTranscriptView.swift | 235 ++++++++++++ wispr/UI/Meeting/MeetingWindowPanel.swift | 106 ++++++ wispr/UI/MenuBarController.swift | 29 +- wispr/UI/ModelDownloadProgressView.swift | 0 wispr/UI/ModelManagementView.swift | 0 .../OnboardingAccessibilityStep.swift | 0 .../Onboarding/OnboardingCompletionStep.swift | 0 .../UI/Onboarding/OnboardingComponents.swift | 0 wispr/UI/Onboarding/OnboardingFlow.swift | 41 +- .../OnboardingMicPermissionStep.swift | 0 .../OnboardingModelSelectionStep.swift | 0 wispr/UI/Onboarding/OnboardingPreview.swift | 0 .../OnboardingTestDictationStep.swift | 0 .../UI/Onboarding/OnboardingWelcomeStep.swift | 0 wispr/UI/RecordingOverlayPanel.swift | 0 wispr/UI/RecordingOverlayView.swift | 0 wispr/UI/Settings/HotkeyRecorderView.swift | 0 wispr/UI/Settings/KeyCodeMapping.swift | 0 wispr/UI/Settings/SettingsView.swift | 0 wispr/UI/Settings/SupportedLanguage.swift | 0 wispr/Utilities/FillerWordCleaner.swift | 0 wispr/Utilities/Logger.swift | 0 wispr/Utilities/ModelPaths.swift | 21 +- wispr/Utilities/PreviewHelpers.swift | 0 wispr/Utilities/SFSymbols.swift | 0 wispr/Utilities/UIThemeEngine.swift | 0 wispr/wisprApp.swift | 73 +++- wisprTests/AccessibilityTests.swift | 0 wisprTests/AppLifecycleIntegrationTests.swift | 0 wisprTests/AudioEngineTests.swift | 0 .../CompositeTranscriptionEngineTests.swift | 0 wisprTests/EndToEndIntegrationTests.swift | 0 wisprTests/ErrorRecoveryTests.swift | 0 wisprTests/FillerWordCleanerTests.swift | 0 wisprTests/HotkeyMonitorTests.swift | 0 wisprTests/MenuBarControllerTests.swift | 0 wisprTests/ModelManagementViewTests.swift | 0 wisprTests/MultiLanguageTests.swift | 0 wisprTests/OnboardingFlowTests.swift | 0 wisprTests/PermissionManagerTests.swift | 0 wisprTests/RecordingOverlayTests.swift | 0 wisprTests/SettingsStoreTests.swift | 0 wisprTests/SettingsViewTests.swift | 0 wisprTests/SoundFeedbackServiceTests.swift | 0 wisprTests/StateManagerTests.swift | 0 wisprTests/TextCorrectionTokenLeakTests.swift | 0 wisprTests/TextInsertionServiceTests.swift | 0 wisprTests/UIThemeEngineTests.swift | 0 wisprTests/UpdateCheckerTests.swift | 0 wisprTests/WhisperServiceTests.swift | 0 wisprTests/wisprTests.swift | 0 wisprUITests/wisprUITests.swift | 0 wisprUITests/wisprUITestsLaunchTests.swift | 0 128 files changed, 1322 insertions(+), 23 deletions(-) mode change 100644 => 100755 CLAUDE.md mode change 100644 => 100755 ExportOptions.plist mode change 100644 => 100755 ExportOptionsHomebrew.plist mode change 100644 => 100755 LICENSE mode change 100644 => 100755 Makefile mode change 100644 => 100755 README.md mode change 100644 => 100755 artwork/generate_app_icons.swift mode change 100644 => 100755 artwork/icon-square.svg mode change 100644 => 100755 artwork/icon.svg mode change 100644 => 100755 artwork/prompt.md mode change 100644 => 100755 artwork/screenshots/main-2880x1800.key mode change 100644 => 100755 artwork/screenshots/main-2880x1800/main-2880x1800.001.png mode change 100644 => 100755 artwork/screenshots/main-2880x1800/main-2880x1800.002.png mode change 100644 => 100755 artwork/screenshots/main-2880x1800/main-2880x1800.003.png mode change 100644 => 100755 artwork/screenshots/menu.png mode change 100644 => 100755 artwork/screenshots/model-management.png mode change 100644 => 100755 artwork/screenshots/settings.png mode change 100644 => 100755 artwork/svg_to_png.swift mode change 100644 => 100755 artwork/wispr-banner.png create mode 100755 tasks/todo.md mode change 100644 => 100755 wispr-cli/WisprCLI.swift mode change 100644 => 100755 wispr.xcodeproj/project.pbxproj mode change 100644 => 100755 wispr.xcodeproj/project.xcworkspace/contents.xcworkspacedata mode change 100644 => 100755 wispr.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved mode change 100644 => 100755 wispr.xcodeproj/xcshareddata/xcschemes/wispr.xcscheme mode change 100644 => 100755 wispr/Assets.xcassets/AccentColor.colorset/Contents.json mode change 100644 => 100755 wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-128x128@1x.png mode change 100644 => 100755 wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-128x128@2x.png mode change 100644 => 100755 wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-16x16@1x.png mode change 100644 => 100755 wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-16x16@2x.png mode change 100644 => 100755 wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-256x256@1x.png mode change 100644 => 100755 wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-256x256@2x.png mode change 100644 => 100755 wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-32x32@1x.png mode change 100644 => 100755 wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-32x32@2x.png mode change 100644 => 100755 wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-512x512@1x.png mode change 100644 => 100755 wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-512x512@2x.png mode change 100644 => 100755 wispr/Assets.xcassets/AppIcon.appiconset/Contents.json mode change 100644 => 100755 wispr/Assets.xcassets/Contents.json mode change 100644 => 100755 wispr/ContentView.swift mode change 100644 => 100755 wispr/Models/AppStateType.swift mode change 100644 => 100755 wispr/Models/AppUpdateInfo.swift mode change 100644 => 100755 wispr/Models/AudioInputDevice.swift mode change 100644 => 100755 wispr/Models/CorrectionStyle.swift mode change 100644 => 100755 wispr/Models/DownloadProgress.swift create mode 100755 wispr/Models/MeetingTranscript.swift mode change 100644 => 100755 wispr/Models/ModelInfo.swift mode change 100644 => 100755 wispr/Models/ModelStatus.swift mode change 100644 => 100755 wispr/Models/OnboardingStep.swift mode change 100644 => 100755 wispr/Models/PermissionStatus.swift mode change 100644 => 100755 wispr/Models/SemanticVersion.swift mode change 100644 => 100755 wispr/Models/TranscriptionLanguage.swift mode change 100644 => 100755 wispr/Models/TranscriptionResult.swift mode change 100644 => 100755 wispr/Models/WisprError.swift mode change 100644 => 100755 wispr/Resources/Sounds/RecordingStarted.aiff mode change 100644 => 100755 wispr/Resources/Sounds/RecordingStopped.aiff mode change 100644 => 100755 wispr/Services/AudioEngine.swift mode change 100644 => 100755 wispr/Services/AudioFileDecoder.swift mode change 100644 => 100755 wispr/Services/CompositeTranscriptionEngine.swift mode change 100644 => 100755 wispr/Services/HotkeyMonitor.swift create mode 100755 wispr/Services/MeetingAudioEngine.swift create mode 100755 wispr/Services/MeetingStateManager.swift mode change 100644 => 100755 wispr/Services/ParakeetService.swift mode change 100644 => 100755 wispr/Services/PermissionManager.swift mode change 100644 => 100755 wispr/Services/SettingsStore.swift mode change 100644 => 100755 wispr/Services/SoundFeedbackService.swift mode change 100644 => 100755 wispr/Services/StateManager.swift mode change 100644 => 100755 wispr/Services/TextCorrectionService.swift mode change 100644 => 100755 wispr/Services/TextInsertionService.swift mode change 100644 => 100755 wispr/Services/TranscriptionEngine.swift mode change 100644 => 100755 wispr/Services/UpdateChecker.swift mode change 100644 => 100755 wispr/Services/WhisperService.swift mode change 100644 => 100755 wispr/UI/CLIInstallDialog.swift mode change 100644 => 100755 wispr/UI/Components/ModelRowView.swift mode change 100644 => 100755 wispr/UI/Components/SuffixEditorView.swift create mode 100755 wispr/UI/Meeting/MeetingTranscriptView.swift create mode 100755 wispr/UI/Meeting/MeetingWindowPanel.swift mode change 100644 => 100755 wispr/UI/MenuBarController.swift mode change 100644 => 100755 wispr/UI/ModelDownloadProgressView.swift mode change 100644 => 100755 wispr/UI/ModelManagementView.swift mode change 100644 => 100755 wispr/UI/Onboarding/OnboardingAccessibilityStep.swift mode change 100644 => 100755 wispr/UI/Onboarding/OnboardingCompletionStep.swift mode change 100644 => 100755 wispr/UI/Onboarding/OnboardingComponents.swift mode change 100644 => 100755 wispr/UI/Onboarding/OnboardingFlow.swift mode change 100644 => 100755 wispr/UI/Onboarding/OnboardingMicPermissionStep.swift mode change 100644 => 100755 wispr/UI/Onboarding/OnboardingModelSelectionStep.swift mode change 100644 => 100755 wispr/UI/Onboarding/OnboardingPreview.swift mode change 100644 => 100755 wispr/UI/Onboarding/OnboardingTestDictationStep.swift mode change 100644 => 100755 wispr/UI/Onboarding/OnboardingWelcomeStep.swift mode change 100644 => 100755 wispr/UI/RecordingOverlayPanel.swift mode change 100644 => 100755 wispr/UI/RecordingOverlayView.swift mode change 100644 => 100755 wispr/UI/Settings/HotkeyRecorderView.swift mode change 100644 => 100755 wispr/UI/Settings/KeyCodeMapping.swift mode change 100644 => 100755 wispr/UI/Settings/SettingsView.swift mode change 100644 => 100755 wispr/UI/Settings/SupportedLanguage.swift mode change 100644 => 100755 wispr/Utilities/FillerWordCleaner.swift mode change 100644 => 100755 wispr/Utilities/Logger.swift mode change 100644 => 100755 wispr/Utilities/ModelPaths.swift mode change 100644 => 100755 wispr/Utilities/PreviewHelpers.swift mode change 100644 => 100755 wispr/Utilities/SFSymbols.swift mode change 100644 => 100755 wispr/Utilities/UIThemeEngine.swift mode change 100644 => 100755 wispr/wisprApp.swift mode change 100644 => 100755 wisprTests/AccessibilityTests.swift mode change 100644 => 100755 wisprTests/AppLifecycleIntegrationTests.swift mode change 100644 => 100755 wisprTests/AudioEngineTests.swift mode change 100644 => 100755 wisprTests/CompositeTranscriptionEngineTests.swift mode change 100644 => 100755 wisprTests/EndToEndIntegrationTests.swift mode change 100644 => 100755 wisprTests/ErrorRecoveryTests.swift mode change 100644 => 100755 wisprTests/FillerWordCleanerTests.swift mode change 100644 => 100755 wisprTests/HotkeyMonitorTests.swift mode change 100644 => 100755 wisprTests/MenuBarControllerTests.swift mode change 100644 => 100755 wisprTests/ModelManagementViewTests.swift mode change 100644 => 100755 wisprTests/MultiLanguageTests.swift mode change 100644 => 100755 wisprTests/OnboardingFlowTests.swift mode change 100644 => 100755 wisprTests/PermissionManagerTests.swift mode change 100644 => 100755 wisprTests/RecordingOverlayTests.swift mode change 100644 => 100755 wisprTests/SettingsStoreTests.swift mode change 100644 => 100755 wisprTests/SettingsViewTests.swift mode change 100644 => 100755 wisprTests/SoundFeedbackServiceTests.swift mode change 100644 => 100755 wisprTests/StateManagerTests.swift mode change 100644 => 100755 wisprTests/TextCorrectionTokenLeakTests.swift mode change 100644 => 100755 wisprTests/TextInsertionServiceTests.swift mode change 100644 => 100755 wisprTests/UIThemeEngineTests.swift mode change 100644 => 100755 wisprTests/UpdateCheckerTests.swift mode change 100644 => 100755 wisprTests/WhisperServiceTests.swift mode change 100644 => 100755 wisprTests/wisprTests.swift mode change 100644 => 100755 wisprUITests/wisprUITests.swift mode change 100644 => 100755 wisprUITests/wisprUITestsLaunchTests.swift diff --git a/.vscode/settings.json b/.vscode/settings.json index 7a73a41..5480842 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,2 +1,3 @@ { + "kiroAgent.configureMCP": "Disabled" } \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md old mode 100644 new mode 100755 diff --git a/ExportOptions.plist b/ExportOptions.plist old mode 100644 new mode 100755 diff --git a/ExportOptionsHomebrew.plist b/ExportOptionsHomebrew.plist old mode 100644 new mode 100755 diff --git a/LICENSE b/LICENSE old mode 100644 new mode 100755 diff --git a/Makefile b/Makefile old mode 100644 new mode 100755 diff --git a/README.md b/README.md old mode 100644 new mode 100755 diff --git a/artwork/generate_app_icons.swift b/artwork/generate_app_icons.swift old mode 100644 new mode 100755 diff --git a/artwork/icon-square.svg b/artwork/icon-square.svg old mode 100644 new mode 100755 diff --git a/artwork/icon.svg b/artwork/icon.svg old mode 100644 new mode 100755 diff --git a/artwork/prompt.md b/artwork/prompt.md old mode 100644 new mode 100755 diff --git a/artwork/screenshots/main-2880x1800.key b/artwork/screenshots/main-2880x1800.key old mode 100644 new mode 100755 diff --git a/artwork/screenshots/main-2880x1800/main-2880x1800.001.png b/artwork/screenshots/main-2880x1800/main-2880x1800.001.png old mode 100644 new mode 100755 diff --git a/artwork/screenshots/main-2880x1800/main-2880x1800.002.png b/artwork/screenshots/main-2880x1800/main-2880x1800.002.png old mode 100644 new mode 100755 diff --git a/artwork/screenshots/main-2880x1800/main-2880x1800.003.png b/artwork/screenshots/main-2880x1800/main-2880x1800.003.png old mode 100644 new mode 100755 diff --git a/artwork/screenshots/menu.png b/artwork/screenshots/menu.png old mode 100644 new mode 100755 diff --git a/artwork/screenshots/model-management.png b/artwork/screenshots/model-management.png old mode 100644 new mode 100755 diff --git a/artwork/screenshots/settings.png b/artwork/screenshots/settings.png old mode 100644 new mode 100755 diff --git a/artwork/svg_to_png.swift b/artwork/svg_to_png.swift old mode 100644 new mode 100755 diff --git a/artwork/wispr-banner.png b/artwork/wispr-banner.png old mode 100644 new mode 100755 diff --git a/tasks/todo.md b/tasks/todo.md new file mode 100755 index 0000000..76c632d --- /dev/null +++ b/tasks/todo.md @@ -0,0 +1,65 @@ +# Meeting Transcription Mode — Implementation Plan + +## Overview +Add a new "Meeting Mode" to Wispr that: +1. Shows a floating square window with recording controls and live transcript +2. Captures both system audio (what others say in meetings) and microphone audio (what you say) +3. Separates speakers into "You" vs "Others" based on audio source +4. Displays a scrolling live transcript in the window +5. Allows copying/exporting the transcript for notes + +## Architecture Decisions + +### System Audio Capture +macOS requires `ScreenCaptureKit` (macOS 13+) to capture system audio. This is the only sanctioned API — `AVAudioEngine` can only capture microphone input. We'll use `SCStreamConfiguration` with `capturesAudio = true` and `excludesCurrentProcessAudio = true`. + +This requires the **Screen Recording** permission (user grants in System Settings > Privacy & Security > Screen Recording). + +### Speaker Separation Strategy +Instead of ML-based diarization (complex, heavy), we use a simple but effective approach: +- **Microphone audio** → labeled as "You" +- **System audio** → labeled as "Others" + +This works perfectly for meetings because system audio = remote participants, mic = you. + +### Dual Audio Engine +Create a new `MeetingAudioEngine` actor that runs two capture pipelines in parallel: +1. `AVAudioEngine` for microphone (existing approach) +2. `SCStreamConfiguration` for system audio + +Both streams are resampled to 16kHz mono Float32 and fed to separate transcription instances. + +### Transcription Approach +Run two parallel transcription sessions: +- One for mic audio chunks → "You:" prefix +- One for system audio chunks → "Others:" prefix + +Use chunked transcription (process every ~5-10 seconds of audio) for near-real-time results. + +## Implementation Tasks + +### Phase 1: Core Infrastructure +- [x] 1.1 Create `MeetingTranscript` model (timestamped entries with speaker labels) +- [x] 1.2 Create `MeetingAudioEngine` actor (dual capture: mic + system audio via ScreenCaptureKit) +- [x] 1.3 Create `MeetingStateManager` (orchestrates meeting mode state machine) +- [x] 1.4 Add Screen Recording permission handling to `PermissionManager` + +### Phase 2: Meeting Mode UI +- [x] 2.1 Create `MeetingTranscriptView` (scrolling transcript with speaker labels) +- [x] 2.2 Create `MeetingWindowPanel` (floating square NSPanel with controls) +- [x] 2.3 Add "Meeting Mode" menu item to `MenuBarController` +- [x] 2.4 Wire meeting window visibility to `MeetingStateManager` + +### Phase 3: Integration +- [x] 3.1 Add meeting mode settings to `SettingsStore` (not needed for MVP — uses existing language settings) +- [x] 3.2 Wire up `WisprAppDelegate` to bootstrap meeting mode services +- [x] 3.3 Add transcript export (copy to clipboard / save as text file) + +## File Plan +``` +wispr/Models/MeetingTranscript.swift — transcript data model +wispr/Services/MeetingAudioEngine.swift — dual audio capture +wispr/Services/MeetingStateManager.swift — meeting mode coordinator +wispr/UI/Meeting/MeetingTranscriptView.swift — transcript UI +wispr/UI/Meeting/MeetingWindowPanel.swift — floating window +``` diff --git a/wispr-cli/WisprCLI.swift b/wispr-cli/WisprCLI.swift old mode 100644 new mode 100755 diff --git a/wispr.xcodeproj/project.pbxproj b/wispr.xcodeproj/project.pbxproj old mode 100644 new mode 100755 index d883c09..c712465 --- a/wispr.xcodeproj/project.pbxproj +++ b/wispr.xcodeproj/project.pbxproj @@ -530,7 +530,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 260417.274; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = 56U756R2L2; + DEVELOPMENT_TEAM = XG5UJZ2PFW; ENABLE_APP_SANDBOX = YES; ENABLE_ENHANCED_SECURITY = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -539,7 +539,7 @@ ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = YES; ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = Wispr; + INFOPLIST_KEY_CFBundleDisplayName = "Wispr Steno"; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSUIElement = YES; @@ -550,7 +550,7 @@ "@executable_path/../Frameworks", ); MARKETING_VERSION = 1.9.2; - PRODUCT_BUNDLE_IDENTIFIER = com.stormacq.mac.wispr; + PRODUCT_BUNDLE_IDENTIFIER = com.stormacq.mac.wispr.steno; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -576,7 +576,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 260417.274; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = 56U756R2L2; + DEVELOPMENT_TEAM = XG5UJZ2PFW; ENABLE_APP_SANDBOX = YES; ENABLE_ENHANCED_SECURITY = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -585,7 +585,7 @@ ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = YES; ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = Wispr; + INFOPLIST_KEY_CFBundleDisplayName = "Wispr Steno"; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSUIElement = YES; @@ -596,7 +596,7 @@ "@executable_path/../Frameworks", ); MARKETING_VERSION = 1.9.2; - PRODUCT_BUNDLE_IDENTIFIER = com.stormacq.mac.wispr; + PRODUCT_BUNDLE_IDENTIFIER = com.stormacq.mac.wispr.steno; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; REGISTER_APP_GROUPS = YES; @@ -712,11 +712,12 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; CREATE_INFOPLIST_SECTION_IN_BINARY = YES; CURRENT_PROJECT_VERSION = 260417.274; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = 56U756R2L2; + DEVELOPMENT_TEAM = XG5UJZ2PFW; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; @@ -739,11 +740,12 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; CREATE_INFOPLIST_SECTION_IN_BINARY = YES; CURRENT_PROJECT_VERSION = 260417.274; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = 56U756R2L2; + DEVELOPMENT_TEAM = XG5UJZ2PFW; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; diff --git a/wispr.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/wispr.xcodeproj/project.xcworkspace/contents.xcworkspacedata old mode 100644 new mode 100755 diff --git a/wispr.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/wispr.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved old mode 100644 new mode 100755 diff --git a/wispr.xcodeproj/xcshareddata/xcschemes/wispr.xcscheme b/wispr.xcodeproj/xcshareddata/xcschemes/wispr.xcscheme old mode 100644 new mode 100755 diff --git a/wispr/Assets.xcassets/AccentColor.colorset/Contents.json b/wispr/Assets.xcassets/AccentColor.colorset/Contents.json old mode 100644 new mode 100755 diff --git a/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-128x128@1x.png b/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-128x128@1x.png old mode 100644 new mode 100755 diff --git a/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-128x128@2x.png b/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-128x128@2x.png old mode 100644 new mode 100755 diff --git a/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-16x16@1x.png b/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-16x16@1x.png old mode 100644 new mode 100755 diff --git a/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-16x16@2x.png b/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-16x16@2x.png old mode 100644 new mode 100755 diff --git a/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-256x256@1x.png b/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-256x256@1x.png old mode 100644 new mode 100755 diff --git a/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-256x256@2x.png b/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-256x256@2x.png old mode 100644 new mode 100755 diff --git a/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-32x32@1x.png b/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-32x32@1x.png old mode 100644 new mode 100755 diff --git a/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-32x32@2x.png b/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-32x32@2x.png old mode 100644 new mode 100755 diff --git a/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-512x512@1x.png b/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-512x512@1x.png old mode 100644 new mode 100755 diff --git a/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-512x512@2x.png b/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-512x512@2x.png old mode 100644 new mode 100755 diff --git a/wispr/Assets.xcassets/AppIcon.appiconset/Contents.json b/wispr/Assets.xcassets/AppIcon.appiconset/Contents.json old mode 100644 new mode 100755 diff --git a/wispr/Assets.xcassets/Contents.json b/wispr/Assets.xcassets/Contents.json old mode 100644 new mode 100755 diff --git a/wispr/ContentView.swift b/wispr/ContentView.swift old mode 100644 new mode 100755 diff --git a/wispr/Models/AppStateType.swift b/wispr/Models/AppStateType.swift old mode 100644 new mode 100755 diff --git a/wispr/Models/AppUpdateInfo.swift b/wispr/Models/AppUpdateInfo.swift old mode 100644 new mode 100755 diff --git a/wispr/Models/AudioInputDevice.swift b/wispr/Models/AudioInputDevice.swift old mode 100644 new mode 100755 diff --git a/wispr/Models/CorrectionStyle.swift b/wispr/Models/CorrectionStyle.swift old mode 100644 new mode 100755 diff --git a/wispr/Models/DownloadProgress.swift b/wispr/Models/DownloadProgress.swift old mode 100644 new mode 100755 diff --git a/wispr/Models/MeetingTranscript.swift b/wispr/Models/MeetingTranscript.swift new file mode 100755 index 0000000..c78c53d --- /dev/null +++ b/wispr/Models/MeetingTranscript.swift @@ -0,0 +1,63 @@ +// +// MeetingTranscript.swift +// wispr +// +// Data model for meeting transcription entries with speaker labels. +// + +import Foundation + +/// Identifies the audio source / speaker in a meeting transcript. +enum MeetingSpeaker: String, Sendable, Equatable, Hashable { + case you = "You" + case others = "Others" +} + +/// A single timestamped entry in a meeting transcript. +struct MeetingTranscriptEntry: Identifiable, Sendable, Equatable { + let id: UUID + let speaker: MeetingSpeaker + let text: String + let timestamp: Date + + init(speaker: MeetingSpeaker, text: String, timestamp: Date = Date()) { + self.id = UUID() + self.speaker = speaker + self.text = text + self.timestamp = timestamp + } +} + +/// The full transcript of a meeting session. +struct MeetingTranscript: Sendable, Equatable { + var entries: [MeetingTranscriptEntry] = [] + let startTime: Date + + init(startTime: Date = Date()) { + self.startTime = startTime + } + + /// Formats the entire transcript as plain text for export. + func asPlainText() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss" + + return entries.map { entry in + let time = formatter.string(from: entry.timestamp) + return "[\(time)] \(entry.speaker.rawValue): \(entry.text)" + }.joined(separator: "\n") + } + + /// Duration of the meeting so far. + var duration: TimeInterval { + Date().timeIntervalSince(startTime) + } + + /// Formatted duration string (e.g. "12:34"). + var formattedDuration: String { + let total = Int(duration) + let minutes = total / 60 + let seconds = total % 60 + return String(format: "%d:%02d", minutes, seconds) + } +} diff --git a/wispr/Models/ModelInfo.swift b/wispr/Models/ModelInfo.swift old mode 100644 new mode 100755 diff --git a/wispr/Models/ModelStatus.swift b/wispr/Models/ModelStatus.swift old mode 100644 new mode 100755 diff --git a/wispr/Models/OnboardingStep.swift b/wispr/Models/OnboardingStep.swift old mode 100644 new mode 100755 diff --git a/wispr/Models/PermissionStatus.swift b/wispr/Models/PermissionStatus.swift old mode 100644 new mode 100755 diff --git a/wispr/Models/SemanticVersion.swift b/wispr/Models/SemanticVersion.swift old mode 100644 new mode 100755 diff --git a/wispr/Models/TranscriptionLanguage.swift b/wispr/Models/TranscriptionLanguage.swift old mode 100644 new mode 100755 diff --git a/wispr/Models/TranscriptionResult.swift b/wispr/Models/TranscriptionResult.swift old mode 100644 new mode 100755 diff --git a/wispr/Models/WisprError.swift b/wispr/Models/WisprError.swift old mode 100644 new mode 100755 diff --git a/wispr/Resources/Sounds/RecordingStarted.aiff b/wispr/Resources/Sounds/RecordingStarted.aiff old mode 100644 new mode 100755 diff --git a/wispr/Resources/Sounds/RecordingStopped.aiff b/wispr/Resources/Sounds/RecordingStopped.aiff old mode 100644 new mode 100755 diff --git a/wispr/Services/AudioEngine.swift b/wispr/Services/AudioEngine.swift old mode 100644 new mode 100755 diff --git a/wispr/Services/AudioFileDecoder.swift b/wispr/Services/AudioFileDecoder.swift old mode 100644 new mode 100755 diff --git a/wispr/Services/CompositeTranscriptionEngine.swift b/wispr/Services/CompositeTranscriptionEngine.swift old mode 100644 new mode 100755 diff --git a/wispr/Services/HotkeyMonitor.swift b/wispr/Services/HotkeyMonitor.swift old mode 100644 new mode 100755 diff --git a/wispr/Services/MeetingAudioEngine.swift b/wispr/Services/MeetingAudioEngine.swift new file mode 100755 index 0000000..6f55bc0 --- /dev/null +++ b/wispr/Services/MeetingAudioEngine.swift @@ -0,0 +1,356 @@ +// +// MeetingAudioEngine.swift +// wispr +// +// Dual audio capture engine for meeting transcription. +// Captures microphone audio via AVAudioEngine and system audio via ScreenCaptureKit. +// +// Note: System audio capture requires Screen Recording permission, which macOS +// prompts for automatically on first use of ScreenCaptureKit. The app must be +// properly code-signed for macOS to list it in System Settings > Privacy & +// Security > Screen Recording. If the permission is denied or unavailable, +// the engine falls back to mic-only capture. +// + +import AVFoundation +import CoreAudio +import Foundation +import ScreenCaptureKit +import os + +/// Actor responsible for capturing both microphone and system audio simultaneously. +/// +/// Uses `AVAudioEngine` for microphone input and `SCStream` (ScreenCaptureKit) +/// for system audio capture. Both streams are resampled to 16kHz mono Float32. +/// +/// If system audio capture fails (e.g. permission denied, sandbox restriction), +/// the engine continues with mic-only capture and logs a warning. +actor MeetingAudioEngine { + + // MARK: - State + + private var micEngine: AVAudioEngine? + private var systemStream: SCStream? + private var systemStreamOutput: SystemAudioOutputHandler? + + private var micBuffer: [Float] = [] + private var systemBuffer: [Float] = [] + + private var micContinuation: AsyncStream<[Float]>.Continuation? + private var systemContinuation: AsyncStream<[Float]>.Continuation? + private var micLevelContinuation: AsyncStream.Continuation? + private var systemLevelContinuation: AsyncStream.Continuation? + + private var isCapturing = false + + /// Whether system audio capture is active (may be false if permission denied). + private var hasSystemAudio = false + + /// The audio chunk streams created at capture start. + private var _micAudioStream: AsyncStream<[Float]>? + private var _systemAudioStream: AsyncStream<[Float]>? + + /// The chunk size in samples before yielding to the transcription stream. + /// ~5 seconds of audio at 16kHz = 80,000 samples. + private let chunkSize = 80_000 + + // MARK: - Public Interface + + /// Starts dual audio capture (microphone + system audio). + /// + /// System audio capture may silently fail if Screen Recording permission + /// is not granted — in that case, only mic capture is active. + /// + /// - Returns: A tuple of (micLevelStream, systemLevelStream) for UI visualization. + /// - Throws: If microphone capture fails to start. + func startCapture() async throws -> (micLevels: AsyncStream, systemLevels: AsyncStream) { + guard !isCapturing else { + throw WisprError.audioRecordingFailed("Meeting capture already active") + } + + isCapturing = true + micBuffer.removeAll() + systemBuffer.removeAll() + + // Create audio chunk streams upfront so continuations are ready + // before the taps start producing data. + let (micStream, micCont) = AsyncStream.makeStream(of: [Float].self) + _micAudioStream = micStream + micContinuation = micCont + + let (sysStream, sysCont) = AsyncStream.makeStream(of: [Float].self) + _systemAudioStream = sysStream + systemContinuation = sysCont + + let micLevels = startMicCapture() + + // Attempt system audio capture — fall back to mic-only on failure + let systemLevels: AsyncStream + do { + systemLevels = try await startSystemAudioCapture() + hasSystemAudio = true + } catch { + Log.audioEngine.warning("MeetingAudioEngine — system audio unavailable: \(error.localizedDescription). Continuing with mic only.") + hasSystemAudio = false + // Return a silent level stream + let (silentStream, silentCont) = AsyncStream.makeStream(of: Float.self) + silentCont.finish() + systemLevels = silentStream + } + + return (micLevels, systemLevels) + } + + /// Stops all capture and cleans up resources. + func stopCapture() async { + if hasSystemAudio { + await stopSystemCapture() + } + teardownMic() + teardownSystemAudio() + isCapturing = false + hasSystemAudio = false + } + + /// Returns the mic audio chunk stream created during `startCapture()`. + var micAudioStream: AsyncStream<[Float]> { + if let stream = _micAudioStream { return stream } + let (stream, cont) = AsyncStream.makeStream(of: [Float].self) + cont.finish() + return stream + } + + /// Returns the system audio chunk stream created during `startCapture()`. + var systemAudioStream: AsyncStream<[Float]> { + if let stream = _systemAudioStream { return stream } + let (stream, cont) = AsyncStream.makeStream(of: [Float].self) + cont.finish() + return stream + } + + /// Flushes any remaining buffered audio as final chunks. + func flushBuffers() { + if !micBuffer.isEmpty { + micContinuation?.yield(micBuffer) + micBuffer.removeAll() + } + if !systemBuffer.isEmpty { + systemContinuation?.yield(systemBuffer) + systemBuffer.removeAll() + } + micContinuation?.finish() + systemContinuation?.finish() + micContinuation = nil + systemContinuation = nil + } + + // MARK: - Microphone Capture + + private func startMicCapture() -> AsyncStream { + let (levelStream, levelContinuation) = AsyncStream.makeStream(of: Float.self) + self.micLevelContinuation = levelContinuation + + guard let targetFormat = AVAudioFormat( + commonFormat: .pcmFormatFloat32, + sampleRate: 16000, + channels: 1, + interleaved: false + ) else { + levelContinuation.finish() + return levelStream + } + + let audioEngine = AVAudioEngine() + self.micEngine = audioEngine + let inputNode = audioEngine.inputNode + + nonisolated(unsafe) var converter: AVAudioConverter? + nonisolated(unsafe) var sampleRateRatio: Double = 0 + + inputNode.installTap(onBus: 0, bufferSize: 4096, format: nil) { [weak self] buffer, _ in + guard let self, buffer.frameLength > 0 else { return } + + if converter == nil { + let bufferFormat = buffer.format + guard bufferFormat.sampleRate > 0, bufferFormat.channelCount > 0 else { return } + guard let newConverter = AVAudioConverter(from: bufferFormat, to: targetFormat) else { return } + converter = newConverter + sampleRateRatio = targetFormat.sampleRate / bufferFormat.sampleRate + } + + guard let tapConverter = converter else { return } + + let outputFrameCount = AVAudioFrameCount(Double(buffer.frameLength) * sampleRateRatio) + guard let outputBuffer = AVAudioPCMBuffer( + pcmFormat: targetFormat, + frameCapacity: outputFrameCount + ) else { return } + + nonisolated(unsafe) let inputBuffer = buffer + var conversionError: NSError? + let status = tapConverter.convert(to: outputBuffer, error: &conversionError) { _, outStatus in + outStatus.pointee = .haveData + return inputBuffer + } + + guard status != .error, + let channelData = outputBuffer.floatChannelData?[0], + outputBuffer.frameLength > 0 else { return } + + let samples = Array(UnsafeBufferPointer(start: channelData, count: Int(outputBuffer.frameLength))) + + Task { + await self.processMicSamples(samples) + } + } + + do { + try audioEngine.start() + Log.audioEngine.debug("MeetingAudioEngine — mic capture started") + } catch { + Log.audioEngine.error("MeetingAudioEngine — mic engine start failed: \(error.localizedDescription)") + teardownMic() + } + + return levelStream + } + + private func processMicSamples(_ samples: [Float]) { + guard isCapturing else { return } + + let sumOfSquares = samples.reduce(0.0) { $0 + $1 * $1 } + let rms = sqrt(sumOfSquares / Float(samples.count)) + let normalizedLevel = min(max(rms * 5.0, 0.0), 1.0) + micLevelContinuation?.yield(normalizedLevel) + + micBuffer.append(contentsOf: samples) + if micBuffer.count >= chunkSize { + let chunk = Array(micBuffer.prefix(chunkSize)) + micBuffer.removeFirst(min(chunkSize, micBuffer.count)) + Log.audioEngine.debug("MeetingAudioEngine — yielding mic chunk of \(chunk.count) samples") + micContinuation?.yield(chunk) + } + } + + private func teardownMic() { + guard let engine = micEngine else { return } + engine.stop() + engine.inputNode.removeTap(onBus: 0) + micEngine = nil + micBuffer.removeAll() + micContinuation?.finish() + micContinuation = nil + micLevelContinuation?.finish() + micLevelContinuation = nil + _micAudioStream = nil + } + + // MARK: - System Audio Capture (ScreenCaptureKit) + + private func startSystemAudioCapture() async throws -> AsyncStream { + let (levelStream, levelContinuation) = AsyncStream.makeStream(of: Float.self) + self.systemLevelContinuation = levelContinuation + + let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: false) + + guard let display = content.displays.first else { + throw WisprError.audioRecordingFailed("No display found for system audio capture") + } + + let filter = SCContentFilter(display: display, excludingWindows: []) + + let config = SCStreamConfiguration() + config.width = 2 + config.height = 2 + config.minimumFrameInterval = CMTime(value: 1, timescale: 1) + config.capturesAudio = true + config.excludesCurrentProcessAudio = true + config.sampleRate = 16000 + config.channelCount = 1 + + let handler = SystemAudioOutputHandler { [weak self] samples in + guard let self else { return } + Task { + await self.processSystemSamples(samples) + } + } + self.systemStreamOutput = handler + + let stream = SCStream(filter: filter, configuration: config, delegate: nil) + try stream.addStreamOutput(handler, type: .audio, sampleHandlerQueue: DispatchQueue(label: "wispr.meeting.systemAudio")) + try await stream.startCapture() + self.systemStream = stream + + Log.audioEngine.debug("MeetingAudioEngine — system audio capture started") + return levelStream + } + + private func processSystemSamples(_ samples: [Float]) { + guard isCapturing else { return } + + let sumOfSquares = samples.reduce(0.0) { $0 + $1 * $1 } + let rms = sqrt(sumOfSquares / Float(samples.count)) + let normalizedLevel = min(max(rms * 5.0, 0.0), 1.0) + systemLevelContinuation?.yield(normalizedLevel) + + systemBuffer.append(contentsOf: samples) + if systemBuffer.count >= chunkSize { + let chunk = Array(systemBuffer.prefix(chunkSize)) + systemBuffer.removeFirst(min(chunkSize, systemBuffer.count)) + Log.audioEngine.debug("MeetingAudioEngine — yielding system chunk of \(chunk.count) samples") + systemContinuation?.yield(chunk) + } + } + + private func teardownSystemAudio() { + systemStream = nil + systemStreamOutput = nil + systemBuffer.removeAll() + systemContinuation?.finish() + systemContinuation = nil + systemLevelContinuation?.finish() + systemLevelContinuation = nil + _systemAudioStream = nil + } + + private func stopSystemCapture() async { + if let stream = systemStream { + try? await stream.stopCapture() + } + } +} + +// MARK: - ScreenCaptureKit Audio Output Handler + +/// Receives audio sample buffers from SCStream and converts them to Float32 arrays. +final class SystemAudioOutputHandler: NSObject, SCStreamOutput, @unchecked Sendable { + + private let onSamples: @Sendable ([Float]) -> Void + + nonisolated init(onSamples: @escaping @Sendable ([Float]) -> Void) { + self.onSamples = onSamples + } + + nonisolated func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) { + guard type == .audio else { return } + guard sampleBuffer.isValid, sampleBuffer.numSamples > 0 else { return } + + guard let blockBuffer = sampleBuffer.dataBuffer else { return } + + var length = 0 + var dataPointer: UnsafeMutablePointer? + let status = CMBlockBufferGetDataPointer(blockBuffer, atOffset: 0, lengthAtOffsetOut: nil, totalLengthOut: &length, dataPointerOut: &dataPointer) + + guard status == noErr, let data = dataPointer, length > 0 else { return } + + let floatCount = length / MemoryLayout.size + guard floatCount > 0 else { return } + + let samples = Array(UnsafeBufferPointer( + start: data.withMemoryRebound(to: Float.self, capacity: floatCount) { $0 }, + count: floatCount + )) + + onSamples(samples) + } +} diff --git a/wispr/Services/MeetingStateManager.swift b/wispr/Services/MeetingStateManager.swift new file mode 100755 index 0000000..e812ac3 --- /dev/null +++ b/wispr/Services/MeetingStateManager.swift @@ -0,0 +1,311 @@ +// +// MeetingStateManager.swift +// wispr +// +// Coordinator for meeting transcription mode. +// Manages audio capture, continuous transcription, and transcript assembly. +// + +import Foundation +import Observation +import AppKit +import UniformTypeIdentifiers +import os + +/// State of the meeting transcription session. +enum MeetingState: Sendable, Equatable { + case idle + case recording + case error(String) +} + +/// Central coordinator for meeting transcription mode. +/// +/// Orchestrates microphone capture, runs continuous chunked transcription, +/// and assembles a timestamped transcript. +/// +/// Note: Currently captures microphone only. System audio capture (for remote +/// meeting participants) requires Screen Recording permission which is +/// incompatible with App Sandbox. Speaker labels default to "You" for all +/// entries until system audio support is added. +@MainActor +@Observable +final class MeetingStateManager { + + // MARK: - Published State + + /// Current meeting state. + var meetingState: MeetingState = .idle + + /// The live transcript being built. + var transcript: MeetingTranscript = MeetingTranscript() + + /// Audio level from microphone (0.0–1.0) for UI visualization. + var micLevel: Float = 0 + + /// Audio level from system audio (0.0–1.0) for UI visualization. + /// Currently always 0 — system audio capture requires Screen Recording + /// permission which is incompatible with App Sandbox. + var systemLevel: Float = 0 + + /// Error message, if any. + var errorMessage: String? + + /// Whether the meeting window should be visible. + var isWindowVisible: Bool = false + + /// Timer display string. + var elapsedTime: String = "0:00" + + // MARK: - Dependencies + + private let meetingAudioEngine: MeetingAudioEngine + private let transcriptionEngine: any TranscriptionEngine + private let settingsStore: SettingsStore + + // MARK: - Tasks + + private var micTranscriptionTask: Task? + private var systemTranscriptionTask: Task? + private var micLevelTask: Task? + private var systemLevelTask: Task? + private var timerTask: Task? + + // MARK: - Initialization + + init( + meetingAudioEngine: MeetingAudioEngine, + transcriptionEngine: any TranscriptionEngine, + settingsStore: SettingsStore + ) { + self.meetingAudioEngine = meetingAudioEngine + self.transcriptionEngine = transcriptionEngine + self.settingsStore = settingsStore + } + + // MARK: - Meeting Lifecycle + + /// Starts a new meeting transcription session. + func startMeeting() async { + guard meetingState == .idle else { return } + + Log.stateManager.debug("MeetingStateManager — starting meeting") + + transcript = MeetingTranscript() + errorMessage = nil + + do { + let (micLevels, systemLevels) = try await meetingAudioEngine.startCapture() + + meetingState = .recording + isWindowVisible = true + + // Start consuming audio levels for UI + startMicLevelConsumption(micLevels) + startSystemLevelConsumption(systemLevels) + + // Start parallel transcription on both audio streams + startMicTranscription() + startSystemTranscription() + + // Start elapsed time timer + startTimer() + + } catch { + Log.stateManager.error("MeetingStateManager — failed to start: \(error.localizedDescription)") + await handleError("Failed to start meeting capture: \(error.localizedDescription)") + } + } + + /// Stops the meeting and finalizes the transcript. + func stopMeeting() async { + guard meetingState == .recording else { return } + + Log.stateManager.debug("MeetingStateManager — stopping meeting") + + // Flush remaining audio buffers before stopping + await meetingAudioEngine.flushBuffers() + + // Give transcription task a moment to process final chunks + try? await Task.sleep(for: .milliseconds(500)) + + // Cancel all tasks + micTranscriptionTask?.cancel() + systemTranscriptionTask?.cancel() + micLevelTask?.cancel() + systemLevelTask?.cancel() + timerTask?.cancel() + + micTranscriptionTask = nil + systemTranscriptionTask = nil + micLevelTask = nil + systemLevelTask = nil + timerTask = nil + + await meetingAudioEngine.stopCapture() + + meetingState = .idle + micLevel = 0 + systemLevel = 0 + } + + /// Toggles between recording and stopped states. + func toggleMeeting() async { + switch meetingState { + case .idle: + await startMeeting() + case .recording: + await stopMeeting() + case .error: + meetingState = .idle + errorMessage = nil + } + } + + /// Copies the transcript to the clipboard. + func copyTranscript() { + let text = transcript.asPlainText() + guard !text.isEmpty else { return } + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + } + + /// Saves the transcript to a text file via save panel. + func exportTranscript() { + let text = transcript.asPlainText() + guard !text.isEmpty else { return } + + let panel = NSSavePanel() + panel.allowedContentTypes = [.plainText] + panel.nameFieldStringValue = "meeting-transcript-\(formattedDate()).txt" + panel.canCreateDirectories = true + + if panel.runModal() == .OK, let url = panel.url { + do { + try text.write(to: url, atomically: true, encoding: .utf8) + Log.stateManager.debug("MeetingStateManager — transcript exported to \(url.path)") + } catch { + Log.stateManager.error("MeetingStateManager — export failed: \(error.localizedDescription)") + } + } + } + + // MARK: - Transcription + + private func startMicTranscription() { + micTranscriptionTask = Task { [weak self] in + guard let self else { return } + let audioStream = await self.meetingAudioEngine.micAudioStream + let language = self.settingsStore.languageMode + + for await chunk in audioStream { + guard !Task.isCancelled else { break } + guard chunk.count >= 8000 else { continue } + + do { + let result = try await self.transcriptionEngine.transcribe(chunk, language: language) + let text = result.text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { continue } + + await MainActor.run { + self.transcript.entries.append( + MeetingTranscriptEntry(speaker: .you, text: text) + ) + } + } catch { + if case WisprError.emptyTranscription = error { continue } + Log.stateManager.warning("MeetingStateManager — mic transcription error: \(error.localizedDescription)") + } + } + } + } + + private func startSystemTranscription() { + systemTranscriptionTask = Task { [weak self] in + guard let self else { return } + let audioStream = await self.meetingAudioEngine.systemAudioStream + let language = self.settingsStore.languageMode + + for await chunk in audioStream { + guard !Task.isCancelled else { break } + guard chunk.count >= 8000 else { continue } + + do { + let result = try await self.transcriptionEngine.transcribe(chunk, language: language) + let text = result.text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { continue } + + await MainActor.run { + self.transcript.entries.append( + MeetingTranscriptEntry(speaker: .others, text: text) + ) + } + } catch { + if case WisprError.emptyTranscription = error { continue } + Log.stateManager.warning("MeetingStateManager — system transcription error: \(error.localizedDescription)") + } + } + } + } + + // MARK: - Audio Level Consumption + + private func startMicLevelConsumption(_ stream: AsyncStream) { + micLevelTask = Task { [weak self] in + for await level in stream { + guard !Task.isCancelled else { break } + await MainActor.run { + self?.micLevel = level + } + } + } + } + + private func startSystemLevelConsumption(_ stream: AsyncStream) { + systemLevelTask = Task { [weak self] in + for await level in stream { + guard !Task.isCancelled else { break } + await MainActor.run { + self?.systemLevel = level + } + } + } + } + + // MARK: - Timer + + private func startTimer() { + timerTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled else { break } + await MainActor.run { + self?.elapsedTime = self?.transcript.formattedDuration ?? "0:00" + } + } + } + } + + // MARK: - Error Handling + + private func handleError(_ message: String) async { + meetingState = .error(message) + errorMessage = message + + // Auto-dismiss after 5 seconds + try? await Task.sleep(for: .seconds(5)) + if case .error = meetingState { + meetingState = .idle + errorMessage = nil + } + } + + // MARK: - Helpers + + private func formattedDate() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd-HHmmss" + return formatter.string(from: Date()) + } +} diff --git a/wispr/Services/ParakeetService.swift b/wispr/Services/ParakeetService.swift old mode 100644 new mode 100755 diff --git a/wispr/Services/PermissionManager.swift b/wispr/Services/PermissionManager.swift old mode 100644 new mode 100755 index 219b263..367b387 --- a/wispr/Services/PermissionManager.swift +++ b/wispr/Services/PermissionManager.swift @@ -2,6 +2,7 @@ import Foundation import AVFAudio import ApplicationServices import AppKit +import ScreenCaptureKit /// Manages microphone and accessibility permissions for the Wispr application. /// This class checks permission status, requests permissions, and monitors changes. @@ -86,7 +87,24 @@ final class PermissionManager { guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone") else { return } NSWorkspace.shared.open(url) } - + + /// Opens System Settings to the Screen Recording privacy pane. + /// Required for meeting mode system audio capture via ScreenCaptureKit. + func openScreenRecordingSettings() { + guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") else { return } + NSWorkspace.shared.open(url) + } + + /// Checks if Screen Recording permission is available by attempting to enumerate shareable content. + func checkScreenRecordingPermission() async -> Bool { + do { + _ = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: false) + return true + } catch { + return false + } + } + // MARK: - Permission Monitoring /// Polls for permission changes every 2 seconds. diff --git a/wispr/Services/SettingsStore.swift b/wispr/Services/SettingsStore.swift old mode 100644 new mode 100755 index d89b319..36ce834 --- a/wispr/Services/SettingsStore.swift +++ b/wispr/Services/SettingsStore.swift @@ -118,6 +118,12 @@ final class SettingsStore { } } + // MARK: - App Variant + + /// When true, the app is running as the meeting transcription variant. + /// Disables features that require accessibility permission (text insertion at cursor). + var isMeetingMode: Bool = false + // MARK: - UserDefaults Keys private enum Keys { static let hotkeyKeyCode = "hotkeyKeyCode" diff --git a/wispr/Services/SoundFeedbackService.swift b/wispr/Services/SoundFeedbackService.swift old mode 100644 new mode 100755 diff --git a/wispr/Services/StateManager.swift b/wispr/Services/StateManager.swift old mode 100644 new mode 100755 diff --git a/wispr/Services/TextCorrectionService.swift b/wispr/Services/TextCorrectionService.swift old mode 100644 new mode 100755 diff --git a/wispr/Services/TextInsertionService.swift b/wispr/Services/TextInsertionService.swift old mode 100644 new mode 100755 diff --git a/wispr/Services/TranscriptionEngine.swift b/wispr/Services/TranscriptionEngine.swift old mode 100644 new mode 100755 diff --git a/wispr/Services/UpdateChecker.swift b/wispr/Services/UpdateChecker.swift old mode 100644 new mode 100755 diff --git a/wispr/Services/WhisperService.swift b/wispr/Services/WhisperService.swift old mode 100644 new mode 100755 diff --git a/wispr/UI/CLIInstallDialog.swift b/wispr/UI/CLIInstallDialog.swift old mode 100644 new mode 100755 diff --git a/wispr/UI/Components/ModelRowView.swift b/wispr/UI/Components/ModelRowView.swift old mode 100644 new mode 100755 diff --git a/wispr/UI/Components/SuffixEditorView.swift b/wispr/UI/Components/SuffixEditorView.swift old mode 100644 new mode 100755 diff --git a/wispr/UI/Meeting/MeetingTranscriptView.swift b/wispr/UI/Meeting/MeetingTranscriptView.swift new file mode 100755 index 0000000..639bac7 --- /dev/null +++ b/wispr/UI/Meeting/MeetingTranscriptView.swift @@ -0,0 +1,235 @@ +// +// MeetingTranscriptView.swift +// wispr +// +// Scrolling transcript view with speaker labels and timestamps. +// Displayed inside the MeetingWindowPanel. +// + +import SwiftUI + +/// The main content view for the meeting transcription window. +/// +/// Shows recording controls at the top, a scrolling transcript in the middle, +/// and export actions at the bottom. +struct MeetingTranscriptView: View { + @Environment(MeetingStateManager.self) private var meetingState: MeetingStateManager + @Environment(UIThemeEngine.self) private var theme: UIThemeEngine + + @State private var scrollProxy: ScrollViewProxy? + + var body: some View { + VStack(spacing: 0) { + // Header with controls + headerBar + + Divider() + + // Transcript area + if meetingState.transcript.entries.isEmpty { + emptyState + } else { + transcriptList + } + + Divider() + + // Footer with export actions + footerBar + } + .frame(minWidth: 360, minHeight: 400) + } + + // MARK: - Header + + private var headerBar: some View { + HStack(spacing: 12) { + // Record/Stop button + Button { + Task { await meetingState.toggleMeeting() } + } label: { + HStack(spacing: 6) { + Image(systemName: meetingState.meetingState == .recording + ? SFSymbols.stopFill + : SFSymbols.recordingMicrophone) + .font(.body) + + Text(meetingState.meetingState == .recording ? "Stop" : "Start Meeting") + .font(.callout.weight(.medium)) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + meetingState.meetingState == .recording + ? Color.red.opacity(0.15) + : theme.accentColor.opacity(0.15) + ) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + .buttonStyle(.plain) + + Spacer() + + // Audio level indicators + if meetingState.meetingState == .recording { + HStack(spacing: 8) { + audioLevelIndicator(label: "You", level: meetingState.micLevel, color: .blue) + audioLevelIndicator(label: "Others", level: meetingState.systemLevel, color: .green) + } + + // Timer + Text(meetingState.elapsedTime) + .font(.callout.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + + private func audioLevelIndicator(label: String, level: Float, color: Color) -> some View { + HStack(spacing: 4) { + Circle() + .fill(color.opacity(Double(max(level, 0.2)))) + .frame(width: 8, height: 8) + + Text(label) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + // MARK: - Empty State + + private var emptyState: some View { + VStack(spacing: 12) { + Spacer() + + Image(systemName: SFSymbols.waveform) + .font(.system(size: 40)) + .foregroundStyle(.tertiary) + + if meetingState.meetingState == .recording { + Text("Listening…") + .font(.title3) + .foregroundStyle(.secondary) + + Text("Speak or play meeting audio — transcription will appear here") + .font(.callout) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + } else { + Text("Meeting Transcription") + .font(.title3) + .foregroundStyle(.secondary) + + Text("Press Start to capture your microphone and system audio.\nSpeakers are separated automatically.") + .font(.callout) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + } + + Spacer() + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Transcript List + + private var transcriptList: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 8) { + ForEach(meetingState.transcript.entries) { entry in + transcriptRow(entry) + .id(entry.id) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + .onChange(of: meetingState.transcript.entries.count) { _, _ in + // Auto-scroll to latest entry + if let lastEntry = meetingState.transcript.entries.last { + withAnimation(.easeOut(duration: 0.2)) { + proxy.scrollTo(lastEntry.id, anchor: .bottom) + } + } + } + } + } + + private func transcriptRow(_ entry: MeetingTranscriptEntry) -> some View { + HStack(alignment: .top, spacing: 8) { + // Timestamp + Text(formatTime(entry.timestamp)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.tertiary) + .frame(width: 50, alignment: .trailing) + + // Speaker badge + Text(entry.speaker.rawValue) + .font(.caption.weight(.semibold)) + .foregroundStyle(entry.speaker == .you ? .blue : .green) + .frame(width: 48, alignment: .leading) + + // Text + Text(entry.text) + .font(.callout) + .foregroundStyle(theme.primaryTextColor) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.vertical, 4) + } + + private func formatTime(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss" + return formatter.string(from: date) + } + + // MARK: - Footer + + private var footerBar: some View { + HStack(spacing: 12) { + // Entry count + Text("\(meetingState.transcript.entries.count) entries") + .font(.caption) + .foregroundStyle(.tertiary) + + Spacer() + + // Copy button + Button { + meetingState.copyTranscript() + } label: { + Label("Copy", systemImage: SFSymbols.clipboard) + .font(.callout) + } + .buttonStyle(.plain) + .disabled(meetingState.transcript.entries.isEmpty) + + // Export button + Button { + meetingState.exportTranscript() + } label: { + Label("Export", systemImage: SFSymbols.download) + .font(.callout) + } + .buttonStyle(.plain) + .disabled(meetingState.transcript.entries.isEmpty) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } +} + +// MARK: - SF Symbols Extensions + +private extension SFSymbols { + static let stopFill = "stop.fill" + static let waveform = "waveform" + static let clipboard = "doc.on.doc" +} diff --git a/wispr/UI/Meeting/MeetingWindowPanel.swift b/wispr/UI/Meeting/MeetingWindowPanel.swift new file mode 100755 index 0000000..917f5a3 --- /dev/null +++ b/wispr/UI/Meeting/MeetingWindowPanel.swift @@ -0,0 +1,106 @@ +// +// MeetingWindowPanel.swift +// wispr +// +// Floating NSPanel that hosts the MeetingTranscriptView. +// Similar to RecordingOverlayPanel but larger and resizable. +// + +import AppKit +import SwiftUI + +/// A floating `NSPanel` that hosts the meeting transcription UI. +/// +/// Unlike the compact RecordingOverlayPanel, this is a resizable window +/// with title bar, close button, and full transcript view. +@MainActor +final class MeetingWindowPanel { + + // MARK: - Properties + + private var panel: NSPanel? + private let meetingStateManager: MeetingStateManager + private let settingsStore: SettingsStore + private let themeEngine: UIThemeEngine + + /// Whether the panel is currently visible. + private(set) var isVisible = false + + // MARK: - Initialization + + init( + meetingStateManager: MeetingStateManager, + settingsStore: SettingsStore, + themeEngine: UIThemeEngine + ) { + self.meetingStateManager = meetingStateManager + self.settingsStore = settingsStore + self.themeEngine = themeEngine + } + + // MARK: - Panel Lifecycle + + /// Shows the meeting window. + func show() { + if panel == nil { + createPanel() + } + + guard let panel, !isVisible else { return } + + positionPanel(panel) + panel.makeKeyAndOrderFront(nil) + panel.orderFrontRegardless() + isVisible = true + } + + /// Dismisses the meeting window. + func dismiss() { + guard let panel, isVisible else { return } + panel.orderOut(nil) + isVisible = false + } + + // MARK: - Private Helpers + + private func createPanel() { + let transcriptView = MeetingTranscriptView() + .environment(meetingStateManager) + .environment(settingsStore) + .environment(themeEngine) + + let hostingView = NSHostingView(rootView: transcriptView) + + let panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 520), + styleMask: [.titled, .closable, .resizable, .nonactivatingPanel, .utilityWindow], + backing: .buffered, + defer: false + ) + + panel.title = "Meeting Transcription" + panel.isFloatingPanel = true + panel.level = .floating + panel.isOpaque = true + panel.hasShadow = true + panel.isMovableByWindowBackground = false + panel.hidesOnDeactivate = false + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + panel.contentView = hostingView + panel.minSize = NSSize(width: 320, height: 300) + panel.isReleasedWhenClosed = false + + self.panel = panel + } + + private func positionPanel(_ panel: NSPanel) { + guard let screen = NSScreen.main else { return } + let screenFrame = screen.visibleFrame + let panelSize = panel.frame.size + + // Position in the bottom-right corner with some padding + let x = screenFrame.maxX - panelSize.width - 20 + let y = screenFrame.minY + 20 + panel.setFrameOrigin(NSPoint(x: x, y: y)) + } +} diff --git a/wispr/UI/MenuBarController.swift b/wispr/UI/MenuBarController.swift old mode 100644 new mode 100755 index 0912d67..83933d3 --- a/wispr/UI/MenuBarController.swift +++ b/wispr/UI/MenuBarController.swift @@ -66,6 +66,9 @@ final class MenuBarController { /// Update checker for surfacing new versions. private let updateChecker: UpdateChecker + /// Meeting state manager for meeting transcription mode. + private let meetingStateManager: MeetingStateManager + /// Observation tracking for state changes. private var observationTask: Task? @@ -117,7 +120,8 @@ final class MenuBarController { whisperService: any TranscriptionEngine, permissionManager: PermissionManager, textCorrectionService: TextCorrectionService, - updateChecker: UpdateChecker + updateChecker: UpdateChecker, + meetingStateManager: MeetingStateManager ) { self.stateManager = stateManager self.settingsStore = settingsStore @@ -128,6 +132,7 @@ final class MenuBarController { self.permissionManager = permissionManager self.textCorrectionService = textCorrectionService self.updateChecker = updateChecker + self.meetingStateManager = meetingStateManager // Requirement 5.1: Create NSStatusItem in the menu bar self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) @@ -173,6 +178,18 @@ final class MenuBarController { updateRecordingMenuItem() menu.addItem(recordingMenuItem) + // Meeting Mode + let meetingItem = NSMenuItem( + title: "Meeting Transcription…", + action: #selector(MenuBarActionHandler.toggleMeetingMode(_:)), + keyEquivalent: "" + ) + meetingItem.image = NSImage( + systemSymbolName: "person.2.wave.2", + accessibilityDescription: "Meeting Transcription" + ) + menu.addItem(meetingItem) + menu.addItem(NSMenuItem.separator()) // Language Selection @@ -647,6 +664,11 @@ final class MenuBarController { stopObserving() NSApp.terminate(nil) } + + /// Toggles the meeting transcription window. + func toggleMeetingMode() { + meetingStateManager.isWindowVisible = true + } } // MARK: - Menu Open Delegate @@ -712,6 +734,11 @@ final class MenuBarActionHandler: NSObject { menuBarController?.showCLIInstallDialog() } + @MainActor + @objc func toggleMeetingMode(_ sender: NSMenuItem) { + menuBarController?.toggleMeetingMode() + } + @MainActor @objc func quitApp(_ sender: NSMenuItem) { menuBarController?.quitApp() diff --git a/wispr/UI/ModelDownloadProgressView.swift b/wispr/UI/ModelDownloadProgressView.swift old mode 100644 new mode 100755 diff --git a/wispr/UI/ModelManagementView.swift b/wispr/UI/ModelManagementView.swift old mode 100644 new mode 100755 diff --git a/wispr/UI/Onboarding/OnboardingAccessibilityStep.swift b/wispr/UI/Onboarding/OnboardingAccessibilityStep.swift old mode 100644 new mode 100755 diff --git a/wispr/UI/Onboarding/OnboardingCompletionStep.swift b/wispr/UI/Onboarding/OnboardingCompletionStep.swift old mode 100644 new mode 100755 diff --git a/wispr/UI/Onboarding/OnboardingComponents.swift b/wispr/UI/Onboarding/OnboardingComponents.swift old mode 100644 new mode 100755 diff --git a/wispr/UI/Onboarding/OnboardingFlow.swift b/wispr/UI/Onboarding/OnboardingFlow.swift old mode 100644 new mode 100755 index fa103b8..53a2370 --- a/wispr/UI/Onboarding/OnboardingFlow.swift +++ b/wispr/UI/Onboarding/OnboardingFlow.swift @@ -103,19 +103,32 @@ struct OnboardingFlow: View { // MARK: - Step Indicator + /// The steps to display, excluding accessibility in meeting mode. + private var visibleSteps: [OnboardingStep] { + if settingsStore.isMeetingMode { + return OnboardingStep.allCases.filter { $0 != .accessibilityPermission } + } + return OnboardingStep.allCases + } + + /// The 1-based index of the current step within the visible steps. + private var currentStepNumber: Int { + (visibleSteps.firstIndex(of: currentStep) ?? 0) + 1 + } + /// Displays connected dots representing each step with the current step highlighted. private var stepIndicator: some View { VStack(spacing: 10) { - Text("Step \(currentStep.rawValue + 1) of \(OnboardingStep.allCases.count)") + Text("Step \(currentStepNumber) of \(visibleSteps.count)") .font(.caption) .fontWeight(.medium) .foregroundStyle(theme.secondaryTextColor) HStack(spacing: 0) { - ForEach(OnboardingStep.allCases, id: \.rawValue) { step in + ForEach(Array(visibleSteps.enumerated()), id: \.element.rawValue) { index, step in stepDot(for: step) - if step != .completion { + if index < visibleSteps.count - 1 { Capsule() .fill(step.rawValue < currentStep.rawValue ? theme.successColor.opacity(0.5) @@ -126,7 +139,7 @@ struct OnboardingFlow: View { } } .accessibilityElement(children: .combine) - .accessibilityLabel("Step \(currentStep.rawValue + 1) of \(OnboardingStep.allCases.count)") + .accessibilityLabel("Step \(currentStepNumber) of \(visibleSteps.count)") } /// A single dot in the step indicator with a ring highlight for the current step. @@ -276,7 +289,8 @@ struct OnboardingFlow: View { case .microphonePermission: return permissionManager.microphoneStatus == .authorized case .accessibilityPermission: - return permissionManager.accessibilityStatus == .authorized + // Meeting mode doesn't need accessibility (no text insertion at cursor) + return settingsStore.isMeetingMode || permissionManager.accessibilityStatus == .authorized case .modelSelection: // Req 13.8: Continue disabled until download completes successfully return downloadComplete @@ -291,13 +305,23 @@ struct OnboardingFlow: View { // MARK: - Navigation private func goForward() { - guard let nextStep = OnboardingStep(rawValue: currentStep.rawValue + 1) else { return } + var nextRaw = currentStep.rawValue + 1 + // Skip accessibility step in meeting mode + if settingsStore.isMeetingMode, nextRaw == OnboardingStep.accessibilityPermission.rawValue { + nextRaw += 1 + } + guard let nextStep = OnboardingStep(rawValue: nextRaw) else { return } transitionDirection = .forward currentStep = nextStep } private func goBack() { - guard let previousStep = OnboardingStep(rawValue: currentStep.rawValue - 1) else { return } + var prevRaw = currentStep.rawValue - 1 + // Skip accessibility step in meeting mode + if settingsStore.isMeetingMode, prevRaw == OnboardingStep.accessibilityPermission.rawValue { + prevRaw -= 1 + } + guard let previousStep = OnboardingStep(rawValue: prevRaw) else { return } transitionDirection = .backward currentStep = previousStep } @@ -367,7 +391,8 @@ struct OnboardingFlow: View { case .microphonePermission: return permissionManager.microphoneStatus == .authorized case .accessibilityPermission: - return permissionManager.accessibilityStatus == .authorized + // Meeting mode doesn't need accessibility permission + return settingsStore.isMeetingMode || permissionManager.accessibilityStatus == .authorized case .modelSelection: // Only skip if the active model is Parakeet V3 (the auto-download target). // A leftover Whisper model name shouldn't skip this step. diff --git a/wispr/UI/Onboarding/OnboardingMicPermissionStep.swift b/wispr/UI/Onboarding/OnboardingMicPermissionStep.swift old mode 100644 new mode 100755 diff --git a/wispr/UI/Onboarding/OnboardingModelSelectionStep.swift b/wispr/UI/Onboarding/OnboardingModelSelectionStep.swift old mode 100644 new mode 100755 diff --git a/wispr/UI/Onboarding/OnboardingPreview.swift b/wispr/UI/Onboarding/OnboardingPreview.swift old mode 100644 new mode 100755 diff --git a/wispr/UI/Onboarding/OnboardingTestDictationStep.swift b/wispr/UI/Onboarding/OnboardingTestDictationStep.swift old mode 100644 new mode 100755 diff --git a/wispr/UI/Onboarding/OnboardingWelcomeStep.swift b/wispr/UI/Onboarding/OnboardingWelcomeStep.swift old mode 100644 new mode 100755 diff --git a/wispr/UI/RecordingOverlayPanel.swift b/wispr/UI/RecordingOverlayPanel.swift old mode 100644 new mode 100755 diff --git a/wispr/UI/RecordingOverlayView.swift b/wispr/UI/RecordingOverlayView.swift old mode 100644 new mode 100755 diff --git a/wispr/UI/Settings/HotkeyRecorderView.swift b/wispr/UI/Settings/HotkeyRecorderView.swift old mode 100644 new mode 100755 diff --git a/wispr/UI/Settings/KeyCodeMapping.swift b/wispr/UI/Settings/KeyCodeMapping.swift old mode 100644 new mode 100755 diff --git a/wispr/UI/Settings/SettingsView.swift b/wispr/UI/Settings/SettingsView.swift old mode 100644 new mode 100755 diff --git a/wispr/UI/Settings/SupportedLanguage.swift b/wispr/UI/Settings/SupportedLanguage.swift old mode 100644 new mode 100755 diff --git a/wispr/Utilities/FillerWordCleaner.swift b/wispr/Utilities/FillerWordCleaner.swift old mode 100644 new mode 100755 diff --git a/wispr/Utilities/Logger.swift b/wispr/Utilities/Logger.swift old mode 100644 new mode 100755 diff --git a/wispr/Utilities/ModelPaths.swift b/wispr/Utilities/ModelPaths.swift old mode 100644 new mode 100755 index 8e1b460..68862cc --- a/wispr/Utilities/ModelPaths.swift +++ b/wispr/Utilities/ModelPaths.swift @@ -38,7 +38,7 @@ enum ModelPaths { // macOS sets APP_SANDBOX_CONTAINER_ID for sandboxed processes. let isSandboxed = ProcessInfo.processInfo.environment["APP_SANDBOX_CONTAINER_ID"] != nil if isSandboxed { - // Inside the sandbox (GUI app): FileManager automatically redirects + // Inside the sandbox: FileManager automatically redirects // to ~/Library/Containers//Data/Library/Application Support/ guard let appSupport = FileManager.default.urls( for: .applicationSupportDirectory, @@ -46,7 +46,24 @@ enum ModelPaths { ).first else { fatalError("Application Support directory unavailable — cannot store models") } - return appSupport.appendingPathComponent("wispr", isDirectory: true) + let ownPath = appSupport.appendingPathComponent("wispr", isDirectory: true) + + // If this app's container already has models, use it. + if FileManager.default.fileExists(atPath: ownPath.path) { + return ownPath + } + + // Otherwise, check the original Wispr app's container for shared models. + let home = FileManager.default.homeDirectoryForCurrentUser + let originalContainer = home.appendingPathComponent( + "Library/Containers/com.stormacq.mac.wispr/Data/Library/Application Support/wispr" + ) + if FileManager.default.fileExists(atPath: originalContainer.path) { + return originalContainer + } + + // Neither exists yet — use own container (will be created on first download). + return ownPath } // Outside the sandbox (wispr-cli): read models from the GUI app's diff --git a/wispr/Utilities/PreviewHelpers.swift b/wispr/Utilities/PreviewHelpers.swift old mode 100644 new mode 100755 diff --git a/wispr/Utilities/SFSymbols.swift b/wispr/Utilities/SFSymbols.swift old mode 100644 new mode 100755 diff --git a/wispr/Utilities/UIThemeEngine.swift b/wispr/Utilities/UIThemeEngine.swift old mode 100644 new mode 100755 diff --git a/wispr/wisprApp.swift b/wispr/wisprApp.swift old mode 100644 new mode 100755 index d84aa5a..70eaed1 --- a/wispr/wisprApp.swift +++ b/wispr/wisprApp.swift @@ -91,18 +91,30 @@ final class WisprAppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate /// Checks GitHub Releases for a newer app version. let updateChecker = UpdateChecker() + /// Meeting audio engine for dual capture (mic + system audio). + let meetingAudioEngine = MeetingAudioEngine() + /// Central state coordinator — depends on all services above. private(set) var stateManager: StateManager? + /// Meeting state manager for meeting transcription mode. + private(set) var meetingStateManager: MeetingStateManager? + /// Menu bar status item controller. private var menuBarController: MenuBarController? /// Recording overlay floating panel. private var overlayPanel: RecordingOverlayPanel? + /// Meeting transcription floating window. + private var meetingPanel: MeetingWindowPanel? + /// Task observing StateManager.appState to drive overlay visibility. private var overlayObservationTask: Task? + /// Task observing MeetingStateManager to drive meeting window visibility. + private var meetingObservationTask: Task? + /// Task observing hotkey settings changes to re-register the global hotkey. private var hotkeyObservationTask: Task? @@ -154,6 +166,14 @@ final class WisprAppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate Log.app.debug("bootstrap — StateManager initialized") + // Create meeting state manager + let msm = MeetingStateManager( + meetingAudioEngine: meetingAudioEngine, + transcriptionEngine: whisperService, + settingsStore: settingsStore + ) + meetingStateManager = msm + // Create menu bar controller (Req 5.1) menuBarController = MenuBarController( stateManager: sm, @@ -164,7 +184,8 @@ final class WisprAppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate whisperService: whisperService, permissionManager: permissionManager, textCorrectionService: textCorrectionService, - updateChecker: updateChecker + updateChecker: updateChecker, + meetingStateManager: msm ) // Create recording overlay panel @@ -174,6 +195,13 @@ final class WisprAppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate themeEngine: themeEngine ) + // Create meeting transcription panel + meetingPanel = MeetingWindowPanel( + meetingStateManager: msm, + settingsStore: settingsStore, + themeEngine: themeEngine + ) + // Register the persisted hotkey (Req 1.3) do { try hotkeyMonitor.register( @@ -190,6 +218,9 @@ final class WisprAppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate // Start observing state to drive overlay visibility (Req 9.1, 9.3, 9.4, 9.5) startOverlayObservation(stateManager: sm) + // Start observing meeting state to drive meeting window visibility + startMeetingObservation(meetingStateManager: msm) + // Re-register hotkey whenever the user changes it in settings startHotkeyObservation() @@ -255,13 +286,17 @@ final class WisprAppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate func applicationWillTerminate(_ notification: Notification) { overlayObservationTask?.cancel() + meetingObservationTask?.cancel() hotkeyObservationTask?.cancel() permissionMonitoringTask?.cancel() updateCheckTask?.cancel() + // Stop any active meeting session + if let msm = meetingStateManager { + Task { await msm.stopMeeting() } + } + // Force UserDefaults to flush to disk before the process exits. - // Without this, in-memory changes (e.g. onboardingCompleted) can be - // lost if the app is terminated quickly (Xcode stop, NSApp.terminate). settingsStore.flush() } @@ -396,4 +431,36 @@ final class WisprAppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate } } } + + // MARK: - Meeting Window Observation + + /// Observes `MeetingStateManager.isWindowVisible` and shows/dismisses the meeting panel. + private func startMeetingObservation(meetingStateManager msm: MeetingStateManager) { + meetingObservationTask = Task { [weak self] in + guard let self else { return } + while !Task.isCancelled { + let shouldShow = msm.isWindowVisible + + if shouldShow { + if let panel = self.meetingPanel, !panel.isVisible { + Log.app.debug("meetingObservation — showing meeting window") + panel.show() + } + } else { + if let panel = self.meetingPanel, panel.isVisible { + Log.app.debug("meetingObservation — dismissing meeting window") + panel.dismiss() + } + } + + await withCheckedContinuation { continuation in + withObservationTracking { + _ = msm.isWindowVisible + } onChange: { + continuation.resume() + } + } + } + } + } } diff --git a/wisprTests/AccessibilityTests.swift b/wisprTests/AccessibilityTests.swift old mode 100644 new mode 100755 diff --git a/wisprTests/AppLifecycleIntegrationTests.swift b/wisprTests/AppLifecycleIntegrationTests.swift old mode 100644 new mode 100755 diff --git a/wisprTests/AudioEngineTests.swift b/wisprTests/AudioEngineTests.swift old mode 100644 new mode 100755 diff --git a/wisprTests/CompositeTranscriptionEngineTests.swift b/wisprTests/CompositeTranscriptionEngineTests.swift old mode 100644 new mode 100755 diff --git a/wisprTests/EndToEndIntegrationTests.swift b/wisprTests/EndToEndIntegrationTests.swift old mode 100644 new mode 100755 diff --git a/wisprTests/ErrorRecoveryTests.swift b/wisprTests/ErrorRecoveryTests.swift old mode 100644 new mode 100755 diff --git a/wisprTests/FillerWordCleanerTests.swift b/wisprTests/FillerWordCleanerTests.swift old mode 100644 new mode 100755 diff --git a/wisprTests/HotkeyMonitorTests.swift b/wisprTests/HotkeyMonitorTests.swift old mode 100644 new mode 100755 diff --git a/wisprTests/MenuBarControllerTests.swift b/wisprTests/MenuBarControllerTests.swift old mode 100644 new mode 100755 diff --git a/wisprTests/ModelManagementViewTests.swift b/wisprTests/ModelManagementViewTests.swift old mode 100644 new mode 100755 diff --git a/wisprTests/MultiLanguageTests.swift b/wisprTests/MultiLanguageTests.swift old mode 100644 new mode 100755 diff --git a/wisprTests/OnboardingFlowTests.swift b/wisprTests/OnboardingFlowTests.swift old mode 100644 new mode 100755 diff --git a/wisprTests/PermissionManagerTests.swift b/wisprTests/PermissionManagerTests.swift old mode 100644 new mode 100755 diff --git a/wisprTests/RecordingOverlayTests.swift b/wisprTests/RecordingOverlayTests.swift old mode 100644 new mode 100755 diff --git a/wisprTests/SettingsStoreTests.swift b/wisprTests/SettingsStoreTests.swift old mode 100644 new mode 100755 diff --git a/wisprTests/SettingsViewTests.swift b/wisprTests/SettingsViewTests.swift old mode 100644 new mode 100755 diff --git a/wisprTests/SoundFeedbackServiceTests.swift b/wisprTests/SoundFeedbackServiceTests.swift old mode 100644 new mode 100755 diff --git a/wisprTests/StateManagerTests.swift b/wisprTests/StateManagerTests.swift old mode 100644 new mode 100755 diff --git a/wisprTests/TextCorrectionTokenLeakTests.swift b/wisprTests/TextCorrectionTokenLeakTests.swift old mode 100644 new mode 100755 diff --git a/wisprTests/TextInsertionServiceTests.swift b/wisprTests/TextInsertionServiceTests.swift old mode 100644 new mode 100755 diff --git a/wisprTests/UIThemeEngineTests.swift b/wisprTests/UIThemeEngineTests.swift old mode 100644 new mode 100755 diff --git a/wisprTests/UpdateCheckerTests.swift b/wisprTests/UpdateCheckerTests.swift old mode 100644 new mode 100755 diff --git a/wisprTests/WhisperServiceTests.swift b/wisprTests/WhisperServiceTests.swift old mode 100644 new mode 100755 diff --git a/wisprTests/wisprTests.swift b/wisprTests/wisprTests.swift old mode 100644 new mode 100755 diff --git a/wisprUITests/wisprUITests.swift b/wisprUITests/wisprUITests.swift old mode 100644 new mode 100755 diff --git a/wisprUITests/wisprUITestsLaunchTests.swift b/wisprUITests/wisprUITestsLaunchTests.swift old mode 100644 new mode 100755 From 9a00e55770485fa9edf60bf65c07ab060122ad6a Mon Sep 17 00:00:00 2001 From: Gabriel Bruno <242597616+gbrunoo@users.noreply.github.com> Date: Sat, 2 May 2026 18:30:45 +0200 Subject: [PATCH 2/5] Refactor: Make meeting transcription a separate feature, not a mode - Remove isMeetingMode from SettingsStore - Revert onboarding flow to not skip accessibility permission - Revert app name to 'Wispr' (from 'Wispr Steno') - Revert bundle identifier to com.stormacq.mac.wispr - Revert development team to original - Meeting transcription is now a separate menu action users can trigger on-demand - Users can switch between dictation and meeting transcription dynamically This addresses maintainer feedback: the app should be polyvalent, allowing users to easily use both dictation and meeting transcription without choosing a persistent mode during onboarding. --- wispr.xcodeproj/project.pbxproj | 16 ++++----- wispr/Services/SettingsStore.swift | 4 +-- wispr/UI/Onboarding/OnboardingFlow.swift | 41 +++++------------------- 3 files changed, 17 insertions(+), 44 deletions(-) diff --git a/wispr.xcodeproj/project.pbxproj b/wispr.xcodeproj/project.pbxproj index c712465..86c5358 100755 --- a/wispr.xcodeproj/project.pbxproj +++ b/wispr.xcodeproj/project.pbxproj @@ -530,7 +530,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 260417.274; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = XG5UJZ2PFW; + DEVELOPMENT_TEAM = 56U756R2L2; ENABLE_APP_SANDBOX = YES; ENABLE_ENHANCED_SECURITY = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -539,7 +539,7 @@ ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = YES; ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = "Wispr Steno"; + INFOPLIST_KEY_CFBundleDisplayName = Wispr; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSUIElement = YES; @@ -550,7 +550,7 @@ "@executable_path/../Frameworks", ); MARKETING_VERSION = 1.9.2; - PRODUCT_BUNDLE_IDENTIFIER = com.stormacq.mac.wispr.steno; + PRODUCT_BUNDLE_IDENTIFIER = com.stormacq.mac.wispr; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -576,7 +576,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 260417.274; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = XG5UJZ2PFW; + DEVELOPMENT_TEAM = 56U756R2L2; ENABLE_APP_SANDBOX = YES; ENABLE_ENHANCED_SECURITY = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -585,7 +585,7 @@ ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = YES; ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = "Wispr Steno"; + INFOPLIST_KEY_CFBundleDisplayName = Wispr; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSUIElement = YES; @@ -596,7 +596,7 @@ "@executable_path/../Frameworks", ); MARKETING_VERSION = 1.9.2; - PRODUCT_BUNDLE_IDENTIFIER = com.stormacq.mac.wispr.steno; + PRODUCT_BUNDLE_IDENTIFIER = com.stormacq.mac.wispr; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; REGISTER_APP_GROUPS = YES; @@ -717,7 +717,7 @@ CREATE_INFOPLIST_SECTION_IN_BINARY = YES; CURRENT_PROJECT_VERSION = 260417.274; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = XG5UJZ2PFW; + DEVELOPMENT_TEAM = 56U756R2L2; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; @@ -745,7 +745,7 @@ CREATE_INFOPLIST_SECTION_IN_BINARY = YES; CURRENT_PROJECT_VERSION = 260417.274; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = XG5UJZ2PFW; + DEVELOPMENT_TEAM = 56U756R2L2; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; diff --git a/wispr/Services/SettingsStore.swift b/wispr/Services/SettingsStore.swift index 36ce834..7440557 100755 --- a/wispr/Services/SettingsStore.swift +++ b/wispr/Services/SettingsStore.swift @@ -120,9 +120,7 @@ final class SettingsStore { // MARK: - App Variant - /// When true, the app is running as the meeting transcription variant. - /// Disables features that require accessibility permission (text insertion at cursor). - var isMeetingMode: Bool = false + // MARK: - UserDefaults Keys private enum Keys { diff --git a/wispr/UI/Onboarding/OnboardingFlow.swift b/wispr/UI/Onboarding/OnboardingFlow.swift index 53a2370..311d139 100755 --- a/wispr/UI/Onboarding/OnboardingFlow.swift +++ b/wispr/UI/Onboarding/OnboardingFlow.swift @@ -103,32 +103,19 @@ struct OnboardingFlow: View { // MARK: - Step Indicator - /// The steps to display, excluding accessibility in meeting mode. - private var visibleSteps: [OnboardingStep] { - if settingsStore.isMeetingMode { - return OnboardingStep.allCases.filter { $0 != .accessibilityPermission } - } - return OnboardingStep.allCases - } - - /// The 1-based index of the current step within the visible steps. - private var currentStepNumber: Int { - (visibleSteps.firstIndex(of: currentStep) ?? 0) + 1 - } - /// Displays connected dots representing each step with the current step highlighted. private var stepIndicator: some View { VStack(spacing: 10) { - Text("Step \(currentStepNumber) of \(visibleSteps.count)") + Text("Step \(currentStep.rawValue + 1) of \(OnboardingStep.allCases.count)") .font(.caption) .fontWeight(.medium) .foregroundStyle(theme.secondaryTextColor) HStack(spacing: 0) { - ForEach(Array(visibleSteps.enumerated()), id: \.element.rawValue) { index, step in + ForEach(Array(OnboardingStep.allCases.enumerated()), id: \.element.rawValue) { index, step in stepDot(for: step) - if index < visibleSteps.count - 1 { + if index < OnboardingStep.allCases.count - 1 { Capsule() .fill(step.rawValue < currentStep.rawValue ? theme.successColor.opacity(0.5) @@ -139,7 +126,7 @@ struct OnboardingFlow: View { } } .accessibilityElement(children: .combine) - .accessibilityLabel("Step \(currentStepNumber) of \(visibleSteps.count)") + .accessibilityLabel("Step \(currentStep.rawValue + 1) of \(OnboardingStep.allCases.count)") } /// A single dot in the step indicator with a ring highlight for the current step. @@ -289,8 +276,7 @@ struct OnboardingFlow: View { case .microphonePermission: return permissionManager.microphoneStatus == .authorized case .accessibilityPermission: - // Meeting mode doesn't need accessibility (no text insertion at cursor) - return settingsStore.isMeetingMode || permissionManager.accessibilityStatus == .authorized + return permissionManager.accessibilityStatus == .authorized case .modelSelection: // Req 13.8: Continue disabled until download completes successfully return downloadComplete @@ -305,23 +291,13 @@ struct OnboardingFlow: View { // MARK: - Navigation private func goForward() { - var nextRaw = currentStep.rawValue + 1 - // Skip accessibility step in meeting mode - if settingsStore.isMeetingMode, nextRaw == OnboardingStep.accessibilityPermission.rawValue { - nextRaw += 1 - } - guard let nextStep = OnboardingStep(rawValue: nextRaw) else { return } + guard let nextStep = OnboardingStep(rawValue: currentStep.rawValue + 1) else { return } transitionDirection = .forward currentStep = nextStep } private func goBack() { - var prevRaw = currentStep.rawValue - 1 - // Skip accessibility step in meeting mode - if settingsStore.isMeetingMode, prevRaw == OnboardingStep.accessibilityPermission.rawValue { - prevRaw -= 1 - } - guard let previousStep = OnboardingStep(rawValue: prevRaw) else { return } + guard let previousStep = OnboardingStep(rawValue: currentStep.rawValue - 1) else { return } transitionDirection = .backward currentStep = previousStep } @@ -391,8 +367,7 @@ struct OnboardingFlow: View { case .microphonePermission: return permissionManager.microphoneStatus == .authorized case .accessibilityPermission: - // Meeting mode doesn't need accessibility permission - return settingsStore.isMeetingMode || permissionManager.accessibilityStatus == .authorized + return permissionManager.accessibilityStatus == .authorized case .modelSelection: // Only skip if the active model is Parakeet V3 (the auto-download target). // A leftover Whisper model name shouldn't skip this step. From 203525c28e28a6b7973fc6afe52922f7cc0be025 Mon Sep 17 00:00:00 2001 From: Gabriel Bruno <242597616+gbrunoo@users.noreply.github.com> Date: Sun, 3 May 2026 10:28:26 +0200 Subject: [PATCH 3/5] Address PR feedback: fix permissions, add tests, clean up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix file permissions (100755 → 100644) on all files - Move tasks/todo.md to .kiro/specs/meeting-transcription/design.md - Revert files that should not be in PR: project.pbxproj, OnboardingFlow.swift, .vscode/settings.json, ModelPaths.swift, SettingsStore.swift - Fix MenuBarControllerTests for new meetingStateManager parameter - Add MeetingAudioEngineTests (8 tests): stream behavior, safe no-ops, capture failure handling, double-start guard - Add MeetingStateManagerTests (14 tests): transcript model formatting, state lifecycle, error handling, copy transcript, double-start prevention --- .../specs/meeting-transcription/design.md | 0 .vscode/settings.json | 1 - CLAUDE.md | 0 ExportOptions.plist | 0 ExportOptionsHomebrew.plist | 0 LICENSE | 0 Makefile | 0 README.md | 0 artwork/generate_app_icons.swift | 0 artwork/icon-square.svg | 0 artwork/icon.svg | 0 artwork/prompt.md | 0 artwork/screenshots/main-2880x1800.key | Bin .../main-2880x1800/main-2880x1800.001.png | Bin .../main-2880x1800/main-2880x1800.002.png | Bin .../main-2880x1800/main-2880x1800.003.png | Bin artwork/screenshots/menu.png | Bin artwork/screenshots/model-management.png | Bin artwork/screenshots/settings.png | Bin artwork/svg_to_png.swift | 0 artwork/wispr-banner.png | Bin wispr-cli/WisprCLI.swift | 0 wispr.xcodeproj/project.pbxproj | 2 - .../contents.xcworkspacedata | 0 .../xcshareddata/swiftpm/Package.resolved | 0 .../xcshareddata/xcschemes/wispr.xcscheme | 0 .../AccentColor.colorset/Contents.json | 0 .../AppIcon.appiconset/AppIcon-128x128@1x.png | Bin .../AppIcon.appiconset/AppIcon-128x128@2x.png | Bin .../AppIcon.appiconset/AppIcon-16x16@1x.png | Bin .../AppIcon.appiconset/AppIcon-16x16@2x.png | Bin .../AppIcon.appiconset/AppIcon-256x256@1x.png | Bin .../AppIcon.appiconset/AppIcon-256x256@2x.png | Bin .../AppIcon.appiconset/AppIcon-32x32@1x.png | Bin .../AppIcon.appiconset/AppIcon-32x32@2x.png | Bin .../AppIcon.appiconset/AppIcon-512x512@1x.png | Bin .../AppIcon.appiconset/AppIcon-512x512@2x.png | Bin .../AppIcon.appiconset/Contents.json | 0 wispr/Assets.xcassets/Contents.json | 0 wispr/ContentView.swift | 0 wispr/Models/AppStateType.swift | 0 wispr/Models/AppUpdateInfo.swift | 0 wispr/Models/AudioInputDevice.swift | 0 wispr/Models/CorrectionStyle.swift | 0 wispr/Models/DownloadProgress.swift | 0 wispr/Models/MeetingTranscript.swift | 0 wispr/Models/ModelInfo.swift | 0 wispr/Models/ModelStatus.swift | 0 wispr/Models/OnboardingStep.swift | 0 wispr/Models/PermissionStatus.swift | 0 wispr/Models/SemanticVersion.swift | 0 wispr/Models/TranscriptionLanguage.swift | 0 wispr/Models/TranscriptionResult.swift | 0 wispr/Models/WisprError.swift | 0 wispr/Resources/Sounds/RecordingStarted.aiff | Bin wispr/Resources/Sounds/RecordingStopped.aiff | Bin wispr/Services/AudioEngine.swift | 0 wispr/Services/AudioFileDecoder.swift | 0 .../CompositeTranscriptionEngine.swift | 0 wispr/Services/HotkeyMonitor.swift | 0 wispr/Services/MeetingAudioEngine.swift | 0 wispr/Services/MeetingStateManager.swift | 0 wispr/Services/ParakeetService.swift | 0 wispr/Services/PermissionManager.swift | 0 wispr/Services/SettingsStore.swift | 4 - wispr/Services/SoundFeedbackService.swift | 0 wispr/Services/StateManager.swift | 0 wispr/Services/TextCorrectionService.swift | 0 wispr/Services/TextInsertionService.swift | 0 wispr/Services/TranscriptionEngine.swift | 0 wispr/Services/UpdateChecker.swift | 0 wispr/Services/WhisperService.swift | 0 wispr/UI/CLIInstallDialog.swift | 0 wispr/UI/Components/ModelRowView.swift | 0 wispr/UI/Components/SuffixEditorView.swift | 0 wispr/UI/Meeting/MeetingTranscriptView.swift | 0 wispr/UI/Meeting/MeetingWindowPanel.swift | 0 wispr/UI/MenuBarController.swift | 0 wispr/UI/ModelDownloadProgressView.swift | 0 wispr/UI/ModelManagementView.swift | 0 .../OnboardingAccessibilityStep.swift | 0 .../Onboarding/OnboardingCompletionStep.swift | 0 .../UI/Onboarding/OnboardingComponents.swift | 0 wispr/UI/Onboarding/OnboardingFlow.swift | 4 +- .../OnboardingMicPermissionStep.swift | 0 .../OnboardingModelSelectionStep.swift | 0 wispr/UI/Onboarding/OnboardingPreview.swift | 0 .../OnboardingTestDictationStep.swift | 0 .../UI/Onboarding/OnboardingWelcomeStep.swift | 0 wispr/UI/RecordingOverlayPanel.swift | 0 wispr/UI/RecordingOverlayView.swift | 0 wispr/UI/Settings/HotkeyRecorderView.swift | 0 wispr/UI/Settings/KeyCodeMapping.swift | 0 wispr/UI/Settings/SettingsView.swift | 0 wispr/UI/Settings/SupportedLanguage.swift | 0 wispr/Utilities/FillerWordCleaner.swift | 0 wispr/Utilities/Logger.swift | 0 wispr/Utilities/ModelPaths.swift | 21 +- wispr/Utilities/PreviewHelpers.swift | 0 wispr/Utilities/SFSymbols.swift | 0 wispr/Utilities/UIThemeEngine.swift | 0 wispr/wisprApp.swift | 0 wisprTests/AccessibilityTests.swift | 0 wisprTests/AppLifecycleIntegrationTests.swift | 0 wisprTests/AudioEngineTests.swift | 0 .../CompositeTranscriptionEngineTests.swift | 0 wisprTests/EndToEndIntegrationTests.swift | 0 wisprTests/ErrorRecoveryTests.swift | 0 wisprTests/FillerWordCleanerTests.swift | 0 wisprTests/HotkeyMonitorTests.swift | 0 wisprTests/MeetingAudioEngineTests.swift | 162 ++++++++ wisprTests/MeetingStateManagerTests.swift | 377 ++++++++++++++++++ wisprTests/MenuBarControllerTests.swift | 19 +- wisprTests/ModelManagementViewTests.swift | 0 wisprTests/MultiLanguageTests.swift | 0 wisprTests/OnboardingFlowTests.swift | 0 wisprTests/PermissionManagerTests.swift | 0 wisprTests/RecordingOverlayTests.swift | 0 wisprTests/SettingsStoreTests.swift | 0 wisprTests/SettingsViewTests.swift | 0 wisprTests/SoundFeedbackServiceTests.swift | 0 wisprTests/StateManagerTests.swift | 0 wisprTests/TextCorrectionTokenLeakTests.swift | 0 wisprTests/TextInsertionServiceTests.swift | 0 wisprTests/UIThemeEngineTests.swift | 0 wisprTests/UpdateCheckerTests.swift | 0 wisprTests/WhisperServiceTests.swift | 0 wisprTests/wisprTests.swift | 0 wisprUITests/wisprUITests.swift | 0 wisprUITests/wisprUITestsLaunchTests.swift | 0 130 files changed, 558 insertions(+), 32 deletions(-) rename tasks/todo.md => .kiro/specs/meeting-transcription/design.md (100%) mode change 100755 => 100644 mode change 100755 => 100644 CLAUDE.md mode change 100755 => 100644 ExportOptions.plist mode change 100755 => 100644 ExportOptionsHomebrew.plist mode change 100755 => 100644 LICENSE mode change 100755 => 100644 Makefile mode change 100755 => 100644 README.md mode change 100755 => 100644 artwork/generate_app_icons.swift mode change 100755 => 100644 artwork/icon-square.svg mode change 100755 => 100644 artwork/icon.svg mode change 100755 => 100644 artwork/prompt.md mode change 100755 => 100644 artwork/screenshots/main-2880x1800.key mode change 100755 => 100644 artwork/screenshots/main-2880x1800/main-2880x1800.001.png mode change 100755 => 100644 artwork/screenshots/main-2880x1800/main-2880x1800.002.png mode change 100755 => 100644 artwork/screenshots/main-2880x1800/main-2880x1800.003.png mode change 100755 => 100644 artwork/screenshots/menu.png mode change 100755 => 100644 artwork/screenshots/model-management.png mode change 100755 => 100644 artwork/screenshots/settings.png mode change 100755 => 100644 artwork/svg_to_png.swift mode change 100755 => 100644 artwork/wispr-banner.png mode change 100755 => 100644 wispr-cli/WisprCLI.swift mode change 100755 => 100644 wispr.xcodeproj/project.pbxproj mode change 100755 => 100644 wispr.xcodeproj/project.xcworkspace/contents.xcworkspacedata mode change 100755 => 100644 wispr.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved mode change 100755 => 100644 wispr.xcodeproj/xcshareddata/xcschemes/wispr.xcscheme mode change 100755 => 100644 wispr/Assets.xcassets/AccentColor.colorset/Contents.json mode change 100755 => 100644 wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-128x128@1x.png mode change 100755 => 100644 wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-128x128@2x.png mode change 100755 => 100644 wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-16x16@1x.png mode change 100755 => 100644 wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-16x16@2x.png mode change 100755 => 100644 wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-256x256@1x.png mode change 100755 => 100644 wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-256x256@2x.png mode change 100755 => 100644 wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-32x32@1x.png mode change 100755 => 100644 wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-32x32@2x.png mode change 100755 => 100644 wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-512x512@1x.png mode change 100755 => 100644 wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-512x512@2x.png mode change 100755 => 100644 wispr/Assets.xcassets/AppIcon.appiconset/Contents.json mode change 100755 => 100644 wispr/Assets.xcassets/Contents.json mode change 100755 => 100644 wispr/ContentView.swift mode change 100755 => 100644 wispr/Models/AppStateType.swift mode change 100755 => 100644 wispr/Models/AppUpdateInfo.swift mode change 100755 => 100644 wispr/Models/AudioInputDevice.swift mode change 100755 => 100644 wispr/Models/CorrectionStyle.swift mode change 100755 => 100644 wispr/Models/DownloadProgress.swift mode change 100755 => 100644 wispr/Models/MeetingTranscript.swift mode change 100755 => 100644 wispr/Models/ModelInfo.swift mode change 100755 => 100644 wispr/Models/ModelStatus.swift mode change 100755 => 100644 wispr/Models/OnboardingStep.swift mode change 100755 => 100644 wispr/Models/PermissionStatus.swift mode change 100755 => 100644 wispr/Models/SemanticVersion.swift mode change 100755 => 100644 wispr/Models/TranscriptionLanguage.swift mode change 100755 => 100644 wispr/Models/TranscriptionResult.swift mode change 100755 => 100644 wispr/Models/WisprError.swift mode change 100755 => 100644 wispr/Resources/Sounds/RecordingStarted.aiff mode change 100755 => 100644 wispr/Resources/Sounds/RecordingStopped.aiff mode change 100755 => 100644 wispr/Services/AudioEngine.swift mode change 100755 => 100644 wispr/Services/AudioFileDecoder.swift mode change 100755 => 100644 wispr/Services/CompositeTranscriptionEngine.swift mode change 100755 => 100644 wispr/Services/HotkeyMonitor.swift mode change 100755 => 100644 wispr/Services/MeetingAudioEngine.swift mode change 100755 => 100644 wispr/Services/MeetingStateManager.swift mode change 100755 => 100644 wispr/Services/ParakeetService.swift mode change 100755 => 100644 wispr/Services/PermissionManager.swift mode change 100755 => 100644 wispr/Services/SettingsStore.swift mode change 100755 => 100644 wispr/Services/SoundFeedbackService.swift mode change 100755 => 100644 wispr/Services/StateManager.swift mode change 100755 => 100644 wispr/Services/TextCorrectionService.swift mode change 100755 => 100644 wispr/Services/TextInsertionService.swift mode change 100755 => 100644 wispr/Services/TranscriptionEngine.swift mode change 100755 => 100644 wispr/Services/UpdateChecker.swift mode change 100755 => 100644 wispr/Services/WhisperService.swift mode change 100755 => 100644 wispr/UI/CLIInstallDialog.swift mode change 100755 => 100644 wispr/UI/Components/ModelRowView.swift mode change 100755 => 100644 wispr/UI/Components/SuffixEditorView.swift mode change 100755 => 100644 wispr/UI/Meeting/MeetingTranscriptView.swift mode change 100755 => 100644 wispr/UI/Meeting/MeetingWindowPanel.swift mode change 100755 => 100644 wispr/UI/MenuBarController.swift mode change 100755 => 100644 wispr/UI/ModelDownloadProgressView.swift mode change 100755 => 100644 wispr/UI/ModelManagementView.swift mode change 100755 => 100644 wispr/UI/Onboarding/OnboardingAccessibilityStep.swift mode change 100755 => 100644 wispr/UI/Onboarding/OnboardingCompletionStep.swift mode change 100755 => 100644 wispr/UI/Onboarding/OnboardingComponents.swift mode change 100755 => 100644 wispr/UI/Onboarding/OnboardingFlow.swift mode change 100755 => 100644 wispr/UI/Onboarding/OnboardingMicPermissionStep.swift mode change 100755 => 100644 wispr/UI/Onboarding/OnboardingModelSelectionStep.swift mode change 100755 => 100644 wispr/UI/Onboarding/OnboardingPreview.swift mode change 100755 => 100644 wispr/UI/Onboarding/OnboardingTestDictationStep.swift mode change 100755 => 100644 wispr/UI/Onboarding/OnboardingWelcomeStep.swift mode change 100755 => 100644 wispr/UI/RecordingOverlayPanel.swift mode change 100755 => 100644 wispr/UI/RecordingOverlayView.swift mode change 100755 => 100644 wispr/UI/Settings/HotkeyRecorderView.swift mode change 100755 => 100644 wispr/UI/Settings/KeyCodeMapping.swift mode change 100755 => 100644 wispr/UI/Settings/SettingsView.swift mode change 100755 => 100644 wispr/UI/Settings/SupportedLanguage.swift mode change 100755 => 100644 wispr/Utilities/FillerWordCleaner.swift mode change 100755 => 100644 wispr/Utilities/Logger.swift mode change 100755 => 100644 wispr/Utilities/ModelPaths.swift mode change 100755 => 100644 wispr/Utilities/PreviewHelpers.swift mode change 100755 => 100644 wispr/Utilities/SFSymbols.swift mode change 100755 => 100644 wispr/Utilities/UIThemeEngine.swift mode change 100755 => 100644 wispr/wisprApp.swift mode change 100755 => 100644 wisprTests/AccessibilityTests.swift mode change 100755 => 100644 wisprTests/AppLifecycleIntegrationTests.swift mode change 100755 => 100644 wisprTests/AudioEngineTests.swift mode change 100755 => 100644 wisprTests/CompositeTranscriptionEngineTests.swift mode change 100755 => 100644 wisprTests/EndToEndIntegrationTests.swift mode change 100755 => 100644 wisprTests/ErrorRecoveryTests.swift mode change 100755 => 100644 wisprTests/FillerWordCleanerTests.swift mode change 100755 => 100644 wisprTests/HotkeyMonitorTests.swift create mode 100644 wisprTests/MeetingAudioEngineTests.swift create mode 100644 wisprTests/MeetingStateManagerTests.swift mode change 100755 => 100644 wisprTests/MenuBarControllerTests.swift mode change 100755 => 100644 wisprTests/ModelManagementViewTests.swift mode change 100755 => 100644 wisprTests/MultiLanguageTests.swift mode change 100755 => 100644 wisprTests/OnboardingFlowTests.swift mode change 100755 => 100644 wisprTests/PermissionManagerTests.swift mode change 100755 => 100644 wisprTests/RecordingOverlayTests.swift mode change 100755 => 100644 wisprTests/SettingsStoreTests.swift mode change 100755 => 100644 wisprTests/SettingsViewTests.swift mode change 100755 => 100644 wisprTests/SoundFeedbackServiceTests.swift mode change 100755 => 100644 wisprTests/StateManagerTests.swift mode change 100755 => 100644 wisprTests/TextCorrectionTokenLeakTests.swift mode change 100755 => 100644 wisprTests/TextInsertionServiceTests.swift mode change 100755 => 100644 wisprTests/UIThemeEngineTests.swift mode change 100755 => 100644 wisprTests/UpdateCheckerTests.swift mode change 100755 => 100644 wisprTests/WhisperServiceTests.swift mode change 100755 => 100644 wisprTests/wisprTests.swift mode change 100755 => 100644 wisprUITests/wisprUITests.swift mode change 100755 => 100644 wisprUITests/wisprUITestsLaunchTests.swift diff --git a/tasks/todo.md b/.kiro/specs/meeting-transcription/design.md old mode 100755 new mode 100644 similarity index 100% rename from tasks/todo.md rename to .kiro/specs/meeting-transcription/design.md diff --git a/.vscode/settings.json b/.vscode/settings.json index 5480842..7a73a41 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,2 @@ { - "kiroAgent.configureMCP": "Disabled" } \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md old mode 100755 new mode 100644 diff --git a/ExportOptions.plist b/ExportOptions.plist old mode 100755 new mode 100644 diff --git a/ExportOptionsHomebrew.plist b/ExportOptionsHomebrew.plist old mode 100755 new mode 100644 diff --git a/LICENSE b/LICENSE old mode 100755 new mode 100644 diff --git a/Makefile b/Makefile old mode 100755 new mode 100644 diff --git a/README.md b/README.md old mode 100755 new mode 100644 diff --git a/artwork/generate_app_icons.swift b/artwork/generate_app_icons.swift old mode 100755 new mode 100644 diff --git a/artwork/icon-square.svg b/artwork/icon-square.svg old mode 100755 new mode 100644 diff --git a/artwork/icon.svg b/artwork/icon.svg old mode 100755 new mode 100644 diff --git a/artwork/prompt.md b/artwork/prompt.md old mode 100755 new mode 100644 diff --git a/artwork/screenshots/main-2880x1800.key b/artwork/screenshots/main-2880x1800.key old mode 100755 new mode 100644 diff --git a/artwork/screenshots/main-2880x1800/main-2880x1800.001.png b/artwork/screenshots/main-2880x1800/main-2880x1800.001.png old mode 100755 new mode 100644 diff --git a/artwork/screenshots/main-2880x1800/main-2880x1800.002.png b/artwork/screenshots/main-2880x1800/main-2880x1800.002.png old mode 100755 new mode 100644 diff --git a/artwork/screenshots/main-2880x1800/main-2880x1800.003.png b/artwork/screenshots/main-2880x1800/main-2880x1800.003.png old mode 100755 new mode 100644 diff --git a/artwork/screenshots/menu.png b/artwork/screenshots/menu.png old mode 100755 new mode 100644 diff --git a/artwork/screenshots/model-management.png b/artwork/screenshots/model-management.png old mode 100755 new mode 100644 diff --git a/artwork/screenshots/settings.png b/artwork/screenshots/settings.png old mode 100755 new mode 100644 diff --git a/artwork/svg_to_png.swift b/artwork/svg_to_png.swift old mode 100755 new mode 100644 diff --git a/artwork/wispr-banner.png b/artwork/wispr-banner.png old mode 100755 new mode 100644 diff --git a/wispr-cli/WisprCLI.swift b/wispr-cli/WisprCLI.swift old mode 100755 new mode 100644 diff --git a/wispr.xcodeproj/project.pbxproj b/wispr.xcodeproj/project.pbxproj old mode 100755 new mode 100644 index 86c5358..d883c09 --- a/wispr.xcodeproj/project.pbxproj +++ b/wispr.xcodeproj/project.pbxproj @@ -712,7 +712,6 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; CREATE_INFOPLIST_SECTION_IN_BINARY = YES; CURRENT_PROJECT_VERSION = 260417.274; @@ -740,7 +739,6 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; CREATE_INFOPLIST_SECTION_IN_BINARY = YES; CURRENT_PROJECT_VERSION = 260417.274; diff --git a/wispr.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/wispr.xcodeproj/project.xcworkspace/contents.xcworkspacedata old mode 100755 new mode 100644 diff --git a/wispr.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/wispr.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved old mode 100755 new mode 100644 diff --git a/wispr.xcodeproj/xcshareddata/xcschemes/wispr.xcscheme b/wispr.xcodeproj/xcshareddata/xcschemes/wispr.xcscheme old mode 100755 new mode 100644 diff --git a/wispr/Assets.xcassets/AccentColor.colorset/Contents.json b/wispr/Assets.xcassets/AccentColor.colorset/Contents.json old mode 100755 new mode 100644 diff --git a/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-128x128@1x.png b/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-128x128@1x.png old mode 100755 new mode 100644 diff --git a/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-128x128@2x.png b/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-128x128@2x.png old mode 100755 new mode 100644 diff --git a/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-16x16@1x.png b/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-16x16@1x.png old mode 100755 new mode 100644 diff --git a/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-16x16@2x.png b/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-16x16@2x.png old mode 100755 new mode 100644 diff --git a/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-256x256@1x.png b/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-256x256@1x.png old mode 100755 new mode 100644 diff --git a/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-256x256@2x.png b/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-256x256@2x.png old mode 100755 new mode 100644 diff --git a/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-32x32@1x.png b/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-32x32@1x.png old mode 100755 new mode 100644 diff --git a/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-32x32@2x.png b/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-32x32@2x.png old mode 100755 new mode 100644 diff --git a/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-512x512@1x.png b/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-512x512@1x.png old mode 100755 new mode 100644 diff --git a/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-512x512@2x.png b/wispr/Assets.xcassets/AppIcon.appiconset/AppIcon-512x512@2x.png old mode 100755 new mode 100644 diff --git a/wispr/Assets.xcassets/AppIcon.appiconset/Contents.json b/wispr/Assets.xcassets/AppIcon.appiconset/Contents.json old mode 100755 new mode 100644 diff --git a/wispr/Assets.xcassets/Contents.json b/wispr/Assets.xcassets/Contents.json old mode 100755 new mode 100644 diff --git a/wispr/ContentView.swift b/wispr/ContentView.swift old mode 100755 new mode 100644 diff --git a/wispr/Models/AppStateType.swift b/wispr/Models/AppStateType.swift old mode 100755 new mode 100644 diff --git a/wispr/Models/AppUpdateInfo.swift b/wispr/Models/AppUpdateInfo.swift old mode 100755 new mode 100644 diff --git a/wispr/Models/AudioInputDevice.swift b/wispr/Models/AudioInputDevice.swift old mode 100755 new mode 100644 diff --git a/wispr/Models/CorrectionStyle.swift b/wispr/Models/CorrectionStyle.swift old mode 100755 new mode 100644 diff --git a/wispr/Models/DownloadProgress.swift b/wispr/Models/DownloadProgress.swift old mode 100755 new mode 100644 diff --git a/wispr/Models/MeetingTranscript.swift b/wispr/Models/MeetingTranscript.swift old mode 100755 new mode 100644 diff --git a/wispr/Models/ModelInfo.swift b/wispr/Models/ModelInfo.swift old mode 100755 new mode 100644 diff --git a/wispr/Models/ModelStatus.swift b/wispr/Models/ModelStatus.swift old mode 100755 new mode 100644 diff --git a/wispr/Models/OnboardingStep.swift b/wispr/Models/OnboardingStep.swift old mode 100755 new mode 100644 diff --git a/wispr/Models/PermissionStatus.swift b/wispr/Models/PermissionStatus.swift old mode 100755 new mode 100644 diff --git a/wispr/Models/SemanticVersion.swift b/wispr/Models/SemanticVersion.swift old mode 100755 new mode 100644 diff --git a/wispr/Models/TranscriptionLanguage.swift b/wispr/Models/TranscriptionLanguage.swift old mode 100755 new mode 100644 diff --git a/wispr/Models/TranscriptionResult.swift b/wispr/Models/TranscriptionResult.swift old mode 100755 new mode 100644 diff --git a/wispr/Models/WisprError.swift b/wispr/Models/WisprError.swift old mode 100755 new mode 100644 diff --git a/wispr/Resources/Sounds/RecordingStarted.aiff b/wispr/Resources/Sounds/RecordingStarted.aiff old mode 100755 new mode 100644 diff --git a/wispr/Resources/Sounds/RecordingStopped.aiff b/wispr/Resources/Sounds/RecordingStopped.aiff old mode 100755 new mode 100644 diff --git a/wispr/Services/AudioEngine.swift b/wispr/Services/AudioEngine.swift old mode 100755 new mode 100644 diff --git a/wispr/Services/AudioFileDecoder.swift b/wispr/Services/AudioFileDecoder.swift old mode 100755 new mode 100644 diff --git a/wispr/Services/CompositeTranscriptionEngine.swift b/wispr/Services/CompositeTranscriptionEngine.swift old mode 100755 new mode 100644 diff --git a/wispr/Services/HotkeyMonitor.swift b/wispr/Services/HotkeyMonitor.swift old mode 100755 new mode 100644 diff --git a/wispr/Services/MeetingAudioEngine.swift b/wispr/Services/MeetingAudioEngine.swift old mode 100755 new mode 100644 diff --git a/wispr/Services/MeetingStateManager.swift b/wispr/Services/MeetingStateManager.swift old mode 100755 new mode 100644 diff --git a/wispr/Services/ParakeetService.swift b/wispr/Services/ParakeetService.swift old mode 100755 new mode 100644 diff --git a/wispr/Services/PermissionManager.swift b/wispr/Services/PermissionManager.swift old mode 100755 new mode 100644 diff --git a/wispr/Services/SettingsStore.swift b/wispr/Services/SettingsStore.swift old mode 100755 new mode 100644 index 7440557..d89b319 --- a/wispr/Services/SettingsStore.swift +++ b/wispr/Services/SettingsStore.swift @@ -118,10 +118,6 @@ final class SettingsStore { } } - // MARK: - App Variant - - - // MARK: - UserDefaults Keys private enum Keys { static let hotkeyKeyCode = "hotkeyKeyCode" diff --git a/wispr/Services/SoundFeedbackService.swift b/wispr/Services/SoundFeedbackService.swift old mode 100755 new mode 100644 diff --git a/wispr/Services/StateManager.swift b/wispr/Services/StateManager.swift old mode 100755 new mode 100644 diff --git a/wispr/Services/TextCorrectionService.swift b/wispr/Services/TextCorrectionService.swift old mode 100755 new mode 100644 diff --git a/wispr/Services/TextInsertionService.swift b/wispr/Services/TextInsertionService.swift old mode 100755 new mode 100644 diff --git a/wispr/Services/TranscriptionEngine.swift b/wispr/Services/TranscriptionEngine.swift old mode 100755 new mode 100644 diff --git a/wispr/Services/UpdateChecker.swift b/wispr/Services/UpdateChecker.swift old mode 100755 new mode 100644 diff --git a/wispr/Services/WhisperService.swift b/wispr/Services/WhisperService.swift old mode 100755 new mode 100644 diff --git a/wispr/UI/CLIInstallDialog.swift b/wispr/UI/CLIInstallDialog.swift old mode 100755 new mode 100644 diff --git a/wispr/UI/Components/ModelRowView.swift b/wispr/UI/Components/ModelRowView.swift old mode 100755 new mode 100644 diff --git a/wispr/UI/Components/SuffixEditorView.swift b/wispr/UI/Components/SuffixEditorView.swift old mode 100755 new mode 100644 diff --git a/wispr/UI/Meeting/MeetingTranscriptView.swift b/wispr/UI/Meeting/MeetingTranscriptView.swift old mode 100755 new mode 100644 diff --git a/wispr/UI/Meeting/MeetingWindowPanel.swift b/wispr/UI/Meeting/MeetingWindowPanel.swift old mode 100755 new mode 100644 diff --git a/wispr/UI/MenuBarController.swift b/wispr/UI/MenuBarController.swift old mode 100755 new mode 100644 diff --git a/wispr/UI/ModelDownloadProgressView.swift b/wispr/UI/ModelDownloadProgressView.swift old mode 100755 new mode 100644 diff --git a/wispr/UI/ModelManagementView.swift b/wispr/UI/ModelManagementView.swift old mode 100755 new mode 100644 diff --git a/wispr/UI/Onboarding/OnboardingAccessibilityStep.swift b/wispr/UI/Onboarding/OnboardingAccessibilityStep.swift old mode 100755 new mode 100644 diff --git a/wispr/UI/Onboarding/OnboardingCompletionStep.swift b/wispr/UI/Onboarding/OnboardingCompletionStep.swift old mode 100755 new mode 100644 diff --git a/wispr/UI/Onboarding/OnboardingComponents.swift b/wispr/UI/Onboarding/OnboardingComponents.swift old mode 100755 new mode 100644 diff --git a/wispr/UI/Onboarding/OnboardingFlow.swift b/wispr/UI/Onboarding/OnboardingFlow.swift old mode 100755 new mode 100644 index 311d139..fa103b8 --- a/wispr/UI/Onboarding/OnboardingFlow.swift +++ b/wispr/UI/Onboarding/OnboardingFlow.swift @@ -112,10 +112,10 @@ struct OnboardingFlow: View { .foregroundStyle(theme.secondaryTextColor) HStack(spacing: 0) { - ForEach(Array(OnboardingStep.allCases.enumerated()), id: \.element.rawValue) { index, step in + ForEach(OnboardingStep.allCases, id: \.rawValue) { step in stepDot(for: step) - if index < OnboardingStep.allCases.count - 1 { + if step != .completion { Capsule() .fill(step.rawValue < currentStep.rawValue ? theme.successColor.opacity(0.5) diff --git a/wispr/UI/Onboarding/OnboardingMicPermissionStep.swift b/wispr/UI/Onboarding/OnboardingMicPermissionStep.swift old mode 100755 new mode 100644 diff --git a/wispr/UI/Onboarding/OnboardingModelSelectionStep.swift b/wispr/UI/Onboarding/OnboardingModelSelectionStep.swift old mode 100755 new mode 100644 diff --git a/wispr/UI/Onboarding/OnboardingPreview.swift b/wispr/UI/Onboarding/OnboardingPreview.swift old mode 100755 new mode 100644 diff --git a/wispr/UI/Onboarding/OnboardingTestDictationStep.swift b/wispr/UI/Onboarding/OnboardingTestDictationStep.swift old mode 100755 new mode 100644 diff --git a/wispr/UI/Onboarding/OnboardingWelcomeStep.swift b/wispr/UI/Onboarding/OnboardingWelcomeStep.swift old mode 100755 new mode 100644 diff --git a/wispr/UI/RecordingOverlayPanel.swift b/wispr/UI/RecordingOverlayPanel.swift old mode 100755 new mode 100644 diff --git a/wispr/UI/RecordingOverlayView.swift b/wispr/UI/RecordingOverlayView.swift old mode 100755 new mode 100644 diff --git a/wispr/UI/Settings/HotkeyRecorderView.swift b/wispr/UI/Settings/HotkeyRecorderView.swift old mode 100755 new mode 100644 diff --git a/wispr/UI/Settings/KeyCodeMapping.swift b/wispr/UI/Settings/KeyCodeMapping.swift old mode 100755 new mode 100644 diff --git a/wispr/UI/Settings/SettingsView.swift b/wispr/UI/Settings/SettingsView.swift old mode 100755 new mode 100644 diff --git a/wispr/UI/Settings/SupportedLanguage.swift b/wispr/UI/Settings/SupportedLanguage.swift old mode 100755 new mode 100644 diff --git a/wispr/Utilities/FillerWordCleaner.swift b/wispr/Utilities/FillerWordCleaner.swift old mode 100755 new mode 100644 diff --git a/wispr/Utilities/Logger.swift b/wispr/Utilities/Logger.swift old mode 100755 new mode 100644 diff --git a/wispr/Utilities/ModelPaths.swift b/wispr/Utilities/ModelPaths.swift old mode 100755 new mode 100644 index 68862cc..8e1b460 --- a/wispr/Utilities/ModelPaths.swift +++ b/wispr/Utilities/ModelPaths.swift @@ -38,7 +38,7 @@ enum ModelPaths { // macOS sets APP_SANDBOX_CONTAINER_ID for sandboxed processes. let isSandboxed = ProcessInfo.processInfo.environment["APP_SANDBOX_CONTAINER_ID"] != nil if isSandboxed { - // Inside the sandbox: FileManager automatically redirects + // Inside the sandbox (GUI app): FileManager automatically redirects // to ~/Library/Containers//Data/Library/Application Support/ guard let appSupport = FileManager.default.urls( for: .applicationSupportDirectory, @@ -46,24 +46,7 @@ enum ModelPaths { ).first else { fatalError("Application Support directory unavailable — cannot store models") } - let ownPath = appSupport.appendingPathComponent("wispr", isDirectory: true) - - // If this app's container already has models, use it. - if FileManager.default.fileExists(atPath: ownPath.path) { - return ownPath - } - - // Otherwise, check the original Wispr app's container for shared models. - let home = FileManager.default.homeDirectoryForCurrentUser - let originalContainer = home.appendingPathComponent( - "Library/Containers/com.stormacq.mac.wispr/Data/Library/Application Support/wispr" - ) - if FileManager.default.fileExists(atPath: originalContainer.path) { - return originalContainer - } - - // Neither exists yet — use own container (will be created on first download). - return ownPath + return appSupport.appendingPathComponent("wispr", isDirectory: true) } // Outside the sandbox (wispr-cli): read models from the GUI app's diff --git a/wispr/Utilities/PreviewHelpers.swift b/wispr/Utilities/PreviewHelpers.swift old mode 100755 new mode 100644 diff --git a/wispr/Utilities/SFSymbols.swift b/wispr/Utilities/SFSymbols.swift old mode 100755 new mode 100644 diff --git a/wispr/Utilities/UIThemeEngine.swift b/wispr/Utilities/UIThemeEngine.swift old mode 100755 new mode 100644 diff --git a/wispr/wisprApp.swift b/wispr/wisprApp.swift old mode 100755 new mode 100644 diff --git a/wisprTests/AccessibilityTests.swift b/wisprTests/AccessibilityTests.swift old mode 100755 new mode 100644 diff --git a/wisprTests/AppLifecycleIntegrationTests.swift b/wisprTests/AppLifecycleIntegrationTests.swift old mode 100755 new mode 100644 diff --git a/wisprTests/AudioEngineTests.swift b/wisprTests/AudioEngineTests.swift old mode 100755 new mode 100644 diff --git a/wisprTests/CompositeTranscriptionEngineTests.swift b/wisprTests/CompositeTranscriptionEngineTests.swift old mode 100755 new mode 100644 diff --git a/wisprTests/EndToEndIntegrationTests.swift b/wisprTests/EndToEndIntegrationTests.swift old mode 100755 new mode 100644 diff --git a/wisprTests/ErrorRecoveryTests.swift b/wisprTests/ErrorRecoveryTests.swift old mode 100755 new mode 100644 diff --git a/wisprTests/FillerWordCleanerTests.swift b/wisprTests/FillerWordCleanerTests.swift old mode 100755 new mode 100644 diff --git a/wisprTests/HotkeyMonitorTests.swift b/wisprTests/HotkeyMonitorTests.swift old mode 100755 new mode 100644 diff --git a/wisprTests/MeetingAudioEngineTests.swift b/wisprTests/MeetingAudioEngineTests.swift new file mode 100644 index 0000000..dae6ed4 --- /dev/null +++ b/wisprTests/MeetingAudioEngineTests.swift @@ -0,0 +1,162 @@ +// +// MeetingAudioEngineTests.swift +// wispr +// +// Unit tests for MeetingAudioEngine and SystemAudioOutputHandler +// using swift-testing framework. +// + +import AVFoundation +import CoreMedia +import Foundation +import ScreenCaptureKit +import Testing + +@testable import wispr + +// MARK: - MeetingAudioEngine Tests + +@Suite("MeetingAudioEngine Tests") +struct MeetingAudioEngineTests { + + // MARK: - Stream Behavior When Not Capturing + + @Test("micAudioStream returns finished stream when not capturing") + func testMicStreamReturnsFinishedWhenNotCapturing() async { + let engine = MeetingAudioEngine() + + let stream = await engine.micAudioStream + var chunks: [[Float]] = [] + for await chunk in stream { + chunks.append(chunk) + } + + #expect( + chunks.isEmpty, + "micAudioStream should finish immediately when not capturing, yielding no chunks") + } + + @Test("systemAudioStream returns finished stream when not capturing") + func testSystemStreamReturnsFinishedWhenNotCapturing() async { + let engine = MeetingAudioEngine() + + let stream = await engine.systemAudioStream + var chunks: [[Float]] = [] + for await chunk in stream { + chunks.append(chunk) + } + + #expect( + chunks.isEmpty, + "systemAudioStream should finish immediately when not capturing, yielding no chunks") + } + + // MARK: - Safe Operations When Not Capturing + + @Test("stopCapture does not crash when not capturing") + func testStopCaptureWhenNotCapturing() async { + let engine = MeetingAudioEngine() + + // Should complete without error or crash + await engine.stopCapture() + } + + @Test("flushBuffers does not crash when not capturing") + func testFlushBuffersWhenNotCapturing() async { + let engine = MeetingAudioEngine() + + // Should complete without error or crash + await engine.flushBuffers() + } + + // MARK: - Capture Start Behavior + + @Test("startCapture throws in test environment without mic permission") + func testStartCaptureFailsInTestEnvironment() async { + let engine = MeetingAudioEngine() + + // In CI/test environments, mic hardware is typically unavailable. + // startCapture should throw because AVAudioEngine cannot start + // without a valid input device. + do { + _ = try await engine.startCapture() + // If we reach here, hardware is available — stop capture to clean up + await engine.stopCapture() + } catch { + // Expected: capture fails due to missing mic permission or hardware. + // Verify it's a WisprError or at least that it threw. + #expect( + error is WisprError || error is NSError, + "Expected a WisprError or NSError, got \(type(of: error))") + } + } + + @Test("Double startCapture throws audioRecordingFailed") + func testDoubleStartCaptureThrows() async throws { + let engine = MeetingAudioEngine() + + // First call: may succeed or fail depending on hardware + let firstStartSucceeded: Bool + do { + _ = try await engine.startCapture() + firstStartSucceeded = true + } catch { + firstStartSucceeded = false + } + + // Only test double-start if the first one succeeded + guard firstStartSucceeded else { + // No hardware available — can't test double-start, clean up and return + await engine.stopCapture() + return + } + + // Second call should throw because capture is already active + do { + _ = try await engine.startCapture() + Issue.record("Second startCapture() should have thrown, but it succeeded") + } catch let error as WisprError { + #expect( + error == .audioRecordingFailed("Meeting capture already active"), + "Expected audioRecordingFailed error for double start") + } catch { + Issue.record("Expected WisprError.audioRecordingFailed, got \(error)") + } + + // Clean up + await engine.stopCapture() + } +} + +// MARK: - SystemAudioOutputHandler Tests + +@Suite("SystemAudioOutputHandler Tests") +struct SystemAudioOutputHandlerTests { + + @Test("Handler can be instantiated with a closure") + func testHandlerInstantiation() { + let handler = SystemAudioOutputHandler { _ in + // no-op callback + } + + #expect(handler is NSObject, "Handler should be an NSObject subclass") + } + + @Test("Handler stores callback and can be used as SCStreamOutput") + func testHandlerCallbackWithSamples() { + nonisolated(unsafe) var receivedSamples: [Float]? + + let handler = SystemAudioOutputHandler { samples in + receivedSamples = samples + } + + // Verify the handler conforms to SCStreamOutput by checking it's the right type. + // We can't easily create a valid CMSampleBuffer with audio data in a unit test, + // but we can confirm the handler was created and is ready to receive callbacks. + #expect(handler is NSObject, "Handler should be an NSObject subclass") + + // The callback hasn't been invoked yet since we haven't sent any sample buffers + #expect( + receivedSamples == nil, "Callback should not be invoked until stream delivers samples") + } +} diff --git a/wisprTests/MeetingStateManagerTests.swift b/wisprTests/MeetingStateManagerTests.swift new file mode 100644 index 0000000..320a63e --- /dev/null +++ b/wisprTests/MeetingStateManagerTests.swift @@ -0,0 +1,377 @@ +// +// MeetingStateManagerTests.swift +// wisprTests +// +// Unit tests for MeetingStateManager and MeetingTranscript. +// + +import AppKit +import Foundation +import Testing + +@testable import wispr + +// MARK: - Test Helpers + +/// Creates a MeetingStateManager with real MeetingAudioEngine (which will fail +/// without mic permission — useful for testing error paths) and a fake +/// transcription engine. +@MainActor +func createTestMeetingStateManager() -> MeetingStateManager { + let audioEngine = MeetingAudioEngine() + let transcriptionEngine = FakeMeetingTranscriptionEngine() + let settingsStore = SettingsStore( + defaults: UserDefaults(suiteName: "test.wispr.meeting.\(UUID().uuidString)")! + ) + + return MeetingStateManager( + meetingAudioEngine: audioEngine, + transcriptionEngine: transcriptionEngine, + settingsStore: settingsStore + ) +} + +// MARK: - MeetingTranscript Tests + +@Suite("MeetingTranscript Tests") +struct MeetingTranscriptTests { + + @Test("Empty transcript asPlainText returns empty string") + func testEmptyTranscriptPlainText() { + let transcript = MeetingTranscript() + #expect(transcript.asPlainText() == "") + } + + @Test("Transcript asPlainText formats entries as [HH:mm:ss] Speaker: text") + func testTranscriptPlainTextFormatting() { + var transcript = MeetingTranscript() + + // Create entries with known timestamps + let calendar = Calendar.current + var components = DateComponents() + components.year = 2025 + components.month = 1 + components.day = 15 + components.hour = 14 + components.minute = 30 + components.second = 45 + let timestamp1 = calendar.date(from: components)! + + components.minute = 31 + components.second = 12 + let timestamp2 = calendar.date(from: components)! + + transcript.entries.append( + MeetingTranscriptEntry(speaker: .you, text: "Hello world", timestamp: timestamp1) + ) + transcript.entries.append( + MeetingTranscriptEntry(speaker: .others, text: "Hi there", timestamp: timestamp2) + ) + + let plainText = transcript.asPlainText() + let lines = plainText.components(separatedBy: "\n") + + #expect(lines.count == 2) + #expect(lines[0] == "[14:30:45] You: Hello world") + #expect(lines[1] == "[14:31:12] Others: Hi there") + } + + @Test("Transcript formattedDuration returns M:SS format") + func testTranscriptFormattedDuration() { + // Create a transcript whose startTime is 125 seconds in the past (2:05) + let startTime = Date().addingTimeInterval(-125) + let transcript = MeetingTranscript(startTime: startTime) + + let formatted = transcript.formattedDuration + // Allow a small tolerance — the exact second may shift by 1 + #expect(formatted == "2:05" || formatted == "2:06") + } + + @Test("Transcript entries with different UUIDs are not equal") + func testTranscriptEntryEquality() { + let timestamp = Date() + let entry1 = MeetingTranscriptEntry(speaker: .you, text: "Hello", timestamp: timestamp) + let entry2 = MeetingTranscriptEntry(speaker: .you, text: "Hello", timestamp: timestamp) + + // Each entry gets a unique UUID in init, so they should NOT be equal + #expect(entry1 != entry2) + #expect(entry1.id != entry2.id) + } + + @Test("MeetingSpeaker raw values are correct") + func testMeetingSpeakerRawValues() { + #expect(MeetingSpeaker.you.rawValue == "You") + #expect(MeetingSpeaker.others.rawValue == "Others") + } +} + +// MARK: - MeetingStateManager Tests + +@MainActor +@Suite("MeetingStateManager Tests", .serialized) +struct MeetingStateManagerTests { + + // MARK: - Initial State + + @Test("MeetingStateManager has correct initial state") + func testInitialState() { + let manager = createTestMeetingStateManager() + + #expect(manager.meetingState == .idle) + #expect(manager.transcript.entries.isEmpty) + #expect(manager.micLevel == 0) + #expect(manager.systemLevel == 0) + #expect(manager.errorMessage == nil) + #expect(manager.isWindowVisible == false) + #expect(manager.elapsedTime == "0:00") + } + + // MARK: - Start Meeting + + @Test("startMeeting fails without mic permission and sets error state") + func testStartMeetingFailsWithoutMic() async { + let manager = createTestMeetingStateManager() + + await manager.startMeeting() + + // startCapture() should throw in the test environment (no mic permission), + // causing handleError to be called + if case .error(let message) = manager.meetingState { + #expect(message.contains("Failed to start meeting capture")) + } else { + // The error auto-dismisses after 5 seconds. If the state has already + // become .idle, just verify errorMessage was set (it also auto-clears, + // but there's a window). Either .error or .idle is acceptable here + // since the handleError has an auto-dismiss timer. + #expect( + manager.meetingState == .idle + || { + if case .error = manager.meetingState { return true } + return false + }()) + } + } + + @Test("toggleMeeting from idle attempts to start meeting") + func testToggleMeetingFromIdle() async { + let manager = createTestMeetingStateManager() + + #expect(manager.meetingState == .idle) + + await manager.toggleMeeting() + + // Should have attempted startMeeting, which fails due to no mic permission + // State should be .error(...) or possibly .idle if auto-dismiss already fired + let isErrorOrIdle: Bool + switch manager.meetingState { + case .error: isErrorOrIdle = true + case .idle: isErrorOrIdle = true + case .recording: isErrorOrIdle = false + } + #expect(isErrorOrIdle) + } + + @Test("toggleMeeting from error resets to idle") + func testToggleMeetingFromError() async { + let manager = createTestMeetingStateManager() + + // Force error state + // First, attempt to start which will error + await manager.startMeeting() + + // If we're in error state, toggle should reset to idle + if case .error = manager.meetingState { + await manager.toggleMeeting() + #expect(manager.meetingState == .idle) + #expect(manager.errorMessage == nil) + } + // If auto-dismiss already fired, state is already idle — that's also fine + } + + @Test("stopMeeting when idle is a no-op") + func testStopMeetingWhenIdle() async { + let manager = createTestMeetingStateManager() + + #expect(manager.meetingState == .idle) + + await manager.stopMeeting() + + #expect(manager.meetingState == .idle) + } + + // MARK: - Copy Transcript + + @Test("copyTranscript with empty transcript does not crash") + func testCopyTranscriptEmpty() { + let manager = createTestMeetingStateManager() + + #expect(manager.transcript.entries.isEmpty) + + // Should not crash — copyTranscript guards on empty text + manager.copyTranscript() + } + + @Test("copyTranscript with entries places text on pasteboard") + func testCopyTranscriptWithEntries() { + let manager = createTestMeetingStateManager() + + let calendar = Calendar.current + var components = DateComponents() + components.year = 2025 + components.month = 6 + components.day = 1 + components.hour = 10 + components.minute = 0 + components.second = 0 + let timestamp = calendar.date(from: components)! + + manager.transcript.entries.append( + MeetingTranscriptEntry(speaker: .you, text: "Test message", timestamp: timestamp) + ) + + manager.copyTranscript() + + let pasteboard = NSPasteboard.general + let pasteboardText = pasteboard.string(forType: .string) + #expect(pasteboardText == "[10:00:00] You: Test message") + } + + // MARK: - Window Visibility + + @Test("startMeeting sets error state when no mic permission") + func testStartMeetingSetsErrorState() async { + let manager = createTestMeetingStateManager() + + await manager.startMeeting() + + // The error path in startMeeting calls handleError which sets meetingState to .error + // It may have auto-dismissed by now, but errorMessage should have been set + // Since handleError auto-dismisses after 5 seconds, check the state within that window + let stateIsExpected: Bool + switch manager.meetingState { + case .error: stateIsExpected = true + case .idle: stateIsExpected = true // auto-dismiss may have fired + case .recording: stateIsExpected = false + } + #expect(stateIsExpected) + // isWindowVisible is NOT set to true in the error path (only in the success path) + // so it should remain false + #expect(manager.isWindowVisible == false) + } + + // MARK: - Double Start Prevention + + @Test("startMeeting when already recording is ignored") + func testDoubleStartMeetingIgnored() async { + let manager = createTestMeetingStateManager() + + // We can't easily get to .recording state without mic permission, + // but we can test the guard by checking that startMeeting from non-idle + // states is a no-op. + + // First, trigger an error state + await manager.startMeeting() + + // If in error state, startMeeting should be a no-op (guard meetingState == .idle) + if case .error(let msg) = manager.meetingState { + await manager.startMeeting() + // State should still be the same error + if case .error(let msg2) = manager.meetingState { + #expect(msg == msg2) + } + } + } +} + +// MARK: - Fake Transcription Engine + +/// Minimal fake TranscriptionEngine for MeetingStateManager tests. +/// Returns simple stubs for all protocol methods. +actor FakeMeetingTranscriptionEngine: TranscriptionEngine { + + private var _activeModel: String? + + func availableModels() async -> [ModelInfo] { + [ + ModelInfo( + id: "fake-model", + displayName: "Fake Model", + sizeDescription: "~1 MB", + qualityDescription: "Test only", + estimatedSize: 1_000_000, + status: .downloaded + ) + ] + } + + func downloadModel(_ model: ModelInfo) async -> AsyncThrowingStream { + let (stream, continuation) = AsyncThrowingStream.makeStream(of: DownloadProgress.self) + continuation.yield( + DownloadProgress( + phase: .downloading, + fractionCompleted: 1.0, + bytesDownloaded: 100, + totalBytes: 100 + ) + ) + continuation.finish() + return stream + } + + func deleteModel(_ modelName: String) async throws { + if _activeModel == modelName { + _activeModel = nil + } + } + + func loadModel(_ modelName: String) async throws { + _activeModel = modelName + } + + func switchModel(to modelName: String) async throws { + _activeModel = modelName + } + + func unloadCurrentModel() async { + _activeModel = nil + } + + func validateModelIntegrity(_ modelName: String) async throws -> Bool { + true + } + + func modelStatus(_ modelName: String) async -> ModelStatus { + if _activeModel == modelName { return .active } + return .downloaded + } + + func activeModel() async -> String? { + _activeModel + } + + func reloadModelWithRetry(maxAttempts: Int) async throws { + // no-op + } + + func transcribe( + _ audioSamples: [Float], + language: TranscriptionLanguage + ) async throws -> TranscriptionResult { + TranscriptionResult(text: "mock transcription", detectedLanguage: nil, duration: 0.1) + } + + func transcribeStream( + _ audioStream: AsyncStream<[Float]>, + language: TranscriptionLanguage + ) async -> AsyncThrowingStream { + let (stream, continuation) = AsyncThrowingStream.makeStream(of: TranscriptionResult.self) + continuation.yield( + TranscriptionResult(text: "mock transcription", detectedLanguage: nil, duration: 0.1)) + continuation.finish() + return stream + } + + func supportsEndOfUtteranceDetection() async -> Bool { + false + } +} diff --git a/wisprTests/MenuBarControllerTests.swift b/wisprTests/MenuBarControllerTests.swift old mode 100755 new mode 100644 index 91b493b..4fedc71 --- a/wisprTests/MenuBarControllerTests.swift +++ b/wisprTests/MenuBarControllerTests.swift @@ -7,8 +7,9 @@ // Requirements: 5.3, 5.4, 17.10 // -import Testing import AppKit +import Testing + @testable import wispr // MARK: - Test Helpers @@ -42,6 +43,13 @@ private func createTestController( let updateChecker = PreviewMocks.makeUpdateChecker() + let meetingAudioEngine = MeetingAudioEngine() + let meetingStateManager = MeetingStateManager( + meetingAudioEngine: meetingAudioEngine, + transcriptionEngine: whisperService, + settingsStore: settingsStore + ) + let controller = MenuBarController( stateManager: stateManager, settingsStore: settingsStore, @@ -51,7 +59,8 @@ private func createTestController( whisperService: whisperService, permissionManager: permissionManager, textCorrectionService: TextCorrectionService(), - updateChecker: updateChecker + updateChecker: updateChecker, + meetingStateManager: meetingStateManager ) return (controller, stateManager, settingsStore, themeEngine) @@ -105,7 +114,9 @@ struct MenuBarControllerIconTests { func testErrorSymbol() { let themeEngine = UIThemeEngine() let symbol = themeEngine.menuBarSymbol(for: .error("test")) - #expect(symbol == "exclamationmark.triangle", "Error state should use 'exclamationmark.triangle' symbol") + #expect( + symbol == "exclamationmark.triangle", + "Error state should use 'exclamationmark.triangle' symbol") } @Test("Each app state maps to a distinct icon symbol") @@ -268,7 +279,7 @@ struct MenuBarControllerAccessibilityTests { func testMenuItemSymbolsResolve() { let themeEngine = UIThemeEngine() let actions: [UIThemeEngine.ActionSymbol] = [ - .settings, .language, .model, .quit + .settings, .language, .model, .quit, ] for action in actions { let symbolName = themeEngine.actionSymbol(action) diff --git a/wisprTests/ModelManagementViewTests.swift b/wisprTests/ModelManagementViewTests.swift old mode 100755 new mode 100644 diff --git a/wisprTests/MultiLanguageTests.swift b/wisprTests/MultiLanguageTests.swift old mode 100755 new mode 100644 diff --git a/wisprTests/OnboardingFlowTests.swift b/wisprTests/OnboardingFlowTests.swift old mode 100755 new mode 100644 diff --git a/wisprTests/PermissionManagerTests.swift b/wisprTests/PermissionManagerTests.swift old mode 100755 new mode 100644 diff --git a/wisprTests/RecordingOverlayTests.swift b/wisprTests/RecordingOverlayTests.swift old mode 100755 new mode 100644 diff --git a/wisprTests/SettingsStoreTests.swift b/wisprTests/SettingsStoreTests.swift old mode 100755 new mode 100644 diff --git a/wisprTests/SettingsViewTests.swift b/wisprTests/SettingsViewTests.swift old mode 100755 new mode 100644 diff --git a/wisprTests/SoundFeedbackServiceTests.swift b/wisprTests/SoundFeedbackServiceTests.swift old mode 100755 new mode 100644 diff --git a/wisprTests/StateManagerTests.swift b/wisprTests/StateManagerTests.swift old mode 100755 new mode 100644 diff --git a/wisprTests/TextCorrectionTokenLeakTests.swift b/wisprTests/TextCorrectionTokenLeakTests.swift old mode 100755 new mode 100644 diff --git a/wisprTests/TextInsertionServiceTests.swift b/wisprTests/TextInsertionServiceTests.swift old mode 100755 new mode 100644 diff --git a/wisprTests/UIThemeEngineTests.swift b/wisprTests/UIThemeEngineTests.swift old mode 100755 new mode 100644 diff --git a/wisprTests/UpdateCheckerTests.swift b/wisprTests/UpdateCheckerTests.swift old mode 100755 new mode 100644 diff --git a/wisprTests/WhisperServiceTests.swift b/wisprTests/WhisperServiceTests.swift old mode 100755 new mode 100644 diff --git a/wisprTests/wisprTests.swift b/wisprTests/wisprTests.swift old mode 100755 new mode 100644 diff --git a/wisprUITests/wisprUITests.swift b/wisprUITests/wisprUITests.swift old mode 100755 new mode 100644 diff --git a/wisprUITests/wisprUITestsLaunchTests.swift b/wisprUITests/wisprUITestsLaunchTests.swift old mode 100755 new mode 100644 From 369a48e69babac5ab0b87fe5372da2e377350691 Mon Sep 17 00:00:00 2001 From: Gabriel Bruno <242597616+gbrunoo@users.noreply.github.com> Date: Sun, 3 May 2026 10:33:51 +0200 Subject: [PATCH 4/5] Fix: meeting window can be reopened after closing via red X button Add NSWindowDelegate conformance to MeetingWindowPanel so that windowWillClose syncs isVisible back to false. Without this, the guard in show() would early-return because isVisible was stale. --- wispr/UI/Meeting/MeetingWindowPanel.swift | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/wispr/UI/Meeting/MeetingWindowPanel.swift b/wispr/UI/Meeting/MeetingWindowPanel.swift index 917f5a3..61a0b2b 100644 --- a/wispr/UI/Meeting/MeetingWindowPanel.swift +++ b/wispr/UI/Meeting/MeetingWindowPanel.swift @@ -14,7 +14,7 @@ import SwiftUI /// Unlike the compact RecordingOverlayPanel, this is a resizable window /// with title bar, close button, and full transcript view. @MainActor -final class MeetingWindowPanel { +final class MeetingWindowPanel: NSObject, NSWindowDelegate { // MARK: - Properties @@ -89,10 +89,23 @@ final class MeetingWindowPanel { panel.contentView = hostingView panel.minSize = NSSize(width: 320, height: 300) panel.isReleasedWhenClosed = false + panel.delegate = self self.panel = panel } + // MARK: - NSWindowDelegate + + /// Called when the user closes the window via the red X button. + /// Syncs both the panel's flag and the state manager's observable property + /// so the observation loop can re-trigger on the next menu click. + func windowWillClose(_ notification: Notification) { + isVisible = false + meetingStateManager.isWindowVisible = false + } + + // MARK: - Positioning + private func positionPanel(_ panel: NSPanel) { guard let screen = NSScreen.main else { return } let screenFrame = screen.visibleFrame From aad2640a58474be43da6c59e532e30716c052ab2 Mon Sep 17 00:00:00 2001 From: Gabriel Bruno <242597616+gbrunoo@users.noreply.github.com> Date: Mon, 4 May 2026 08:38:10 +0200 Subject: [PATCH 5/5] Refactor for Swift 6 structured concurrency compliance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MeetingAudioEngine: - Replace per-buffer Task spawning with AsyncStream bridge pattern. Tap callbacks yield into continuations; single consumer tasks on the actor read from the streams. Eliminates hundreds of short-lived tasks under heavy audio load and provides natural backpressure. - Replace @unchecked Sendable with plain Sendable on SystemAudioOutputHandler (final class with immutable let property). MeetingStateManager: - Remove 5 redundant await MainActor.run {} wrappers. Since the class is @MainActor, spawned Tasks inherit isolation — direct property access is correct. - Replace 5 unstructured Task properties with single recordingTask using withTaskGroup. Cancelling the parent cascades to all children. Add cancelRecording() for synchronous cancellation at app termination. wisprApp: - Replace fire-and-forget Task { await msm.stopMeeting() } in applicationWillTerminate with synchronous cancelRecording() call. --- wispr/Services/MeetingAudioEngine.swift | 127 +++++++++++----- wispr/Services/MeetingStateManager.swift | 186 ++++++++++------------- wispr/wisprApp.swift | 26 ++-- 3 files changed, 185 insertions(+), 154 deletions(-) diff --git a/wispr/Services/MeetingAudioEngine.swift b/wispr/Services/MeetingAudioEngine.swift index 6f55bc0..8fa8bdc 100644 --- a/wispr/Services/MeetingAudioEngine.swift +++ b/wispr/Services/MeetingAudioEngine.swift @@ -41,6 +41,11 @@ actor MeetingAudioEngine { private var micLevelContinuation: AsyncStream.Continuation? private var systemLevelContinuation: AsyncStream.Continuation? + private var micSampleBridgeContinuation: AsyncStream<[Float]>.Continuation? + private var micConsumerTask: Task? + private var systemSampleBridgeContinuation: AsyncStream<[Float]>.Continuation? + private var systemConsumerTask: Task? + private var isCapturing = false /// Whether system audio capture is active (may be false if permission denied). @@ -63,7 +68,9 @@ actor MeetingAudioEngine { /// /// - Returns: A tuple of (micLevelStream, systemLevelStream) for UI visualization. /// - Throws: If microphone capture fails to start. - func startCapture() async throws -> (micLevels: AsyncStream, systemLevels: AsyncStream) { + func startCapture() async throws -> ( + micLevels: AsyncStream, systemLevels: AsyncStream + ) { guard !isCapturing else { throw WisprError.audioRecordingFailed("Meeting capture already active") } @@ -90,7 +97,9 @@ actor MeetingAudioEngine { systemLevels = try await startSystemAudioCapture() hasSystemAudio = true } catch { - Log.audioEngine.warning("MeetingAudioEngine — system audio unavailable: \(error.localizedDescription). Continuing with mic only.") + Log.audioEngine.warning( + "MeetingAudioEngine — system audio unavailable: \(error.localizedDescription). Continuing with mic only." + ) hasSystemAudio = false // Return a silent level stream let (silentStream, silentCont) = AsyncStream.makeStream(of: Float.self) @@ -150,16 +159,21 @@ actor MeetingAudioEngine { let (levelStream, levelContinuation) = AsyncStream.makeStream(of: Float.self) self.micLevelContinuation = levelContinuation - guard let targetFormat = AVAudioFormat( - commonFormat: .pcmFormatFloat32, - sampleRate: 16000, - channels: 1, - interleaved: false - ) else { + guard + let targetFormat = AVAudioFormat( + commonFormat: .pcmFormatFloat32, + sampleRate: 16000, + channels: 1, + interleaved: false + ) + else { levelContinuation.finish() return levelStream } + let (micBridgeStream, micBridgeCont) = AsyncStream.makeStream(of: [Float].self) + self.micSampleBridgeContinuation = micBridgeCont + let audioEngine = AVAudioEngine() self.micEngine = audioEngine let inputNode = audioEngine.inputNode @@ -167,13 +181,15 @@ actor MeetingAudioEngine { nonisolated(unsafe) var converter: AVAudioConverter? nonisolated(unsafe) var sampleRateRatio: Double = 0 + let bridgeContinuation = micBridgeCont inputNode.installTap(onBus: 0, bufferSize: 4096, format: nil) { [weak self] buffer, _ in - guard let self, buffer.frameLength > 0 else { return } + guard self != nil, buffer.frameLength > 0 else { return } if converter == nil { let bufferFormat = buffer.format guard bufferFormat.sampleRate > 0, bufferFormat.channelCount > 0 else { return } - guard let newConverter = AVAudioConverter(from: bufferFormat, to: targetFormat) else { return } + guard let newConverter = AVAudioConverter(from: bufferFormat, to: targetFormat) + else { return } converter = newConverter sampleRateRatio = targetFormat.sampleRate / bufferFormat.sampleRate } @@ -181,37 +197,47 @@ actor MeetingAudioEngine { guard let tapConverter = converter else { return } let outputFrameCount = AVAudioFrameCount(Double(buffer.frameLength) * sampleRateRatio) - guard let outputBuffer = AVAudioPCMBuffer( - pcmFormat: targetFormat, - frameCapacity: outputFrameCount - ) else { return } + guard + let outputBuffer = AVAudioPCMBuffer( + pcmFormat: targetFormat, + frameCapacity: outputFrameCount + ) + else { return } nonisolated(unsafe) let inputBuffer = buffer var conversionError: NSError? - let status = tapConverter.convert(to: outputBuffer, error: &conversionError) { _, outStatus in + let status = tapConverter.convert(to: outputBuffer, error: &conversionError) { + _, outStatus in outStatus.pointee = .haveData return inputBuffer } guard status != .error, - let channelData = outputBuffer.floatChannelData?[0], - outputBuffer.frameLength > 0 else { return } + let channelData = outputBuffer.floatChannelData?[0], + outputBuffer.frameLength > 0 + else { return } - let samples = Array(UnsafeBufferPointer(start: channelData, count: Int(outputBuffer.frameLength))) + let samples = Array( + UnsafeBufferPointer(start: channelData, count: Int(outputBuffer.frameLength))) - Task { - await self.processMicSamples(samples) - } + bridgeContinuation.yield(samples) } do { try audioEngine.start() Log.audioEngine.debug("MeetingAudioEngine — mic capture started") } catch { - Log.audioEngine.error("MeetingAudioEngine — mic engine start failed: \(error.localizedDescription)") + Log.audioEngine.error( + "MeetingAudioEngine — mic engine start failed: \(error.localizedDescription)") teardownMic() } + micConsumerTask = Task { + for await samples in micBridgeStream { + self.processMicSamples(samples) + } + } + return levelStream } @@ -227,12 +253,17 @@ actor MeetingAudioEngine { if micBuffer.count >= chunkSize { let chunk = Array(micBuffer.prefix(chunkSize)) micBuffer.removeFirst(min(chunkSize, micBuffer.count)) - Log.audioEngine.debug("MeetingAudioEngine — yielding mic chunk of \(chunk.count) samples") + Log.audioEngine.debug( + "MeetingAudioEngine — yielding mic chunk of \(chunk.count) samples") micContinuation?.yield(chunk) } } private func teardownMic() { + micConsumerTask?.cancel() + micConsumerTask = nil + micSampleBridgeContinuation?.finish() + micSampleBridgeContinuation = nil guard let engine = micEngine else { return } engine.stop() engine.inputNode.removeTap(onBus: 0) @@ -251,7 +282,8 @@ actor MeetingAudioEngine { let (levelStream, levelContinuation) = AsyncStream.makeStream(of: Float.self) self.systemLevelContinuation = levelContinuation - let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: false) + let content = try await SCShareableContent.excludingDesktopWindows( + false, onScreenWindowsOnly: false) guard let display = content.displays.first else { throw WisprError.audioRecordingFailed("No display found for system audio capture") @@ -268,19 +300,27 @@ actor MeetingAudioEngine { config.sampleRate = 16000 config.channelCount = 1 - let handler = SystemAudioOutputHandler { [weak self] samples in - guard let self else { return } - Task { - await self.processSystemSamples(samples) - } + let (systemBridgeStream, systemBridgeCont) = AsyncStream.makeStream(of: [Float].self) + self.systemSampleBridgeContinuation = systemBridgeCont + + let handler = SystemAudioOutputHandler { samples in + systemBridgeCont.yield(samples) } self.systemStreamOutput = handler let stream = SCStream(filter: filter, configuration: config, delegate: nil) - try stream.addStreamOutput(handler, type: .audio, sampleHandlerQueue: DispatchQueue(label: "wispr.meeting.systemAudio")) + try stream.addStreamOutput( + handler, type: .audio, + sampleHandlerQueue: DispatchQueue(label: "wispr.meeting.systemAudio")) try await stream.startCapture() self.systemStream = stream + systemConsumerTask = Task { + for await samples in systemBridgeStream { + self.processSystemSamples(samples) + } + } + Log.audioEngine.debug("MeetingAudioEngine — system audio capture started") return levelStream } @@ -297,12 +337,17 @@ actor MeetingAudioEngine { if systemBuffer.count >= chunkSize { let chunk = Array(systemBuffer.prefix(chunkSize)) systemBuffer.removeFirst(min(chunkSize, systemBuffer.count)) - Log.audioEngine.debug("MeetingAudioEngine — yielding system chunk of \(chunk.count) samples") + Log.audioEngine.debug( + "MeetingAudioEngine — yielding system chunk of \(chunk.count) samples") systemContinuation?.yield(chunk) } } private func teardownSystemAudio() { + systemConsumerTask?.cancel() + systemConsumerTask = nil + systemSampleBridgeContinuation?.finish() + systemSampleBridgeContinuation = nil systemStream = nil systemStreamOutput = nil systemBuffer.removeAll() @@ -323,7 +368,7 @@ actor MeetingAudioEngine { // MARK: - ScreenCaptureKit Audio Output Handler /// Receives audio sample buffers from SCStream and converts them to Float32 arrays. -final class SystemAudioOutputHandler: NSObject, SCStreamOutput, @unchecked Sendable { +final class SystemAudioOutputHandler: NSObject, SCStreamOutput, Sendable { private let onSamples: @Sendable ([Float]) -> Void @@ -331,7 +376,10 @@ final class SystemAudioOutputHandler: NSObject, SCStreamOutput, @unchecked Senda self.onSamples = onSamples } - nonisolated func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) { + nonisolated func stream( + _ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, + of type: SCStreamOutputType + ) { guard type == .audio else { return } guard sampleBuffer.isValid, sampleBuffer.numSamples > 0 else { return } @@ -339,17 +387,20 @@ final class SystemAudioOutputHandler: NSObject, SCStreamOutput, @unchecked Senda var length = 0 var dataPointer: UnsafeMutablePointer? - let status = CMBlockBufferGetDataPointer(blockBuffer, atOffset: 0, lengthAtOffsetOut: nil, totalLengthOut: &length, dataPointerOut: &dataPointer) + let status = CMBlockBufferGetDataPointer( + blockBuffer, atOffset: 0, lengthAtOffsetOut: nil, totalLengthOut: &length, + dataPointerOut: &dataPointer) guard status == noErr, let data = dataPointer, length > 0 else { return } let floatCount = length / MemoryLayout.size guard floatCount > 0 else { return } - let samples = Array(UnsafeBufferPointer( - start: data.withMemoryRebound(to: Float.self, capacity: floatCount) { $0 }, - count: floatCount - )) + let samples = Array( + UnsafeBufferPointer( + start: data.withMemoryRebound(to: Float.self, capacity: floatCount) { $0 }, + count: floatCount + )) onSamples(samples) } diff --git a/wispr/Services/MeetingStateManager.swift b/wispr/Services/MeetingStateManager.swift index e812ac3..862fe29 100644 --- a/wispr/Services/MeetingStateManager.swift +++ b/wispr/Services/MeetingStateManager.swift @@ -6,9 +6,9 @@ // Manages audio capture, continuous transcription, and transcript assembly. // +import AppKit import Foundation import Observation -import AppKit import UniformTypeIdentifiers import os @@ -65,11 +65,7 @@ final class MeetingStateManager { // MARK: - Tasks - private var micTranscriptionTask: Task? - private var systemTranscriptionTask: Task? - private var micLevelTask: Task? - private var systemLevelTask: Task? - private var timerTask: Task? + private var recordingTask: Task? // MARK: - Initialization @@ -100,19 +96,19 @@ final class MeetingStateManager { meetingState = .recording isWindowVisible = true - // Start consuming audio levels for UI - startMicLevelConsumption(micLevels) - startSystemLevelConsumption(systemLevels) - - // Start parallel transcription on both audio streams - startMicTranscription() - startSystemTranscription() - - // Start elapsed time timer - startTimer() + recordingTask = Task { + await withTaskGroup(of: Void.self) { group in + group.addTask { await self.consumeMicLevels(micLevels) } + group.addTask { await self.consumeSystemLevels(systemLevels) } + group.addTask { await self.transcribeMicAudio() } + group.addTask { await self.transcribeSystemAudio() } + group.addTask { await self.runTimer() } + } + } } catch { - Log.stateManager.error("MeetingStateManager — failed to start: \(error.localizedDescription)") + Log.stateManager.error( + "MeetingStateManager — failed to start: \(error.localizedDescription)") await handleError("Failed to start meeting capture: \(error.localizedDescription)") } } @@ -123,24 +119,11 @@ final class MeetingStateManager { Log.stateManager.debug("MeetingStateManager — stopping meeting") - // Flush remaining audio buffers before stopping await meetingAudioEngine.flushBuffers() - - // Give transcription task a moment to process final chunks try? await Task.sleep(for: .milliseconds(500)) - // Cancel all tasks - micTranscriptionTask?.cancel() - systemTranscriptionTask?.cancel() - micLevelTask?.cancel() - systemLevelTask?.cancel() - timerTask?.cancel() - - micTranscriptionTask = nil - systemTranscriptionTask = nil - micLevelTask = nil - systemLevelTask = nil - timerTask = nil + recordingTask?.cancel() + recordingTask = nil await meetingAudioEngine.stopCapture() @@ -186,107 +169,98 @@ final class MeetingStateManager { try text.write(to: url, atomically: true, encoding: .utf8) Log.stateManager.debug("MeetingStateManager — transcript exported to \(url.path)") } catch { - Log.stateManager.error("MeetingStateManager — export failed: \(error.localizedDescription)") + Log.stateManager.error( + "MeetingStateManager — export failed: \(error.localizedDescription)") } } } // MARK: - Transcription - private func startMicTranscription() { - micTranscriptionTask = Task { [weak self] in - guard let self else { return } - let audioStream = await self.meetingAudioEngine.micAudioStream - let language = self.settingsStore.languageMode - - for await chunk in audioStream { - guard !Task.isCancelled else { break } - guard chunk.count >= 8000 else { continue } - - do { - let result = try await self.transcriptionEngine.transcribe(chunk, language: language) - let text = result.text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { continue } - - await MainActor.run { - self.transcript.entries.append( - MeetingTranscriptEntry(speaker: .you, text: text) - ) - } - } catch { - if case WisprError.emptyTranscription = error { continue } - Log.stateManager.warning("MeetingStateManager — mic transcription error: \(error.localizedDescription)") - } + private func transcribeMicAudio() async { + let audioStream = await meetingAudioEngine.micAudioStream + let language = settingsStore.languageMode + + for await chunk in audioStream { + guard !Task.isCancelled else { break } + guard chunk.count >= 8000 else { continue } + + do { + let result = try await transcriptionEngine.transcribe(chunk, language: language) + let text = result.text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { continue } + + transcript.entries.append( + MeetingTranscriptEntry(speaker: .you, text: text) + ) + } catch { + if case WisprError.emptyTranscription = error { continue } + Log.stateManager.warning( + "MeetingStateManager — mic transcription error: \(error.localizedDescription)") } } } - private func startSystemTranscription() { - systemTranscriptionTask = Task { [weak self] in - guard let self else { return } - let audioStream = await self.meetingAudioEngine.systemAudioStream - let language = self.settingsStore.languageMode - - for await chunk in audioStream { - guard !Task.isCancelled else { break } - guard chunk.count >= 8000 else { continue } - - do { - let result = try await self.transcriptionEngine.transcribe(chunk, language: language) - let text = result.text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { continue } - - await MainActor.run { - self.transcript.entries.append( - MeetingTranscriptEntry(speaker: .others, text: text) - ) - } - } catch { - if case WisprError.emptyTranscription = error { continue } - Log.stateManager.warning("MeetingStateManager — system transcription error: \(error.localizedDescription)") - } + private func transcribeSystemAudio() async { + let audioStream = await meetingAudioEngine.systemAudioStream + let language = settingsStore.languageMode + + for await chunk in audioStream { + guard !Task.isCancelled else { break } + guard chunk.count >= 8000 else { continue } + + do { + let result = try await transcriptionEngine.transcribe(chunk, language: language) + let text = result.text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { continue } + + transcript.entries.append( + MeetingTranscriptEntry(speaker: .others, text: text) + ) + } catch { + if case WisprError.emptyTranscription = error { continue } + Log.stateManager.warning( + "MeetingStateManager — system transcription error: \(error.localizedDescription)" + ) } } } // MARK: - Audio Level Consumption - private func startMicLevelConsumption(_ stream: AsyncStream) { - micLevelTask = Task { [weak self] in - for await level in stream { - guard !Task.isCancelled else { break } - await MainActor.run { - self?.micLevel = level - } - } + private func consumeMicLevels(_ stream: AsyncStream) async { + for await level in stream { + guard !Task.isCancelled else { break } + self.micLevel = level } } - private func startSystemLevelConsumption(_ stream: AsyncStream) { - systemLevelTask = Task { [weak self] in - for await level in stream { - guard !Task.isCancelled else { break } - await MainActor.run { - self?.systemLevel = level - } - } + private func consumeSystemLevels(_ stream: AsyncStream) async { + for await level in stream { + guard !Task.isCancelled else { break } + self.systemLevel = level } } // MARK: - Timer - private func startTimer() { - timerTask = Task { [weak self] in - while !Task.isCancelled { - try? await Task.sleep(for: .seconds(1)) - guard !Task.isCancelled else { break } - await MainActor.run { - self?.elapsedTime = self?.transcript.formattedDuration ?? "0:00" - } - } + private func runTimer() async { + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled else { break } + self.elapsedTime = self.transcript.formattedDuration ?? "0:00" } } + // MARK: - Cancellation + + /// Cancels all recording tasks immediately. Safe to call synchronously + /// (e.g. from applicationWillTerminate). + func cancelRecording() { + recordingTask?.cancel() + recordingTask = nil + } + // MARK: - Error Handling private func handleError(_ message: String) async { diff --git a/wispr/wisprApp.swift b/wispr/wisprApp.swift index 70eaed1..df1990b 100644 --- a/wispr/wisprApp.swift +++ b/wispr/wisprApp.swift @@ -73,7 +73,7 @@ final class WisprAppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate /// Composite engine aggregating WhisperKit and Parakeet V3 behind a single interface. let whisperService: any TranscriptionEngine = CompositeTranscriptionEngine(engines: [ WhisperService(), - ParakeetService() + ParakeetService(), ]) /// Text insertion via Accessibility API / clipboard fallback. @@ -209,7 +209,8 @@ final class WisprAppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate modifiers: settingsStore.hotkeyModifiers ) } catch { - Log.hotkey.error("bootstrap — hotkey registration failed: \(error.localizedDescription)") + Log.hotkey.error( + "bootstrap — hotkey registration failed: \(error.localizedDescription)") } // Start theme engine monitoring for appearance / accessibility changes @@ -235,7 +236,9 @@ final class WisprAppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate let updater = updateChecker updateCheckTask = Task { await updater.checkForUpdate() - Log.updateChecker.info("Update check task completed — availableUpdate: \(updater.availableUpdate?.version ?? "none")") + Log.updateChecker.info( + "Update check task completed — availableUpdate: \(updater.availableUpdate?.version ?? "none")" + ) } // Requirement 13.1, 13.12: Show onboarding on first launch @@ -277,7 +280,8 @@ final class WisprAppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate /// the onboarding-completed flag so the wizard reappears on next launch (Req 13.16). func windowWillClose(_ notification: Notification) { guard let closingWindow = notification.object as? NSWindow, - closingWindow === onboardingWindow else { return } + closingWindow === onboardingWindow + else { return } if !settingsStore.onboardingCompleted { NSApplication.shared.terminate(nil) } @@ -291,10 +295,8 @@ final class WisprAppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate permissionMonitoringTask?.cancel() updateCheckTask?.cancel() - // Stop any active meeting session - if let msm = meetingStateManager { - Task { await msm.stopMeeting() } - } + // Stop any active meeting session (synchronous — cascades via task group) + meetingStateManager?.cancelRecording() // Force UserDefaults to flush to disk before the process exits. settingsStore.flush() @@ -373,7 +375,9 @@ final class WisprAppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate ) Log.app.debug("hotkeyObservation — re-registered hotkey") } catch { - Log.app.error("hotkeyObservation — failed to re-register hotkey: \(error.localizedDescription)") + Log.app.error( + "hotkeyObservation — failed to re-register hotkey: \(error.localizedDescription)" + ) } } } @@ -421,7 +425,9 @@ final class WisprAppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate if settingsStore.showRecordingOverlay, let overlay = overlayPanel, !overlay.isVisible { Log.app.debug("overlayObservation — showing overlay for state: \(state)") overlay.show() - } else if !settingsStore.showRecordingOverlay, let overlay = overlayPanel, overlay.isVisible { + } else if !settingsStore.showRecordingOverlay, let overlay = overlayPanel, + overlay.isVisible + { overlay.dismiss() } case .idle: