From 859c14d783dc31fe68550b114fc8adeea09dab2a Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Sat, 13 Jun 2026 21:48:32 +0800
Subject: [PATCH] Sync macOS paste clipboard safety to X-ASR branch

---
 privatevoice.src/app.go |  168 ++++++++++++++++++++++++++++++++++++++++++++++++++-----
 1 files changed, 151 insertions(+), 17 deletions(-)

diff --git a/privatevoice.src/app.go b/privatevoice.src/app.go
index 40d79a1..ad5b0e4 100755
--- a/privatevoice.src/app.go
+++ b/privatevoice.src/app.go
@@ -3,6 +3,7 @@
 import (
 	"context"
 	"fmt"
+	"strings"
 	"sync"
 	"sync/atomic"
 	"time"
@@ -14,6 +15,7 @@
 	"voicesnap/internal/history"
 	"voicesnap/internal/hotkey"
 	"voicesnap/internal/input"
+	"voicesnap/internal/language"
 	"voicesnap/internal/logger"
 	"voicesnap/internal/overlay"
 	"voicesnap/internal/sound"
@@ -26,16 +28,18 @@
 )
 
 const (
-	appVersion        = "2.1.19"
-	appBuild          = "20260602.2022"
+	appVersion        = "2.1.30"
+	appBuild          = "20260607.1127"
 	appDisplayVersion = appVersion + " (build " + appBuild + ")"
-	appName           = "PrivateVoice Input"
+	appName           = "PrivateVoice Dictation"
 
 	silenceThreshold         = 0.05            // RMS below this = silence (matches HasVoiceActivity)
 	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
 	doneIndicatorHideDelayMs = 250
+	liveCaptionInterval      = 120 * time.Millisecond
+	liveCaptionMaxBytes      = 108
 )
 
 // App holds all application state and orchestration logic.
@@ -47,6 +51,7 @@
 	cfg      *config.Config
 	recorder *audio.Recorder
 	eng      engine.Engine
+	engineMu sync.Mutex
 	hk       hotkey.Listener
 	paster   input.Paster
 	history  *history.Store
@@ -73,6 +78,8 @@
 	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
 }
 
 func RunApp() error {
@@ -110,7 +117,7 @@
 	// Create services for Wails bindings
 	appService := services.NewAppService(app.cfg, appDisplayVersion)
 	configService := services.NewConfigService(app.cfg)
-	engineService := services.NewEngineService()
+	engineService := services.NewEngineService(app.cfg)
 	app.engineService = engineService
 	hotkeyService := services.NewHotkeyService(app.cfg)
 	permissionService := services.NewPermissionService()
@@ -186,7 +193,7 @@
 	tray.SetTemplateIcon(trayIcon)
 
 	showLabel, exitLabel := "Show Settings", "Exit"
-	if isChinese() {
+	if language.Resolve(app.cfg.LanguageMode, app.cfg.LanguageID, language.NewSystemDetector()).UILocale == "zh" {
 		showLabel, exitLabel = "显示设置", "退出"
 	}
 
@@ -419,6 +426,7 @@
 		a.isRecording = false
 		return
 	}
+	a.startLiveCaptionLocked(overlay.StatusRecording)
 	a.startRecordingTimer(overlay.StatusRecording)
 }
 
@@ -431,6 +439,7 @@
 
 	if cancel {
 		logger.Info("Recording cancelled (combination key)")
+		a.stopLiveCaptionLocked()
 		a.indicator.SetStatus(overlay.StatusCancelled, "已取消")
 		a.recorder.Stop()
 		if a.cfg.SoundFeedback {
@@ -440,6 +449,7 @@
 		return
 	}
 
+	a.stopLiveCaptionLocked()
 	hasVoice := a.recorder.HasVoiceActivity()
 	a.indicator.SetStatus(overlay.StatusProcessing, "识别中")
 	go func() {
@@ -472,6 +482,7 @@
 		a.isRecording = false
 		return
 	}
+	a.startLiveCaptionLocked(overlay.StatusFreetalking)
 	a.startRecordingTimer(overlay.StatusFreetalking)
 }
 
@@ -489,15 +500,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() {
@@ -508,6 +624,7 @@
 	a.isRecording = false
 	a.lastStopTime = time.Now()
 
+	a.stopLiveCaptionLocked()
 	hasVoice := a.recorder.HasVoiceActivity()
 	logger.Info("Tap recording stopped")
 	a.indicator.SetStatus(overlay.StatusProcessing, "识别中")
@@ -554,6 +671,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.
@@ -596,7 +714,17 @@
 		return
 	}
 
-	text, err := a.eng.Recognize(samples)
+	a.engineMu.Lock()
+	eng := a.eng
+	if eng == nil {
+		a.engineMu.Unlock()
+		logger.Error("Recognition skipped: engine not ready")
+		a.indicator.SetStatus(overlay.StatusError, "引擎未就绪")
+		a.delayedHideIf(autoHide, 2000)
+		return
+	}
+	text, err := eng.Recognize(samples)
+	a.engineMu.Unlock()
 	if err != nil {
 		logger.Error("Recognition failed: %v", err)
 		a.indicator.SetStatus(overlay.StatusError, "错误")
@@ -686,6 +814,7 @@
 	eng, err := engine.New()
 	if err != nil {
 		logger.Error("Engine initialization failed: %v", err)
+		a.replaceEngine(nil)
 		status := "need_model"
 		if modelExists {
 			status = "error"
@@ -700,9 +829,7 @@
 		return
 	}
 
-	a.mu.Lock()
-	a.eng = eng
-	a.mu.Unlock()
+	a.replaceEngine(eng)
 
 	logger.Info("ASR engine ready: %s", eng.HardwareInfo())
 	if a.engineService != nil {
@@ -727,6 +854,17 @@
 	a.indicator.SetStatus(overlay.StatusReady, hotkeyReadyText(keyName, hotkeyMode))
 	a.indicator.Show()
 	a.delayedHideIf(autoHide, 2000)
+}
+
+func (a *App) replaceEngine(eng engine.Engine) {
+	a.engineMu.Lock()
+	old := a.eng
+	a.eng = eng
+	a.engineMu.Unlock()
+
+	if old != nil && old != eng {
+		old.Close()
+	}
 }
 
 func hotkeyReadyText(keyName, mode string) string {
@@ -910,9 +1048,7 @@
 	if a.recorder != nil {
 		a.recorder.Close()
 	}
-	if a.eng != nil {
-		a.eng.Close()
-	}
+	a.replaceEngine(nil)
 	a.wg.Wait()
 	logger.Info("Cleanup complete")
 }
@@ -938,14 +1074,12 @@
 
 // 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
 }
 
 // SetEngine sets the engine after model download.
 func (a *App) SetEngine(eng engine.Engine) {
-	a.mu.Lock()
-	defer a.mu.Unlock()
-	a.eng = eng
+	a.replaceEngine(eng)
 }

--
Gitblit v1.9.3