From dc2ced67afb03933edc00aeb681f2bc6c6f920c5 Mon Sep 17 00:00:00 2001
From: Young Han <110819238+seyeong-han@users.noreply.github.com>
Date: Fri, 15 May 2026 15:09:43 -0700
Subject: [PATCH 1/8] ExecuWhisper macOS app for executorch-examples
Add a native macOS dictation app that runs fully on-device using ExecuTorch:
NVIDIA Parakeet-TDT for ASR (Metal backend) plus a fine-tuned LiquidAI
LFM2.5-350M for cleaning up disfluencies, casing, and punctuation (MLX
delegate).
Layout follows the voxtral_realtime/macos/ convention:
execuwhisper/
macos/
ExecuWhisper/ Swift app source
ExecuWhisperTests/ XCTest target
docs/ Demo script, support runbook, release QA checklist
scripts/ Build / DMG / sign / verify / probe scripts
project.yml xcodegen spec (no DEVELOPMENT_TEAM hard-coded;
supply via env var)
README.md Public README with prebuilt + from-source paths
THIRD_PARTY_NOTICES Upstream component attribution
CHANGELOG.md v0.1.0 initial open-source release notes
.gitignore xcodeproj/, build/, DMG, etc.
Models live in two Hugging Face repos:
younghan-meta/Parakeet-TDT-ExecuTorch-Metal (ASR runtime)
younghan-meta/LFM2.5-350M-ExecuWhisper-Formatter (formatter runtime + fp32)
Helper binaries depend on three upstream ExecuTorch PRs in review:
pytorch/executorch#18861 - parakeet_helper (ASR runtime)
pytorch/executorch#19195 - LFM2.5 MLX export pipeline
pytorch/executorch#19562 - lfm25_formatter_helper (formatter runtime)
Until those land, build via the README from-source path or use the
prebuilt arm64 helpers attached to the GitHub Release on this PR.
Eval: AMI release-gate run for the formatter shows forbidden 0.030 (gate
0.10) and coverage 0.874 (gate 0.85). Full eval reports in the formatter
HF repo under eval/.
No telemetry. The only network call is the first-launch model download
from huggingface.co.
---
execuwhisper/macos/.gitignore | 25 +
execuwhisper/macos/CHANGELOG.md | 21 +
.../ExecuWhisper/ExecuWhisper.entitlements | 12 +
.../macos/ExecuWhisper/ExecuWhisperApp.swift | 159 ++++
execuwhisper/macos/ExecuWhisper/Info.plist | 8 +
.../Models/DictationShortcut.swift | 110 +++
.../ExecuWhisper/Models/Preferences.swift | 283 ++++++
.../Models/ReplacementEntry.swift | 37 +
.../macos/ExecuWhisper/Models/Session.swift | 80 ++
.../ExecuWhisper/Models/TranscriptStore.swift | 843 ++++++++++++++++++
.../AccentColor.colorset/Contents.json | 20 +
.../AppIcon.appiconset/Contents.json | 68 ++
.../AppIcon.appiconset/icon_128.png | Bin 0 -> 16553 bytes
.../AppIcon.appiconset/icon_128@2x.png | Bin 0 -> 56899 bytes
.../AppIcon.appiconset/icon_16.png | Bin 0 -> 1298 bytes
.../AppIcon.appiconset/icon_16@2x.png | Bin 0 -> 2554 bytes
.../AppIcon.appiconset/icon_256.png | Bin 0 -> 56899 bytes
.../AppIcon.appiconset/icon_256@2x.png | Bin 0 -> 189962 bytes
.../AppIcon.appiconset/icon_32.png | Bin 0 -> 2554 bytes
.../AppIcon.appiconset/icon_32@2x.png | Bin 0 -> 5875 bytes
.../AppIcon.appiconset/icon_512.png | Bin 0 -> 189962 bytes
.../AppIcon.appiconset/icon_512@2x.png | Bin 0 -> 605965 bytes
.../Resources/Assets.xcassets/Contents.json | 6 +
.../Resources/model_manifest.json | 29 +
.../ExecuWhisper/Services/AudioRecorder.swift | 432 +++++++++
.../Services/DictationManager.swift | 323 +++++++
.../Services/FormatterBridge.swift | 495 ++++++++++
.../Services/FormatterHelperProtocol.swift | 144 +++
.../Services/FormatterPromptBuilder.swift | 92 ++
.../Services/GlobalHotKeyManager.swift | 103 +++
.../ExecuWhisper/Services/HealthCheck.swift | 99 ++
.../Services/ImportedAudioDecoder.swift | 144 +++
.../Services/ModelDownloader.swift | 439 +++++++++
.../Services/ParakeetHelperProtocol.swift | 153 ++++
.../Services/PasteController.swift | 217 +++++
.../Services/ReplacementStore.swift | 75 ++
.../ExecuWhisper/Services/RunnerBridge.swift | 590 ++++++++++++
.../ExecuWhisper/Services/TextPipeline.swift | 303 +++++++
.../Support/PasteHelper/main.swift | 129 +++
.../Utilities/DiagnosticLogging.swift | 30 +
.../Utilities/PersistencePaths.swift | 36 +
.../ExecuWhisper/Utilities/RunnerError.swift | 76 ++
.../Utilities/SessionExportFormat.swift | 95 ++
.../Utilities/SessionHistory.swift | 82 ++
.../ExecuWhisper/Views/AudioLevelView.swift | 54 ++
.../ExecuWhisper/Views/ContentView.swift | 172 ++++
.../Views/DictationOverlayView.swift | 32 +
.../ExecuWhisper/Views/DictationPanel.swift | 46 +
.../ExecuWhisper/Views/ErrorBannerView.swift | 46 +
.../Views/RecordingControls.swift | 137 +++
.../Views/ReplacementManagementView.swift | 155 ++++
.../ExecuWhisper/Views/SettingsView.swift | 228 +++++
.../ExecuWhisper/Views/SetupGuideView.swift | 126 +++
.../Views/ShortcutRecorderView.swift | 82 ++
.../ExecuWhisper/Views/SidebarView.swift | 222 +++++
.../ExecuWhisper/Views/TranscriptView.swift | 101 +++
.../ExecuWhisper/Views/WelcomeView.swift | 282 ++++++
.../AudioRecorderTests.swift | 156 ++++
.../DictationManagerTests.swift | 76 ++
.../FormatterBridgeTests.swift | 158 ++++
.../FormatterHelperProtocolTests.swift | 64 ++
.../FormatterPromptBuilderTests.swift | 178 ++++
.../ImportedAudioDecoderTests.swift | 80 ++
.../ParakeetHelperProtocolTests.swift | 72 ++
.../PersistenceRegressionTests.swift | 108 +++
.../PreferencesFormattingTests.swift | 52 ++
.../ExecuWhisperTests/RunnerBridgeTests.swift | 229 +++++
.../SessionCompatibilityTests.swift | 78 ++
.../SessionExportTests.swift | 78 ++
.../SessionHistoryTests.swift | 129 +++
.../ExecuWhisperTests/TextPipelineTests.swift | 493 ++++++++++
.../TranscriptStoreLatencyTests.swift | 354 ++++++++
execuwhisper/macos/README.md | 275 ++++++
execuwhisper/macos/THIRD_PARTY_NOTICES.md | 69 ++
execuwhisper/macos/docs/DEMO_SCRIPT.md | 61 ++
.../macos/docs/RELEASE_QA_CHECKLIST.md | 52 ++
execuwhisper/macos/docs/SUPPORT_RUNBOOK.md | 63 ++
execuwhisper/macos/docs/architecture.png | Bin 0 -> 5509309 bytes
execuwhisper/macos/docs/logo.png | Bin 0 -> 56899 bytes
execuwhisper/macos/project.yml | 230 +++++
.../macos/scripts/benchmark_helper.py | 252 ++++++
execuwhisper/macos/scripts/build.sh | 286 ++++++
execuwhisper/macos/scripts/create_dmg.sh | 141 +++
execuwhisper/macos/scripts/probe_formatter.py | 298 +++++++
.../macos/scripts/requirements_et-metal.txt | 268 ++++++
.../macos/scripts/requirements_et-mlx.txt | 191 ++++
execuwhisper/macos/scripts/sign_release.sh | 67 ++
.../macos/scripts/verify_project_settings.sh | 73 ++
execuwhisper/macos/scripts/verify_release.sh | 63 ++
89 files changed, 12135 insertions(+)
create mode 100644 execuwhisper/macos/.gitignore
create mode 100644 execuwhisper/macos/CHANGELOG.md
create mode 100644 execuwhisper/macos/ExecuWhisper/ExecuWhisper.entitlements
create mode 100644 execuwhisper/macos/ExecuWhisper/ExecuWhisperApp.swift
create mode 100644 execuwhisper/macos/ExecuWhisper/Info.plist
create mode 100644 execuwhisper/macos/ExecuWhisper/Models/DictationShortcut.swift
create mode 100644 execuwhisper/macos/ExecuWhisper/Models/Preferences.swift
create mode 100644 execuwhisper/macos/ExecuWhisper/Models/ReplacementEntry.swift
create mode 100644 execuwhisper/macos/ExecuWhisper/Models/Session.swift
create mode 100644 execuwhisper/macos/ExecuWhisper/Models/TranscriptStore.swift
create mode 100644 execuwhisper/macos/ExecuWhisper/Resources/Assets.xcassets/AccentColor.colorset/Contents.json
create mode 100644 execuwhisper/macos/ExecuWhisper/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
create mode 100644 execuwhisper/macos/ExecuWhisper/Resources/Assets.xcassets/AppIcon.appiconset/icon_128.png
create mode 100644 execuwhisper/macos/ExecuWhisper/Resources/Assets.xcassets/AppIcon.appiconset/icon_128@2x.png
create mode 100644 execuwhisper/macos/ExecuWhisper/Resources/Assets.xcassets/AppIcon.appiconset/icon_16.png
create mode 100644 execuwhisper/macos/ExecuWhisper/Resources/Assets.xcassets/AppIcon.appiconset/icon_16@2x.png
create mode 100644 execuwhisper/macos/ExecuWhisper/Resources/Assets.xcassets/AppIcon.appiconset/icon_256.png
create mode 100644 execuwhisper/macos/ExecuWhisper/Resources/Assets.xcassets/AppIcon.appiconset/icon_256@2x.png
create mode 100644 execuwhisper/macos/ExecuWhisper/Resources/Assets.xcassets/AppIcon.appiconset/icon_32.png
create mode 100644 execuwhisper/macos/ExecuWhisper/Resources/Assets.xcassets/AppIcon.appiconset/icon_32@2x.png
create mode 100644 execuwhisper/macos/ExecuWhisper/Resources/Assets.xcassets/AppIcon.appiconset/icon_512.png
create mode 100644 execuwhisper/macos/ExecuWhisper/Resources/Assets.xcassets/AppIcon.appiconset/icon_512@2x.png
create mode 100644 execuwhisper/macos/ExecuWhisper/Resources/Assets.xcassets/Contents.json
create mode 100644 execuwhisper/macos/ExecuWhisper/Resources/model_manifest.json
create mode 100644 execuwhisper/macos/ExecuWhisper/Services/AudioRecorder.swift
create mode 100644 execuwhisper/macos/ExecuWhisper/Services/DictationManager.swift
create mode 100644 execuwhisper/macos/ExecuWhisper/Services/FormatterBridge.swift
create mode 100644 execuwhisper/macos/ExecuWhisper/Services/FormatterHelperProtocol.swift
create mode 100644 execuwhisper/macos/ExecuWhisper/Services/FormatterPromptBuilder.swift
create mode 100644 execuwhisper/macos/ExecuWhisper/Services/GlobalHotKeyManager.swift
create mode 100644 execuwhisper/macos/ExecuWhisper/Services/HealthCheck.swift
create mode 100644 execuwhisper/macos/ExecuWhisper/Services/ImportedAudioDecoder.swift
create mode 100644 execuwhisper/macos/ExecuWhisper/Services/ModelDownloader.swift
create mode 100644 execuwhisper/macos/ExecuWhisper/Services/ParakeetHelperProtocol.swift
create mode 100644 execuwhisper/macos/ExecuWhisper/Services/PasteController.swift
create mode 100644 execuwhisper/macos/ExecuWhisper/Services/ReplacementStore.swift
create mode 100644 execuwhisper/macos/ExecuWhisper/Services/RunnerBridge.swift
create mode 100644 execuwhisper/macos/ExecuWhisper/Services/TextPipeline.swift
create mode 100644 execuwhisper/macos/ExecuWhisper/Support/PasteHelper/main.swift
create mode 100644 execuwhisper/macos/ExecuWhisper/Utilities/DiagnosticLogging.swift
create mode 100644 execuwhisper/macos/ExecuWhisper/Utilities/PersistencePaths.swift
create mode 100644 execuwhisper/macos/ExecuWhisper/Utilities/RunnerError.swift
create mode 100644 execuwhisper/macos/ExecuWhisper/Utilities/SessionExportFormat.swift
create mode 100644 execuwhisper/macos/ExecuWhisper/Utilities/SessionHistory.swift
create mode 100644 execuwhisper/macos/ExecuWhisper/Views/AudioLevelView.swift
create mode 100644 execuwhisper/macos/ExecuWhisper/Views/ContentView.swift
create mode 100644 execuwhisper/macos/ExecuWhisper/Views/DictationOverlayView.swift
create mode 100644 execuwhisper/macos/ExecuWhisper/Views/DictationPanel.swift
create mode 100644 execuwhisper/macos/ExecuWhisper/Views/ErrorBannerView.swift
create mode 100644 execuwhisper/macos/ExecuWhisper/Views/RecordingControls.swift
create mode 100644 execuwhisper/macos/ExecuWhisper/Views/ReplacementManagementView.swift
create mode 100644 execuwhisper/macos/ExecuWhisper/Views/SettingsView.swift
create mode 100644 execuwhisper/macos/ExecuWhisper/Views/SetupGuideView.swift
create mode 100644 execuwhisper/macos/ExecuWhisper/Views/ShortcutRecorderView.swift
create mode 100644 execuwhisper/macos/ExecuWhisper/Views/SidebarView.swift
create mode 100644 execuwhisper/macos/ExecuWhisper/Views/TranscriptView.swift
create mode 100644 execuwhisper/macos/ExecuWhisper/Views/WelcomeView.swift
create mode 100644 execuwhisper/macos/ExecuWhisperTests/AudioRecorderTests.swift
create mode 100644 execuwhisper/macos/ExecuWhisperTests/DictationManagerTests.swift
create mode 100644 execuwhisper/macos/ExecuWhisperTests/FormatterBridgeTests.swift
create mode 100644 execuwhisper/macos/ExecuWhisperTests/FormatterHelperProtocolTests.swift
create mode 100644 execuwhisper/macos/ExecuWhisperTests/FormatterPromptBuilderTests.swift
create mode 100644 execuwhisper/macos/ExecuWhisperTests/ImportedAudioDecoderTests.swift
create mode 100644 execuwhisper/macos/ExecuWhisperTests/ParakeetHelperProtocolTests.swift
create mode 100644 execuwhisper/macos/ExecuWhisperTests/PersistenceRegressionTests.swift
create mode 100644 execuwhisper/macos/ExecuWhisperTests/PreferencesFormattingTests.swift
create mode 100644 execuwhisper/macos/ExecuWhisperTests/RunnerBridgeTests.swift
create mode 100644 execuwhisper/macos/ExecuWhisperTests/SessionCompatibilityTests.swift
create mode 100644 execuwhisper/macos/ExecuWhisperTests/SessionExportTests.swift
create mode 100644 execuwhisper/macos/ExecuWhisperTests/SessionHistoryTests.swift
create mode 100644 execuwhisper/macos/ExecuWhisperTests/TextPipelineTests.swift
create mode 100644 execuwhisper/macos/ExecuWhisperTests/TranscriptStoreLatencyTests.swift
create mode 100644 execuwhisper/macos/README.md
create mode 100644 execuwhisper/macos/THIRD_PARTY_NOTICES.md
create mode 100644 execuwhisper/macos/docs/DEMO_SCRIPT.md
create mode 100644 execuwhisper/macos/docs/RELEASE_QA_CHECKLIST.md
create mode 100644 execuwhisper/macos/docs/SUPPORT_RUNBOOK.md
create mode 100644 execuwhisper/macos/docs/architecture.png
create mode 100644 execuwhisper/macos/docs/logo.png
create mode 100644 execuwhisper/macos/project.yml
create mode 100644 execuwhisper/macos/scripts/benchmark_helper.py
create mode 100755 execuwhisper/macos/scripts/build.sh
create mode 100755 execuwhisper/macos/scripts/create_dmg.sh
create mode 100644 execuwhisper/macos/scripts/probe_formatter.py
create mode 100644 execuwhisper/macos/scripts/requirements_et-metal.txt
create mode 100644 execuwhisper/macos/scripts/requirements_et-mlx.txt
create mode 100755 execuwhisper/macos/scripts/sign_release.sh
create mode 100755 execuwhisper/macos/scripts/verify_project_settings.sh
create mode 100755 execuwhisper/macos/scripts/verify_release.sh
diff --git a/execuwhisper/macos/.gitignore b/execuwhisper/macos/.gitignore
new file mode 100644
index 0000000000..55814f3b6b
--- /dev/null
+++ b/execuwhisper/macos/.gitignore
@@ -0,0 +1,25 @@
+# Xcode generated files (run `xcodegen generate` to recreate the project)
+*.xcodeproj/
+xcuserdata/
+*.xcuserstate
+*.xcuserdatad/
+
+# Build outputs
+build/
+DerivedData/
+*.dmg
+
+# Python
+__pycache__/
+*.pyc
+.venv/
+
+# macOS
+.DS_Store
+
+# Local-only dictation samples and prompt-quality corpus.
+test_audio/
+evaluation/
+
+# Local notes / scratch
+docs/superpowers/
diff --git a/execuwhisper/macos/CHANGELOG.md b/execuwhisper/macos/CHANGELOG.md
new file mode 100644
index 0000000000..21d07eb436
--- /dev/null
+++ b/execuwhisper/macos/CHANGELOG.md
@@ -0,0 +1,21 @@
+# ExecuWhisper Changelog
+
+## v0.1.0 — Initial open-source release
+
+- Initial open-source publication of the ExecuWhisper macOS dictation app.
+- Apple Silicon-only (M1+); requires macOS 14.0 or newer.
+- ASR: NVIDIA Parakeet-TDT via the Metal backend (executorch helper from
+ pytorch/executorch#18861).
+- Formatter: fine-tuned LFM2.5-350M via the MLX delegate (executorch helper
+ from pytorch/executorch#19562; export pipeline from #19195).
+- Models distributed via Hugging Face Hub:
+ - `younghan-meta/Parakeet-TDT-ExecuTorch-Metal`
+ - `younghan-meta/LFM2.5-350M-ExecuWhisper-Formatter`
+- AMI release-gate eval for the formatter: forbidden 0.030 ≤ 0.10,
+ coverage 0.874 ≥ 0.85 (RELEASE-READY).
+- Build via `xcodegen generate` + `xcodebuild`. Set `DEVELOPMENT_TEAM` to
+ your Apple Developer team via env var; the project no longer hard-codes
+ a team identifier.
+- Helpers signed with the hardened runtime + `disable-library-validation`
+ + `allow-dyld-environment-variables` entitlements so they can load the
+ user-supplied `libomp.dylib` (install with `brew install libomp`).
diff --git a/execuwhisper/macos/ExecuWhisper/ExecuWhisper.entitlements b/execuwhisper/macos/ExecuWhisper/ExecuWhisper.entitlements
new file mode 100644
index 0000000000..0c8b3b6f3c
--- /dev/null
+++ b/execuwhisper/macos/ExecuWhisper/ExecuWhisper.entitlements
@@ -0,0 +1,12 @@
+
+
+
+
+ com.apple.security.cs.disable-library-validation
+
+ com.apple.security.device.audio-input
+
+ com.apple.security.network.client
+
+
+
diff --git a/execuwhisper/macos/ExecuWhisper/ExecuWhisperApp.swift b/execuwhisper/macos/ExecuWhisper/ExecuWhisperApp.swift
new file mode 100644
index 0000000000..6e4f3660ad
--- /dev/null
+++ b/execuwhisper/macos/ExecuWhisper/ExecuWhisperApp.swift
@@ -0,0 +1,159 @@
+/*
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import AppKit
+import SwiftUI
+
+@main
+struct ExecuWhisperApp: App {
+ @State private var preferences = Preferences()
+ @State private var downloader = ModelDownloader()
+ @State private var replacementStore = ReplacementStore()
+ @State private var store: TranscriptStore
+ @State private var dictationManager: DictationManager
+
+ init() {
+ let prefs = Preferences()
+ let downloader = ModelDownloader()
+ let replacementStore = ReplacementStore()
+ let formatterBridge = FormatterBridge()
+ let textPipeline = TextPipeline(
+ replacementStore: replacementStore,
+ formatterBridge: formatterBridge
+ ) {
+ TextPipeline.FormatterPaths(
+ runnerPath: prefs.formatterRunnerPath,
+ modelPath: prefs.formatterModelPath,
+ tokenizerPath: prefs.formatterTokenizerPath,
+ tokenizerConfigPath: prefs.formatterTokenizerConfigPath
+ )
+ }
+ let store = TranscriptStore(
+ preferences: prefs,
+ downloader: downloader,
+ textPipeline: textPipeline
+ )
+ let dictationManager = DictationManager(store: store, preferences: prefs)
+ _preferences = State(initialValue: prefs)
+ _downloader = State(initialValue: downloader)
+ _replacementStore = State(initialValue: replacementStore)
+ _store = State(initialValue: store)
+ _dictationManager = State(initialValue: dictationManager)
+ }
+
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ .environment(store)
+ .environment(preferences)
+ .environment(downloader)
+ .environment(replacementStore)
+ .environment(dictationManager)
+ .frame(minWidth: 700, minHeight: 460)
+ .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
+ Task { await store.runHealthCheck() }
+ }
+ }
+ .defaultSize(width: 960, height: 640)
+ .windowToolbarStyle(.unified)
+ .commands {
+ CommandGroup(replacing: .newItem) {}
+
+ CommandMenu("Transcription") {
+ switch store.sessionState {
+ case .idle:
+ Button("Start Recording") {
+ Task { await store.startRecording() }
+ }
+ .keyboardShortcut("R", modifiers: [.command, .shift])
+ .disabled(!store.isModelReady)
+
+ case .recording:
+ Button("Stop and Transcribe") {
+ Task { await store.stopRecordingAndTranscribe() }
+ }
+ .keyboardShortcut("R", modifiers: [.command, .shift])
+
+ case .transcribing:
+ Button("Transcribing...") {}
+ .disabled(true)
+ }
+
+ Button("Import Audio...") {
+ store.importAudioFileWithPanel()
+ }
+ .disabled(store.hasActiveSession || downloader.isDownloading)
+
+ if store.healthResult?.shouldOfferModelDownload == true && !downloader.isDownloading {
+ Divider()
+ Button("Download Model") {
+ Task { await store.downloadModel() }
+ }
+ }
+
+ if store.resourcesReady && !store.hasActiveSession {
+ Divider()
+ switch store.helperState {
+ case .unloaded:
+ Button("Preload Model") {
+ Task { await store.preloadModel() }
+ }
+ .keyboardShortcut("L", modifiers: [.command, .shift])
+
+ case .loading:
+ Button("Warming Model...") {}
+ .disabled(true)
+
+ case .warm:
+ Button("Unload Model") {
+ Task { await store.unloadModel() }
+ }
+ .keyboardShortcut("U", modifiers: [.command, .shift])
+
+ case .failed:
+ Button("Retry Preload") {
+ Task { await store.preloadModel() }
+ }
+ }
+ }
+
+ Divider()
+
+ Button("Copy Transcript") {
+ let text = currentTranscript
+ guard !text.isEmpty else { return }
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(text, forType: .string)
+ }
+ .keyboardShortcut("C", modifiers: [.command, .shift])
+ .disabled(currentTranscript.isEmpty)
+ }
+
+ CommandMenu("Dictation") {
+ Button(dictationManager.isListening ? "Stop Dictation" : "Start Dictation") {
+ Task { await dictationManager.toggle() }
+ }
+ .disabled(store.isTranscribing)
+ }
+ }
+
+ Settings {
+ SettingsView(usesFixedWindowSize: true)
+ .environment(preferences)
+ .environment(dictationManager)
+ }
+ }
+
+ private var currentTranscript: String {
+ if store.hasActiveSession {
+ return store.liveTranscript
+ }
+ guard let id = store.selectedSessionID else { return "" }
+ return store.sessions.first(where: { $0.id == id })?.transcript ?? ""
+ }
+}
diff --git a/execuwhisper/macos/ExecuWhisper/Info.plist b/execuwhisper/macos/ExecuWhisper/Info.plist
new file mode 100644
index 0000000000..eb385a63d1
--- /dev/null
+++ b/execuwhisper/macos/ExecuWhisper/Info.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ NSMicrophoneUsageDescription
+ ExecuWhisper needs microphone access to record audio for on-device transcription.
+
+
diff --git a/execuwhisper/macos/ExecuWhisper/Models/DictationShortcut.swift b/execuwhisper/macos/ExecuWhisper/Models/DictationShortcut.swift
new file mode 100644
index 0000000000..7cf301bc54
--- /dev/null
+++ b/execuwhisper/macos/ExecuWhisper/Models/DictationShortcut.swift
@@ -0,0 +1,110 @@
+/*
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import AppKit
+import Carbon.HIToolbox
+import Foundation
+
+struct DictationShortcut: Codable, Equatable, Sendable {
+ var keyCode: UInt32
+ var carbonModifiers: UInt32
+ var keyDisplay: String
+
+ static let controlSpace = DictationShortcut(
+ keyCode: UInt32(kVK_Space),
+ carbonModifiers: UInt32(controlKey),
+ keyDisplay: "Space"
+ )
+
+ init(keyCode: UInt32, carbonModifiers: UInt32, keyDisplay: String) {
+ self.keyCode = keyCode
+ self.carbonModifiers = carbonModifiers
+ self.keyDisplay = keyDisplay
+ }
+
+ init?(event: NSEvent) {
+ let carbonModifiers = Self.carbonModifiers(from: event.modifierFlags)
+ guard carbonModifiers != 0 else { return nil }
+ guard let keyDisplay = Self.keyDisplay(for: event) else { return nil }
+ self.init(
+ keyCode: UInt32(event.keyCode),
+ carbonModifiers: carbonModifiers,
+ keyDisplay: keyDisplay
+ )
+ }
+
+ var displayString: String {
+ var value = ""
+ if carbonModifiers & UInt32(controlKey) != 0 {
+ value += "⌃"
+ }
+ if carbonModifiers & UInt32(optionKey) != 0 {
+ value += "⌥"
+ }
+ if carbonModifiers & UInt32(shiftKey) != 0 {
+ value += "⇧"
+ }
+ if carbonModifiers & UInt32(cmdKey) != 0 {
+ value += "⌘"
+ }
+ return value + keyDisplay
+ }
+
+ static func carbonModifiers(from flags: NSEvent.ModifierFlags) -> UInt32 {
+ let sanitized = flags.intersection(.deviceIndependentFlagsMask)
+ var value: UInt32 = 0
+ if sanitized.contains(.control) {
+ value |= UInt32(controlKey)
+ }
+ if sanitized.contains(.option) {
+ value |= UInt32(optionKey)
+ }
+ if sanitized.contains(.shift) {
+ value |= UInt32(shiftKey)
+ }
+ if sanitized.contains(.command) {
+ value |= UInt32(cmdKey)
+ }
+ return value
+ }
+
+ private static func keyDisplay(for event: NSEvent) -> String? {
+ switch Int(event.keyCode) {
+ case kVK_Space:
+ return "Space"
+ case kVK_Return:
+ return "Return"
+ case kVK_Tab:
+ return "Tab"
+ case kVK_Delete:
+ return "Delete"
+ case kVK_ForwardDelete:
+ return "Fn-Delete"
+ case kVK_Escape:
+ return "Esc"
+ case kVK_LeftArrow:
+ return "Left"
+ case kVK_RightArrow:
+ return "Right"
+ case kVK_UpArrow:
+ return "Up"
+ case kVK_DownArrow:
+ return "Down"
+ default:
+ break
+ }
+
+ guard let characters = event.charactersIgnoringModifiers?
+ .trimmingCharacters(in: .whitespacesAndNewlines),
+ !characters.isEmpty
+ else {
+ return nil
+ }
+ return characters.uppercased()
+ }
+}
diff --git a/execuwhisper/macos/ExecuWhisper/Models/Preferences.swift b/execuwhisper/macos/ExecuWhisper/Models/Preferences.swift
new file mode 100644
index 0000000000..2e1c020379
--- /dev/null
+++ b/execuwhisper/macos/ExecuWhisper/Models/Preferences.swift
@@ -0,0 +1,283 @@
+/*
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import Foundation
+import Observation
+
+@MainActor @Observable
+final class Preferences {
+ @ObservationIgnored private let defaults: UserDefaults
+
+ var enableGlobalHotkey: Bool = true {
+ didSet { defaults.set(enableGlobalHotkey, forKey: "enableGlobalHotkey") }
+ }
+
+ var dictationShortcut: DictationShortcut = .controlSpace {
+ didSet { Self.persist(dictationShortcut: dictationShortcut, in: defaults) }
+ }
+
+ var selectedMicrophoneID: String = "" {
+ didSet { defaults.set(selectedMicrophoneID, forKey: "selectedMicrophoneID") }
+ }
+
+ var silenceThreshold: Double = 0.02 {
+ didSet { defaults.set(silenceThreshold, forKey: "silenceThreshold") }
+ }
+
+ var silenceTimeout: Double = 1.5 {
+ didSet { defaults.set(silenceTimeout, forKey: "silenceTimeout") }
+ }
+
+ var runnerPath: String = "" {
+ didSet { defaults.set(runnerPath, forKey: "runnerPath") }
+ }
+
+ var formatterRunnerPath: String = "" {
+ didSet { defaults.set(formatterRunnerPath, forKey: "formatterRunnerPath") }
+ }
+
+ var enableSmartFormatting: Bool = true {
+ didSet { defaults.set(enableSmartFormatting, forKey: "enableSmartFormatting") }
+ }
+
+ var modelDirectory: String = "" {
+ didSet {
+ defaults.set(modelDirectory, forKey: "modelDirectory")
+ try? FileManager.default.createDirectory(
+ at: modelDirectoryURL,
+ withIntermediateDirectories: true
+ )
+ }
+ }
+
+ var formatterModelDirectory: String = "" {
+ didSet {
+ defaults.set(formatterModelDirectory, forKey: "formatterModelDirectory")
+ try? FileManager.default.createDirectory(
+ at: formatterModelDirectoryURL,
+ withIntermediateDirectories: true
+ )
+ }
+ }
+
+ var modelPath: String { modelDirectoryURL.appendingPathComponent("model.pte").path(percentEncoded: false) }
+ var tokenizerPath: String { modelDirectoryURL.appendingPathComponent("tokenizer.model").path(percentEncoded: false) }
+ var formatterModelPath: String { formatterModelDirectoryURL.appendingPathComponent("lfm2_5_350m_mlx_4w.pte").path(percentEncoded: false) }
+ var formatterTokenizerPath: String { formatterModelDirectoryURL.appendingPathComponent("tokenizer.json").path(percentEncoded: false) }
+ var formatterTokenizerConfigPath: String { formatterModelDirectoryURL.appendingPathComponent("tokenizer_config.json").path(percentEncoded: false) }
+
+ var modelDirectoryURL: URL { URL(fileURLWithPath: modelDirectory, isDirectory: true) }
+ var formatterModelDirectoryURL: URL { URL(fileURLWithPath: formatterModelDirectory, isDirectory: true) }
+
+ var bundledRunnerPath: String {
+ let resources = Bundle.main.resourcePath ?? ""
+ return URL(fileURLWithPath: resources).appendingPathComponent("parakeet_helper").path(percentEncoded: false)
+ }
+
+ var bundledFormatterRunnerPath: String {
+ let resources = Bundle.main.resourcePath ?? ""
+ return URL(fileURLWithPath: resources).appendingPathComponent("lfm25_formatter_helper").path(percentEncoded: false)
+ }
+
+ var bundledLibompPath: String {
+ let resources = Bundle.main.resourcePath ?? ""
+ return URL(fileURLWithPath: resources).appendingPathComponent("libomp.dylib").path(percentEncoded: false)
+ }
+
+ var bundledModelDirectoryURL: URL? {
+ guard let resources = Bundle.main.resourcePath else { return nil }
+ let directoryURL = URL(fileURLWithPath: resources, isDirectory: true)
+ let modelURL = directoryURL.appendingPathComponent("model.pte")
+ let tokenizerURL = directoryURL.appendingPathComponent("tokenizer.model")
+ if FileManager.default.fileExists(atPath: modelURL.path(percentEncoded: false))
+ && FileManager.default.fileExists(atPath: tokenizerURL.path(percentEncoded: false)) {
+ return directoryURL
+ }
+ return nil
+ }
+
+ var bundledFormatterModelDirectoryURL: URL? {
+ guard let resources = Bundle.main.resourcePath else { return nil }
+ let directoryURL = URL(fileURLWithPath: resources, isDirectory: true)
+ let modelURL = directoryURL.appendingPathComponent("lfm2_5_350m_mlx_4w.pte")
+ let tokenizerURL = directoryURL.appendingPathComponent("tokenizer.json")
+ let tokenizerConfigURL = directoryURL.appendingPathComponent("tokenizer_config.json")
+ if FileManager.default.fileExists(atPath: modelURL.path(percentEncoded: false))
+ && FileManager.default.fileExists(atPath: tokenizerURL.path(percentEncoded: false))
+ && FileManager.default.fileExists(atPath: tokenizerConfigURL.path(percentEncoded: false)) {
+ return directoryURL
+ }
+ return nil
+ }
+
+ var downloadedModelDirectoryURL: URL {
+ PersistencePaths.modelsDirectoryURL
+ }
+
+ var downloadedFormatterModelDirectoryURL: URL {
+ PersistencePaths.modelsDirectoryURL.appendingPathComponent("formatter", isDirectory: true)
+ }
+
+ static func resolveRunnerPath(
+ savedRunnerPath: String?,
+ savedRunnerExists: Bool,
+ bundledRunnerPath: String,
+ bundledRunnerExists: Bool,
+ buildRunnerPath: String
+ ) -> String {
+ if let savedRunnerPath, !savedRunnerPath.isEmpty, savedRunnerExists {
+ return savedRunnerPath
+ }
+ if bundledRunnerExists {
+ return bundledRunnerPath
+ }
+ if let savedRunnerPath, !savedRunnerPath.isEmpty {
+ return savedRunnerPath
+ }
+ return buildRunnerPath
+ }
+
+ static func modelDirectoryCandidates(
+ savedModelDirectory: String?,
+ bundledModelDirectory: String?,
+ downloadedModelDirectory: String
+ ) -> [String] {
+ var candidates: [String] = []
+ for candidate in [savedModelDirectory, bundledModelDirectory, downloadedModelDirectory] {
+ guard let candidate, !candidate.isEmpty, !candidates.contains(candidate) else { continue }
+ candidates.append(candidate)
+ }
+ return candidates
+ }
+
+ static func resolveModelDirectory(
+ savedModelDirectory: String?,
+ bundledModelDirectory: String?,
+ downloadedModelDirectory: String,
+ hasUsableModelFiles: (String) -> Bool
+ ) -> String {
+ let candidates = modelDirectoryCandidates(
+ savedModelDirectory: savedModelDirectory,
+ bundledModelDirectory: bundledModelDirectory,
+ downloadedModelDirectory: downloadedModelDirectory
+ )
+
+ if let resolved = candidates.first(where: hasUsableModelFiles) {
+ return resolved
+ }
+
+ return candidates.first ?? downloadedModelDirectory
+ }
+
+ init(defaults: UserDefaults = .standard) {
+ self.defaults = defaults
+ let home = FileManager.default.homeDirectoryForCurrentUser.path(percentEncoded: false)
+ let buildRunner = "\(home)/executorch/cmake-out/examples/models/parakeet/parakeet_helper"
+ let buildFormatterRunner = "\(home)/executorch/cmake-out/examples/models/llama/lfm25_formatter_helper"
+ let savedRunnerPath = defaults.string(forKey: "runnerPath")
+ let migratedSavedRunnerPath = Self.migrateHelperPath(savedRunnerPath)
+ let savedFormatterRunnerPath = defaults.string(forKey: "formatterRunnerPath")
+
+ enableGlobalHotkey = defaults.object(forKey: "enableGlobalHotkey") as? Bool ?? true
+ dictationShortcut = Self.loadDictationShortcut(from: defaults)
+ selectedMicrophoneID = defaults.string(forKey: "selectedMicrophoneID") ?? ""
+ silenceThreshold = defaults.object(forKey: "silenceThreshold") as? Double ?? 0.02
+ silenceTimeout = defaults.object(forKey: "silenceTimeout") as? Double ?? 1.5
+ enableSmartFormatting = defaults.object(forKey: "enableSmartFormatting") as? Bool ?? true
+ defaults.removeObject(forKey: "formattingMode")
+ defaults.removeObject(forKey: "customFormattingPrompt")
+
+ let bundledRunner = bundledRunnerPath
+ runnerPath = Self.resolveRunnerPath(
+ savedRunnerPath: migratedSavedRunnerPath,
+ savedRunnerExists: migratedSavedRunnerPath.map {
+ FileManager.default.isExecutableFile(atPath: $0)
+ } ?? false,
+ bundledRunnerPath: bundledRunner,
+ bundledRunnerExists: FileManager.default.isExecutableFile(atPath: bundledRunner),
+ buildRunnerPath: buildRunner
+ )
+
+ let bundledFormatterRunner = bundledFormatterRunnerPath
+ formatterRunnerPath = Self.resolveRunnerPath(
+ savedRunnerPath: savedFormatterRunnerPath,
+ savedRunnerExists: savedFormatterRunnerPath.map {
+ FileManager.default.isExecutableFile(atPath: $0)
+ } ?? false,
+ bundledRunnerPath: bundledFormatterRunner,
+ bundledRunnerExists: FileManager.default.isExecutableFile(atPath: bundledFormatterRunner),
+ buildRunnerPath: buildFormatterRunner
+ )
+
+ let preferredModelDir = Self.resolveModelDirectory(
+ savedModelDirectory: defaults.string(forKey: "modelDirectory"),
+ bundledModelDirectory: bundledModelDirectoryURL?.path(percentEncoded: false),
+ downloadedModelDirectory: downloadedModelDirectoryURL.path(percentEncoded: false)
+ ) { candidate in
+ let directoryURL = URL(fileURLWithPath: candidate, isDirectory: true)
+ let modelPath = directoryURL.appendingPathComponent("model.pte").path(percentEncoded: false)
+ let tokenizerPath = directoryURL.appendingPathComponent("tokenizer.model").path(percentEncoded: false)
+ return FileManager.default.fileExists(atPath: modelPath) && FileManager.default.fileExists(atPath: tokenizerPath)
+ }
+ modelDirectory = preferredModelDir
+
+ let preferredFormatterModelDir = Self.resolveModelDirectory(
+ savedModelDirectory: defaults.string(forKey: "formatterModelDirectory"),
+ bundledModelDirectory: bundledFormatterModelDirectoryURL?.path(percentEncoded: false),
+ downloadedModelDirectory: downloadedFormatterModelDirectoryURL.path(percentEncoded: false)
+ ) { candidate in
+ let directoryURL = URL(fileURLWithPath: candidate, isDirectory: true)
+ let modelPath = directoryURL.appendingPathComponent("lfm2_5_350m_mlx_4w.pte").path(percentEncoded: false)
+ let tokenizerPath = directoryURL.appendingPathComponent("tokenizer.json").path(percentEncoded: false)
+ let tokenizerConfigPath = directoryURL.appendingPathComponent("tokenizer_config.json").path(percentEncoded: false)
+ return FileManager.default.fileExists(atPath: modelPath)
+ && FileManager.default.fileExists(atPath: tokenizerPath)
+ && FileManager.default.fileExists(atPath: tokenizerConfigPath)
+ }
+ formatterModelDirectory = preferredFormatterModelDir
+
+ try? FileManager.default.createDirectory(
+ at: downloadedModelDirectoryURL,
+ withIntermediateDirectories: true
+ )
+ try? FileManager.default.createDirectory(
+ at: downloadedFormatterModelDirectoryURL,
+ withIntermediateDirectories: true
+ )
+ }
+
+ private static func migrateHelperPath(_ savedRunnerPath: String?) -> String? {
+ guard let savedRunnerPath, !savedRunnerPath.isEmpty else { return savedRunnerPath }
+ let savedURL = URL(fileURLWithPath: savedRunnerPath)
+ guard savedURL.lastPathComponent == "parakeet_runner" else { return savedRunnerPath }
+
+ let siblingHelperPath = savedURL
+ .deletingLastPathComponent()
+ .appendingPathComponent("parakeet_helper")
+ .path(percentEncoded: false)
+ if FileManager.default.isExecutableFile(atPath: siblingHelperPath) {
+ return siblingHelperPath
+ }
+
+ return savedRunnerPath
+ }
+
+ private static func loadDictationShortcut(from defaults: UserDefaults) -> DictationShortcut {
+ guard let data = defaults.data(forKey: "dictationShortcut"),
+ let shortcut = try? JSONDecoder().decode(DictationShortcut.self, from: data)
+ else {
+ return .controlSpace
+ }
+ return shortcut
+ }
+
+ private static func persist(dictationShortcut: DictationShortcut, in defaults: UserDefaults) {
+ guard let data = try? JSONEncoder().encode(dictationShortcut) else { return }
+ defaults.set(data, forKey: "dictationShortcut")
+ }
+}
diff --git a/execuwhisper/macos/ExecuWhisper/Models/ReplacementEntry.swift b/execuwhisper/macos/ExecuWhisper/Models/ReplacementEntry.swift
new file mode 100644
index 0000000000..545c26a7eb
--- /dev/null
+++ b/execuwhisper/macos/ExecuWhisper/Models/ReplacementEntry.swift
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import Foundation
+
+struct ReplacementEntry: Identifiable, Codable, Sendable, Hashable {
+ let id: UUID
+ var trigger: String
+ var replacement: String
+ var isEnabled: Bool
+ var isCaseSensitive: Bool
+ var requiresWordBoundary: Bool
+ var notes: String
+
+ init(
+ id: UUID = UUID(),
+ trigger: String = "",
+ replacement: String = "",
+ isEnabled: Bool = true,
+ isCaseSensitive: Bool = false,
+ requiresWordBoundary: Bool = true,
+ notes: String = ""
+ ) {
+ self.id = id
+ self.trigger = trigger
+ self.replacement = replacement
+ self.isEnabled = isEnabled
+ self.isCaseSensitive = isCaseSensitive
+ self.requiresWordBoundary = requiresWordBoundary
+ self.notes = notes
+ }
+}
diff --git a/execuwhisper/macos/ExecuWhisper/Models/Session.swift b/execuwhisper/macos/ExecuWhisper/Models/Session.swift
new file mode 100644
index 0000000000..575e534854
--- /dev/null
+++ b/execuwhisper/macos/ExecuWhisper/Models/Session.swift
@@ -0,0 +1,80 @@
+/*
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import Foundation
+
+struct Session: Identifiable, Codable, Sendable, Hashable {
+ let id: UUID
+ let date: Date
+ var title: String
+ var transcript: String
+ var duration: TimeInterval
+ var rawTranscript: String?
+ var tags: [String]
+ var pinned: Bool
+ var usedSnippetIDs: [UUID]
+
+ init(
+ id: UUID = UUID(),
+ date: Date = .now,
+ title: String = "",
+ transcript: String = "",
+ duration: TimeInterval = 0,
+ rawTranscript: String? = nil,
+ tags: [String] = [],
+ pinned: Bool = false,
+ usedSnippetIDs: [UUID] = []
+ ) {
+ self.id = id
+ self.date = date
+ self.title = title
+ self.transcript = transcript
+ self.duration = duration
+ self.rawTranscript = rawTranscript
+ self.tags = tags
+ self.pinned = pinned
+ self.usedSnippetIDs = usedSnippetIDs
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case id
+ case date
+ case title
+ case transcript
+ case duration
+ case rawTranscript
+ case tags
+ case pinned
+ case usedSnippetIDs
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
+ date = try container.decodeIfPresent(Date.self, forKey: .date) ?? .now
+ title = try container.decodeIfPresent(String.self, forKey: .title) ?? ""
+ transcript = try container.decodeIfPresent(String.self, forKey: .transcript) ?? ""
+ duration = try container.decodeIfPresent(TimeInterval.self, forKey: .duration) ?? 0
+ rawTranscript = try container.decodeIfPresent(String.self, forKey: .rawTranscript)
+ tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? []
+ pinned = try container.decodeIfPresent(Bool.self, forKey: .pinned) ?? false
+ usedSnippetIDs = try container.decodeIfPresent([UUID].self, forKey: .usedSnippetIDs) ?? []
+ }
+
+ var displayTitle: String {
+ if !title.isEmpty { return title }
+ let formatter = DateFormatter()
+ formatter.dateStyle = .medium
+ formatter.timeStyle = .short
+ return formatter.string(from: date)
+ }
+
+ var previewText: String {
+ transcript.isEmpty ? (rawTranscript ?? "") : transcript
+ }
+}
diff --git a/execuwhisper/macos/ExecuWhisper/Models/TranscriptStore.swift b/execuwhisper/macos/ExecuWhisper/Models/TranscriptStore.swift
new file mode 100644
index 0000000000..20fcd34f96
--- /dev/null
+++ b/execuwhisper/macos/ExecuWhisper/Models/TranscriptStore.swift
@@ -0,0 +1,843 @@
+/*
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import AppKit
+import Foundation
+import os
+
+private let storeLog = Logger(subsystem: "org.pytorch.executorch.ExecuWhisper", category: "TranscriptStore")
+
+@MainActor @Observable
+final class TranscriptStore {
+ enum SessionState: Equatable {
+ case idle
+ case recording
+ case transcribing
+ }
+
+ enum ModelState: Equatable {
+ case checking
+ case missing
+ case downloading
+ case ready
+ }
+
+ var sessions: [Session] = []
+ var selectedSessionID: UUID?
+ var selectedHistorySessionIDs: Set = []
+ var liveTranscript = ""
+ var sessionState: SessionState = .idle
+ var modelState: ModelState = .checking
+ var currentError: RunnerError?
+ var healthResult: HealthCheck.Result?
+ var audioLevel: Float = 0
+ var statusMessage = ""
+ var helperState: RunnerBridge.ResidencyState = .unloaded
+ var helperStatusMessage = ""
+
+ var hasActiveSession: Bool { sessionState != .idle }
+ var isRecording: Bool { sessionState == .recording }
+ var isTranscribing: Bool { sessionState == .transcribing }
+ var isModelReady: Bool { modelState == .ready }
+ var resourcesReady: Bool { healthResult?.resourcesReady == true }
+ var isHelperWarm: Bool { helperState == .warm }
+ var isHelperLoading: Bool { helperState == .loading }
+
+ private let recorder: AudioRecorder
+ private let runner: any RunnerBridgeClient
+ private let preferences: Preferences
+ private let downloader: ModelDownloader
+ private let sessionsURL: URL
+ private let textPipeline: TextPipeline?
+ private let audioDecoder: any ImportedAudioDecoding
+ private let maxRecordingDuration: TimeInterval
+ private var recordingStartDate: Date?
+ private var initialized = false
+ private var warmupTask: Task?
+ private var recordingLimitTask: Task?
+ private var explicitlyUnloaded = false
+
+ init(
+ preferences: Preferences,
+ downloader: ModelDownloader,
+ sessionsURL: URL = PersistencePaths.sessionsURL,
+ textPipeline: TextPipeline? = nil,
+ audioDecoder: any ImportedAudioDecoding = ImportedAudioDecoder(),
+ recorder: AudioRecorder = AudioRecorder(),
+ runner: any RunnerBridgeClient = RunnerBridge(),
+ maxRecordingDuration: TimeInterval = 30 * 60
+ ) {
+ self.recorder = recorder
+ self.runner = runner
+ self.preferences = preferences
+ self.downloader = downloader
+ self.sessionsURL = sessionsURL
+ self.textPipeline = textPipeline
+ self.audioDecoder = audioDecoder
+ self.maxRecordingDuration = maxRecordingDuration
+ loadSessions()
+ }
+
+ func initialize() async {
+ guard !initialized else { return }
+ initialized = true
+ await runHealthCheck()
+
+ if healthResult?.modelAssetsMissing == true {
+ await downloadModelIfNeeded()
+ }
+
+ if preferences.enableSmartFormatting && !formatterAssetsReady {
+ await downloadFormatterModelIfNeeded()
+ }
+
+ await autoPreloadModelIfReady()
+ }
+
+ func runHealthCheck() async {
+ var result = await HealthCheck.run(
+ runnerPath: preferences.runnerPath,
+ modelPath: preferences.modelPath,
+ tokenizerPath: preferences.tokenizerPath
+ )
+
+ if result.runnerAvailable && !result.resourcesReady {
+ let bundledPath = preferences.bundledModelDirectoryURL?.path(percentEncoded: false)
+ let candidates = Preferences.modelDirectoryCandidates(
+ savedModelDirectory: preferences.modelDirectory,
+ bundledModelDirectory: bundledPath,
+ downloadedModelDirectory: preferences.downloadedModelDirectoryURL.path(percentEncoded: false)
+ )
+
+ for candidate in candidates where candidate != preferences.modelDirectory {
+ let candidateURL = URL(fileURLWithPath: candidate, isDirectory: true)
+ let candidateResult = await HealthCheck.run(
+ runnerPath: preferences.runnerPath,
+ modelPath: candidateURL.appendingPathComponent("model.pte").path(percentEncoded: false),
+ tokenizerPath: candidateURL.appendingPathComponent("tokenizer.model").path(percentEncoded: false)
+ )
+ if candidateResult.resourcesReady {
+ preferences.modelDirectory = candidate
+ result = candidateResult
+ break
+ }
+ }
+ }
+
+ healthResult = result
+
+ if downloader.isDownloading {
+ modelState = .downloading
+ if statusMessage.isEmpty || statusMessage == "Ready" {
+ statusMessage = "Downloading model..."
+ }
+ } else if result.resourcesReady {
+ modelState = .ready
+ if !hasActiveSession {
+ statusMessage = "Ready"
+ }
+ } else {
+ modelState = .missing
+ if !hasActiveSession {
+ statusMessage = result.setupStatusMessage
+ }
+ }
+
+ if result.resourcesReady {
+ await syncHelperState()
+ if !hasActiveSession {
+ await autoPreloadModelIfReady()
+ }
+ } else {
+ helperState = .unloaded
+ helperStatusMessage = ""
+ warmupTask?.cancel()
+ warmupTask = nil
+ }
+ }
+
+ func downloadModelIfNeeded(force: Bool = false) async {
+ if !force && healthResult?.resourcesReady == true {
+ modelState = .ready
+ return
+ }
+ if !force && healthResult?.shouldOfferModelDownload != true {
+ return
+ }
+ await downloadModel()
+ }
+
+ func downloadFormatterModelIfNeeded(force: Bool = false) async {
+ guard force || !formatterAssetsReady else { return }
+ await downloadFormatterModel()
+ }
+
+ func downloadModel() async {
+ guard !downloader.isDownloading else {
+ modelState = .downloading
+ return
+ }
+
+ preferences.modelDirectory = preferences.downloadedModelDirectoryURL.path(percentEncoded: false)
+ modelState = .downloading
+ statusMessage = "Downloading model..."
+ currentError = nil
+
+ do {
+ try await downloader.downloadModels(
+ destinationDirectory: preferences.downloadedModelDirectoryURL
+ )
+ if preferences.enableSmartFormatting && !formatterAssetsReady {
+ preferences.formatterModelDirectory = preferences.downloadedFormatterModelDirectoryURL.path(percentEncoded: false)
+ try await downloader.downloadFormatterModels(
+ destinationDirectory: preferences.downloadedFormatterModelDirectoryURL
+ )
+ }
+ await runHealthCheck()
+ await autoPreloadModelIfReady()
+ } catch let error as RunnerError {
+ currentError = error
+ await runHealthCheck()
+ } catch {
+ currentError = .downloadFailed(file: "Parakeet model", description: error.localizedDescription)
+ await runHealthCheck()
+ }
+ }
+
+ func downloadFormatterModel() async {
+ guard !downloader.isDownloading else {
+ modelState = .downloading
+ return
+ }
+
+ preferences.formatterModelDirectory = preferences.downloadedFormatterModelDirectoryURL.path(percentEncoded: false)
+ modelState = .downloading
+ statusMessage = "Downloading formatter..."
+ currentError = nil
+
+ do {
+ try await downloader.downloadFormatterModels(
+ destinationDirectory: preferences.downloadedFormatterModelDirectoryURL
+ )
+ await runHealthCheck()
+ } catch let error as RunnerError {
+ currentError = error
+ await runHealthCheck()
+ } catch {
+ currentError = .downloadFailed(file: "LFM2.5 formatter", description: error.localizedDescription)
+ await runHealthCheck()
+ }
+ }
+
+ func preloadModel() async {
+ explicitlyUnloaded = false
+ await performHelperWarmupIfNeeded(updateStatusMessage: true)
+ }
+
+ func unloadModel() async {
+ explicitlyUnloaded = true
+ warmupTask?.cancel()
+ warmupTask = nil
+ await runner.shutdown()
+ helperState = .unloaded
+ helperStatusMessage = resourcesReady ? "Helper unloaded" : ""
+ if !hasActiveSession {
+ statusMessage = resourcesReady ? "Ready" : (healthResult?.setupStatusMessage ?? "Ready")
+ }
+ }
+
+ func startRecording() async {
+ guard sessionState == .idle else { return }
+
+ await runHealthCheck()
+ if healthResult?.shouldOfferModelDownload == true {
+ await downloadModelIfNeeded()
+ await runHealthCheck()
+ }
+ guard resourcesReady else {
+ if healthResult?.runnerAvailable == false {
+ currentError = .binaryNotFound(path: preferences.runnerPath)
+ }
+ return
+ }
+
+ let micPermission = await HealthCheck.liveMicPermission()
+ if micPermission == .notDetermined {
+ let granted = await HealthCheck.requestMicrophoneAccess()
+ if !granted {
+ currentError = .microphonePermissionDenied
+ return
+ }
+ } else if micPermission == .denied {
+ currentError = .microphonePermissionDenied
+ return
+ }
+
+ selectedSessionID = nil
+ selectedHistorySessionIDs = []
+ liveTranscript = ""
+ audioLevel = 0
+ statusMessage = "Recording..."
+ currentError = nil
+ sessionState = .recording
+ recordingStartDate = .now
+ scheduleRecordingLimit {
+ await self.stopRecordingAndTranscribe()
+ }
+
+ do {
+ try await recorder.startRecording(selectedMicrophoneID: preferences.selectedMicrophoneID) { [weak self] level in
+ Task { @MainActor in
+ self?.audioLevel = level
+ }
+ }
+ startBackgroundWarmupIfNeeded()
+ } catch let error as RunnerError {
+ cancelRecordingLimit()
+ currentError = error
+ sessionState = .idle
+ } catch {
+ cancelRecordingLimit()
+ currentError = .launchFailed(description: error.localizedDescription)
+ sessionState = .idle
+ }
+ }
+
+ func startDictationCapture() async -> Bool {
+ guard sessionState == .idle else { return false }
+ storeLog.info("Dictation capture requested")
+
+ await runHealthCheck()
+ if healthResult?.shouldOfferModelDownload == true {
+ await downloadModelIfNeeded()
+ await runHealthCheck()
+ }
+ guard resourcesReady else {
+ if healthResult?.runnerAvailable == false {
+ currentError = .binaryNotFound(path: preferences.runnerPath)
+ }
+ return false
+ }
+
+ let micPermission = await HealthCheck.liveMicPermission()
+ if micPermission == .notDetermined {
+ let granted = await HealthCheck.requestMicrophoneAccess()
+ if !granted {
+ currentError = .microphonePermissionDenied
+ return false
+ }
+ } else if micPermission == .denied {
+ currentError = .microphonePermissionDenied
+ return false
+ }
+
+ selectedSessionID = nil
+ selectedHistorySessionIDs = []
+ liveTranscript = ""
+ audioLevel = 0
+ statusMessage = "Listening..."
+ currentError = nil
+ sessionState = .recording
+ recordingStartDate = .now
+ storeLog.info("Dictation capture starting with runnerPath=\(self.preferences.runnerPath, privacy: .public) modelPath=\(self.preferences.modelPath, privacy: .public)")
+
+ do {
+ try await recorder.startRecording(selectedMicrophoneID: preferences.selectedMicrophoneID) { [weak self] level in
+ Task { @MainActor in
+ self?.audioLevel = level
+ }
+ }
+ startBackgroundWarmupIfNeeded()
+ storeLog.info("Dictation capture started")
+ return true
+ } catch let error as RunnerError {
+ cancelRecordingLimit()
+ storeLog.error("Dictation capture failed to start: \(error.localizedDescription, privacy: .public)")
+ currentError = error
+ resetLiveState(status: "Ready")
+ return false
+ } catch {
+ cancelRecordingLimit()
+ storeLog.error("Dictation capture failed with unexpected error: \(error.localizedDescription, privacy: .public)")
+ currentError = .launchFailed(description: error.localizedDescription)
+ resetLiveState(status: "Ready")
+ return false
+ }
+ }
+
+ func finishDictationCapture() async throws -> TextProcessingResult {
+ guard sessionState == .recording else {
+ throw RunnerError.dictationNotActive
+ }
+
+ let duration = recordingStartDate.map { Date.now.timeIntervalSince($0) } ?? 0
+ sessionState = .transcribing
+ statusMessage = "Transcribing..."
+ audioLevel = 0
+ cancelRecordingLimit()
+ storeLog.info("Dictation capture stopping after duration=\(duration, format: .fixed(precision: 3))s")
+
+ do {
+ let pcmData = try await recorder.stopRecording()
+ storeLog.info("Dictation captured pcmBytes=\(pcmData.count)")
+ let finalResult = try await transcribeCapturedAudio(pcmData)
+ liveTranscript = finalResult.text
+ storeLog.info("Dictation transcription completed textLength=\(finalResult.text.count)")
+ return await storeDictationTranscription(rawText: finalResult.text, duration: duration)
+ } catch {
+ storeLog.error("Dictation transcription failed: \(error.localizedDescription, privacy: .public)")
+ resetLiveState(status: "Ready")
+ throw error
+ }
+ }
+
+ func stopRecordingAndTranscribe() async {
+ guard sessionState == .recording else { return }
+
+ let duration = recordingStartDate.map { Date.now.timeIntervalSince($0) } ?? 0
+ sessionState = .transcribing
+ statusMessage = "Finalizing recording..."
+ audioLevel = 0
+ cancelRecordingLimit()
+
+ do {
+ let pcmData = try await recorder.stopRecording()
+ storeLog.info("Recording captured pcmBytes=\(pcmData.count)")
+ let finalResult = try await transcribeCapturedAudio(pcmData)
+ liveTranscript = finalResult.text
+ await storeCompletedTranscription(rawText: finalResult.text, duration: duration)
+ } catch let error as RunnerError {
+ currentError = error
+ resetLiveState()
+ } catch {
+ currentError = .transcriptionFailed(description: error.localizedDescription)
+ resetLiveState()
+ }
+ }
+
+ @discardableResult
+ func importAudioFile(_ url: URL) async -> Bool {
+ guard sessionState == .idle else {
+ currentError = .transcriptionFailed(description: "Wait for the current transcription to finish before importing another audio file.")
+ return false
+ }
+
+ await runHealthCheck()
+ if healthResult?.shouldOfferModelDownload == true {
+ await downloadModelIfNeeded()
+ await runHealthCheck()
+ }
+ guard resourcesReady else {
+ if healthResult?.runnerAvailable == false {
+ currentError = .binaryNotFound(path: preferences.runnerPath)
+ }
+ return false
+ }
+
+ let previousSelectedSessionID = selectedSessionID
+ let previousHistorySelection = selectedHistorySessionIDs
+ selectedSessionID = nil
+ selectedHistorySessionIDs = []
+ liveTranscript = ""
+ audioLevel = 0
+ statusMessage = "Preparing audio file..."
+ currentError = nil
+ sessionState = .transcribing
+ recordingStartDate = .now
+
+ do {
+ let decoded = try audioDecoder.decodeAudioFile(at: url)
+ let finalResult = try await transcribeCapturedAudio(decoded.pcmData)
+ liveTranscript = finalResult.text
+ await storeImportedTranscription(
+ rawText: finalResult.text,
+ duration: decoded.duration,
+ title: importedSessionTitle(for: url)
+ )
+ return true
+ } catch let error as RunnerError {
+ selectedSessionID = previousSelectedSessionID
+ selectedHistorySessionIDs = previousHistorySelection
+ currentError = error
+ resetLiveState()
+ return false
+ } catch {
+ selectedSessionID = previousSelectedSessionID
+ selectedHistorySessionIDs = previousHistorySelection
+ currentError = .transcriptionFailed(description: error.localizedDescription)
+ resetLiveState()
+ return false
+ }
+ }
+
+ func deleteSession(_ session: Session) {
+ sessions.removeAll { $0.id == session.id }
+ selectedHistorySessionIDs.remove(session.id)
+ if selectedSessionID == session.id {
+ selectedSessionID = sessions.first?.id
+ }
+ saveSessions()
+ }
+
+ func deleteSessions(ids: Set) {
+ guard !ids.isEmpty else { return }
+ sessions.removeAll { ids.contains($0.id) }
+ selectedHistorySessionIDs.subtract(ids)
+ if let selectedSessionID, ids.contains(selectedSessionID) {
+ self.selectedSessionID = nil
+ }
+ saveSessions()
+ }
+
+ func renameSession(_ session: Session, to newTitle: String) {
+ guard let idx = sessions.firstIndex(where: { $0.id == session.id }) else { return }
+ sessions[idx].title = newTitle
+ saveSessions()
+ }
+
+ func togglePinned(_ session: Session) {
+ guard let idx = sessions.firstIndex(where: { $0.id == session.id }) else { return }
+ sessions[idx].pinned.toggle()
+ saveSessions()
+ }
+
+ func clearError() {
+ currentError = nil
+ }
+
+ func exportSession(_ session: Session, format: SessionExportFormat) {
+ let panel = NSSavePanel()
+ panel.allowedContentTypes = [format.contentType]
+ panel.canCreateDirectories = true
+ panel.nameFieldStringValue = suggestedExportFileName(for: session, format: format)
+
+ guard panel.runModal() == .OK, let url = panel.url else { return }
+
+ do {
+ try writeSessionExport(session, format: format, to: url)
+ } catch {
+ currentError = .exportFailed(description: error.localizedDescription)
+ }
+ }
+
+ func importAudioFileWithPanel() {
+ let panel = NSOpenPanel()
+ panel.allowedContentTypes = ImportedAudioDecoder.allowedContentTypes
+ panel.canChooseFiles = true
+ panel.canChooseDirectories = false
+ panel.allowsMultipleSelection = false
+
+ guard panel.runModal() == .OK, let url = panel.url else { return }
+ Task { @MainActor in
+ await importAudioFile(url)
+ }
+ }
+
+ func writeSessionExport(_ session: Session, format: SessionExportFormat, to url: URL) throws {
+ let rendered = format.render(session)
+ try rendered.write(to: url, atomically: true, encoding: .utf8)
+ }
+
+ func storeCompletedTranscription(rawText: String, duration: TimeInterval) async {
+ _ = await processCompletedTranscription(
+ rawText: rawText,
+ duration: duration,
+ context: .standard,
+ persistSession: true,
+ titleOverride: nil
+ )
+ }
+
+ @discardableResult
+ func storeDictationTranscription(rawText: String, duration: TimeInterval) async -> TextProcessingResult {
+ await processCompletedTranscription(
+ rawText: rawText,
+ duration: duration,
+ context: .dictation,
+ persistSession: false,
+ titleOverride: nil
+ )
+ }
+
+ func storeImportedTranscription(rawText: String, duration: TimeInterval, title: String) async {
+ _ = await processCompletedTranscription(
+ rawText: rawText,
+ duration: duration,
+ context: .standard,
+ persistSession: true,
+ titleOverride: title
+ )
+ }
+
+ private func finishTranscription(
+ rawText: String,
+ transcript: String,
+ tags: [String],
+ duration: TimeInterval,
+ persistSession: Bool,
+ titleOverride: String?
+ ) {
+ if persistSession && !transcript.isEmpty {
+ let session = Session(
+ date: recordingStartDate ?? .now,
+ title: titleOverride ?? "",
+ transcript: transcript,
+ duration: duration,
+ rawTranscript: rawText,
+ tags: tags
+ )
+ sessions.insert(session, at: 0)
+ selectedSessionID = session.id
+ selectedHistorySessionIDs = [session.id]
+ saveSessions()
+ }
+
+ liveTranscript = transcript
+ resetLiveState(status: transcript.isEmpty ? "No speech detected" : "Ready")
+ }
+
+ private func suggestedExportFileName(for session: Session, format: SessionExportFormat) -> String {
+ let base = session.displayTitle
+ .replacingOccurrences(of: "/", with: "-")
+ .replacingOccurrences(of: ":", with: "-")
+ return "\(base).\(format.fileExtension)"
+ }
+
+ private func transcribe(audioURL: URL) async throws -> RunnerBridge.TranscriptionResult {
+ storeLog.info("Beginning batch transcription for audioPath=\(audioURL.path(percentEncoded: false), privacy: .public)")
+ let events = await runner.transcribe(
+ runnerPath: preferences.runnerPath,
+ modelPath: preferences.modelPath,
+ tokenizerPath: preferences.tokenizerPath,
+ audioPath: audioURL.path(percentEncoded: false),
+ options: .fromEnvironment(ProcessInfo.processInfo.environment)
+ )
+ return try await collectFinalResult(from: events)
+ }
+
+ func transcribeCapturedAudio(_ pcmData: Data) async throws -> RunnerBridge.TranscriptionResult {
+ storeLog.info("Beginning batch transcription for capturedAudioBytes=\(pcmData.count)")
+ if helperState != .warm {
+ helperState = .loading
+ helperStatusMessage = "Warming model..."
+ }
+ let events = await runner.transcribePCM(
+ runnerPath: preferences.runnerPath,
+ modelPath: preferences.modelPath,
+ tokenizerPath: preferences.tokenizerPath,
+ pcmData: pcmData,
+ options: .fromEnvironment(ProcessInfo.processInfo.environment)
+ )
+ let result = try await collectFinalResult(from: events)
+ await syncHelperState()
+ return result
+ }
+
+ private func collectFinalResult(
+ from events: AsyncThrowingStream
+ ) async throws -> RunnerBridge.TranscriptionResult {
+ var finalResult: RunnerBridge.TranscriptionResult?
+ for try await event in events {
+ switch event {
+ case .status(let status):
+ statusMessage = status
+ storeLog.info("Runner status event: \(status, privacy: .public)")
+ case .completed(let result):
+ finalResult = result
+ storeLog.info("Runner completed event textLength=\(result.text.count) stdoutLength=\(result.stdout.count) stderrLength=\(result.stderr.count)")
+ if DiagnosticLogging.shouldLogTranscriptsPublicly {
+ storeLog.info("Parakeet transcript: \(result.text, privacy: .public)")
+ } else {
+ storeLog.info("Parakeet transcript: \(result.text, privacy: .private)")
+ }
+ if let runtimeProfile = result.runtimeProfile {
+ storeLog.info("Runner runtime profile: \(runtimeProfile, privacy: .public)")
+ }
+ }
+ }
+
+ guard let finalResult else {
+ storeLog.error("Runner stream finished without a completed event")
+ throw RunnerError.invalidRunnerOutput(stdout: "")
+ }
+ return finalResult
+ }
+
+ private func startBackgroundWarmupIfNeeded() {
+ guard resourcesReady else { return }
+ guard helperState == .unloaded || helperState == .failed else { return }
+ guard warmupTask == nil else { return }
+
+ warmupTask = Task { @MainActor [weak self] in
+ await self?.performHelperWarmupIfNeeded(updateStatusMessage: false)
+ }
+ }
+
+ private func autoPreloadModelIfReady() async {
+ guard !explicitlyUnloaded else { return }
+ guard resourcesReady else { return }
+ guard helperState == .unloaded || helperState == .failed else { return }
+ await performHelperWarmupIfNeeded(updateStatusMessage: false)
+ }
+
+ private func performHelperWarmupIfNeeded(updateStatusMessage: Bool) async {
+ guard resourcesReady else { return }
+
+ if helperState == .warm {
+ helperStatusMessage = "Model preloaded"
+ return
+ }
+
+ if helperState == .loading, let warmupTask {
+ await warmupTask.value
+ return
+ }
+
+ helperState = .loading
+ helperStatusMessage = "Warming model..."
+ if updateStatusMessage && !hasActiveSession {
+ statusMessage = "Warming model..."
+ }
+
+ do {
+ try await runner.prepare(
+ runnerPath: preferences.runnerPath,
+ modelPath: preferences.modelPath,
+ tokenizerPath: preferences.tokenizerPath
+ )
+ helperState = .warm
+ helperStatusMessage = "Model preloaded"
+ logResidentMemory(context: "Parakeet helper preloaded")
+ if updateStatusMessage && !hasActiveSession {
+ statusMessage = "Ready"
+ }
+ } catch let error as RunnerError {
+ helperState = .failed
+ helperStatusMessage = "Warmup failed"
+ currentError = error
+ if updateStatusMessage && !hasActiveSession {
+ statusMessage = healthResult?.setupStatusMessage ?? "Ready"
+ }
+ } catch {
+ helperState = .failed
+ helperStatusMessage = "Warmup failed"
+ currentError = .launchFailed(description: error.localizedDescription)
+ if updateStatusMessage && !hasActiveSession {
+ statusMessage = healthResult?.setupStatusMessage ?? "Ready"
+ }
+ }
+
+ warmupTask = nil
+ }
+
+ private func syncHelperState() async {
+ let snapshot = await runner.runtimeSnapshot()
+ helperState = snapshot.state
+ switch snapshot.state {
+ case .unloaded:
+ helperStatusMessage = resourcesReady ? "Helper unloaded" : ""
+ case .loading:
+ helperStatusMessage = "Warming model..."
+ case .warm:
+ helperStatusMessage = "Model preloaded"
+ case .failed:
+ helperStatusMessage = "Warmup failed"
+ }
+ }
+
+ private func logResidentMemory(context: String) {
+ guard UserDefaults.standard.bool(forKey: DiagnosticLogging.transcriptDebugKey),
+ let bytes = DiagnosticLogging.residentMemoryBytes()
+ else {
+ return
+ }
+ storeLog.info("\(context, privacy: .public) residentMemoryBytes=\(bytes)")
+ }
+
+ @discardableResult
+ private func processCompletedTranscription(
+ rawText: String,
+ duration: TimeInterval,
+ context: TextPipeline.Context,
+ persistSession: Bool,
+ titleOverride: String?
+ ) async -> TextProcessingResult {
+ if preferences.enableSmartFormatting && !rawText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ statusMessage = "Formatting..."
+ liveTranscript = "Formatting..."
+ }
+
+ let processed = await textPipeline?.process(
+ rawText,
+ context: context,
+ smartFormattingEnabled: preferences.enableSmartFormatting
+ )
+ ?? TextProcessingResult(rawText: rawText, outputText: rawText, tags: [])
+ finishTranscription(
+ rawText: processed.rawText,
+ transcript: processed.outputText,
+ tags: processed.tags,
+ duration: duration,
+ persistSession: persistSession,
+ titleOverride: titleOverride
+ )
+ return processed
+ }
+
+ private func importedSessionTitle(for url: URL) -> String {
+ let title = url.deletingPathExtension().lastPathComponent.trimmingCharacters(in: .whitespacesAndNewlines)
+ return title.isEmpty ? "Imported Audio" : title
+ }
+
+ private func resetLiveState(status: String = "Ready") {
+ cancelRecordingLimit()
+ audioLevel = 0
+ sessionState = .idle
+ recordingStartDate = nil
+ statusMessage = status
+ }
+
+ private func scheduleRecordingLimit(onLimit: @escaping @MainActor () async -> Void) {
+ cancelRecordingLimit()
+ guard maxRecordingDuration > 0 else { return }
+ recordingLimitTask = Task { @MainActor [weak self] in
+ try? await Task.sleep(for: .seconds(maxRecordingDuration))
+ guard let self, self.sessionState == .recording else { return }
+ self.statusMessage = "Maximum recording duration reached"
+ self.currentError = .transcriptionFailed(description: "Maximum recording duration reached.")
+ await onLimit()
+ }
+ }
+
+ private func cancelRecordingLimit() {
+ recordingLimitTask?.cancel()
+ recordingLimitTask = nil
+ }
+
+ private var formatterAssetsReady: Bool {
+ let fm = FileManager.default
+ return fm.fileExists(atPath: preferences.formatterModelPath)
+ && fm.fileExists(atPath: preferences.formatterTokenizerPath)
+ && fm.fileExists(atPath: preferences.formatterTokenizerConfigPath)
+ }
+
+ private func saveSessions() {
+ guard let data = try? JSONEncoder().encode(sessions) else { return }
+ try? data.write(to: sessionsURL, options: .atomic)
+ }
+
+ private func loadSessions() {
+ guard let data = try? Data(contentsOf: sessionsURL),
+ let decoded = try? JSONDecoder().decode([Session].self, from: data)
+ else {
+ return
+ }
+ sessions = decoded.sorted { $0.date > $1.date }
+ selectedSessionID = nil
+ }
+}
diff --git a/execuwhisper/macos/ExecuWhisper/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/execuwhisper/macos/ExecuWhisper/Resources/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000000..667b84d5cd
--- /dev/null
+++ b/execuwhisper/macos/ExecuWhisper/Resources/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.800",
+ "green" : "0.345",
+ "red" : "0.259"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/execuwhisper/macos/ExecuWhisper/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/execuwhisper/macos/ExecuWhisper/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000000..4ab5e2f324
--- /dev/null
+++ b/execuwhisper/macos/ExecuWhisper/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,68 @@
+{
+ "images" : [
+ {
+ "filename" : "icon_16.png",
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "16x16"
+ },
+ {
+ "filename" : "icon_16@2x.png",
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "16x16"
+ },
+ {
+ "filename" : "icon_32.png",
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "32x32"
+ },
+ {
+ "filename" : "icon_32@2x.png",
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "32x32"
+ },
+ {
+ "filename" : "icon_128.png",
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "128x128"
+ },
+ {
+ "filename" : "icon_128@2x.png",
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "128x128"
+ },
+ {
+ "filename" : "icon_256.png",
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "256x256"
+ },
+ {
+ "filename" : "icon_256@2x.png",
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "256x256"
+ },
+ {
+ "filename" : "icon_512.png",
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "512x512"
+ },
+ {
+ "filename" : "icon_512@2x.png",
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "512x512"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/execuwhisper/macos/ExecuWhisper/Resources/Assets.xcassets/AppIcon.appiconset/icon_128.png b/execuwhisper/macos/ExecuWhisper/Resources/Assets.xcassets/AppIcon.appiconset/icon_128.png
new file mode 100644
index 0000000000000000000000000000000000000000..cf71558efd2b8f0fdc0939147bd995574ddff975
GIT binary patch
literal 16553
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
H3jTip)vb+(
literal 0
HcmV?d00001
diff --git a/execuwhisper/macos/ExecuWhisper/Resources/Assets.xcassets/AppIcon.appiconset/icon_128@2x.png b/execuwhisper/macos/ExecuWhisper/Resources/Assets.xcassets/AppIcon.appiconset/icon_128@2x.png
new file mode 100644
index 0000000000000000000000000000000000000000..49e194e7582f9fae180e11d9289bdc77bbfab6dc
GIT binary patch
literal 56899
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