From a5adc6d1d88cadeead81b79fd270501c243524c7 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Wed, 24 Jun 2026 02:48:56 +0800
Subject: [PATCH] Sync model download state in settings
---
privatevoice.src/app.go | 205 ++++++++++++++++++++++++++++++++++++++++++--------
1 files changed, 171 insertions(+), 34 deletions(-)
diff --git a/privatevoice.src/app.go b/privatevoice.src/app.go
index ad5b0e4..c73f9c8 100755
--- a/privatevoice.src/app.go
+++ b/privatevoice.src/app.go
@@ -28,8 +28,8 @@
)
const (
- appVersion = "2.1.30"
- appBuild = "20260607.1127"
+ appVersion = "2.1.35"
+ appBuild = "20260623.1435"
appDisplayVersion = appVersion + " (build " + appBuild + ")"
appName = "PrivateVoice Dictation"
@@ -63,23 +63,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
- liveCaptionCancel context.CancelFunc
- liveCaptionSeq uint64
+ 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 {
@@ -279,19 +283,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, "已取消")
@@ -302,15 +310,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
@@ -325,20 +341,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 {
@@ -347,6 +372,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)
@@ -354,7 +384,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
@@ -400,17 +483,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")
@@ -435,6 +519,7 @@
return
}
a.isRecording = false
+ a.isHoldRecordingPending = false
a.lastStopTime = time.Now()
if cancel {
@@ -450,16 +535,16 @@
}
a.stopLiveCaptionLocked()
- hasVoice := a.recorder.HasVoiceActivity()
+ a.isStoppingRecording = true
a.indicator.SetStatus(overlay.StatusProcessing, "识别中")
go func() {
- samples := a.recorder.StopAndGetSamples()
+ samples, hasVoice := a.stopRecorderAfterReleaseTailCapture()
a.recognizeAndPaste(hasVoice, samples)
}()
}
func (a *App) startTapRecordingLocked() {
- if a.isRecording || a.isFreetalking {
+ if a.isRecording || a.isStoppingRecording || a.isFreetalking {
return
}
a.isFreetalking = true
@@ -625,11 +710,11 @@
a.lastStopTime = time.Now()
a.stopLiveCaptionLocked()
- hasVoice := a.recorder.HasVoiceActivity()
+ a.isStoppingRecording = true
logger.Info("Tap recording stopped")
a.indicator.SetStatus(overlay.StatusProcessing, "识别中")
go func() {
- samples := a.recorder.StopAndGetSamples()
+ samples, hasVoice := a.stopRecorderAfterReleaseTailCapture()
a.recognizeAndPaste(hasVoice, samples)
}()
}
@@ -688,6 +773,35 @@
logger.Info("Free talk stopped (silence auto-stop)")
a.indicator.SetStatus(overlay.StatusProcessing, "识别中")
a.recognizeAndPaste(hasVoice, samples)
+}
+
+func (a *App) waitForReleaseTailCapture() {
+ delay := a.releaseTailCaptureDelay()
+ if delay <= 0 {
+ return
+ }
+ logger.Info("Release tail capture: waiting %dms before stopping audio", delay.Milliseconds())
+ time.Sleep(delay)
+}
+
+func (a *App) releaseTailCaptureDelay() time.Duration {
+ nanos := a.releaseTailCaptureNanos.Load()
+ if nanos <= 0 {
+ return 0
+ }
+ return time.Duration(nanos)
+}
+
+func (a *App) stopRecorderAfterReleaseTailCapture() ([]float32, bool) {
+ a.waitForReleaseTailCapture()
+ samples := a.recorder.StopAndGetSamples()
+ hasVoice := a.recorder.HasVoiceActivity()
+
+ a.mu.Lock()
+ a.isStoppingRecording = false
+ a.mu.Unlock()
+
+ return samples, hasVoice
}
// recognizeAndPaste runs ASR on the recorded samples and pastes the result.
@@ -857,6 +971,9 @@
}
func (a *App) replaceEngine(eng engine.Engine) {
+ a.releaseTailCaptureNanos.Store(int64(releaseTailCaptureDelayForEngine(eng)))
+ a.holdPreCaptureEnabled.Store(holdPreCaptureEnabledForEngine(eng))
+
a.engineMu.Lock()
old := a.eng
a.eng = eng
@@ -867,6 +984,26 @@
}
}
+func releaseTailCaptureDelayForEngine(eng engine.Engine) time.Duration {
+ 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 {
+ preCaptureEng, ok := eng.(engine.HoldPreCaptureEngine)
+ if !ok {
+ return false
+ }
+ return preCaptureEng.HoldPreCaptureEnabled()
+}
+
func hotkeyReadyText(keyName, mode string) string {
if mode == config.HotkeyModeTap {
return "点按" + keyName + "说话"
--
Gitblit v1.9.3