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