From 8f11df460a247dfcaafd84ce3046605724d11998 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Mon, 18 May 2026 17:20:11 +0800
Subject: [PATCH] feat: add hotkey input mode setting

---
 VoiceSnapGo/app.go |  100 +++++++++++++++++++++++++++++++++++++++-----------
 1 files changed, 78 insertions(+), 22 deletions(-)

diff --git a/VoiceSnapGo/app.go b/VoiceSnapGo/app.go
index 2805702..595d31c 100755
--- a/VoiceSnapGo/app.go
+++ b/VoiceSnapGo/app.go
@@ -26,8 +26,8 @@
 )
 
 const (
-	appVersion        = "2.1.2"
-	appBuild          = "20260518.1623"
+	appVersion        = "2.1.3"
+	appBuild          = "20260518.1651"
 	appDisplayVersion = appVersion + " (build " + appBuild + ")"
 	appName           = "VoiceSnap"
 
@@ -249,9 +249,10 @@
 		a.hotkeyPollSeen = true
 		a.lastHotkeyDown = isDown
 		logger.Info(
-			"Hotkey poll state: key=%s vk=0x%X down=%t active=%t recording=%t freetalk=%t engineReady=%t recordingHotkey=%t",
+			"Hotkey poll state: key=%s vk=0x%X mode=%s down=%t active=%t recording=%t freetalk=%t engineReady=%t recordingHotkey=%t",
 			hotkey.GetKeyName(a.cfg.HotkeyVK),
 			a.cfg.HotkeyVK,
+			config.NormalizeHotkeyMode(a.cfg.HotkeyMode),
 			isDown,
 			a.hotkeyActive,
 			a.isRecording,
@@ -284,18 +285,27 @@
 		return
 	}
 
+	switch config.NormalizeHotkeyMode(a.cfg.HotkeyMode) {
+	case config.HotkeyModeTap:
+		a.pollTapHotkeyLocked(isDown)
+	default:
+		a.pollHoldHotkeyLocked(isDown)
+	}
+}
+
+func (a *App) pollHoldHotkeyLocked(isDown bool) {
 	if isDown {
 		if !a.hotkeyActive {
 			// Key just pressed
 			a.hotkeyActive = true
 			a.isCombination = false
 			a.hotkeyPressTime = time.Now()
-			logger.Info("Hotkey accepted: key=%s vk=0x%X freetalk=%t", hotkey.GetKeyName(a.cfg.HotkeyVK), a.cfg.HotkeyVK, a.isFreetalking)
+			logger.Info("Hotkey accepted: key=%s vk=0x%X mode=%s freetalk=%t", hotkey.GetKeyName(a.cfg.HotkeyVK), a.cfg.HotkeyVK, config.HotkeyModeHold, a.isFreetalking)
 
-			// If in free talk mode, stop on next press
+			// If the mode changed while a tap recording was active, let the next press stop it.
 			if a.isFreetalking {
-				logger.Info("Hotkey action: stop free talk")
-				a.stopFreetalkLocked()
+				logger.Info("Hotkey action: stop tap recording after mode switch")
+				a.stopTapRecordingLocked()
 				return
 			}
 		} else {
@@ -317,17 +327,54 @@
 	} else if a.hotkeyActive {
 		// Key released
 		pressDuration := time.Since(a.hotkeyPressTime)
-		logger.Info("Hotkey released: key=%s duration=%dms recording=%t combination=%t", hotkey.GetKeyName(a.cfg.HotkeyVK), pressDuration.Milliseconds(), a.isRecording, a.isCombination)
+		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.isRecording {
 			// Hold-to-talk: release stops recording
 			a.stopRecordingLocked(a.isCombination)
-		} else if !a.isCombination && pressDuration < 300*time.Millisecond && time.Since(a.lastStopTime) > 500*time.Millisecond {
-			// Short tap (<300ms): start free talk mode
-			logger.Info("Hotkey action: start free talk after short tap")
-			a.startFreetalkLocked()
 		}
+	}
+}
+
+func (a *App) pollTapHotkeyLocked(isDown bool) {
+	if isDown {
+		if !a.hotkeyActive {
+			a.hotkeyActive = true
+			a.isCombination = 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)
+			return
+		}
+
+		if !a.isCombination && a.hk.IsAnyOtherKeyPressedSince(a.cfg.HotkeyVK, a.hotkeyPressTime) {
+			a.isCombination = true
+			logger.Info("Hotkey marked as combination: key=%s mode=%s", hotkey.GetKeyName(a.cfg.HotkeyVK), config.HotkeyModeTap)
+		}
+		return
+	}
+
+	if !a.hotkeyActive {
+		return
+	}
+
+	pressDuration := time.Since(a.hotkeyPressTime)
+	logger.Info("Hotkey released: key=%s mode=%s duration=%dms recording=%t combination=%t", hotkey.GetKeyName(a.cfg.HotkeyVK), config.HotkeyModeTap, pressDuration.Milliseconds(), a.isRecording, a.isCombination)
+	a.hotkeyActive = false
+
+	if a.isCombination {
+		return
+	}
+
+	if a.isFreetalking {
+		logger.Info("Hotkey action: stop tap recording")
+		a.stopTapRecordingLocked()
+		return
+	}
+
+	if time.Since(a.lastStopTime) > 500*time.Millisecond {
+		logger.Info("Hotkey action: start tap recording")
+		a.startTapRecordingLocked()
 	}
 }
 
@@ -379,7 +426,7 @@
 	go a.recognizeAndPaste(hasVoice, samples)
 }
 
-func (a *App) startFreetalkLocked() {
+func (a *App) startTapRecordingLocked() {
 	if a.isRecording || a.isFreetalking {
 		return
 	}
@@ -389,7 +436,7 @@
 	a.silenceSince = time.Time{}
 	a.hideGen.Add(1)
 
-	logger.Info("Free talk started")
+	logger.Info("Tap recording started")
 	a.positionIndicator()
 	a.indicator.SetStatus(overlay.StatusFreetalking, "0:00")
 	a.indicator.Show()
@@ -398,7 +445,7 @@
 	}
 
 	if err := a.recorder.Start(); err != nil {
-		logger.Error("Failed to start free talk recording: %v", err)
+		logger.Error("Failed to start tap recording: %v", err)
 		a.isFreetalking = false
 		a.isRecording = false
 		return
@@ -431,7 +478,7 @@
 	}()
 }
 
-func (a *App) stopFreetalkLocked() {
+func (a *App) stopTapRecordingLocked() {
 	if !a.isFreetalking {
 		return
 	}
@@ -442,19 +489,20 @@
 	hasVoice := a.recorder.HasVoiceActivity()
 	samples := a.recorder.StopAndGetSamples()
 
-	logger.Info("Free talk stopped")
+	logger.Info("Tap recording stopped")
 	a.indicator.SetStatus(overlay.StatusProcessing, "识别中")
 	go a.recognizeAndPaste(hasVoice, samples)
 }
 
-// checkSilenceTimeout monitors volume during free-talk mode and auto-stops
-// recording if silence persists for silenceTimeoutDuration.
+// checkSilenceTimeout monitors legacy free-talk recordings. The explicit tap
+// mode is user-stopped by pressing the hotkey again, so it does not auto-stop
+// on silence.
 // Called from the audio volume callback (~every 50ms).
 func (a *App) checkSilenceTimeout(vol float64) {
 	a.mu.Lock()
 	defer a.mu.Unlock()
 
-	if !a.isFreetalking {
+	if !a.isFreetalking || config.NormalizeHotkeyMode(a.cfg.HotkeyMode) == config.HotkeyModeTap {
 		a.silenceSince = time.Time{}
 		return
 	}
@@ -502,7 +550,7 @@
 }
 
 // recognizeAndPaste runs ASR on the recorded samples and pastes the result.
-// Shared by both hold-to-talk and free-talk modes.
+// Shared by both hold and tap modes.
 func (a *App) recognizeAndPaste(hasVoice bool, samples []float32) {
 	// Snapshot config values under lock to avoid data races
 	a.mu.Lock()
@@ -631,6 +679,7 @@
 	// Show indicator briefly — snapshot config under lock
 	a.mu.Lock()
 	hotkeyVK := a.cfg.HotkeyVK
+	hotkeyMode := config.NormalizeHotkeyMode(a.cfg.HotkeyMode)
 	autoHide := a.cfg.AutoHide
 	indX := a.cfg.IndicatorX
 	indY := a.cfg.IndicatorY
@@ -638,11 +687,18 @@
 
 	keyName := hotkey.GetKeyName(hotkeyVK)
 	a.positionIndicatorAt(indX, indY)
-	a.indicator.SetStatus(overlay.StatusReady, "按住"+keyName+"说话")
+	a.indicator.SetStatus(overlay.StatusReady, hotkeyReadyText(keyName, hotkeyMode))
 	a.indicator.Show()
 	a.delayedHideIf(autoHide, 2000)
 }
 
+func hotkeyReadyText(keyName, mode string) string {
+	if mode == config.HotkeyModeTap {
+		return "点按" + keyName + "说话"
+	}
+	return "按住" + keyName + "说话"
+}
+
 // positionIndicator places the indicator at the saved position, or bottom-center if not saved.
 // Caller must hold a.mu (reads a.cfg).
 func (a *App) positionIndicator() {

--
Gitblit v1.9.3