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