diff --git a/README.md b/README.md new file mode 100644 index 0000000..9f3b10e --- /dev/null +++ b/README.md @@ -0,0 +1,235 @@ +# OpenWispher + +Opensource alternative to wisprflow and superwhisper + +--- + +## What's New + +### New Features & Improvements + +**Flexible Recording Activation** +Choose how you trigger recordings in Settings. Use **Click to Start / Click to Stop** (toggle mode) for hands-free dictation, or **Press and Hold** to record only while the hotkey is held down — the transcription is sent the moment you release. + +**Escape to Cancel** +Changed your mind mid-sentence? Press **Escape** at any point during an active recording to immediately discard the audio. Nothing is transcribed and nothing is pasted — it's as if you never started. + +**Automatic Fallback Provider** +Configure a secondary provider in Settings under Providers. If your primary provider returns an error, times out, or gets rate-limited, OpenWispher automatically retries your request through the fallback provider — no interruption to your workflow. + +**50+ Models Across ElevenLabs & Deepgram** +When using ElevenLabs or Deepgram, you can now browse and select from the full catalog of models each provider offers — including Nova-2, Nova-3, Flux (Deepgram) and Scribe v1/v2 (ElevenLabs) — along with fine-grained language selection. + +**Bulk Export** +Added an Export button to both the transcription history view and Settings > History. Export all your transcriptions at once to a plain `.txt` file. + +--- + +## What It Does + +1. Press your global hotkey (default `⌥ Space`) +2. Speak +3. Release / press again / the app transcribes via your chosen AI provider +4. The text is automatically copied to your clipboard and pasted into whatever app is in focus + +A floating notch overlay shows you the current state: **Listening → Processing → Copied!** + +--- + +## Requirements + +| Requirement | Minimum | +|---|---| +| macOS | 14 Sonoma | +| Xcode | 15+ | +| Swift | 5.9+ | +| API Key | At least one of: Groq, ElevenLabs, or Deepgram | + +--- + +## Supported Providers + +| Provider | Models | Languages | +|---|---|---| +| **Groq** | `whisper-large-v3` | English | +| **ElevenLabs** | `scribe_v1`, `scribe_v2` | Auto-detect + 90+ languages | +| **Deepgram** | `nova-3` (default), `nova-2`, `flux` | Auto-detect + 40+ locale variants (`flux` is English-only) | + +API keys are stored securely in the macOS Keychain. During development, you can also set them via environment variables (`GROQ_API_KEY`, `DEEPGRAM_API_KEY`, `ELEVENLABS_API_KEY`). + +--- + +## Features + +- **Global hotkey** — fully customizable, default `⌥ Space` +- **Two activation modes** — toggle (click to start, click to stop) or hold-to-record +- **Escape to cancel** — discard a recording at any point without transcribing +- **Notch overlay** — animated status pill anchored to the MacBook notch +- **Auto-paste** — pastes transcribed text into the active app via Accessibility API +- **Transcription history** — persistent local storage with 30-day auto-cleanup; favorites are never deleted +- **Bulk export** — export all history to a `.txt` file +- **Fallback provider** — automatic failover to a secondary provider on error or timeout +- **50+ model choices** — full model + language selection for ElevenLabs and Deepgram +- **Auto-updater** — checks GitHub Releases for new versions and verifies DMG integrity via SHA-256 +- **Launch at Login** — optional background agent mode +- **Privacy-first analytics** — PostHog with `personProfiles = .never`; no PII collected + +--- + +## Project Structure + +```text +dhavnii/ +├── .github/ +│ └── workflows/ +│ ├── ci.yml # Build check on every PR / push to main +│ └── release.yml # DMG + latest.json published on version tags +├── Scripts/ +│ ├── build_release.sh # Local release build +│ ├── reset_permissions.sh # Clears UserDefaults + resets mic/accessibility +│ └── generate_icons.sh # Regenerates app icon set +├── dhavnii.xcodeproj/ # Xcode project (scheme: openwispher) +├── dhavnii/ # Main app source +│ ├── App/ +│ │ └── AppState.swift # Global app state observable +│ ├── Core/ +│ │ ├── Feedback/ # User-facing feedback system +│ │ ├── Security/ # Keychain wrapper (SecureStorage) +│ │ └── UI/ # Shared UI constants, animations, window helpers +│ └── Features/ +│ ├── Clipboard/ # Auto-paste via CGEvent + NSPasteboard +│ ├── History/ # SwiftData models, retention, export +│ ├── Home/ # Main window (HomeView + ViewModel) +│ ├── Hotkeys/ # Carbon global hotkey registration + Escape monitor +│ ├── Notch/ # Floating notch overlay window + animated view +│ ├── Onboarding/ # 5-step first-run flow +│ ├── Permissions/ # Microphone + Accessibility permission management +│ ├── Settings/ # Full settings UI (5 sections) +│ └── Transcription/ # Audio recording, provider clients, fallback orchestrator +└── openwispher/ + ├── openwispherApp.swift # @main entry point, hotkey wiring, lifecycle + └── AnalyticsManager.swift # PostHog analytics +``` + +--- + +## Architecture + +- **Pattern**: Feature-based folder structure with MVVM inside each feature +- **State**: `@Observable` (Swift 5.9 macro) throughout; `@MainActor` for all UI-touching code +- **Persistence**: SwiftData (`TranscriptionRecord`, `HistoryPreferences` models) +- **API clients**: Swift actors (`GroqAPIClient`, `ElevenLabsAPIClient`, `DeepgramAPIClient`) — one per provider, isolated for thread safety +- **Audio**: AVFoundation recording to a temp `.m4a` (AAC, 16 kHz mono) deleted after transcription +- **Hotkeys**: macOS Carbon Event Manager (`RegisterEventHotKey`) for the global hotkey; `NSEvent` local monitor for Escape +- **Keychain**: All API keys stored under `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` + +--- + +## Getting Started + +### 1. Clone the repo + +```bash +git clone https://github.com/maker-or/openwispher.git +cd openwispher +``` + +### 2. Open in Xcode + +```bash +open dhavnii.xcodeproj +``` + +Select the **openwispher** scheme and your Mac as the destination. + +### 3. Set up API keys (development) + +You can supply keys via environment variables so you don't have to go through onboarding on every run. In Xcode, edit the scheme (`Product → Scheme → Edit Scheme → Run → Arguments`) and add: + +```text +GROQ_API_KEY= +DEEPGRAM_API_KEY= +ELEVENLABS_API_KEY= +``` + +Or just run the app and complete onboarding normally. + +### 4. PostHog (optional for local dev) + +Analytics is a no-op if no PostHog key is present. For CI/release builds, set the following secrets in your GitHub repository: + +- `POSTHOG_API_KEY` +- `POSTHOG_HOST` + +### 5. Build & run + +Press `⌘R` in Xcode. The app will walk you through onboarding on first launch. + +--- + +## Development Scripts + +| Script | Purpose | +|---|---| +| `Scripts/build_release.sh` | Build a release DMG locally | +| `Scripts/reset_permissions.sh` | Reset all UserDefaults and revoke mic/accessibility permissions — useful when testing onboarding | +| `Scripts/generate_icons.sh` | Regenerate the app icon set from a source image | + +See `Scripts/README.md` for full usage details. + +--- + + + +## Contributing + +Contributions are welcome. Here's how to get set up and what to keep in mind. + +### Before You Start + +1. Fork the repository and create a branch from `main`: + ```bash + git checkout -b feature/your-feature-name + ``` +2. Make sure the project builds cleanly before making changes (`⌘B` in Xcode). +3. Run `Scripts/reset_permissions.sh` if you need to test the onboarding flow from scratch. + +### Code Style + +- **Swift**: Follow standard Swift API Design Guidelines. Use `@Observable` and structured concurrency (`async/await`, actors) — no completion handlers or Combine for new code. +- **SwiftUI**: Prefer small, composable views. Avoid putting business logic in views — extract to a `@Observable` view model or a service. +- **Actors**: API clients (`GroqAPIClient`, etc.) are Swift actors. Keep all network calls inside them. +- **`@MainActor`**: All code that touches SwiftUI state or AppKit must be `@MainActor`. +- **Keychain**: Store all secrets via `SecureStorage` — never in `UserDefaults` or `Info.plist`. +- **Analytics**: Add a `AnalyticsManager.shared.track*()` method for any new user-facing action. Always call `captureAndFlush` so events send immediately. + +### Adding a New Provider + +1. Add a case to `TranscriptionProviderType` in `TranscriptionProvider.swift`. +2. Create `APIClient.swift` in `Features/Transcription/` as a Swift actor conforming to `TranscriptionProvider`. +3. Register the provider in `TranscriptionService.swift` inside `makeClient(for:)`. +4. Add model and language enums / arrays as needed. +5. Add API key fields to `SecureStorage` and wire them into `SettingsView` and `OnboardingView`. + +### Submitting a Pull Request + +1. Ensure the project builds without warnings on the `openwispher` scheme. +2. Test manually: + - Onboarding flow (use `reset_permissions.sh` to start fresh) + - Recording in both toggle and hold modes + - Escape-to-cancel + - Fallback provider triggering (you can force this by entering a bad API key as primary) + - History export +3. Open a PR against `main` with a clear description of what changed and why. +4. The CI workflow will run an unsigned build automatically — fix any build failures before requesting review. + +### Reporting Issues + +Please include: +- macOS version +- Which provider you are using +- Steps to reproduce +- Expected vs. actual behaviour +- Any relevant output from Console.app (filter by process name `openwispher`) + +--- diff --git a/dhavnii/Core/Feedback/UserFeedbackSystem.swift b/dhavnii/Core/Feedback/UserFeedbackSystem.swift index 1d088c7..ad4e3fb 100644 --- a/dhavnii/Core/Feedback/UserFeedbackSystem.swift +++ b/dhavnii/Core/Feedback/UserFeedbackSystem.swift @@ -368,6 +368,8 @@ internal extension Error { return "Failed to parse response. Please try again." case .providerNotConfigured: return "Transcription provider not configured. Please check your settings." + case .timeout(provider: let provider): + return "\(provider) took too long to respond. Please try again." } } return localizedDescription diff --git a/dhavnii/Core/Security/SecureStorage.swift b/dhavnii/Core/Security/SecureStorage.swift index 7b291ce..0d88c36 100644 --- a/dhavnii/Core/Security/SecureStorage.swift +++ b/dhavnii/Core/Security/SecureStorage.swift @@ -18,6 +18,7 @@ internal enum SecureStorage { private static let groqAPIKey = "com.openwispher.apiKeys.groq" private static let elevenLabsAPIKey = "com.openwispher.apiKeys.elevenlabs" private static let deepgramAPIKey = "com.openwispher.apiKeys.deepgram" + private static let sarvamAPIKey = "com.openwispher.apiKeys.sarvam" // MARK: - Error Types internal enum KeychainError: Error { @@ -45,7 +46,9 @@ internal enum SecureStorage { kSecAttrAccount as String: key, kSecAttrService as String: "openwispher_api_keys", kSecValueData as String: data, - kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly + // AfterFirstUnlock: accessible after the user logs in once per boot, + // without prompting for the system password on every app launch. + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly ] let status = SecItemAdd(query as CFDictionary, nil) @@ -120,9 +123,37 @@ internal enum SecureStorage { } + /// Re-write any existing keychain items to use the AfterFirstUnlock accessibility level. + /// Run once on launch to silently upgrade keys stored under the old WhenUnlocked attribute, + /// which caused a system-password prompt on every fresh app launch. + internal static func migrateKeychainAccessibility() { + let migrationKey = "com.openwispher.keychainAccessibilityMigrated.v1" + guard !UserDefaults.standard.bool(forKey: migrationKey) else { return } + + let providers: [TranscriptionProviderType] = [.groq, .elevenLabs, .deepgram, .sarvam] + var allSucceeded = true + for provider in providers { + // Read the existing value (if any) — this may still prompt once + // during this single migration run, but never again afterwards. + guard let existingKey = retrieveAPIKey(for: provider), !existingKey.isEmpty else { continue } + // Re-store with the new accessibility attribute (delete-then-add inside storeAPIKey) + do { + try storeAPIKey(existingKey, for: provider) + } catch { + print("⚠️ SecureStorage: failed to migrate keychain accessibility for \(provider.rawValue): \(error)") + allSucceeded = false + } + } + + // Only mark migration complete if every present key was successfully re-written. + if allSucceeded { + UserDefaults.standard.set(true, forKey: migrationKey) + } + } + /// Migrate existing UserDefaults keys to Keychain (one-time migration) internal static func migrateFromUserDefaults() { - let providers: [TranscriptionProviderType] = [.groq, .elevenLabs, .deepgram] + let providers: [TranscriptionProviderType] = [.groq, .elevenLabs, .deepgram, .sarvam] for provider in providers { let userDefaultsKey: String @@ -133,6 +164,8 @@ internal enum SecureStorage { userDefaultsKey = "elevenLabsAPIKey" case .deepgram: userDefaultsKey = "deepgramAPIKey" + case .sarvam: + userDefaultsKey = "sarvamAPIKey" } // Check if already in keychain @@ -156,7 +189,7 @@ internal enum SecureStorage { /// Clear all stored API keys internal static func clearAllAPIKeys() { - let providers: [TranscriptionProviderType] = [.groq, .elevenLabs, .deepgram] + let providers: [TranscriptionProviderType] = [.groq, .elevenLabs, .deepgram, .sarvam] for provider in providers { try? deleteAPIKey(for: provider) @@ -166,6 +199,7 @@ internal enum SecureStorage { UserDefaults.standard.removeObject(forKey: "groqAPIKey") UserDefaults.standard.removeObject(forKey: "elevenLabsAPIKey") UserDefaults.standard.removeObject(forKey: "deepgramAPIKey") + UserDefaults.standard.removeObject(forKey: "sarvamAPIKey") } // MARK: - Private Helpers @@ -178,6 +212,8 @@ internal enum SecureStorage { return elevenLabsAPIKey case .deepgram: return deepgramAPIKey + case .sarvam: + return sarvamAPIKey } } diff --git a/dhavnii/Core/UI/LiquidGlassHelpers.swift b/dhavnii/Core/UI/LiquidGlassHelpers.swift index b31406c..4d3fa35 100644 --- a/dhavnii/Core/UI/LiquidGlassHelpers.swift +++ b/dhavnii/Core/UI/LiquidGlassHelpers.swift @@ -5,6 +5,7 @@ // Shared Liquid Glass helpers and fallbacks. // +import AppKit import SwiftUI internal extension View { @@ -17,7 +18,7 @@ internal extension View { strokeColor: Color = Color.black.opacity(0.12) ) -> some View { let fillColor = tint ?? fallbackFill - let opacity: Double = interactive ? 0.5 : 0.35 + let opacity: Double = interactive ? 0.62 : 0.48 self .background( @@ -34,6 +35,25 @@ internal extension View { ) } + @ViewBuilder + func liquidGlassPanelBackground( + material: NSVisualEffectView.Material = .windowBackground, + tint: Color = Color.black.opacity(0.28), + includeStroke: Bool = false + ) -> some View { + self + .background( + VisualEffectBlur(material: material, blendingMode: .withinWindow, state: .active) + .overlay(tint) + ) + .overlay { + if includeStroke { + Rectangle() + .stroke(Color.white.opacity(0.08), lineWidth: 0.5) + } + } + } + @ViewBuilder func liquidGlassButtonStyle(prominent: Bool = false) -> some View { if prominent { @@ -53,7 +73,6 @@ internal extension View { if #available(macOS 15.0, *) { self .toolbarBackgroundVisibility(.hidden, for: .windowToolbar) - .containerBackground(.ultraThinMaterial, for: .window) } else { self } @@ -63,7 +82,8 @@ internal extension View { internal struct LiquidGlassBackground: View { var body: some View { Group { - VisualEffectBlur(material: .hudWindow, blendingMode: .behindWindow, state: .active) + VisualEffectBlur(material: .windowBackground, blendingMode: .withinWindow, state: .active) + .overlay(Color.black.opacity(0.3)) } .ignoresSafeArea() } diff --git a/dhavnii/Core/UI/WindowConfigurator.swift b/dhavnii/Core/UI/WindowConfigurator.swift index fd2d6d8..758795a 100644 --- a/dhavnii/Core/UI/WindowConfigurator.swift +++ b/dhavnii/Core/UI/WindowConfigurator.swift @@ -2,7 +2,7 @@ // WindowConfigurator.swift // OpenWispher // -// Makes the window transparent so the glass background shows through. +// Configures native glass window chrome with readable contrast. // import AppKit @@ -27,10 +27,9 @@ internal struct WindowConfigurator: NSViewRepresentable { @MainActor private func configure(window: NSWindow) { - // Enable translucent “glass” window. - // IMPORTANT: Do NOT toggle isOpaque (can flash white / break titlebar blending). + // Keep liquid-glass translucency, but avoid fully clear windows. window.isOpaque = false - window.backgroundColor = .clear + window.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.92) // Make content extend under the titlebar so the traffic-light area // uses the same background as the body. diff --git a/dhavnii/Features/History/HistoryExporter.swift b/dhavnii/Features/History/HistoryExporter.swift new file mode 100644 index 0000000..b40a978 --- /dev/null +++ b/dhavnii/Features/History/HistoryExporter.swift @@ -0,0 +1,98 @@ +// +// HistoryExporter.swift +// OpenWispher +// +// Exports transcription history to a plain text file via NSSavePanel. +// + +import AppKit +import Foundation +import UniformTypeIdentifiers + +/// Handles exporting transcription records to a plain text file. +@MainActor +enum HistoryExporter { + + /// Convenience wrapper: formats, presents the save panel, writes the file, + /// and — on success — fires `AnalyticsManager.shared.trackHistoryExported`. + /// Returns `true` if the file was written successfully. + @discardableResult + static func exportAndTrack(_ records: [TranscriptionRecord]) -> Bool { + let exported = exportAsText(records) + if exported { + AnalyticsManager.shared.trackHistoryExported(count: records.count) + } + return exported + } + + /// Formats `records` as plain text and presents a save panel to the user. + /// Returns `true` if the file was written successfully. + @discardableResult + static func exportAsText(_ records: [TranscriptionRecord]) -> Bool { + guard !records.isEmpty else { return false } + + let content = formatted(records) + + let panel = NSSavePanel() + panel.title = "Export Transcriptions" + panel.nameFieldStringValue = defaultFilename() + panel.allowedContentTypes = [.plainText] + panel.canCreateDirectories = true + + guard panel.runModal() == .OK, let url = panel.url else { return false } + + do { + try content.write(to: url, atomically: true, encoding: .utf8) + return true + } catch { + print("❌ HistoryExporter: failed to write file: \(error)") + return false + } + } + + // MARK: - Private helpers + + private static func formatted(_ records: [TranscriptionRecord]) -> String { + let dateFormatter = DateFormatter() + dateFormatter.dateStyle = .medium + dateFormatter.timeStyle = .short + + let header = """ + OpenWispher — Transcription History + Exported: \(dateFormatter.string(from: Date())) + Total: \(records.count) transcription\(records.count == 1 ? "" : "s") + ════════════════════════════════════════ + + """ + + let blocks = records.map { record -> String in + var lines: [String] = [] + lines.append(dateFormatter.string(from: record.timestamp)) + lines.append("Provider: \(record.provider)") + if let duration = record.audioDuration { + lines.append("Duration: \(formattedDuration(duration))") + } + if record.isFavorite { + lines.append("Starred") + } + lines.append("") + lines.append(record.text) + return lines.joined(separator: "\n") + } + + return header + blocks.joined(separator: "\n\n────────────────────────────────────────\n\n") + "\n" + } + + private static func defaultFilename() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return "transcriptions-\(formatter.string(from: Date())).txt" + } + + private static func formattedDuration(_ seconds: Double) -> String { + let total = Int(seconds.rounded()) + let m = total / 60 + let s = total % 60 + return m > 0 ? "\(m)m \(s)s" : "\(s)s" + } +} diff --git a/dhavnii/Features/Home/HomeView.swift b/dhavnii/Features/Home/HomeView.swift index 84d0f5a..ee834a2 100644 --- a/dhavnii/Features/Home/HomeView.swift +++ b/dhavnii/Features/Home/HomeView.swift @@ -125,7 +125,7 @@ struct HomeView: View { } } .frame(minWidth: 220) - .background(.ultraThinMaterial) + } private func sidebarItem(for section: SidebarSection) -> some View { @@ -175,7 +175,7 @@ struct HomeView: View { private var detailContent: some View { HomeContentView(viewModel: homeViewModel, appState: appState) - .background(.ultraThinMaterial.opacity(0.3)) + } } @@ -191,6 +191,9 @@ struct HomeContentView: View { var body: some View { VStack(spacing: 0) { // Header + if !viewModel.transcriptions.isEmpty { + exportToolbar + } // Content if viewModel.transcriptions.isEmpty { @@ -215,24 +218,34 @@ struct HomeContentView: View { } } - // MARK: - Header + // MARK: - Export Toolbar - private var contentHeader: some View { + private var exportToolbar: some View { HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Home") - .font(.title2) - .fontWeight(.semibold) - .foregroundStyle(.primary) - .font(.subheadline) - .foregroundStyle(.secondary) - } + Text("Transcriptions") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.secondary) Spacer() + + Button { + exportHistory() + } label: { + HStack(spacing: 4) { + Image(systemName: "square.and.arrow.up") + .font(.system(size: 12, weight: .medium)) + Text("Export") + .font(.system(size: 12, weight: .medium)) + } + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Export all transcriptions to a text file") + .accessibilityLabel("Export transcriptions") } .padding(.horizontal, 24) - .padding(.vertical, 16) - .background(.ultraThinMaterial.opacity(0.5)) + .padding(.vertical, 10) + } // MARK: - Empty State @@ -302,6 +315,10 @@ struct HomeContentView: View { // MARK: - Actions + private func exportHistory() { + HistoryExporter.exportAndTrack(viewModel.transcriptions) + } + private func copy(_ record: TranscriptionRecord) { NSPasteboard.general.clearContents() NSPasteboard.general.setString(record.text, forType: .string) diff --git a/dhavnii/Features/Onboarding/OnboardingView.swift b/dhavnii/Features/Onboarding/OnboardingView.swift index c2b523e..ad503b7 100644 --- a/dhavnii/Features/Onboarding/OnboardingView.swift +++ b/dhavnii/Features/Onboarding/OnboardingView.swift @@ -381,6 +381,8 @@ private struct ApiKeyScreen: View { return URL(string: "https://elevenlabs.io/app/developers/api-keys")! case .deepgram: return URL(string: "https://developers.deepgram.com/docs/create-additional-api-keys")! + case .sarvam: + return URL(string: "https://dashboard.sarvam.ai")! } } diff --git a/dhavnii/Features/Settings/SettingsView.swift b/dhavnii/Features/Settings/SettingsView.swift index 06f2897..745ee8b 100644 --- a/dhavnii/Features/Settings/SettingsView.swift +++ b/dhavnii/Features/Settings/SettingsView.swift @@ -76,7 +76,7 @@ internal struct SettingsView: View { sidebarContent } detail: { detailView - .background(.ultraThinMaterial.opacity(0.3)) + } .frame(minWidth: 750, minHeight: 550) .background(WindowConfigurator()) @@ -133,7 +133,7 @@ internal struct SettingsView: View { // } } .frame(minWidth: 220) - .background(.ultraThinMaterial) + } private func settingsSidebarItem(for section: SettingsSection) -> some View { @@ -541,7 +541,8 @@ private struct ProvidersSettingsView: View { return } let resolvedLanguage = type.resolveLanguageID(language, modelID: model) - UserDefaults.standard.set(resolvedLanguage, forKey: type.languageUserDefaultsKey) + UserDefaults.standard.set( + resolvedLanguage, forKey: type.languageUserDefaultsKey) }, getAPIKey: { provider in guard let type = TranscriptionProviderType(rawValue: provider) else { @@ -567,6 +568,8 @@ private struct ProvidersSettingsView: View { } ) + // Fallback & timeout + FallbackSettingsCard(primaryProviderRaw: selectedProviderRaw) } } } @@ -921,6 +924,319 @@ private struct ProviderCard: View { } } +// MARK: - Fallback Settings Card + +private struct FallbackSettingsCard: View { + /// The raw value of the currently selected primary provider (passed from ProvidersSettingsView). + let primaryProviderRaw: String + + @AppStorage("fallbackTranscriptionProvider") private var fallbackRaw = "" + @AppStorage("transcriptionTimeoutSeconds") private var timeoutSeconds = 8.0 + + // API key entry state + @State private var fallbackHasKey = false + @State private var isEditingKey = false + @State private var apiKeyText = "" + + private let timeoutOptions: [Double] = [5, 8, 10, 15, 20, 30] + + // Providers that can be selected as fallback (excludes the primary) + private var fallbackOptions: [(id: String, name: String)] { + TranscriptionProviderType.allCases + .filter { $0.rawValue != primaryProviderRaw } + .map { ($0.rawValue, $0.displayName) } + } + + private var fallbackName: String { + guard !fallbackRaw.isEmpty, + let type = TranscriptionProviderType(rawValue: fallbackRaw) + else { return "None" } + return type.displayName + } + + private var timeoutLabel: String { + let t = Int(timeoutSeconds) + return "\(t)s" + } + + /// True when a fallback provider is chosen but has no API key stored. + private var fallbackNeedsKey: Bool { + !fallbackRaw.isEmpty && !fallbackHasKey + } + + var body: some View { + VStack(spacing: 0) { + // Header + HStack(spacing: 12) { + Image(systemName: "arrow.triangle.2.circlepath") + .font(.system(size: 16)) + .foregroundStyle(.secondary) + + Text("Fallback") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.primary) + + Spacer() + } + .padding(16) + + Divider() + .padding(.horizontal, 16) + + // Fallback provider + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Fallback Provider") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + Text("Used when primary fails, times out, or is rate-limited") + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + } + + Spacer() + + Menu { + Button("None") { + fallbackRaw = "" + isEditingKey = false + apiKeyText = "" + checkFallbackKey() + } + Divider() + ForEach(fallbackOptions, id: \.id) { option in + Button(option.name) { + fallbackRaw = option.id + isEditingKey = false + apiKeyText = "" + checkFallbackKey() + } + } + } label: { + HStack(spacing: 6) { + Text(fallbackName) + .font(.system(size: 13, weight: .medium)) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + } + .foregroundStyle(.primary) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.primary.opacity(0.06)) + ) + } + .menuStyle(.borderlessButton) + } + .padding(16) + + // API key warning / entry (only when a fallback is selected) + if !fallbackRaw.isEmpty { + Divider() + .padding(.horizontal, 16) + + if isEditingKey { + // Inline key entry + VStack(spacing: 12) { + SecureField("Enter API Key for \(fallbackName)", text: $apiKeyText) + .textFieldStyle(.plain) + .font(.system(size: 13, design: .monospaced)) + .padding(10) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.primary.opacity(0.04)) + ) + + HStack { + Button("Cancel") { + isEditingKey = false + apiKeyText = "" + } + .buttonStyle(.bordered) + .controlSize(.small) + + Spacer() + + Button("Save") { + saveFallbackKey() + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(apiKeyText.isEmpty) + } + } + .padding(16) + } else if fallbackNeedsKey { + // Missing key warning + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 13)) + .foregroundStyle(.orange) + + VStack(alignment: .leading, spacing: 2) { + Text("API key required") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.orange) + Text("\(fallbackName) needs an API key to work as a fallback.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + + Spacer() + + Button("Add Key") { + isEditingKey = true + } + .buttonStyle(.bordered) + .controlSize(.small) + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.orange.opacity(0.06)) + ) + .padding(.horizontal, 16) + .padding(.vertical, 8) + } else { + // Key is configured — show status with option to update/remove + HStack { + HStack(spacing: 6) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 12)) + .foregroundStyle(.green) + Text("API key configured for \(fallbackName)") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + + Spacer() + + Menu { + Button("Update Key") { + isEditingKey = true + } + Button("Remove Key", role: .destructive) { + removeFallbackKey() + } + } label: { + Image(systemName: "ellipsis.circle") + .font(.system(size: 16)) + .foregroundStyle(.secondary) + } + .menuStyle(.borderlessButton) + } + .padding(16) + } + } + + // Timeout threshold — only relevant when a fallback provider is selected + if !fallbackRaw.isEmpty { + Divider() + .padding(.horizontal, 16) + + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Timeout") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + Text("Fallback kicks in if primary exceeds this time") + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + } + + Spacer() + + Menu { + ForEach(timeoutOptions, id: \.self) { seconds in + Button("\(Int(seconds))s") { + timeoutSeconds = seconds + } + } + } label: { + HStack(spacing: 6) { + Text(timeoutLabel) + .font(.system(size: 13, weight: .medium)) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + } + .foregroundStyle(.primary) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.primary.opacity(0.06)) + ) + } + .menuStyle(.borderlessButton) + } + .padding(16) + } + } + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.primary.opacity(0.02)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.primary.opacity(0.06), lineWidth: 0.5) + ) + .onAppear { + checkFallbackKey() + } + // If the primary provider changes to match the current fallback, clear the fallback + .onChange(of: primaryProviderRaw) { _, newPrimary in + if fallbackRaw == newPrimary { + fallbackRaw = "" + isEditingKey = false + apiKeyText = "" + } + checkFallbackKey() + } + } + + // MARK: - Helpers + + private func checkFallbackKey() { + guard !fallbackRaw.isEmpty, + let type = TranscriptionProviderType(rawValue: fallbackRaw) + else { + fallbackHasKey = false + return + } + let key = SecureStorage.retrieveAPIKey(for: type) + fallbackHasKey = key != nil && !key!.isEmpty + } + + private func saveFallbackKey() { + guard !apiKeyText.isEmpty, + let type = TranscriptionProviderType(rawValue: fallbackRaw) + else { return } + do { + try SecureStorage.storeAPIKey(apiKeyText, for: type) + isEditingKey = false + fallbackHasKey = true + apiKeyText = "" + } catch { + print("❌ FallbackSettingsCard: failed to store API key for \(type.rawValue): \(error)") + // Leave isEditingKey = true so the UI reflects the failed write. + } + } + + private func removeFallbackKey() { + guard let type = TranscriptionProviderType(rawValue: fallbackRaw) else { return } + do { + try SecureStorage.deleteAPIKey(for: type) + fallbackHasKey = false + } catch { + print("❌ FallbackSettingsCard: failed to delete API key for \(type.rawValue): \(error)") + // fallbackHasKey is intentionally left unchanged — the key is still present. + } + } +} + // MARK: - General Settings private struct GeneralSettingsView: View { @@ -1076,6 +1392,8 @@ private struct GeneralSettingsView: View { UserDefaults.standard.removeObject(forKey: "hasCompletedOnboarding") UserDefaults.standard.removeObject(forKey: "autoLaunchEnabled") UserDefaults.standard.removeObject(forKey: "selectedTranscriptionProvider") + UserDefaults.standard.removeObject(forKey: "fallbackTranscriptionProvider") + UserDefaults.standard.removeObject(forKey: "transcriptionTimeoutSeconds") for provider in TranscriptionProviderType.allCases { UserDefaults.standard.removeObject(forKey: provider.modelUserDefaultsKey) UserDefaults.standard.removeObject(forKey: provider.languageUserDefaultsKey) @@ -1183,8 +1501,22 @@ private struct HistorySettingsView: View { } } - // Clear + // Clear / Export SettingsGroup(title: "Data") { + SettingsRow( + icon: "square.and.arrow.up", + title: "Export History", + subtitle: "Save all transcriptions as a text file" + ) { + Button("Export") { + let records = historyManager?.fetchAllTranscriptions() ?? [] + HistoryExporter.exportAndTrack(records) + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(count == 0) + } + SettingsRow( icon: "trash", title: "Clear History", subtitle: "Delete all transcriptions" ) { @@ -1310,7 +1642,7 @@ private struct AboutSettingsView: View { .padding(.bottom, 24) } .frame(maxWidth: .infinity) - .background(.ultraThinMaterial.opacity(0.3)) + .task { guard !hasAutoCheckedUpdates else { return } hasAutoCheckedUpdates = true diff --git a/dhavnii/Features/Transcription/GroqAPIClient.swift b/dhavnii/Features/Transcription/GroqAPIClient.swift index cd3984c..6f36a3b 100644 --- a/dhavnii/Features/Transcription/GroqAPIClient.swift +++ b/dhavnii/Features/Transcription/GroqAPIClient.swift @@ -10,12 +10,19 @@ import Foundation /// Client for Groq's Whisper API actor GroqAPIClient: TranscriptionProvider { let providerType: TranscriptionProviderType = .groq - + private let baseURL = "https://api.groq.com/openai/v1/audio/transcriptions" + + /// Vocabulary and style hints forwarded to Whisper as the `prompt` field. + /// Per Groq/Whisper guidance this field influences word choice, capitalization, + /// and domain-specific terms only — the model still transcribes verbatim audio. + private let transcriptionPrompt = + "Preserve the speaker's exact words. Apply standard capitalization and punctuation. Use domain-specific spellings where context is clear (e.g. proper nouns, technical terms)." + nonisolated private var model: String { providerType.selectedAPIModelID } - + nonisolated private var apiKey: String { // Prefer user-provided key from Secure Keychain, fall back to env var for dev. let stored = SecureStorage.retrieveAPIKey(for: .groq) ?? "" @@ -26,69 +33,96 @@ actor GroqAPIClient: TranscriptionProvider { nonisolated var isConfigured: Bool { !apiKey.isEmpty } - + init() { // Intentionally empty – `apiKey` is read dynamically. } - - /// Transcribe audio data using Whisper - func transcribe(audioData: Data, fileName: String = "audio.m4a") async throws -> String { + + /// Transcribe audio data using Whisper. + /// - Parameters: + /// - audioData: Raw audio bytes. + /// - fileName: Filename hint sent in the multipart body (default `"audio.m4a"`). + /// - preserveVerbatim: When `true` (default) the vocabulary/style prompt is + /// omitted, letting Whisper output its raw hypothesis with no additional + /// guidance. Pass `false` to include the `transcriptionPrompt` hint so + /// Whisper applies the specified vocabulary and formatting preferences. + func transcribe(audioData: Data, fileName: String) async throws -> String { + try await transcribe(audioData: audioData, fileName: fileName, preserveVerbatim: true) + } + + func transcribe( + audioData: Data, + fileName: String, + preserveVerbatim: Bool + ) async throws -> String { let key = apiKey guard !key.isEmpty else { throw TranscriptionError.missingAPIKey(provider: "Groq") } print("🎙️ Groq STT request: model=\(model) file=\(fileName) bytes=\(audioData.count)") - + guard let url = URL(string: baseURL) else { throw TranscriptionError.invalidURL } - + // Create multipart form data let boundary = UUID().uuidString var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization") - request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") - + request.setValue( + "multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + var body = Data() - + // Add file field body.append("--\(boundary)\r\n".data(using: .utf8)!) - body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\r\n".data(using: .utf8)!) + body.append( + "Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\r\n".data( + using: .utf8)!) body.append("Content-Type: audio/m4a\r\n\r\n".data(using: .utf8)!) body.append(audioData) body.append("\r\n".data(using: .utf8)!) - + // Add model field body.append("--\(boundary)\r\n".data(using: .utf8)!) body.append("Content-Disposition: form-data; name=\"model\"\r\n\r\n".data(using: .utf8)!) body.append("\(model)\r\n".data(using: .utf8)!) - + // Add language field (English only) body.append("--\(boundary)\r\n".data(using: .utf8)!) body.append("Content-Disposition: form-data; name=\"language\"\r\n\r\n".data(using: .utf8)!) body.append("en\r\n".data(using: .utf8)!) - + + // Optionally include vocabulary/style hint prompt (opt-in via preserveVerbatim = false) + if !preserveVerbatim { + body.append("--\(boundary)\r\n".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"prompt\"\r\n\r\n".data(using: .utf8)!) + body.append("\(transcriptionPrompt)\r\n".data(using: .utf8)!) + } + // Close boundary body.append("--\(boundary)--\r\n".data(using: .utf8)!) - + request.httpBody = body - + let (data, response) = try await URLSession.shared.data(for: request) - + guard let httpResponse = response as? HTTPURLResponse else { throw TranscriptionError.invalidResponse } - + guard httpResponse.statusCode == 200 else { let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error" print("❌ Groq STT API error status \(httpResponse.statusCode): \(errorMessage)") - throw TranscriptionError.apiError(statusCode: httpResponse.statusCode, message: errorMessage) + throw TranscriptionError.apiError( + statusCode: httpResponse.statusCode, message: errorMessage) } - + // Parse response - let transcriptionResponse = try JSONDecoder().decode(GroqTranscriptionResponse.self, from: data) + let transcriptionResponse = try JSONDecoder().decode( + GroqTranscriptionResponse.self, from: data) return transcriptionResponse.text } } diff --git a/dhavnii/Features/Transcription/SarvamAPIClient.swift b/dhavnii/Features/Transcription/SarvamAPIClient.swift new file mode 100644 index 0000000..f321be5 --- /dev/null +++ b/dhavnii/Features/Transcription/SarvamAPIClient.swift @@ -0,0 +1,132 @@ +// +// SarvamAPIClient.swift +// OpenWispher +// +// Sarvam AI client for Saaras speech-to-text transcription. +// + +import Foundation + +/// Client for Sarvam AI Speech-to-Text API (Saaras model) +actor SarvamAPIClient: TranscriptionProvider { + let providerType: TranscriptionProviderType = .sarvam + + private let baseURL = "https://api.sarvam.ai/speech-to-text" + + nonisolated private var model: String { + providerType.selectedAPIModelID + } + + nonisolated private var language: String { + providerType.selectedLanguageID(for: providerType.selectedModelID) + } + + nonisolated private var apiKey: String { + // Prefer user-provided key from Secure Keychain, fall back to env var for dev. + let stored = SecureStorage.retrieveAPIKey(for: .sarvam) ?? "" + if !stored.isEmpty { return stored } + return ProcessInfo.processInfo.environment["SARVAM_API_KEY"] ?? "" + } + + nonisolated var isConfigured: Bool { + !apiKey.isEmpty + } + + init() { + // Intentionally empty – `apiKey` is read dynamically. + } + + /// Transcribe audio data using Sarvam AI Saaras. + func transcribe(audioData: Data, fileName: String) async throws -> String { + let key = apiKey + guard !key.isEmpty else { + throw TranscriptionError.missingAPIKey(provider: "Sarvam") + } + + let lang = language + print( + "🎙️ Sarvam STT request: model=\(model) language=\(lang) file=\(fileName) bytes=\(audioData.count)" + ) + + guard let url = URL(string: baseURL) else { + throw TranscriptionError.invalidURL + } + + let boundary = UUID().uuidString + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue(key, forHTTPHeaderField: "api-subscription-key") + request.setValue( + "multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + + var body = Data() + + // File field + body.append("--\(boundary)\r\n".data(using: .utf8)!) + body.append( + "Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\r\n" + .data(using: .utf8)!) + body.append("Content-Type: application/octet-stream\r\n\r\n".data(using: .utf8)!) + body.append(audioData) + body.append("\r\n".data(using: .utf8)!) + + // Model field + body.append("--\(boundary)\r\n".data(using: .utf8)!) + body.append( + "Content-Disposition: form-data; name=\"model\"\r\n\r\n".data(using: .utf8)!) + body.append("\(model)\r\n".data(using: .utf8)!) + + // Language field — "unknown" tells the API to auto-detect; never omit it + let langCode = lang == TranscriptionLanguageOption.autoID ? "unknown" : lang + body.append("--\(boundary)\r\n".data(using: .utf8)!) + body.append( + "Content-Disposition: form-data; name=\"language_code\"\r\n\r\n" + .data(using: .utf8)!) + body.append("\(langCode)\r\n".data(using: .utf8)!) + + body.append("--\(boundary)--\r\n".data(using: .utf8)!) + request.httpBody = body + + do { + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw TranscriptionError.invalidResponse + } + + guard httpResponse.statusCode == 200 else { + let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error" + print("❌ Sarvam STT API error status \(httpResponse.statusCode): \(errorMessage)") + throw TranscriptionError.apiError( + statusCode: httpResponse.statusCode, message: errorMessage) + } + + let transcriptionResponse = try JSONDecoder().decode( + SarvamTranscriptionResponse.self, from: data) + + let text = transcriptionResponse.transcript + guard !text.isEmpty else { + throw TranscriptionError.emptyTranscription + } + return text + } catch let error as TranscriptionError { + throw error + } catch { + throw TranscriptionError.networkError(error) + } + } +} + +// MARK: - Response Model + +private struct SarvamTranscriptionResponse: Codable { + let requestId: String? + let transcript: String + let languageCode: String? + + enum CodingKeys: String, CodingKey { + case requestId = "request_id" + case transcript + case languageCode = "language_code" + } +} diff --git a/dhavnii/Features/Transcription/TranscriptionProvider.swift b/dhavnii/Features/Transcription/TranscriptionProvider.swift index f116640..28c640c 100644 --- a/dhavnii/Features/Transcription/TranscriptionProvider.swift +++ b/dhavnii/Features/Transcription/TranscriptionProvider.swift @@ -12,6 +12,7 @@ enum TranscriptionProviderType: String, CaseIterable, Identifiable { case groq = "Groq" case elevenLabs = "ElevenLabs" case deepgram = "Deepgram" + case sarvam = "Sarvam" var id: String { rawValue } @@ -20,6 +21,7 @@ enum TranscriptionProviderType: String, CaseIterable, Identifiable { case .groq: return "Groq (Whisper)" case .elevenLabs: return "ElevenLabs" case .deepgram: return "Deepgram" + case .sarvam: return "Sarvam AI" } } @@ -31,6 +33,8 @@ enum TranscriptionProviderType: String, CaseIterable, Identifiable { return "High-quality speech-to-text with advanced features" case .deepgram: return "Enterprise-grade transcription with real-time capabilities" + case .sarvam: + return "Indian-language-first speech-to-text powered by Saaras" } } } @@ -54,6 +58,33 @@ struct TranscriptionLanguageOption: Identifiable, Hashable { } extension TranscriptionProviderType { + // MARK: - Fallback & timeout UserDefaults keys + + /// UserDefaults key that stores the raw value of the fallback provider ("" = disabled). + nonisolated static let fallbackProviderDefaultsKey = "fallbackTranscriptionProvider" + + /// UserDefaults key that stores the per-attempt timeout in seconds (Double). + nonisolated static let timeoutSecondsDefaultsKey = "transcriptionTimeoutSeconds" + + /// Default timeout before falling back to the secondary provider (seconds). + nonisolated static let defaultTimeoutSeconds: Double = 8 + + /// The currently saved fallback provider, or nil when disabled. + nonisolated static var savedFallbackProvider: TranscriptionProviderType? { + guard let raw = UserDefaults.standard.string(forKey: fallbackProviderDefaultsKey), + !raw.isEmpty + else { return nil } + return TranscriptionProviderType(rawValue: raw) + } + + /// The currently saved timeout (seconds). Falls back to the default if unset. + nonisolated static var savedTimeoutSeconds: Double { + let stored = UserDefaults.standard.double(forKey: timeoutSecondsDefaultsKey) + return stored > 0 ? stored : defaultTimeoutSeconds + } + + // MARK: - Existing keys (model / language) + nonisolated var modelUserDefaultsKey: String { switch self { case .groq: @@ -62,6 +93,8 @@ extension TranscriptionProviderType { return "selectedElevenLabsTranscriptionModel" case .deepgram: return "selectedDeepgramTranscriptionModel" + case .sarvam: + return "selectedSarvamTranscriptionModel" } } @@ -73,6 +106,8 @@ extension TranscriptionProviderType { return "scribe_v2" case .deepgram: return "nova-3" + case .sarvam: + return "saaras:v3" } } @@ -120,6 +155,19 @@ extension TranscriptionProviderType { "Recommended for languages not yet supported by nova-3 and filler words" ), ] + case .sarvam: + return [ + TranscriptionModelOption( + id: "saaras:v3", + name: "Saaras v3", + description: "Latest Sarvam model, recommended for all Indian languages" + ), + TranscriptionModelOption( + id: "saaras:v2.5", + name: "Saaras v2.5", + description: "Previous generation model" + ), + ] } } @@ -131,6 +179,8 @@ extension TranscriptionProviderType { return "selectedElevenLabsTranscriptionLanguage" case .deepgram: return "selectedDeepgramTranscriptionLanguage" + case .sarvam: + return "selectedSarvamTranscriptionLanguage" } } @@ -158,6 +208,8 @@ extension TranscriptionProviderType { return TranscriptionLanguageOption.autoID case .deepgram: return resolvedModel == "flux" ? "en" : TranscriptionLanguageOption.autoID + case .sarvam: + return TranscriptionLanguageOption.autoID } } @@ -210,6 +262,26 @@ extension TranscriptionProviderType { ) ] + Self.expandLanguageGroups(Self.deepgramNova3LanguageGroups) } + + case .sarvam: + return [ + TranscriptionLanguageOption( + id: TranscriptionLanguageOption.autoID, + name: "Auto", + description: "Automatically detect language from audio" + ), + TranscriptionLanguageOption(id: "hi-IN", name: "Hindi", description: "Locale code: hi-IN"), + TranscriptionLanguageOption(id: "bn-IN", name: "Bengali", description: "Locale code: bn-IN"), + TranscriptionLanguageOption(id: "kn-IN", name: "Kannada", description: "Locale code: kn-IN"), + TranscriptionLanguageOption(id: "ml-IN", name: "Malayalam", description: "Locale code: ml-IN"), + TranscriptionLanguageOption(id: "mr-IN", name: "Marathi", description: "Locale code: mr-IN"), + TranscriptionLanguageOption(id: "od-IN", name: "Odia", description: "Locale code: od-IN"), + TranscriptionLanguageOption(id: "pa-IN", name: "Punjabi", description: "Locale code: pa-IN"), + TranscriptionLanguageOption(id: "ta-IN", name: "Tamil", description: "Locale code: ta-IN"), + TranscriptionLanguageOption(id: "te-IN", name: "Telugu", description: "Locale code: te-IN"), + TranscriptionLanguageOption(id: "gu-IN", name: "Gujarati", description: "Locale code: gu-IN"), + TranscriptionLanguageOption(id: "en-IN", name: "English (India)", description: "Locale code: en-IN"), + ] } } @@ -217,7 +289,7 @@ extension TranscriptionProviderType { switch self { case .deepgram: return modelID == "flux" ? "flux-general-en" : modelID - case .groq, .elevenLabs: + case .groq, .elevenLabs, .sarvam: return modelID } } @@ -476,7 +548,9 @@ enum TranscriptionError: Error, LocalizedError { case networkError(Error) case decodingError(Error) case providerNotConfigured - + /// The request did not complete within the configured timeout interval. + case timeout(provider: String) + var errorDescription: String? { switch self { case .missingAPIKey(let provider): @@ -495,6 +569,20 @@ enum TranscriptionError: Error, LocalizedError { return "Failed to parse response: \(error.localizedDescription)" case .providerNotConfigured: return "Transcription provider not properly configured" + case .timeout(let provider): + return "\(provider) did not respond in time" + } + } + + /// Returns true for errors where retrying with a different provider is worthwhile. + var shouldTryFallback: Bool { + switch self { + case .timeout: return true + case .apiError(let statusCode, _): return statusCode == 429 || statusCode >= 500 + case .networkError: return true + case .missingAPIKey: return false + case .emptyTranscription: return false + case .invalidURL, .invalidResponse, .decodingError, .providerNotConfigured: return false } } } diff --git a/dhavnii/Features/Transcription/TranscriptionService.swift b/dhavnii/Features/Transcription/TranscriptionService.swift index 11fbcb9..52f1b1b 100644 --- a/dhavnii/Features/Transcription/TranscriptionService.swift +++ b/dhavnii/Features/Transcription/TranscriptionService.swift @@ -2,7 +2,7 @@ // TranscriptionService.swift // OpenWispher // -// Orchestrates the complete transcription flow. +// Orchestrates the complete transcription flow with timeout and fallback support. // import Foundation @@ -13,23 +13,30 @@ import Foundation internal class TranscriptionService { private let audioRecorder = AudioRecorder() private let clipboardManager = ClipboardManager() - + private var groqClient = GroqAPIClient() private var elevenLabsClient = ElevenLabsAPIClient() private var deepgramClient = DeepgramAPIClient() - + private var sarvamClient = SarvamAPIClient() + + // selectedProvider is still injected at startup but is now also re-read live from + // UserDefaults on every transcription attempt so that Settings changes take effect + // immediately without requiring an app restart. internal var selectedProvider: TranscriptionProviderType internal var appState: AppState internal var historyManager: HistoryManager? - + /// Notification posted when a new transcription is saved - internal static let transcriptionSavedNotification = Notification.Name("OpenWispher.TranscriptionSaved") + internal static let transcriptionSavedNotification = Notification.Name( + "OpenWispher.TranscriptionSaved") internal init(appState: AppState, selectedProvider: TranscriptionProviderType = .groq) { self.appState = appState self.selectedProvider = selectedProvider } + // MARK: - Recording control + /// Start the recording session internal func startRecording() { print("🎤 TranscriptionService.startRecording() called") @@ -68,14 +75,15 @@ internal class TranscriptionService { } } - /// Cancel recording and discard audio without saving + /// Cancel recording and discard audio without transcribing internal func cancelRecording() { print("⏹️ TranscriptionService.cancelRecording() called") print(" Current appState.recordingState: \(appState.recordingState)") print(" AudioRecorder.isRecording: \(audioRecorder.isRecording)") guard audioRecorder.isRecording else { - print("⚠️ cancelRecording() called but audioRecorder.isRecording is false - ignoring") + print( + "⚠️ cancelRecording() called but audioRecorder.isRecording is false - ignoring") return } @@ -84,10 +92,14 @@ internal class TranscriptionService { appState.recordingState = .idle } - /// Process the recorded audio through the selected provider + // MARK: - Transcription orchestration + + /// Full pipeline: read audio → try primary → try fallback on eligible error → deliver result. private func processTranscription() async { + // The disk file is deleted after this function exits regardless of outcome. + // audioData below is an in-memory copy, so both primary and fallback calls + // can use it even though the file is gone by the time defer fires. defer { - // Always clean up the audio file audioRecorder.deleteRecording() } @@ -98,50 +110,157 @@ internal class TranscriptionService { return } - let provider: TranscriptionProvider - switch selectedProvider { - case .groq: - provider = groqClient - case .elevenLabs: - provider = elevenLabsClient - case .deepgram: - provider = deepgramClient - } + // Re-read the primary provider live from UserDefaults so that changes made + // in Settings are reflected without restarting the app. + let primary = refreshSelectedProvider() + let fallback = TranscriptionProviderType.savedFallbackProvider + let timeoutSeconds = TranscriptionProviderType.savedTimeoutSeconds + + print( + "🎙️ STT request: primary=\(primary.rawValue), fallback=\(fallback?.rawValue ?? "none"), timeout=\(timeoutSeconds)s" + ) do { - print("🎙️ STT request: provider=\(selectedProvider.rawValue)") - let transcription = try await provider.transcribe(audioData: audioData, fileName: "audio.m4a") - - if transcription.isEmpty { - appState.recordingState = .error(message: "Empty result") - FeedbackManager.shared.showEmptyRecordingWarning() - resetAfterDelay() - return + let transcription = try await attempt( + provider: primary, + audioData: audioData, + timeoutSeconds: timeoutSeconds + ) + await deliver(transcription: transcription, provider: primary) + } catch let primaryError { + print("⚠️ Primary provider \(primary.rawValue) failed: \(primaryError)") + + // Determine whether to try the fallback. + // Unknown/unexpected errors default to NOT retrying (false) to avoid + // silently swallowing errors that are not transient provider failures. + // CancellationError is explicitly excluded — a cancelled task must not + // trigger a fallback attempt. + let eligibleForFallback: Bool + if primaryError is CancellationError { + eligibleForFallback = false + } else { + eligibleForFallback = (primaryError as? TranscriptionError)?.shouldTryFallback ?? false + } + + if eligibleForFallback, let fallback, fallback != primary { + print("🔄 Trying fallback provider: \(fallback.rawValue)") + do { + let transcription = try await attempt( + provider: fallback, + audioData: audioData, + timeoutSeconds: timeoutSeconds + ) + // Successful fallback — track analytics silently, no user-facing notice + AnalyticsManager.shared.trackFallbackUsed( + primary: primary, + fallback: fallback, + reason: (primaryError as? TranscriptionError)?.analyticsReason ?? "unknown" + ) + await deliver(transcription: transcription, provider: fallback) + } catch let fallbackError { + print("❌ Fallback provider \(fallback.rawValue) also failed: \(fallbackError)") + surfaceError(fallbackError) + } + } else { + surfaceError(primaryError) } + } + } - // Copy to clipboard and auto-paste if possible - clipboardManager.copyAndPasteIfPossible(transcription) + // MARK: - Single-attempt helper - // Save to history after successful transcription - historyManager?.saveTranscription( - text: transcription, - provider: selectedProvider - ) - - // Post notification for UI refresh (event-driven instead of polling) - NotificationCenter.default.post(name: Self.transcriptionSavedNotification, object: nil) + /// Calls `provider.transcribe` and cancels it if it exceeds `timeoutSeconds`. + /// Throws `TranscriptionError.timeout` when the deadline is exceeded. + private func attempt( + provider providerType: TranscriptionProviderType, + audioData: Data, + timeoutSeconds: Double + ) async throws -> String { + let client = client(for: providerType) - appState.lastTranscription = transcription - appState.recordingState = .success - FeedbackManager.shared.showTranscriptionSuccess() - resetAfterDelay(seconds: 1.5) + return try await withThrowingTaskGroup(of: String.self) { group in + // Transcription task + group.addTask { + try await client.transcribe(audioData: audioData, fileName: "audio.m4a") + } - } catch { - let userMessage = error.userFriendlyMessage - appState.recordingState = .error(message: String(userMessage.prefix(20))) - FeedbackManager.shared.showAPIError(userMessage) + // Timeout watchdog + let timeoutNS = UInt64(timeoutSeconds * 1_000_000_000) + group.addTask { + try await Task.sleep(nanoseconds: timeoutNS) + throw TranscriptionError.timeout(provider: providerType.rawValue) + } + + // Whichever finishes first wins; cancel the other + do { + guard let result = try await group.next() else { + // Should never happen: the group always contains at least two tasks. + group.cancelAll() + throw TranscriptionError.invalidResponse + } + group.cancelAll() + return result + } catch { + group.cancelAll() + throw error + } + } + } + + // MARK: - Delivery + + /// Validates the transcription text, copies it to clipboard, saves to history, updates state. + private func deliver(transcription: String, provider: TranscriptionProviderType) async { + guard !transcription.isEmpty else { + appState.recordingState = .error(message: "Empty result") + FeedbackManager.shared.showEmptyRecordingWarning() resetAfterDelay() + return } + + clipboardManager.copyAndPasteIfPossible(transcription) + + historyManager?.saveTranscription(text: transcription, provider: provider) + + NotificationCenter.default.post(name: Self.transcriptionSavedNotification, object: nil) + + appState.lastTranscription = transcription + appState.recordingState = .success + FeedbackManager.shared.showTranscriptionSuccess() + resetAfterDelay(seconds: 1.5) + } + + // MARK: - Error surface + + private func surfaceError(_ error: Error) { + let userMessage = error.userFriendlyMessage + appState.recordingState = .error(message: String(userMessage.prefix(20))) + FeedbackManager.shared.showAPIError(userMessage) + resetAfterDelay() + } + + // MARK: - Utilities + + private func client(for providerType: TranscriptionProviderType) -> any TranscriptionProvider { + switch providerType { + case .groq: return groqClient + case .elevenLabs: return elevenLabsClient + case .deepgram: return deepgramClient + case .sarvam: return sarvamClient + } + } + + /// Reads the primary provider live from UserDefaults and updates `selectedProvider` + /// as a side effect so in-memory state stays consistent with persisted settings. + @discardableResult + private func refreshSelectedProvider() -> TranscriptionProviderType { + guard + let raw = UserDefaults.standard.string( + forKey: "selectedTranscriptionProvider"), + let live = TranscriptionProviderType(rawValue: raw) + else { return selectedProvider } + selectedProvider = live + return live } /// Reset to idle state after a delay @@ -152,3 +271,16 @@ internal class TranscriptionService { } } } + +// MARK: - TranscriptionError analytics helper + +private extension TranscriptionError { + var analyticsReason: String { + switch self { + case .timeout: return "timeout" + case .apiError(let code, _): return code == 429 ? "rate_limit" : "api_error_\(code)" + case .networkError: return "network_error" + default: return "error" + } + } +} diff --git a/openwispher/AnalyticsManager.swift b/openwispher/AnalyticsManager.swift index a81056a..d5c2914 100644 --- a/openwispher/AnalyticsManager.swift +++ b/openwispher/AnalyticsManager.swift @@ -13,12 +13,22 @@ internal final class AnalyticsManager { internal static let shared = AnalyticsManager() private var isConfigured = false + private var appOpenedCount = 0 private init() {} @discardableResult internal func configureIfNeeded() -> Bool { - guard !isConfigured else { return true } + guard !isConfigured else { + #if DEBUG + print("📊 PostHog already configured") + #endif + return true + } + + #if DEBUG + print("📊 Configuring PostHog SDK") + #endif let apiKey = resolvedInfoValue(primaryKey: "POSTHOG_API_KEY", fallbackKey: "INFOPLIST_KEY_POSTHOG_API_KEY") ?? ProcessInfo.processInfo.environment["POSTHOG_API_KEY"] @@ -52,17 +62,34 @@ internal final class AnalyticsManager { PostHogSDK.shared.setup(config) registerAppMetadata() isConfigured = true + + #if DEBUG + print("✅ PostHog SDK configured") + #endif + return true } - internal func trackAppOpened() { + internal func trackAppOpened(trigger: String = "unknown") { + #if DEBUG + print("🚀 trackAppOpened called (trigger=\(trigger))") + #endif + guard configureIfNeeded() else { #if DEBUG print("⚠️ PostHog not configured. Skipping app_opened.") #endif return } - captureAndFlush("app_opened") + + appOpenedCount += 1 + captureAndFlush( + "app_opened", + properties: [ + "trigger": trigger, + "open_count_in_run": appOpenedCount, + ] + ) } internal func trackHotkeyPressed(hotkey: HotkeyDefinition) { @@ -95,7 +122,39 @@ internal final class AnalyticsManager { captureAndFlush("onboarding_completed") } + internal func trackHistoryExported(count: Int) { + guard configureIfNeeded() else { return } + captureAndFlush( + "history_exported", + properties: ["transcription_count": count] + ) + } + + internal func trackFallbackUsed( + primary: TranscriptionProviderType, + fallback: TranscriptionProviderType, + reason: String + ) { + guard configureIfNeeded() else { return } + captureAndFlush( + "transcription_fallback_used", + properties: [ + "primary_provider": primary.rawValue, + "fallback_provider": fallback.rawValue, + "reason": reason, + ] + ) + } + private func captureAndFlush(_ event: String, properties: [String: Any]? = nil) { + #if DEBUG + if let properties { + print("📊 Capturing event '\(event)' with properties: \(properties)") + } else { + print("📊 Capturing event '\(event)'") + } + #endif + if let properties { PostHogSDK.shared.capture(event, properties: properties) } else { diff --git a/openwispher/openwispherApp.swift b/openwispher/openwispherApp.swift index bbffe86..c76a1ec 100644 --- a/openwispher/openwispherApp.swift +++ b/openwispher/openwispherApp.swift @@ -11,6 +11,7 @@ import SwiftUI @main internal struct openwispherApp: App { + @NSApplicationDelegateAdaptor(AppLifecycleDelegate.self) private var appLifecycleDelegate @State private var appState = AppState() @State private var permissionManager = PermissionManager() @State private var historyManager: HistoryManager? @@ -47,7 +48,6 @@ internal struct openwispherApp: App { NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification) ) { _ in permissionManager.checkPermissions() - AnalyticsManager.shared.trackAppOpened() } .preferredColorScheme(.dark) } @@ -91,6 +91,28 @@ internal struct openwispherApp: App { } +@MainActor +internal final class AppLifecycleDelegate: NSObject, NSApplicationDelegate { + internal func applicationDidFinishLaunching(_ notification: Notification) { + #if DEBUG + print("🧭 App lifecycle: didFinishLaunching") + #endif + + let isConfigured = AnalyticsManager.shared.configureIfNeeded() + #if DEBUG + print("🧭 PostHog configured before app_opened=\(isConfigured)") + #endif + } + + internal func applicationDidBecomeActive(_ notification: Notification) { + #if DEBUG + print("🧭 App lifecycle: didBecomeActive") + #endif + + AnalyticsManager.shared.trackAppOpened(trigger: "did_become_active") + } +} + /// Content view that manages environment and setup private struct AppContentView: View { @Bindable var appState: AppState @@ -266,6 +288,8 @@ private struct AppContentView: View { appState.hasCompletedOnboarding = hasCompleted if hasCompleted { + // Migrate existing keychain items to AfterFirstUnlock accessibility (one-time, silent) + SecureStorage.migrateKeychainAccessibility() // Warm up keychain access only after onboarding _ = SecureStorage.retrieveAPIKey(for: selectedProvider) }