| | |
| | | import ( |
| | | "context" |
| | | "fmt" |
| | | "runtime" |
| | | "strings" |
| | | "sync" |
| | | "sync/atomic" |
| | |
| | | "voicesnap/internal/input" |
| | | "voicesnap/internal/language" |
| | | "voicesnap/internal/logger" |
| | | "voicesnap/internal/model" |
| | | "voicesnap/internal/modelselection" |
| | | "voicesnap/internal/overlay" |
| | | "voicesnap/internal/sound" |
| | | "voicesnap/internal/textproc" |
| | |
| | | ) |
| | | |
| | | const ( |
| | | appVersion = "2.2.0" |
| | | appBuild = "20260702.0303" |
| | | appVersion = "2.2.2" |
| | | appBuild = "20260703.0335" |
| | | appDisplayVersion = appVersion + " (build " + appBuild + ")" |
| | | appName = "PrivateVoice Dictation" |
| | | |
| | |
| | | cancel context.CancelFunc |
| | | wg sync.WaitGroup |
| | | |
| | | cfg *config.Config |
| | | recorder *audio.Recorder |
| | | eng engine.Engine |
| | | engineMu sync.Mutex |
| | | hk hotkey.Listener |
| | | paster input.Paster |
| | | history *history.Store |
| | | userdict *userdict.Store |
| | | cfg *config.Config |
| | | recorder *audio.Recorder |
| | | eng engine.Engine |
| | | engineMu sync.Mutex |
| | | engineInitMu sync.Mutex |
| | | engineInitSeq atomic.Uint64 |
| | | engineMeta engineMetadata |
| | | engineFactory engineFactoryFunc |
| | | hk hotkey.Listener |
| | | paster input.Paster |
| | | history *history.Store |
| | | userdict *userdict.Store |
| | | |
| | | engineService *services.EngineService |
| | | wailsApp *application.App |
| | |
| | | liveCaptionSeq uint64 |
| | | releaseTailCaptureNanos atomic.Int64 |
| | | holdPreCaptureEnabled atomic.Bool |
| | | } |
| | | |
| | | type engineFactoryFunc func(initID uint64) (engine.Engine, engineMetadata, bool, error) |
| | | |
| | | type engineMetadata struct { |
| | | InitID uint64 |
| | | ResolvedModelID string |
| | | ConfigSelectedModelID string |
| | | SelectionMode string |
| | | LanguageMode string |
| | | ConfigLanguageID string |
| | | EffectiveLanguageID string |
| | | BackendKind string |
| | | Provider string |
| | | LanguageParam string |
| | | FallbackReason string |
| | | NumThreads int |
| | | HardwareInfo string |
| | | } |
| | | |
| | | func RunApp() error { |
| | |
| | | historyService := services.NewHistoryService(app.history) |
| | | userDictService := services.NewUserDictService(app.userdict) |
| | | correctionCSVService := services.NewCorrectionCSVService(app.history, app.userdict) |
| | | diagnosticsService := services.NewDiagnosticsService(app.cfg, appDisplayVersion) |
| | | |
| | | // Create Wails application |
| | | wailsApp := application.New(application.Options{ |
| | |
| | | application.NewService(historyService), |
| | | application.NewService(userDictService), |
| | | application.NewService(correctionCSVService), |
| | | application.NewService(diagnosticsService), |
| | | }, |
| | | Assets: application.AssetOptions{ |
| | | Handler: application.AssetFileServerFS(assets), |
| | |
| | | case <-a.ctx.Done(): |
| | | return |
| | | case <-ticker.C: |
| | | d := time.Since(start) |
| | | a.mu.Lock() |
| | | recording := a.isRecording |
| | | captionActive := a.liveCaptionCancel != nil |
| | | if recording && d >= audio.MaxRecordingDuration { |
| | | logger.Error("Recording reached max duration; auto-stopping max_seconds=%d", int(audio.MaxRecordingDuration/time.Second)) |
| | | if a.isFreetalking { |
| | | a.stopTapRecordingLocked() |
| | | } else { |
| | | a.stopRecordingLocked(false) |
| | | } |
| | | a.mu.Unlock() |
| | | return |
| | | } |
| | | a.mu.Unlock() |
| | | if !recording { |
| | | return |
| | |
| | | if captionActive { |
| | | continue |
| | | } |
| | | d := time.Since(start) |
| | | a.indicator.SetStatus(status, fmt.Sprintf("%d:%02d", int(d.Minutes()), int(d.Seconds())%60)) |
| | | } |
| | | } |
| | |
| | | hotkeyVK := a.cfg.HotkeyVK |
| | | autoHide := a.cfg.AutoHide |
| | | copyToClipboard := a.cfg.CopyToClipboard |
| | | selectedModelID := a.cfg.SelectedModelID |
| | | languageID := a.cfg.LanguageID |
| | | a.mu.Unlock() |
| | | |
| | | logger.Info( |
| | |
| | | a.engineMu.Lock() |
| | | engineLockElapsed := time.Since(engineLockStart) |
| | | eng := a.eng |
| | | engMeta := a.engineMeta |
| | | if eng == nil { |
| | | a.engineMu.Unlock() |
| | | logger.Error("Recognition skipped: engine not ready") |
| | |
| | | } |
| | | totalMS := perfSinceMS(pipelineStart) |
| | | logger.Info("PERF pipeline_done id=%d result=success total_ms=%d", perfID, totalMS) |
| | | logSlowRecognitionIfNeeded(perfID, eng, selectedModelID, languageID, samples, recognizeElapsedMS, totalMS) |
| | | logSlowRecognitionIfNeeded(perfID, engMeta, samples, recognizeElapsedMS, totalMS) |
| | | a.delayedHideIf(autoHide, doneIndicatorHideDelayMs) |
| | | } |
| | | |
| | | func logSlowRecognitionIfNeeded(perfID int64, eng engine.Engine, modelID, languageID string, samples []float32, recognizeMS, totalMS int64) { |
| | | func logSlowRecognitionIfNeeded(perfID int64, meta engineMetadata, samples []float32, recognizeMS, totalMS int64) { |
| | | reason := slowRecognitionReason(recognizeMS, totalMS) |
| | | if reason == "" { |
| | | return |
| | | } |
| | | |
| | | engineInfo := "" |
| | | if eng != nil { |
| | | engineInfo = eng.HardwareInfo() |
| | | audioMS := perfAudioDurationMS(samples) |
| | | rtf := 0.0 |
| | | if audioMS > 0 { |
| | | rtf = float64(recognizeMS) / float64(audioMS) |
| | | } |
| | | |
| | | numCPU := runtime.NumCPU() |
| | | load, hasLoad := currentSystemLoadAverage() |
| | | if hasLoad { |
| | | loadPerCore := 0.0 |
| | | if numCPU > 0 { |
| | | loadPerCore = load[0] / float64(numCPU) |
| | | } |
| | | logger.Info( |
| | | "PERF slow_recognition id=%d reason=%s model_id=%q language_id=%q engine=%q audio_ms=%d samples=%d recognize_ms=%d total_ms=%d load1=%.2f load5=%.2f load15=%.2f", |
| | | "PERF slow_recognition id=%d reason=%s model_id=%q resolved_model_id=%q cfg_selected_model_id=%q selection_mode=%q language_mode=%q config_language_id=%q effective_language_id=%q backend=%q provider=%q language_param=%q fallback_reason=%q num_threads=%d engine=%q init_id=%d audio_ms=%d samples=%d recognize_ms=%d total_ms=%d rtf=%.3f load1=%.2f load5=%.2f load15=%.2f load1_per_core=%.2f cpu_count=%d", |
| | | perfID, |
| | | reason, |
| | | modelID, |
| | | languageID, |
| | | engineInfo, |
| | | perfAudioDurationMS(samples), |
| | | meta.ResolvedModelID, |
| | | meta.ResolvedModelID, |
| | | meta.ConfigSelectedModelID, |
| | | meta.SelectionMode, |
| | | meta.LanguageMode, |
| | | meta.ConfigLanguageID, |
| | | meta.EffectiveLanguageID, |
| | | meta.BackendKind, |
| | | meta.Provider, |
| | | meta.LanguageParam, |
| | | meta.FallbackReason, |
| | | meta.NumThreads, |
| | | meta.HardwareInfo, |
| | | meta.InitID, |
| | | audioMS, |
| | | len(samples), |
| | | recognizeMS, |
| | | totalMS, |
| | | rtf, |
| | | load[0], |
| | | load[1], |
| | | load[2], |
| | | loadPerCore, |
| | | numCPU, |
| | | ) |
| | | return |
| | | } |
| | | |
| | | logger.Info( |
| | | "PERF slow_recognition id=%d reason=%s model_id=%q language_id=%q engine=%q audio_ms=%d samples=%d recognize_ms=%d total_ms=%d load_unavailable=true", |
| | | "PERF slow_recognition id=%d reason=%s model_id=%q resolved_model_id=%q cfg_selected_model_id=%q selection_mode=%q language_mode=%q config_language_id=%q effective_language_id=%q backend=%q provider=%q language_param=%q fallback_reason=%q num_threads=%d engine=%q init_id=%d audio_ms=%d samples=%d recognize_ms=%d total_ms=%d rtf=%.3f load_unavailable=true cpu_count=%d", |
| | | perfID, |
| | | reason, |
| | | modelID, |
| | | languageID, |
| | | engineInfo, |
| | | perfAudioDurationMS(samples), |
| | | meta.ResolvedModelID, |
| | | meta.ResolvedModelID, |
| | | meta.ConfigSelectedModelID, |
| | | meta.SelectionMode, |
| | | meta.LanguageMode, |
| | | meta.ConfigLanguageID, |
| | | meta.EffectiveLanguageID, |
| | | meta.BackendKind, |
| | | meta.Provider, |
| | | meta.LanguageParam, |
| | | meta.FallbackReason, |
| | | meta.NumThreads, |
| | | meta.HardwareInfo, |
| | | meta.InitID, |
| | | audioMS, |
| | | len(samples), |
| | | recognizeMS, |
| | | totalMS, |
| | | rtf, |
| | | numCPU, |
| | | ) |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | func (a *App) initEngine() { |
| | | logger.Info("Initializing ASR engine...") |
| | | if a.engineService != nil { |
| | | a.engineService.SetStatus("loading", "", "") |
| | | } |
| | | a.wailsApp.Event.Emit("engine:status", map[string]interface{}{ |
| | | "status": "loading", |
| | | }) |
| | | initID := a.engineInitSeq.Add(1) |
| | | |
| | | modelExists := engine.ModelExists() |
| | | eng, err := engine.New() |
| | | a.engineInitMu.Lock() |
| | | defer a.engineInitMu.Unlock() |
| | | |
| | | if initID != a.engineInitSeq.Load() { |
| | | logger.Info("Skipping superseded ASR engine init: init_id=%d current_init_id=%d", initID, a.engineInitSeq.Load()) |
| | | return |
| | | } |
| | | |
| | | logger.Info("Initializing ASR engine... init_id=%d", initID) |
| | | a.setEngineStatus("loading", "", "") |
| | | |
| | | eng, meta, modelExists, err := a.createEngineForInit(initID) |
| | | if err != nil { |
| | | logger.Error("Engine initialization failed: %v", err) |
| | | logger.Error("Engine initialization failed: init_id=%d model_id=%q error=%v", initID, meta.ResolvedModelID, err) |
| | | if a.hasEngine() { |
| | | logger.Info("Keeping existing ASR engine after reload failure") |
| | | } else { |
| | |
| | | if modelExists { |
| | | status = "error" |
| | | } |
| | | if a.engineService != nil { |
| | | a.engineService.SetStatus(status, "", err.Error()) |
| | | } |
| | | a.wailsApp.Event.Emit("engine:status", map[string]interface{}{ |
| | | "status": status, |
| | | "error": err.Error(), |
| | | }) |
| | | a.setEngineStatus(status, "", err.Error()) |
| | | return |
| | | } |
| | | |
| | | a.replaceEngine(eng) |
| | | |
| | | logger.Info("ASR engine ready: %s", eng.HardwareInfo()) |
| | | if a.engineService != nil { |
| | | a.engineService.SetStatus("ready", eng.HardwareInfo(), "") |
| | | if initID != a.engineInitSeq.Load() { |
| | | logger.Info("Discarding stale ASR engine init: init_id=%d current_init_id=%d model_id=%q", initID, a.engineInitSeq.Load(), meta.ResolvedModelID) |
| | | eng.Close() |
| | | return |
| | | } |
| | | a.wailsApp.Event.Emit("engine:status", map[string]interface{}{ |
| | | "status": "ready", |
| | | "hardwareInfo": eng.HardwareInfo(), |
| | | }) |
| | | |
| | | a.replaceEngineWithMetadata(eng, meta) |
| | | |
| | | logger.Info("ASR engine ready: init_id=%d model_id=%q backend=%s provider=%s hardware=%s", initID, meta.ResolvedModelID, meta.BackendKind, meta.Provider, eng.HardwareInfo()) |
| | | a.setEngineStatus("ready", eng.HardwareInfo(), "") |
| | | |
| | | if a.indicator == nil { |
| | | return |
| | | } |
| | | |
| | | // Show indicator briefly — snapshot config under lock |
| | | a.mu.Lock() |
| | |
| | | a.delayedHideIf(autoHide, 2000) |
| | | } |
| | | |
| | | func (a *App) createEngineForInit(initID uint64) (engine.Engine, engineMetadata, bool, error) { |
| | | if a.engineFactory != nil { |
| | | return a.engineFactory(initID) |
| | | } |
| | | |
| | | cfg, err := config.Load() |
| | | if err != nil { |
| | | logger.Error("Failed to load config for engine init: %v", err) |
| | | cfg = a.cfg |
| | | if cfg == nil { |
| | | cfg = config.Default() |
| | | } |
| | | } |
| | | |
| | | current := modelselection.Resolve(cfg, language.NewSystemDetector()) |
| | | meta := engineMetadata{ |
| | | InitID: initID, |
| | | ResolvedModelID: current.ModelID, |
| | | ConfigSelectedModelID: cfg.SelectedModelID, |
| | | SelectionMode: current.SelectionMode, |
| | | LanguageMode: config.NormalizeLanguageMode(cfg.LanguageMode), |
| | | ConfigLanguageID: cfg.LanguageID, |
| | | EffectiveLanguageID: current.LanguageSettings.EffectiveLanguageID, |
| | | FallbackReason: current.FallbackReason, |
| | | } |
| | | |
| | | resolved, err := model.ResolveModel(model.NormalizeModelID(current.ModelID)) |
| | | if err != nil { |
| | | return nil, meta, false, err |
| | | } |
| | | meta.ResolvedModelID = resolved.ModelID |
| | | meta.BackendKind = resolved.BackendKind |
| | | meta.LanguageParam = resolved.Profile.LanguageParam |
| | | meta.NumThreads = resolved.Profile.NumThreads |
| | | |
| | | modelExists := resolved.IsUsable() |
| | | if !modelExists { |
| | | return nil, meta, false, fmt.Errorf("model %s is not usable: %s missing=%v problems=%v", resolved.ModelID, resolved.Status, resolved.Missing, resolved.Problems) |
| | | } |
| | | |
| | | eng, err := engine.NewWithResolvedModel(resolved) |
| | | if err != nil { |
| | | return nil, meta, modelExists, err |
| | | } |
| | | meta.HardwareInfo = eng.HardwareInfo() |
| | | if providerEng, ok := eng.(engine.ProviderEngine); ok { |
| | | meta.Provider = providerEng.Provider() |
| | | } |
| | | |
| | | return eng, meta, modelExists, nil |
| | | } |
| | | |
| | | func (a *App) setEngineStatus(status, hardwareInfo, errText string) { |
| | | if a.engineService != nil { |
| | | a.engineService.SetStatus(status, hardwareInfo, errText) |
| | | } |
| | | if a.wailsApp == nil { |
| | | return |
| | | } |
| | | payload := map[string]interface{}{ |
| | | "status": status, |
| | | } |
| | | if hardwareInfo != "" { |
| | | payload["hardwareInfo"] = hardwareInfo |
| | | } |
| | | if errText != "" { |
| | | payload["error"] = errText |
| | | } |
| | | a.wailsApp.Event.Emit("engine:status", payload) |
| | | } |
| | | |
| | | func (a *App) hasEngine() bool { |
| | | a.engineMu.Lock() |
| | | defer a.engineMu.Unlock() |
| | |
| | | } |
| | | |
| | | func (a *App) replaceEngine(eng engine.Engine) { |
| | | a.replaceEngineWithMetadata(eng, engineMetadata{}) |
| | | } |
| | | |
| | | func (a *App) replaceEngineWithMetadata(eng engine.Engine, meta engineMetadata) { |
| | | a.releaseTailCaptureNanos.Store(int64(releaseTailCaptureDelayForEngine(eng))) |
| | | a.holdPreCaptureEnabled.Store(holdPreCaptureEnabledForEngine(eng)) |
| | | |
| | | a.engineMu.Lock() |
| | | old := a.eng |
| | | a.eng = eng |
| | | a.engineMeta = meta |
| | | a.engineMu.Unlock() |
| | | |
| | | if old != nil && old != eng { |
| | |
| | | if a.recorder != nil { |
| | | a.recorder.Close() |
| | | } |
| | | if a.hk != nil { |
| | | a.hk.Close() |
| | | } |
| | | a.replaceEngine(nil) |
| | | a.wg.Wait() |
| | | logger.Info("Cleanup complete") |
| | | logger.Close() |
| | | } |
| | | |
| | | // SetRecordingHotkey is called by the hotkey service when entering hotkey recording mode. |