From 5df683eba7c103010a7c110d97a5fd808b39fe46 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Thu, 02 Jul 2026 01:47:41 +0800
Subject: [PATCH] Fix local universal macOS package assembly
---
privatevoice.src/app.go | 534 ++++++++++++++++++++++++++++++++++++++++++++++++++++++----
1 files changed, 490 insertions(+), 44 deletions(-)
diff --git a/privatevoice.src/app.go b/privatevoice.src/app.go
index 080dcc5..3cfd3b4 100755
--- a/privatevoice.src/app.go
+++ b/privatevoice.src/app.go
@@ -3,6 +3,7 @@
import (
"context"
"fmt"
+ "strings"
"sync"
"sync/atomic"
"time"
@@ -27,8 +28,8 @@
)
const (
- appVersion = "2.1.28"
- appBuild = "20260604.0049"
+ appVersion = "2.1.40"
+ appBuild = "20260702.0143"
appDisplayVersion = appVersion + " (build " + appBuild + ")"
appName = "PrivateVoice Dictation"
@@ -36,7 +37,11 @@
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
+ slowRecognizeThresholdMs = int64(2000)
+ slowPipelineThresholdMs = int64(2500)
doneIndicatorHideDelayMs = 250
+ liveCaptionInterval = 120 * time.Millisecond
+ liveCaptionMaxBytes = 108
)
// App holds all application state and orchestration logic.
@@ -60,21 +65,27 @@
indicator overlay.Overlay
// State
- mu sync.Mutex
- isRecording bool
- isFreetalking bool
- hotkeyActive bool
- hotkeyPressTime time.Time
- isCombination bool
- tapStopOnPress bool
- isRecordingHotkey bool
- hideGen atomic.Uint64
- lastStopTime time.Time
- hotkeyPollSeen bool
- lastHotkeyDown bool
- lastHotkeyBlocked time.Time
- silenceSince time.Time // when continuous silence started (free-talk only)
- freeTalkStart time.Time // when free-talk mode started
+ mu sync.Mutex
+ isRecording bool
+ isStoppingRecording bool
+ isHoldRecordingPending bool
+ isFreetalking bool
+ hotkeyActive bool
+ hotkeyPressTime time.Time
+ isCombination bool
+ tapStopOnPress bool
+ isRecordingHotkey bool
+ hideGen atomic.Uint64
+ lastStopTime time.Time
+ hotkeyPollSeen bool
+ lastHotkeyDown bool
+ lastHotkeyBlocked time.Time
+ silenceSince time.Time // when continuous silence started (free-talk only)
+ freeTalkStart time.Time // when free-talk mode started
+ liveCaptionCancel context.CancelFunc
+ liveCaptionSeq uint64
+ releaseTailCaptureNanos atomic.Int64
+ holdPreCaptureEnabled atomic.Bool
}
func RunApp() error {
@@ -274,19 +285,23 @@
)
}
- if a.eng == nil || a.isRecordingHotkey {
+ engineReady := a.eng != nil
+ if a.isRecordingHotkey || a.isStoppingRecording {
if isDown && time.Since(a.lastHotkeyBlocked) > time.Second {
a.lastHotkeyBlocked = time.Now()
- logger.Info("Hotkey ignored: key=%s engineReady=%t recordingHotkey=%t", hotkey.GetKeyName(a.cfg.HotkeyVK), a.eng != nil, a.isRecordingHotkey)
+ logger.Info("Hotkey ignored: key=%s engineReady=%t recordingHotkey=%t stoppingRecording=%t", hotkey.GetKeyName(a.cfg.HotkeyVK), engineReady, a.isRecordingHotkey, a.isStoppingRecording)
}
return
}
// Escape cancels any active recording
if a.cfg.HotkeyVK != 0x1B && (a.isRecording || a.isFreetalking) && a.hk.IsKeyDown(0x1B) {
+ a.isCombination = true
a.isFreetalking = false
a.isRecording = false
+ a.isHoldRecordingPending = false
a.lastStopTime = time.Now()
+ a.stopLiveCaptionLocked()
a.recorder.Stop()
logger.Info("Recording cancelled (Escape)")
a.indicator.SetStatus(overlay.StatusCancelled, "已取消")
@@ -297,15 +312,23 @@
return
}
+ if !engineReady && !a.hotkeyActive && !a.isRecording && !a.isFreetalking {
+ if isDown && time.Since(a.lastHotkeyBlocked) > time.Second {
+ a.lastHotkeyBlocked = time.Now()
+ logger.Info("Hotkey ignored: key=%s engineReady=false recordingHotkey=%t stoppingRecording=%t", hotkey.GetKeyName(a.cfg.HotkeyVK), a.isRecordingHotkey, a.isStoppingRecording)
+ }
+ return
+ }
+
switch config.NormalizeHotkeyMode(a.cfg.HotkeyMode) {
case config.HotkeyModeTap:
- a.pollTapHotkeyLocked(isDown)
+ a.pollTapHotkeyLocked(isDown, engineReady)
default:
- a.pollHoldHotkeyLocked(isDown)
+ a.pollHoldHotkeyLocked(isDown, engineReady)
}
}
-func (a *App) pollHoldHotkeyLocked(isDown bool) {
+func (a *App) pollHoldHotkeyLocked(isDown bool, engineReady bool) {
if isDown {
if !a.hotkeyActive {
// Key just pressed
@@ -320,20 +343,29 @@
a.stopTapRecordingLocked()
return
}
+ if engineReady && a.shouldStartHoldPreCaptureLocked() {
+ logger.Info("Hotkey action: start hold pre-capture")
+ a.startHoldPreCaptureLocked()
+ }
} else {
// Key held down - check for combination keys
if !a.isCombination && a.hk.IsAnyOtherKeyPressedSince(a.cfg.HotkeyVK, a.hotkeyPressTime) {
a.isCombination = true
logger.Info("Hotkey marked as combination: key=%s", hotkey.GetKeyName(a.cfg.HotkeyVK))
- if a.isRecording {
+ if a.isHoldRecordingPending {
+ a.cancelHoldPreCaptureLocked("combination key")
+ } else if a.isRecording {
a.stopRecordingLocked(true)
}
}
// If held long enough without combo, start hold-to-talk recording.
- if !a.isRecording && !a.isCombination && time.Since(a.hotkeyPressTime) > holdActivationDelay && time.Since(a.lastStopTime) > 500*time.Millisecond {
+ if engineReady && !a.isRecording && !a.isCombination && time.Since(a.hotkeyPressTime) > holdActivationDelay && time.Since(a.lastStopTime) > 500*time.Millisecond {
logger.Info("Hotkey action: start hold-to-talk after %dms", time.Since(a.hotkeyPressTime).Milliseconds())
a.startRecordingLocked()
+ } else if engineReady && a.isHoldRecordingPending && !a.isCombination && time.Since(a.hotkeyPressTime) > holdActivationDelay {
+ logger.Info("Hotkey action: confirm hold pre-capture after %dms", time.Since(a.hotkeyPressTime).Milliseconds())
+ a.confirmHoldPreCaptureLocked()
}
}
} else if a.hotkeyActive {
@@ -342,6 +374,11 @@
logger.Info("Hotkey released: key=%s mode=%s duration=%dms recording=%t combination=%t", hotkey.GetKeyName(a.cfg.HotkeyVK), config.HotkeyModeHold, pressDuration.Milliseconds(), a.isRecording, a.isCombination)
a.hotkeyActive = false
+ if a.isHoldRecordingPending {
+ a.cancelHoldPreCaptureLocked("released before activation")
+ return
+ }
+
if a.isRecording {
// Hold-to-talk: release stops recording
a.stopRecordingLocked(a.isCombination)
@@ -349,7 +386,60 @@
}
}
-func (a *App) pollTapHotkeyLocked(isDown bool) {
+func (a *App) shouldStartHoldPreCaptureLocked() bool {
+ return a.holdPreCaptureEnabled.Load() &&
+ !a.isRecording &&
+ !a.isStoppingRecording &&
+ !a.isFreetalking &&
+ time.Since(a.lastStopTime) > 500*time.Millisecond
+}
+
+func (a *App) startHoldPreCaptureLocked() {
+ if a.isRecording || a.isStoppingRecording || a.isFreetalking {
+ return
+ }
+ if err := a.recorder.Start(); err != nil {
+ logger.Error("Failed to start hold pre-capture: %v", err)
+ return
+ }
+ a.isRecording = true
+ a.isHoldRecordingPending = true
+ logger.Info("Hold pre-capture started")
+}
+
+func (a *App) confirmHoldPreCaptureLocked() {
+ if !a.isHoldRecordingPending || !a.isRecording {
+ a.isHoldRecordingPending = false
+ return
+ }
+ a.isHoldRecordingPending = false
+ a.hideGen.Add(1)
+
+ logger.Info("Hold pre-capture confirmed")
+ a.positionIndicator()
+ a.indicator.SetStatus(overlay.StatusRecording, "0:00")
+ a.indicator.Show()
+ if a.cfg.SoundFeedback {
+ sound.PlayStart()
+ }
+ a.startLiveCaptionLocked(overlay.StatusRecording)
+ a.startRecordingTimer(overlay.StatusRecording)
+}
+
+func (a *App) cancelHoldPreCaptureLocked(reason string) {
+ if !a.isHoldRecordingPending {
+ return
+ }
+ a.isHoldRecordingPending = false
+ if a.isRecording {
+ a.isRecording = false
+ a.lastStopTime = time.Now()
+ a.recorder.Stop()
+ }
+ logger.Info("Hold pre-capture cancelled: %s", reason)
+}
+
+func (a *App) pollTapHotkeyLocked(isDown bool, engineReady bool) {
if isDown {
if !a.hotkeyActive {
a.hotkeyActive = true
@@ -395,17 +485,18 @@
return
}
- if time.Since(a.lastStopTime) > 500*time.Millisecond {
+ if engineReady && time.Since(a.lastStopTime) > 500*time.Millisecond {
logger.Info("Hotkey action: start tap recording")
a.startTapRecordingLocked()
}
}
func (a *App) startRecordingLocked() {
- if a.isRecording {
+ if a.isRecording || a.isStoppingRecording {
return
}
a.isRecording = true
+ a.isHoldRecordingPending = false
a.hideGen.Add(1) // cancel any pending delayed hide
logger.Info("Recording started")
@@ -421,6 +512,7 @@
a.isRecording = false
return
}
+ a.startLiveCaptionLocked(overlay.StatusRecording)
a.startRecordingTimer(overlay.StatusRecording)
}
@@ -429,10 +521,12 @@
return
}
a.isRecording = false
+ a.isHoldRecordingPending = false
a.lastStopTime = time.Now()
if cancel {
logger.Info("Recording cancelled (combination key)")
+ a.stopLiveCaptionLocked()
a.indicator.SetStatus(overlay.StatusCancelled, "已取消")
a.recorder.Stop()
if a.cfg.SoundFeedback {
@@ -442,16 +536,20 @@
return
}
- hasVoice := a.recorder.HasVoiceActivity()
+ a.stopLiveCaptionLocked()
+ a.isStoppingRecording = true
a.indicator.SetStatus(overlay.StatusProcessing, "识别中")
go func() {
- samples := a.recorder.StopAndGetSamples()
- 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)
}()
}
func (a *App) startTapRecordingLocked() {
- if a.isRecording || a.isFreetalking {
+ if a.isRecording || a.isStoppingRecording || a.isFreetalking {
return
}
a.isFreetalking = true
@@ -474,6 +572,7 @@
a.isRecording = false
return
}
+ a.startLiveCaptionLocked(overlay.StatusFreetalking)
a.startRecordingTimer(overlay.StatusFreetalking)
}
@@ -491,15 +590,120 @@
case <-ticker.C:
a.mu.Lock()
recording := a.isRecording
+ captionActive := a.liveCaptionCancel != nil
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))
}
}
}()
+}
+
+func (a *App) startLiveCaptionLocked(status overlay.Status) {
+ if a.liveCaptionCancel != nil {
+ return
+ }
+
+ a.engineMu.Lock()
+ streamingEng, ok := a.eng.(engine.StreamingEngine)
+ if !ok {
+ a.engineMu.Unlock()
+ return
+ }
+
+ ctx, cancel := context.WithCancel(a.ctx)
+ a.liveCaptionSeq++
+ seq := a.liveCaptionSeq
+ a.liveCaptionCancel = cancel
+ go a.runLiveCaption(ctx, seq, status, streamingEng, a.engineMu.Unlock)
+}
+
+func (a *App) stopLiveCaptionLocked() {
+ if a.liveCaptionCancel == nil {
+ return
+ }
+ a.liveCaptionCancel()
+ a.liveCaptionCancel = nil
+ a.liveCaptionSeq++
+}
+
+func (a *App) clearLiveCaption(seq uint64) {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ if a.liveCaptionSeq == seq {
+ a.liveCaptionCancel = nil
+ }
+}
+
+func (a *App) runLiveCaption(ctx context.Context, seq uint64, status overlay.Status, streamingEng engine.StreamingEngine, releaseEngine func()) {
+ defer a.clearLiveCaption(seq)
+ defer releaseEngine()
+
+ session, err := streamingEng.NewStreamingSession()
+ if err != nil {
+ logger.Error("Live caption disabled: %v", err)
+ return
+ }
+ defer session.Close()
+
+ ticker := time.NewTicker(liveCaptionInterval)
+ defer ticker.Stop()
+
+ offset := 0
+ lastDisplay := ""
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ samples, nextOffset := a.recorder.ReadSamplesSince(offset)
+ offset = nextOffset
+ if len(samples) == 0 {
+ continue
+ }
+ partial, err := session.Accept(samples)
+ if err != nil {
+ logger.Error("Live caption failed: %v", err)
+ return
+ }
+ display := liveCaptionDisplayText(a.userdict.Apply(textproc.PostProcess(partial)))
+ if display == "" || display == lastDisplay {
+ continue
+ }
+
+ a.mu.Lock()
+ active := a.isRecording && a.liveCaptionSeq == seq
+ a.mu.Unlock()
+ if !active {
+ return
+ }
+
+ lastDisplay = display
+ a.indicator.SetStatus(status, display)
+ }
+ }
+}
+
+func liveCaptionDisplayText(text string) string {
+ text = strings.TrimSpace(text)
+ if text == "" || len([]byte(text)) <= liveCaptionMaxBytes {
+ return text
+ }
+
+ runes := []rune(text)
+ for len(runes) > 0 && len([]byte("..."+string(runes))) > liveCaptionMaxBytes {
+ runes = runes[1:]
+ }
+ if len(runes) == 0 {
+ return ""
+ }
+ return "..." + string(runes)
}
func (a *App) stopTapRecordingLocked() {
@@ -510,12 +714,16 @@
a.isRecording = false
a.lastStopTime = time.Now()
- hasVoice := a.recorder.HasVoiceActivity()
+ a.stopLiveCaptionLocked()
+ a.isStoppingRecording = true
logger.Info("Tap recording stopped")
a.indicator.SetStatus(overlay.StatusProcessing, "识别中")
go func() {
- samples := a.recorder.StopAndGetSamples()
- 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)
}()
}
@@ -556,6 +764,7 @@
a.isFreetalking = false
a.isRecording = false
a.lastStopTime = time.Now()
+ a.stopLiveCaptionLocked()
// MUST stop device in a separate goroutine: we are inside the audio
// data callback, and device.Stop() waits for in-flight callbacks to
// finish — calling it here would deadlock.
@@ -566,97 +775,308 @@
// 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(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 {
+ nanos := a.releaseTailCaptureNanos.Load()
+ if nanos <= 0 {
+ return 0
+ }
+ return time.Duration(nanos)
+}
+
+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.
@@ -741,17 +1161,43 @@
}
func (a *App) replaceEngine(eng engine.Engine) {
- a.engineMu.Lock()
- defer a.engineMu.Unlock()
+ a.releaseTailCaptureNanos.Store(int64(releaseTailCaptureDelayForEngine(eng)))
+ a.holdPreCaptureEnabled.Store(holdPreCaptureEnabledForEngine(eng))
- a.mu.Lock()
+ a.engineMu.Lock()
old := a.eng
a.eng = eng
- a.mu.Unlock()
+ a.engineMu.Unlock()
if old != nil && old != eng {
old.Close()
}
+}
+
+func releaseTailCaptureDelayForEngine(eng engine.Engine) time.Duration {
+ if eng == nil {
+ return 0
+ }
+ tailCaptureEng, ok := eng.(engine.ReleaseTailCaptureEngine)
+ if !ok {
+ return 0
+ }
+ delay := tailCaptureEng.ReleaseTailCaptureDelay()
+ if delay < 0 {
+ return 0
+ }
+ return delay
+}
+
+func holdPreCaptureEnabledForEngine(eng engine.Engine) bool {
+ if eng == nil {
+ return false
+ }
+ preCaptureEng, ok := eng.(engine.HoldPreCaptureEngine)
+ if !ok {
+ return true
+ }
+ return preCaptureEng.HoldPreCaptureEnabled()
}
func hotkeyReadyText(keyName, mode string) string {
@@ -961,8 +1407,8 @@
// GetEngine returns the current engine (may be nil).
func (a *App) GetEngine() engine.Engine {
- a.mu.Lock()
- defer a.mu.Unlock()
+ a.engineMu.Lock()
+ defer a.engineMu.Unlock()
return a.eng
}
--
Gitblit v1.9.3