| | |
| | | ) |
| | | |
| | | const ( |
| | | appVersion = "2.1.2" |
| | | appBuild = "20260518.1623" |
| | | appVersion = "2.1.3" |
| | | appBuild = "20260518.1651" |
| | | appDisplayVersion = appVersion + " (build " + appBuild + ")" |
| | | appName = "VoiceSnap" |
| | | |
| | |
| | | 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, |
| | |
| | | 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 { |
| | |
| | | } 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() |
| | | } |
| | | } |
| | | |
| | |
| | | go a.recognizeAndPaste(hasVoice, samples) |
| | | } |
| | | |
| | | func (a *App) startFreetalkLocked() { |
| | | func (a *App) startTapRecordingLocked() { |
| | | if a.isRecording || a.isFreetalking { |
| | | return |
| | | } |
| | |
| | | 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() |
| | |
| | | } |
| | | |
| | | 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 |
| | |
| | | }() |
| | | } |
| | | |
| | | func (a *App) stopFreetalkLocked() { |
| | | func (a *App) stopTapRecordingLocked() { |
| | | if !a.isFreetalking { |
| | | return |
| | | } |
| | |
| | | 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 |
| | | } |
| | |
| | | } |
| | | |
| | | // 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() |
| | |
| | | // 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 |
| | |
| | | |
| | | 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() { |