Skip to content
Merged

es #12

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
235 changes: 235 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment on lines +133 to +135

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

cd dhavnii should be cd openwispher after the clone.

The clone command (now correctly pointing to maker-or/openwispher.git) creates a directory named openwispher, but the next line still tries to cd dhavnii.

📝 Proposed fix
 git clone https://github.com/maker-or/openwispher.git
-cd dhavnii
+cd openwispher
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
git clone https://github.com/maker-or/openwispher.git
cd dhavnii
```
git clone https://github.com/maker-or/openwispher.git
cd openwispher
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` around lines 133 - 135, The README contains an incorrect directory
change command: after the git clone command `git clone
https://github.com/maker-or/openwispher.git` the subsequent `cd dhavnii` is
wrong; update that command to `cd openwispher` so it matches the cloned
repository name and allows subsequent steps to run in the correct project
directory.


### 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=<your key>
DEEPGRAM_API_KEY=<your key>
ELEVENLABS_API_KEY=<your 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 `<Provider>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`)

---
2 changes: 2 additions & 0 deletions dhavnii/Core/Feedback/UserFeedbackSystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 39 additions & 3 deletions dhavnii/Core/Security/SecureStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -133,6 +164,8 @@ internal enum SecureStorage {
userDefaultsKey = "elevenLabsAPIKey"
case .deepgram:
userDefaultsKey = "deepgramAPIKey"
case .sarvam:
userDefaultsKey = "sarvamAPIKey"
}

// Check if already in keychain
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -178,6 +212,8 @@ internal enum SecureStorage {
return elevenLabsAPIKey
case .deepgram:
return deepgramAPIKey
case .sarvam:
return sarvamAPIKey
}
}

Expand Down
26 changes: 23 additions & 3 deletions dhavnii/Core/UI/LiquidGlassHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// Shared Liquid Glass helpers and fallbacks.
//

import AppKit
import SwiftUI

internal extension View {
Expand All @@ -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(
Expand All @@ -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 {
Expand All @@ -53,7 +73,6 @@ internal extension View {
if #available(macOS 15.0, *) {
self
.toolbarBackgroundVisibility(.hidden, for: .windowToolbar)
.containerBackground(.ultraThinMaterial, for: .window)
} else {
self
}
Expand All @@ -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()
}
Expand Down
Loading