diff --git a/.gitignore b/.gitignore index 585ad25..1eabaa0 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,7 @@ Temporary Items *.bak *.backup +# Build logs +build_log.txt +*.log + diff --git a/Makefile b/Makefile index 21cf5cf..4e2cd9a 100644 --- a/Makefile +++ b/Makefile @@ -1,17 +1,23 @@ # Makefile for SpeakType -.PHONY: help build clean test lint format run setup +.PHONY: help build clean test lint format run setup logs logs-live logs-errors logs-export # Default target help: @echo "SpeakType - Available commands:" - @echo " make setup - Initial project setup" - @echo " make build - Build the project" - @echo " make run - Run the application" - @echo " make test - Run all tests" - @echo " make lint - Run SwiftLint" - @echo " make format - Format code with SwiftLint" - @echo " make clean - Clean build artifacts" + @echo " make setup - Initial project setup" + @echo " make build - Build the project" + @echo " make run - Run the application" + @echo " make test - Run all tests" + @echo " make lint - Run SwiftLint" + @echo " make format - Format code with SwiftLint" + @echo " make clean - Clean build artifacts" + @echo "" + @echo "Logging:" + @echo " make logs - View live logs" + @echo " make logs-live - Stream live logs (alias)" + @echo " make logs-errors - View recent errors" + @echo " make logs-export - Export last 24h logs to Desktop" # Project setup setup: @@ -82,3 +88,40 @@ xcode: @echo "Opening in Xcode..." open speaktype.xcodeproj +# LOGGING COMMANDS + +# View live logs +logs: + @echo "πŸ“± Streaming SpeakType logs (Ctrl+C to stop)..." + @echo "Tip: Run 'make run' in another terminal first" + @echo "" + log stream --predicate 'process == "speaktype"' --level debug --style compact + +# Alias for logs +logs-live: logs + +# View recent errors +logs-errors: + @echo "❌ Recent SpeakType errors (last hour)..." + @log show --predicate 'process == "speaktype" AND messageType == error' --last 1h --style compact || echo "No errors found" + +# View recent logs (last 30 minutes) +logs-recent: + @echo "πŸ“ Recent SpeakType logs (last 30 minutes)..." + @log show --predicate 'process == "speaktype"' --last 30m --style compact + +# Export logs to Desktop +logs-export: + @echo "πŸ’Ύ Exporting logs to Desktop..." + @mkdir -p ~/Desktop/SpeakType_Logs + @log show --predicate 'process == "speaktype"' --last 1d > ~/Desktop/SpeakType_Logs/app_logs_$(shell date +%Y%m%d_%H%M%S).txt + @echo "System: $(shell sw_vers -productVersion)" > ~/Desktop/SpeakType_Logs/system_info.txt + @echo "Date: $(shell date)" >> ~/Desktop/SpeakType_Logs/system_info.txt + @echo "βœ… Logs exported to ~/Desktop/SpeakType_Logs/" + @open ~/Desktop/SpeakType_Logs/ + +# Open Console.app filtered to SpeakType +logs-console: + @echo "Opening Console.app..." + @open -a Console + diff --git a/README.md b/README.md index 5bcc57c..7e3a156 100644 --- a/README.md +++ b/README.md @@ -1,103 +1,371 @@ # SpeakType -A modern macOS application built with SwiftUI. +
-## Project Structure +![SpeakType Icon](speaktype/Assets.xcassets/AppIcon.appiconset/icon_256x256.png) -``` -speaktype/ -β”œβ”€β”€ speaktype/ # Main application target -β”‚ β”œβ”€β”€ App/ # Application entry point and configuration -β”‚ β”œβ”€β”€ Views/ # SwiftUI views and UI components -β”‚ β”œβ”€β”€ ViewModels/ # View models and business logic -β”‚ β”œβ”€β”€ Models/ # Data models and entities -β”‚ β”œβ”€β”€ Services/ # Business logic and API services -β”‚ β”œβ”€β”€ Utilities/ # Helper functions and extensions -β”‚ β”œβ”€β”€ Resources/ # Non-code resources -β”‚ β”‚ └── Assets.xcassets/ # Images, colors, and other assets -β”‚ └── Supporting Files/ # Info.plist, entitlements, etc. -β”œβ”€β”€ speaktypeTests/ # Unit tests -β”œβ”€β”€ speaktypeUITests/ # UI tests -└── docs/ # Documentation -``` +**Fast, Offline Voice-to-Text for macOS** + +[![Swift](https://img.shields.io/badge/Swift-5.9-orange?logo=swift)](https://swift.org) +[![Platform](https://img.shields.io/badge/Platform-macOS%2013.0+-blue?logo=apple)](https://www.apple.com/macos/) +[![SwiftUI](https://img.shields.io/badge/SwiftUI-Native-green)](https://developer.apple.com/xcode/swiftui/) +[![License](https://img.shields.io/badge/License-MIT-red)](LICENSE) + +*Press a hotkey, speak, and instantly paste text anywhere on your Mac.* + +[Features](#features) β€’ [Installation](#installation) β€’ [Usage](#usage) β€’ [Development](#development) β€’ [Contributing](#contributing) + +
+ +--- + +## 🎯 What is SpeakType? + +SpeakType is a **privacy-first, offline voice dictation tool** for macOS that lets you type with your voice anywhereβ€”in any app, at any time. Unlike online dictation services, everything runs **100% locally** on your Mac using OpenAI's Whisper AI model. + +### Why SpeakType? + +- πŸ”’ **Privacy First**: Zero data leaves your Macβ€”ever +- ⚑ **Lightning Fast**: Optimized for Apple Silicon +- 🌐 **Works Everywhere**: Any app, any text field +- 🎯 **Accurate**: Powered by OpenAI Whisper AI +- πŸ’° **Free & Open Source**: No subscriptions, no limits -## Requirements +--- -- macOS 13.0+ -- Xcode 15.0+ -- Swift 5.9+ +##✨ Features -## Getting Started +### Core Features +- **🎀 System-Wide Dictation** - Works in Notes, Slack, Chrome, Terminalβ€”anywhere +- **⌨️ Global Hotkey** - Press, speak, release = instant text (default: `βŒ₯ Space`) +- **πŸ”’ 100% Offline** - All processing happens locally with WhisperKit +- **πŸ“‹ Smart Paste** - Auto-copies to clipboard or pastes directly +- **🎨 Native macOS UI** - Beautiful SwiftUI interface with dark mode -### Installation +### AI & Models +- **Multiple Models** - Choose between Base (fast) or Large (accurate) +- **Auto-Download** - One-click model downloads with progress tracking +- **Model Management** - Easy switching and cache cleanup +- **Optimized for Apple Silicon** - Best performance on M1/M2/M3 Macs + +### User Experience +- **Mini Recorder** - Elegant floating recorder window +- **Visual Feedback** - Real-time audio waveforms and indicators +- **History** - Review past transcriptions +- **Customizable** - Configure hotkeys, audio devices, and behavior + +--- + +## πŸš€ Installation + +### Requirements + +| Component | Requirement | +|-----------|-------------| +| **OS** | macOS 13.0+ (Ventura or newer) | +| **Architecture** | Apple Silicon (M1+) recommended | +| **Storage** | 2GB free (for AI models) | +| **Permissions** | Microphone & Accessibility | + +### Quick Start -1. Clone the repository: ```bash -git clone +# 1. Clone the repository +git clone https://github.com/yourusername/speaktype.git cd speaktype -``` -2. Open the project in Xcode: -```bash +# 2. Build & run +make build +make run + +# Or open in Xcode open speaktype.xcodeproj ``` -3. Build and run the project (⌘R) +### First Time Setup -### Development +1. **Grant Permissions** (you'll be prompted): + - 🎀 Microphone access for recording + - β™Ώ Accessibility access for auto-paste -- Follow the [Contributing Guidelines](CONTRIBUTING.md) when making changes -- Use SwiftUI for all UI components -- Follow Swift naming conventions and style guide -- Write unit tests for business logic -- Write UI tests for critical user flows +2. **Download AI Model**: + - Open SpeakType β†’ AI Models + - Click "Download" on Whisper Base (550MB) + - Wait for download to complete -## Architecture +3. **Configure Hotkey** (optional): + - Go to Settings + - Default is `βŒ₯ Space` (Option + Space) + - Customize to your preference -This project follows the MVVM (Model-View-ViewModel) architecture pattern: +4. **Start Using**: + - Press hotkey anywhere + - Speak naturally + - Release = text appears! -- **Models**: Data structures and business entities -- **Views**: SwiftUI views that display data -- **ViewModels**: Manage view state and handle user interactions -- **Services**: Handle data persistence, networking, and other business logic +--- -## Building +## πŸ’‘ Usage -### Debug Build -```bash -xcodebuild -scheme speaktype -configuration Debug +### Basic Usage + +``` +1. Press hotkey (βŒ₯ Space) +2. Speak your text +3. Release hotkey +4. ✨ Text appears! ``` -### Release Build -```bash -xcodebuild -scheme speaktype -configuration Release +### Examples + +**Writing an Email** +``` +Press hotkey β†’ "Hi team comma I wanted to share the latest updates period" +β†’ "Hi team, I wanted to share the latest updates." +``` + +**Coding** +``` +Press hotkey β†’ "function calculate total open paren items close paren" +β†’ "function calculateTotal(items)" ``` -## Testing +**Quick Notes** +``` +Press hotkey β†’ "Remember to buy milk and eggs" +β†’ "Remember to buy milk and eggs" +``` + +### Pro Tips + +- πŸ’¬ **Speak naturally** - Whisper handles accents and casual speech +- ⏱️ **Shorter is better** - Best results with 3-10 second clips +- 🎯 **Say punctuation** - "comma", "period", "question mark" +- πŸ”„ **Try again** - If transcription is off, just press hotkey again + +--- + +## πŸ› οΈ Development + +### Project Structure + +``` +speaktype/ +β”œβ”€β”€ App/ # Application entry point +β”‚ β”œβ”€β”€ speaktypeApp.swift +β”‚ └── AppDelegate.swift +β”œβ”€β”€ Views/ # SwiftUI user interface +β”‚ β”œβ”€β”€ Screens/ # Full-screen views +β”‚ β”œβ”€β”€ Components/ # Reusable UI components +β”‚ └── Overlays/ # Floating overlays +β”œβ”€β”€ ViewModels/ # MVVM view models +β”œβ”€β”€ Models/ # Data models +β”‚ β”œβ”€β”€ AIModel.swift +β”‚ β”œβ”€β”€ TranscriptionResult.swift +β”‚ └── ... +β”œβ”€β”€ Services/ # Business logic +β”‚ β”œβ”€β”€ AudioRecordingService.swift +β”‚ β”œβ”€β”€ WhisperService.swift +β”‚ β”œβ”€β”€ ModelDownloadService.swift +β”‚ └── ... +β”œβ”€β”€ Controllers/ # Window management +β”œβ”€β”€ Utilities/ # Helpers & extensions +β”‚ β”œβ”€β”€ AppLogger.swift +β”‚ β”œβ”€β”€ Constants.swift +β”‚ └── Extensions/ +└── Resources/ # Assets & config + β”œβ”€β”€ Assets.xcassets/ + β”œβ”€β”€ Info.plist + └── speaktype.entitlements +``` + +### Architecture + +SpeakType follows **MVVM (Model-View-ViewModel)** pattern with clean separation: + +- **Models**: Data structures (`AIModel`, `TranscriptionResult`) +- **Views**: SwiftUI UI components +- **ViewModels**: Business logic and state management +- **Services**: Core functionality (audio, transcription, clipboard) + +### Tech Stack + +| Layer | Technology | +|-------|------------| +| **Language** | Swift 5.9+ | +| **UI** | SwiftUI + AppKit | +| **AI** | [WhisperKit](https://github.com/argmaxinc/WhisperKit) 0.9.4 | +| **Audio** | AVFoundation | +| **Hotkeys** | [KeyboardShortcuts](https://github.com/sindresorhus/KeyboardShortcuts) | +| **State** | Combine + SwiftUI | + +### Quick Commands -### Run Unit Tests ```bash -xcodebuild test -scheme speaktype -destination 'platform=macOS' +# Build & run +make build # Build debug +make run # Run app +make clean # Clean build artifacts + +# Testing +make test # Run all tests +make test-unit # Unit tests only +make test-ui # UI tests only + +# Logging +make logs # View live logs +make logs-errors # Recent errors only +make logs-export # Export to Desktop + +# Code quality +make lint # Run SwiftLint +make format # Auto-fix issues +``` + +### Logging & Debugging + +SpeakType uses Apple's **unified logging system** with categories: + +```swift +// In your code +import Foundation + +AppLogger.info("Started recording", category: .audio) +AppLogger.success("Transcription complete", category: .transcription) +AppLogger.error("Download failed", error: error, category: .models) ``` -### Run UI Tests +**View logs in terminal:** ```bash -xcodebuild test -scheme speaktype -destination 'platform=macOS' -only-testing:speaktypeUITests +# Live stream +make logs + +# Filter by category +log stream --predicate 'process == "speaktype" AND category == "Transcription"' + +# Export for debugging +make logs-export +``` + +--- + +## 🀝 Contributing + +We welcome contributions! Here's how to get started: + +### Quick Start for Contributors + +1. **Fork & clone** + ```bash + git clone https://github.com/yourusername/speaktype.git + cd speaktype + ``` + +2. **Create a branch** + ```bash + git checkout -b feature/amazing-feature + ``` + +3. **Make changes** + - Follow SwiftUI best practices + - Write tests for new features + - Run `make lint` before committing + +4. **Submit PR** + ```bash + git commit -m "Add amazing feature" + git push origin feature/amazing-feature + ``` + +### Code Guidelines + +- βœ… Use MVVM pattern +- βœ… Write SwiftUI views, not UIKit +- βœ… Add unit tests for services +- βœ… Follow [Swift API Design Guidelines](https://swift.org/documentation/api-design-guidelines/) +- βœ… Use `AppLogger` instead of `print()` + +See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. + +--- + +## πŸ› Known Issues + +### WhisperKit Dependency Warnings + +You may see these build warnings: ``` +warning: 'WhisperKit' is missing a dependency on 'TensorUtils' +warning: 'WhisperKit' is missing a dependency on 'Hub' +warning: 'WhisperKit' is missing a dependency on 'Tokenizers' +``` + +**These are harmless** and come from Xcode's dependency scanner analyzing WhisperKit's internal structure. They don't affect functionalityβ€”your app builds and runs perfectly. This is a limitation of WhisperKit 0.9.4, not your code. + +--- + +## πŸ—ΊοΈ Roadmap + +### Version 1.1 (Next) +- [ ] Streaming transcription (real-time) +- [ ] Multiple language support +- [ ] Custom vocabulary/terms +- [ ] Improved punctuation handling + +### Version 1.2 (Future) +- [ ] Transcription history with search +- [ ] Text formatting commands +- [ ] Per-app custom hotkeys +- [ ] iCloud sync (optional) + +### Version 2.0 (Vision) +- [ ] iOS companion app +- [ ] App Store release +- [ ] Advanced settings +- [ ] Plugin system + +--- + +## πŸ“„ License + +MIT License - see [LICENSE](LICENSE) file for details. + +--- + +## πŸ™ Credits & Acknowledgments + +SpeakType is built with amazing open-source projects: + +- **[WhisperKit](https://github.com/argmaxinc/WhisperKit)** by Argmax - Whisper models for macOS +- **[KeyboardShortcuts](https://github.com/sindresorhus/KeyboardShortcuts)** by Sindre Sorhus - Global hotkey management +- **[OpenAI Whisper](https://github.com/openai/whisper)** - State-of-the-art speech recognition + +**Inspiration:** +- [VoiceInk](https://voices.ink/) - Original inspiration +- macOS Speech Framework - System dictation + +--- + +## πŸ“ž Support & Community + +- **πŸ› Bug Reports**: [GitHub Issues](https://github.com/yourusername/speaktype/issues) +- **πŸ’¬ Discussions**: [GitHub Discussions](https://github.com/yourusername/speaktype/discussions) +- **πŸ“§ Email**: [your.email@example.com](mailto:your.email@example.com) + +--- + +## ⭐ Star History -## Code Style +If you find SpeakType useful, please consider starring the repo! -- Use SwiftLint for code linting (configuration in `.swiftlint.yml`) -- Follow Apple's Swift API Design Guidelines -- Use meaningful variable and function names -- Add comments for complex logic -- Keep functions small and focused +--- -## License +
-[Add your license here] +**Made with ❀️ by developers, for developers** -## Contact +*Privacy-first β€’ Open Source β€’ Forever Free* -[Add contact information here] +[⬆ Back to Top](#speaktype) +
diff --git a/TESTING.md b/TESTING.md deleted file mode 100644 index ceac42c..0000000 --- a/TESTING.md +++ /dev/null @@ -1,360 +0,0 @@ -# SpeakType Phase Testing Guide - -Quick tests to verify each phase works before moving to the next one. - ---- - -## βœ… Phase 1: Foundation (DONE) - -### Test 1: Project Builds -```bash -cd /Users/karansingh/projects/iosapps/speaktype -xcodebuild -scheme speaktype -destination 'platform=macOS' clean build -``` -**Pass**: Build succeeds -**Fail**: Build errors - -### Test 2: Models Import -Add to `ContentView.swift`: -```swift -let state: TranscriptionState = .idle -let settings = AppSettings.default -``` -**Pass**: No compile errors -**Fail**: "Cannot find type" errors - -### Manual Setup Needed: -1. Open `speaktype.xcodeproj` in Xcode -2. Add WhisperKit package: `https://github.com/argmaxinc/WhisperKit` -3. Add all model files to Xcode project (right-click Models folder β†’ Add Files) -4. Set Info.plist path in Build Settings -5. Set entitlements path in Build Settings - ---- - -## 🎀 Phase 2: Core Services - -### Test 1: Microphone Permission -Run app β†’ Should request microphone permission - -**Pass**: Permission dialog appears -**Fail**: No dialog or crash - -### Test 2: Audio Recording -Add test button to ContentView: -```swift -Button("Test Record") { - let recorder = AudioRecordingService() - recorder.startRecording() - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - recorder.stopRecording() - print("βœ… Recording works") - } -} -``` -**Pass**: Prints "βœ… Recording works" -**Fail**: Crash or error - -### Test 3: Whisper Transcription -Add test button: -```swift -Button("Test Whisper") { - Task { - let service = WhisperService() - try? await service.initialize() - print("βœ… Whisper loaded") - } -} -``` -**Pass**: Prints "βœ… Whisper loaded" after 3-5 seconds -**Fail**: Crash or never loads - -### Test 4: Clipboard Copy -```swift -Button("Test Clipboard") { - ClipboardService.shared.copy(text: "Hello World") - let result = ClipboardService.shared.paste() - print("Clipboard: \(result)") -} -``` -**Pass**: Prints "Clipboard: Hello World" -**Fail**: Empty or wrong text - ---- - -## πŸ–₯️ Phase 3: UI Components - -### Test 1: Menu Bar Icon -Run app β†’ Check menu bar (top right) - -**Pass**: App icon appears in menu bar -**Fail**: No icon or dock icon instead - -### Test 2: Menu Bar Popover -Click menu bar icon - -**Pass**: Popover appears with status -**Fail**: Nothing happens or crash - -### Test 3: Listening Overlay -```swift -Button("Show Listening") { - // Should show floating "Listening..." panel - WindowManager.shared.showListeningOverlay() -} -``` -**Pass**: Centered floating panel appears -**Fail**: No panel or crashes - -### Test 4: Settings Window -```swift -Button("Open Settings") { - WindowManager.shared.showSettings() -} -``` -**Pass**: Settings window opens with tabs -**Fail**: No window or crash - ---- - -## πŸ”— Phase 4: ViewModels - -### Test 1: AppViewModel State -```swift -let viewModel = AppViewModel() -viewModel.startListening() -print("State: \(viewModel.state)") -``` -**Pass**: State changes from .idle to .listening -**Fail**: State doesn't change - -### Test 2: Recording β†’ Transcribing Flow -```swift -let viewModel = AppViewModel() -viewModel.startListening() -// Wait 2 seconds -viewModel.stopListening() -// State should go: idle β†’ listening β†’ transcribing β†’ ready -``` -**Pass**: State transitions correctly -**Fail**: Gets stuck or crashes - ---- - -## πŸ’Ύ Phase 5: Storage - -### Test 1: Settings Persistence -```swift -var settings = AppSettings.default -settings.autoPaste = true -SettingsStorageService.shared.save(settings) - -let loaded = SettingsStorageService.shared.load() -print("Auto-paste: \(loaded.autoPaste)") -``` -**Pass**: Prints "Auto-paste: true" -**Fail**: Prints false or crashes - -### Test 2: Hotkey Storage -```swift -let hotkey = HotkeyConfiguration.default -HotkeyStorageService.shared.save(hotkey) -let loaded = HotkeyStorageService.shared.load() -print("Hotkey: \(loaded.description)") -``` -**Pass**: Prints hotkey description -**Fail**: Nil or crash - ---- - -## 🎯 Phase 6: Integration - -### Test 1: Complete App Launch -Run app from Xcode (⌘R) - -**Pass**: -- App icon in menu bar -- Click icon β†’ popover appears -- No crash for 1 minute - -**Fail**: Crash or no menu bar icon - -### Test 2: Hotkey Registration -Run app β†’ Open Settings β†’ Try changing hotkey - -**Pass**: Can press new key combo and it registers -**Fail**: Can't capture hotkey or crash - ---- - -## 🎬 Phase 7: End-to-End Flow - -### THE BIG TEST: Full Recording Flow - -1. Run app -2. Press hotkey (default: βŒƒβ‡§Space) -3. Speak: "Hello world this is a test" -4. Release hotkey -5. Wait for transcription -6. Check clipboard - -**Pass**: -- Listening overlay appeared -- Transcribing overlay appeared -- Result panel shows "hello world this is a test" -- Text is in clipboard - -**Fail**: Any step breaks or wrong output - -### Test 2: Auto-Paste -1. Open Notes app -2. Click in text area -3. Press hotkey -4. Speak: "This should paste automatically" -5. Release hotkey - -**Pass**: Text appears in Notes -**Fail**: Nothing happens or clipboard only - ---- - -## ✨ Phase 8: Polish - -### Test 1: Dark Mode -1. Run app -2. System Preferences β†’ Appearance β†’ Dark -3. Check all UI - -**Pass**: UI looks good in dark mode -**Fail**: White text on white, wrong colors - -### Test 2: Animations -Watch overlay appear/disappear - -**Pass**: Smooth fade in/out -**Fail**: Instant pop or glitchy - ---- - -## πŸ§ͺ Phase 9: Testing - -### Test 1: Short Recording -Speak for 2 seconds β†’ Should transcribe in <3 seconds - -**Pass**: Fast and accurate -**Fail**: Slow or wrong text - -### Test 2: Long Recording -Speak for 30 seconds β†’ Should transcribe in <10 seconds - -**Pass**: Completes without crash -**Fail**: Crash or timeout - -### Test 3: No Speech -Press hotkey, don't speak, release - -**Pass**: Shows "No audio detected" or empty result -**Fail**: Crash - -### Test 4: Multiple Apps -Test pasting in: Notes, TextEdit, Safari, Messages - -**Pass**: Works in all apps -**Fail**: Works in some but not others - ---- - -## πŸ“¦ Phase 10: Release - -### Test 1: Archive Build -```bash -xcodebuild archive -scheme speaktype -archivePath ./build/SpeakType.xcarchive -``` -**Pass**: Archive succeeds -**Fail**: Build errors - -### Test 2: Fresh Install Test -1. Delete app from Applications -2. Clear cache: `rm -rf ~/Library/Caches/com.yourcompany.speaktype` -3. Install and run -4. Complete onboarding - -**Pass**: Works on fresh install -**Fail**: Crashes on first run - ---- - -## 🚨 Common Issues & Fixes - -### "Microphone permission denied" -Fix: System Preferences β†’ Privacy β†’ Microphone β†’ Check speaktype - -### "Accessibility permission denied" -Fix: System Preferences β†’ Privacy β†’ Accessibility β†’ Check speaktype - -### "Whisper model not found" -Fix: Check `~/Library/Caches/whisperkit/models/` or re-download - -### "Hotkey doesn't work" -Fix: Check no conflicts in System Preferences β†’ Keyboard β†’ Shortcuts - -### "Build fails with SwiftUI errors" -Fix: Clean build folder (βŒ˜β‡§K) and rebuild - ---- - -## ⏱️ Expected Times - -| Phase | Implementation | Testing | -|-------|---------------|---------| -| 1 | 2 hours | 10 min | -| 2 | 3 hours | 15 min | -| 3 | 4 hours | 20 min | -| 4 | 2 hours | 15 min | -| 5 | 1 hour | 10 min | -| 6 | 2 hours | 15 min | -| 7 | 3 hours | 30 min | -| 8 | 2 hours | 15 min | -| 9 | 2 hours | 1 hour | -| 10 | 2 hours | 30 min | -| **Total** | **23 hours** | **3 hours** | - ---- - -## πŸ“‹ Intern Checklist - -``` -[ ] Phase 1 - All tests pass -[ ] Phase 2 - Can record audio + Whisper loads -[ ] Phase 3 - All UI appears correctly -[ ] Phase 4 - State changes work -[ ] Phase 5 - Settings save/load -[ ] Phase 6 - App launches without crash -[ ] Phase 7 - Full flow works (MOST IMPORTANT) -[ ] Phase 8 - Looks good in light/dark -[ ] Phase 9 - All edge cases handled -[ ] Phase 10 - Can build for release -``` - ---- - -## 🎯 Definition of Done (Each Phase) - -βœ… All tests pass -βœ… No compiler warnings -βœ… No crashes in normal use -βœ… Code committed to git -βœ… Brief note of what works/doesn't work - ---- - -## πŸ†˜ When Stuck - -1. Check Issue Navigator (⌘5) for errors -2. Clean build (βŒ˜β‡§K) -3. Check IMPLEMENTATION_PLAN.md for code examples -4. Google the error message -5. Check [architecture docs](docs/ARCHITECTURE.md) - -**Phase 7 is the critical milestone** - if the full recording flow works, you're 80% done! - diff --git a/build_log.txt b/build_log.txt deleted file mode 100644 index 095dbe0..0000000 --- a/build_log.txt +++ /dev/null @@ -1,21 +0,0 @@ -2026-01-08 00:49:15.446 xcodebuild[53214:2330497] [MT] IDERunDestination: Supported platforms for the buildables in the current scheme is empty. ---- xcodebuild: WARNING: Using the first of multiple matching destinations: -{ platform:macOS, arch:arm64, id:00008122-000104641ADA001C, name:My Mac } -{ platform:macOS, arch:x86_64, id:00008122-000104641ADA001C, name:My Mac } -/Users/orm/speaktype/speaktype/Views/HistoryView.swift:9:13: error: referencing initializer 'init(_:selection:rowContent:)' on 'List' requires that 'HistoryItem' conform to 'Hashable' - List(historyService.items, selection: $selectedItem) { item in - ^ -SwiftUI.List:2:11: note: where 'SelectionValue' = 'HistoryItem' -extension List { - ^ -/Users/orm/speaktype/speaktype/Views/HistoryView.swift:10:17: error: initializer 'init(value:label:)' requires that 'HistoryItem' conform to 'Hashable' - NavigationLink(value: item) { - ^ -SwiftUI.NavigationLink.init:2:20: note: where 'P' = 'HistoryItem' -nonisolated public init

(value: P?, @ViewBuilder label: () -> Label) where P : Decodable, P : Encodable, P : Hashable} - ^ -Failed frontend command: -/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swift-frontend -frontend -c /Users/orm/speaktype/speaktype/App/speaktypeApp.swift /Users/orm/speaktype/speaktype/Models/AppSettings.swift /Users/orm/speaktype/speaktype/Models/AudioDevice.swift /Users/orm/speaktype/speaktype/Models/HotkeyConfiguration.swift /Users/orm/speaktype/speaktype/Models/RecordingSession.swift /Users/orm/speaktype/speaktype/Models/TranscriptionResult.swift /Users/orm/speaktype/speaktype/Models/TranscriptionState.swift /Users/orm/speaktype/speaktype/Services/AudioRecordingService.swift /Users/orm/speaktype/speaktype/Services/ClipboardService.swift /Users/orm/speaktype/speaktype/Services/HistoryService.swift /Users/orm/speaktype/speaktype/Services/HotkeyService.swift /Users/orm/speaktype/speaktype/Services/WhisperService.swift /Users/orm/speaktype/speaktype/Utilities/Extensions/Color+Extensions.swift /Users/orm/speaktype/speaktype/Utilities/Extensions/View+Extensions.swift /Users/orm/speaktype/speaktype/Utilities/AppColors.swift /Users/orm/speaktype/speaktype/Utilities/Constants.swift /Users/orm/speaktype/speaktype/Views/AIModelsView.swift /Users/orm/speaktype/speaktype/Views/AudioInputView.swift /Users/orm/speaktype/speaktype/Views/ContentView.swift /Users/orm/speaktype/speaktype/Views/DashboardView.swift -primary-file /Users/orm/speaktype/speaktype/Views/HistoryView.swift /Users/orm/speaktype/speaktype/Views/MainView.swift /Users/orm/speaktype/speaktype/Views/MiniRecorderView.swift /Users/orm/speaktype/speaktype/Views/OnboardingView.swift /Users/orm/speaktype/speaktype/Views/PermissionsView.swift /Users/orm/speaktype/speaktype/Views/SettingsView.swift /Users/orm/speaktype/speaktype/Views/ShortcutRecorderView.swift /Users/orm/speaktype/speaktype/Views/SidebarView.swift /Users/orm/speaktype/speaktype/Views/TranscribeAudioView.swift /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/DerivedSources/GeneratedAssetSymbols.swift -emit-dependencies-path /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/Objects-normal/arm64/HistoryView.d -emit-const-values-path /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/Objects-normal/arm64/HistoryView.swiftconstvalues -emit-reference-dependencies-path /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/Objects-normal/arm64/HistoryView.swiftdeps -serialize-diagnostics-path /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/Objects-normal/arm64/HistoryView.dia -emit-localized-strings -emit-localized-strings-path /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/Objects-normal/arm64 -target arm64-apple-macos26.1 -module-can-import-version AppKit 2685.20.119 2685.20.119 -module-can-import-version DeveloperToolsSupport 23.0.4 23.0.4 -module-can-import-version SwiftUI 7.1.13.1 7.1.13 -load-resolved-plugin /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib/swift/host/plugins/libFoundationMacros.dylib\#/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/bin/swift-plugin-server\#FoundationMacros -load-resolved-plugin /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib/swift/host/plugins/libObservationMacros.dylib\#/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/bin/swift-plugin-server\#ObservationMacros -load-resolved-plugin /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib/swift/host/plugins/libPreviewsMacros.dylib\#/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/bin/swift-plugin-server\#PreviewsMacros -load-resolved-plugin /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib/swift/host/plugins/libSwiftMacros.dylib\#/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/bin/swift-plugin-server\#SwiftMacros -load-resolved-plugin /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib/swift/host/plugins/libSwiftUIMacros.dylib\#/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/bin/swift-plugin-server\#SwiftUIMacros -disable-implicit-swift-modules -Xcc -fno-implicit-modules -Xcc -fno-implicit-module-maps -explicit-swift-module-map-file /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/Objects-normal/arm64/speaktype-dependencies-1.json -Xllvm -aarch64-use-tbi -enable-objc-interop -stack-check -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX26.1.sdk -I /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Products/Debug -F /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Products/Debug/PackageFrameworks -F /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Products/Debug/PackageFrameworks -F /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Products/Debug/PackageFrameworks -F /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Products/Debug/PackageFrameworks -F /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Products/Debug/PackageFrameworks -F /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Products/Debug/PackageFrameworks -F /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Products/Debug -no-color-diagnostics -Xcc -fno-color-diagnostics -enable-testing -g -debug-info-format\=dwarf -dwarf-version\=5 -module-cache-path /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -profile-generate -profile-coverage-mapping -swift-version 5 -enforce-exclusivity\=checked -Onone -D DEBUG -serialize-debugging-options -const-gather-protocols-file /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/Objects-normal/arm64/speaktype_const_extract_protocols.json -enable-upcoming-feature DisableOutwardActorInference -enable-upcoming-feature InferSendableFromCaptures -enable-upcoming-feature GlobalActorIsolatedTypesUsability -enable-upcoming-feature MemberImportVisibility -enable-upcoming-feature InferIsolatedConformances -enable-upcoming-feature NonisolatedNonsendingByDefault -enable-experimental-feature DebugDescriptionMacro -enable-bare-slash-regex -default-isolation\=MainActor -empty-abi-descriptor -validate-clang-modules-once -clang-build-session-file /Users/orm/Library/Developer/Xcode/DerivedData/ModuleCache.noindex/Session.modulevalidation -Xcc -working-directory -Xcc /Users/orm/speaktype -enable-anonymous-context-mangled-names -file-compilation-dir /Users/orm/speaktype -Xcc -D_LIBCPP_HARDENING_MODE\=_LIBCPP_HARDENING_MODE_DEBUG -Xcc -ivfsstatcache -Xcc /Users/orm/Library/Developer/Xcode/DerivedData/SDKStatCaches.noindex/macosx26.1-25B74-3ae1261907bdadc328169391a47051a6.sdkstatcache -Xcc -I/Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/swift-overrides.hmap -Xcc -iquote -Xcc /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/speaktype-generated-files.hmap -Xcc -I/Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/speaktype-own-target-headers.hmap -Xcc -I/Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/speaktype-all-non-framework-target-headers.hmap -Xcc -ivfsoverlay -Xcc /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype-a09c45879bccb08166620f197fcee798-VFS/all-product-headers.yaml -Xcc -iquote -Xcc /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/speaktype-project-headers.hmap -Xcc -I/Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Products/Debug/include -Xcc -I/Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/DerivedSources-normal/arm64 -Xcc -I/Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/DerivedSources/arm64 -Xcc -I/Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/DerivedSources -Xcc -DDEBUG\=1 -no-auto-bridging-header-chaining -module-name speaktype -frontend-parseable-output -disable-clang-spi -target-sdk-version 26.1 -target-sdk-name macosx26.1 -clang-target arm64-apple-macos26.1 -in-process-plugin-server-path /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/host/libSwiftInProcPluginServer.dylib -o /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/Objects-normal/arm64/HistoryView.o -index-unit-output-path /speaktype.build/Debug/speaktype.build/Objects-normal/arm64/HistoryView.o -index-store-path /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Index.noindex/DataStore -index-system-modules -/Users/orm/speaktype/speaktype.xcodeproj: warning: The Copy Bundle Resources build phase contains this target's Info.plist file '/Users/orm/speaktype/speaktype/Resources/Info.plist'. (in target 'speaktype' from project 'speaktype') -** BUILD FAILED ** - diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md deleted file mode 100644 index 8030cf7..0000000 --- a/docs/ARCHITECTURE.md +++ /dev/null @@ -1,297 +0,0 @@ -# Architecture - -## Overview - -SpeakType follows the MVVM (Model-View-ViewModel) architecture pattern, which provides clear separation of concerns and makes the codebase maintainable and testable. - -## Architecture Layers - -### 1. Models (`Models/`) - -Models represent the data structures and business entities in the application. - -**Responsibilities:** -- Define data structures -- Implement Codable for serialization -- Business logic related to data validation -- Computed properties for derived data - -**Example:** -```swift -struct User: Codable, Identifiable { - let id: UUID - var name: String - var email: String - - var displayName: String { - name.isEmpty ? email : name - } -} -``` - -### 2. Views (`Views/`) - -Views are SwiftUI components that display data and handle user interactions. - -**Responsibilities:** -- Display UI elements -- Handle user input -- Observe ViewModel state -- Navigate between screens - -**Structure:** -- `Views/Screens/` - Full screen views -- `Views/Components/` - Reusable UI components - -**Example:** -```swift -struct UserProfileView: View { - @StateObject private var viewModel = UserProfileViewModel() - - var body: some View { - VStack { - Text(viewModel.user.displayName) - Button("Update") { - viewModel.updateProfile() - } - } - } -} -``` - -### 3. ViewModels (`ViewModels/`) - -ViewModels manage view state and handle business logic. - -**Responsibilities:** -- Manage view state -- Handle user actions -- Coordinate with Services -- Transform data for display -- Handle validation - -**Guidelines:** -- Conform to `ObservableObject` -- Use `@Published` for state that views observe -- Keep UI-independent (no SwiftUI imports except for simple types) -- Use dependency injection for services - -**Example:** -```swift -class UserProfileViewModel: ObservableObject { - @Published var user: User - @Published var isLoading = false - @Published var errorMessage: String? - - private let userService: UserServiceProtocol - - init(userService: UserServiceProtocol = UserService()) { - self.userService = userService - } - - func updateProfile() { - isLoading = true - Task { - do { - user = try await userService.updateUser(user) - isLoading = false - } catch { - errorMessage = error.localizedDescription - isLoading = false - } - } - } -} -``` - -### 4. Services (`Services/`) - -Services handle business logic, data persistence, and external communication. - -**Responsibilities:** -- Network requests -- Data persistence -- Business logic -- External API integration - -**Structure:** -- `Services/Network/` - Networking layer -- `Services/Storage/` - Data persistence (UserDefaults, CoreData, etc.) - -**Guidelines:** -- Define protocols for testability -- Use async/await for asynchronous operations -- Handle errors appropriately -- Keep services focused on a single responsibility - -**Example:** -```swift -protocol UserServiceProtocol { - func fetchUser(id: UUID) async throws -> User - func updateUser(_ user: User) async throws -> User -} - -class UserService: UserServiceProtocol { - private let networkManager: NetworkManager - - init(networkManager: NetworkManager = .shared) { - self.networkManager = networkManager - } - - func fetchUser(id: UUID) async throws -> User { - try await networkManager.request(endpoint: .user(id)) - } - - func updateUser(_ user: User) async throws -> User { - try await networkManager.request(endpoint: .updateUser(user)) - } -} -``` - -### 5. Utilities (`Utilities/`) - -Helper functions, extensions, and constants. - -**Structure:** -- `Utilities/Extensions/` - Swift and SwiftUI extensions -- `Utilities/Helpers/` - Helper functions and utilities -- `Utilities/Constants.swift` - App-wide constants - -## Data Flow - -1. **User Interaction** β†’ View receives user input -2. **View β†’ ViewModel** β†’ View calls ViewModel method -3. **ViewModel β†’ Service** β†’ ViewModel requests data from Service -4. **Service β†’ External** β†’ Service fetches/saves data -5. **Service β†’ ViewModel** β†’ Service returns data to ViewModel -6. **ViewModel β†’ View** β†’ ViewModel updates @Published properties -7. **View Update** β†’ SwiftUI automatically re-renders view - -## Dependency Injection - -Use protocol-based dependency injection for better testability: - -```swift -// Define protocol -protocol DataServiceProtocol { - func fetchData() async throws -> [Item] -} - -// Implement service -class DataService: DataServiceProtocol { - func fetchData() async throws -> [Item] { - // Implementation - } -} - -// Inject in ViewModel -class MyViewModel: ObservableObject { - private let dataService: DataServiceProtocol - - init(dataService: DataServiceProtocol = DataService()) { - self.dataService = dataService - } -} - -// Easy to mock for testing -class MockDataService: DataServiceProtocol { - func fetchData() async throws -> [Item] { - return [Item(id: 1, name: "Test")] - } -} -``` - -## State Management - -### Local State -Use `@State` for view-local state: -```swift -@State private var isExpanded = false -``` - -### Shared State -Use `@StateObject` for view-owned objects: -```swift -@StateObject private var viewModel = MyViewModel() -``` - -Use `@ObservedObject` for passed-in objects: -```swift -@ObservedObject var viewModel: MyViewModel -``` - -### App-Wide State -Use `@EnvironmentObject` for app-wide shared state: -```swift -@EnvironmentObject var appState: AppState -``` - -## Error Handling - -1. **Service Layer**: Throw specific errors -2. **ViewModel Layer**: Catch and transform errors for display -3. **View Layer**: Display errors to user - -```swift -// Custom errors -enum NetworkError: LocalizedError { - case invalidURL - case noData - case decodingError - - var errorDescription: String? { - switch self { - case .invalidURL: return "Invalid URL" - case .noData: return "No data received" - case .decodingError: return "Failed to decode data" - } - } -} - -// ViewModel handles errors -@Published var errorMessage: String? - -func loadData() { - Task { - do { - data = try await service.fetchData() - } catch { - errorMessage = error.localizedDescription - } - } -} -``` - -## Testing Strategy - -### Unit Tests -- Test ViewModels with mocked services -- Test Models for validation logic -- Test Services with mocked network layer - -### UI Tests -- Test critical user flows -- Test navigation -- Test error states - -### Integration Tests -- Test ViewModel + Service integration -- Test data persistence - -## Best Practices - -1. **Single Responsibility**: Each component should have one clear purpose -2. **Dependency Injection**: Use protocols for testability -3. **Async/Await**: Use modern concurrency for asynchronous operations -4. **Error Handling**: Handle errors at appropriate layers -5. **Immutability**: Prefer `let` over `var` when possible -6. **Type Safety**: Use Swift's type system to prevent errors -7. **Documentation**: Document complex logic and public APIs -8. **Testing**: Write tests for business logic and critical flows - -## Resources - -- [Swift API Design Guidelines](https://swift.org/documentation/api-design-guidelines/) -- [SwiftUI Documentation](https://developer.apple.com/documentation/swiftui/) -- [Swift Concurrency](https://docs.swift.org/swift-book/LanguageGuide/Concurrency.html) - diff --git a/docs/FOLDER_STRUCTURE.md b/docs/FOLDER_STRUCTURE.md deleted file mode 100644 index 95f3150..0000000 --- a/docs/FOLDER_STRUCTURE.md +++ /dev/null @@ -1,357 +0,0 @@ -# Folder Structure Guidelines - -This document outlines the folder structure and organization guidelines for the SpeakType macOS application. - -## Project Root Structure - -``` -speaktype/ -β”œβ”€β”€ .git/ # Git repository -β”œβ”€β”€ .gitignore # Git ignore rules -β”œβ”€β”€ .swiftlint.yml # SwiftLint configuration -β”œβ”€β”€ README.md # Project overview -β”œβ”€β”€ CONTRIBUTING.md # Contribution guidelines -β”œβ”€β”€ speaktype.xcodeproj/ # Xcode project file -β”œβ”€β”€ speaktype/ # Main application source -β”œβ”€β”€ speaktypeTests/ # Unit tests -β”œβ”€β”€ speaktypeUITests/ # UI tests -└── docs/ # Documentation -``` - -## Main Application Structure (`speaktype/`) - -``` -speaktype/ -β”œβ”€β”€ App/ -β”‚ β”œβ”€β”€ speaktypeApp.swift # App entry point -β”‚ └── AppDelegate.swift # App delegate (if needed for AppKit) -β”‚ -β”œβ”€β”€ Views/ -β”‚ β”œβ”€β”€ ContentView.swift # Main content view -β”‚ β”œβ”€β”€ Screens/ # Full-screen views -β”‚ β”‚ β”œβ”€β”€ HomeScreen.swift -β”‚ β”‚ β”œβ”€β”€ SettingsScreen.swift -β”‚ β”‚ └── ... -β”‚ └── Components/ # Reusable UI components -β”‚ β”œβ”€β”€ Buttons/ -β”‚ β”‚ β”œβ”€β”€ PrimaryButton.swift -β”‚ β”‚ └── SecondaryButton.swift -β”‚ β”œβ”€β”€ Cards/ -β”‚ β”‚ └── InfoCard.swift -β”‚ └── ... -β”‚ -β”œβ”€β”€ ViewModels/ -β”‚ β”œβ”€β”€ HomeViewModel.swift -β”‚ β”œβ”€β”€ SettingsViewModel.swift -β”‚ └── ... -β”‚ -β”œβ”€β”€ Models/ -β”‚ β”œβ”€β”€ User.swift -β”‚ β”œβ”€β”€ Settings.swift -β”‚ └── ... -β”‚ -β”œβ”€β”€ Services/ -β”‚ β”œβ”€β”€ Network/ -β”‚ β”‚ β”œβ”€β”€ NetworkManager.swift -β”‚ β”‚ β”œβ”€β”€ APIEndpoint.swift -β”‚ β”‚ └── NetworkError.swift -β”‚ β”œβ”€β”€ Storage/ -β”‚ β”‚ β”œβ”€β”€ UserDefaultsManager.swift -β”‚ β”‚ β”œβ”€β”€ KeychainManager.swift -β”‚ β”‚ └── FileManager+Extensions.swift -β”‚ └── ... -β”‚ -β”œβ”€β”€ Utilities/ -β”‚ β”œβ”€β”€ Constants.swift -β”‚ β”œβ”€β”€ Extensions/ -β”‚ β”‚ β”œβ”€β”€ View+Extensions.swift -β”‚ β”‚ β”œβ”€β”€ Color+Extensions.swift -β”‚ β”‚ β”œβ”€β”€ String+Extensions.swift -β”‚ β”‚ └── ... -β”‚ └── Helpers/ -β”‚ β”œβ”€β”€ DateFormatter+Helpers.swift -β”‚ β”œβ”€β”€ ValidationHelpers.swift -β”‚ └── ... -β”‚ -└── Resources/ - β”œβ”€β”€ Assets.xcassets/ # Images, colors, icons - β”‚ β”œβ”€β”€ AppIcon.appiconset/ - β”‚ β”œβ”€β”€ AccentColor.colorset/ - β”‚ β”œβ”€β”€ Images/ - β”‚ └── Colors/ - β”œβ”€β”€ Localizable.strings # Localization strings - β”œβ”€β”€ Info.plist # App configuration - └── speaktype.entitlements # App entitlements -``` - -## Testing Structure - -### Unit Tests (`speaktypeTests/`) - -``` -speaktypeTests/ -β”œβ”€β”€ ViewModelTests/ -β”‚ β”œβ”€β”€ HomeViewModelTests.swift -β”‚ └── SettingsViewModelTests.swift -β”œβ”€β”€ ModelTests/ -β”‚ β”œβ”€β”€ UserTests.swift -β”‚ └── SettingsTests.swift -β”œβ”€β”€ ServiceTests/ -β”‚ β”œβ”€β”€ NetworkManagerTests.swift -β”‚ └── StorageManagerTests.swift -β”œβ”€β”€ Mocks/ -β”‚ β”œβ”€β”€ MockNetworkManager.swift -β”‚ └── MockStorageManager.swift -└── Helpers/ - └── TestHelpers.swift -``` - -### UI Tests (`speaktypeUITests/`) - -``` -speaktypeUITests/ -β”œβ”€β”€ Screens/ -β”‚ β”œβ”€β”€ HomeScreenUITests.swift -β”‚ └── SettingsScreenUITests.swift -β”œβ”€β”€ Flows/ -β”‚ β”œβ”€β”€ OnboardingFlowTests.swift -β”‚ └── UserFlowTests.swift -└── Helpers/ - └── UITestHelpers.swift -``` - -## Documentation Structure (`docs/`) - -``` -docs/ -β”œβ”€β”€ ARCHITECTURE.md # Architecture overview -β”œβ”€β”€ FOLDER_STRUCTURE.md # This file -β”œβ”€β”€ API.md # API documentation -β”œβ”€β”€ SETUP.md # Setup instructions -└── images/ # Documentation images - └── architecture-diagram.png -``` - -## File Naming Conventions - -### Swift Files - -1. **Views**: Use descriptive names ending with `View` - - `HomeView.swift` - - `SettingsView.swift` - - `UserProfileView.swift` - -2. **ViewModels**: Match view name + `ViewModel` - - `HomeViewModel.swift` - - `SettingsViewModel.swift` - - `UserProfileViewModel.swift` - -3. **Models**: Use singular nouns - - `User.swift` - - `Settings.swift` - - `Article.swift` - -4. **Services**: Descriptive name + `Manager` or `Service` - - `NetworkManager.swift` - - `AuthenticationService.swift` - - `StorageManager.swift` - -5. **Extensions**: Type name + `+Extensions` - - `View+Extensions.swift` - - `Color+Extensions.swift` - - `String+Extensions.swift` - -6. **Protocols**: Descriptive name + `Protocol` or `-able` suffix - - `NetworkServiceProtocol.swift` - - `Cacheable.swift` - - `Validatable.swift` - -### Test Files - -Test files should mirror the structure of the main app with `Tests` suffix: -- `HomeViewModel.swift` β†’ `HomeViewModelTests.swift` -- `User.swift` β†’ `UserTests.swift` - -## Organization Guidelines - -### 1. Group Related Files - -Use Xcode groups (folders) to organize related files: -- Group views by feature or screen -- Group models by domain -- Group services by functionality - -### 2. Keep Files Focused - -- One primary type per file -- Related extensions can be in the same file -- Keep files under 400 lines when possible - -### 3. Use MARK Comments - -Organize code within files using `// MARK:` comments: - -```swift -// MARK: - Properties -private let service: DataService -@Published var items: [Item] = [] - -// MARK: - Initialization -init(service: DataService = DataService()) { - self.service = service -} - -// MARK: - Public Methods -func loadData() { - // ... -} - -// MARK: - Private Methods -private func processData() { - // ... -} -``` - -### 4. Alphabetical Ordering - -Within groups, order files alphabetically for easy navigation. - -### 5. Shared Components - -Place reusable components in appropriate shared folders: -- `Views/Components/` for UI components -- `Utilities/Extensions/` for extensions -- `Utilities/Helpers/` for helper functions - -## Asset Organization - -### Assets.xcassets Structure - -``` -Assets.xcassets/ -β”œβ”€β”€ AppIcon.appiconset/ -β”œβ”€β”€ AccentColor.colorset/ -β”œβ”€β”€ Colors/ -β”‚ β”œβ”€β”€ PrimaryColor.colorset/ -β”‚ β”œβ”€β”€ SecondaryColor.colorset/ -β”‚ └── BackgroundColor.colorset/ -β”œβ”€β”€ Images/ -β”‚ β”œβ”€β”€ Icons/ -β”‚ β”‚ β”œβ”€β”€ HomeIcon.imageset/ -β”‚ β”‚ └── SettingsIcon.imageset/ -β”‚ └── Illustrations/ -β”‚ └── EmptyState.imageset/ -└── Symbols/ - └── CustomSymbol.symbolset/ -``` - -### Asset Naming - -- Use descriptive, lowercase names with hyphens -- Group related assets with prefixes -- Examples: - - `icon-home` - - `button-primary-background` - - `illustration-empty-state` - -## Configuration Files - -### Root Level Configuration - -- `.gitignore` - Git ignore patterns -- `.swiftlint.yml` - SwiftLint rules -- `README.md` - Project overview -- `CONTRIBUTING.md` - Contribution guidelines - -### Xcode Configuration - -- `Info.plist` - App metadata and configuration -- `*.entitlements` - App capabilities and permissions -- `*.xcconfig` - Build configuration (optional) - -## Best Practices - -### 1. Consistent Structure - -Maintain consistent folder structure across features: -``` -Feature/ -β”œβ”€β”€ FeatureView.swift -β”œβ”€β”€ FeatureViewModel.swift -β”œβ”€β”€ FeatureModel.swift -└── Components/ - └── FeatureComponent.swift -``` - -### 2. Avoid Deep Nesting - -Keep folder hierarchy shallow (max 3-4 levels deep). - -### 3. Clear Boundaries - -Maintain clear separation between: -- UI (Views) -- Logic (ViewModels) -- Data (Models) -- Services - -### 4. Scalability - -Structure should scale as the app grows: -- Easy to add new features -- Easy to find existing code -- Easy to refactor - -### 5. Team Collaboration - -- Clear structure helps team members find code -- Consistent naming reduces confusion -- Documented conventions prevent debates - -## Adding New Features - -When adding a new feature, follow this checklist: - -1. **Create folder structure**: - ``` - Views/Screens/NewFeature/ - ViewModels/ - Models/ - ``` - -2. **Add necessary files**: - - View file(s) - - ViewModel - - Model (if needed) - - Service (if needed) - -3. **Add tests**: - - Unit tests for ViewModel - - UI tests for critical flows - -4. **Update documentation**: - - Update README if needed - - Add API documentation - - Update architecture docs - -## Migration Guide - -If you're reorganizing existing code: - -1. **Plan the structure** first -2. **Move files** in Xcode (not Finder) to maintain references -3. **Update imports** if needed -4. **Run tests** to ensure nothing broke -5. **Update documentation** -6. **Commit changes** with clear message - -## Questions? - -If you're unsure where to place a file: -1. Check existing similar files -2. Refer to this document -3. Ask the team -4. Document your decision - -Remember: Consistency is more important than perfection! - diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md deleted file mode 100644 index 9d1c8ff..0000000 --- a/docs/IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,752 +0,0 @@ -# SpeakType Implementation Plan - -## Project Overview -A macOS system-wide voice-to-text utility that enables fast, offline voice dictation across all applications. Inspired by VoiceInk, with a goal to ship an MVP within 3-4 days. - -## Timeline: 3-4 Days MVP - ---- - -## Phase 1: Foundation & Setup (Day 1 - Morning) -**Goal**: Set up core project structure and dependencies - -### 1.1 Project Configuration & Dependencies -- [ ] Update `Info.plist` with required permissions - - Microphone usage description - - Accessibility API usage description -- [ ] Add entitlements - - `com.apple.security.device.microphone` - - `com.apple.security.automation.apple-events` - - App Sandbox with necessary permissions -- [ ] Research and integrate Whisper model - - Evaluate `whisper.cpp` Swift bindings - - Alternative: `WhisperKit` framework - - Download base/small model files -- [ ] Update Xcode project settings - - Set minimum macOS version to 13.0+ - - Configure build settings for menu bar app - - Add LSUIElement = YES for menu bar only app - -### 1.2 Core Models -**Files to create in `Models/`:** -- [ ] `TranscriptionState.swift` - Enum for app states (idle, listening, transcribing, ready) -- [ ] `TranscriptionResult.swift` - Model for transcription data -- [ ] `AppSettings.swift` - User preferences model -- [ ] `RecordingSession.swift` - Audio recording session data -- [ ] `HotkeyConfiguration.swift` - Keyboard shortcut configuration - ---- - -## Phase 2: Core Services (Day 1 - Afternoon) -**Goal**: Implement essential services for audio, transcription, and system integration - -### 2.1 Audio Service -**Create `Services/Audio/`:** -- [ ] `AudioRecordingService.swift` - - Initialize AVAudioEngine - - Configure audio session - - Start/stop recording - - Buffer audio data - - Handle microphone permissions -- [ ] `AudioBufferManager.swift` - - Manage audio buffer - - Convert audio format for Whisper - - Handle memory efficiently -- [ ] `AudioDeviceManager.swift` - - Enumerate available audio input devices - - Select/switch between devices - - Monitor device changes (connect/disconnect) - - Get device metadata (name, sample rate, channels) -- [ ] `MicrophonePermissionManager.swift` - - Request microphone access - - Handle permission states - - Provide user-friendly error messages - -### 2.2 Transcription Service -**Create `Services/Transcription/`:** -- [ ] `WhisperService.swift` - - Initialize Whisper model - - Load model on app launch - - Transcribe audio buffer - - Handle model lifecycle - - Support base/small model selection -- [ ] `TranscriptionQueue.swift` - - Queue transcription requests - - Handle concurrent requests - - Cancel pending requests -- [ ] `TranscriptionCache.swift` (optional) - - Cache recent transcriptions - - Improve performance - -### 2.3 System Integration Services -**Create `Services/System/`:** -- [ ] `HotkeyService.swift` - - Register global keyboard shortcut - - Handle press and release events - - Support customizable hotkeys - - Use Carbon or modern Swift alternatives -- [ ] `ClipboardService.swift` - - Copy text to clipboard - - Read current clipboard (if needed) - - Handle clipboard history -- [ ] `AccessibilityService.swift` - - Request accessibility permissions - - Implement auto-paste functionality - - Get active application - - Paste text programmatically -- [ ] `PermissionCoordinator.swift` - - Centralize permission management - - Check all required permissions - - Guide user through permission setup - ---- - -## Phase 3: UI Components (Day 2 - Morning) -**Goal**: Build all UI components following macOS design guidelines - -### 3.1 Menu Bar Integration -**Create `Views/MenuBar/`:** -- [ ] `MenuBarView.swift` - - Menu bar icon with SF Symbol - - Status indicator overlay - - Popover presentation -- [ ] `MenuBarPopoverView.swift` - - App status display - - Quick action buttons - - Settings navigation - - Quit button - -### 3.2 Floating Overlays -**Create `Views/Overlays/`:** -- [ ] `ListeningOverlayView.swift` - - Centered floating panel - - "Listening..." title - - Recording indicator (animated) - - Waveform visualization (optional) - - Instructions text - - Blur/vibrancy background -- [ ] `TranscribingOverlayView.swift` - - "Transcribing..." message - - Loading spinner - - Cancel button (optional) - -### 3.3 Result Display -**Create `Views/Results/`:** -- [ ] `TranscriptionResultView.swift` - - Scrollable text display - - Editable text field - - Action buttons (Copy, Paste, Save, Dismiss) - - Keyboard shortcuts - - Auto-dismiss timer option - -### 3.4 Settings Screen -**Create `Views/Settings/`:** -- [ ] `SettingsView.swift` - - Tab-based navigation (SwiftUI native) - - Organized sections -- [ ] `GeneralSettingsView.swift` - - Hotkey customization - - Launch at login toggle - - Status in menu bar preferences -- [ ] `AudioInputSettingsView.swift` - - Audio device selection - - Input mode (System Default, Custom Device, Prioritized) - - Device list with active indicator - - Refresh devices button -- [ ] `TranscriptionSettingsView.swift` - - Model selection (Base/Small) - - Model details (size, speed, accuracy indicators) - - Download/manage models - - Language (English - fixed for MVP) - - Show confidence scores (optional) -- [ ] `ClipboardSettingsView.swift` - - Auto-paste toggle - - Auto-dismiss result panel - - Clipboard history (optional) -- [ ] `AboutSettingsView.swift` - - App version - - Open-source notice - - GitHub link - - Privacy policy - - Model information - -### 3.5 Permission Screens -**Create `Views/Permissions/`:** -- [ ] `PermissionOnboardingView.swift` - - First-launch wizard - - Explain required permissions - - Guide to system preferences -- [ ] `PermissionRequestView.swift` - - Individual permission cards - - Open System Preferences buttons - - Status indicators - -### 3.6 Reusable Components -**Create `Views/Components/`:** -- [ ] `RecordingIndicator.swift` - - Animated recording dot - - Customizable colors -- [ ] `WaveformView.swift` (optional) - - Real-time waveform visualization - - Level meter -- [ ] `StatusBadge.swift` - - Color-coded status indicators -- [ ] `ActionButton.swift` - - Consistent button styling - - Hover effects -- [ ] `FloatingPanel.swift` - - Reusable floating window wrapper - - Blur/vibrancy materials - ---- - -## Phase 4: ViewModels & State Management (Day 2 - Afternoon) -**Goal**: Connect UI to services with proper state management - -### 4.1 Core ViewModels -**Create `ViewModels/`:** -- [ ] `AppViewModel.swift` - - Central app state coordinator - - Manage app lifecycle - - Coordinate between services - - Handle global hotkey events - - Publish transcription states -- [ ] `RecordingViewModel.swift` - - Control recording session - - Monitor audio levels - - Handle start/stop events - - Publish recording duration -- [ ] `TranscriptionViewModel.swift` - - Manage transcription process - - Handle results - - Error handling - - Retry logic -- [ ] `SettingsViewModel.swift` - - Manage user preferences - - Persist settings - - Handle hotkey changes - - Model selection -- [ ] `PermissionViewModel.swift` - - Check permission status - - Request permissions - - Guide user to settings - - Track permission states - -### 4.2 State Management -**Create `State/`:** -- [ ] `AppState.swift` - - Global app state (Environment Object) - - Current transcription state - - Active recording session - - Settings -- [ ] `NotificationCenter+App.swift` - - Custom notifications for app events - - Hotkey pressed/released - - Transcription complete - - Error notifications - ---- - -## Phase 5: Storage & Persistence (Day 2 - Evening) -**Goal**: Implement data persistence - -### 5.1 Settings Storage -**Create `Services/Storage/`:** -- [ ] `SettingsStorageService.swift` - - UserDefaults wrapper - - Save/load app settings - - Default values - - Migration support -- [ ] `HotkeyStorageService.swift` - - Persist hotkey configuration - - Validate hotkey combinations - -### 5.2 Optional History -**Create if time permits:** -- [ ] `TranscriptionHistoryService.swift` - - Store recent transcriptions - - Limit storage size - - Clear history option -- [ ] `TranscriptionHistory.swift` model - ---- - -## Phase 6: Integration & App Flow (Day 3 - Morning) -**Goal**: Wire everything together into complete user flows - -### 6.1 App Entry Point -**Update `App/`:** -- [ ] `speaktypeApp.swift` - - Initialize as menu bar app - - Set up environment objects - - Load Whisper model on launch - - Register global hotkey - - Show permissions if needed -- [ ] `AppDelegate.swift` - - Handle app lifecycle events - - Terminate behavior - - Dock icon management - -### 6.2 Main Coordinator -**Create `Coordinators/`:** -- [ ] `AppCoordinator.swift` - - Coordinate user flows - - Manage window presentation - - Handle hotkey events - - Show/hide overlays - - Navigate to settings - -### 6.3 Window Management -**Create `Utilities/Windows/`:** -- [ ] `WindowManager.swift` - - Manage floating windows - - Position windows (center, etc.) - - Window levels (floating on top) - - Focus management -- [ ] `PanelWindowController.swift` - - Custom NSPanel for overlays - - Transparency support - - Click-through behavior - ---- - -## Phase 7: Complete User Flows (Day 3 - Afternoon) -**Goal**: Implement end-to-end functionality - -### 7.1 Recording Flow -**Implementation sequence:** -1. [ ] User presses hotkey -2. [ ] HotkeyService detects press -3. [ ] AppViewModel updates state to `.listening` -4. [ ] ListeningOverlayView appears -5. [ ] AudioRecordingService starts recording -6. [ ] Waveform/indicator shows activity -7. [ ] User releases hotkey -8. [ ] AudioRecordingService stops recording -9. [ ] AppViewModel updates state to `.transcribing` -10. [ ] TranscribingOverlayView appears -11. [ ] WhisperService transcribes audio -12. [ ] AppViewModel updates state to `.ready` -13. [ ] TranscriptionResultView appears with text -14. [ ] ClipboardService copies text -15. [ ] (Optional) AccessibilityService pastes text -16. [ ] Auto-dismiss or wait for user action - -### 7.2 Settings Flow -**Implementation:** -- [ ] Click settings in menu bar popover -- [ ] Open settings window -- [ ] Update preferences -- [ ] Save changes -- [ ] Apply changes immediately - -### 7.3 Error Flows -**Handle edge cases:** -- [ ] Microphone permission denied -- [ ] Accessibility permission denied -- [ ] Model loading failure -- [ ] Transcription error -- [ ] No audio detected -- [ ] Empty transcription result - ---- - -## Phase 8: Polish & Refinement (Day 3 - Evening) -**Goal**: Improve UX and fix issues - -### 8.1 Visual Polish -- [ ] Implement blur/vibrancy effects -- [ ] Add smooth animations - - Overlay fade in/out - - Recording indicator pulse - - Button hover effects -- [ ] Support light/dark mode -- [ ] Adjust spacing and sizing -- [ ] Icon refinement - -### 8.2 UX Improvements -- [ ] Keyboard shortcuts for result panel -- [ ] Escape to dismiss overlays -- [ ] Sound feedback (optional) -- [ ] Haptic feedback if supported -- [ ] Loading states -- [ ] Empty states -- [ ] Error messages user-friendly - -### 8.3 Performance Optimization -- [ ] Lazy load Whisper model -- [ ] Optimize audio buffer size -- [ ] Reduce memory footprint -- [ ] Test with long recordings -- [ ] Profile with Instruments - ---- - -## Phase 9: Testing & Bug Fixes (Day 4 - Morning) -**Goal**: Ensure stability and reliability - -### 9.1 Unit Tests -**Create in `speaktypeTests/`:** -- [ ] `AudioRecordingServiceTests.swift` -- [ ] `ClipboardServiceTests.swift` -- [ ] `SettingsStorageServiceTests.swift` -- [ ] `AppViewModelTests.swift` -- [ ] `TranscriptionViewModelTests.swift` - -### 9.2 Integration Tests -- [ ] Test full recording β†’ transcription β†’ paste flow -- [ ] Test permission flows -- [ ] Test settings persistence -- [ ] Test hotkey registration - -### 9.3 Manual Testing -**Test scenarios:** -- [ ] First launch experience -- [ ] Permission requests -- [ ] Record short utterance (3-5 seconds) -- [ ] Record long utterance (30+ seconds) -- [ ] Test in various apps (Notes, Safari, Messages, etc.) -- [ ] Change hotkey -- [ ] Change model -- [ ] Test with no microphone -- [ ] Test with denied permissions -- [ ] Light/dark mode switching -- [ ] System sleep/wake - -### 9.4 Bug Fixes -- [ ] Fix crashes -- [ ] Fix memory leaks -- [ ] Fix UI glitches -- [ ] Fix permission issues -- [ ] Fix transcription errors - ---- - -## Phase 10: Documentation & Release Prep (Day 4 - Afternoon) -**Goal**: Prepare for release - -### 10.1 Documentation -- [ ] Update `README.md` - - Feature list - - Installation instructions - - Usage guide - - Screenshots/GIFs - - Permissions explanation -- [ ] Create `docs/USER_GUIDE.md` - - Getting started - - Keyboard shortcuts - - Settings explanation - - Troubleshooting -- [ ] Create `docs/PRIVACY.md` - - Local-only processing - - No data collection - - Permissions explanation -- [ ] Update `LICENSE` (MIT or Apache 2.0) -- [ ] Create `CHANGELOG.md` - -### 10.2 Code Quality -- [ ] Run SwiftLint and fix warnings -- [ ] Add code documentation -- [ ] Clean up commented code -- [ ] Remove debug logs -- [ ] Code review - -### 10.3 Build & Package -- [ ] Create Release build configuration -- [ ] Archive for distribution -- [ ] Code signing setup -- [ ] Notarization (if distributing outside App Store) -- [ ] Create DMG installer (optional) - -### 10.4 Repository Setup -- [ ] Create GitHub repository -- [ ] Push code -- [ ] Add README with badges -- [ ] Create release notes -- [ ] Tag v1.0.0 - ---- - -## Technical Architecture - -### Key Technologies -- **Language**: Swift 5.9+ -- **UI Framework**: SwiftUI + AppKit (for menu bar) -- **Speech Recognition**: whisper.cpp or WhisperKit -- **Audio**: AVFoundation -- **Hotkeys**: Carbon API or modern alternative -- **Persistence**: UserDefaults - -### Third-Party Dependencies -1. **Whisper Integration** (Choose one): - - `WhisperKit` - Swift package by Argmax - - `whisper.cpp` - C++ implementation with Swift bindings - -2. **Global Hotkeys** (Choose one): - - `KeyboardShortcuts` - Swift package by Sindre Sorhus - - Custom Carbon API wrapper - -3. **Optional**: - - `LaunchAtLogin` - Swift package for launch at login - -### Data Flow -``` -User β†’ Hotkey Press - ↓ -Audio Recording Service - ↓ -Audio Buffer - ↓ -Whisper Service - ↓ -Transcription Result - ↓ -Clipboard Service - ↓ -(Optional) Accessibility Service β†’ Paste -``` - -### State Machine -``` -Idle β†’ Listening β†’ Transcribing β†’ Ready β†’ Idle - ↓ ↓ ↓ - (Error) β†’ (Error) β†’ (Error) β†’ Idle -``` - ---- - -## File Structure Overview - -``` -speaktype/ -β”œβ”€β”€ App/ -β”‚ β”œβ”€β”€ speaktypeApp.swift -β”‚ └── AppDelegate.swift -β”œβ”€β”€ Models/ -β”‚ β”œβ”€β”€ TranscriptionState.swift -β”‚ β”œβ”€β”€ TranscriptionResult.swift -β”‚ β”œβ”€β”€ AppSettings.swift -β”‚ β”œβ”€β”€ RecordingSession.swift -β”‚ β”œβ”€β”€ HotkeyConfiguration.swift -β”‚ └── AudioDevice.swift -β”œβ”€β”€ Views/ -β”‚ β”œβ”€β”€ MenuBar/ -β”‚ β”‚ β”œβ”€β”€ MenuBarView.swift -β”‚ β”‚ └── MenuBarPopoverView.swift -β”‚ β”œβ”€β”€ Overlays/ -β”‚ β”‚ β”œβ”€β”€ ListeningOverlayView.swift -β”‚ β”‚ └── TranscribingOverlayView.swift -β”‚ β”œβ”€β”€ Results/ -β”‚ β”‚ └── TranscriptionResultView.swift -β”‚ β”œβ”€β”€ Settings/ -β”‚ β”‚ β”œβ”€β”€ SettingsView.swift -β”‚ β”‚ β”œβ”€β”€ GeneralSettingsView.swift -β”‚ β”‚ β”œβ”€β”€ TranscriptionSettingsView.swift -β”‚ β”‚ β”œβ”€β”€ ClipboardSettingsView.swift -β”‚ β”‚ └── AboutSettingsView.swift -β”‚ β”œβ”€β”€ Permissions/ -β”‚ β”‚ β”œβ”€β”€ PermissionOnboardingView.swift -β”‚ β”‚ └── PermissionRequestView.swift -β”‚ └── Components/ -β”‚ β”œβ”€β”€ RecordingIndicator.swift -β”‚ β”œβ”€β”€ WaveformView.swift -β”‚ β”œβ”€β”€ StatusBadge.swift -β”‚ β”œβ”€β”€ ActionButton.swift -β”‚ └── FloatingPanel.swift -β”œβ”€β”€ ViewModels/ -β”‚ β”œβ”€β”€ AppViewModel.swift -β”‚ β”œβ”€β”€ RecordingViewModel.swift -β”‚ β”œβ”€β”€ TranscriptionViewModel.swift -β”‚ β”œβ”€β”€ SettingsViewModel.swift -β”‚ └── PermissionViewModel.swift -β”œβ”€β”€ Services/ -β”‚ β”œβ”€β”€ Audio/ -β”‚ β”‚ β”œβ”€β”€ AudioRecordingService.swift -β”‚ β”‚ β”œβ”€β”€ AudioBufferManager.swift -β”‚ β”‚ β”œβ”€β”€ AudioDeviceManager.swift -β”‚ β”‚ └── MicrophonePermissionManager.swift -β”‚ β”œβ”€β”€ Transcription/ -β”‚ β”‚ β”œβ”€β”€ WhisperService.swift -β”‚ β”‚ β”œβ”€β”€ TranscriptionQueue.swift -β”‚ β”‚ └── TranscriptionCache.swift -β”‚ β”œβ”€β”€ System/ -β”‚ β”‚ β”œβ”€β”€ HotkeyService.swift -β”‚ β”‚ β”œβ”€β”€ ClipboardService.swift -β”‚ β”‚ β”œβ”€β”€ AccessibilityService.swift -β”‚ β”‚ └── PermissionCoordinator.swift -β”‚ └── Storage/ -β”‚ β”œβ”€β”€ SettingsStorageService.swift -β”‚ β”œβ”€β”€ HotkeyStorageService.swift -β”‚ └── TranscriptionHistoryService.swift -β”œβ”€β”€ Coordinators/ -β”‚ └── AppCoordinator.swift -β”œβ”€β”€ State/ -β”‚ β”œβ”€β”€ AppState.swift -β”‚ └── NotificationCenter+App.swift -β”œβ”€β”€ Utilities/ -β”‚ β”œβ”€β”€ Windows/ -β”‚ β”‚ β”œβ”€β”€ WindowManager.swift -β”‚ β”‚ └── PanelWindowController.swift -β”‚ β”œβ”€β”€ Extensions/ -β”‚ β”‚ β”œβ”€β”€ View+Extensions.swift -β”‚ β”‚ β”œβ”€β”€ Color+Extensions.swift -β”‚ β”‚ └── NSApplication+Extensions.swift -β”‚ └── Constants.swift -└── Resources/ - β”œβ”€β”€ Assets.xcassets/ - β”œβ”€β”€ Info.plist - └── speaktype.entitlements -``` - ---- - -## Risk Mitigation - -### High-Risk Items -1. **Whisper Integration** (HIGH PRIORITY - Day 1) - - Risk: Complex integration, large model files - - Mitigation: Research on Day 1 morning, have fallback option - - Fallback: Use system Speech framework temporarily - -2. **Global Hotkeys** (HIGH PRIORITY - Day 1) - - Risk: macOS permissions, deprecated APIs - - Mitigation: Use proven Swift package (KeyboardShortcuts) - - Fallback: Manual activation from menu bar - -3. **Auto-paste Functionality** (MEDIUM PRIORITY) - - Risk: Accessibility permissions complex - - Mitigation: Make it optional, default to clipboard only - - Fallback: Copy to clipboard only - -4. **Performance** (MEDIUM PRIORITY) - - Risk: Model loading slow, transcription lag - - Mitigation: Load model on app launch, optimize audio format - - Test: Profile early and often - -### Contingency Plans -- If Whisper integration takes too long β†’ Use macOS Speech framework -- If auto-paste is problematic β†’ Focus on clipboard functionality -- If custom UI is complex β†’ Simplify to basic SwiftUI components -- If 3-4 days is tight β†’ Cut optional features (waveform, history) - ---- - -## Success Metrics (MVP) - -### Functional Requirements -- βœ… Press hotkey β†’ speak β†’ release β†’ text in clipboard -- βœ… Works offline -- βœ… Works in any text field -- βœ… Transcription accuracy >80% for clear speech -- βœ… Latency <5 seconds for short clips (<10 sec speech) - -### Quality Requirements -- βœ… No crashes during basic usage -- βœ… Proper error messages -- βœ… Permissions clearly explained -- βœ… Works on macOS 13.0+ - -### Polish Requirements (Nice to have) -- βœ… Smooth animations -- βœ… Light/dark mode support -- βœ… Keyboard shortcuts -- βœ… Clean, minimal UI - ---- - -## Post-MVP Roadmap - -### Version 1.1 (Week 2) -- Real-time streaming transcription -- Multiple language support -- Improved model selection -- Transcription history -- Custom vocabulary - -### Version 1.2 (Week 3-4) -- Text formatting commands -- Punctuation auto-formatting -- Custom hotkeys per app -- Profiles/presets -- Cloud sync (optional) - -### Version 2.0 (Month 2) -- iOS standalone app -- macOS App Store release -- Pro features -- Advanced settings -- Analytics dashboard - ---- - -## Daily Checkpoints - -### Day 1 End -- [ ] Project configured with dependencies -- [ ] Core models defined -- [ ] Audio recording working -- [ ] Whisper integration functional -- [ ] Can record and transcribe (even if UI is basic) - -### Day 2 End -- [ ] All UI components built -- [ ] ViewModels connected -- [ ] Settings working -- [ ] Can use app end-to-end (rough but functional) - -### Day 3 End -- [ ] Complete user flows working -- [ ] Permissions handled -- [ ] UI polished -- [ ] Major bugs fixed - -### Day 4 End -- [ ] Tests written and passing -- [ ] Documentation complete -- [ ] Release build created -- [ ] GitHub repository ready - ---- - -## Resources - -### Documentation -- [SwiftUI Documentation](https://developer.apple.com/documentation/swiftui/) -- [AVFoundation Audio](https://developer.apple.com/documentation/avfoundation/audio/) -- [Accessibility Programming Guide](https://developer.apple.com/accessibility/) -- [Whisper Documentation](https://github.com/openai/whisper) - -### Swift Packages -- [WhisperKit](https://github.com/argmaxinc/WhisperKit) -- [KeyboardShortcuts](https://github.com/sindresorhus/KeyboardShortcuts) -- [LaunchAtLogin](https://github.com/sindresorhus/LaunchAtLogin-Modern) - -### Inspiration -- [VoiceInk](https://voices.ink/) -- [Whisper UI Examples](https://github.com/topics/whisper-ui) - ---- - -## Notes - -- Prioritize functionality over polish for MVP -- Test on real devices early and often -- Keep UI simple and focused -- Err on the side of fewer features done well -- Document decisions and assumptions -- Commit code frequently with clear messages - ---- - -## Getting Started - -To begin implementation: -1. Read this plan thoroughly -2. Set up development environment -3. Start with Phase 1, Task 1.1 -4. Check off items as completed -5. Update this document with learnings -6. Adjust timeline if needed - -Good luck! πŸš€ - diff --git a/docs/SETUP.md b/docs/SETUP.md deleted file mode 100644 index 7deecf4..0000000 --- a/docs/SETUP.md +++ /dev/null @@ -1,313 +0,0 @@ -# Setup Guide - -This guide will help you set up the SpeakType development environment. - -## Prerequisites - -### Required - -- **macOS**: 13.0 (Ventura) or later -- **Xcode**: 15.0 or later -- **Swift**: 5.9 or later (included with Xcode) -- **Git**: For version control - -### Recommended - -- **Homebrew**: Package manager for macOS -- **SwiftLint**: Code linting tool -- **Xcode Command Line Tools**: For terminal-based builds - -## Initial Setup - -### 1. Install Xcode - -Download and install Xcode from the Mac App Store or Apple Developer website. - -```bash -# Verify Xcode installation -xcode-select --version - -# Install command line tools if needed -xcode-select --install -``` - -### 2. Install Homebrew (if not already installed) - -```bash -/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" -``` - -### 3. Install SwiftLint - -```bash -brew install swiftlint -``` - -### 4. Clone the Repository - -```bash -git clone -cd speaktype -``` - -### 5. Open the Project - -```bash -# Open in Xcode -open speaktype.xcodeproj - -# Or use the Makefile -make xcode -``` - -### 6. Build the Project - -In Xcode: -- Select the `speaktype` scheme -- Choose your Mac as the destination -- Press `⌘B` to build - -Or from the terminal: -```bash -make build -``` - -## Development Workflow - -### Building - -```bash -# Debug build -make build - -# Release build -make build-release - -# Clean build -make clean -``` - -### Running - -```bash -# Run from terminal -make run - -# Or press ⌘R in Xcode -``` - -### Testing - -```bash -# Run all tests -make test - -# Run unit tests only -make test-unit - -# Run UI tests only -make test-ui - -# Or press ⌘U in Xcode -``` - -### Linting - -```bash -# Check for linting issues -make lint - -# Auto-fix linting issues -make format -``` - -## Project Structure - -After setup, your project structure should look like this: - -``` -speaktype/ -β”œβ”€β”€ .git/ # Git repository -β”œβ”€β”€ .gitignore # Git ignore rules -β”œβ”€β”€ .swiftlint.yml # SwiftLint configuration -β”œβ”€β”€ .editorconfig # Editor configuration -β”œβ”€β”€ .gitattributes # Git attributes -β”œβ”€β”€ Makefile # Build automation -β”œβ”€β”€ README.md # Project overview -β”œβ”€β”€ CONTRIBUTING.md # Contribution guidelines -β”œβ”€β”€ speaktype.xcodeproj/ # Xcode project -β”œβ”€β”€ speaktype/ # Main source code -β”‚ β”œβ”€β”€ App/ # App entry point -β”‚ β”œβ”€β”€ Views/ # UI views -β”‚ β”œβ”€β”€ ViewModels/ # View models -β”‚ β”œβ”€β”€ Models/ # Data models -β”‚ β”œβ”€β”€ Services/ # Business logic -β”‚ β”œβ”€β”€ Utilities/ # Helpers and extensions -β”‚ └── Resources/ # Assets and resources -β”œβ”€β”€ speaktypeTests/ # Unit tests -β”œβ”€β”€ speaktypeUITests/ # UI tests -└── docs/ # Documentation -``` - -## Configuration - -### Xcode Settings - -Recommended Xcode settings for development: - -1. **Text Editing**: - - Enable: "Automatically trim trailing whitespace" - - Enable: "Including whitespace-only lines" - - Tab width: 4 spaces - - Indent width: 4 spaces - - Use spaces instead of tabs - -2. **Source Control**: - - Enable: "Enable source control" - - Enable: "Refresh local status automatically" - -3. **Build Settings**: - - Swift Language Version: Swift 5 - - Optimization Level (Debug): -Onone - - Optimization Level (Release): -O - -### SwiftLint Integration - -SwiftLint is configured via `.swiftlint.yml`. To integrate with Xcode: - -1. Open Xcode project -2. Select the target -3. Go to Build Phases -4. Add a new "Run Script Phase" -5. Add this script: - -```bash -if which swiftlint >/dev/null; then - swiftlint -else - echo "warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint" -fi -``` - -## Troubleshooting - -### Build Errors - -**Problem**: "No such module" errors - -**Solution**: -```bash -# Clean build folder -make clean - -# Or in Xcode: Product > Clean Build Folder (β‡§βŒ˜K) -``` - -**Problem**: SwiftLint warnings/errors - -**Solution**: -```bash -# Auto-fix issues -make format - -# Or manually fix based on warnings -``` - -### Git Issues - -**Problem**: Line ending issues - -**Solution**: The project uses `.gitattributes` to normalize line endings. Ensure you have: -```bash -git config --global core.autocrlf input -``` - -**Problem**: Merge conflicts in `.pbxproj` - -**Solution**: The `.gitattributes` file is configured to use `merge=union` for project files, which helps reduce conflicts. - -### Xcode Issues - -**Problem**: Xcode not finding files - -**Solution**: -1. Clean build folder (β‡§βŒ˜K) -2. Close Xcode -3. Delete DerivedData: - ```bash - rm -rf ~/Library/Developer/Xcode/DerivedData - ``` -4. Reopen project - -**Problem**: Simulator issues - -**Solution**: -```bash -# Reset simulator -xcrun simctl erase all - -# Or use Xcode: Device > Erase All Content and Settings -``` - -## IDE Setup - -### Xcode Extensions - -Recommended Xcode extensions: -- **SwiftLint for Xcode**: Real-time linting -- **Xcode Themes**: Better color schemes - -### VS Code (Optional) - -If you prefer VS Code for editing: - -1. Install Swift extension -2. Install SwiftLint extension -3. Open workspace: `code .` - -`.editorconfig` is already configured for consistent formatting. - -## Environment Variables - -If your app requires environment variables: - -1. Create a `.env` file (ignored by git): - ```bash - API_KEY=your_api_key_here - BASE_URL=https://api.example.com - ``` - -2. Add to Xcode scheme: - - Edit Scheme > Run > Arguments - - Add environment variables - -## Next Steps - -After setup: - -1. Read [ARCHITECTURE.md](ARCHITECTURE.md) to understand the project structure -2. Read [FOLDER_STRUCTURE.md](FOLDER_STRUCTURE.md) for organization guidelines -3. Read [CONTRIBUTING.md](../CONTRIBUTING.md) before making changes -4. Run tests to ensure everything works: `make test` -5. Start coding! πŸš€ - -## Additional Resources - -- [Swift Documentation](https://swift.org/documentation/) -- [SwiftUI Documentation](https://developer.apple.com/documentation/swiftui/) -- [Xcode Documentation](https://developer.apple.com/documentation/xcode) -- [Git Documentation](https://git-scm.com/doc) - -## Getting Help - -If you encounter issues: - -1. Check this documentation -2. Search existing issues on GitHub -3. Ask in team chat/Slack -4. Create a new issue with details - -## License - -[Add your license information here] - diff --git a/speaktype.xcodeproj/project.pbxproj b/speaktype.xcodeproj/project.pbxproj index 5483be6..5ad6273 100644 --- a/speaktype.xcodeproj/project.pbxproj +++ b/speaktype.xcodeproj/project.pbxproj @@ -35,9 +35,22 @@ 362947392F0DBC5100BE895D /* speaktypeUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = speaktypeUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 362947242F0DBC4E00BE895E /* Exceptions for "speaktype" folder in "speaktype" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Resources/Info.plist, + ); + target = 362947212F0DBC4E00BE895D /* speaktype */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ 362947242F0DBC4E00BE895D /* speaktype */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 362947242F0DBC4E00BE895E /* Exceptions for "speaktype" folder in "speaktype" target */, + ); path = speaktype; sourceTree = ""; }; diff --git a/speaktype/Assets.xcassets/AppIcon.appiconset/Contents.json b/speaktype/Assets.xcassets/AppIcon.appiconset/Contents.json index 0386aff..fb38217 100644 --- a/speaktype/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/speaktype/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -59,12 +59,6 @@ "idiom": "mac", "filename": "icon_1024x1024.png", "scale": "2x" - }, - { - "size": "1024x1024", - "idiom": "mac", - "filename": "icon_1024x1024.png", - "scale": "1x" } ], "info": { diff --git a/speaktype/Controllers/MiniRecorderWindowController.swift b/speaktype/Controllers/MiniRecorderWindowController.swift index b2937b9..a31cdbd 100644 --- a/speaktype/Controllers/MiniRecorderWindowController.swift +++ b/speaktype/Controllers/MiniRecorderWindowController.swift @@ -19,31 +19,14 @@ class MiniRecorderWindowController: NSObject { panel.orderOut(nil) } else { print("Showing Mini Recorder Panel") - // Center closely to mouse or screen center? Center of screen is safer. - // But let's respect previous floating behavior. - panel.center() - // panel.makeKeyAndOrderFront(nil) -> Activates app. We want specific behavior. - // We want it to float but NOT activate the app if possible? - // Actually, for a recorder, we usually want it visible. + panel.center() - // To prevent activating the Main App (and thus showing Main Dashboard if it was key), - // we show the panel without activating the app? - // "panel.orderFront(nil)" shows it. - - // However, to type into it (if needed) or interact, we might need activation. - // But our Mini Recorder is button-based. - - // Critical: If we use NSApp.activate, it might switch to the main dashboard scene if that's "primary". - // By managing this panel separately, we avoid SwiftUI triggering the main scene. - - // orderFrontRegardless allows showing the window without activating the app + // Show without activating to avoid pulling main app focus unnecessarily panel.orderFrontRegardless() - // panel.orderFrontDuringApplicationMakeKey() // Does not exist - // panel.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) + NSApp.activate(ignoringOtherApps: true) // Trigger instant recording - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { NotificationCenter.default.post(name: .hotkeyTriggered, object: nil) } } diff --git a/speaktype/Models/AIModel.swift b/speaktype/Models/AIModel.swift index 890c1cb..a9a495c 100644 --- a/speaktype/Models/AIModel.swift +++ b/speaktype/Models/AIModel.swift @@ -10,43 +10,29 @@ struct AIModel: Identifiable, Equatable { static let availableModels: [AIModel] = [ AIModel( - name: "Distil-Whisper Large v3", - variant: "distil-whisper/distil-large-v3", - details: "English β€’ High Quality β€’ Fast", + name: "Whisper Large v3 Turbo", + variant: "openai_whisper-large-v3_turbo", + details: "Multilingual β€’ High Accuracy β€’ Fast", rating: "Excellent", - size: "756 MB" - ), - AIModel( - name: "Whisper Large v3", - variant: "openai/whisper-large-v3", - details: "Multilingual β€’ Best Accuracy β€’ Slower", - rating: "Best", - size: "3.1 GB" + size: "1.6 GB" ), AIModel( name: "Whisper Medium", - variant: "openai/whisper-medium", + variant: "openai_whisper-medium", details: "Multilingual β€’ Balanced", rating: "Great", size: "1.5 GB" ), - AIModel( - name: "Distil-Whisper Medium", - variant: "distil-whisper/distil-medium.en", - details: "English-only β€’ Fast β€’ Good Accuracy", - rating: "Good", - size: "396 MB" - ), AIModel( name: "Whisper Base", - variant: "openai_whisper-base", + variant: "openai_whisper-base.en", details: "English-only β€’ Optimized for Apple Silicon", rating: "Standard", size: "74 MB" ), AIModel( name: "Whisper Small", - variant: "openai_whisper-small", + variant: "openai_whisper-small.en", details: "English-only β€’ Higher accuracy", rating: "Good", size: "244 MB" diff --git a/speaktype/Services/ModelDownloadService.swift b/speaktype/Services/ModelDownloadService.swift index b5acf63..61c3405 100644 --- a/speaktype/Services/ModelDownloadService.swift +++ b/speaktype/Services/ModelDownloadService.swift @@ -57,13 +57,13 @@ class ModelDownloadService: ObservableObject { // try await WhisperKit.download(variant: variant) { progress in ... } // likely: download(variant:progressCallback:) - 'from' usually has a default - let path = try await WhisperKit.download(variant: variant, progressCallback: { progress in + let _ = try await WhisperKit.download(variant: variant, progressCallback: { progress in DispatchQueue.main.async { self.downloadProgress[variant] = progress.fractionCompleted } }) - print("Model downloaded to: \(path)") + print("Model downloaded successfully") DispatchQueue.main.async { self.isDownloading[variant] = false @@ -74,37 +74,43 @@ class ModelDownloadService: ObservableObject { // Auto-Repair: If duplicate models found, delete and retry ONCE if error.localizedDescription.contains("Multiple models found") { - print("Corruption detected. cleaning cache and retrying...") - Task { @MainActor in - self.downloadError[variant] = "Repairing..." + print("⚠️ Multiple models detected. Cleaning cache and retrying...") + + await MainActor.run { + self.downloadError[variant] = "Cleaning duplicates..." } let log = await self.deleteModel(variant: variant) + print("🧹 Cleanup result: \(log)") + + // Give filesystem time to settle + try? await Task.sleep(nanoseconds: 2_000_000_000) - Task { @MainActor in - self.downloadError[variant] = "Repairing... \(log)" - try? await Task.sleep(nanoseconds: 1_000_000_000) // Sleep 1s to let FS settle + await MainActor.run { + self.downloadError[variant] = "Retrying download..." } - // Retry download - Task { - do { - let path = try await WhisperKit.download(variant: variant, progressCallback: { progress in - DispatchQueue.main.async { - self.downloadProgress[variant] = progress.fractionCompleted - } - }) + // Retry download once + do { + let _ = try await WhisperKit.download(variant: variant, progressCallback: { progress in DispatchQueue.main.async { - self.isDownloading[variant] = false - self.downloadProgress[variant] = 1.0 - self.downloadError[variant] = nil - } - } catch { - DispatchQueue.main.async { - self.isDownloading[variant] = false - self.downloadProgress[variant] = 0.0 - self.downloadError[variant] = error.localizedDescription + self.downloadProgress[variant] = progress.fractionCompleted } + }) + + print("βœ… Model downloaded successfully after cleanup") + + DispatchQueue.main.async { + self.isDownloading[variant] = false + self.downloadProgress[variant] = 1.0 + self.downloadError[variant] = nil + } + } catch { + print("❌ Retry failed: \(error)") + DispatchQueue.main.async { + self.isDownloading[variant] = false + self.downloadProgress[variant] = 0.0 + self.downloadError[variant] = "Error: \(error.localizedDescription)\n\nTry clicking the trash icon to manually clean cache." } } return @@ -113,8 +119,7 @@ class ModelDownloadService: ObservableObject { DispatchQueue.main.async { self.isDownloading[variant] = false self.downloadProgress[variant] = 0.0 - // Append hint - self.downloadError[variant] = error.localizedDescription + "\n(Try Trash icon)" + self.downloadError[variant] = error.localizedDescription + "\n\n(Try Trash icon to clean cache)" } } } @@ -125,70 +130,98 @@ class ModelDownloadService: ObservableObject { let fileManager = FileManager.default let searchDirs: [FileManager.SearchPathDirectory] = [.documentDirectory, .applicationSupportDirectory, .cachesDirectory] - // "openai/whisper-large-v3" -> "whisper-large-v3" + // Parse variant: "openai/whisper-medium" or "openai_whisper-medium" let variantParts = variant.split(separator: "/") let modelName = variantParts.last ?? Substring(variant) + // Also search for underscore version: openai_whisper-medium + let underscoreVariant = variant.replacingOccurrences(of: "/", with: "_") + var deletedCount = 0 var checkedPaths: [String] = [] + print("πŸ—‘οΈ Searching for model caches matching: '\(modelName)' or '\(underscoreVariant)'") + // 1. Check Standard macOS Paths for searchDir in searchDirs { guard let baseDir = fileManager.urls(for: searchDir, in: .userDomainMask).first else { continue } - // Check ./huggingface/models - let hfDir = baseDir.appendingPathComponent("huggingface/models") - checkedPaths.append(hfDir.path) // Checks: ~/Library/Application Support/huggingface/models - deletedCount += cleanupDirectory(hfDir, match: modelName) + // Check ./huggingface/models (HuggingFace cache) + let hfModelsDir = baseDir.appendingPathComponent("huggingface/models") + checkedPaths.append(hfModelsDir.path) + deletedCount += cleanupDirectory(hfModelsDir, matchAny: [String(modelName), underscoreVariant]) + + // Check ./huggingface/hub (Alternative HF structure) + let hfHubDir = baseDir.appendingPathComponent("huggingface/hub") + checkedPaths.append(hfHubDir.path) + deletedCount += cleanupDirectory(hfHubDir, matchAny: [String(modelName), underscoreVariant]) + + // Check SpeakType-specific directory + let speaktypeDir = baseDir.appendingPathComponent("SpeakType/huggingface") + checkedPaths.append(speaktypeDir.path) + deletedCount += cleanupDirectory(speaktypeDir, matchAny: [String(modelName), underscoreVariant]) - // Check ./ (root of Documents/Support, sometimes WhisperKit dumps here) - // check baseDir.path - deletedCount += cleanupDirectory(baseDir, match: modelName) + // Check root directory (sometimes models are here) + deletedCount += cleanupDirectory(baseDir, matchAny: [String(modelName), underscoreVariant]) } // 2. Check ~/.cache (Common for Python/Unix HF tools) let homeDir = fileManager.homeDirectoryForCurrentUser - let dotCache = homeDir.appendingPathComponent(".cache/huggingface/models") - checkedPaths.append(dotCache.path) - deletedCount += cleanupDirectory(dotCache, match: modelName) + let dotCacheModels = homeDir.appendingPathComponent(".cache/huggingface/models") + checkedPaths.append(dotCacheModels.path) + deletedCount += cleanupDirectory(dotCacheModels, matchAny: [String(modelName), underscoreVariant]) - // 3. Explicit Container Check (Fallback) - let container = homeDir.appendingPathComponent("Library/Containers/org.speaktype.speaktype/Data/Documents/huggingface/models") - checkedPaths.append(container.path) - deletedCount += cleanupDirectory(container, match: modelName) + let dotCacheHub = homeDir.appendingPathComponent(".cache/huggingface/hub") + checkedPaths.append(dotCacheHub.path) + deletedCount += cleanupDirectory(dotCacheHub, matchAny: [String(modelName), underscoreVariant]) - // 4. Check Temporary Directory + // 3. Check Temporary Directory let tempDir = fileManager.temporaryDirectory - // check tempDir/huggingface/models and just tempDir - let tempHf = tempDir.appendingPathComponent("huggingface/models") - checkedPaths.append(tempDir.path) + let tempHf = tempDir.appendingPathComponent("huggingface") checkedPaths.append(tempHf.path) - deletedCount += cleanupDirectory(tempDir, match: modelName) - deletedCount += cleanupDirectory(tempHf, match: modelName) + deletedCount += cleanupDirectory(tempHf, matchAny: [String(modelName), underscoreVariant]) + deletedCount += cleanupDirectory(tempDir, matchAny: [String(modelName), underscoreVariant]) + + // 4. Check for models--* pattern in Application Support (WhisperKit style) + if let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first { + let modelsPattern = "models--" + variant.replacingOccurrences(of: "/", with: "--") + deletedCount += cleanupDirectory(appSupport, matchAny: [modelsPattern]) + + // Also check nested huggingface/hub for models-- pattern + let hfHub = appSupport.appendingPathComponent("huggingface/hub") + deletedCount += cleanupDirectory(hfHub, matchAny: [modelsPattern]) + } + + print("πŸ—‘οΈ Cleanup complete. Deleted \(deletedCount) items from \(checkedPaths.count) locations") if deletedCount > 0 { - return "Deleted \(deletedCount) items" + return "Deleted \(deletedCount) items. Retry download." } else { - // Return only first 2 checked paths for brevity if they exist, or just summary - return "No match for '\(modelName)' in \(checkedPaths.count) locations. checked: \(checkedPaths.map { $0.replacingOccurrences(of: homeDir.path, with: "~") }.joined(separator: ", "))" + return "No cached models found matching '\(modelName)'. This error may be in a different location." } } - private func cleanupDirectory(_ dir: URL, match: Substring) -> Int { + private func cleanupDirectory(_ dir: URL, matchAny patterns: [String]) -> Int { let fileManager = FileManager.default guard let contents = try? fileManager.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) else { return 0 } var count = 0 for url in contents { - if url.lastPathComponent.contains(match) { - do { - try fileManager.removeItem(at: url) - print("Deleted corrupted cache: \(url.path)") - count += 1 - } catch { - print("Failed to delete \(url.path): \(error)") - } - } + let fileName = url.lastPathComponent + // Check if any pattern matches + let matches = patterns.contains { pattern in + fileName.contains(pattern) || fileName.contains(pattern.replacingOccurrences(of: "/", with: "--")) + } + + if matches { + do { + try fileManager.removeItem(at: url) + print("βœ… Deleted cache: \(url.lastPathComponent)") + count += 1 + } catch { + print("❌ Failed to delete \(url.lastPathComponent): \(error)") + } + } } return count } diff --git a/speaktype/Utilities/AppLogger.swift b/speaktype/Utilities/AppLogger.swift new file mode 100644 index 0000000..bff3ef0 --- /dev/null +++ b/speaktype/Utilities/AppLogger.swift @@ -0,0 +1,67 @@ +import Foundation +import OSLog + +/// Unified logging for SpeakType +/// Usage: AppLogger.service.info("Model downloaded") +enum AppLogger { + /// General app lifecycle events + static let app = Logger(subsystem: subsystem, category: "App") + + /// Audio recording events + static let audio = Logger(subsystem: subsystem, category: "Audio") + + /// Transcription and WhisperKit events + static let transcription = Logger(subsystem: subsystem, category: "Transcription") + + /// Model download and management + static let models = Logger(subsystem: subsystem, category: "Models") + + /// Clipboard and pasteboard operations + static let clipboard = Logger(subsystem: subsystem, category: "Clipboard") + + /// Hotkey and keyboard shortcuts + static let hotkeys = Logger(subsystem: subsystem, category: "Hotkeys") + + /// UI and window management + static let ui = Logger(subsystem: subsystem, category: "UI") + + /// General service operations + static let service = Logger(subsystem: subsystem, category: "Service") + + /// History and persistence + static let history = Logger(subsystem: subsystem, category: "History") + + /// Permissions and system access + static let permissions = Logger(subsystem: subsystem, category: "Permissions") + + private static let subsystem = "com.2048labs.speaktype" +} + +// MARK: - Convenience Methods +extension AppLogger { + /// Log with emoji prefix for better visual scanning + static func info(_ message: String, category: Logger = AppLogger.service) { + category.info("ℹ️ \(message)") + } + + static func debug(_ message: String, category: Logger = AppLogger.service) { + category.debug("πŸ” \(message)") + } + + static func error(_ message: String, error: Error? = nil, category: Logger = AppLogger.service) { + if let error = error { + category.error("❌ \(message): \(error.localizedDescription)") + } else { + category.error("❌ \(message)") + } + } + + static func warning(_ message: String, category: Logger = AppLogger.service) { + category.warning("⚠️ \(message)") + } + + static func success(_ message: String, category: Logger = AppLogger.service) { + category.info("βœ… \(message)") + } +} + diff --git a/speaktype/Views/AIModelsView.swift b/speaktype/Views/AIModelsView.swift deleted file mode 100644 index 2a8441b..0000000 --- a/speaktype/Views/AIModelsView.swift +++ /dev/null @@ -1,236 +0,0 @@ -import SwiftUI - -struct AIModelsView: View { - @StateObject private var downloadService = ModelDownloadService.shared - @AppStorage("selectedModelVariant") private var selectedModel: String = "openai_whisper-base" - - // Using standard WhisperKit model variants - @State private var models = AIModel.availableModels - - // Helper to get name of selected model - var selectedModelName: String { - models.first(where: { $0.variant == selectedModel })?.name ?? "Unknown Model" - } - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 30) { - // Header (Redesigned) - VStack(alignment: .leading, spacing: 10) { - Text("AI Models") - .font(.largeTitle) - .fontWeight(.bold) - .foregroundStyle(.white) - Text("Manage your local transcription models powered by WhisperKit.") - .font(.body) - .foregroundStyle(.gray) - } - - VStack(alignment: .leading, spacing: 20) { - VStack(alignment: .leading, spacing: 8) { - Text("Default Model") - .font(.headline) - .foregroundStyle(.gray) - Text(selectedModelName) - .font(.title2) - .fontWeight(.bold) - .foregroundStyle(.white) - } - .padding() - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.contentBackground.opacity(0.5)) - .cornerRadius(12) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(Color.gray.opacity(0.3), lineWidth: 1) - ) - } - - HStack { - Text("Available Models") - .font(.title2) - .fontWeight(.bold) - .foregroundStyle(.white) - Spacer() - } - - // Models List - VStack(spacing: 16) { - ForEach($models) { $model in - ModelRow(model: $model, selectedModel: $selectedModel) - } - } - } - .padding(30) - } - .background(Color.contentBackground) - } -} - - - -struct ModelRow: View { - @Binding var model: AIModel - @Binding var selectedModel: String // Bind to parent selection - @ObservedObject var downloadService = ModelDownloadService.shared - - var progress: Double { - downloadService.downloadProgress[model.variant] ?? 0.0 - } - - var isDownloading: Bool { - downloadService.isDownloading[model.variant] ?? false - } - - var isDownloaded: Bool { - // Placeholder check. In real app, check file existence or WhisperKit.isModelAvailable(variant) - return progress >= 1.0 - } - - var isActive: Bool { - selectedModel == model.variant - } - - var downloadError: String? { - downloadService.downloadError[model.variant] - } - - var body: some View { - HStack { - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(model.name) - .font(.headline) - .foregroundStyle(.white) - - // Rating Badge - Text(model.rating) - .font(.caption2) - .fontWeight(.bold) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(ratingColor(for: model.rating).opacity(0.2)) - .foregroundStyle(ratingColor(for: model.rating)) - .clipShape(Capsule()) - - if isActive { - Text("(Active)") - .font(.caption) - .foregroundStyle(Color.green) - } - } - HStack(spacing: 6) { - Text(model.details) - .font(.caption) - .foregroundStyle(.gray) - - Text("β€’") - .font(.caption) - .foregroundStyle(.gray) - - Text(model.size) - .font(.caption) - .foregroundStyle(.gray.opacity(0.8)) - } - } - - Spacer() - - if isDownloaded { - // If downloaded, allow selection - if isActive { - HStack(spacing: 4) { - Image(systemName: "checkmark.circle.fill") - Text("Selected") - } - .padding(.horizontal, 12) - .padding(.vertical, 6) - .foregroundStyle(Color.green) - } else { - Button("Use") { - selectedModel = model.variant - } - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color.white.opacity(0.1)) - .foregroundStyle(.white) - .cornerRadius(16) - .buttonStyle(.plain) - } - } else if isDownloading { - HStack { - ProgressView(value: progress) - .progressViewStyle(.linear) - .frame(width: 80) - .tint(Color.appRed) - Text("\(Int(progress * 100))%") - .font(.caption) - .foregroundStyle(.gray) - .frame(width: 35, alignment: .trailing) - } - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color.black.opacity(0.2)) - .cornerRadius(16) - } else { - VStack(alignment: .trailing) { - Button(action: { - downloadService.downloadModel(variant: model.variant) - }) { - HStack(spacing: 4) { - Text("Download") - Image(systemName: "arrow.down.circle") - } - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color.appRed) - .foregroundStyle(.white) - .cornerRadius(16) - } - .buttonStyle(.plain) - - // Debug: Manual Cache Clear - Button(action: { - Task { - let result = await downloadService.deleteModel(variant: model.variant) - await MainActor.run { - downloadService.downloadError[model.variant] = "Manual Delete: \(result)" - } - } - }) { - Image(systemName: "trash") - .foregroundStyle(.gray) - .padding(6) - } - .buttonStyle(.plain) - .help("Force Delete Cache") - - if let error = downloadError { - Text(error) - .font(.caption2) - .foregroundStyle(.red) - .multilineTextAlignment(.trailing) - .frame(maxWidth: 150) - } - } - } - } - .padding() - .background(Color.white.opacity(0.05)) - .cornerRadius(12) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(isActive ? Color.green.opacity(0.5) : Color.white.opacity(0.05), lineWidth: 1) - ) - } - - func ratingColor(for rating: String) -> Color { - switch rating { - case "Best", "Excellent": return .purple - case "Great", "Good": return .green - case "Standard": return .blue - case "Basic": return .gray - default: return .white - } - } -} diff --git a/speaktype/Views/Components/ModelRow.swift b/speaktype/Views/Components/ModelRow.swift new file mode 100644 index 0000000..b4131b1 --- /dev/null +++ b/speaktype/Views/Components/ModelRow.swift @@ -0,0 +1,205 @@ +import SwiftUI + +/// Reusable component for displaying a single AI model in the models list +struct ModelRow: View { + @Binding var model: AIModel + @Binding var selectedModel: String + @ObservedObject var downloadService = ModelDownloadService.shared + + // MARK: - Computed Properties + + var progress: Double { + downloadService.downloadProgress[model.variant] ?? 0.0 + } + + var isDownloading: Bool { + downloadService.isDownloading[model.variant] ?? false + } + + var isDownloaded: Bool { + progress >= 1.0 + } + + var isActive: Bool { + selectedModel == model.variant + } + + var downloadError: String? { + downloadService.downloadError[model.variant] + } + + var ratingColor: Color { + switch model.rating { + case "Best", "Excellent": return .purple + case "Great", "Good": return .green + case "Standard": return .blue + case "Basic": return .gray + default: return .white + } + } + + // MARK: - Body + + var body: some View { + HStack { + // Model Info + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(model.name) + .font(.headline) + .foregroundStyle(.white) + + // Rating Badge + Text(model.rating) + .font(.caption2) + .fontWeight(.bold) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(ratingColor.opacity(0.2)) + .foregroundStyle(ratingColor) + .clipShape(Capsule()) + + if isActive { + Text("(Active)") + .font(.caption) + .foregroundStyle(Color.green) + } + } + + HStack(spacing: 6) { + Text(model.details) + .font(.caption) + .foregroundStyle(.gray) + + Text("β€’") + .font(.caption) + .foregroundStyle(.gray) + + Text(model.size) + .font(.caption) + .foregroundStyle(.gray.opacity(0.8)) + } + } + + Spacer() + + // Actions + actionButton + } + .padding() + .background(Color.white.opacity(0.05)) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(isActive ? Color.green.opacity(0.5) : Color.white.opacity(0.05), lineWidth: 1) + ) + } + + // MARK: - Subviews + + @ViewBuilder + private var actionButton: some View { + if isDownloaded { + downloadedActions + } else if isDownloading { + downloadingProgress + } else { + downloadButton + } + } + + private var downloadedActions: some View { + HStack(spacing: 12) { + if isActive { + HStack(spacing: 4) { + Image(systemName: "checkmark.circle.fill") + Text("Selected") + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .foregroundStyle(Color.green) + } else { + Button("Use") { + selectedModel = model.variant + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.white.opacity(0.1)) + .foregroundStyle(.white) + .cornerRadius(16) + .buttonStyle(.plain) + } + } + } + + private var downloadingProgress: some View { + HStack { + ProgressView(value: progress) + .progressViewStyle(.linear) + .frame(width: 80) + .tint(Color.appRed) + + Text("\(Int(progress * 100))%") + .font(.caption) + .foregroundStyle(.gray) + .frame(width: 35, alignment: .trailing) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.black.opacity(0.2)) + .cornerRadius(16) + } + + private var downloadButton: some View { + VStack(alignment: .trailing, spacing: 8) { + Button(action: { + downloadService.downloadModel(variant: model.variant) + }) { + HStack(spacing: 4) { + Text("Download") + Image(systemName: "arrow.down.circle") + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.appRed) + .foregroundStyle(.white) + .cornerRadius(16) + } + .buttonStyle(.plain) + + Button(action: { + Task { + let result = await downloadService.deleteModel(variant: model.variant) + await MainActor.run { + downloadService.downloadError[model.variant] = "Manual Delete: \(result)" + } + } + }) { + Image(systemName: "trash") + .foregroundStyle(.gray) + .padding(6) + } + .buttonStyle(.plain) + .help("Force Delete Cache") + + if let error = downloadError { + Text(error) + .font(.caption2) + .foregroundStyle(.red) + .multilineTextAlignment(.trailing) + .frame(maxWidth: 150) + } + } + } +} + +// MARK: - Preview + +#Preview { + ModelRow( + model: .constant(AIModel.availableModels[0]), + selectedModel: .constant("openai_whisper-base") + ) + .padding() + .background(Color.black) +} diff --git a/speaktype/Views/ContentView.swift b/speaktype/Views/ContentView.swift deleted file mode 100644 index d7414b2..0000000 --- a/speaktype/Views/ContentView.swift +++ /dev/null @@ -1,97 +0,0 @@ -import SwiftUI - -struct ContentView: View { - @StateObject private var audioRecorder = AudioRecordingService() - @State private var whisperService = WhisperService() - @State private var transcribedText: String = "" - @State private var isTranscribing = false - - var body: some View { - VStack(spacing: 20) { - Text("SpeakType Integration Test") - .font(.headline) - - // Audio Recording Section - if audioRecorder.isRecording { - Button("Stop & Transcribe") { - if let url = audioRecorder.stopRecording() { - print("Recorded to: \(url)") - Task { - isTranscribing = true - do { - transcribedText = try await whisperService.transcribe(audioFile: url) - } catch { - transcribedText = "Error: \(error.localizedDescription)" - } - isTranscribing = false - } - } - } - .buttonStyle(.borderedProminent) - .tint(.red) - } else { - Button("Start Recording") { - transcribedText = "" // Clear previous - audioRecorder.requestPermission() - audioRecorder.startRecording() - } - .buttonStyle(.bordered) - } - - // Whisper Initialization - if !whisperService.isInitialized { - Button("Initialize Whisper") { - Task { - try? await whisperService.initialize() - } - } - .buttonStyle(.borderedProminent) - } - - Divider() - - // Transcription Output - VStack(alignment: .leading) { - Text("Transcription:") - .font(.caption) - .foregroundStyle(.secondary) - - if isTranscribing { - ProgressView("Transcribing...") - } else { - Text(transcribedText.isEmpty ? "No text yet" : transcribedText) - .padding() - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.gray.opacity(0.1)) - .cornerRadius(8) - } - } - - // Clipboard Actions - HStack { - Button("Copy Text") { - ClipboardService.shared.copy(text: transcribedText) - } - .disabled(transcribedText.isEmpty) - - Button("Paste (Simulate Cmd+V)") { - ClipboardService.shared.paste() - } - .disabled(transcribedText.isEmpty) - } - - Text("Status: \(whisperService.isInitialized ? "Whisper Ready" : "Whisper Idle")") - .font(.caption) - .foregroundStyle(whisperService.isInitialized ? .green : .orange) - } - .padding() - .frame(width: 400, height: 500) - .onAppear { - // HotkeyService removed in favor of KeyboardShortcuts defined in App - } - } -} - -#Preview { - ContentView() -} diff --git a/speaktype/Views/HistoryView.swift b/speaktype/Views/HistoryView.swift deleted file mode 100644 index a458b42..0000000 --- a/speaktype/Views/HistoryView.swift +++ /dev/null @@ -1,101 +0,0 @@ -import SwiftUI - -struct HistoryView: View { - @StateObject private var historyService = HistoryService.shared - @State private var selectedItem: HistoryItem? - @State private var showCopyAlert = false - - var body: some View { - NavigationSplitView { - List(historyService.items, selection: $selectedItem) { item in - NavigationLink(value: item) { - VStack(alignment: .leading, spacing: 4) { - Text(item.transcript.prefix(50) + (item.transcript.count > 50 ? "..." : "")) - .font(.headline) - .foregroundStyle(.white) - .frame(maxWidth: .infinity, alignment: .leading) - - HStack { - Text(item.date.formatted(date: .abbreviated, time: .shortened)) - Spacer() - Text(formatDuration(item.duration)) - } - .font(.caption) - .foregroundStyle(.gray) - } - .padding(.vertical, 4) - .frame(maxWidth: .infinity, alignment: .leading) - .contextMenu { - Button("Delete", role: .destructive) { - historyService.deleteItem(id: item.id) - if selectedItem?.id == item.id { - selectedItem = nil - } - } - } - } - } - .navigationTitle("History") - .listStyle(.sidebar) - } detail: { - if let item = selectedItem { - VStack(spacing: 0) { - // Header Bar - HStack { - Text(item.date.formatted(date: .long, time: .standard)) - .font(.subheadline) - .foregroundStyle(.gray) - - Spacer() - - Button(action: { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(item.transcript, forType: .string) - showCopyAlert = true - }) { - Label("Copy", systemImage: "doc.on.doc") - } - .buttonStyle(.bordered) - .controlSize(.small) - .alert("Text Copied", isPresented: $showCopyAlert) { - Button("OK", role: .cancel) { } - } message: { - Text("Transcript has been copied to clipboard.") - } - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background(Color(nsColor: .controlBackgroundColor)) - - Divider() - - // Edge-to-Edge Transcript - ScrollView { - Text(item.transcript) - .font(.body) - .foregroundStyle(.white) - .textSelection(.enabled) - .lineSpacing(4) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 16) - .padding(.vertical, 20) - .multilineTextAlignment(.leading) - } - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.contentBackground) - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - } else { - Text("Select a recording to view transcript") - .foregroundStyle(.gray) - } - } - } - - func formatDuration(_ duration: TimeInterval) -> String { - let formatter = DateComponentsFormatter() - formatter.allowedUnits = [.minute, .second] - formatter.unitsStyle = .abbreviated - return formatter.string(from: duration) ?? "0s" - } -} diff --git a/speaktype/Views/MiniRecorderView.swift b/speaktype/Views/Overlays/MiniRecorderView.swift similarity index 95% rename from speaktype/Views/MiniRecorderView.swift rename to speaktype/Views/Overlays/MiniRecorderView.swift index fe458c6..0a8bb44 100644 --- a/speaktype/Views/MiniRecorderView.swift +++ b/speaktype/Views/Overlays/MiniRecorderView.swift @@ -170,7 +170,7 @@ struct MiniRecorderView: View { } // Save to History - let duration = getAudioDuration(url: url) + let duration = await getAudioDuration(url: url) HistoryService.shared.addItem(transcript: text, duration: duration) // Delegate Commit to Controller (Copy, Hide, Paste happens there) @@ -189,8 +189,14 @@ struct MiniRecorderView: View { } } - private func getAudioDuration(url: URL) -> TimeInterval { + private func getAudioDuration(url: URL) async -> TimeInterval { let asset = AVURLAsset(url: url) - return CMTimeGetSeconds(asset.duration) + do { + let duration = try await asset.load(.duration) + return duration.seconds + } catch { + print("Error loading audio duration: \(error)") + return 0 + } } } diff --git a/speaktype/Views/DashboardView.swift b/speaktype/Views/Screens/Dashboard/DashboardView.swift similarity index 100% rename from speaktype/Views/DashboardView.swift rename to speaktype/Views/Screens/Dashboard/DashboardView.swift diff --git a/speaktype/Views/Screens/History/HistoryView.swift b/speaktype/Views/Screens/History/HistoryView.swift new file mode 100644 index 0000000..d2d102d --- /dev/null +++ b/speaktype/Views/Screens/History/HistoryView.swift @@ -0,0 +1,100 @@ +import SwiftUI + +struct HistoryView: View { + @StateObject private var historyService = HistoryService.shared + @State private var showCopyAlert = false + + // We don't need selectedItem anymore for a simple list + // We can track which item gets copied to show the alert using a simpler mechanism or just the bool + + var body: some View { + VStack(spacing: 0) { + Text("History") + .font(.largeTitle) + .fontWeight(.bold) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + + if historyService.items.isEmpty { + ContentUnavailableView( + "No History Yet", + systemImage: "clock", + description: Text("Your transcriptions will appear here.") + ) + } else { + List { + ForEach(historyService.items) { item in + DisclosureGroup { + // Expanded Content + VStack(alignment: .leading, spacing: 12) { + Divider() + + Text(item.transcript) + .font(.body) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) // Allow multiline growth + + Button(action: { + copyToClipboard(text: item.transcript) + }) { + Label("Copy Transcript", systemImage: "doc.on.doc") + } + .buttonStyle(.bordered) + .controlSize(.small) + .padding(.top, 4) + } + .padding(.vertical, 8) + } label: { + // Collapsed Header + HStack { + VStack(alignment: .leading) { + Text(item.date.formatted(date: .abbreviated, time: .shortened)) + .font(.headline) + Text(item.transcript.prefix(60) + (item.transcript.count > 60 ? "..." : "")) + .font(.caption) + .foregroundStyle(.gray) + .lineLimit(1) + } + + Spacer() + + Text(formatDuration(item.duration)) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.gray.opacity(0.2)) + .cornerRadius(4) + } + .padding(.vertical, 4) + } + } + .onDelete(perform: deleteItems) + } + .listStyle(.inset) + } + } + .alert("Copied", isPresented: $showCopyAlert) { + Button("OK", role: .cancel) { } + } message: { + Text("Transcript copied to clipboard.") + } + } + + private func copyToClipboard(text: String) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + showCopyAlert = true + } + + private func deleteItems(at offsets: IndexSet) { + historyService.deleteItem(at: offsets) + } + + func formatDuration(_ duration: TimeInterval) -> String { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.minute, .second] + formatter.unitsStyle = .abbreviated + return formatter.string(from: duration) ?? "0s" + } +} diff --git a/speaktype/Views/OnboardingView.swift b/speaktype/Views/Screens/Onboarding/OnboardingView.swift similarity index 100% rename from speaktype/Views/OnboardingView.swift rename to speaktype/Views/Screens/Onboarding/OnboardingView.swift diff --git a/speaktype/Views/PermissionsView.swift b/speaktype/Views/Screens/Onboarding/PermissionsView.swift similarity index 100% rename from speaktype/Views/PermissionsView.swift rename to speaktype/Views/Screens/Onboarding/PermissionsView.swift diff --git a/speaktype/Views/Screens/Settings/AIModelsView.swift b/speaktype/Views/Screens/Settings/AIModelsView.swift new file mode 100644 index 0000000..5777ef6 --- /dev/null +++ b/speaktype/Views/Screens/Settings/AIModelsView.swift @@ -0,0 +1,91 @@ +import SwiftUI + +/// Screen for managing AI transcription models +struct AIModelsView: View { + // MARK: - Properties + + @StateObject private var downloadService = ModelDownloadService.shared + @AppStorage("selectedModelVariant") private var selectedModel: String = "openai_whisper-base" + @State private var models = AIModel.availableModels + + // MARK: - Computed Properties + + var selectedModelName: String { + models.first(where: { $0.variant == selectedModel })?.name ?? "Unknown Model" + } + + // MARK: - Body + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 30) { + headerSection + currentModelCard + modelsListSection + } + .padding(30) + } + .background(Color.contentBackground) + } + + // MARK: - Subviews + + private var headerSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text("AI Models") + .font(.largeTitle) + .fontWeight(.bold) + .foregroundStyle(.white) + + Text("Manage your local transcription models powered by WhisperKit.") + .font(.body) + .foregroundStyle(.gray) + } + } + + private var currentModelCard: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Default Model") + .font(.headline) + .foregroundStyle(.gray) + + Text(selectedModelName) + .font(.title2) + .fontWeight(.bold) + .foregroundStyle(.white) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.contentBackground.opacity(0.5)) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.gray.opacity(0.3), lineWidth: 1) + ) + } + + private var modelsListSection: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("Available Models") + .font(.title2) + .fontWeight(.bold) + .foregroundStyle(.white) + Spacer() + } + + VStack(spacing: 16) { + ForEach($models) { $model in + ModelRow(model: $model, selectedModel: $selectedModel) + } + } + } + } +} + +// MARK: - Preview + +#Preview { + AIModelsView() + .background(Color.black) +} diff --git a/speaktype/Views/AudioInputView.swift b/speaktype/Views/Screens/Settings/AudioInputView.swift similarity index 100% rename from speaktype/Views/AudioInputView.swift rename to speaktype/Views/Screens/Settings/AudioInputView.swift diff --git a/speaktype/Views/SettingsView.swift b/speaktype/Views/Screens/Settings/SettingsView.swift similarity index 100% rename from speaktype/Views/SettingsView.swift rename to speaktype/Views/Screens/Settings/SettingsView.swift diff --git a/speaktype/Views/TranscribeAudioView.swift b/speaktype/Views/TranscribeAudioView.swift index de2ad59..c4a77e2 100644 --- a/speaktype/Views/TranscribeAudioView.swift +++ b/speaktype/Views/TranscribeAudioView.swift @@ -1,12 +1,14 @@ import SwiftUI import AVFoundation import CoreMedia +import UniformTypeIdentifiers struct TranscribeAudioView: View { @StateObject private var audioRecorder = AudioRecordingService() @State private var whisperService = WhisperService() @State private var transcribedText: String = "" @State private var isTranscribing = false + @State private var showFileImporter = false var body: some View { VStack(spacing: 30) { @@ -19,6 +21,15 @@ struct TranscribeAudioView: View { .stroke(style: StrokeStyle(lineWidth: 2, dash: [10])) .foregroundStyle(Color.gray.opacity(0.3)) .frame(maxWidth: .infinity, maxHeight: 400) + // Make the entire area tappable for file picker + .contentShape(Rectangle()) + .onTapGesture { + showFileImporter = true + } + .onDrop(of: [.audio, .movie, .fileURL], isTargeted: nil) { providers in + validateAndTranscribe(providers: providers) + return true + } VStack(spacing: 20) { Image(systemName: "arrow.down.doc") @@ -99,6 +110,20 @@ struct TranscribeAudioView: View { Spacer() } .background(Color.contentBackground) + .fileImporter( + isPresented: $showFileImporter, + allowedContentTypes: [.audio, .movie], + allowsMultipleSelection: false + ) { result in + switch result { + case .success(let urls): + if let url = urls.first { + handleFileSelection(url: url) + } + case .failure(let error): + print("File selection error: \(error.localizedDescription)") + } + } .onAppear { Task { if !whisperService.isInitialized { @@ -108,6 +133,63 @@ struct TranscribeAudioView: View { } } + private func handleFileSelection(url: URL) { + // Access security scoped resource if needed (for file picker) + let didStartAccessing = url.startAccessingSecurityScopedResource() + + // Create a copy or use the URL directly. + // For simplicity in this context, we'll try to use it directly but ensure we stop accessing later if needed. + // However, since startTranscription is async, we might lose access. + // Better pattern: Copy to temp directory if possible, or keep access open during transcription. + // Given WhisperKit might need file access, let's copy to a temp location to be safe and avoid scope issues. + + do { + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(url.lastPathComponent) + try? FileManager.default.removeItem(at: tempURL) // Clean up if exists + try FileManager.default.copyItem(at: url, to: tempURL) + + if didStartAccessing { + url.stopAccessingSecurityScopedResource() + } + + startTranscription(url: tempURL) + } catch { + print("Error copying file: \(error)") + if didStartAccessing { + url.stopAccessingSecurityScopedResource() + } + // Fallback: try original URL if copy fails + startTranscription(url: url) + } + } + + private func validateAndTranscribe(providers: [NSItemProvider]) { + for provider in providers { + if provider.hasItemConformingToTypeIdentifier(UTType.audio.identifier) || + provider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) { + + provider.loadFileRepresentation(forTypeIdentifier: UTType.content.identifier) { url, error in + if let url = url { + // LoadFileRepresentation gives us a temporary URL that might not persist. + // We should copy it immediately. + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(url.lastPathComponent) + do { + try? FileManager.default.removeItem(at: tempURL) + try FileManager.default.copyItem(at: url, to: tempURL) + + DispatchQueue.main.async { + startTranscription(url: tempURL) + } + } catch { + print("Error copying dropped file: \(error)") + } + } + } + return // Only handle the first valid file + } + } + } + private func startTranscription(url: URL) { Task { isTranscribing = true diff --git a/speaktypeTests/ClipboardServiceTests.swift b/speaktypeTests/ClipboardServiceTests.swift new file mode 100644 index 0000000..f0bdd4d --- /dev/null +++ b/speaktypeTests/ClipboardServiceTests.swift @@ -0,0 +1,18 @@ +import XCTest +@testable import speaktype + +final class ClipboardServiceTests: XCTestCase { + + func testCopy() { + let text = "Copied Text Check" + ClipboardService.shared.copy(text: text) + + let pasteboard = NSPasteboard.general + let copied = pasteboard.string(forType: .string) + + XCTAssertEqual(copied, text, "Clipboard content should match copied text") + } + + // Testing paste() is difficult in unit tests as it requires active application focus and AX permissions. + // We primarily verify the write operation here. +} diff --git a/speaktypeTests/HistoryServiceTests.swift b/speaktypeTests/HistoryServiceTests.swift new file mode 100644 index 0000000..4df3b5a --- /dev/null +++ b/speaktypeTests/HistoryServiceTests.swift @@ -0,0 +1,65 @@ +import XCTest +@testable import speaktype + +final class HistoryServiceTests: XCTestCase { + + var service: HistoryService! + + override func setUp() { + super.setUp() + service = HistoryService.shared + // Clear existing history for test isolation + service.items = [] + UserDefaults.standard.removeObject(forKey: "history_items") + } + + override func tearDown() { + service.items = [] + UserDefaults.standard.removeObject(forKey: "history_items") + super.tearDown() + } + + func testAddItem() { + XCTAssertTrue(service.items.isEmpty) + + let transcript = "Test Transcript" + let duration: TimeInterval = 10.0 + + service.addItem(transcript: transcript, duration: duration) + + XCTAssertEqual(service.items.count, 1) + XCTAssertEqual(service.items.first?.transcript, transcript) + XCTAssertEqual(service.items.first?.duration, duration) + } + + func testPersistence() { + let transcript = "Persistent Item" + service.addItem(transcript: transcript, duration: 5.0) + + // Simulate app restart by re-initializing (or checking UserDefaults directly) + // Since 'init' loads from UserDefaults, creating a new instance isn't easy with singleton, + // but we can check if UserDefaults has the data. + + guard let data = UserDefaults.standard.data(forKey: "history_items"), + let decoded = try? JSONDecoder().decode([HistoryItem].self, from: data) else { + XCTFail("Failed to load from UserDefaults") + return + } + + XCTAssertEqual(decoded.count, 1) + XCTAssertEqual(decoded.first?.transcript, transcript) + } + + func testDeleteItem() { + service.addItem(transcript: "Item 1", duration: 1.0) + service.addItem(transcript: "Item 2", duration: 2.0) + + XCTAssertEqual(service.items.count, 2) + + let itemToDelete = service.items.last! // "Item 1" (since newest is first) + service.deleteItem(id: itemToDelete.id) + + XCTAssertEqual(service.items.count, 1) + XCTAssertEqual(service.items.first?.transcript, "Item 2") + } +} diff --git a/speaktypeTests/ModelDownloadServiceTests.swift b/speaktypeTests/ModelDownloadServiceTests.swift new file mode 100644 index 0000000..5e5b92a --- /dev/null +++ b/speaktypeTests/ModelDownloadServiceTests.swift @@ -0,0 +1,19 @@ +import XCTest +@testable import speaktype + +final class ModelDownloadServiceTests: XCTestCase { + + func testInitialState() { + let service = ModelDownloadService.shared + + // Ensure no lingering downloads from other runs + // (Note: Shared singleton might have state if tests run in parallel or sequence without clearing) + // We can't easily clear private vars, but we can check types. + + XCTAssertNotNil(service.downloadProgress) + XCTAssertNotNil(service.isDownloading) + } + + // Real download tests require mocking backend connections or WhisperKit, which is out of scope for basic unit tests without dependency injection. + // We verified the model IDs in the previous verification step. +} diff --git a/speaktypeTests/speaktypeTests.swift b/speaktypeTests/speaktypeTests.swift deleted file mode 100644 index 1febd54..0000000 --- a/speaktypeTests/speaktypeTests.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// speaktypeTests.swift -// speaktypeTests -// -// Created by Karan Singh on 7/1/26. -// - -import Testing -@testable import speaktype - -struct speaktypeTests { - - @Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. - } - -} diff --git a/speaktypeUITests/speaktypeUITests.swift b/speaktypeUITests/speaktypeUITests.swift index d6105ea..40f7820 100644 --- a/speaktypeUITests/speaktypeUITests.swift +++ b/speaktypeUITests/speaktypeUITests.swift @@ -1,43 +1,33 @@ -// -// speaktypeUITests.swift -// speaktypeUITests -// -// Created by Karan Singh on 7/1/26. -// - import XCTest final class speaktypeUITests: XCTestCase { override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - - // In UI tests it is usually best to stop immediately when a failure occurs. continueAfterFailure = false - - // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. } - @MainActor - func testExample() throws { - // UI tests must launch the application that they test. + func testAppLaunchAndNavigation() throws { let app = XCUIApplication() app.launch() - - // Use XCTAssert and related functions to verify your tests produce the correct results. - let window = app.windows["SpeakType"] - XCTAssertTrue(window.exists, "The SpeakType window should exist") - } - - @MainActor - func testLaunchPerformance() throws { - // This measures how long it takes to launch your application. - measure(metrics: [XCTApplicationLaunchMetric()]) { - XCUIApplication().launch() + + // Verify Sidebar exists (macOS List maps to Outline) + let sidebar = app.outlines.firstMatch + // Wait up to 10s for UI to stabilize (increased for robustness) + if !sidebar.waitForExistence(timeout: 10.0) { + print("🚨 Sidebar NOT found! App Hierarchy:\n\(app.debugDescription)") + XCTFail("Sidebar should exist (Outline)") + } + + // Navigate to Settings + // On macOS, text is often exposed directly. We look for "Settings" static text. + let settingsText = app.staticTexts["Settings"] + + if settingsText.waitForExistence(timeout: 5.0) { + settingsText.click() + + // Verify we are on Settings View + let settingsContent = app.staticTexts["Startup Behavior"] + XCTAssertTrue(settingsContent.waitForExistence(timeout: 5.0), "Should find Settings content") } } } diff --git a/speaktypeUITests/speaktypeUITestsLaunchTests.swift b/speaktypeUITests/speaktypeUITestsLaunchTests.swift deleted file mode 100644 index b817767..0000000 --- a/speaktypeUITests/speaktypeUITestsLaunchTests.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// speaktypeUITestsLaunchTests.swift -// speaktypeUITests -// -// Created by Karan Singh on 7/1/26. -// - -import XCTest - -final class speaktypeUITestsLaunchTests: XCTestCase { - - override class var runsForEachTargetApplicationUIConfiguration: Bool { - true - } - - override func setUpWithError() throws { - continueAfterFailure = false - } - - @MainActor - func testLaunch() throws { - let app = XCUIApplication() - app.launch() - - // Insert steps here to perform after app launch but before taking a screenshot, - // such as logging into a test account or navigating somewhere in the app - - let attachment = XCTAttachment(screenshot: app.screenshot()) - attachment.name = "Launch Screen" - attachment.lifetime = .keepAlways - add(attachment) - } -}