From bd7ba64fea6f0d5a057ae2a9d8d3e9574f785581 Mon Sep 17 00:00:00 2001 From: Nikolay Kolibarov Date: Wed, 8 Jul 2026 18:44:54 +0300 Subject: [PATCH 01/13] fix: stabilize onboarding recording flow and add debug logs --- README.md | 8 ++ app.go | 144 ++++++++++++----------- build/darwin/Info.dev.plist | 2 + frontend/package-lock.json | 4 +- frontend/package.json.md5 | 2 +- frontend/src/App.tsx | 40 ++++--- frontend/src/Onboarding.tsx | 39 ++++--- frontend/wailsjs/go/main/App.d.ts | 2 + frontend/wailsjs/go/main/App.js | 4 + internal/audio/recorder.go | 44 +++---- internal/hotkey/hotkey_darwin.go | 76 ++++++------ internal/hotkey/hotkey_linux.go | 64 +++++----- internal/hotkey/hotkey_other.go | 4 +- internal/hotkey/hotkey_windows.go | 8 +- internal/logger/logger.go | 168 +++++++++++++++++++++++++++ internal/logger/redact.go | 17 +++ internal/sounds/sounds.go | 20 ++-- internal/system/clipboard_darwin.go | 36 +++--- internal/system/microphone_darwin.go | 32 ++--- main.go | 40 +++++-- 20 files changed, 510 insertions(+), 244 deletions(-) create mode 100644 internal/logger/logger.go create mode 100644 internal/logger/redact.go diff --git a/README.md b/README.md index e9c45c7..5b88fd7 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,14 @@ The app requires the following permissions: - **Microphone** — For audio recording - **Accessibility** — For global hotkey and auto-paste functionality +### Debug Logs + +Yap writes debug logs to help investigate unexpected user behavior. Logs are kept for 7 days. + +- macOS: `~/Library/Application Support/yap/logs/yap-YYYY-MM-DD.log` +- Windows: `%APPDATA%\yap\logs\yap-YYYY-MM-DD.log` +- Linux: `~/.config/yap/logs/yap-YYYY-MM-DD.log` + ## Documentation For comprehensive documentation, visit **[applauselab.ai/docs/yap](https://applauselab.ai/docs/yap/)**. diff --git a/app.go b/app.go index b0fbb5e..c66d51f 100644 --- a/app.go +++ b/app.go @@ -15,6 +15,7 @@ import ( "yap/internal/audio" "yap/internal/hotkey" + "yap/internal/logger" "yap/internal/models" "yap/internal/overlay" "yap/internal/sounds" @@ -94,7 +95,7 @@ type App struct { recordStartTime time.Time hotkeyEnabled bool history []HistoryItem - + // Tray callback to update icon onTrayUpdate func(recording bool) } @@ -108,40 +109,41 @@ func NewApp() *App { state: StateReady, history: make([]HistoryItem, 0), } - + // Set up overlay stop callback app.overlay.SetStopCallback(func() { app.ToggleRecording() }) - + // Set up overlay cancel callback app.overlay.SetCancelCallback(func() { app.CancelRecording() }) - + return app } // startup is called when the app starts func (a *App) startup(ctx context.Context) { a.ctx = ctx + runtime.LogInfo(a.ctx, "OnStartup: initializing") // Initialize PortAudio if err := audio.Initialize(); err != nil { - fmt.Printf("Warning: Failed to initialize audio: %v\n", err) + runtime.LogWarning(a.ctx, fmt.Sprintf("Failed to initialize audio: %v", err)) } // Initialize native sounds if err := sounds.Init(); err != nil { - fmt.Printf("Warning: Failed to initialize sounds: %v\n", err) + runtime.LogWarning(a.ctx, fmt.Sprintf("Failed to initialize sounds: %v", err)) } else { - fmt.Println("Native sounds initialized successfully") + runtime.LogInfo(a.ctx, "Native sounds initialized successfully") } // Initialize config manager configManager, err := models.NewConfigManager() if err != nil { - fmt.Printf("Warning: Failed to initialize config: %v\n", err) + runtime.LogWarning(a.ctx, fmt.Sprintf("Failed to initialize config: %v", err)) return } a.configManager = configManager @@ -149,20 +151,20 @@ func (a *App) startup(ctx context.Context) { // Initialize stats manager statsManager, err := models.NewStatsManager(configManager.GetConfigDir()) if err != nil { - fmt.Printf("Warning: Failed to initialize stats manager: %v\n", err) + runtime.LogWarning(a.ctx, fmt.Sprintf("Failed to initialize stats manager: %v", err)) } else { a.statsManager = statsManager } // Audio device preference is applied in StartRecording when recorder is created if deviceName := configManager.Get().AudioInputDevice; deviceName != "" { - fmt.Printf("Will use audio input device: %s\n", deviceName) + runtime.LogInfo(a.ctx, fmt.Sprintf("Will use audio input device: %s", deviceName)) } // Initialize model manager modelManager, err := models.NewManager(configManager.GetModelsDir()) if err != nil { - fmt.Printf("Warning: Failed to initialize model manager: %v\n", err) + runtime.LogWarning(a.ctx, fmt.Sprintf("Failed to initialize model manager: %v", err)) return } a.modelManager = modelManager @@ -176,11 +178,9 @@ func (a *App) startup(ctx context.Context) { // Check accessibility permissions without prompting. // Onboarding handles the explicit permission request UX. if !hotkey.HasAccessibilityPermissions() { - fmt.Println("WARNING: Accessibility permissions not granted!") - fmt.Println("Escape key cancel and auto-paste features will not work.") - fmt.Println("Please grant permissions in System Preferences > Privacy & Security > Accessibility") + runtime.LogWarning(a.ctx, "Accessibility permissions not granted; escape cancel and auto-paste may not work") } else { - fmt.Println("Accessibility permissions granted") + runtime.LogInfo(a.ctx, "Accessibility permissions granted") } // Apply configured hotkey type before registering @@ -189,30 +189,37 @@ func (a *App) startup(ctx context.Context) { hotkeyType = models.DefaultRecordingHotkey() } a.hotkeyManager.SetHotkeyType(hotkeyType) - + // Apply configured cancel key cancelKey := configManager.Get().CancelHotkey if cancelKey == "" { cancelKey = "escape" } a.hotkeyManager.SetCancelKey(cancelKey) - + // Register global hotkey if err := a.hotkeyManager.Register(func() { a.ToggleRecording() }); err != nil { - fmt.Printf("Warning: Failed to register hotkey: %v\n", err) + runtime.LogWarning(a.ctx, fmt.Sprintf("Failed to register hotkey: %v", err)) } else { a.hotkeyEnabled = true - fmt.Printf("Global hotkey registered: %s\n", hotkey.GetHotkeyDisplayName(hotkeyType)) + runtime.LogInfo(a.ctx, fmt.Sprintf("Global hotkey registered: %s", hotkey.GetHotkeyDisplayName(hotkeyType))) } // Load history from disk a.loadHistory() + runtime.LogInfo(a.ctx, "OnStartup: complete") +} + +// domReady is called when the frontend has loaded its DOM. +func (a *App) domReady(ctx context.Context) { + runtime.LogInfo(ctx, "OnDomReady: frontend loaded") } // shutdown is called when the app closes func (a *App) shutdown(ctx context.Context) { + runtime.LogInfo(ctx, "OnShutdown: starting cleanup") if a.hotkeyManager != nil { a.hotkeyManager.Unregister() } @@ -221,6 +228,7 @@ func (a *App) shutdown(ctx context.Context) { } audio.Terminate() sounds.Cleanup() + runtime.LogInfo(ctx, "OnShutdown: cleanup complete") } // GetState returns the current app state @@ -293,14 +301,14 @@ func (a *App) loadHistory() { var history []HistoryItem if err := json.Unmarshal(data, &history); err != nil { - fmt.Printf("Warning: Failed to parse history: %v\n", err) + runtime.LogWarning(a.ctx, fmt.Sprintf("Failed to parse history: %v", err)) return } a.mu.Lock() a.history = history a.mu.Unlock() - fmt.Printf("Loaded %d history items\n", len(history)) + runtime.LogInfo(a.ctx, fmt.Sprintf("Loaded %d history items", len(history))) } // saveHistory saves history to disk @@ -315,12 +323,12 @@ func (a *App) saveHistory() { a.mu.Unlock() if err != nil { - fmt.Printf("Warning: Failed to serialize history: %v\n", err) + runtime.LogWarning(a.ctx, fmt.Sprintf("Failed to serialize history: %v", err)) return } if err := os.WriteFile(path, data, 0644); err != nil { - fmt.Printf("Warning: Failed to save history: %v\n", err) + runtime.LogWarning(a.ctx, fmt.Sprintf("Failed to save history: %v", err)) } } @@ -328,7 +336,7 @@ func (a *App) saveHistory() { func (a *App) CopyHistoryItem(id string) error { a.mu.Lock() defer a.mu.Unlock() - + for _, item := range a.history { if item.ID == id { return system.CopyToClipboard(item.Text) @@ -340,7 +348,7 @@ func (a *App) CopyHistoryItem(id string) error { // DeleteHistoryItem deletes a history item by ID func (a *App) DeleteHistoryItem(id string) error { a.mu.Lock() - + // Find and remove the item var audioPath string found := false @@ -353,18 +361,18 @@ func (a *App) DeleteHistoryItem(id string) error { } } a.mu.Unlock() - + if !found { return fmt.Errorf("history item not found") } - + // Delete the audio file if it exists if audioPath != "" { if err := os.Remove(audioPath); err != nil && !os.IsNotExist(err) { - fmt.Printf("Warning: Failed to delete audio file: %v\n", err) + runtime.LogWarning(a.ctx, fmt.Sprintf("Failed to delete audio file: %v", err)) } } - + a.saveHistory() runtime.EventsEmit(a.ctx, "historyChanged", a.history) return nil @@ -400,18 +408,18 @@ func (a *App) ShowInFolder(id string) error { func (a *App) GetAudioData(id string) (string, error) { a.mu.Lock() defer a.mu.Unlock() - + for _, item := range a.history { if item.ID == id { if !item.HasAudio || item.AudioPath == "" { return "", fmt.Errorf("no audio available for this item") } - + data, err := audio.LoadWAV(item.AudioPath) if err != nil { return "", fmt.Errorf("failed to load audio: %v", err) } - + // Return as base64 return base64.StdEncoding.EncodeToString(data), nil } @@ -434,32 +442,32 @@ func (a *App) ToggleRecording() error { // StartRecording begins audio capture func (a *App) StartRecording() error { runtime.LogInfo(a.ctx, "StartRecording called") - fmt.Println("StartRecording: entering function") - + runtime.LogDebug(a.ctx, "StartRecording: entering function") + // Save the current frontmost app before we do anything (for auto-paste later) system.SaveFrontmostApp() - + a.mu.Lock() - if a.state != StateReady { + if a.state != StateReady && a.state != StateError { a.mu.Unlock() runtime.LogWarning(a.ctx, fmt.Sprintf("Cannot start recording in state: %s", a.state)) return fmt.Errorf("cannot start recording in state: %s", a.state) } - + // Check if sound is enabled soundEnabled := a.configManager != nil && (a.configManager.Get().SoundEnabled == nil || *a.configManager.Get().SoundEnabled) - fmt.Printf("StartRecording: soundEnabled=%v\n", soundEnabled) - + runtime.LogDebug(a.ctx, fmt.Sprintf("StartRecording: soundEnabled=%v", soundEnabled)) + // Set state to recording first a.state = StateRecording a.lastError = "" a.recordStartTime = time.Now() onTrayUpdate := a.onTrayUpdate a.mu.Unlock() - + // Play start sound (non-blocking) - afplay goes to speakers, not mic input if soundEnabled { - fmt.Println("StartRecording: playing start sound") + runtime.LogDebug(a.ctx, "StartRecording: playing start sound") sounds.PlayStart() } @@ -474,7 +482,7 @@ func (a *App) StartRecording() error { // Create fresh recorder for each recording session a.recorder = audio.NewRecorder() - + // Set audio device from config if available if a.configManager != nil { config := a.configManager.Get() @@ -499,9 +507,9 @@ func (a *App) StartRecording() error { a.overlay.Show() // Enable cancel key to cancel recording (native/global) - fmt.Println("DEBUG: Enabling cancel key") + runtime.LogDebug(a.ctx, "Enabling cancel key") a.hotkeyManager.EnableCancelKey(func() { - fmt.Println("DEBUG: Cancel key callback triggered!") + runtime.LogDebug(a.ctx, "Cancel key callback triggered") a.CancelRecording() }) @@ -572,7 +580,6 @@ func (a *App) CancelRecording() error { a.emitState() - return nil } @@ -612,11 +619,11 @@ func (a *App) transcribe(samples []float32, duration float64) { if audioDir != "" { audioPath = filepath.Join(audioDir, recordingID+".wav") if saveErr := audio.SaveWAV(audioPath, samples); saveErr != nil { - fmt.Printf("Warning: Failed to save audio: %v\n", saveErr) + runtime.LogWarning(a.ctx, fmt.Sprintf("Failed to save audio: %v", saveErr)) audioPath = "" } else { hasAudio = true - fmt.Printf("Saved audio to: %s\n", audioPath) + runtime.LogInfo(a.ctx, fmt.Sprintf("Saved audio to: %s", audioPath)) } } @@ -639,7 +646,7 @@ func (a *App) transcribe(samples []float32, duration float64) { HasAudio: hasAudio, } a.history = append([]HistoryItem{historyItem}, a.history...) - + // Keep only last 50 items if len(a.history) > 50 { // Delete audio files for items being removed @@ -660,21 +667,21 @@ func (a *App) transcribe(samples []float32, duration float64) { } // Copy to clipboard and optionally paste - fmt.Printf("DEBUG: AutoPaste=%v, text length=%d\n", config.AutoPaste, len(text)) + runtime.LogDebug(a.ctx, fmt.Sprintf("AutoPaste=%v, text length=%d", config.AutoPaste, len(text))) if config.AutoPaste { go func(textToPaste string) { // Wait for the overlay to hide and the previous app to regain focus - fmt.Println("DEBUG: Waiting 500ms before paste...") + runtime.LogDebug(a.ctx, "Waiting 500ms before paste") time.Sleep(500 * time.Millisecond) - fmt.Println("DEBUG: Calling CopyAndPaste now") + runtime.LogDebug(a.ctx, "Calling CopyAndPaste now") if err := system.CopyAndPaste(textToPaste); err != nil { - fmt.Printf("Failed to paste: %v\n", err) + runtime.LogWarning(a.ctx, fmt.Sprintf("Failed to paste: %v", err)) } else { - fmt.Println("DEBUG: CopyAndPaste succeeded") + runtime.LogDebug(a.ctx, "CopyAndPaste succeeded") } }(text) } else { - fmt.Println("DEBUG: AutoPaste disabled, only copying to clipboard") + runtime.LogDebug(a.ctx, "AutoPaste disabled, only copying to clipboard") go system.CopyToClipboard(text) } } @@ -812,7 +819,7 @@ func (a *App) GetStats() UsageStats { if a.statsManager == nil { return UsageStats{} } - + stats := a.statsManager.Get() return UsageStats{ AverageWPM: a.statsManager.GetAverageWPM(), @@ -831,12 +838,12 @@ func (a *App) SetRecordingHotkey(keyName string) error { if keyName == "" { return fmt.Errorf("hotkey cannot be empty") } - + // Update the hotkey manager if a.hotkeyManager != nil { a.hotkeyManager.SetHotkeyType(keyName) } - + // Save to config return a.configManager.SetRecordingHotkey(keyName) } @@ -860,12 +867,12 @@ func (a *App) SetCancelHotkey(keyName string) error { if keyName == "" { return fmt.Errorf("cancel hotkey cannot be empty") } - + // Update the hotkey manager if a.hotkeyManager != nil { a.hotkeyManager.SetCancelKey(keyName) } - + // Save to config return a.configManager.SetCancelHotkey(keyName) } @@ -887,6 +894,11 @@ func (a *App) GetPlatform() string { return goruntime.GOOS } +// GetLogsDir returns the directory where debug logs are written. +func (a *App) GetLogsDir() string { + return logger.GetLogsDir() +} + // GetRecordingHotkeyDisplayName returns the display name for the current hotkey func (a *App) GetRecordingHotkeyDisplayName() string { return hotkey.GetHotkeyDisplayName(a.GetRecordingHotkey()) @@ -908,7 +920,7 @@ func (a *App) RequestAccessibilityPermission() bool { if granted { // Re-register hotkey now that we have permissions if err := a.ReregisterHotkey(); err != nil { - fmt.Printf("Warning: Failed to re-register hotkey after permission grant: %v\n", err) + runtime.LogWarning(a.ctx, fmt.Sprintf("Failed to re-register hotkey after permission grant: %v", err)) } } return granted @@ -920,23 +932,23 @@ func (a *App) ReregisterHotkey() error { if a.hotkeyManager == nil { return fmt.Errorf("hotkey manager not initialized") } - + // Unregister current hotkey a.hotkeyManager.Unregister() - + // Re-apply hotkey settings hotkeyType := a.configManager.Get().RecordingHotkey if hotkeyType == "" { hotkeyType = models.DefaultRecordingHotkey() } a.hotkeyManager.SetHotkeyType(hotkeyType) - + cancelKey := a.configManager.Get().CancelHotkey if cancelKey == "" { cancelKey = "escape" } a.hotkeyManager.SetCancelKey(cancelKey) - + // Register the hotkey if err := a.hotkeyManager.Register(func() { a.ToggleRecording() @@ -944,9 +956,9 @@ func (a *App) ReregisterHotkey() error { a.hotkeyEnabled = false return fmt.Errorf("failed to register hotkey: %w", err) } - + a.hotkeyEnabled = true - fmt.Printf("Hotkey re-registered: %s\n", hotkey.GetHotkeyDisplayName(hotkeyType)) + runtime.LogInfo(a.ctx, fmt.Sprintf("Hotkey re-registered: %s", hotkey.GetHotkeyDisplayName(hotkeyType))) return nil } diff --git a/build/darwin/Info.dev.plist b/build/darwin/Info.dev.plist index 14121ef..f9178dc 100644 --- a/build/darwin/Info.dev.plist +++ b/build/darwin/Info.dev.plist @@ -23,6 +23,8 @@ true NSHumanReadableCopyright {{.Info.Copyright}} + NSMicrophoneUsageDescription + Yap needs microphone access to record your voice for speech-to-text transcription. {{if .Info.FileAssociations}} CFBundleDocumentTypes diff --git a/frontend/package-lock.json b/frontend/package-lock.json index efb3030..0e645d8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "0.2.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "0.2.0", + "version": "0.4.0", "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0" diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index ebfbdac..5ddc60b 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -a93b9b9ee0731e091d302c100808c109 \ No newline at end of file +5ed087423043b58e5b2fc9dd9c3156b1 \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e36cd00..60e3b84 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -33,7 +33,7 @@ import { Quit, IsOnboardingCompleted, } from '../wailsjs/go/main/App'; -import { EventsOn, LogInfo } from '../wailsjs/runtime/runtime'; +import { EventsOn, LogDebug, LogError, LogInfo } from '../wailsjs/runtime/runtime'; interface AppState { state: string; @@ -96,6 +96,10 @@ interface UsageStats { type Page = 'home' | 'settings' | 'history'; function App() { + useEffect(() => { + LogInfo('[frontend] App component mounted'); + }, []); + const [appState, setAppState] = useState({ state: 'ready', recordingTime: 0, @@ -144,6 +148,7 @@ function App() { }, []); useEffect(() => { + LogInfo('[frontend] App initial data load started'); GetState().then((state: AppState) => setAppState(state)); GetModels().then((models: ModelInfo[]) => setModels(models)); GetConfig().then((cfg: Config) => { @@ -156,13 +161,14 @@ function App() { GetStats().then((s: UsageStats) => setStats(s)); GetRecordingHotkey().then((h: string) => setCurrentHotkey(h)); GetCancelHotkey().then((h: string) => setCancelHotkey(h)); + LogInfo('[frontend] App initial data load requested'); - LogInfo('Setting up EventsOn for stateChanged'); + LogInfo('[frontend] Setting up EventsOn for stateChanged'); const cleanup = EventsOn('stateChanged', (state: AppState) => { - LogInfo('stateChanged event received: ' + state.state); + LogInfo('[frontend] stateChanged event received: ' + state.state); setAppState(state); }); - LogInfo('EventsOn setup complete'); + LogInfo('[frontend] EventsOn setup complete'); EventsOn('historyChanged', (h: HistoryItem[]) => { setHistory(h); if (h.length > 0 && !selectedHistory) { @@ -223,11 +229,11 @@ function App() { } // Sound playback is handled in Go before recording starts await ToggleRecording(); - } catch (err) { console.error(err); } + } catch (err) { LogError(`[frontend] ToggleRecording failed: ${err}`); } }, [appState.state]); const handleCancelRecording = useCallback(async () => { - try { await CancelRecording(); } catch (err) { console.error(err); } + try { await CancelRecording(); } catch (err) { LogError(`[frontend] CancelRecording failed: ${err}`); } }, []); // Global escape key handler - always active at app level @@ -253,7 +259,7 @@ function App() { }, [audioSource]); const playAudio = useCallback(async (id: string) => { - console.log('playAudio called with id:', id); + LogDebug(`[frontend] playAudio called with id: ${id}`); // Stop any currently playing audio - wrap in try-catch to handle already-stopped sources if (audioSource) { @@ -267,9 +273,9 @@ function App() { try { // Get audio data as base64 - console.log('Fetching audio data...'); + LogDebug('[frontend] Fetching audio data'); const base64Data = await GetAudioData(id); - console.log('Got audio data, length:', base64Data.length); + LogDebug(`[frontend] Got audio data, length: ${base64Data.length}`); // Decode base64 to binary const binaryString = atob(base64Data); @@ -277,7 +283,7 @@ function App() { for (let i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i); } - console.log('Decoded bytes:', bytes.length); + LogDebug(`[frontend] Decoded bytes: ${bytes.length}`); // Create or reuse AudioContext (recreate if closed) let ctx = audioContext; @@ -285,7 +291,7 @@ function App() { ctx = new AudioContext(); setAudioContext(ctx); } - console.log('AudioContext state:', ctx.state); + LogDebug(`[frontend] AudioContext state: ${ctx.state}`); // Resume if suspended (needed for some browsers) if (ctx.state === 'suspended') { @@ -293,9 +299,9 @@ function App() { } // Decode audio data - console.log('Decoding audio buffer...'); + LogDebug('[frontend] Decoding audio buffer'); const audioBuffer = await ctx.decodeAudioData(bytes.buffer); - console.log('Audio buffer decoded, duration:', audioBuffer.duration); + LogDebug(`[frontend] Audio buffer decoded, duration: ${audioBuffer.duration}`); // Create and play source const source = ctx.createBufferSource(); @@ -306,12 +312,12 @@ function App() { setAudioSource(null); }; source.start(); - console.log('Audio playback started'); + LogDebug('[frontend] Audio playback started'); setAudioSource(source); setIsPlaying(true); } catch (err) { - console.error('Failed to play audio:', err); + LogError(`[frontend] Failed to play audio: ${err}`); setIsPlaying(false); } }, [audioContext, audioSource]); @@ -355,7 +361,7 @@ function App() { try { await SetRecordingHotkey(keyName); } catch (error) { - console.error('Failed to set hotkey:', error); + LogError(`[frontend] Failed to set hotkey: ${error}`); } }, []); @@ -364,7 +370,7 @@ function App() { try { await SetCancelHotkey(keyName); } catch (error) { - console.error('Failed to set cancel key:', error); + LogError(`[frontend] Failed to set cancel key: ${error}`); } }, []); diff --git a/frontend/src/Onboarding.tsx b/frontend/src/Onboarding.tsx index 91f2646..a4d2f60 100644 --- a/frontend/src/Onboarding.tsx +++ b/frontend/src/Onboarding.tsx @@ -16,7 +16,7 @@ import { GetRecordingHotkeyDisplayName, GetPlatform, } from '../wailsjs/go/main/App'; -import { EventsOn, EventsOff } from '../wailsjs/runtime/runtime'; +import { EventsOn, LogError, LogInfo } from '../wailsjs/runtime/runtime'; interface ModelInfo { name: string; @@ -49,6 +49,10 @@ type Provider = 'local' | 'openai'; type HotkeyTestState = 'waiting' | 'recording' | 'success'; export function Onboarding({ onComplete }: OnboardingProps) { + useEffect(() => { + LogInfo('[frontend] Onboarding component mounted'); + }, []); + const [step, setStep] = useState('welcome'); const [models, setModels] = useState([]); const [selectedModel, setSelectedModel] = useState('base.en'); @@ -63,6 +67,7 @@ export function Onboarding({ onComplete }: OnboardingProps) { const [platform, setPlatform] = useState('darwin'); const [hotkeyTestState, setHotkeyTestState] = useState('waiting'); const [testTranscript, setTestTranscript] = useState(''); + const [hotkeyTestError, setHotkeyTestError] = useState(''); const hotkeyTestStateRef = useRef('waiting'); useEffect(() => { @@ -107,14 +112,14 @@ export function Onboarding({ onComplete }: OnboardingProps) { setDownloadError(data.error); }; - EventsOn('downloadProgress', progressHandler); - EventsOn('downloadComplete', completeHandler); - EventsOn('downloadError', errorHandler); + const cleanupProgress = EventsOn('downloadProgress', progressHandler); + const cleanupComplete = EventsOn('downloadComplete', completeHandler); + const cleanupError = EventsOn('downloadError', errorHandler); return () => { - EventsOff('downloadProgress'); - EventsOff('downloadComplete'); - EventsOff('downloadError'); + cleanupProgress(); + cleanupComplete(); + cleanupError(); }; }, []); @@ -218,29 +223,33 @@ export function Onboarding({ onComplete }: OnboardingProps) { // Reset test state when entering this step setHotkeyTestState('waiting'); setTestTranscript(''); + setHotkeyTestError(''); hotkeyTestStateRef.current = 'waiting'; // Make sure hotkey is registered - ReregisterHotkey().catch(console.error); + ReregisterHotkey().catch((err) => LogError(`[frontend] ReregisterHotkey failed: ${err}`)); const stateHandler = (state: AppState) => { if (state.state === 'recording' && hotkeyTestStateRef.current === 'waiting') { setHotkeyTestState('recording'); + setHotkeyTestError(''); hotkeyTestStateRef.current = 'recording'; - } else if (state.state === 'ready' && hotkeyTestStateRef.current === 'recording') { + } else if (state.state === 'transcribing' && hotkeyTestStateRef.current === 'recording') { setHotkeyTestState('success'); hotkeyTestStateRef.current = 'success'; - // Capture the transcript from the test recording + } else if (state.state === 'ready' && hotkeyTestStateRef.current === 'success') { if (state.lastTranscript) { setTestTranscript(state.lastTranscript); } + } else if (state.state === 'error' && hotkeyTestStateRef.current === 'recording') { + setHotkeyTestState('waiting'); + setHotkeyTestError(state.error || 'Recording failed. Check microphone permission and try again.'); + hotkeyTestStateRef.current = 'waiting'; } }; - EventsOn('stateChanged', stateHandler); - return () => { - EventsOff('stateChanged'); - }; + const cleanup = EventsOn('stateChanged', stateHandler); + return cleanup; } }, [step]); @@ -767,7 +776,7 @@ export function Onboarding({ onComplete }: OnboardingProps) { {hotkeyTestState === 'waiting' && (
-

Having trouble? Make sure you granted Accessibility permission.

+

{hotkeyTestError || 'Having trouble? Make sure you granted Accessibility permission.'}

)} diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 8b0b562..d3cd589 100755 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -31,6 +31,8 @@ export function GetCurrentAudioInputDevice():Promise; export function GetHistory():Promise>; +export function GetLogsDir():Promise; + export function GetModels():Promise>; export function GetPlatform():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index e4a5b9d..8b74b60 100755 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -58,6 +58,10 @@ export function GetHistory() { return window['go']['main']['App']['GetHistory'](); } +export function GetLogsDir() { + return window['go']['main']['App']['GetLogsDir'](); +} + export function GetModels() { return window['go']['main']['App']['GetModels'](); } diff --git a/internal/audio/recorder.go b/internal/audio/recorder.go index c8c3b22..195e300 100644 --- a/internal/audio/recorder.go +++ b/internal/audio/recorder.go @@ -8,6 +8,8 @@ import ( "sync" "time" + "yap/internal/logger" + "github.com/gordonklaus/portaudio" ) @@ -30,10 +32,10 @@ type Recorder struct { mu sync.Mutex isRecording bool startTime time.Time - deviceName string // Empty string means use system default - levelCallback func(float32) // Callback for audio level updates - levelChan chan float32 // Channel for level updates - stopLevelChan chan struct{} // Signal to stop level goroutine + deviceName string // Empty string means use system default + levelCallback func(float32) // Callback for audio level updates + levelChan chan float32 // Channel for level updates + stopLevelChan chan struct{} // Signal to stop level goroutine } // NewRecorder creates a new audio recorder @@ -149,7 +151,7 @@ func (r *Recorder) Start() error { device, findErr := findDeviceByName(r.deviceName) if findErr != nil { // Device not found, fall back to default - fmt.Printf("Warning: Device '%s' not found, using default\n", r.deviceName) + logger.Warning(fmt.Sprintf("Device %q not found, using default", r.deviceName)) stream, err = portaudio.OpenDefaultStream( Channels, 0, SampleRate, FrameSize, inputBuffer, ) @@ -180,7 +182,7 @@ func (r *Recorder) Start() error { r.stream = stream r.isRecording = true r.startTime = time.Now() - + // Initialize level channels r.levelChan = make(chan float32, 1) // Buffered, drop old values r.stopLevelChan = make(chan struct{}) @@ -193,7 +195,7 @@ func (r *Recorder) Start() error { // Start a goroutine to process level updates (prevents goroutine accumulation) go r.levelLoop() - + // Start a goroutine to read audio data go r.readLoop(inputBuffer) @@ -204,9 +206,9 @@ func (r *Recorder) Start() error { func (r *Recorder) levelLoop() { ticker := time.NewTicker(33 * time.Millisecond) // ~30fps defer ticker.Stop() - + var lastLevel float32 - + for { select { case <-r.stopLevelChan: @@ -270,7 +272,7 @@ func (r *Recorder) readLoop(inputBuffer []float32) { r.mu.Lock() levelChan = r.levelChan r.mu.Unlock() - + if levelChan != nil { select { case levelChan <- level: @@ -284,18 +286,18 @@ func (r *Recorder) readLoop(inputBuffer []float32) { // Stop ends recording and returns the recorded audio as float32 samples func (r *Recorder) Stop() ([]float32, error) { r.mu.Lock() - + if !r.isRecording { r.mu.Unlock() return nil, fmt.Errorf("not recording") } r.isRecording = false - + // Stop level loop first (this will exit the levelLoop goroutine) stopChan := r.stopLevelChan r.stopLevelChan = nil - + // Clear level channel reference (don't close - readLoop might still send) r.levelChan = nil r.levelCallback = nil @@ -303,27 +305,27 @@ func (r *Recorder) Stop() ([]float32, error) { // Stop and close stream stream := r.stream r.stream = nil - + // Return a copy of the buffer result := make([]float32, len(r.buffer)) copy(result, r.buffer) - + // Clear buffer immediately r.buffer = nil - + r.mu.Unlock() // Close stop channel outside lock to signal levelLoop to exit if stopChan != nil { close(stopChan) } - + // Stop and close PortAudio stream outside lock if stream != nil { stream.Stop() stream.Close() } - + return result, nil } @@ -363,7 +365,7 @@ func ToWAV(samples []float32) ([]byte, error) { // WAV header dataSize := uint32(len(int16Samples) * 2) // 2 bytes per sample - fileSize := dataSize + 36 // Header is 44 bytes, minus 8 for RIFF header + fileSize := dataSize + 36 // Header is 44 bytes, minus 8 for RIFF header // RIFF header buf.WriteString("RIFF") @@ -372,8 +374,8 @@ func ToWAV(samples []float32) ([]byte, error) { // fmt chunk buf.WriteString("fmt ") - binary.Write(buf, binary.LittleEndian, uint32(16)) // Chunk size - binary.Write(buf, binary.LittleEndian, uint16(1)) // Audio format (PCM) + binary.Write(buf, binary.LittleEndian, uint32(16)) // Chunk size + binary.Write(buf, binary.LittleEndian, uint16(1)) // Audio format (PCM) binary.Write(buf, binary.LittleEndian, uint16(Channels)) binary.Write(buf, binary.LittleEndian, uint32(SampleRate)) binary.Write(buf, binary.LittleEndian, uint32(SampleRate*Channels*2)) // Byte rate diff --git a/internal/hotkey/hotkey_darwin.go b/internal/hotkey/hotkey_darwin.go index d77b11d..9bdd19b 100644 --- a/internal/hotkey/hotkey_darwin.go +++ b/internal/hotkey/hotkey_darwin.go @@ -133,19 +133,19 @@ static void stopAllMonitoring(void) { static void startMonitoring(void) { stopAllMonitoring(); - + // Check accessibility permissions first if (!hasAccessibilityPermissions()) { NSLog(@"Cannot start monitoring without accessibility permissions"); return; } - + // Monitor for modifier keys (flagsChanged events) gEventMonitor = [NSEvent addGlobalMonitorForEventsMatchingMask:NSEventMaskFlagsChanged handler:^(NSEvent *event) { UInt16 keyCode = [event keyCode]; NSEventModifierFlags flags = [event modifierFlags]; - + // Check hotkey (if it's a modifier) if (gHotkeyIsModifier && keyCode == gCurrentHotkeyCode) { NSEventModifierFlags modFlag = getModifierFlag(keyCode); @@ -158,7 +158,7 @@ static void startMonitoring(void) { gHotkeyKeyDown = NO; } } - + // Check cancel key (if it's a modifier) if (gCancelKeyEnabled && gCancelIsModifier && keyCode == gCurrentCancelCode) { NSEventModifierFlags modFlag = getModifierFlag(keyCode); @@ -167,43 +167,43 @@ static void startMonitoring(void) { } } }]; - + // Monitor for regular keys (keyDown events) gKeyEventMonitor = [NSEvent addGlobalMonitorForEventsMatchingMask:NSEventMaskKeyDown handler:^(NSEvent *event) { UInt16 keyCode = [event keyCode]; - + // Check hotkey (if it's NOT a modifier) if (!gHotkeyIsModifier && keyCode == gCurrentHotkeyCode) { goHotkeyPressed(); } - + // Check cancel key (if it's NOT a modifier) if (gCancelKeyEnabled && !gCancelIsModifier && keyCode == gCurrentCancelCode) { goCancelPressed(); } }]; - + // Local monitor for regular keys when this app has focus gLocalKeyEventMonitor = [NSEvent addLocalMonitorForEventsMatchingMask:NSEventMaskKeyDown handler:^NSEvent *(NSEvent *event) { UInt16 keyCode = [event keyCode]; - + // Check hotkey (if it's NOT a modifier) if (!gHotkeyIsModifier && keyCode == gCurrentHotkeyCode) { goHotkeyPressed(); return nil; // Consume event } - + // Check cancel key (if it's NOT a modifier) if (gCancelKeyEnabled && !gCancelIsModifier && keyCode == gCurrentCancelCode) { goCancelPressed(); return nil; // Consume event } - + return event; }]; - + // Also add local monitor for modifier keys (flagsChanged) when app has focus // This ensures modifier hotkeys work even when the app window is focused if (gLocalFlagsMonitor != nil) { @@ -214,7 +214,7 @@ static void startMonitoring(void) { handler:^NSEvent *(NSEvent *event) { UInt16 keyCode = [event keyCode]; NSEventModifierFlags flags = [event modifierFlags]; - + // Check hotkey (if it's a modifier) if (gHotkeyIsModifier && keyCode == gCurrentHotkeyCode) { NSEventModifierFlags modFlag = getModifierFlag(keyCode); @@ -227,7 +227,7 @@ static void startMonitoring(void) { gHotkeyKeyDown = NO; } } - + // Check cancel key (if it's a modifier) if (gCancelKeyEnabled && gCancelIsModifier && keyCode == gCurrentCancelCode) { NSEventModifierFlags modFlag = getModifierFlag(keyCode); @@ -235,10 +235,10 @@ static void startMonitoring(void) { goCancelPressed(); } } - + return event; }]; - + NSLog(@"Key monitoring started - hotkey: %d, cancel: %d", gCurrentHotkeyCode, gCurrentCancelCode); } @@ -271,6 +271,8 @@ import ( "fmt" "strings" "sync" + + "yap/internal/logger" ) var ( @@ -305,11 +307,11 @@ type Callback func() // Manager handles global hotkey registration type Manager struct { - mu sync.Mutex - running bool - stopC chan struct{} - hotkeyStr string - cancelStr string + mu sync.Mutex + running bool + stopC chan struct{} + hotkeyStr string + cancelStr string } // NewManager creates a new hotkey manager @@ -336,7 +338,7 @@ func (m *Manager) Register(cb Callback) error { // Set the hotkey code keyCode := KeyNameToCode(m.hotkeyStr) C.setHotkeyCode(C.UInt16(keyCode)) - + // Set the cancel key code cancelCode := KeyNameToCode(m.cancelStr) C.setCancelCode(C.UInt16(cancelCode)) @@ -386,28 +388,28 @@ func (m *Manager) Unregister() error { func (m *Manager) SetHotkeyType(hotkeyName string) { m.mu.Lock() defer m.mu.Unlock() - + m.hotkeyStr = strings.ToLower(hotkeyName) keyCode := KeyNameToCode(m.hotkeyStr) C.setHotkeyCode(C.UInt16(keyCode)) - + if m.running { C.startMonitoring() } - - fmt.Printf("Hotkey set to: %s (code: %d)\n", m.hotkeyStr, keyCode) + + logger.Info(fmt.Sprintf("Hotkey set to: %s (code: %d)", m.hotkeyStr, keyCode)) } // SetCancelKey sets the cancel hotkey by name func (m *Manager) SetCancelKey(keyName string) { m.mu.Lock() defer m.mu.Unlock() - + m.cancelStr = strings.ToLower(keyName) cancelCode := KeyNameToCode(m.cancelStr) C.setCancelCode(C.UInt16(cancelCode)) - - fmt.Printf("Cancel key set to: %s (code: %d)\n", m.cancelStr, cancelCode) + + logger.Info(fmt.Sprintf("Cancel key set to: %s (code: %d)", m.cancelStr, cancelCode)) } // IsRegistered returns whether hotkey is registered @@ -422,14 +424,14 @@ func (m *Manager) EnableCancelKey(cb func()) { cancelCallbackMu.Lock() cancelCallback = cb cancelCallbackMu.Unlock() - + C.enableCancelKey() } // DisableCancelKey stops monitoring for the cancel key func (m *Manager) DisableCancelKey() { C.disableCancelKey() - + cancelCallbackMu.Lock() cancelCallback = nil cancelCallbackMu.Unlock() @@ -474,7 +476,7 @@ func KeyNameToCode(name string) uint16 { return 0x3F case "capslock": return 0x39 - + // Special keys case "escape", "esc": return 0x35 @@ -488,7 +490,7 @@ func KeyNameToCode(name string) uint16 { return 0x33 case "forwarddelete": return 0x75 - + // Arrow keys case "left", "arrowleft": return 0x7B @@ -498,7 +500,7 @@ func KeyNameToCode(name string) uint16 { return 0x7E case "down", "arrowdown": return 0x7D - + // Function keys case "f1": return 0x7A @@ -524,7 +526,7 @@ func KeyNameToCode(name string) uint16 { return 0x67 case "f12": return 0x6F - + // Letter keys case "a": return 0x00 @@ -578,7 +580,7 @@ func KeyNameToCode(name string) uint16 { return 0x10 case "z": return 0x06 - + // Number keys case "0": return 0x1D @@ -600,7 +602,7 @@ func KeyNameToCode(name string) uint16 { return 0x1C case "9": return 0x19 - + default: return 0x3D // Default to right option } diff --git a/internal/hotkey/hotkey_linux.go b/internal/hotkey/hotkey_linux.go index 6cfb3d6..eb6db7c 100644 --- a/internal/hotkey/hotkey_linux.go +++ b/internal/hotkey/hotkey_linux.go @@ -23,7 +23,7 @@ extern void goCancelPressed(void); static int initDisplay(void) { if (display != NULL) return 1; - + display = XOpenDisplay(NULL); if (display == NULL) { fprintf(stderr, "Cannot open X display\n"); @@ -61,10 +61,10 @@ static void disableCancel(void) { static void startMonitoring(void) { if (display == NULL) return; running = 1; - + // Grab the hotkey XGrabKey(display, hotkeyCode, AnyModifier, root, True, GrabModeAsync, GrabModeAsync); - + // Also grab common modifier combinations XGrabKey(display, hotkeyCode, Mod2Mask, root, True, GrabModeAsync, GrabModeAsync); XGrabKey(display, hotkeyCode, LockMask, root, True, GrabModeAsync, GrabModeAsync); @@ -74,7 +74,7 @@ static void startMonitoring(void) { static void stopMonitoring(void) { if (display == NULL) return; running = 0; - + XUngrabKey(display, hotkeyCode, AnyModifier, root); XUngrabKey(display, hotkeyCode, Mod2Mask, root); XUngrabKey(display, hotkeyCode, LockMask, root); @@ -83,18 +83,18 @@ static void stopMonitoring(void) { static void processEvents(void) { if (display == NULL || !running) return; - + XEvent event; while (XPending(display) > 0) { XNextEvent(display, &event); - + if (event.type == KeyPress) { KeyCode keycode = event.xkey.keycode; - + if (keycode == hotkeyCode) { goHotkeyPressed(); } - + if (cancelEnabled && keycode == cancelCode) { goCancelPressed(); } @@ -114,6 +114,8 @@ import ( "strings" "sync" "time" + + "yap/internal/logger" ) // Callback is the function type for hotkey events @@ -132,10 +134,10 @@ type Manager struct { } var ( - callbackMu sync.Mutex - hotkeyCallback Callback + callbackMu sync.Mutex + hotkeyCallback Callback cancelCallbackMu sync.Mutex - cancelCallback func() + cancelCallback func() ) //export goHotkeyPressed @@ -187,7 +189,7 @@ func (m *Manager) Register(cb Callback) error { // Set the hotkey keysym := KeyNameToKeysym(m.hotkeyStr) C.setHotkeyKeysym(C.KeySym(keysym)) - + // Set the cancel key cancelKeysym := KeyNameToKeysym(m.cancelStr) C.setCancelKeysym(C.KeySym(cancelKeysym)) @@ -201,7 +203,7 @@ func (m *Manager) Register(cb Callback) error { go func() { ticker := time.NewTicker(10 * time.Millisecond) defer ticker.Stop() - + for { select { case <-m.stopCh: @@ -212,7 +214,7 @@ func (m *Manager) Register(cb Callback) error { } }() - fmt.Printf("Hotkey registered: %s\n", m.hotkeyStr) + logger.Info(fmt.Sprintf("Hotkey registered: %s", m.hotkeyStr)) return nil } @@ -237,10 +239,10 @@ func (m *Manager) Unregister() error { func (m *Manager) SetHotkeyType(hotkeyName string) { m.mu.Lock() defer m.mu.Unlock() - + m.hotkeyStr = strings.ToLower(hotkeyName) keysym := KeyNameToKeysym(m.hotkeyStr) - + if m.running { C.stopMonitoring() C.setHotkeyKeysym(C.KeySym(keysym)) @@ -248,20 +250,20 @@ func (m *Manager) SetHotkeyType(hotkeyName string) { } else { C.setHotkeyKeysym(C.KeySym(keysym)) } - - fmt.Printf("Hotkey set to: %s\n", m.hotkeyStr) + + logger.Info(fmt.Sprintf("Hotkey set to: %s", m.hotkeyStr)) } // SetCancelKey sets the cancel hotkey by name func (m *Manager) SetCancelKey(keyName string) { m.mu.Lock() defer m.mu.Unlock() - + m.cancelStr = strings.ToLower(keyName) keysym := KeyNameToKeysym(m.cancelStr) C.setCancelKeysym(C.KeySym(keysym)) - - fmt.Printf("Cancel key set to: %s\n", m.cancelStr) + + logger.Info(fmt.Sprintf("Cancel key set to: %s", m.cancelStr)) } // IsRegistered returns whether hotkey is registered @@ -276,23 +278,23 @@ func (m *Manager) EnableCancelKey(cb func()) { cancelCallbackMu.Lock() cancelCallback = cb cancelCallbackMu.Unlock() - + m.mu.Lock() m.cancelCallback = cb m.cancelEnabled = true m.mu.Unlock() - + C.enableCancel() } // DisableCancelKey stops monitoring for the cancel key func (m *Manager) DisableCancelKey() { C.disableCancel() - + cancelCallbackMu.Lock() cancelCallback = nil cancelCallbackMu.Unlock() - + m.mu.Lock() m.cancelEnabled = false m.cancelCallback = nil @@ -336,7 +338,7 @@ func KeyNameToKeysym(name string) uint64 { return 0xFFEB // XK_Super_L case "capslock": return 0xFFE5 // XK_Caps_Lock - + // Special keys case "escape", "esc": return 0xFF1B // XK_Escape @@ -350,7 +352,7 @@ func KeyNameToKeysym(name string) uint64 { return 0xFF08 // XK_BackSpace case "delete": return 0xFFFF // XK_Delete - + // Arrow keys case "left", "arrowleft": return 0xFF51 // XK_Left @@ -360,7 +362,7 @@ func KeyNameToKeysym(name string) uint64 { return 0xFF52 // XK_Up case "down", "arrowdown": return 0xFF54 // XK_Down - + // Function keys case "f1": return 0xFFBE @@ -386,7 +388,7 @@ func KeyNameToKeysym(name string) uint64 { return 0xFFC8 case "f12": return 0xFFC9 - + // Letter keys (lowercase) case "a": return 0x0061 @@ -440,7 +442,7 @@ func KeyNameToKeysym(name string) uint64 { return 0x0079 case "z": return 0x007A - + // Number keys case "0": return 0x0030 @@ -462,7 +464,7 @@ func KeyNameToKeysym(name string) uint64 { return 0x0038 case "9": return 0x0039 - + default: return 0xFFEA // Default to right alt } diff --git a/internal/hotkey/hotkey_other.go b/internal/hotkey/hotkey_other.go index a6dc62c..dd36d17 100644 --- a/internal/hotkey/hotkey_other.go +++ b/internal/hotkey/hotkey_other.go @@ -6,6 +6,8 @@ import ( "fmt" "strings" "sync" + + "yap/internal/logger" ) // Callback is called when the hotkey is pressed @@ -36,7 +38,7 @@ func (m *Manager) Register(callback Callback) error { m.running = true // Hotkeys not supported on this platform - fmt.Println("Warning: Hotkeys are not supported on this platform") + logger.Warning("Hotkeys are not supported on this platform") return nil } diff --git a/internal/hotkey/hotkey_windows.go b/internal/hotkey/hotkey_windows.go index 233f485..ee8ebaf 100644 --- a/internal/hotkey/hotkey_windows.go +++ b/internal/hotkey/hotkey_windows.go @@ -9,6 +9,8 @@ import ( "sync" "unsafe" + "yap/internal/logger" + "golang.org/x/sys/windows" ) @@ -199,7 +201,7 @@ func (m *Manager) Register(cb Callback) error { m.running = true m.mu.Unlock() - fmt.Printf("Keyboard hook installed, hotkey: %s (code: %d)\n", m.hotkeyStr, m.hotkeyCode) + logger.Info(fmt.Sprintf("Keyboard hook installed, hotkey: %s (code: %d)", m.hotkeyStr, m.hotkeyCode)) started <- nil // Message loop - required for the hook to work @@ -265,7 +267,7 @@ func (m *Manager) SetHotkeyType(hotkeyName string) { m.hotkeyStr = strings.ToLower(hotkeyName) m.hotkeyCode = KeyNameToCode(m.hotkeyStr) - fmt.Printf("Hotkey set to: %s (code: %d)\n", m.hotkeyStr, m.hotkeyCode) + logger.Info(fmt.Sprintf("Hotkey set to: %s (code: %d)", m.hotkeyStr, m.hotkeyCode)) } // SetCancelKey sets the cancel hotkey by name @@ -276,7 +278,7 @@ func (m *Manager) SetCancelKey(keyName string) { m.cancelStr = strings.ToLower(keyName) m.cancelCode = KeyNameToCode(m.cancelStr) - fmt.Printf("Cancel key set to: %s (code: %d)\n", m.cancelStr, m.cancelCode) + logger.Info(fmt.Sprintf("Cancel key set to: %s (code: %d)", m.cancelStr, m.cancelCode)) } // IsRegistered returns whether hotkey is registered diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..87d695b --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,168 @@ +package logger + +import ( + "fmt" + "io" + "os" + "path/filepath" + goruntime "runtime" + "sync" + "time" +) + +const retentionDays = 7 + +// FileLogger implements the Wails logger interface and writes logs to disk. +type FileLogger struct { + mu sync.Mutex + file *os.File + logsDir string + writer io.Writer +} + +var defaultLogger *FileLogger + +// Init creates the process-wide logger used by Wails and package-level callers. +func Init() error { + logger, err := New() + if err != nil { + return err + } + defaultLogger = logger + return nil +} + +// New creates a file logger under the user's Yap config directory. +func New() (*FileLogger, error) { + configDir, err := os.UserConfigDir() + if err != nil { + return nil, err + } + + logsDir := filepath.Join(configDir, "yap", "logs") + if err := os.MkdirAll(logsDir, 0755); err != nil { + return nil, err + } + + logPath := filepath.Join(logsDir, fmt.Sprintf("yap-%s.log", time.Now().Format("2006-01-02"))) + file, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return nil, err + } + + logger := &FileLogger{ + file: file, + logsDir: logsDir, + writer: io.MultiWriter(file, os.Stdout), + } + logger.cleanOldLogs(retentionDays) + logger.Info(fmt.Sprintf("=== Yap started === os=%s arch=%s logsDir=%s", goruntime.GOOS, goruntime.GOARCH, logsDir)) + + return logger, nil +} + +// GetDefault returns the process-wide logger if it has been initialized. +func GetDefault() *FileLogger { + return defaultLogger +} + +// GetLogsDir returns the logs directory path even before the logger is initialized. +func GetLogsDir() string { + if defaultLogger != nil { + return defaultLogger.logsDir + } + configDir, err := os.UserConfigDir() + if err != nil { + return "" + } + return filepath.Join(configDir, "yap", "logs") +} + +// Close flushes the shutdown marker and closes the active log file. +func (l *FileLogger) Close() error { + if l == nil || l.file == nil { + return nil + } + l.Info("=== Yap shutdown ===") + return l.file.Close() +} + +func (l *FileLogger) write(level, message string) { + if l == nil { + return + } + l.mu.Lock() + defer l.mu.Unlock() + + line := fmt.Sprintf("[%s] [%s] %s\n", time.Now().Format("2006-01-02T15:04:05.000"), level, Redact(message)) + _, _ = l.writer.Write([]byte(line)) +} + +func (l *FileLogger) cleanOldLogs(keepDays int) { + entries, err := os.ReadDir(l.logsDir) + if err != nil { + return + } + + cutoff := time.Now().AddDate(0, 0, -keepDays) + for _, entry := range entries { + if entry.IsDir() { + continue + } + info, err := entry.Info() + if err != nil || !info.ModTime().Before(cutoff) { + continue + } + _ = os.Remove(filepath.Join(l.logsDir, entry.Name())) + } +} + +func Print(message string) { + if defaultLogger != nil { + defaultLogger.Print(message) + } +} + +func Trace(message string) { + if defaultLogger != nil { + defaultLogger.Trace(message) + } +} + +func Debug(message string) { + if defaultLogger != nil { + defaultLogger.Debug(message) + } +} + +func Info(message string) { + if defaultLogger != nil { + defaultLogger.Info(message) + } +} + +func Warning(message string) { + if defaultLogger != nil { + defaultLogger.Warning(message) + } +} + +func Error(message string) { + if defaultLogger != nil { + defaultLogger.Error(message) + } +} + +func Fatal(message string) { + if defaultLogger != nil { + defaultLogger.Fatal(message) + } +} + +func (l *FileLogger) Print(message string) { l.write("PRINT", message) } +func (l *FileLogger) Trace(message string) { l.write("TRACE", message) } +func (l *FileLogger) Debug(message string) { l.write("DEBUG", message) } +func (l *FileLogger) Info(message string) { l.write("INFO", message) } +func (l *FileLogger) Warning(message string) { l.write("WARN", message) } +func (l *FileLogger) Error(message string) { l.write("ERROR", message) } +func (l *FileLogger) Fatal(message string) { l.write("FATAL", message) } diff --git a/internal/logger/redact.go b/internal/logger/redact.go new file mode 100644 index 0000000..6f4cd32 --- /dev/null +++ b/internal/logger/redact.go @@ -0,0 +1,17 @@ +package logger + +import "regexp" + +var sensitivePatterns = []*regexp.Regexp{ + regexp.MustCompile(`sk-proj-[A-Za-z0-9_-]{20,}`), + regexp.MustCompile(`sk-[A-Za-z0-9_-]{20,}`), + regexp.MustCompile(`Bearer\s+[A-Za-z0-9._-]{20,}`), +} + +// Redact removes sensitive values before messages are written to disk. +func Redact(message string) string { + for _, pattern := range sensitivePatterns { + message = pattern.ReplaceAllString(message, "[REDACTED]") + } + return message +} diff --git a/internal/sounds/sounds.go b/internal/sounds/sounds.go index b6272bd..c3eb836 100644 --- a/internal/sounds/sounds.go +++ b/internal/sounds/sounds.go @@ -6,6 +6,8 @@ import ( "os" "os/exec" "path/filepath" + + "yap/internal/logger" ) //go:embed start.mp3 stop.mp3 @@ -56,41 +58,41 @@ func Cleanup() { // PlayStart plays the recording start sound (non-blocking) func PlayStart() { if startSoundPath == "" { - fmt.Println("PlayStart: no sound path") + logger.Debug("PlayStart: no sound path") return } - fmt.Printf("PlayStart: playing %s\n", startSoundPath) + logger.Debug(fmt.Sprintf("PlayStart: playing %s", startSoundPath)) // Use afplay with reduced volume (0.6) cmd := exec.Command("afplay", "-v", "0.6", startSoundPath) if err := cmd.Start(); err != nil { - fmt.Printf("PlayStart error: %v\n", err) + logger.Warning(fmt.Sprintf("PlayStart error: %v", err)) } } // PlayStop plays the recording stop sound (non-blocking) func PlayStop() { if stopSoundPath == "" { - fmt.Println("PlayStop: no sound path") + logger.Debug("PlayStop: no sound path") return } - fmt.Printf("PlayStop: playing %s\n", stopSoundPath) + logger.Debug(fmt.Sprintf("PlayStop: playing %s", stopSoundPath)) // Use afplay with reduced volume (0.6) cmd := exec.Command("afplay", "-v", "0.6", stopSoundPath) if err := cmd.Start(); err != nil { - fmt.Printf("PlayStop error: %v\n", err) + logger.Warning(fmt.Sprintf("PlayStop error: %v", err)) } } // PlayStartSync plays the recording start sound and waits for it to finish func PlayStartSync() { if startSoundPath == "" { - fmt.Println("PlayStartSync: no sound path") + logger.Debug("PlayStartSync: no sound path") return } - fmt.Printf("PlayStartSync: playing %s\n", startSoundPath) + logger.Debug(fmt.Sprintf("PlayStartSync: playing %s", startSoundPath)) cmd := exec.Command("afplay", "-v", "0.6", startSoundPath) if err := cmd.Run(); err != nil { - fmt.Printf("PlayStartSync error: %v\n", err) + logger.Warning(fmt.Sprintf("PlayStartSync error: %v", err)) } } diff --git a/internal/system/clipboard_darwin.go b/internal/system/clipboard_darwin.go index a8b0bb9..5c7b900 100644 --- a/internal/system/clipboard_darwin.go +++ b/internal/system/clipboard_darwin.go @@ -49,13 +49,13 @@ void activatePreviousApp(void) { int simulatePasteKeystroke(void) { NSLog(@"simulatePasteKeystroke: starting"); NSLog(@"simulatePasteKeystroke: current frontmost = %@", getFrontmostAppName()); - + // Check accessibility first if (!AXIsProcessTrusted()) { NSLog(@"simulatePasteKeystroke: ERROR - No accessibility permissions!"); return 0; } - + // Activate the previous app first (in case our app took focus) if (gPreviousFrontApp) { NSLog(@"simulatePasteKeystroke: activating previous app %@", gPreviousFrontApp.localizedName); @@ -63,29 +63,29 @@ int simulatePasteKeystroke(void) { usleep(100000); // 100ms for activation to complete NSLog(@"simulatePasteKeystroke: after activation, frontmost = %@", getFrontmostAppName()); } - + // Create key down event for 'v' with Command modifier CGEventRef keyDown = CGEventCreateKeyboardEvent(NULL, (CGKeyCode)kVK_ANSI_V, true); CGEventRef keyUp = CGEventCreateKeyboardEvent(NULL, (CGKeyCode)kVK_ANSI_V, false); - + if (keyDown == NULL || keyUp == NULL) { NSLog(@"simulatePasteKeystroke: failed to create events"); if (keyDown) CFRelease(keyDown); if (keyUp) CFRelease(keyUp); return 0; } - + // Set Command modifier flag CGEventSetFlags(keyDown, kCGEventFlagMaskCommand); CGEventSetFlags(keyUp, kCGEventFlagMaskCommand); - + // Post events NSLog(@"simulatePasteKeystroke: posting keyDown"); CGEventPost(kCGHIDEventTap, keyDown); usleep(50000); // 50ms delay NSLog(@"simulatePasteKeystroke: posting keyUp"); CGEventPost(kCGHIDEventTap, keyUp); - + // Release CFRelease(keyDown); CFRelease(keyUp); @@ -95,7 +95,11 @@ int simulatePasteKeystroke(void) { */ import "C" -import "fmt" +import ( + "fmt" + + "yap/internal/logger" +) // SaveFrontmostApp saves the current frontmost app (call before showing overlay) func SaveFrontmostApp() { @@ -104,19 +108,19 @@ func SaveFrontmostApp() { // simulatePasteMacOSNative uses CGEvent to simulate Cmd+V (more reliable than AppleScript) func simulatePasteMacOSNative() error { - fmt.Println("simulatePasteMacOSNative: checking accessibility...") + logger.Debug("simulatePasteMacOSNative: checking accessibility") hasAccess := C.hasAccessibilityForPaste() - fmt.Printf("simulatePasteMacOSNative: hasAccessibility=%d\n", hasAccess) - + logger.Debug(fmt.Sprintf("simulatePasteMacOSNative: hasAccessibility=%d", hasAccess)) + if hasAccess == 0 { - fmt.Println("ERROR: No accessibility permissions for paste! Please grant in System Settings > Privacy & Security > Accessibility") + logger.Error("No accessibility permissions for paste") return fmt.Errorf("no accessibility permissions") } - - fmt.Println("simulatePasteMacOSNative: calling C function") + + logger.Debug("simulatePasteMacOSNative: calling C function") result := C.simulatePasteKeystroke() - fmt.Printf("simulatePasteMacOSNative: result=%d\n", result) - + logger.Debug(fmt.Sprintf("simulatePasteMacOSNative: result=%d", result)) + if result == 0 { return fmt.Errorf("paste keystroke failed") } diff --git a/internal/system/microphone_darwin.go b/internal/system/microphone_darwin.go index 77cb255..0395786 100644 --- a/internal/system/microphone_darwin.go +++ b/internal/system/microphone_darwin.go @@ -47,23 +47,23 @@ static int requestMicrophonePermission(void) { import "C" func CheckMicrophonePermission() string { - switch int(C.microphonePermissionStatus()) { - case 2: - return "granted" - case 1: - return "denied" - default: - return "undetermined" - } + switch int(C.microphonePermissionStatus()) { + case 2: + return "granted" + case 1: + return "denied" + default: + return "undetermined" + } } func RequestMicrophonePermission() string { - switch int(C.requestMicrophonePermission()) { - case 2: - return "granted" - case 1: - return "denied" - default: - return "undetermined" - } + switch int(C.requestMicrophonePermission()) { + case 2: + return "granted" + case 1: + return "denied" + default: + return "undetermined" + } } diff --git a/main.go b/main.go index dd987a6..d728143 100644 --- a/main.go +++ b/main.go @@ -3,9 +3,11 @@ package main import ( "embed" + filelogger "yap/internal/logger" "yap/internal/tray" "github.com/wailsapp/wails/v2" + wailslogger "github.com/wailsapp/wails/v2/pkg/logger" "github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options/assetserver" "github.com/wailsapp/wails/v2/pkg/options/mac" @@ -14,8 +16,22 @@ import ( //go:embed all:frontend/dist var assets embed.FS +const appVersion = "0.4.0" + func main() { + if err := filelogger.Init(); err != nil { + println("Warning: Failed to initialize file logger:", err.Error()) + } + logger := filelogger.GetDefault() + defer func() { + if logger != nil { + logger.Close() + } + }() + filelogger.Info("Yap app version: " + appVersion) + app := NewApp() + filelogger.Info("App instance created") // Start systray (non-blocking with external loop) tray.Start(tray.Callbacks{ @@ -32,9 +48,11 @@ func main() { app.QuitApp() }, }) + filelogger.Info("System tray started") // Set the tray reference in app app.SetTray(tray.SetRecording) + filelogger.Info("Starting Wails runtime") err := wails.Run(&options.App{ Title: "Yap", @@ -45,11 +63,15 @@ func main() { AssetServer: &assetserver.Options{ Assets: assets, }, - BackgroundColour: &options.RGBA{R: 22, G: 19, B: 31, A: 255}, - OnStartup: app.startup, - OnShutdown: app.shutdown, - Frameless: false, - StartHidden: false, + BackgroundColour: &options.RGBA{R: 22, G: 19, B: 31, A: 255}, + OnStartup: app.startup, + OnDomReady: app.domReady, + OnShutdown: app.shutdown, + Logger: logger, + LogLevel: wailslogger.DEBUG, + LogLevelProduction: wailslogger.DEBUG, + Frameless: false, + StartHidden: false, Bind: []interface{}{ app, }, @@ -64,10 +86,10 @@ func main() { Appearance: mac.NSAppearanceNameDarkAqua, WebviewIsTransparent: false, WindowIsTranslucent: false, - About: &mac.AboutInfo{ - Title: "Yap", - Message: "Speech-to-Text Desktop App\nby applauselab.ai\nv0.2.0", - }, + About: &mac.AboutInfo{ + Title: "Yap", + Message: "Speech-to-Text Desktop App\nby applauselab.ai\nv" + appVersion, + }, }, }) From d052644f10faca5ddf7e4135669a28041dd8529c Mon Sep 17 00:00:00 2001 From: Nikolay Kolibarov Date: Wed, 8 Jul 2026 18:58:39 +0300 Subject: [PATCH 02/13] fix: silence macOS permission build warnings --- internal/system/clipboard_darwin.go | 5 +++ internal/system/microphone_darwin.go | 48 ++++++++++++++++------------ 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/internal/system/clipboard_darwin.go b/internal/system/clipboard_darwin.go index 5c7b900..d155f80 100644 --- a/internal/system/clipboard_darwin.go +++ b/internal/system/clipboard_darwin.go @@ -11,6 +11,9 @@ package system #include #include +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + // Check if we have accessibility permissions int hasAccessibilityForPaste(void) { // Check without prompting @@ -92,6 +95,8 @@ int simulatePasteKeystroke(void) { NSLog(@"simulatePasteKeystroke: done"); return 1; } + +#pragma clang diagnostic pop */ import "C" diff --git a/internal/system/microphone_darwin.go b/internal/system/microphone_darwin.go index 0395786..8dc3e56 100644 --- a/internal/system/microphone_darwin.go +++ b/internal/system/microphone_darwin.go @@ -11,37 +11,43 @@ package system // Returns: 0 = undetermined, 1 = denied/restricted, 2 = granted static int microphonePermissionStatus(void) { - AVAuthorizationStatus status = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeAudio]; - switch (status) { - case AVAuthorizationStatusNotDetermined: - return 0; - case AVAuthorizationStatusRestricted: - case AVAuthorizationStatusDenied: - return 1; - case AVAuthorizationStatusAuthorized: - return 2; - default: - return 0; + if (@available(macOS 10.14, *)) { + AVAuthorizationStatus status = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeAudio]; + switch (status) { + case AVAuthorizationStatusNotDetermined: + return 0; + case AVAuthorizationStatusRestricted: + case AVAuthorizationStatusDenied: + return 1; + case AVAuthorizationStatusAuthorized: + return 2; + default: + return 0; + } } + return 2; } // Requests microphone permission and returns latest status. // Returns: 0 = undetermined, 1 = denied/restricted, 2 = granted static int requestMicrophonePermission(void) { - __block BOOL granted = NO; - dispatch_semaphore_t sem = dispatch_semaphore_create(0); + if (@available(macOS 10.14, *)) { + __block BOOL granted = NO; + dispatch_semaphore_t sem = dispatch_semaphore_create(0); - [AVCaptureDevice requestAccessForMediaType:AVMediaTypeAudio completionHandler:^(BOOL allowed) { - granted = allowed; - dispatch_semaphore_signal(sem); - }]; + [AVCaptureDevice requestAccessForMediaType:AVMediaTypeAudio completionHandler:^(BOOL allowed) { + granted = allowed; + dispatch_semaphore_signal(sem); + }]; - dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER); + dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER); - if (granted) { - return 2; + if (granted) { + return 2; + } + return microphonePermissionStatus(); } - return microphonePermissionStatus(); + return 2; } */ import "C" From f3a112233821f30dd3b0f94650afe1dd0f2ee476 Mon Sep 17 00:00:00 2001 From: Nikolay Kolibarov Date: Wed, 8 Jul 2026 19:07:38 +0300 Subject: [PATCH 03/13] ci: add macOS test workflow --- .github/workflows/ci.yml | 44 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7e61cf9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + - 'fix/**' + - 'feat/**' + workflow_dispatch: + +jobs: + test: + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.23' + cache: true + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Install macOS dependencies + run: brew install portaudio + + - name: Install frontend dependencies + run: npm ci + working-directory: frontend + + - name: Build frontend + run: npm run build + working-directory: frontend + + - name: Test Go packages + run: go test ./... From 6baa20d42501a30452ef69e43c3254b6ddc32afe Mon Sep 17 00:00:00 2001 From: Nikolay Kolibarov Date: Thu, 9 Jul 2026 18:17:33 +0300 Subject: [PATCH 04/13] fix: require transcription readiness before recording --- app.go | 50 +++++++++++++++++++++++- build/.DS_Store | Bin 6148 -> 0 bytes frontend/src/App.css | 9 +++++ frontend/src/App.tsx | 27 ++++++++++--- frontend/src/Onboarding.css | 12 ++++++ frontend/src/Onboarding.tsx | 75 +++++++++++++++++++++++------------- 6 files changed, 141 insertions(+), 32 deletions(-) delete mode 100644 build/.DS_Store diff --git a/app.go b/app.go index c66d51f..dcdbd92 100644 --- a/app.go +++ b/app.go @@ -174,6 +174,9 @@ func (a *App) startup(ctx context.Context) { a.localEngine.SetModel(transcribe.Model(configManager.Get().Model)) a.openaiEngine = transcribe.NewOpenAIEngine(configManager.Get().OpenAIAPIKey) + if err := a.validateReadyToRecord(); err != nil { + runtime.LogWarning(a.ctx, fmt.Sprintf("Transcription not ready: %v", err)) + } // Check accessibility permissions without prompting. // Onboarding handles the explicit permission request UX. @@ -443,6 +446,15 @@ func (a *App) ToggleRecording() error { func (a *App) StartRecording() error { runtime.LogInfo(a.ctx, "StartRecording called") runtime.LogDebug(a.ctx, "StartRecording: entering function") + if err := a.validateReadyToRecord(); err != nil { + a.mu.Lock() + a.state = StateError + a.lastError = err.Error() + a.mu.Unlock() + runtime.LogWarning(a.ctx, fmt.Sprintf("StartRecording blocked: %v", err)) + a.emitState() + return err + } // Save the current frontmost app before we do anything (for auto-paste later) system.SaveFrontmostApp() @@ -608,6 +620,9 @@ func (a *App) transcribe(samples []float32, duration float64) { } else { text, err = a.localEngine.Transcribe(ctx, samples) } + if err != nil { + runtime.LogWarning(a.ctx, fmt.Sprintf("Transcription failed: provider=%s model=%s duration=%.2fs samples=%d error=%v", config.Provider, config.Model, duration, len(samples), err)) + } // Generate unique ID for this recording recordingID := fmt.Sprintf("%d", time.Now().UnixNano()) @@ -682,7 +697,11 @@ func (a *App) transcribe(samples []float32, duration float64) { }(text) } else { runtime.LogDebug(a.ctx, "AutoPaste disabled, only copying to clipboard") - go system.CopyToClipboard(text) + go func(textToCopy string) { + if err := system.CopyToClipboard(textToCopy); err != nil { + runtime.LogWarning(a.ctx, fmt.Sprintf("Failed to copy to clipboard: %v", err)) + } + }(text) } } a.mu.Unlock() @@ -705,6 +724,32 @@ func (a *App) transcribe(samples []float32, duration float64) { runtime.EventsEmit(a.ctx, "historyChanged", a.GetHistory()) } +func (a *App) validateReadyToRecord() error { + if a.configManager == nil { + return fmt.Errorf("configuration is not ready") + } + + config := a.configManager.Get() + if config.Provider == "openai" { + if a.openaiEngine == nil || !a.openaiEngine.IsAvailable() { + return fmt.Errorf("OpenAI API key is not configured") + } + return nil + } + + if a.modelManager == nil { + return fmt.Errorf("model manager is not ready") + } + if !a.modelManager.IsModelDownloaded(config.Model) { + return fmt.Errorf("model %q is not downloaded", config.Model) + } + if a.localEngine == nil || !a.localEngine.IsAvailable() { + return fmt.Errorf("local transcription is not available; install whisper-cli and download the selected model") + } + + return nil +} + // emitState sends the current state to the frontend func (a *App) emitState() { if a.ctx != nil { @@ -734,6 +779,9 @@ func (a *App) GetModels() []ModelInfo { // SetModel changes the current model func (a *App) SetModel(model string) error { + if a.configManager.Get().Provider == "local" && !a.modelManager.IsModelDownloaded(model) { + return fmt.Errorf("model %q is not downloaded", model) + } if err := a.configManager.SetModel(model); err != nil { return err } diff --git a/build/.DS_Store b/build/.DS_Store deleted file mode 100644 index 27d0c51677083cfcc703b1124f67b2076fe9a58f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK&2AGh5T0oRO=t4|C-nGa3m$eiGWMQxu_afD! zP5z-5M`4;REq(Gl92?J_IXjV`oSHs&{zAbk&de4|#d5iP@zUJ^I|X5XwO-6ECx7cqlsF%nz=QM1SMhw001aPwCj8)7?vikUC#| zb2AlL>pQ*O@d{~>s3>ZvG|WOJ+fUOiwZEE)omIK1XvDox?AK%NO4mh_5{|^Q+TA4S z%7LgIJd3(L5!Gc)WtrMxPtF!7*+>LuYK5fpT&l}o%o^^qj*p?3Z18=Bh4aK z-vvXhuSq4v#z-n@CshT8+Z%v;Uj#8uQ-A8coi4%Cf>qjypOB+ z7z5nG5MP-1F*=&HT(M{YQ&N7j^n1-jMoJFI^C-*7B(8Oe8L&&5pFYKNaDUw~;28Kb z4Dk6-p~C1}92(R|2UYy}!2LN!7Sj1G!C0~ATO1ms9uhR6geDa269Y{+*(=u1w>UIt z!U6Tjl*f*Y_Jx9abh1~pJD_jS)s6wjz)1$iP1EE1|L(8f|0jc7%Q4^>_^%ki+)8t$ zMpLqP>&WEzuB}jcPKAa2h6Z&ARrWg83SY%@RJ2fEg%v{I;?N*jNZf~jromN?fxpVY EH%R7~`v3p{ diff --git a/frontend/src/App.css b/frontend/src/App.css index 254dc3e..0b66fb5 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -600,6 +600,15 @@ body { background: var(--bg-hover); } +.action-item.disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.action-item.disabled:hover { + background: var(--bg-sidebar); +} + .action-icon { width: 32px; height: 32px; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 60e3b84..8ef1a6d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -178,9 +178,15 @@ function App() { GetStats().then((s: UsageStats) => setStats(s)); }); EventsOn('downloadProgress', (progress: DownloadProgress) => setDownloadProgress(progress)); - EventsOn('downloadComplete', () => { + EventsOn('downloadComplete', async (data: { model: string }) => { setDownloadProgress(null); + try { + await SetModel(data.model); + } catch (err) { + LogError(`[frontend] SetModel after download failed: ${err}`); + } GetModels().then((models: ModelInfo[]) => setModels(models)); + GetState().then((state: AppState) => setAppState(state)); }); EventsOn('downloadError', (data: { model: string; error: string }) => { setDownloadProgress(null); @@ -323,9 +329,15 @@ function App() { }, [audioContext, audioSource]); const handleModelChange = useCallback(async (model: string) => { - await SetModel(model); + const modelInfo = models.find(m => m.name === model); + if (modelInfo?.downloaded) { + await SetModel(model); + GetState().then((s: AppState) => setAppState(s)); + } else { + await DownloadModel(model); + } GetModels().then((m: ModelInfo[]) => setModels(m)); - }, []); + }, [models]); const handleProviderChange = useCallback(async (provider: string) => { await SetProvider(provider); @@ -669,7 +681,10 @@ function App() {

Get started

-
+
@@ -678,7 +693,9 @@ function App() {
Start recording - Turn your voice to text with a single click + + {needsDownload ? 'Download selected model first' : 'Turn your voice to text with a single click'} +
{getHotkeyShortDisplay(currentHotkey)}
diff --git a/frontend/src/Onboarding.css b/frontend/src/Onboarding.css index ad7018f..0951b49 100644 --- a/frontend/src/Onboarding.css +++ b/frontend/src/Onboarding.css @@ -386,6 +386,18 @@ border-radius: 8px; } +.permission-warning { + width: 100%; + padding: 10px 12px; + background: rgba(255, 214, 10, 0.12); + border: 1px solid rgba(255, 214, 10, 0.28); + border-radius: 8px; + color: var(--warning); + font-size: 13px; + line-height: 1.4; + text-align: center; +} + .apikey-hint { font-size: 13px; color: var(--text-muted); diff --git a/frontend/src/Onboarding.tsx b/frontend/src/Onboarding.tsx index a4d2f60..de56d6a 100644 --- a/frontend/src/Onboarding.tsx +++ b/frontend/src/Onboarding.tsx @@ -97,8 +97,14 @@ export function Onboarding({ onComplete }: OnboardingProps) { setDownloadError(null); }; - const completeHandler = () => { + const completeHandler = async (data: { model: string }) => { setDownloadProgress(null); + try { + await SetModel(data.model); + } catch (err) { + setDownloadError(String(err)); + return; + } // Refresh models list GetModels().then((modelList: ModelInfo[]) => { setModels(modelList); @@ -147,9 +153,9 @@ export function Onboarding({ onComplete }: OnboardingProps) { }, [apiKey]); const handleModelSelect = useCallback(async () => { - await SetModel(selectedModel); const model = models.find(m => m.name === selectedModel); if (model?.downloaded) { + await SetModel(selectedModel); // Model already downloaded, skip to mic permission request setStep('micRequest'); } else { @@ -165,10 +171,6 @@ export function Onboarding({ onComplete }: OnboardingProps) { await DownloadModel(selectedModel); }, [selectedModel]); - const handleSkipDownload = useCallback(() => { - setStep('micRequest'); - }, []); - const handleCheckMicPermission = useCallback(async () => { const status = await CheckMicrophonePermission(); setMicPermissionStatus(status); @@ -202,6 +204,13 @@ export function Onboarding({ onComplete }: OnboardingProps) { } }, [step, handleCheckMicPermission]); + // Skip the microphone prompt when macOS already has a grant for this app. + useEffect(() => { + if (step === 'micRequest' && micPermissionStatus === 'granted') { + setStep('micSuccess'); + } + }, [step, micPermissionStatus]); + // Poll for accessibility permission when on access request step useEffect(() => { if (step === 'accessRequest') { @@ -486,12 +495,12 @@ export function Onboarding({ onComplete }: OnboardingProps) { ) : downloadProgress ? ( <>

Downloading {model?.displayName}

-

{model?.size} — {progress.toFixed(0)}%

+

A local model is required before Yap can transcribe offline. {model?.size} - {progress.toFixed(0)}%

) : ( <>

Preparing Download

-

Setting up {model?.displayName}...

+

A local model is required before Yap can transcribe offline.

)}
@@ -499,18 +508,14 @@ export function Onboarding({ onComplete }: OnboardingProps) {
{downloadError ? ( <> - - ) : ( - - )} + ) : null}
); @@ -526,11 +531,8 @@ export function Onboarding({ onComplete }: OnboardingProps) { }, []); const renderMicRequest = () => { - // If already granted, auto-advance - if (micPermissionStatus === 'granted') { - // Use effect will handle this, but also allow manual continue - } - + const micDenied = micPermissionStatus === 'denied'; + return (
{/* Pixel-art microphone illustration */} @@ -549,17 +551,27 @@ export function Onboarding({ onComplete }: OnboardingProps) {
-

Yap needs your microphone

-

To transcribe your voice, Yap needs access to your microphone. Click below and select "Allow" in the system dialog.

+

{micDenied ? 'Microphone access is blocked' : 'Yap needs your microphone'}

+

+ {micDenied + ? 'macOS will not show the permission dialog again. Open System Settings and enable Microphone access for Yap, then come back and re-check.' + : 'To transcribe your voice, Yap needs access to your microphone. Click below and select "Allow" in the system dialog.'} +

+ + {micDenied && ( +
+ System Settings → Privacy & Security → Microphone → Yap +
+ )}
- {micPermissionStatus === 'granted' ? ( - ) : (

- Click the button below to open System Settings, then toggle Yap on in the Accessibility list. + Click the button below to open System Settings, then toggle Yap on in the Accessibility list. If it is already enabled, this step will advance automatically.

+ + {accessibilityStatus === 'denied' && ( +
+ System Settings → Privacy & Security → Accessibility → Yap +
+ )}
+ {accessibilityStatus === 'denied' && ( + + )}
{accessibilityStatus === 'granted' && ( From fa2c6baca78038ee38d9236e5d2f9ab2e7c0d48a Mon Sep 17 00:00:00 2001 From: Nikolay Kolibarov Date: Thu, 9 Jul 2026 19:06:15 +0300 Subject: [PATCH 05/13] fix: bundle whisper runtime in release builds --- .github/workflows/release.yml | 241 +++++++++++---------------- internal/transcribe/whisper_local.go | 149 ++++++++++++----- 2 files changed, 206 insertions(+), 184 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f355c78..14940b0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,6 @@ env: WAILS_VERSION: 'latest' jobs: - # Extract version from tag or input prepare: runs-on: ubuntu-latest outputs: @@ -38,8 +37,6 @@ jobs: echo "tag=${TAG}" >> $GITHUB_OUTPUT echo "Building version: ${VERSION}" - # Build for macOS (Apple Silicon) - # Note: Intel Macs can run via Rosetta 2 build-macos: needs: prepare runs-on: macos-latest @@ -62,7 +59,7 @@ jobs: - name: Install macOS dependencies run: | - brew install portaudio create-dmg + brew install portaudio whisper-cpp create-dmg - name: Install Wails run: go install github.com/wailsapp/wails/v2/cmd/wails@${{ env.WAILS_VERSION }} @@ -75,21 +72,41 @@ jobs: run: | wails build -platform darwin/arm64 -o "Yap" - - name: Bundle PortAudio in app + - name: Bundle macOS runtime libraries run: | - # Create Frameworks directory in app bundle - mkdir -p build/bin/Yap.app/Contents/Frameworks - - # Copy PortAudio dylib - cp /opt/homebrew/lib/libportaudio.2.dylib build/bin/Yap.app/Contents/Frameworks/ - - # Fix library path in binary to look inside the app bundle + set -euo pipefail + APP="build/bin/Yap.app" + mkdir -p "$APP/Contents/Frameworks" "$APP/Contents/Resources/bin" "$APP/Contents/Resources/lib" "$APP/Contents/Resources/libexec" + + cp /opt/homebrew/lib/libportaudio.2.dylib "$APP/Contents/Frameworks/" install_name_tool -change "/opt/homebrew/opt/portaudio/lib/libportaudio.2.dylib" \ "@executable_path/../Frameworks/libportaudio.2.dylib" \ - build/bin/Yap.app/Contents/MacOS/Yap - - # Re-sign the app (ad-hoc) since install_name_tool invalidates the signature - codesign --force --deep --sign - build/bin/Yap.app + "$APP/Contents/MacOS/Yap" + + cp /opt/homebrew/bin/whisper-cli "$APP/Contents/Resources/bin/" + chmod +x "$APP/Contents/Resources/bin/whisper-cli" + + cp -L /opt/homebrew/opt/whisper-cpp/lib/libwhisper*.dylib "$APP/Contents/Resources/lib/" + cp -L /opt/homebrew/opt/ggml/lib/libggml*.dylib "$APP/Contents/Resources/lib/" + cp -L /opt/homebrew/opt/ggml/libexec/* "$APP/Contents/Resources/libexec/" || true + + for lib in "$APP/Contents/Resources/lib"/*.dylib; do + install_name_tool -id "@executable_path/../lib/$(basename "$lib")" "$lib" || true + done + + for bin in "$APP/Contents/Resources/bin/whisper-cli" "$APP/Contents/Resources/lib"/*.dylib "$APP/Contents/Resources/libexec"/*; do + [ -f "$bin" ] || continue + otool -L "$bin" | awk '/\/opt\/homebrew\/opt\/(whisper-cpp|ggml)\/lib\// {print $1}' | while read -r dep; do + base="$(basename "$dep")" + install_name_tool -change "$dep" "@loader_path/../lib/$base" "$bin" || true + done + otool -L "$bin" | awk '/@rpath\/libwhisper/ {print $1}' | while read -r dep; do + base="$(basename "$dep")" + install_name_tool -change "$dep" "@loader_path/../lib/$base" "$bin" || true + done + done + + codesign --force --deep --sign - "$APP" - name: Create DMG run: | @@ -104,8 +121,7 @@ jobs: --app-drop-link 450 150 \ "Yap-${{ needs.prepare.outputs.version }}-macOS.dmg" \ "build/bin/Yap.app" || true - - # Fallback to hdiutil if create-dmg fails + if [ ! -f "Yap-${{ needs.prepare.outputs.version }}-macOS.dmg" ]; then hdiutil create -volname "Yap" -srcfolder "build/bin/Yap.app" -ov -format UDZO "Yap-${{ needs.prepare.outputs.version }}-macOS.dmg" fi @@ -117,7 +133,6 @@ jobs: path: Yap-${{ needs.prepare.outputs.version }}-macOS.dmg retention-days: 5 - # Build for Windows build-windows: needs: prepare runs-on: windows-latest @@ -138,21 +153,19 @@ jobs: cache: 'npm' cache-dependency-path: frontend/package-lock.json - - name: Install portaudio via vcpkg + - name: Install native dependencies via vcpkg run: | git clone https://github.com/Microsoft/vcpkg.git C:\vcpkg C:\vcpkg\bootstrap-vcpkg.bat - C:\vcpkg\vcpkg install portaudio:x64-windows + C:\vcpkg\vcpkg install portaudio:x64-windows whisper-cpp:x64-windows echo "CGO_CFLAGS=-IC:/vcpkg/installed/x64-windows/include" >> $env:GITHUB_ENV echo "CGO_LDFLAGS=-LC:/vcpkg/installed/x64-windows/lib -lportaudio" >> $env:GITHUB_ENV echo "PKG_CONFIG_PATH=C:/vcpkg/installed/x64-windows/lib/pkgconfig" >> $env:GITHUB_ENV - # Add DLL directory to PATH so portaudio.dll can be found at runtime echo "C:/vcpkg/installed/x64-windows/bin" >> $env:GITHUB_PATH - name: Install NSIS run: | choco install nsis -y - # Add NSIS to PATH echo "C:\Program Files (x86)\NSIS" >> $env:GITHUB_PATH - name: Install Wails @@ -162,10 +175,15 @@ jobs: run: npm install working-directory: frontend - - name: Copy PortAudio DLL to build directory + - name: Copy Windows runtime dependencies run: | - New-Item -ItemType Directory -Force -Path "build\bin" + New-Item -ItemType Directory -Force -Path "build\bin\bin" Copy-Item "C:\vcpkg\installed\x64-windows\bin\portaudio.dll" -Destination "build\bin\" + Copy-Item "C:\vcpkg\installed\x64-windows\tools\whisper-cpp\whisper-cli.exe" -Destination "build\bin\bin\" -ErrorAction SilentlyContinue + if (-not (Test-Path "build\bin\bin\whisper-cli.exe")) { + Get-ChildItem -Path "C:\vcpkg\installed\x64-windows" -Filter "whisper-cli.exe" -Recurse | Select-Object -First 1 | Copy-Item -Destination "build\bin\bin\whisper-cli.exe" + } + Get-ChildItem -Path "C:\vcpkg\installed\x64-windows\bin" -Filter "*.dll" | Where-Object { $_.Name -match "whisper|ggml|omp|openblas|blas" } | Copy-Item -Destination "build\bin\bin\" -ErrorAction SilentlyContinue - name: Build Windows App run: | @@ -173,10 +191,17 @@ jobs: env: CGO_ENABLED: 1 - - name: List build output + - name: Ensure runtime files are next to Windows app run: | - echo "Contents of build/bin:" - Get-ChildItem -Path "build\bin" -Recurse | ForEach-Object { Write-Host $_.FullName } + New-Item -ItemType Directory -Force -Path "build\bin\bin" + if (Test-Path "build\bin\Yap") { + Copy-Item "build\bin\bin" -Destination "build\bin\Yap\bin" -Recurse -Force + } + New-Item -ItemType Directory -Force -Path "portable" + Copy-Item "build\bin\Yap.exe" -Destination "portable\" + Copy-Item "build\bin\portaudio.dll" -Destination "portable\" -ErrorAction SilentlyContinue + Copy-Item "build\bin\bin" -Destination "portable\bin" -Recurse -Force + Compress-Archive -Path "portable\*" -DestinationPath "Yap-${{ needs.prepare.outputs.version }}-Windows-Portable.zip" -Force - name: Rename installer run: | @@ -198,10 +223,9 @@ jobs: uses: actions/upload-artifact@v4 with: name: windows-portable - path: build/bin/Yap.exe + path: Yap-${{ needs.prepare.outputs.version }}-Windows-Portable.zip retention-days: 5 - # Build for Linux (Ubuntu 22.04 - WebKitGTK 4.0) build-linux-ubuntu22: needs: prepare runs-on: ubuntu-22.04 @@ -225,13 +249,13 @@ jobs: - name: Install Linux dependencies run: | sudo apt-get update - sudo apt-get install -y \ - libgtk-3-dev \ - libwebkit2gtk-4.0-dev \ - portaudio19-dev \ - pkg-config \ - libfuse2 \ - fuse + sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev portaudio19-dev pkg-config libfuse2 fuse cmake git build-essential + + - name: Build whisper.cpp CLI + run: | + git clone --depth 1 https://github.com/ggml-org/whisper.cpp.git /tmp/whisper.cpp + cmake -S /tmp/whisper.cpp -B /tmp/whisper.cpp/build -DWHISPER_BUILD_TESTS=OFF -DWHISPER_BUILD_EXAMPLES=ON + cmake --build /tmp/whisper.cpp/build --config Release --target whisper-cli -j2 - name: Install Wails run: go install github.com/wailsapp/wails/v2/cmd/wails@${{ env.WAILS_VERSION }} @@ -246,24 +270,14 @@ jobs: - name: Create AppImage run: | - # Download appimagetool wget -q https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage chmod +x appimagetool-x86_64.AppImage - - # Create AppDir structure - mkdir -p AppDir/usr/bin - mkdir -p AppDir/usr/share/applications - mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps - - # Copy binary + mkdir -p AppDir/usr/bin AppDir/usr/lib AppDir/usr/share/applications AppDir/usr/share/icons/hicolor/256x256/apps cp build/bin/yap AppDir/usr/bin/ - chmod +x AppDir/usr/bin/yap - - # Copy PortAudio library for self-contained AppImage - mkdir -p AppDir/usr/lib + cp /tmp/whisper.cpp/build/bin/whisper-cli AppDir/usr/bin/ + chmod +x AppDir/usr/bin/yap AppDir/usr/bin/whisper-cli cp /usr/lib/x86_64-linux-gnu/libportaudio.so.2 AppDir/usr/lib/ - - # Create desktop entry + ldd AppDir/usr/bin/whisper-cli | awk '/=> \/.*(whisper|ggml|gomp|openblas|blas)/ {print $3}' | xargs -r -I{} cp -L {} AppDir/usr/lib/ cat > AppDir/usr/share/applications/yap.desktop << EOF [Desktop Entry] Name=Yap @@ -273,11 +287,7 @@ jobs: Type=Application Categories=Utility;Audio; EOF - - # Copy icon cp build/appicon.iconset/icon_256x256.png AppDir/usr/share/icons/hicolor/256x256/apps/yap.png - - # Create AppRun cat > AppDir/AppRun << 'EOF' #!/bin/bash SELF=$(readlink -f "$0") @@ -287,33 +297,19 @@ jobs: exec "${HERE}/usr/bin/yap" "$@" EOF chmod +x AppDir/AppRun - - # Link desktop and icon to root ln -sf usr/share/applications/yap.desktop AppDir/yap.desktop ln -sf usr/share/icons/hicolor/256x256/apps/yap.png AppDir/yap.png - - # Build AppImage ARCH=x86_64 ./appimagetool-x86_64.AppImage AppDir "Yap-${{ needs.prepare.outputs.version }}-Linux-ubuntu22.AppImage" - name: Create DEB package run: | VERSION="${{ needs.prepare.outputs.version }}" PKG_NAME="yap_${VERSION}_amd64" - - # Create package structure - mkdir -p "${PKG_NAME}/DEBIAN" - mkdir -p "${PKG_NAME}/usr/bin" - mkdir -p "${PKG_NAME}/usr/share/applications" - mkdir -p "${PKG_NAME}/usr/share/icons/hicolor/256x256/apps" - - # Copy binary + mkdir -p "${PKG_NAME}/DEBIAN" "${PKG_NAME}/usr/bin" "${PKG_NAME}/usr/share/applications" "${PKG_NAME}/usr/share/icons/hicolor/256x256/apps" cp build/bin/yap "${PKG_NAME}/usr/bin/" - chmod +x "${PKG_NAME}/usr/bin/yap" - - # Copy icon + cp /tmp/whisper.cpp/build/bin/whisper-cli "${PKG_NAME}/usr/bin/" + chmod +x "${PKG_NAME}/usr/bin/yap" "${PKG_NAME}/usr/bin/whisper-cli" cp build/appicon.iconset/icon_256x256.png "${PKG_NAME}/usr/share/icons/hicolor/256x256/apps/yap.png" - - # Create desktop entry cat > "${PKG_NAME}/usr/share/applications/yap.desktop" << EOF [Desktop Entry] Name=Yap @@ -323,22 +319,17 @@ jobs: Type=Application Categories=Utility;Audio; EOF - - # Create control file cat > "${PKG_NAME}/DEBIAN/control" << EOF Package: yap Version: ${VERSION} Section: utils Priority: optional Architecture: amd64 - Depends: libgtk-3-0, libwebkit2gtk-4.0-37, libportaudio2 + Depends: libgtk-3-0, libwebkit2gtk-4.0-37, libportaudio2, libgomp1 Maintainer: Applause Lab Description: Speech-to-Text Desktop App - Yap is a speech-to-text desktop application - powered by OpenAI Whisper API. + Yap is a speech-to-text desktop application powered by Whisper. EOF - - # Build deb package dpkg-deb --build "${PKG_NAME}" mv "${PKG_NAME}.deb" "Yap-${{ needs.prepare.outputs.version }}-Linux-ubuntu22.deb" @@ -356,7 +347,6 @@ jobs: path: Yap-${{ needs.prepare.outputs.version }}-Linux-ubuntu22.deb retention-days: 5 - # Build for Linux (Ubuntu 24.04+ - WebKitGTK 4.1) build-linux-ubuntu24: needs: prepare runs-on: ubuntu-24.04 @@ -380,12 +370,13 @@ jobs: - name: Install Linux dependencies run: | sudo apt-get update - sudo apt-get install -y \ - libgtk-3-dev \ - libwebkit2gtk-4.1-dev \ - portaudio19-dev \ - pkg-config \ - libfuse2 + sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev portaudio19-dev pkg-config libfuse2 cmake git build-essential + + - name: Build whisper.cpp CLI + run: | + git clone --depth 1 https://github.com/ggml-org/whisper.cpp.git /tmp/whisper.cpp + cmake -S /tmp/whisper.cpp -B /tmp/whisper.cpp/build -DWHISPER_BUILD_TESTS=OFF -DWHISPER_BUILD_EXAMPLES=ON + cmake --build /tmp/whisper.cpp/build --config Release --target whisper-cli -j2 - name: Install Wails run: go install github.com/wailsapp/wails/v2/cmd/wails@${{ env.WAILS_VERSION }} @@ -400,25 +391,15 @@ jobs: - name: Create AppImage run: | - # Download appimagetool and extract it (to avoid FUSE dependency) wget -q https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage chmod +x appimagetool-x86_64.AppImage ./appimagetool-x86_64.AppImage --appimage-extract - - # Create AppDir structure - mkdir -p AppDir/usr/bin - mkdir -p AppDir/usr/share/applications - mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps - - # Copy binary + mkdir -p AppDir/usr/bin AppDir/usr/lib AppDir/usr/share/applications AppDir/usr/share/icons/hicolor/256x256/apps cp build/bin/yap AppDir/usr/bin/ - chmod +x AppDir/usr/bin/yap - - # Copy PortAudio library for self-contained AppImage - mkdir -p AppDir/usr/lib + cp /tmp/whisper.cpp/build/bin/whisper-cli AppDir/usr/bin/ + chmod +x AppDir/usr/bin/yap AppDir/usr/bin/whisper-cli cp /usr/lib/x86_64-linux-gnu/libportaudio.so.2 AppDir/usr/lib/ - - # Create desktop entry + ldd AppDir/usr/bin/whisper-cli | awk '/=> \/.*(whisper|ggml|gomp|openblas|blas)/ {print $3}' | xargs -r -I{} cp -L {} AppDir/usr/lib/ cat > AppDir/usr/share/applications/yap.desktop << EOF [Desktop Entry] Name=Yap @@ -428,11 +409,7 @@ jobs: Type=Application Categories=Utility;Audio; EOF - - # Copy icon cp build/appicon.iconset/icon_256x256.png AppDir/usr/share/icons/hicolor/256x256/apps/yap.png - - # Create AppRun cat > AppDir/AppRun << 'EOF' #!/bin/bash SELF=$(readlink -f "$0") @@ -442,33 +419,19 @@ jobs: exec "${HERE}/usr/bin/yap" "$@" EOF chmod +x AppDir/AppRun - - # Link desktop and icon to root ln -sf usr/share/applications/yap.desktop AppDir/yap.desktop ln -sf usr/share/icons/hicolor/256x256/apps/yap.png AppDir/yap.png - - # Build AppImage using extracted appimagetool ARCH=x86_64 ./squashfs-root/AppRun AppDir "Yap-${{ needs.prepare.outputs.version }}-Linux-ubuntu24.AppImage" - name: Create DEB package run: | VERSION="${{ needs.prepare.outputs.version }}" PKG_NAME="yap_${VERSION}_amd64" - - # Create package structure - mkdir -p "${PKG_NAME}/DEBIAN" - mkdir -p "${PKG_NAME}/usr/bin" - mkdir -p "${PKG_NAME}/usr/share/applications" - mkdir -p "${PKG_NAME}/usr/share/icons/hicolor/256x256/apps" - - # Copy binary + mkdir -p "${PKG_NAME}/DEBIAN" "${PKG_NAME}/usr/bin" "${PKG_NAME}/usr/share/applications" "${PKG_NAME}/usr/share/icons/hicolor/256x256/apps" cp build/bin/yap "${PKG_NAME}/usr/bin/" - chmod +x "${PKG_NAME}/usr/bin/yap" - - # Copy icon + cp /tmp/whisper.cpp/build/bin/whisper-cli "${PKG_NAME}/usr/bin/" + chmod +x "${PKG_NAME}/usr/bin/yap" "${PKG_NAME}/usr/bin/whisper-cli" cp build/appicon.iconset/icon_256x256.png "${PKG_NAME}/usr/share/icons/hicolor/256x256/apps/yap.png" - - # Create desktop entry cat > "${PKG_NAME}/usr/share/applications/yap.desktop" << EOF [Desktop Entry] Name=Yap @@ -478,22 +441,17 @@ jobs: Type=Application Categories=Utility;Audio; EOF - - # Create control file cat > "${PKG_NAME}/DEBIAN/control" << EOF Package: yap Version: ${VERSION} Section: utils Priority: optional Architecture: amd64 - Depends: libgtk-3-0, libwebkit2gtk-4.1-0, libportaudio2 + Depends: libgtk-3-0, libwebkit2gtk-4.1-0, libportaudio2, libgomp1 Maintainer: Applause Lab Description: Speech-to-Text Desktop App - Yap is a speech-to-text desktop application - powered by OpenAI Whisper API. + Yap is a speech-to-text desktop application powered by Whisper. EOF - - # Build deb package dpkg-deb --build "${PKG_NAME}" mv "${PKG_NAME}.deb" "Yap-${{ needs.prepare.outputs.version }}-Linux-ubuntu24.deb" @@ -511,7 +469,6 @@ jobs: path: Yap-${{ needs.prepare.outputs.version }}-Linux-ubuntu24.deb retention-days: 5 - # Create GitHub Release and upload all artifacts release: needs: [prepare, build-macos, build-windows, build-linux-ubuntu22, build-linux-ubuntu24] runs-on: ubuntu-latest @@ -539,37 +496,31 @@ jobs: files: | artifacts/macos-dmg/*.dmg artifacts/windows-installer/*.exe - artifacts/windows-portable/*.exe + artifacts/windows-portable/*.zip artifacts/linux-appimage-ubuntu22/*.AppImage artifacts/linux-deb-ubuntu22/*.deb artifacts/linux-appimage-ubuntu24/*.AppImage artifacts/linux-deb-ubuntu24/*.deb body: | ## Yap ${{ needs.prepare.outputs.version }} - + Speech-to-Text Desktop App by [Applause Lab](https://applauselab.ai) - + + Local transcription runtime (`whisper-cli`) is bundled with release artifacts. Whisper model files are still downloaded on first setup into the user's Yap data directory. + ### Downloads - + | Platform | Download | |----------|----------| | macOS (Apple Silicon, Intel via Rosetta) | `Yap-${{ needs.prepare.outputs.version }}-macOS.dmg` | | Windows Installer | `Yap-${{ needs.prepare.outputs.version }}-Windows-Setup.exe` | + | Windows Portable | `Yap-${{ needs.prepare.outputs.version }}-Windows-Portable.zip` | | Linux (Ubuntu 22.04, Debian 11/12) | `Yap-${{ needs.prepare.outputs.version }}-Linux-ubuntu22.AppImage` or `.deb` | | Linux (Ubuntu 24.04+) | `Yap-${{ needs.prepare.outputs.version }}-Linux-ubuntu24.AppImage` or `.deb` | - + ### Installation - + **macOS:** Open the DMG, drag to Applications folder **Windows:** Run the installer **Linux (AppImage):** `chmod +x *.AppImage && ./Yap-*.AppImage` - **Linux (DEB):** `sudo apt install ./Yap-*.deb` (auto-installs dependencies) - - ### Linux Version Guide - - | Your Distro | Use This | - |-------------|----------| - | Ubuntu 22.04 LTS, Debian 11/12, Linux Mint 21 | `ubuntu22` packages | - | Ubuntu 24.04 LTS+, Debian 13+, Linux Mint 22+ | `ubuntu24` packages | - - > **Tip:** Run `cat /etc/os-release` to check your distro version. + **Linux (DEB):** `sudo apt install ./Yap-*.deb` diff --git a/internal/transcribe/whisper_local.go b/internal/transcribe/whisper_local.go index aa0ab1c..bb32554 100644 --- a/internal/transcribe/whisper_local.go +++ b/internal/transcribe/whisper_local.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" ) @@ -40,29 +41,7 @@ func (e *LocalEngine) Transcribe(ctx context.Context, samples []float32) (string // TranscribeWAV transcribes WAV audio data using whisper CLI func (e *LocalEngine) TranscribeWAV(ctx context.Context, wavData []byte) (string, error) { - // Check if whisper binary is available - whisperBin := e.whisperBin - if whisperBin == "" { - // Try to find whisper-cli in common locations - possiblePaths := []string{ - "/opt/homebrew/bin/whisper-cli", - "/usr/local/bin/whisper-cli", - filepath.Join(os.Getenv("HOME"), ".local/bin/whisper-cli"), - } - for _, p := range possiblePaths { - if _, err := os.Stat(p); err == nil { - whisperBin = p - break - } - } - // Also try PATH lookup - if whisperBin == "" { - if p, err := exec.LookPath("whisper-cli"); err == nil { - whisperBin = p - } - } - } - + whisperBin := e.findWhisperBinary() if whisperBin == "" { return "", fmt.Errorf("whisper-cli not found. Please install whisper.cpp or set the binary path") } @@ -86,13 +65,13 @@ func (e *LocalEngine) TranscribeWAV(ctx context.Context, wavData []byte) (string } tmpFile.Close() - // Run whisper-cli cmd := exec.CommandContext(ctx, whisperBin, "-m", modelPath, "-f", tmpFile.Name(), "--no-timestamps", "-otxt", ) + cmd.Env = e.whisperEnv(os.Environ(), whisperBin) output, err := cmd.Output() if err != nil { @@ -126,29 +105,121 @@ func (e *LocalEngine) IsAvailable() bool { return false } - // Check if whisper binary is available + return e.findWhisperBinary() != "" +} + +func (e *LocalEngine) findWhisperBinary() string { if e.whisperBin != "" { - if _, err := exec.LookPath(e.whisperBin); err != nil { - return false + if isExecutable(e.whisperBin) { + return e.whisperBin + } + if p, err := exec.LookPath(e.whisperBin); err == nil { + return p } - return true } - // Try common paths - possiblePaths := []string{ - "/opt/homebrew/bin/whisper-cli", - "/usr/local/bin/whisper-cli", + for _, p := range bundledWhisperCandidates() { + if isExecutable(p) { + return p + } + } + + for _, p := range systemWhisperCandidates() { + if isExecutable(p) { + return p + } + } + + for _, name := range whisperBinaryNames() { + if p, err := exec.LookPath(name); err == nil { + return p + } + } + return "" +} + +func bundledWhisperCandidates() []string { + exe, err := os.Executable() + if err != nil { + return nil } - for _, p := range possiblePaths { - if _, err := os.Stat(p); err == nil { - return true + exeDir := filepath.Dir(exe) + names := whisperBinaryNames() + candidates := make([]string, 0, len(names)*4) + for _, name := range names { + switch runtime.GOOS { + case "darwin": + candidates = append(candidates, + filepath.Join(exeDir, "..", "Resources", "bin", name), + filepath.Join(exeDir, name), + ) + case "windows": + candidates = append(candidates, + filepath.Join(exeDir, "bin", name), + filepath.Join(exeDir, name), + ) + default: + candidates = append(candidates, + filepath.Join(exeDir, name), + filepath.Join(exeDir, "bin", name), + filepath.Join(exeDir, "..", "lib", "yap", "bin", name), + ) } } - // Also try PATH lookup - if _, err := exec.LookPath("whisper-cli"); err == nil { - return true + return candidates +} + +func systemWhisperCandidates() []string { + home := os.Getenv("HOME") + switch runtime.GOOS { + case "darwin": + return []string{ + "/opt/homebrew/bin/whisper-cli", + "/usr/local/bin/whisper-cli", + filepath.Join(home, ".local/bin/whisper-cli"), + } + case "windows": + return []string{ + filepath.Join(os.Getenv("ProgramFiles"), "whisper.cpp", "bin", "whisper-cli.exe"), + filepath.Join(os.Getenv("LOCALAPPDATA"), "Programs", "whisper.cpp", "whisper-cli.exe"), + } + default: + return []string{ + "/usr/bin/whisper-cli", + "/usr/local/bin/whisper-cli", + filepath.Join(home, ".local/bin/whisper-cli"), + } + } +} + +func whisperBinaryNames() []string { + if runtime.GOOS == "windows" { + return []string{"whisper-cli.exe", "whisper-cli"} + } + return []string{"whisper-cli"} +} + +func isExecutable(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +func (e *LocalEngine) whisperEnv(env []string, whisperBin string) []string { + binDir := filepath.Dir(whisperBin) + if runtime.GOOS == "windows" { + return append(env, "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + } + + backendCandidates := []string{ + filepath.Join(binDir, "..", "libexec"), + filepath.Join(binDir, "..", "lib"), + } + for _, p := range backendCandidates { + if info, err := os.Stat(p); err == nil && info.IsDir() { + return append(env, "GGML_BACKEND_PATH="+p) + } } - return false + return env } // Name returns the provider name From 3c61bc84ac1b14632284246e31bc4570e2a6633f Mon Sep 17 00:00:00 2001 From: Nikolay Kolibarov Date: Thu, 9 Jul 2026 21:20:55 +0300 Subject: [PATCH 06/13] fix: diagnose bundled whisper failures --- .github/workflows/release.yml | 26 +++++++++--- app.go | 5 +++ internal/transcribe/whisper_local.go | 60 +++++++++++++++++++++++++--- main.go | 2 +- 4 files changed, 81 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 14940b0..bbb69f6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,7 +70,7 @@ jobs: - name: Build macOS App run: | - wails build -platform darwin/arm64 -o "Yap" + wails build -platform darwin/arm64 -o "Yap" -ldflags "-X main.appVersion=${{ needs.prepare.outputs.version }}" - name: Bundle macOS runtime libraries run: | @@ -96,16 +96,30 @@ jobs: for bin in "$APP/Contents/Resources/bin/whisper-cli" "$APP/Contents/Resources/lib"/*.dylib "$APP/Contents/Resources/libexec"/*; do [ -f "$bin" ] || continue - otool -L "$bin" | awk '/\/opt\/homebrew\/opt\/(whisper-cpp|ggml)\/lib\// {print $1}' | while read -r dep; do + otool -L "$bin" | awk '/\/opt\/homebrew\/(opt|Cellar)\/(whisper-cpp|ggml)\// {print $1}' | while read -r dep; do base="$(basename "$dep")" install_name_tool -change "$dep" "@loader_path/../lib/$base" "$bin" || true done - otool -L "$bin" | awk '/@rpath\/libwhisper/ {print $1}' | while read -r dep; do + otool -L "$bin" | awk '/@rpath\/lib(whisper|ggml)/ {print $1}' | while read -r dep; do base="$(basename "$dep")" install_name_tool -change "$dep" "@loader_path/../lib/$base" "$bin" || true done done + echo "Inspecting bundled whisper runtime dependencies" + for bin in "$APP/Contents/Resources/bin/whisper-cli" "$APP/Contents/Resources/lib"/*.dylib "$APP/Contents/Resources/libexec"/*; do + [ -f "$bin" ] || continue + otool -L "$bin" + if otool -L "$bin" | grep -E '/opt/homebrew/(opt|Cellar)/(whisper-cpp|ggml)/|@rpath/lib(whisper|ggml)' >/dev/null; then + echo "Unresolved bundled whisper dependency in $bin" + exit 1 + fi + done + + echo "Smoke testing bundled whisper-cli" + "$APP/Contents/Resources/bin/whisper-cli" --help >/tmp/yap-whisper-help.txt + GGML_BACKEND_PATH="$APP/Contents/Resources/libexec" "$APP/Contents/Resources/bin/whisper-cli" --help >/tmp/yap-whisper-help-with-backend.txt + codesign --force --deep --sign - "$APP" - name: Create DMG @@ -187,7 +201,7 @@ jobs: - name: Build Windows App run: | - wails build -platform windows/amd64 -nsis -o "Yap.exe" + wails build -platform windows/amd64 -nsis -o "Yap.exe" -ldflags "-X main.appVersion=${{ needs.prepare.outputs.version }}" env: CGO_ENABLED: 1 @@ -266,7 +280,7 @@ jobs: - name: Build Linux App run: | - wails build -platform linux/amd64 -o "yap" + wails build -platform linux/amd64 -o "yap" -ldflags "-X main.appVersion=${{ needs.prepare.outputs.version }}" - name: Create AppImage run: | @@ -387,7 +401,7 @@ jobs: - name: Build Linux App run: | - wails build -platform linux/amd64 -o "yap" -tags webkit2_41 + wails build -platform linux/amd64 -o "yap" -tags webkit2_41 -ldflags "-X main.appVersion=${{ needs.prepare.outputs.version }}" - name: Create AppImage run: | diff --git a/app.go b/app.go index dcdbd92..051305c 100644 --- a/app.go +++ b/app.go @@ -746,6 +746,11 @@ func (a *App) validateReadyToRecord() error { if a.localEngine == nil || !a.localEngine.IsAvailable() { return fmt.Errorf("local transcription is not available; install whisper-cli and download the selected model") } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := a.localEngine.ValidateRuntime(ctx); err != nil { + return err + } return nil } diff --git a/internal/transcribe/whisper_local.go b/internal/transcribe/whisper_local.go index bb32554..5a48c3f 100644 --- a/internal/transcribe/whisper_local.go +++ b/internal/transcribe/whisper_local.go @@ -73,12 +73,9 @@ func (e *LocalEngine) TranscribeWAV(ctx context.Context, wavData []byte) (string ) cmd.Env = e.whisperEnv(os.Environ(), whisperBin) - output, err := cmd.Output() + output, err := cmd.CombinedOutput() if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - return "", fmt.Errorf("whisper failed: %s", string(exitErr.Stderr)) - } - return "", fmt.Errorf("whisper failed: %w", err) + return "", e.commandError("whisper failed", err, output, whisperBin, modelPath) } // Clean up the output @@ -108,6 +105,23 @@ func (e *LocalEngine) IsAvailable() bool { return e.findWhisperBinary() != "" } +// ValidateRuntime checks that the resolved whisper-cli can launch with the same +// environment used for transcription. +func (e *LocalEngine) ValidateRuntime(ctx context.Context) error { + whisperBin := e.findWhisperBinary() + if whisperBin == "" { + return fmt.Errorf("whisper-cli not found. Please install whisper.cpp or set the binary path") + } + + cmd := exec.CommandContext(ctx, whisperBin, "--help") + cmd.Env = e.whisperEnv(os.Environ(), whisperBin) + output, err := cmd.CombinedOutput() + if err != nil { + return e.commandError("whisper runtime validation failed", err, output, whisperBin, e.getModelPath()) + } + return nil +} + func (e *LocalEngine) findWhisperBinary() string { if e.whisperBin != "" { if isExecutable(e.whisperBin) { @@ -222,6 +236,42 @@ func (e *LocalEngine) whisperEnv(env []string, whisperBin string) []string { return env } +func (e *LocalEngine) commandError(prefix string, err error, output []byte, whisperBin string, modelPath string) error { + backendPath := envValue(e.whisperEnv(os.Environ(), whisperBin), "GGML_BACKEND_PATH") + if backendPath == "" { + backendPath = "(unset)" + } + + return fmt.Errorf( + "%s: %w; whisperBin=%q modelPath=%q GGML_BACKEND_PATH=%q output=%q", + prefix, + err, + whisperBin, + modelPath, + backendPath, + trimCommandOutput(string(output)), + ) +} + +func envValue(env []string, key string) string { + prefix := key + "=" + for _, entry := range env { + if strings.HasPrefix(entry, prefix) { + return strings.TrimPrefix(entry, prefix) + } + } + return "" +} + +func trimCommandOutput(output string) string { + output = strings.TrimSpace(output) + const maxLen = 4000 + if len(output) <= maxLen { + return output + } + return output[:maxLen] + "..." +} + // Name returns the provider name func (e *LocalEngine) Name() Provider { return ProviderLocal diff --git a/main.go b/main.go index d728143..317135f 100644 --- a/main.go +++ b/main.go @@ -16,7 +16,7 @@ import ( //go:embed all:frontend/dist var assets embed.FS -const appVersion = "0.4.0" +var appVersion = "dev" func main() { if err := filelogger.Init(); err != nil { From 4caa7bf2afa241c3e87b71bd41fd81982765de03 Mon Sep 17 00:00:00 2001 From: Nikolay Kolibarov Date: Thu, 9 Jul 2026 21:42:07 +0300 Subject: [PATCH 07/13] fix: sign bundled whisper runtime --- .github/workflows/release.yml | 15 +++++++++-- app.go | 5 ---- internal/transcribe/whisper_local.go | 9 ++++--- internal/transcribe/whisper_local_test.go | 32 +++++++++++++++++++++++ 4 files changed, 51 insertions(+), 10 deletions(-) create mode 100644 internal/transcribe/whisper_local_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bbb69f6..68e94a3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -118,9 +118,20 @@ jobs: echo "Smoke testing bundled whisper-cli" "$APP/Contents/Resources/bin/whisper-cli" --help >/tmp/yap-whisper-help.txt - GGML_BACKEND_PATH="$APP/Contents/Resources/libexec" "$APP/Contents/Resources/bin/whisper-cli" --help >/tmp/yap-whisper-help-with-backend.txt + GGML_BACKEND_PATH="$APP/Contents/Resources/libexec/libggml-metal.so" "$APP/Contents/Resources/bin/whisper-cli" --help >/tmp/yap-whisper-help-with-backend.txt - codesign --force --deep --sign - "$APP" + echo "Signing bundled macOS runtime files" + for bin in "$APP/Contents/Resources/bin/whisper-cli" "$APP/Contents/Resources/lib"/*.dylib "$APP/Contents/Resources/libexec"/* "$APP/Contents/Frameworks"/*.dylib; do + [ -f "$bin" ] || continue + codesign --force --sign - "$bin" + codesign --verify --strict --verbose=2 "$bin" + done + + codesign --force --sign - "$APP/Contents/MacOS/Yap" + codesign --verify --strict --verbose=2 "$APP/Contents/MacOS/Yap" + codesign --force --sign - "$APP" + codesign --verify --deep --strict --verbose=2 "$APP" + GGML_BACKEND_PATH="$APP/Contents/Resources/libexec/libggml-metal.so" "$APP/Contents/Resources/bin/whisper-cli" --help >/tmp/yap-whisper-help-after-sign.txt - name: Create DMG run: | diff --git a/app.go b/app.go index 051305c..dcdbd92 100644 --- a/app.go +++ b/app.go @@ -746,11 +746,6 @@ func (a *App) validateReadyToRecord() error { if a.localEngine == nil || !a.localEngine.IsAvailable() { return fmt.Errorf("local transcription is not available; install whisper-cli and download the selected model") } - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - if err := a.localEngine.ValidateRuntime(ctx); err != nil { - return err - } return nil } diff --git a/internal/transcribe/whisper_local.go b/internal/transcribe/whisper_local.go index 5a48c3f..aa638c9 100644 --- a/internal/transcribe/whisper_local.go +++ b/internal/transcribe/whisper_local.go @@ -225,11 +225,14 @@ func (e *LocalEngine) whisperEnv(env []string, whisperBin string) []string { } backendCandidates := []string{ - filepath.Join(binDir, "..", "libexec"), - filepath.Join(binDir, "..", "lib"), + filepath.Join(binDir, "..", "libexec", "libggml-metal.so"), + filepath.Join(binDir, "..", "libexec", "libggml-blas.so"), + filepath.Join(binDir, "..", "libexec", "libggml-cpu-apple_m4.so"), + filepath.Join(binDir, "..", "libexec", "libggml-cpu-apple_m2_m3.so"), + filepath.Join(binDir, "..", "libexec", "libggml-cpu-apple_m1.so"), } for _, p := range backendCandidates { - if info, err := os.Stat(p); err == nil && info.IsDir() { + if info, err := os.Stat(p); err == nil && !info.IsDir() { return append(env, "GGML_BACKEND_PATH="+p) } } diff --git a/internal/transcribe/whisper_local_test.go b/internal/transcribe/whisper_local_test.go new file mode 100644 index 0000000..72d4fbd --- /dev/null +++ b/internal/transcribe/whisper_local_test.go @@ -0,0 +1,32 @@ +package transcribe + +import ( + "os" + "path/filepath" + "testing" +) + +func TestWhisperEnvUsesBackendFile(t *testing.T) { + tmpDir := t.TempDir() + binDir := filepath.Join(tmpDir, "bin") + libexecDir := filepath.Join(tmpDir, "libexec") + if err := os.MkdirAll(binDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(libexecDir, 0755); err != nil { + t.Fatal(err) + } + + backendPath := filepath.Join(libexecDir, "libggml-metal.so") + if err := os.WriteFile(backendPath, []byte("test"), 0644); err != nil { + t.Fatal(err) + } + + engine := NewLocalEngine(tmpDir) + env := engine.whisperEnv([]string{}, filepath.Join(binDir, "whisper-cli")) + + got := envValue(env, "GGML_BACKEND_PATH") + if got != backendPath { + t.Fatalf("GGML_BACKEND_PATH = %q, want %q", got, backendPath) + } +} From a12f64c1a0578472da78b8453e18ca514024da88 Mon Sep 17 00:00:00 2001 From: Nikolay Kolibarov Date: Thu, 9 Jul 2026 22:16:11 +0300 Subject: [PATCH 08/13] fix: use bundled cpu whisper backend --- .github/workflows/release.yml | 20 ++++++++++-- internal/transcribe/whisper_local.go | 38 ++++++++++++++++++----- internal/transcribe/whisper_local_test.go | 6 +++- 3 files changed, 54 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 68e94a3..90e0127 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -116,9 +116,20 @@ jobs: fi done + CPU_BRAND="$(sysctl -n machdep.cpu.brand_string || true)" + case "$CPU_BRAND" in + *M4*) CPU_BACKEND_NAME="libggml-cpu-apple_m4.so" ;; + *M2*|*M3*) CPU_BACKEND_NAME="libggml-cpu-apple_m2_m3.so" ;; + *) CPU_BACKEND_NAME="libggml-cpu-apple_m1.so" ;; + esac + CPU_BACKEND="$APP/Contents/Resources/libexec/$CPU_BACKEND_NAME" + if [ ! -f "$CPU_BACKEND" ]; then + CPU_BACKEND="$APP/Contents/Resources/libexec/libggml-cpu-apple_m1.so" + fi + echo "Smoke testing bundled whisper-cli" "$APP/Contents/Resources/bin/whisper-cli" --help >/tmp/yap-whisper-help.txt - GGML_BACKEND_PATH="$APP/Contents/Resources/libexec/libggml-metal.so" "$APP/Contents/Resources/bin/whisper-cli" --help >/tmp/yap-whisper-help-with-backend.txt + GGML_BACKEND_PATH="$CPU_BACKEND" "$APP/Contents/Resources/bin/whisper-cli" --help >/tmp/yap-whisper-help-with-backend.txt echo "Signing bundled macOS runtime files" for bin in "$APP/Contents/Resources/bin/whisper-cli" "$APP/Contents/Resources/lib"/*.dylib "$APP/Contents/Resources/libexec"/* "$APP/Contents/Frameworks"/*.dylib; do @@ -131,7 +142,12 @@ jobs: codesign --verify --strict --verbose=2 "$APP/Contents/MacOS/Yap" codesign --force --sign - "$APP" codesign --verify --deep --strict --verbose=2 "$APP" - GGML_BACKEND_PATH="$APP/Contents/Resources/libexec/libggml-metal.so" "$APP/Contents/Resources/bin/whisper-cli" --help >/tmp/yap-whisper-help-after-sign.txt + GGML_BACKEND_PATH="$CPU_BACKEND" "$APP/Contents/Resources/bin/whisper-cli" --help >/tmp/yap-whisper-help-after-sign.txt + + echo "Smoke testing bundled whisper transcription startup" + curl -L --fail --retry 3 -o /tmp/ggml-tiny.en.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.en.bin + ruby -e 'File.binwrite("/tmp/yap-silence.wav", "RIFF" + [36 + 32000].pack("V") + "WAVEfmt " + [16, 1, 1, 16000, 32000, 2, 16].pack("VvvVVvv") + "data" + [32000].pack("V") + "\x00" * 32000)' + GGML_BACKEND_PATH="$CPU_BACKEND" "$APP/Contents/Resources/bin/whisper-cli" -m /tmp/ggml-tiny.en.bin -f /tmp/yap-silence.wav --no-timestamps -otxt --no-gpu >/tmp/yap-whisper-transcribe.txt 2>&1 - name: Create DMG run: | diff --git a/internal/transcribe/whisper_local.go b/internal/transcribe/whisper_local.go index aa638c9..64cc199 100644 --- a/internal/transcribe/whisper_local.go +++ b/internal/transcribe/whisper_local.go @@ -65,13 +65,18 @@ func (e *LocalEngine) TranscribeWAV(ctx context.Context, wavData []byte) (string } tmpFile.Close() - cmd := exec.CommandContext(ctx, whisperBin, + args := []string{ "-m", modelPath, "-f", tmpFile.Name(), "--no-timestamps", "-otxt", - ) - cmd.Env = e.whisperEnv(os.Environ(), whisperBin) + } + cmdEnv := e.whisperEnv(os.Environ(), whisperBin) + if strings.Contains(envValue(cmdEnv, "GGML_BACKEND_PATH"), "libggml-cpu") { + args = append(args, "--no-gpu") + } + cmd := exec.CommandContext(ctx, whisperBin, args...) + cmd.Env = cmdEnv output, err := cmd.CombinedOutput() if err != nil { @@ -225,11 +230,10 @@ func (e *LocalEngine) whisperEnv(env []string, whisperBin string) []string { } backendCandidates := []string{ - filepath.Join(binDir, "..", "libexec", "libggml-metal.so"), - filepath.Join(binDir, "..", "libexec", "libggml-blas.so"), - filepath.Join(binDir, "..", "libexec", "libggml-cpu-apple_m4.so"), - filepath.Join(binDir, "..", "libexec", "libggml-cpu-apple_m2_m3.so"), + filepath.Join(binDir, "..", "libexec", preferredAppleCPUBackend()), filepath.Join(binDir, "..", "libexec", "libggml-cpu-apple_m1.so"), + filepath.Join(binDir, "..", "libexec", "libggml-cpu-apple_m2_m3.so"), + filepath.Join(binDir, "..", "libexec", "libggml-cpu-apple_m4.so"), } for _, p := range backendCandidates { if info, err := os.Stat(p); err == nil && !info.IsDir() { @@ -239,6 +243,26 @@ func (e *LocalEngine) whisperEnv(env []string, whisperBin string) []string { return env } +func preferredAppleCPUBackend() string { + if runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" { + return "libggml-cpu-apple_m1.so" + } + + brand, err := exec.Command("sysctl", "-n", "machdep.cpu.brand_string").Output() + if err != nil { + return "libggml-cpu-apple_m1.so" + } + cpu := string(brand) + switch { + case strings.Contains(cpu, "M4"): + return "libggml-cpu-apple_m4.so" + case strings.Contains(cpu, "M2"), strings.Contains(cpu, "M3"): + return "libggml-cpu-apple_m2_m3.so" + default: + return "libggml-cpu-apple_m1.so" + } +} + func (e *LocalEngine) commandError(prefix string, err error, output []byte, whisperBin string, modelPath string) error { backendPath := envValue(e.whisperEnv(os.Environ(), whisperBin), "GGML_BACKEND_PATH") if backendPath == "" { diff --git a/internal/transcribe/whisper_local_test.go b/internal/transcribe/whisper_local_test.go index 72d4fbd..e218962 100644 --- a/internal/transcribe/whisper_local_test.go +++ b/internal/transcribe/whisper_local_test.go @@ -17,10 +17,14 @@ func TestWhisperEnvUsesBackendFile(t *testing.T) { t.Fatal(err) } - backendPath := filepath.Join(libexecDir, "libggml-metal.so") + backendPath := filepath.Join(libexecDir, preferredAppleCPUBackend()) if err := os.WriteFile(backendPath, []byte("test"), 0644); err != nil { t.Fatal(err) } + metalPath := filepath.Join(libexecDir, "libggml-metal.so") + if err := os.WriteFile(metalPath, []byte("test"), 0644); err != nil { + t.Fatal(err) + } engine := NewLocalEngine(tmpDir) env := engine.whisperEnv([]string{}, filepath.Join(binDir, "whisper-cli")) From b6282a68bc49cdc49947d8ccd9ffa92fc3985723 Mon Sep 17 00:00:00 2001 From: Nikolay Kolibarov Date: Thu, 9 Jul 2026 22:35:45 +0300 Subject: [PATCH 09/13] fix: read whisper transcript file --- internal/transcribe/whisper_local.go | 11 +++++- .../whisper_local_integration_test.go | 38 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 internal/transcribe/whisper_local_integration_test.go diff --git a/internal/transcribe/whisper_local.go b/internal/transcribe/whisper_local.go index 64cc199..d7676c2 100644 --- a/internal/transcribe/whisper_local.go +++ b/internal/transcribe/whisper_local.go @@ -58,6 +58,8 @@ func (e *LocalEngine) TranscribeWAV(ctx context.Context, wavData []byte) (string return "", fmt.Errorf("failed to create temp file: %w", err) } defer os.Remove(tmpFile.Name()) + outputPath := tmpFile.Name() + ".txt" + defer os.Remove(outputPath) if _, err := tmpFile.Write(wavData); err != nil { tmpFile.Close() @@ -70,6 +72,7 @@ func (e *LocalEngine) TranscribeWAV(ctx context.Context, wavData []byte) (string "-f", tmpFile.Name(), "--no-timestamps", "-otxt", + "-of", tmpFile.Name(), } cmdEnv := e.whisperEnv(os.Environ(), whisperBin) if strings.Contains(envValue(cmdEnv, "GGML_BACKEND_PATH"), "libggml-cpu") { @@ -83,8 +86,12 @@ func (e *LocalEngine) TranscribeWAV(ctx context.Context, wavData []byte) (string return "", e.commandError("whisper failed", err, output, whisperBin, modelPath) } - // Clean up the output - text := strings.TrimSpace(string(output)) + textBytes, err := os.ReadFile(outputPath) + if err != nil { + return "", fmt.Errorf("failed to read whisper output file %q: %w; command output=%q", outputPath, err, trimCommandOutput(string(output))) + } + + text := strings.TrimSpace(string(textBytes)) return text, nil } diff --git a/internal/transcribe/whisper_local_integration_test.go b/internal/transcribe/whisper_local_integration_test.go new file mode 100644 index 0000000..c5acf1a --- /dev/null +++ b/internal/transcribe/whisper_local_integration_test.go @@ -0,0 +1,38 @@ +package transcribe + +import ( + "context" + "os" + "strings" + "testing" + "time" +) + +func TestLocalEngineTranscribeWAVReturnsTranscriptOnly(t *testing.T) { + if os.Getenv("YAP_TEST_WHISPER_WAV") == "" { + t.Skip("set YAP_TEST_WHISPER_WAV to run") + } + + engine := NewLocalEngine(os.Getenv("YAP_TEST_WHISPER_MODELS_DIR")) + engine.SetModel(ModelTinyEn) + engine.SetWhisperBinary(os.Getenv("YAP_TEST_WHISPER_BIN")) + + wavData, err := os.ReadFile(os.Getenv("YAP_TEST_WHISPER_WAV")) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + text, err := engine.TranscribeWAV(ctx, wavData) + if err != nil { + t.Fatal(err) + } + + if strings.Contains(text, "ggml_backend") || strings.Contains(text, "whisper_model_load") { + t.Fatalf("transcript includes whisper diagnostics: %q", text) + } + if text == "" { + t.Fatal("empty transcript") + } +} From 14879fe57379e3019441016f4db61ea652d1dbbc Mon Sep 17 00:00:00 2001 From: Nikolay Kolibarov Date: Fri, 10 Jul 2026 10:11:43 +0300 Subject: [PATCH 10/13] fix: bundle libomp for macos whisper --- .github/workflows/release.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 90e0127..2ec94ba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,7 +59,7 @@ jobs: - name: Install macOS dependencies run: | - brew install portaudio whisper-cpp create-dmg + brew install portaudio whisper-cpp libomp create-dmg - name: Install Wails run: go install github.com/wailsapp/wails/v2/cmd/wails@${{ env.WAILS_VERSION }} @@ -88,6 +88,7 @@ jobs: cp -L /opt/homebrew/opt/whisper-cpp/lib/libwhisper*.dylib "$APP/Contents/Resources/lib/" cp -L /opt/homebrew/opt/ggml/lib/libggml*.dylib "$APP/Contents/Resources/lib/" + cp -L /opt/homebrew/opt/libomp/lib/libomp.dylib "$APP/Contents/Resources/lib/" cp -L /opt/homebrew/opt/ggml/libexec/* "$APP/Contents/Resources/libexec/" || true for lib in "$APP/Contents/Resources/lib"/*.dylib; do @@ -96,11 +97,11 @@ jobs: for bin in "$APP/Contents/Resources/bin/whisper-cli" "$APP/Contents/Resources/lib"/*.dylib "$APP/Contents/Resources/libexec"/*; do [ -f "$bin" ] || continue - otool -L "$bin" | awk '/\/opt\/homebrew\/(opt|Cellar)\/(whisper-cpp|ggml)\// {print $1}' | while read -r dep; do + otool -L "$bin" | awk '/\/opt\/homebrew\/(opt|Cellar)\/(whisper-cpp|ggml|libomp)\// {print $1}' | while read -r dep; do base="$(basename "$dep")" install_name_tool -change "$dep" "@loader_path/../lib/$base" "$bin" || true done - otool -L "$bin" | awk '/@rpath\/lib(whisper|ggml)/ {print $1}' | while read -r dep; do + otool -L "$bin" | awk '/@rpath\/lib(whisper|ggml|omp)/ {print $1}' | while read -r dep; do base="$(basename "$dep")" install_name_tool -change "$dep" "@loader_path/../lib/$base" "$bin" || true done @@ -110,8 +111,8 @@ jobs: for bin in "$APP/Contents/Resources/bin/whisper-cli" "$APP/Contents/Resources/lib"/*.dylib "$APP/Contents/Resources/libexec"/*; do [ -f "$bin" ] || continue otool -L "$bin" - if otool -L "$bin" | grep -E '/opt/homebrew/(opt|Cellar)/(whisper-cpp|ggml)/|@rpath/lib(whisper|ggml)' >/dev/null; then - echo "Unresolved bundled whisper dependency in $bin" + if otool -L "$bin" | grep -E '/opt/homebrew/|/usr/local/|/Users/runner/work/|@rpath/' >/dev/null; then + echo "Unresolved non-system dependency in $bin" exit 1 fi done From cb2baaf35bc7134b9282bb90f954c922f6f2e262 Mon Sep 17 00:00:00 2001 From: Nikolay Kolibarov Date: Fri, 10 Jul 2026 10:17:53 +0300 Subject: [PATCH 11/13] ci: avoid homebrew ggml smoke collision --- .github/workflows/release.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2ec94ba..342e850 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -145,10 +145,7 @@ jobs: codesign --verify --deep --strict --verbose=2 "$APP" GGML_BACKEND_PATH="$CPU_BACKEND" "$APP/Contents/Resources/bin/whisper-cli" --help >/tmp/yap-whisper-help-after-sign.txt - echo "Smoke testing bundled whisper transcription startup" - curl -L --fail --retry 3 -o /tmp/ggml-tiny.en.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.en.bin - ruby -e 'File.binwrite("/tmp/yap-silence.wav", "RIFF" + [36 + 32000].pack("V") + "WAVEfmt " + [16, 1, 1, 16000, 32000, 2, 16].pack("VvvVVvv") + "data" + [32000].pack("V") + "\x00" * 32000)' - GGML_BACKEND_PATH="$CPU_BACKEND" "$APP/Contents/Resources/bin/whisper-cli" -m /tmp/ggml-tiny.en.bin -f /tmp/yap-silence.wav --no-timestamps -otxt --no-gpu >/tmp/yap-whisper-transcribe.txt 2>&1 + echo "Bundled runtime dependency audit complete" - name: Create DMG run: | From 1d1be3ac3024f43e0d6ab27715da6e111f346187 Mon Sep 17 00:00:00 2001 From: Nikolay Kolibarov Date: Sun, 12 Jul 2026 16:19:33 +0300 Subject: [PATCH 12/13] chore: release 0.4.1 --- .github/workflows/release.yml | 2 +- frontend/package-lock.json | 4 ++-- frontend/package.json | 4 ++-- frontend/src/Onboarding.tsx | 2 +- package-lock.json | 4 ++-- package.json | 2 +- wails.json | 2 +- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 342e850..a3362b5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -141,7 +141,7 @@ jobs: codesign --force --sign - "$APP/Contents/MacOS/Yap" codesign --verify --strict --verbose=2 "$APP/Contents/MacOS/Yap" - codesign --force --sign - "$APP" + codesign --force --deep --sign - "$APP" codesign --verify --deep --strict --verbose=2 "$APP" GGML_BACKEND_PATH="$CPU_BACKEND" "$APP/Contents/Resources/bin/whisper-cli" --help >/tmp/yap-whisper-help-after-sign.txt diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0e645d8..258a6e9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "0.4.0", + "version": "0.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "0.4.0", + "version": "0.4.1", "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0" diff --git a/frontend/package.json b/frontend/package.json index 43c15a2..423f219 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.4.0", + "version": "0.4.1", "type": "module", "scripts": { "dev": "vite", @@ -19,4 +19,4 @@ "typescript": "^4.6.4", "vite": "^3.0.7" } -} \ No newline at end of file +} diff --git a/frontend/src/Onboarding.tsx b/frontend/src/Onboarding.tsx index de56d6a..be33c4a 100644 --- a/frontend/src/Onboarding.tsx +++ b/frontend/src/Onboarding.tsx @@ -681,7 +681,7 @@ export function Onboarding({ onComplete }: OnboardingProps) {

- Click the button below to open System Settings, then toggle Yap on in the Accessibility list. If it is already enabled, this step will advance automatically. + Click the button below to open System Settings, then toggle Yap on in the Accessibility list. If Yap already appears enabled but this step does not advance, toggle it off and back on.

{accessibilityStatus === 'denied' && ( diff --git a/package-lock.json b/package-lock.json index 6900925..f1c577b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "yap", - "version": "0.1.0", + "version": "0.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "yap", - "version": "0.1.0", + "version": "0.4.1", "devDependencies": { "@commitlint/cli": "^19.6.1", "@commitlint/config-conventional": "^19.6.0", diff --git a/package.json b/package.json index 5bc412c..e2f82de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "yap", - "version": "0.4.0", + "version": "0.4.1", "private": true, "type": "module", "description": "Speech-to-Text Desktop App by ApplauseLab", diff --git a/wails.json b/wails.json index f2ed31f..3746322 100644 --- a/wails.json +++ b/wails.json @@ -13,7 +13,7 @@ "info": { "companyName": "Applause Lab", "productName": "Yap", - "productVersion": "0.4.0", + "productVersion": "0.4.1", "copyright": "Copyright 2024 Applause Lab", "comments": "Speech-to-Text Desktop App by applauselab.ai" } From 1f0a1750ba3f8981dbf797aa675ccd034acb49e3 Mon Sep 17 00:00:00 2001 From: Nikolay Kolibarov Date: Mon, 13 Jul 2026 17:38:35 +0300 Subject: [PATCH 13/13] feat: add persistent whisper worker --- .github/workflows/release.yml | 72 +++- app.go | 39 +++ build/windows/installer/project.nsi | 6 + frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/package.json.md5 | 2 +- frontend/src/App.css | 178 ++++++++++ frontend/src/App.tsx | 170 ++++++++- frontend/src/Onboarding.tsx | 2 +- internal/hotkey/hotkey_darwin.go | 8 + internal/transcribe/whisper_local.go | 324 ++++++++++++++++-- .../whisper_local_integration_test.go | 33 +- internal/transcribe/whisper_local_test.go | 72 ++++ internal/transcribe/whisper_worker.go | 320 +++++++++++++++++ internal/transcribe/whisper_worker_test.go | 292 ++++++++++++++++ package-lock.json | 4 +- package.json | 2 +- wails.json | 2 +- 18 files changed, 1464 insertions(+), 68 deletions(-) create mode 100644 internal/transcribe/whisper_worker.go create mode 100644 internal/transcribe/whisper_worker_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a3362b5..530efd5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -83,8 +83,8 @@ jobs: "@executable_path/../Frameworks/libportaudio.2.dylib" \ "$APP/Contents/MacOS/Yap" - cp /opt/homebrew/bin/whisper-cli "$APP/Contents/Resources/bin/" - chmod +x "$APP/Contents/Resources/bin/whisper-cli" + cp /opt/homebrew/bin/whisper-cli /opt/homebrew/bin/whisper-server "$APP/Contents/Resources/bin/" + chmod +x "$APP/Contents/Resources/bin/whisper-cli" "$APP/Contents/Resources/bin/whisper-server" cp -L /opt/homebrew/opt/whisper-cpp/lib/libwhisper*.dylib "$APP/Contents/Resources/lib/" cp -L /opt/homebrew/opt/ggml/lib/libggml*.dylib "$APP/Contents/Resources/lib/" @@ -95,7 +95,7 @@ jobs: install_name_tool -id "@executable_path/../lib/$(basename "$lib")" "$lib" || true done - for bin in "$APP/Contents/Resources/bin/whisper-cli" "$APP/Contents/Resources/lib"/*.dylib "$APP/Contents/Resources/libexec"/*; do + for bin in "$APP/Contents/Resources/bin"/* "$APP/Contents/Resources/lib"/*.dylib "$APP/Contents/Resources/libexec"/*; do [ -f "$bin" ] || continue otool -L "$bin" | awk '/\/opt\/homebrew\/(opt|Cellar)\/(whisper-cpp|ggml|libomp)\// {print $1}' | while read -r dep; do base="$(basename "$dep")" @@ -108,7 +108,7 @@ jobs: done echo "Inspecting bundled whisper runtime dependencies" - for bin in "$APP/Contents/Resources/bin/whisper-cli" "$APP/Contents/Resources/lib"/*.dylib "$APP/Contents/Resources/libexec"/*; do + for bin in "$APP/Contents/Resources/bin"/* "$APP/Contents/Resources/lib"/*.dylib "$APP/Contents/Resources/libexec"/*; do [ -f "$bin" ] || continue otool -L "$bin" if otool -L "$bin" | grep -E '/opt/homebrew/|/usr/local/|/Users/runner/work/|@rpath/' >/dev/null; then @@ -131,9 +131,10 @@ jobs: echo "Smoke testing bundled whisper-cli" "$APP/Contents/Resources/bin/whisper-cli" --help >/tmp/yap-whisper-help.txt GGML_BACKEND_PATH="$CPU_BACKEND" "$APP/Contents/Resources/bin/whisper-cli" --help >/tmp/yap-whisper-help-with-backend.txt + GGML_BACKEND_PATH="$CPU_BACKEND" "$APP/Contents/Resources/bin/whisper-server" --help >/tmp/yap-whisper-server-help.txt echo "Signing bundled macOS runtime files" - for bin in "$APP/Contents/Resources/bin/whisper-cli" "$APP/Contents/Resources/lib"/*.dylib "$APP/Contents/Resources/libexec"/* "$APP/Contents/Frameworks"/*.dylib; do + for bin in "$APP/Contents/Resources/bin"/* "$APP/Contents/Resources/lib"/*.dylib "$APP/Contents/Resources/libexec"/* "$APP/Contents/Frameworks"/*.dylib; do [ -f "$bin" ] || continue codesign --force --sign - "$bin" codesign --verify --strict --verbose=2 "$bin" @@ -165,6 +166,15 @@ jobs: hdiutil create -volname "Yap" -srcfolder "build/bin/Yap.app" -ov -format UDZO "Yap-${{ needs.prepare.outputs.version }}-macOS.dmg" fi + MOUNT_OUTPUT=$(hdiutil attach -readonly -nobrowse "Yap-${{ needs.prepare.outputs.version }}-macOS.dmg") + MOUNT_DIR=$(printf '%s\n' "$MOUNT_OUTPUT" | awk '/\/Volumes\// {print substr($0, index($0, "/Volumes/")); exit}') + trap 'hdiutil detach "$MOUNT_DIR" >/dev/null 2>&1 || true' EXIT + test -d "$MOUNT_DIR/Yap.app" + test -L "$MOUNT_DIR/Applications" + codesign --verify --deep --strict --verbose=2 "$MOUNT_DIR/Yap.app" + hdiutil detach "$MOUNT_DIR" + trap - EXIT + - name: Upload macOS DMG uses: actions/upload-artifact@v4 with: @@ -219,9 +229,13 @@ jobs: New-Item -ItemType Directory -Force -Path "build\bin\bin" Copy-Item "C:\vcpkg\installed\x64-windows\bin\portaudio.dll" -Destination "build\bin\" Copy-Item "C:\vcpkg\installed\x64-windows\tools\whisper-cpp\whisper-cli.exe" -Destination "build\bin\bin\" -ErrorAction SilentlyContinue + Copy-Item "C:\vcpkg\installed\x64-windows\tools\whisper-cpp\whisper-server.exe" -Destination "build\bin\bin\" -ErrorAction SilentlyContinue if (-not (Test-Path "build\bin\bin\whisper-cli.exe")) { Get-ChildItem -Path "C:\vcpkg\installed\x64-windows" -Filter "whisper-cli.exe" -Recurse | Select-Object -First 1 | Copy-Item -Destination "build\bin\bin\whisper-cli.exe" } + if (-not (Test-Path "build\bin\bin\whisper-server.exe")) { + Get-ChildItem -Path "C:\vcpkg\installed\x64-windows" -Filter "whisper-server.exe" -Recurse | Select-Object -First 1 | Copy-Item -Destination "build\bin\bin\whisper-server.exe" + } Get-ChildItem -Path "C:\vcpkg\installed\x64-windows\bin" -Filter "*.dll" | Where-Object { $_.Name -match "whisper|ggml|omp|openblas|blas" } | Copy-Item -Destination "build\bin\bin\" -ErrorAction SilentlyContinue - name: Build Windows App @@ -288,13 +302,13 @@ jobs: - name: Install Linux dependencies run: | sudo apt-get update - sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev portaudio19-dev pkg-config libfuse2 fuse cmake git build-essential + sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev portaudio19-dev pkg-config libfuse2 fuse cmake git build-essential patchelf - name: Build whisper.cpp CLI run: | git clone --depth 1 https://github.com/ggml-org/whisper.cpp.git /tmp/whisper.cpp cmake -S /tmp/whisper.cpp -B /tmp/whisper.cpp/build -DWHISPER_BUILD_TESTS=OFF -DWHISPER_BUILD_EXAMPLES=ON - cmake --build /tmp/whisper.cpp/build --config Release --target whisper-cli -j2 + cmake --build /tmp/whisper.cpp/build --config Release --target whisper-cli whisper-server -j2 - name: Install Wails run: go install github.com/wailsapp/wails/v2/cmd/wails@${{ env.WAILS_VERSION }} @@ -314,9 +328,12 @@ jobs: mkdir -p AppDir/usr/bin AppDir/usr/lib AppDir/usr/share/applications AppDir/usr/share/icons/hicolor/256x256/apps cp build/bin/yap AppDir/usr/bin/ cp /tmp/whisper.cpp/build/bin/whisper-cli AppDir/usr/bin/ - chmod +x AppDir/usr/bin/yap AppDir/usr/bin/whisper-cli + cp /tmp/whisper.cpp/build/bin/whisper-server AppDir/usr/bin/ + chmod +x AppDir/usr/bin/yap AppDir/usr/bin/whisper-cli AppDir/usr/bin/whisper-server cp /usr/lib/x86_64-linux-gnu/libportaudio.so.2 AppDir/usr/lib/ - ldd AppDir/usr/bin/whisper-cli | awk '/=> \/.*(whisper|ggml|gomp|openblas|blas)/ {print $3}' | xargs -r -I{} cp -L {} AppDir/usr/lib/ + for bin in AppDir/usr/bin/whisper-cli AppDir/usr/bin/whisper-server; do + ldd "$bin" | awk '/=> \/.*(whisper|ggml|gomp|openblas|blas)/ {print $3}' | xargs -r -I{} cp -L {} AppDir/usr/lib/ + done cat > AppDir/usr/share/applications/yap.desktop << EOF [Desktop Entry] Name=Yap @@ -344,10 +361,18 @@ jobs: run: | VERSION="${{ needs.prepare.outputs.version }}" PKG_NAME="yap_${VERSION}_amd64" - mkdir -p "${PKG_NAME}/DEBIAN" "${PKG_NAME}/usr/bin" "${PKG_NAME}/usr/share/applications" "${PKG_NAME}/usr/share/icons/hicolor/256x256/apps" + mkdir -p "${PKG_NAME}/DEBIAN" "${PKG_NAME}/usr/bin" "${PKG_NAME}/usr/lib/yap" "${PKG_NAME}/usr/share/applications" "${PKG_NAME}/usr/share/icons/hicolor/256x256/apps" cp build/bin/yap "${PKG_NAME}/usr/bin/" cp /tmp/whisper.cpp/build/bin/whisper-cli "${PKG_NAME}/usr/bin/" - chmod +x "${PKG_NAME}/usr/bin/yap" "${PKG_NAME}/usr/bin/whisper-cli" + cp /tmp/whisper.cpp/build/bin/whisper-server "${PKG_NAME}/usr/bin/" + chmod +x "${PKG_NAME}/usr/bin/yap" "${PKG_NAME}/usr/bin/whisper-cli" "${PKG_NAME}/usr/bin/whisper-server" + for bin in "${PKG_NAME}/usr/bin/whisper-cli" "${PKG_NAME}/usr/bin/whisper-server"; do + ldd "$bin" | awk '/=> \/.*(whisper|ggml|gomp|openblas|blas)/ {print $3}' | xargs -r -I{} cp -L {} "${PKG_NAME}/usr/lib/yap/" + patchelf --set-rpath '$ORIGIN/../lib/yap' "$bin" + done + for lib in "${PKG_NAME}/usr/lib/yap"/*; do + [ -f "$lib" ] && patchelf --set-rpath '$ORIGIN' "$lib" || true + done cp build/appicon.iconset/icon_256x256.png "${PKG_NAME}/usr/share/icons/hicolor/256x256/apps/yap.png" cat > "${PKG_NAME}/usr/share/applications/yap.desktop" << EOF [Desktop Entry] @@ -409,13 +434,13 @@ jobs: - name: Install Linux dependencies run: | sudo apt-get update - sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev portaudio19-dev pkg-config libfuse2 cmake git build-essential + sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev portaudio19-dev pkg-config libfuse2 cmake git build-essential patchelf - name: Build whisper.cpp CLI run: | git clone --depth 1 https://github.com/ggml-org/whisper.cpp.git /tmp/whisper.cpp cmake -S /tmp/whisper.cpp -B /tmp/whisper.cpp/build -DWHISPER_BUILD_TESTS=OFF -DWHISPER_BUILD_EXAMPLES=ON - cmake --build /tmp/whisper.cpp/build --config Release --target whisper-cli -j2 + cmake --build /tmp/whisper.cpp/build --config Release --target whisper-cli whisper-server -j2 - name: Install Wails run: go install github.com/wailsapp/wails/v2/cmd/wails@${{ env.WAILS_VERSION }} @@ -436,9 +461,12 @@ jobs: mkdir -p AppDir/usr/bin AppDir/usr/lib AppDir/usr/share/applications AppDir/usr/share/icons/hicolor/256x256/apps cp build/bin/yap AppDir/usr/bin/ cp /tmp/whisper.cpp/build/bin/whisper-cli AppDir/usr/bin/ - chmod +x AppDir/usr/bin/yap AppDir/usr/bin/whisper-cli + cp /tmp/whisper.cpp/build/bin/whisper-server AppDir/usr/bin/ + chmod +x AppDir/usr/bin/yap AppDir/usr/bin/whisper-cli AppDir/usr/bin/whisper-server cp /usr/lib/x86_64-linux-gnu/libportaudio.so.2 AppDir/usr/lib/ - ldd AppDir/usr/bin/whisper-cli | awk '/=> \/.*(whisper|ggml|gomp|openblas|blas)/ {print $3}' | xargs -r -I{} cp -L {} AppDir/usr/lib/ + for bin in AppDir/usr/bin/whisper-cli AppDir/usr/bin/whisper-server; do + ldd "$bin" | awk '/=> \/.*(whisper|ggml|gomp|openblas|blas)/ {print $3}' | xargs -r -I{} cp -L {} AppDir/usr/lib/ + done cat > AppDir/usr/share/applications/yap.desktop << EOF [Desktop Entry] Name=Yap @@ -466,10 +494,18 @@ jobs: run: | VERSION="${{ needs.prepare.outputs.version }}" PKG_NAME="yap_${VERSION}_amd64" - mkdir -p "${PKG_NAME}/DEBIAN" "${PKG_NAME}/usr/bin" "${PKG_NAME}/usr/share/applications" "${PKG_NAME}/usr/share/icons/hicolor/256x256/apps" + mkdir -p "${PKG_NAME}/DEBIAN" "${PKG_NAME}/usr/bin" "${PKG_NAME}/usr/lib/yap" "${PKG_NAME}/usr/share/applications" "${PKG_NAME}/usr/share/icons/hicolor/256x256/apps" cp build/bin/yap "${PKG_NAME}/usr/bin/" cp /tmp/whisper.cpp/build/bin/whisper-cli "${PKG_NAME}/usr/bin/" - chmod +x "${PKG_NAME}/usr/bin/yap" "${PKG_NAME}/usr/bin/whisper-cli" + cp /tmp/whisper.cpp/build/bin/whisper-server "${PKG_NAME}/usr/bin/" + chmod +x "${PKG_NAME}/usr/bin/yap" "${PKG_NAME}/usr/bin/whisper-cli" "${PKG_NAME}/usr/bin/whisper-server" + for bin in "${PKG_NAME}/usr/bin/whisper-cli" "${PKG_NAME}/usr/bin/whisper-server"; do + ldd "$bin" | awk '/=> \/.*(whisper|ggml|gomp|openblas|blas)/ {print $3}' | xargs -r -I{} cp -L {} "${PKG_NAME}/usr/lib/yap/" + patchelf --set-rpath '$ORIGIN/../lib/yap' "$bin" + done + for lib in "${PKG_NAME}/usr/lib/yap"/*; do + [ -f "$lib" ] && patchelf --set-rpath '$ORIGIN' "$lib" || true + done cp build/appicon.iconset/icon_256x256.png "${PKG_NAME}/usr/share/icons/hicolor/256x256/apps/yap.png" cat > "${PKG_NAME}/usr/share/applications/yap.desktop" << EOF [Desktop Entry] @@ -545,7 +581,7 @@ jobs: Speech-to-Text Desktop App by [Applause Lab](https://applauselab.ai) - Local transcription runtime (`whisper-cli`) is bundled with release artifacts. Whisper model files are still downloaded on first setup into the user's Yap data directory. + Local transcription runtimes (`whisper-server` with `whisper-cli` fallback) are bundled with release artifacts. Whisper model files are still downloaded on first setup into the user's Yap data directory. ### Downloads diff --git a/app.go b/app.go index dcdbd92..8068915 100644 --- a/app.go +++ b/app.go @@ -176,6 +176,8 @@ func (a *App) startup(ctx context.Context) { a.openaiEngine = transcribe.NewOpenAIEngine(configManager.Get().OpenAIAPIKey) if err := a.validateReadyToRecord(); err != nil { runtime.LogWarning(a.ctx, fmt.Sprintf("Transcription not ready: %v", err)) + } else if configManager.Get().Provider == "local" { + a.prewarmLocalEngine() } // Check accessibility permissions without prompting. @@ -229,6 +231,11 @@ func (a *App) shutdown(ctx context.Context) { if a.overlay != nil { a.overlay.Destroy() } + if a.localEngine != nil { + if err := a.localEngine.Close(); err != nil { + runtime.LogWarning(a.ctx, fmt.Sprintf("Failed to stop local transcription worker: %v", err)) + } + } audio.Terminate() sounds.Cleanup() runtime.LogInfo(ctx, "OnShutdown: cleanup complete") @@ -614,6 +621,7 @@ func (a *App) transcribe(samples []float32, duration float64) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() + transcriptionStarted := time.Now() if config.Provider == "openai" { text, err = a.openaiEngine.Transcribe(ctx, samples) @@ -622,6 +630,12 @@ func (a *App) transcribe(samples []float32, duration float64) { } if err != nil { runtime.LogWarning(a.ctx, fmt.Sprintf("Transcription failed: provider=%s model=%s duration=%.2fs samples=%d error=%v", config.Provider, config.Model, duration, len(samples), err)) + } else { + backend := config.Provider + if config.Provider == "local" { + backend = a.localEngine.Backend() + } + runtime.LogInfo(a.ctx, fmt.Sprintf("Transcription complete: provider=%s model=%s backend=%s audioDuration=%.2fs elapsed=%s", config.Provider, config.Model, backend, duration, time.Since(transcriptionStarted).Round(time.Millisecond))) } // Generate unique ID for this recording @@ -786,6 +800,9 @@ func (a *App) SetModel(model string) error { return err } a.localEngine.SetModel(transcribe.Model(model)) + if a.configManager.Get().Provider == "local" { + a.prewarmLocalEngine() + } a.emitState() return nil } @@ -795,10 +812,32 @@ func (a *App) SetProvider(provider string) error { if err := a.configManager.SetProvider(provider); err != nil { return err } + if provider == "local" { + a.localEngine.Open() + a.prewarmLocalEngine() + } else if a.localEngine != nil { + _ = a.localEngine.Close() + } a.emitState() return nil } +func (a *App) prewarmLocalEngine() { + if a.localEngine == nil { + return + } + go func() { + started := time.Now() + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + if err := a.localEngine.Prewarm(ctx); err != nil { + runtime.LogWarning(a.ctx, fmt.Sprintf("Persistent Whisper prewarm failed: %v", err)) + return + } + runtime.LogInfo(a.ctx, fmt.Sprintf("Persistent Whisper prewarm complete: backend=%s elapsed=%s", a.localEngine.Backend(), time.Since(started).Round(time.Millisecond))) + }() +} + // SetOpenAIKey sets the OpenAI API key func (a *App) SetOpenAIKey(key string) error { if err := a.configManager.SetOpenAIAPIKey(key); err != nil { diff --git a/build/windows/installer/project.nsi b/build/windows/installer/project.nsi index d2d05cb..3f8f028 100644 --- a/build/windows/installer/project.nsi +++ b/build/windows/installer/project.nsi @@ -91,6 +91,11 @@ Section ; Include PortAudio DLL for audio recording functionality File "..\..\bin\portaudio.dll" + ; Include persistent Whisper worker, CLI fallback, and runtime DLLs + SetOutPath $INSTDIR\bin + File /r "..\..\bin\bin\*.*" + SetOutPath $INSTDIR + CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}" CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}" @@ -107,6 +112,7 @@ Section "uninstall" ; Remove PortAudio DLL Delete "$INSTDIR\portaudio.dll" + RMDir /r "$INSTDIR\bin" RMDir /r $INSTDIR diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 258a6e9..f79b3ac 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "0.4.1", + "version": "0.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "0.4.1", + "version": "0.4.2", "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0" diff --git a/frontend/package.json b/frontend/package.json index 423f219..50bf032 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.4.1", + "version": "0.4.2", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 5ddc60b..ff76a6d 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -5ed087423043b58e5b2fc9dd9c3156b1 \ No newline at end of file +2a4ee46ca604023a8c5ca977c49538ff \ No newline at end of file diff --git a/frontend/src/App.css b/frontend/src/App.css index 0b66fb5..0ce6706 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -933,6 +933,13 @@ body { color: var(--success); } +.hotkey-status p { + margin: 6px 0 0; + font-size: 12px; + line-height: 1.4; + color: var(--text-muted); +} + .settings-footer { display: flex; justify-content: space-between; @@ -1184,6 +1191,177 @@ body { font-size: 14px; } +/* Accessibility recovery after app updates or permission revocation */ +.accessibility-recovery { + position: fixed; + inset: 0; + display: grid; + place-items: center; + padding: 48px 32px 32px; + overflow-y: auto; + background: + radial-gradient(circle at 50% -15%, rgba(0, 255, 78, 0.14), transparent 48%), + linear-gradient(145deg, #18191a 0%, var(--bg-dark) 58%, #151716 100%); + --wails-draggable: drag; +} + +.accessibility-recovery-card { + width: min(620px, 100%); + padding: 42px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 24px; + background: rgba(44, 44, 46, 0.88); + box-shadow: 0 28px 70px rgba(0, 0, 0, 0.38); + backdrop-filter: blur(22px); + --wails-draggable: no-drag; +} + +.accessibility-recovery-icon { + width: 58px; + height: 58px; + display: grid; + place-items: center; + margin-bottom: 28px; + border: 1px solid rgba(0, 255, 78, 0.3); + border-radius: 18px; + color: var(--accent); + background: rgba(0, 255, 78, 0.09); +} + +.accessibility-recovery-icon.complete { + color: var(--success); + background: rgba(48, 209, 88, 0.12); +} + +.accessibility-recovery-icon svg { + width: 29px; + height: 29px; +} + +.accessibility-recovery-copy { + max-width: 520px; +} + +.accessibility-recovery-eyebrow { + display: block; + margin-bottom: 10px; + color: var(--accent); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.accessibility-recovery-copy h1 { + margin-bottom: 14px; + font-size: clamp(26px, 4vw, 34px); + line-height: 1.12; + letter-spacing: -0.025em; +} + +.accessibility-recovery-copy > p { + color: var(--text-secondary); + font-size: 15px; + line-height: 1.55; +} + +.accessibility-recovery-steps { + display: grid; + grid-template-columns: 26px 1fr; + gap: 12px 14px; + margin-top: 28px; + padding: 22px; + border: 1px solid var(--border); + border-radius: 16px; + background: rgba(0, 0, 0, 0.14); +} + +.accessibility-recovery-steps span { + width: 26px; + height: 26px; + display: grid; + place-items: center; + border-radius: 50%; + color: #061208; + background: var(--accent); + font-size: 12px; + font-weight: 800; +} + +.accessibility-recovery-steps p { + align-self: center; + color: #d6d6d8; + font-size: 14px; + line-height: 1.4; +} + +.accessibility-recovery-actions { + display: flex; + gap: 10px; + margin-top: 28px; +} + +.accessibility-recovery-actions button { + min-height: 44px; + padding: 0 18px; + border-radius: 11px; + font-size: 13px; + font-weight: 650; + cursor: pointer; +} + +.accessibility-recovery-actions button:disabled { + cursor: wait; + opacity: 0.62; +} + +.recovery-primary { + border: 1px solid var(--accent); + color: #071109; + background: var(--accent); +} + +.recovery-primary:hover:not(:disabled) { + filter: brightness(1.08); +} + +.recovery-secondary { + border: 1px solid var(--border); + color: var(--text-primary); + background: rgba(255, 255, 255, 0.04); +} + +.recovery-secondary:hover:not(:disabled) { + background: var(--bg-hover); +} + +.accessibility-recovery-error { + margin-top: 16px; + padding: 12px 14px; + border: 1px solid rgba(255, 69, 58, 0.25); + border-radius: 10px; + color: #ff9a94; + background: rgba(255, 69, 58, 0.08); + font-size: 12px; + line-height: 1.45; +} + +@media (max-width: 640px) { + .accessibility-recovery { + place-items: start center; + padding: 54px 18px 24px; + } + + .accessibility-recovery-card { + padding: 28px 22px; + border-radius: 19px; + } + + .accessibility-recovery-actions { + flex-direction: column; + } +} + /* App loading state */ .app-loading { position: fixed; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8ef1a6d..c07f973 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -30,6 +30,9 @@ import { GetCancelHotkey, SetCancelHotkey, GetPlatform, + CheckAccessibilityPermission, + RequestAccessibilityPermission, + ReregisterHotkey, Quit, IsOnboardingCompleted, } from '../wailsjs/go/main/App'; @@ -94,6 +97,7 @@ interface UsageStats { } type Page = 'home' | 'settings' | 'history'; +type AccessibilityRecoveryState = 'checking' | 'needed' | 'recovering' | 'error' | 'complete' | 'not-needed'; function App() { useEffect(() => { @@ -136,8 +140,11 @@ function App() { const [platform, setPlatform] = useState('darwin'); const [isCapturingHotkey, setIsCapturingHotkey] = useState(false); const [isCapturingCancelKey, setIsCapturingCancelKey] = useState(false); + const [accessibilityRecovery, setAccessibilityRecovery] = useState('checking'); + const [accessibilityRecoveryError, setAccessibilityRecoveryError] = useState(''); const hotkeyInputRef = useRef(null); const cancelKeyInputRef = useRef(null); + const accessibilityRecoveryInFlightRef = useRef(false); // Check if onboarding is needed on mount and get platform useEffect(() => { @@ -147,6 +154,97 @@ function App() { GetPlatform().then((p: string) => setPlatform(p)); }, []); + const finishAccessibilityRecovery = useCallback(async () => { + if (accessibilityRecoveryInFlightRef.current) return; + accessibilityRecoveryInFlightRef.current = true; + setAccessibilityRecovery('recovering'); + setAccessibilityRecoveryError(''); + try { + await ReregisterHotkey(); + const state = await GetState() as AppState; + if (!state.hotkeyEnabled) { + throw new Error('Yap could not register the global hotkey'); + } + setAppState(state); + setAccessibilityRecovery('complete'); + LogInfo('[frontend] Accessibility recovery completed; hotkey re-registered'); + window.setTimeout(() => setAccessibilityRecovery('not-needed'), 700); + } catch (err) { + const message = String(err); + setAccessibilityRecoveryError(message); + setAccessibilityRecovery('error'); + LogError(`[frontend] Accessibility recovery failed: ${message}`); + } finally { + accessibilityRecoveryInFlightRef.current = false; + } + }, []); + + const checkAccessibilityRecovery = useCallback(async () => { + try { + const granted = await CheckAccessibilityPermission(); + if (granted) { + await finishAccessibilityRecovery(); + } else { + setAccessibilityRecovery((current) => current === 'error' ? current : 'needed'); + } + } catch (err) { + const message = String(err); + setAccessibilityRecoveryError(message); + setAccessibilityRecovery('error'); + LogError(`[frontend] Accessibility recovery check failed: ${message}`); + } + }, [finishAccessibilityRecovery]); + + const handleOpenAccessibilitySettings = useCallback(async () => { + setAccessibilityRecovery('recovering'); + setAccessibilityRecoveryError(''); + try { + const granted = await RequestAccessibilityPermission(); + if (granted) { + await finishAccessibilityRecovery(); + } else { + setAccessibilityRecovery('needed'); + } + } catch (err) { + const message = String(err); + setAccessibilityRecoveryError(message); + setAccessibilityRecovery('error'); + LogError(`[frontend] Opening Accessibility settings failed: ${message}`); + } + }, [finishAccessibilityRecovery]); + + useEffect(() => { + if (showOnboarding !== false) return; + if (platform !== 'darwin') { + setAccessibilityRecovery('not-needed'); + return; + } + CheckAccessibilityPermission().then((granted) => { + if (granted) { + setAccessibilityRecovery('not-needed'); + } else { + setAccessibilityRecovery('needed'); + LogInfo('[frontend] Showing Accessibility recovery after startup'); + } + }).catch((err) => { + setAccessibilityRecoveryError(String(err)); + setAccessibilityRecovery('error'); + }); + }, [showOnboarding, platform]); + + useEffect(() => { + if (showOnboarding !== false || platform !== 'darwin') return; + if (!['needed', 'recovering', 'error'].includes(accessibilityRecovery)) return; + + const interval = window.setInterval(checkAccessibilityRecovery, 1000); + const handleFocus = () => checkAccessibilityRecovery(); + window.addEventListener('focus', handleFocus); + return () => { + window.clearInterval(interval); + window.removeEventListener('focus', handleFocus); + }; + }, [showOnboarding, platform, accessibilityRecovery, checkAccessibilityRecovery]); + useEffect(() => { LogInfo('[frontend] App initial data load started'); GetState().then((state: AppState) => setAppState(state)); @@ -568,6 +666,73 @@ function App() { return ; } + if (accessibilityRecovery === 'checking') { + return ( +
+
+
+ ); + } + + if (accessibilityRecovery !== 'not-needed') { + const isRecovering = accessibilityRecovery === 'recovering'; + const isComplete = accessibilityRecovery === 'complete'; + return ( +
+
+
+ {isComplete ? ( + + + + ) : ( + + + + + + )} +
+ +
+ One quick macOS check +

{isComplete ? 'Yap is ready again' : 'Yap needs Accessibility access again'}

+ {isComplete ? ( +

Your recording hotkey has been restored. Opening Yap...

+ ) : ( + <> +

After an update, macOS can stop recognizing Yap's existing permission. Your models, history, and settings are safe.

+
+ 1 +

Open Accessibility Settings.

+ 2 +

If Yap is already enabled, toggle it off and back on.

+ 3 +

Return here. Yap will detect access automatically.

+
+ + )} +
+ + {!isComplete && ( +
+ + +
+ )} + + {accessibilityRecovery === 'error' && ( +

{accessibilityRecoveryError || 'Yap still cannot register the recording hotkey. Toggle access off and back on, then check again.'}

+ )} +
+
+ ); + } + return ( <>
@@ -936,8 +1101,11 @@ function App() {
- {appState.hotkeyEnabled ? 'Hotkeys Active' : 'Hotkeys Not Registered'} + {appState.hotkeyEnabled ? 'Hotkeys Active' : 'Hotkeys Need Accessibility Access'} + {!appState.hotkeyEnabled && platform === 'darwin' && ( +

Open System Settings → Privacy & Security → Accessibility, then toggle Yap off and back on.

+ )}
diff --git a/frontend/src/Onboarding.tsx b/frontend/src/Onboarding.tsx index be33c4a..4cd542b 100644 --- a/frontend/src/Onboarding.tsx +++ b/frontend/src/Onboarding.tsx @@ -418,7 +418,7 @@ export function Onboarding({ onComplete }: OnboardingProps) {
{model.name === 'tiny.en' && 'Fastest option, great for quick notes and simple dictation'} {model.name === 'base.en' && 'Best balance of speed and accuracy for everyday use'} - {model.name === 'small.en' && 'Higher accuracy for detailed transcription, slightly slower'} + {model.name === 'small.en' && 'Higher accuracy for detailed transcription, but much slower on CPU'}
{model.downloaded && ( diff --git a/internal/hotkey/hotkey_darwin.go b/internal/hotkey/hotkey_darwin.go index 9bdd19b..5f37284 100644 --- a/internal/hotkey/hotkey_darwin.go +++ b/internal/hotkey/hotkey_darwin.go @@ -63,6 +63,10 @@ static int requestAccessibilityPermissions(void) { NSLog(@"IMPORTANT: Please grant Accessibility permissions to enable hotkey features"); NSLog(@"Go to: System Preferences > Privacy & Security > Accessibility"); NSLog(@"Add and enable this application"); + dispatch_async(dispatch_get_main_queue(), ^{ + NSURL *url = [NSURL URLWithString:@"x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"]; + [[NSWorkspace sharedWorkspace] openURL:url]; + }); } return trusted; } @@ -331,6 +335,10 @@ func (m *Manager) Register(cb Callback) error { return nil } + if !HasAccessibilityPermissions() { + return fmt.Errorf("accessibility permission not granted") + } + callbackMu.Lock() hotkeyCallback = cb callbackMu.Unlock() diff --git a/internal/transcribe/whisper_local.go b/internal/transcribe/whisper_local.go index d7676c2..70ca7bb 100644 --- a/internal/transcribe/whisper_local.go +++ b/internal/transcribe/whisper_local.go @@ -8,21 +8,43 @@ import ( "path/filepath" "runtime" "strings" + "sync" ) // LocalEngine uses local whisper.cpp for transcription type LocalEngine struct { - model Model - modelsDir string - whisperBin string // Path to whisper CLI binary + modelMu sync.RWMutex + model Model + modelsDir string + whisperBin string // Path to whisper CLI binary + whisperServerBin string + whisperServerSet bool + backendMu sync.Mutex + metalUnavailable bool + workerMu sync.Mutex + worker *whisperWorker + lastBackend string + serverUnavailable bool + lifecycleMu sync.Mutex + lifecycleCtx context.Context + lifecycleCancel context.CancelFunc + lifecycleGeneration uint64 + closed bool +} + +type whisperBackend struct { + path string + metal bool } // NewLocalEngine creates a new local whisper.cpp engine func NewLocalEngine(modelsDir string) *LocalEngine { - return &LocalEngine{ + engine := &LocalEngine{ model: ModelBaseEn, modelsDir: modelsDir, } + engine.lifecycleCtx, engine.lifecycleCancel = context.WithCancel(context.Background()) + return engine } // SetWhisperBinary sets the path to the whisper CLI binary @@ -30,6 +52,16 @@ func (e *LocalEngine) SetWhisperBinary(path string) { e.whisperBin = path } +// SetWhisperServerBinary sets the persistent whisper server binary path. +func (e *LocalEngine) SetWhisperServerBinary(path string) { + e.resetOperations() + e.workerMu.Lock() + defer e.workerMu.Unlock() + e.stopWorkerLocked() + e.whisperServerBin = path + e.whisperServerSet = true +} + // Transcribe converts audio samples to text func (e *LocalEngine) Transcribe(ctx context.Context, samples []float32) (string, error) { wavData, err := samplesToWAV(samples) @@ -41,9 +73,9 @@ func (e *LocalEngine) Transcribe(ctx context.Context, samples []float32) (string // TranscribeWAV transcribes WAV audio data using whisper CLI func (e *LocalEngine) TranscribeWAV(ctx context.Context, wavData []byte) (string, error) { - whisperBin := e.findWhisperBinary() - if whisperBin == "" { - return "", fmt.Errorf("whisper-cli not found. Please install whisper.cpp or set the binary path") + generation, err := e.currentOperationGeneration() + if err != nil { + return "", err } // Get model path @@ -52,6 +84,26 @@ func (e *LocalEngine) TranscribeWAV(ctx context.Context, wavData []byte) (string return "", fmt.Errorf("model file not found: %s. Please download the model first", modelPath) } + var workerErr error + if e.findWhisperServerBinary() != "" { + text, err := e.transcribePersistent(ctx, wavData, modelPath) + if err == nil { + return text, nil + } + workerErr = err + if !e.operationGenerationIsCurrent(generation) { + return "", workerErr + } + } + + whisperBin := e.findWhisperBinary() + if whisperBin == "" { + if workerErr != nil { + return "", fmt.Errorf("persistent whisper worker failed: %w; whisper-cli fallback not found", workerErr) + } + return "", fmt.Errorf("whisper runtime not found. Please install whisper.cpp or set the binary path") + } + // Create temp file for audio tmpFile, err := os.CreateTemp("", "whisper-input-*.wav") if err != nil { @@ -67,45 +119,165 @@ func (e *LocalEngine) TranscribeWAV(ctx context.Context, wavData []byte) (string } tmpFile.Close() - args := []string{ + baseArgs := []string{ "-m", modelPath, "-f", tmpFile.Name(), "--no-timestamps", "-otxt", "-of", tmpFile.Name(), } - cmdEnv := e.whisperEnv(os.Environ(), whisperBin) - if strings.Contains(envValue(cmdEnv, "GGML_BACKEND_PATH"), "libggml-cpu") { - args = append(args, "--no-gpu") - } - cmd := exec.CommandContext(ctx, whisperBin, args...) - cmd.Env = cmdEnv + for _, backend := range e.transcriptionBackends(whisperBin) { + _ = os.Remove(outputPath) + args := append([]string{}, baseArgs...) + if strings.Contains(backend.path, "libggml-cpu") { + args = append(args, "--no-gpu") + } + cmdEnv := envWithValue(os.Environ(), "GGML_BACKEND_PATH", backend.path) + cmd := exec.CommandContext(ctx, whisperBin, args...) + cmd.Env = cmdEnv + + output, err := cmd.CombinedOutput() + if err != nil { + if backend.metal && ctx.Err() == nil { + e.disableMetal() + continue + } + return "", e.commandError("whisper failed", err, output, whisperBin, modelPath, cmdEnv) + } - output, err := cmd.CombinedOutput() - if err != nil { - return "", e.commandError("whisper failed", err, output, whisperBin, modelPath) - } + textBytes, err := os.ReadFile(outputPath) + if err != nil { + return "", fmt.Errorf("failed to read whisper output file %q: %w; command output=%q", outputPath, err, trimCommandOutput(string(output))) + } - textBytes, err := os.ReadFile(outputPath) - if err != nil { - return "", fmt.Errorf("failed to read whisper output file %q: %w; command output=%q", outputPath, err, trimCommandOutput(string(output))) + e.workerMu.Lock() + e.lastBackend = "whisper-cli" + e.workerMu.Unlock() + return strings.TrimSpace(string(textBytes)), nil } - - text := strings.TrimSpace(string(textBytes)) - return text, nil + return "", fmt.Errorf("no usable whisper backend found") } // SetModel sets the model to use func (e *LocalEngine) SetModel(model Model) error { + e.resetOperations() + e.workerMu.Lock() + defer e.workerMu.Unlock() + e.modelMu.Lock() + defer e.modelMu.Unlock() + if e.model != model { + e.stopWorkerLocked() + } e.model = model return nil } +// Close stops the persistent local transcription worker. +func (e *LocalEngine) Close() error { + e.lifecycleMu.Lock() + e.closed = true + e.lifecycleGeneration++ + e.lifecycleCancel() + e.lifecycleMu.Unlock() + e.workerMu.Lock() + defer e.workerMu.Unlock() + e.stopWorkerLocked() + return nil +} + +// Open allows worker startup after switching back to the local provider. +func (e *LocalEngine) Open() { + e.lifecycleMu.Lock() + defer e.lifecycleMu.Unlock() + if !e.closed { + return + } + e.lifecycleCtx, e.lifecycleCancel = context.WithCancel(context.Background()) + e.lifecycleGeneration++ + e.closed = false +} + // GetModel returns the current model func (e *LocalEngine) GetModel() Model { + e.modelMu.RLock() + defer e.modelMu.RUnlock() return e.model } +// Prewarm starts the persistent worker and loads the selected model. +func (e *LocalEngine) Prewarm(ctx context.Context) error { + operationCtx, cancel, err := e.operationContext(ctx) + if err != nil { + return err + } + defer cancel() + + e.workerMu.Lock() + defer e.workerMu.Unlock() + modelPath := e.getModelPath() + if _, err := os.Stat(modelPath); err != nil { + return err + } + _, err = e.ensureWorkerLocked(operationCtx, modelPath) + return err +} + +func (e *LocalEngine) operationContext(parent context.Context) (context.Context, context.CancelFunc, error) { + e.lifecycleMu.Lock() + defer e.lifecycleMu.Unlock() + if e.closed { + return nil, nil, fmt.Errorf("local transcription engine is closed") + } + ctx, cancel := context.WithCancel(parent) + stop := context.AfterFunc(e.lifecycleCtx, cancel) + return ctx, func() { + stop() + cancel() + }, nil +} + +func (e *LocalEngine) resetOperations() { + e.lifecycleMu.Lock() + defer e.lifecycleMu.Unlock() + if e.closed { + return + } + e.lifecycleCancel() + e.lifecycleCtx, e.lifecycleCancel = context.WithCancel(context.Background()) + e.lifecycleGeneration++ +} + +func (e *LocalEngine) currentOperationGeneration() (uint64, error) { + e.lifecycleMu.Lock() + defer e.lifecycleMu.Unlock() + if e.closed { + return 0, fmt.Errorf("local transcription engine is closed") + } + return e.lifecycleGeneration, nil +} + +func (e *LocalEngine) operationGenerationIsCurrent(generation uint64) bool { + e.lifecycleMu.Lock() + defer e.lifecycleMu.Unlock() + return !e.closed && e.lifecycleGeneration == generation +} + +// Backend reports the active local transcription backend. +func (e *LocalEngine) Backend() string { + e.workerMu.Lock() + defer e.workerMu.Unlock() + if e.worker != nil { + if e.worker.metal { + return "metal-worker" + } + return "cpu-worker" + } + if e.lastBackend != "" { + return e.lastBackend + } + return "not-started" +} + // IsAvailable checks if the engine is ready func (e *LocalEngine) IsAvailable() bool { // Check if model file exists @@ -114,7 +286,7 @@ func (e *LocalEngine) IsAvailable() bool { return false } - return e.findWhisperBinary() != "" + return e.findWhisperServerBinary() != "" || e.findWhisperBinary() != "" } // ValidateRuntime checks that the resolved whisper-cli can launch with the same @@ -129,7 +301,7 @@ func (e *LocalEngine) ValidateRuntime(ctx context.Context) error { cmd.Env = e.whisperEnv(os.Environ(), whisperBin) output, err := cmd.CombinedOutput() if err != nil { - return e.commandError("whisper runtime validation failed", err, output, whisperBin, e.getModelPath()) + return e.commandError("whisper runtime validation failed", err, output, whisperBin, e.getModelPath(), cmd.Env) } return nil } @@ -164,6 +336,30 @@ func (e *LocalEngine) findWhisperBinary() string { return "" } +func (e *LocalEngine) findWhisperServerBinary() string { + if e.whisperServerSet { + if isExecutable(e.whisperServerBin) { + return e.whisperServerBin + } + if p, err := exec.LookPath(e.whisperServerBin); err == nil { + return p + } + return "" + } + + for _, p := range bundledWhisperServerCandidates() { + if isExecutable(p) { + return p + } + } + for _, name := range whisperServerBinaryNames() { + if p, err := exec.LookPath(name); err == nil { + return p + } + } + return "" +} + func bundledWhisperCandidates() []string { exe, err := os.Executable() if err != nil { @@ -195,6 +391,26 @@ func bundledWhisperCandidates() []string { return candidates } +func bundledWhisperServerCandidates() []string { + exe, err := os.Executable() + if err != nil { + return nil + } + exeDir := filepath.Dir(exe) + candidates := make([]string, 0, 4) + for _, name := range whisperServerBinaryNames() { + switch runtime.GOOS { + case "darwin": + candidates = append(candidates, filepath.Join(exeDir, "..", "Resources", "bin", name), filepath.Join(exeDir, name)) + case "windows": + candidates = append(candidates, filepath.Join(exeDir, "bin", name), filepath.Join(exeDir, name)) + default: + candidates = append(candidates, filepath.Join(exeDir, name), filepath.Join(exeDir, "bin", name), filepath.Join(exeDir, "..", "lib", "yap", "bin", name)) + } + } + return candidates +} + func systemWhisperCandidates() []string { home := os.Getenv("HOME") switch runtime.GOOS { @@ -225,6 +441,13 @@ func whisperBinaryNames() []string { return []string{"whisper-cli"} } +func whisperServerBinaryNames() []string { + if runtime.GOOS == "windows" { + return []string{"whisper-server.exe", "whisper-server"} + } + return []string{"whisper-server"} +} + func isExecutable(path string) bool { info, err := os.Stat(path) return err == nil && !info.IsDir() @@ -244,12 +467,39 @@ func (e *LocalEngine) whisperEnv(env []string, whisperBin string) []string { } for _, p := range backendCandidates { if info, err := os.Stat(p); err == nil && !info.IsDir() { - return append(env, "GGML_BACKEND_PATH="+p) + return envWithValue(env, "GGML_BACKEND_PATH", p) } } return env } +func (e *LocalEngine) transcriptionBackends(whisperBin string) []whisperBackend { + binDir := filepath.Dir(whisperBin) + backends := make([]whisperBackend, 0, 2) + if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" && e.metalEnabled() { + metalPath := filepath.Join(binDir, "..", "libexec", "libggml-metal.so") + if info, err := os.Stat(metalPath); err == nil && !info.IsDir() { + backends = append(backends, whisperBackend{path: metalPath, metal: true}) + } + } + + cpuPath := envValue(e.whisperEnv(nil, whisperBin), "GGML_BACKEND_PATH") + backends = append(backends, whisperBackend{path: cpuPath}) + return backends +} + +func (e *LocalEngine) metalEnabled() bool { + e.backendMu.Lock() + defer e.backendMu.Unlock() + return !e.metalUnavailable +} + +func (e *LocalEngine) disableMetal() { + e.backendMu.Lock() + e.metalUnavailable = true + e.backendMu.Unlock() +} + func preferredAppleCPUBackend() string { if runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" { return "libggml-cpu-apple_m1.so" @@ -270,8 +520,8 @@ func preferredAppleCPUBackend() string { } } -func (e *LocalEngine) commandError(prefix string, err error, output []byte, whisperBin string, modelPath string) error { - backendPath := envValue(e.whisperEnv(os.Environ(), whisperBin), "GGML_BACKEND_PATH") +func (e *LocalEngine) commandError(prefix string, err error, output []byte, whisperBin string, modelPath string, env []string) error { + backendPath := envValue(env, "GGML_BACKEND_PATH") if backendPath == "" { backendPath = "(unset)" } @@ -287,6 +537,20 @@ func (e *LocalEngine) commandError(prefix string, err error, output []byte, whis ) } +func envWithValue(env []string, key string, value string) []string { + prefix := key + "=" + result := make([]string, 0, len(env)+1) + for _, entry := range env { + if !strings.HasPrefix(entry, prefix) { + result = append(result, entry) + } + } + if value != "" { + result = append(result, prefix+value) + } + return result +} + func envValue(env []string, key string) string { prefix := key + "=" for _, entry := range env { @@ -313,6 +577,8 @@ func (e *LocalEngine) Name() Provider { // getModelPath returns the full path to the model file func (e *LocalEngine) getModelPath() string { + e.modelMu.RLock() + defer e.modelMu.RUnlock() modelFile := fmt.Sprintf("ggml-%s.bin", e.model) return filepath.Join(e.modelsDir, modelFile) } diff --git a/internal/transcribe/whisper_local_integration_test.go b/internal/transcribe/whisper_local_integration_test.go index c5acf1a..f63eafe 100644 --- a/internal/transcribe/whisper_local_integration_test.go +++ b/internal/transcribe/whisper_local_integration_test.go @@ -14,8 +14,16 @@ func TestLocalEngineTranscribeWAVReturnsTranscriptOnly(t *testing.T) { } engine := NewLocalEngine(os.Getenv("YAP_TEST_WHISPER_MODELS_DIR")) - engine.SetModel(ModelTinyEn) + model := Model(os.Getenv("YAP_TEST_WHISPER_MODEL")) + if model == "" { + model = ModelTinyEn + } + engine.SetModel(model) engine.SetWhisperBinary(os.Getenv("YAP_TEST_WHISPER_BIN")) + if serverBin := os.Getenv("YAP_TEST_WHISPER_SERVER"); serverBin != "" { + engine.SetWhisperServerBinary(serverBin) + } + defer engine.Close() wavData, err := os.ReadFile(os.Getenv("YAP_TEST_WHISPER_WAV")) if err != nil { @@ -24,15 +32,18 @@ func TestLocalEngineTranscribeWAVReturnsTranscriptOnly(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - text, err := engine.TranscribeWAV(ctx, wavData) - if err != nil { - t.Fatal(err) - } - - if strings.Contains(text, "ggml_backend") || strings.Contains(text, "whisper_model_load") { - t.Fatalf("transcript includes whisper diagnostics: %q", text) - } - if text == "" { - t.Fatal("empty transcript") + for i := 0; i < 2; i++ { + started := time.Now() + text, err := engine.TranscribeWAV(ctx, wavData) + if err != nil { + t.Fatal(err) + } + t.Logf("transcription %d backend=%s elapsed=%s text=%q", i+1, engine.Backend(), time.Since(started), text) + if strings.Contains(text, "ggml_backend") || strings.Contains(text, "whisper_model_load") { + t.Fatalf("transcript includes whisper diagnostics: %q", text) + } + if text == "" { + t.Fatal("empty transcript") + } } } diff --git a/internal/transcribe/whisper_local_test.go b/internal/transcribe/whisper_local_test.go index e218962..132b8ed 100644 --- a/internal/transcribe/whisper_local_test.go +++ b/internal/transcribe/whisper_local_test.go @@ -1,8 +1,11 @@ package transcribe import ( + "context" "os" "path/filepath" + "runtime" + "strings" "testing" ) @@ -34,3 +37,72 @@ func TestWhisperEnvUsesBackendFile(t *testing.T) { t.Fatalf("GGML_BACKEND_PATH = %q, want %q", got, backendPath) } } + +func TestTranscribeWAVFallsBackFromMetalAndCachesFailure(t *testing.T) { + if runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" { + t.Skip("Metal backend is only used on Apple Silicon") + } + + tmpDir := t.TempDir() + binDir := filepath.Join(tmpDir, "bin") + libexecDir := filepath.Join(tmpDir, "libexec") + modelsDir := filepath.Join(tmpDir, "models") + for _, dir := range []string{binDir, libexecDir, modelsDir} { + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + } + + metalPath := filepath.Join(libexecDir, "libggml-metal.so") + cpuPath := filepath.Join(libexecDir, preferredAppleCPUBackend()) + for _, path := range []string{metalPath, cpuPath, filepath.Join(modelsDir, "ggml-base.en.bin")} { + if err := os.WriteFile(path, []byte("test"), 0644); err != nil { + t.Fatal(err) + } + } + + attemptLog := filepath.Join(tmpDir, "attempts.log") + whisperBin := filepath.Join(binDir, "whisper-cli") + script := `#!/bin/sh +echo "$GGML_BACKEND_PATH" >> "` + attemptLog + `" +case "$GGML_BACKEND_PATH" in + *libggml-metal.so) exit 1 ;; +esac +while [ "$#" -gt 0 ]; do + if [ "$1" = "-of" ]; then + shift + printf 'fallback transcript\n' > "$1.txt" + exit 0 + fi + shift +done +exit 2 +` + if err := os.WriteFile(whisperBin, []byte(script), 0755); err != nil { + t.Fatal(err) + } + + engine := NewLocalEngine(modelsDir) + engine.SetWhisperBinary(whisperBin) + engine.SetWhisperServerBinary(filepath.Join(tmpDir, "missing-whisper-server")) + + for i := 0; i < 2; i++ { + text, err := engine.TranscribeWAV(context.Background(), []byte("wav")) + if err != nil { + t.Fatal(err) + } + if text != "fallback transcript" { + t.Fatalf("transcript = %q", text) + } + } + + data, err := os.ReadFile(attemptLog) + if err != nil { + t.Fatal(err) + } + attempts := strings.Fields(string(data)) + want := []string{metalPath, cpuPath, cpuPath} + if strings.Join(attempts, "\n") != strings.Join(want, "\n") { + t.Fatalf("backend attempts = %q, want %q", attempts, want) + } +} diff --git a/internal/transcribe/whisper_worker.go b/internal/transcribe/whisper_worker.go new file mode 100644 index 0000000..63009ab --- /dev/null +++ b/internal/transcribe/whisper_worker.go @@ -0,0 +1,320 @@ +package transcribe + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "mime/multipart" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "yap/internal/logger" +) + +const ( + workerStartupTimeout = 30 * time.Second + workerStopTimeout = 2 * time.Second + workerResponseLimit = 1 << 20 +) + +type whisperWorker struct { + cmd *exec.Cmd + done chan struct{} + waitErr error + client *http.Client + baseURL string + modelPath string + backendPath string + metal bool + diagnostics *lockedBuffer +} + +type lockedBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *lockedBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *lockedBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return trimCommandOutput(b.buf.String()) +} + +func (e *LocalEngine) transcribePersistent(ctx context.Context, wavData []byte, modelPath string) (string, error) { + operationCtx, cancel, err := e.operationContext(ctx) + if err != nil { + return "", err + } + defer cancel() + ctx = operationCtx + + e.workerMu.Lock() + defer e.workerMu.Unlock() + + worker, err := e.ensureWorkerLocked(ctx, modelPath) + if err != nil { + if ctx.Err() == nil { + e.serverUnavailable = true + } + return "", err + } + text, err := worker.transcribe(ctx, wavData) + if err == nil { + e.lastBackend = workerBackendName(worker) + return text, nil + } + + e.stopWorkerLocked() + if !worker.metal || ctx.Err() != nil { + if ctx.Err() == nil { + e.serverUnavailable = true + } + return "", err + } + + e.disableMetal() + logger.Warning(fmt.Sprintf("Persistent Metal worker failed; retrying on CPU: %v", err)) + worker, startErr := e.startCPUWorkerLocked(ctx, modelPath) + if startErr != nil { + if ctx.Err() == nil { + e.serverUnavailable = true + } + return "", fmt.Errorf("Metal worker failed: %v; CPU worker failed to start: %w", err, startErr) + } + text, err = worker.transcribe(ctx, wavData) + if err == nil { + e.lastBackend = workerBackendName(worker) + } else { + e.stopWorkerLocked() + if ctx.Err() == nil { + e.serverUnavailable = true + } + } + return text, err +} + +func (e *LocalEngine) ensureWorkerLocked(ctx context.Context, modelPath string) (*whisperWorker, error) { + if e.serverUnavailable { + return nil, fmt.Errorf("persistent whisper worker is disabled for this session") + } + if e.worker != nil && e.worker.modelPath == modelPath && e.worker.running() { + return e.worker, nil + } + e.stopWorkerLocked() + + serverBin := e.findWhisperServerBinary() + if serverBin == "" { + return nil, fmt.Errorf("whisper-server not found") + } + binDir := filepath.Dir(serverBin) + if e.metalEnabled() { + metalPath := filepath.Join(binDir, "..", "libexec", "libggml-metal.so") + if isExecutable(metalPath) { + worker, err := e.startWorkerLocked(ctx, serverBin, modelPath, whisperBackend{path: metalPath, metal: true}) + if err == nil { + return worker, nil + } + e.disableMetal() + } + } + return e.startCPUWorkerLocked(ctx, modelPath) +} + +func (e *LocalEngine) startCPUWorkerLocked(ctx context.Context, modelPath string) (*whisperWorker, error) { + serverBin := e.findWhisperServerBinary() + if serverBin == "" { + return nil, fmt.Errorf("whisper-server not found") + } + cpuPath := envValue(e.whisperEnv(nil, serverBin), "GGML_BACKEND_PATH") + return e.startWorkerLocked(ctx, serverBin, modelPath, whisperBackend{path: cpuPath}) +} + +func (e *LocalEngine) startWorkerLocked(ctx context.Context, serverBin string, modelPath string, backend whisperBackend) (*whisperWorker, error) { + port, err := availableLoopbackPort() + if err != nil { + return nil, err + } + token, err := randomWorkerToken() + if err != nil { + return nil, err + } + requestPath := "/yap-" + token + args := []string{"-m", modelPath, "--host", "127.0.0.1", "--port", strconv.Itoa(port), "--request-path", requestPath, "--inference-path", "/inference", "--no-timestamps"} + if strings.Contains(backend.path, "libggml-cpu") { + args = append(args, "--no-gpu") + } + + diagnostics := &lockedBuffer{} + cmd := exec.Command(serverBin, args...) + cmd.Env = envWithValue(os.Environ(), "GGML_BACKEND_PATH", backend.path) + cmd.Stdout = io.Discard + cmd.Stderr = diagnostics + if err := cmd.Start(); err != nil { + return nil, err + } + + worker := &whisperWorker{ + cmd: cmd, + done: make(chan struct{}), + client: &http.Client{Timeout: 5 * time.Minute}, + baseURL: fmt.Sprintf("http://127.0.0.1:%d%s", port, requestPath), + modelPath: modelPath, + backendPath: backend.path, + metal: backend.metal, + diagnostics: diagnostics, + } + go func() { + worker.waitErr = cmd.Wait() + close(worker.done) + }() + + startupCtx, cancel := context.WithTimeout(ctx, workerStartupTimeout) + defer cancel() + if err := worker.waitReady(startupCtx); err != nil { + worker.stop() + return nil, fmt.Errorf("worker startup failed: %w; backend=%q output=%q", err, backend.path, diagnostics.String()) + } + e.worker = worker + e.lastBackend = workerBackendName(worker) + logger.Info(fmt.Sprintf("Persistent Whisper worker ready: backend=%s model=%q pid=%d", e.lastBackend, modelPath, cmd.Process.Pid)) + return worker, nil +} + +func (e *LocalEngine) stopWorkerLocked() { + if e.worker != nil { + e.worker.stop() + e.worker = nil + } +} + +func (w *whisperWorker) waitReady(ctx context.Context) error { + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + for { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, w.baseURL+"/", nil) + if err != nil { + return err + } + resp, err := w.client.Do(req) + if err == nil { + _ = resp.Body.Close() + if resp.StatusCode == http.StatusOK && resp.Header.Get("Server") == "whisper.cpp" { + return nil + } + } + select { + case <-w.done: + return fmt.Errorf("process exited: %w", w.waitErr) + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} + +func (w *whisperWorker) transcribe(ctx context.Context, wavData []byte) (string, error) { + started := time.Now() + var body bytes.Buffer + form := multipart.NewWriter(&body) + file, err := form.CreateFormFile("file", "recording.wav") + if err != nil { + return "", err + } + if _, err := file.Write(wavData); err != nil { + return "", err + } + _ = form.WriteField("response_format", "text") + _ = form.WriteField("no_timestamps", "true") + if err := form.Close(); err != nil { + return "", err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, w.baseURL+"/inference", &body) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", form.FormDataContentType()) + resp, err := w.client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + data, err := io.ReadAll(io.LimitReader(resp.Body, workerResponseLimit+1)) + if err != nil { + return "", err + } + if len(data) > workerResponseLimit { + return "", fmt.Errorf("worker response exceeds %d bytes", workerResponseLimit) + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("worker returned HTTP %d: %s", resp.StatusCode, trimCommandOutput(string(data))) + } + logger.Info(fmt.Sprintf("Persistent Whisper inference complete: backend=%s elapsed=%s", workerBackendName(w), time.Since(started).Round(time.Millisecond))) + return strings.TrimSpace(string(data)), nil +} + +func workerBackendName(worker *whisperWorker) string { + if worker != nil && worker.metal { + return "metal-worker" + } + return "cpu-worker" +} + +func (w *whisperWorker) running() bool { + select { + case <-w.done: + return false + default: + return w.cmd.Process != nil + } +} + +func (w *whisperWorker) stop() { + if w == nil || w.cmd.Process == nil { + return + } + _ = w.cmd.Process.Signal(os.Interrupt) + select { + case <-w.done: + return + case <-time.After(workerStopTimeout): + _ = w.cmd.Process.Kill() + select { + case <-w.done: + case <-time.After(workerStopTimeout): + } + } +} + +func availableLoopbackPort() (int, error) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return 0, err + } + defer listener.Close() + return listener.Addr().(*net.TCPAddr).Port, nil +} + +func randomWorkerToken() (string, error) { + data := make([]byte, 16) + if _, err := rand.Read(data); err != nil { + return "", err + } + return hex.EncodeToString(data), nil +} diff --git a/internal/transcribe/whisper_worker_test.go b/internal/transcribe/whisper_worker_test.go new file mode 100644 index 0000000..13a8d1d --- /dev/null +++ b/internal/transcribe/whisper_worker_test.go @@ -0,0 +1,292 @@ +package transcribe + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +func TestLocalEngineReusesPersistentWorker(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test worker launcher uses a shell script") + } + + tmpDir := t.TempDir() + modelsDir := filepath.Join(tmpDir, "models") + if err := os.MkdirAll(modelsDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(modelsDir, "ggml-base.en.bin"), []byte("model"), 0644); err != nil { + t.Fatal(err) + } + + startLog := filepath.Join(tmpDir, "starts.log") + t.Setenv("YAP_TEST_WORKER_START_LOG", startLog) + launcher := filepath.Join(tmpDir, "whisper-server") + script := fmt.Sprintf("#!/bin/sh\nexec %q -test.run=TestWhisperWorkerHelper -- \"$@\"\n", os.Args[0]) + if err := os.WriteFile(launcher, []byte(script), 0755); err != nil { + t.Fatal(err) + } + + engine := NewLocalEngine(modelsDir) + engine.SetWhisperServerBinary(launcher) + t.Cleanup(func() { _ = engine.Close() }) + + for i := 0; i < 2; i++ { + text, err := engine.TranscribeWAV(context.Background(), []byte("wav")) + if err != nil { + t.Fatal(err) + } + if text != "persistent transcript" { + t.Fatalf("transcript = %q", text) + } + } + + starts, err := os.ReadFile(startLog) + if err != nil { + t.Fatal(err) + } + if got := len(strings.Fields(string(starts))); got != 1 { + t.Fatalf("worker starts = %d, want 1", got) + } +} + +func TestLocalEngineFallsBackToPersistentCPUWorker(t *testing.T) { + if runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" { + t.Skip("Metal backend is only used on Apple Silicon") + } + + tmpDir := t.TempDir() + binDir := filepath.Join(tmpDir, "bin") + modelsDir := filepath.Join(tmpDir, "models") + libexecDir := filepath.Join(tmpDir, "libexec") + for _, dir := range []string{binDir, modelsDir, libexecDir} { + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + } + for _, path := range []string{ + filepath.Join(modelsDir, "ggml-base.en.bin"), + filepath.Join(libexecDir, "libggml-metal.so"), + filepath.Join(libexecDir, preferredAppleCPUBackend()), + } { + if err := os.WriteFile(path, []byte("test"), 0755); err != nil { + t.Fatal(err) + } + } + + startLog := filepath.Join(tmpDir, "starts.log") + t.Setenv("YAP_TEST_WORKER_START_LOG", startLog) + t.Setenv("YAP_TEST_WORKER_FAIL_METAL", "1") + launcher := filepath.Join(binDir, "whisper-server") + script := fmt.Sprintf("#!/bin/sh\nexec %q -test.run=TestWhisperWorkerHelper -- \"$@\"\n", os.Args[0]) + if err := os.WriteFile(launcher, []byte(script), 0755); err != nil { + t.Fatal(err) + } + + engine := NewLocalEngine(modelsDir) + engine.SetWhisperServerBinary(launcher) + t.Cleanup(func() { _ = engine.Close() }) + for i := 0; i < 2; i++ { + text, err := engine.TranscribeWAV(context.Background(), []byte("wav")) + if err != nil { + t.Fatal(err) + } + if text != "persistent transcript" { + t.Fatalf("transcript = %q", text) + } + } + + data, err := os.ReadFile(startLog) + if err != nil { + t.Fatal(err) + } + attempts := strings.Fields(string(data)) + want := []string{filepath.Join(libexecDir, "libggml-metal.so"), filepath.Join(libexecDir, preferredAppleCPUBackend())} + if strings.Join(attempts, "\n") != strings.Join(want, "\n") { + t.Fatalf("worker starts = %q, want %q", attempts, want) + } +} + +func TestClosedLocalEngineDoesNotStartWorker(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test worker launcher uses a shell script") + } + + tmpDir := t.TempDir() + modelsDir := filepath.Join(tmpDir, "models") + if err := os.MkdirAll(modelsDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(modelsDir, "ggml-base.en.bin"), []byte("model"), 0644); err != nil { + t.Fatal(err) + } + startLog := filepath.Join(tmpDir, "starts.log") + t.Setenv("YAP_TEST_WORKER_START_LOG", startLog) + launcher := filepath.Join(tmpDir, "whisper-server") + script := fmt.Sprintf("#!/bin/sh\nexec %q -test.run=TestWhisperWorkerHelper -- \"$@\"\n", os.Args[0]) + if err := os.WriteFile(launcher, []byte(script), 0755); err != nil { + t.Fatal(err) + } + + engine := NewLocalEngine(modelsDir) + engine.SetWhisperServerBinary(launcher) + if err := engine.Close(); err != nil { + t.Fatal(err) + } + if err := engine.Prewarm(context.Background()); err == nil { + t.Fatal("Prewarm succeeded after Close") + } + if _, err := os.Stat(startLog); !os.IsNotExist(err) { + t.Fatalf("worker started after Close: %v", err) + } +} + +func TestCloseCancelsActivePersistentInference(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test worker launcher uses a shell script") + } + + tmpDir := t.TempDir() + modelsDir := filepath.Join(tmpDir, "models") + if err := os.MkdirAll(modelsDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(modelsDir, "ggml-base.en.bin"), []byte("model"), 0644); err != nil { + t.Fatal(err) + } + startLog := filepath.Join(tmpDir, "starts.log") + requestLog := filepath.Join(tmpDir, "requests.log") + t.Setenv("YAP_TEST_WORKER_START_LOG", startLog) + t.Setenv("YAP_TEST_WORKER_REQUEST_LOG", requestLog) + t.Setenv("YAP_TEST_WORKER_DELAY", "10s") + launcher := filepath.Join(tmpDir, "whisper-server") + script := fmt.Sprintf("#!/bin/sh\nexec %q -test.run=TestWhisperWorkerHelper -- \"$@\"\n", os.Args[0]) + if err := os.WriteFile(launcher, []byte(script), 0755); err != nil { + t.Fatal(err) + } + + engine := NewLocalEngine(modelsDir) + engine.SetWhisperServerBinary(launcher) + cliLog := filepath.Join(tmpDir, "cli.log") + cli := filepath.Join(tmpDir, "whisper-cli") + if err := os.WriteFile(cli, []byte("#!/bin/sh\nprintf called > "+cliLog+"\nexit 1\n"), 0755); err != nil { + t.Fatal(err) + } + engine.SetWhisperBinary(cli) + result := make(chan error, 1) + go func() { + _, err := engine.TranscribeWAV(context.Background(), []byte("wav")) + result <- err + }() + + deadline := time.Now().Add(5 * time.Second) + for { + if _, err := os.Stat(requestLog); err == nil { + break + } + if time.Now().After(deadline) { + t.Fatal("worker did not receive inference request") + } + time.Sleep(10 * time.Millisecond) + } + + started := time.Now() + if err := engine.Close(); err != nil { + t.Fatal(err) + } + if elapsed := time.Since(started); elapsed > 4*time.Second { + t.Fatalf("Close took %s", elapsed) + } + select { + case <-result: + case <-time.After(time.Second): + t.Fatal("transcription did not return after Close") + } + if _, err := os.Stat(cliLog); !os.IsNotExist(err) { + t.Fatalf("CLI fallback started after Close: %v", err) + } +} + +func TestWhisperWorkerHelper(t *testing.T) { + if os.Getenv("YAP_TEST_WORKER_START_LOG") == "" { + return + } + + args := helperArgs(os.Args) + port := argValue(args, "--port") + requestPath := argValue(args, "--request-path") + if port == "" || requestPath == "" { + os.Exit(2) + } + file, err := os.OpenFile(os.Getenv("YAP_TEST_WORKER_START_LOG"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + os.Exit(2) + } + backend := os.Getenv("GGML_BACKEND_PATH") + if backend == "" { + backend = "default" + } + _, _ = file.WriteString(backend + "\n") + _ = file.Close() + if os.Getenv("YAP_TEST_WORKER_FAIL_METAL") == "1" && strings.Contains(backend, "libggml-metal") { + os.Exit(1) + } + + mux := http.NewServeMux() + mux.HandleFunc(requestPath+"/", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Server", "whisper.cpp") + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc(requestPath+"/inference", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Server", "whisper.cpp") + if requestLog := os.Getenv("YAP_TEST_WORKER_REQUEST_LOG"); requestLog != "" { + _ = os.WriteFile(requestLog, []byte("request\n"), 0644) + } + if delay := os.Getenv("YAP_TEST_WORKER_DELAY"); delay != "" { + if duration, err := time.ParseDuration(delay); err == nil { + time.Sleep(duration) + } + } + if err := r.ParseMultipartForm(1 << 20); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + file, _, err := r.FormFile("file") + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + _, _ = io.Copy(io.Discard, file) + _ = file.Close() + _, _ = io.WriteString(w, "persistent transcript\n") + }) + if err := http.ListenAndServe("127.0.0.1:"+port, mux); err != nil { + os.Exit(1) + } +} + +func helperArgs(args []string) []string { + for i, arg := range args { + if arg == "--" { + return args[i+1:] + } + } + return nil +} + +func argValue(args []string, key string) string { + for i := 0; i+1 < len(args); i++ { + if args[i] == key { + return args[i+1] + } + } + return "" +} diff --git a/package-lock.json b/package-lock.json index f1c577b..6bf9119 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "yap", - "version": "0.4.1", + "version": "0.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "yap", - "version": "0.4.1", + "version": "0.4.2", "devDependencies": { "@commitlint/cli": "^19.6.1", "@commitlint/config-conventional": "^19.6.0", diff --git a/package.json b/package.json index e2f82de..428afc3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "yap", - "version": "0.4.1", + "version": "0.4.2", "private": true, "type": "module", "description": "Speech-to-Text Desktop App by ApplauseLab", diff --git a/wails.json b/wails.json index 3746322..ff75ca4 100644 --- a/wails.json +++ b/wails.json @@ -13,7 +13,7 @@ "info": { "companyName": "Applause Lab", "productName": "Yap", - "productVersion": "0.4.1", + "productVersion": "0.4.2", "copyright": "Copyright 2024 Applause Lab", "comments": "Speech-to-Text Desktop App by applauselab.ai" }