| | |
| | | ) |
| | | |
| | | const ( |
| | | appVersion = "2.1.37" |
| | | appBuild = "20260629.0059" |
| | | appVersion = "2.1.38" |
| | | appBuild = "20260629.2014" |
| | | appDisplayVersion = appVersion + " (build " + appBuild + ")" |
| | | appName = "PrivateVoice Dictation" |
| | | |
| | |
| | | silenceTimeoutDuration = 3 * time.Second // auto-stop after this much silence in free-talk |
| | | silenceGracePeriod = 2 * time.Second // don't auto-stop within first 2s of free-talk |
| | | holdActivationDelay = 180 * time.Millisecond |
| | | defaultReleaseTailDelay = 300 * time.Millisecond |
| | | slowRecognizeThresholdMs = int64(2000) |
| | | slowPipelineThresholdMs = int64(2500) |
| | | doneIndicatorHideDelayMs = 250 |
| | | liveCaptionInterval = 120 * time.Millisecond |
| | | liveCaptionMaxBytes = 108 |
| | |
| | | a.isStoppingRecording = true |
| | | a.indicator.SetStatus(overlay.StatusProcessing, "识别中") |
| | | go func() { |
| | | samples, hasVoice := a.stopRecorderAfterReleaseTailCapture() |
| | | a.recognizeAndPaste(hasVoice, samples) |
| | | perfID := newPerfTraceID() |
| | | pipelineStart := time.Now() |
| | | logger.Info("PERF pipeline_start id=%d mode=hold", perfID) |
| | | samples, hasVoice := a.stopRecorderAfterReleaseTailCapture(perfID, pipelineStart) |
| | | a.recognizeAndPaste(hasVoice, samples, perfID, pipelineStart) |
| | | }() |
| | | } |
| | | |
| | |
| | | logger.Info("Tap recording stopped") |
| | | a.indicator.SetStatus(overlay.StatusProcessing, "识别中") |
| | | go func() { |
| | | samples, hasVoice := a.stopRecorderAfterReleaseTailCapture() |
| | | a.recognizeAndPaste(hasVoice, samples) |
| | | perfID := newPerfTraceID() |
| | | pipelineStart := time.Now() |
| | | logger.Info("PERF pipeline_start id=%d mode=tap", perfID) |
| | | samples, hasVoice := a.stopRecorderAfterReleaseTailCapture(perfID, pipelineStart) |
| | | a.recognizeAndPaste(hasVoice, samples, perfID, pipelineStart) |
| | | }() |
| | | } |
| | | |
| | |
| | | // silenceAutoStop performs the actual device stop and ASR after a silence timeout. |
| | | // Runs in its own goroutine to avoid deadlocking the audio callback thread. |
| | | func (a *App) silenceAutoStop() { |
| | | perfID := newPerfTraceID() |
| | | pipelineStart := time.Now() |
| | | logger.Info("PERF pipeline_start id=%d mode=silence_auto_stop", perfID) |
| | | |
| | | voiceStart := time.Now() |
| | | hasVoice := a.recorder.HasVoiceActivity() |
| | | voiceElapsed := time.Since(voiceStart) |
| | | |
| | | stopStart := time.Now() |
| | | samples := a.recorder.StopAndGetSamples() |
| | | stopElapsed := time.Since(stopStart) |
| | | logger.Info( |
| | | "PERF audio_stop_done id=%d mode=silence_auto_stop stop_ms=%d voice_check_ms=%d samples=%d audio_ms=%d has_voice=%t since_start_ms=%d", |
| | | perfID, |
| | | perfDurationMS(stopElapsed), |
| | | perfDurationMS(voiceElapsed), |
| | | len(samples), |
| | | perfAudioDurationMS(samples), |
| | | hasVoice, |
| | | perfSinceMS(pipelineStart), |
| | | ) |
| | | |
| | | logger.Info("Free talk stopped (silence auto-stop)") |
| | | a.indicator.SetStatus(overlay.StatusProcessing, "识别中") |
| | | a.recognizeAndPaste(hasVoice, samples) |
| | | a.recognizeAndPaste(hasVoice, samples, perfID, pipelineStart) |
| | | } |
| | | |
| | | func (a *App) waitForReleaseTailCapture() { |
| | | func (a *App) waitForReleaseTailCapture(perfID int64) { |
| | | delay := a.releaseTailCaptureDelay() |
| | | if delay <= 0 { |
| | | logger.Info("PERF release_tail_wait_done id=%d configured_ms=0 elapsed_ms=0", perfID) |
| | | return |
| | | } |
| | | logger.Info("Release tail capture: waiting %dms before stopping audio", delay.Milliseconds()) |
| | | start := time.Now() |
| | | time.Sleep(delay) |
| | | logger.Info("PERF release_tail_wait_done id=%d configured_ms=%d elapsed_ms=%d", perfID, delay.Milliseconds(), perfSinceMS(start)) |
| | | } |
| | | |
| | | func (a *App) releaseTailCaptureDelay() time.Duration { |
| | |
| | | return time.Duration(nanos) |
| | | } |
| | | |
| | | func (a *App) stopRecorderAfterReleaseTailCapture() ([]float32, bool) { |
| | | a.waitForReleaseTailCapture() |
| | | func (a *App) stopRecorderAfterReleaseTailCapture(perfID int64, pipelineStart time.Time) ([]float32, bool) { |
| | | a.waitForReleaseTailCapture(perfID) |
| | | |
| | | stopStart := time.Now() |
| | | samples := a.recorder.StopAndGetSamples() |
| | | stopElapsed := time.Since(stopStart) |
| | | |
| | | voiceStart := time.Now() |
| | | hasVoice := a.recorder.HasVoiceActivity() |
| | | voiceElapsed := time.Since(voiceStart) |
| | | |
| | | a.mu.Lock() |
| | | a.isStoppingRecording = false |
| | | a.mu.Unlock() |
| | | |
| | | logger.Info( |
| | | "PERF audio_stop_done id=%d stop_ms=%d voice_check_ms=%d samples=%d audio_ms=%d has_voice=%t since_start_ms=%d", |
| | | perfID, |
| | | perfDurationMS(stopElapsed), |
| | | perfDurationMS(voiceElapsed), |
| | | len(samples), |
| | | perfAudioDurationMS(samples), |
| | | hasVoice, |
| | | perfSinceMS(pipelineStart), |
| | | ) |
| | | |
| | | return samples, hasVoice |
| | | } |
| | | |
| | | // recognizeAndPaste runs ASR on the recorded samples and pastes the result. |
| | | // Shared by both hold and tap modes. |
| | | func (a *App) recognizeAndPaste(hasVoice bool, samples []float32) { |
| | | func (a *App) recognizeAndPaste(hasVoice bool, samples []float32, perfID int64, pipelineStart time.Time) { |
| | | // Snapshot config values under lock to avoid data races |
| | | a.mu.Lock() |
| | | soundFeedback := a.cfg.SoundFeedback |
| | | 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( |
| | | "PERF recognize_pipeline_input id=%d has_voice=%t samples=%d audio_ms=%d copy_to_clipboard=%t since_start_ms=%d", |
| | | perfID, |
| | | hasVoice, |
| | | len(samples), |
| | | perfAudioDurationMS(samples), |
| | | copyToClipboard, |
| | | perfSinceMS(pipelineStart), |
| | | ) |
| | | |
| | | if !hasVoice { |
| | | logger.Info("No voice activity detected") |
| | | logger.Info("PERF pipeline_done id=%d result=no_voice total_ms=%d", perfID, perfSinceMS(pipelineStart)) |
| | | a.indicator.SetStatus(overlay.StatusNoVoice, "无语音") |
| | | a.delayedHideIf(autoHide, 1500) |
| | | return |
| | | } |
| | | |
| | | if len(samples) == 0 { |
| | | logger.Info("PERF pipeline_done id=%d result=no_samples total_ms=%d", perfID, perfSinceMS(pipelineStart)) |
| | | a.indicator.SetStatus(overlay.StatusNoContent, "无内容") |
| | | a.delayedHideIf(autoHide, 1500) |
| | | return |
| | | } |
| | | |
| | | engineLockStart := time.Now() |
| | | a.engineMu.Lock() |
| | | engineLockElapsed := time.Since(engineLockStart) |
| | | eng := a.eng |
| | | if eng == nil { |
| | | a.engineMu.Unlock() |
| | | logger.Error("Recognition skipped: engine not ready") |
| | | logger.Info("PERF pipeline_done id=%d result=engine_not_ready engine_lock_ms=%d total_ms=%d", perfID, perfDurationMS(engineLockElapsed), perfSinceMS(pipelineStart)) |
| | | a.indicator.SetStatus(overlay.StatusError, "引擎未就绪") |
| | | a.delayedHideIf(autoHide, 2000) |
| | | return |
| | | } |
| | | recognizeStart := time.Now() |
| | | text, err := eng.Recognize(samples) |
| | | recognizeElapsed := time.Since(recognizeStart) |
| | | recognizeElapsedMS := perfDurationMS(recognizeElapsed) |
| | | a.engineMu.Unlock() |
| | | logger.Info( |
| | | "PERF recognize_done id=%d engine_lock_wait_ms=%d recognize_ms=%d samples=%d audio_ms=%d since_start_ms=%d", |
| | | perfID, |
| | | perfDurationMS(engineLockElapsed), |
| | | recognizeElapsedMS, |
| | | len(samples), |
| | | perfAudioDurationMS(samples), |
| | | perfSinceMS(pipelineStart), |
| | | ) |
| | | if err != nil { |
| | | logger.Error("Recognition failed: %v", err) |
| | | logger.Info("PERF pipeline_done id=%d result=recognize_error total_ms=%d", perfID, perfSinceMS(pipelineStart)) |
| | | a.indicator.SetStatus(overlay.StatusError, "错误") |
| | | a.delayedHideIf(autoHide, 2000) |
| | | return |
| | | } |
| | | |
| | | postProcessStart := time.Now() |
| | | text = textproc.PostProcess(text) |
| | | text = a.userdict.Apply(text) |
| | | postProcessElapsed := time.Since(postProcessStart) |
| | | logger.Info( |
| | | "PERF text_postprocess_done id=%d postprocess_ms=%d text_runes=%d since_start_ms=%d", |
| | | perfID, |
| | | perfDurationMS(postProcessElapsed), |
| | | len([]rune(text)), |
| | | perfSinceMS(pipelineStart), |
| | | ) |
| | | |
| | | if text == "" { |
| | | logger.Info("PERF pipeline_done id=%d result=empty_text total_ms=%d", perfID, perfSinceMS(pipelineStart)) |
| | | a.indicator.SetStatus(overlay.StatusNoContent, "无内容") |
| | | a.delayedHideIf(autoHide, 1500) |
| | | return |
| | | } |
| | | |
| | | logger.Info("Recognized: %s", text) |
| | | historyStart := time.Now() |
| | | a.history.Add(text) |
| | | logger.Info("PERF history_done id=%d history_ms=%d since_start_ms=%d", perfID, perfSinceMS(historyStart), perfSinceMS(pipelineStart)) |
| | | |
| | | a.waitForHotkeyRelease(hotkeyVK) |
| | | hotkeyWaitStart := time.Now() |
| | | polls, released := a.waitForHotkeyRelease(hotkeyVK) |
| | | logger.Info( |
| | | "PERF hotkey_release_wait_done id=%d wait_ms=%d polls=%d released=%t since_start_ms=%d", |
| | | perfID, |
| | | perfSinceMS(hotkeyWaitStart), |
| | | polls, |
| | | released, |
| | | perfSinceMS(pipelineStart), |
| | | ) |
| | | |
| | | pasteStart := time.Now() |
| | | if err := a.paster.Paste(text, copyToClipboard); err != nil { |
| | | pasteElapsed := time.Since(pasteStart) |
| | | logger.Info("PERF paste_done id=%d result=error paste_ms=%d since_start_ms=%d", perfID, perfDurationMS(pasteElapsed), perfSinceMS(pipelineStart)) |
| | | logger.Error("Paste failed, trying fallback: %v", err) |
| | | typeStart := time.Now() |
| | | if err := a.paster.TypeText(text); err != nil { |
| | | logger.Info("PERF fallback_type_done id=%d result=error type_ms=%d total_ms=%d", perfID, perfSinceMS(typeStart), perfSinceMS(pipelineStart)) |
| | | logger.Error("Fallback type also failed: %v", err) |
| | | a.indicator.SetStatus(overlay.StatusError, "需辅助权限") |
| | | a.delayedHideIf(autoHide, 2500) |
| | | return |
| | | } |
| | | logger.Info("PERF fallback_type_done id=%d result=success type_ms=%d total_ms=%d", perfID, perfSinceMS(typeStart), perfSinceMS(pipelineStart)) |
| | | } else { |
| | | logger.Info("PERF paste_done id=%d result=success paste_ms=%d since_start_ms=%d", perfID, perfSinceMS(pasteStart), perfSinceMS(pipelineStart)) |
| | | } |
| | | |
| | | a.indicator.SetStatus(overlay.StatusDone, "完成") |
| | | if soundFeedback { |
| | | sound.PlayDone() |
| | | } |
| | | 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) |
| | | a.delayedHideIf(autoHide, doneIndicatorHideDelayMs) |
| | | } |
| | | |
| | | func logSlowRecognitionIfNeeded(perfID int64, eng engine.Engine, modelID, languageID string, samples []float32, recognizeMS, totalMS int64) { |
| | | reason := slowRecognitionReason(recognizeMS, totalMS) |
| | | if reason == "" { |
| | | return |
| | | } |
| | | |
| | | engineInfo := "" |
| | | if eng != nil { |
| | | engineInfo = eng.HardwareInfo() |
| | | } |
| | | |
| | | load, hasLoad := currentSystemLoadAverage() |
| | | if hasLoad { |
| | | 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", |
| | | perfID, |
| | | reason, |
| | | modelID, |
| | | languageID, |
| | | engineInfo, |
| | | perfAudioDurationMS(samples), |
| | | len(samples), |
| | | recognizeMS, |
| | | totalMS, |
| | | load[0], |
| | | load[1], |
| | | load[2], |
| | | ) |
| | | 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", |
| | | perfID, |
| | | reason, |
| | | modelID, |
| | | languageID, |
| | | engineInfo, |
| | | perfAudioDurationMS(samples), |
| | | len(samples), |
| | | recognizeMS, |
| | | totalMS, |
| | | ) |
| | | } |
| | | |
| | | func slowRecognitionReason(recognizeMS, totalMS int64) string { |
| | | recognizeSlow := recognizeMS > slowRecognizeThresholdMs |
| | | totalSlow := totalMS > slowPipelineThresholdMs |
| | | switch { |
| | | case recognizeSlow && totalSlow: |
| | | return "recognize,total" |
| | | case recognizeSlow: |
| | | return "recognize" |
| | | case totalSlow: |
| | | return "total" |
| | | default: |
| | | return "" |
| | | } |
| | | } |
| | | |
| | | // waitForHotkeyRelease polls until the hotkey is released (max 500ms), |
| | | // then waits an additional 50ms settling delay. |
| | | func (a *App) waitForHotkeyRelease(hotkeyVK int) { |
| | | func (a *App) waitForHotkeyRelease(hotkeyVK int) (int, bool) { |
| | | polls := 0 |
| | | released := false |
| | | for i := 0; i < 50; i++ { |
| | | polls = i + 1 |
| | | if !a.hk.IsKeyDown(hotkeyVK) { |
| | | released = true |
| | | break |
| | | } |
| | | time.Sleep(10 * time.Millisecond) |
| | | } |
| | | time.Sleep(50 * time.Millisecond) |
| | | return polls, released |
| | | } |
| | | |
| | | func newPerfTraceID() int64 { |
| | | return time.Now().UnixNano() |
| | | } |
| | | |
| | | func perfSinceMS(start time.Time) int64 { |
| | | return perfDurationMS(time.Since(start)) |
| | | } |
| | | |
| | | func perfDurationMS(duration time.Duration) int64 { |
| | | return duration.Milliseconds() |
| | | } |
| | | |
| | | func perfAudioDurationMS(samples []float32) int64 { |
| | | return int64(len(samples)) * 1000 / 16000 |
| | | } |
| | | |
| | | // delayedHide hides the indicator after a delay if AutoHide is enabled. |
| | |
| | | } |
| | | tailCaptureEng, ok := eng.(engine.ReleaseTailCaptureEngine) |
| | | if !ok { |
| | | return defaultReleaseTailDelay |
| | | return 0 |
| | | } |
| | | delay := tailCaptureEng.ReleaseTailCaptureDelay() |
| | | if delay < 0 { |