From 3fbceec8de833416bf1ae049f959f222c3f1a4a0 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Tue, 02 Jun 2026 14:05:59 +0800
Subject: [PATCH] Prepare Round 1 model profile QA baseline

---
 privatevoice.src/app.go |  212 ++++++++++++++++++++++++++++++++++++++++++++++------
 1 files changed, 186 insertions(+), 26 deletions(-)

diff --git a/VoiceSnapGo/app.go b/privatevoice.src/app.go
similarity index 78%
rename from VoiceSnapGo/app.go
rename to privatevoice.src/app.go
index dd957bc..54bbc93 100755
--- a/VoiceSnapGo/app.go
+++ b/privatevoice.src/app.go
@@ -26,14 +26,16 @@
 )
 
 const (
-	appVersion        = "2.1.4"
-	appBuild          = "20260518.1748"
+	appVersion        = "2.1.18"
+	appBuild          = "20260601.1905"
 	appDisplayVersion = appVersion + " (build " + appBuild + ")"
-	appName           = "VoiceSnap"
+	appName           = "PrivateVoice Input"
 
-	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
+	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
 )
 
 // App holds all application state and orchestration logic.
@@ -50,6 +52,7 @@
 	history  *history.Store
 	userdict *userdict.Store
 
+	engineService  *services.EngineService
 	wailsApp       *application.App
 	settingsWindow *application.WebviewWindow
 	indicator      overlay.Overlay
@@ -61,6 +64,7 @@
 	hotkeyActive      bool
 	hotkeyPressTime   time.Time
 	isCombination     bool
+	tapStopOnPress    bool
 	isRecordingHotkey bool
 	hideGen           atomic.Uint64
 	lastStopTime      time.Time
@@ -107,12 +111,14 @@
 	appService := services.NewAppService(app.cfg, appDisplayVersion)
 	configService := services.NewConfigService(app.cfg)
 	engineService := services.NewEngineService()
+	app.engineService = engineService
 	hotkeyService := services.NewHotkeyService(app.cfg)
 	permissionService := services.NewPermissionService()
 	updaterService := services.NewUpdaterService(appVersion)
 	audioService := services.NewAudioService(app.recorder, app.cfg)
 	historyService := services.NewHistoryService(app.history)
 	userDictService := services.NewUserDictService(app.userdict)
+	correctionCSVService := services.NewCorrectionCSVService(app.history, app.userdict)
 
 	// Create Wails application
 	wailsApp := application.New(application.Options{
@@ -128,6 +134,7 @@
 			application.NewService(audioService),
 			application.NewService(historyService),
 			application.NewService(userDictService),
+			application.NewService(correctionCSVService),
 		},
 		Assets: application.AssetOptions{
 			Handler: application.AssetFileServerFS(assets),
@@ -145,7 +152,7 @@
 	// Create settings window (visible on startup, close → hide to tray)
 	app.settingsWindow = wailsApp.Window.NewWithOptions(application.WebviewWindowOptions{
 		Name:             "settings",
-		Title:            "VoiceSnap",
+		Title:            appName,
 		Width:            820,
 		Height:           580,
 		URL:              "/",
@@ -162,10 +169,13 @@
 		app.settingsWindow.Hide()
 	})
 	userDictService.SetApp(wailsApp, app.settingsWindow)
+	correctionCSVService.SetApp(wailsApp, app.settingsWindow)
 
 	// Create overlay indicator (native Win32 on Windows, native AppKit on macOS)
 	app.indicator = overlay.New()
 	app.indicator.OnDragged(func(x, y int) {
+		x, y = app.clampIndicatorPosition(x, y)
+		app.indicator.SetPosition(x, y)
 		app.cfg.IndicatorX = x
 		app.cfg.IndicatorY = y
 		config.Save(app.cfg)
@@ -318,8 +328,8 @@
 				}
 			}
 
-			// If held > 300ms without combo, start hold-to-talk recording
-			if !a.isRecording && !a.isCombination && time.Since(a.hotkeyPressTime) > 300*time.Millisecond && time.Since(a.lastStopTime) > 500*time.Millisecond {
+			// 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 {
 				logger.Info("Hotkey action: start hold-to-talk after %dms", time.Since(a.hotkeyPressTime).Milliseconds())
 				a.startRecordingLocked()
 			}
@@ -342,8 +352,14 @@
 		if !a.hotkeyActive {
 			a.hotkeyActive = true
 			a.isCombination = false
+			a.tapStopOnPress = false
 			a.hotkeyPressTime = time.Now()
 			logger.Info("Hotkey accepted: key=%s vk=0x%X mode=%s recording=%t", hotkey.GetKeyName(a.cfg.HotkeyVK), a.cfg.HotkeyVK, config.HotkeyModeTap, a.isFreetalking)
+			if a.isFreetalking {
+				logger.Info("Hotkey action: stop tap recording on press")
+				a.tapStopOnPress = true
+				a.stopTapRecordingLocked()
+			}
 			return
 		}
 
@@ -363,6 +379,11 @@
 	a.hotkeyActive = false
 
 	if a.isCombination {
+		return
+	}
+
+	if a.tapStopOnPress {
+		a.tapStopOnPress = false
 		return
 	}
 
@@ -409,9 +430,9 @@
 	a.lastStopTime = time.Now()
 
 	if cancel {
-		a.recorder.Stop()
 		logger.Info("Recording cancelled (combination key)")
 		a.indicator.SetStatus(overlay.StatusCancelled, "已取消")
+		a.recorder.Stop()
 		if a.cfg.SoundFeedback {
 			sound.PlayCancel()
 		}
@@ -420,10 +441,11 @@
 	}
 
 	hasVoice := a.recorder.HasVoiceActivity()
-	samples := a.recorder.StopAndGetSamples()
-
 	a.indicator.SetStatus(overlay.StatusProcessing, "识别中")
-	go a.recognizeAndPaste(hasVoice, samples)
+	go func() {
+		samples := a.recorder.StopAndGetSamples()
+		a.recognizeAndPaste(hasVoice, samples)
+	}()
 }
 
 func (a *App) startTapRecordingLocked() {
@@ -487,11 +509,12 @@
 	a.lastStopTime = time.Now()
 
 	hasVoice := a.recorder.HasVoiceActivity()
-	samples := a.recorder.StopAndGetSamples()
-
 	logger.Info("Tap recording stopped")
 	a.indicator.SetStatus(overlay.StatusProcessing, "识别中")
-	go a.recognizeAndPaste(hasVoice, samples)
+	go func() {
+		samples := a.recorder.StopAndGetSamples()
+		a.recognizeAndPaste(hasVoice, samples)
+	}()
 }
 
 // checkSilenceTimeout monitors legacy free-talk recordings. The explicit tap
@@ -609,7 +632,7 @@
 	if soundFeedback {
 		sound.PlayDone()
 	}
-	a.delayedHideIf(autoHide, 2000)
+	a.delayedHideIf(autoHide, doneIndicatorHideDelayMs)
 }
 
 // waitForHotkeyRelease polls until the hotkey is released (max 500ms),
@@ -652,15 +675,26 @@
 
 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",
 	})
 
+	modelExists := engine.ModelExists()
 	eng, err := engine.New()
 	if err != nil {
 		logger.Error("Engine initialization failed: %v", err)
+		status := "need_model"
+		if modelExists {
+			status = "error"
+		}
+		if a.engineService != nil {
+			a.engineService.SetStatus(status, "", err.Error())
+		}
 		a.wailsApp.Event.Emit("engine:status", map[string]interface{}{
-			"status": "need_model",
+			"status": status,
 			"error":  err.Error(),
 		})
 		return
@@ -671,6 +705,9 @@
 	a.mu.Unlock()
 
 	logger.Info("ASR engine ready: %s", eng.HardwareInfo())
+	if a.engineService != nil {
+		a.engineService.SetStatus("ready", eng.HardwareInfo(), "")
+	}
 	a.wailsApp.Event.Emit("engine:status", map[string]interface{}{
 		"status":       "ready",
 		"hardwareInfo": eng.HardwareInfo(),
@@ -707,20 +744,143 @@
 
 // positionIndicatorAt places the indicator at (x, y), or bottom-center if both are 0.
 func (a *App) positionIndicatorAt(x, y int) {
-	if x != 0 || y != 0 {
+	indicatorW, indicatorH := a.indicator.Size()
+	if indicatorW <= 0 {
+		indicatorW = 170
+	}
+	if indicatorH <= 0 {
+		indicatorH = 48
+	}
+
+	// On macOS, place the overlay near the currently focused input window each
+	// time instead of reusing a stale saved position from another display.
+	if autoPositioner, ok := a.indicator.(interface{ AutoPosition() }); ok {
+		autoPositioner.AutoPosition()
+		return
+	}
+
+	screen := a.wailsApp.Screen.GetPrimary()
+	if screen == nil {
+		if x != 0 || y != 0 {
+			a.indicator.SetPosition(x, y)
+		}
+		return
+	}
+
+	wa := screen.WorkArea
+	if x == 0 && y == 0 {
+		x = wa.X + (wa.Width-indicatorW)/2
+		y = wa.Y + wa.Height - indicatorH - 100
+		x, y = clampOverlayPosition(x, y, indicatorW, indicatorH, wa.X, wa.Y, wa.Width, wa.Height)
 		a.indicator.SetPosition(x, y)
 		return
 	}
-	// Try Screen.GetPrimary(); on macOS this may return nil during early startup.
-	// The darwin overlay has its own autoPosition fallback in Show().
-	screen := a.wailsApp.Screen.GetPrimary()
+	x, y = a.clampIndicatorPositionWithSize(x, y, indicatorW, indicatorH)
+	a.indicator.SetPosition(x, y)
+}
+
+func (a *App) clampIndicatorPosition(x, y int) (int, int) {
+	if a.wailsApp == nil || a.indicator == nil {
+		return x, y
+	}
+	indicatorW, indicatorH := a.indicator.Size()
+	if indicatorW <= 0 {
+		indicatorW = 170
+	}
+	if indicatorH <= 0 {
+		indicatorH = 48
+	}
+	return a.clampIndicatorPositionWithSize(x, y, indicatorW, indicatorH)
+}
+
+func (a *App) clampIndicatorPositionWithSize(x, y, indicatorW, indicatorH int) (int, int) {
+	screen := a.bestScreenForOverlay(x, y, indicatorW, indicatorH)
 	if screen == nil {
-		return
+		return x, y
 	}
 	wa := screen.WorkArea
-	cx := wa.X + (wa.Width-170)/2
-	cy := wa.Y + wa.Height - 48 - 100
-	a.indicator.SetPosition(cx, cy)
+	return clampOverlayPosition(x, y, indicatorW, indicatorH, wa.X, wa.Y, wa.Width, wa.Height)
+}
+
+func (a *App) bestScreenForOverlay(x, y, overlayW, overlayH int) *application.Screen {
+	if a.wailsApp == nil {
+		return nil
+	}
+
+	screens := a.wailsApp.Screen.GetAll()
+	if len(screens) == 0 {
+		if primary := a.wailsApp.Screen.GetPrimary(); primary != nil {
+			return primary
+		}
+		return nil
+	}
+
+	centerX := x + overlayW/2
+	centerY := y + overlayH/2
+	for _, screen := range screens {
+		wa := screen.WorkArea
+		if pointInRect(centerX, centerY, wa.X, wa.Y, wa.Width, wa.Height) {
+			return screen
+		}
+	}
+
+	var best *application.Screen
+	bestScore := 0
+	bestDistance := 0
+	for _, screen := range screens {
+		wa := screen.WorkArea
+		intersection := intersectionArea(x, y, overlayW, overlayH, wa.X, wa.Y, wa.Width, wa.Height)
+		distance := rectDistanceSquared(x, y, overlayW, overlayH, wa.X, wa.Y, wa.Width, wa.Height)
+		if best == nil || intersection > bestScore || (intersection == bestScore && distance < bestDistance) {
+			best = screen
+			bestScore = intersection
+			bestDistance = distance
+		}
+	}
+	return best
+}
+
+func clampOverlayPosition(x, y, overlayW, overlayH, areaX, areaY, areaW, areaH int) (int, int) {
+	return clampOverlayAxis(x, areaX, areaW, overlayW), clampOverlayAxis(y, areaY, areaH, overlayH)
+}
+
+func clampOverlayAxis(pos, areaStart, areaSize, overlaySize int) int {
+	if areaSize <= 0 || overlaySize <= 0 {
+		return pos
+	}
+	if overlaySize >= areaSize {
+		return areaStart + (areaSize-overlaySize)/2
+	}
+	minPos := areaStart
+	maxPos := areaStart + areaSize - overlaySize
+	if pos < minPos {
+		return minPos
+	}
+	if pos > maxPos {
+		return maxPos
+	}
+	return pos
+}
+
+func pointInRect(x, y, rx, ry, rw, rh int) bool {
+	return x >= rx && x < rx+rw && y >= ry && y < ry+rh
+}
+
+func intersectionArea(ax, ay, aw, ah, bx, by, bw, bh int) int {
+	left := max(ax, bx)
+	top := max(ay, by)
+	right := min(ax+aw, bx+bw)
+	bottom := min(ay+ah, by+bh)
+	if right <= left || bottom <= top {
+		return 0
+	}
+	return (right - left) * (bottom - top)
+}
+
+func rectDistanceSquared(ax, ay, aw, ah, bx, by, bw, bh int) int {
+	dx := max(0, max(bx-(ax+aw), ax-(bx+bw)))
+	dy := max(0, max(by-(ay+ah), ay-(by+bh)))
+	return dx*dx + dy*dy
 }
 
 func (a *App) showSettings() {

--
Gitblit v1.9.3