Ariver
2026-06-02 3fbceec8de833416bf1ae049f959f222c3f1a4a0
privatevoice.src/app.go
File was renamed from VoiceSnapGo/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() {