Ariver
2026-05-18 8f11df460a247dfcaafd84ce3046605724d11998
feat: add hotkey input mode setting
15 files modified
261 ■■■■ changed files
CHANGELOG.md 16 ●●●●● patch | view | raw | blame | history
TODO.md 7 ●●●●● patch | view | raw | blame | history
VoiceSnapGo/app.go 100 ●●●● patch | view | raw | blame | history
VoiceSnapGo/build/config.yml 2 ●●● patch | view | raw | blame | history
VoiceSnapGo/build/darwin/Info.plist 4 ●●●● patch | view | raw | blame | history
VoiceSnapGo/build/windows/wails.exe.manifest 2 ●●● patch | view | raw | blame | history
VoiceSnapGo/frontend/package-lock.json 4 ●●●● patch | view | raw | blame | history
VoiceSnapGo/frontend/package.json 2 ●●● patch | view | raw | blame | history
VoiceSnapGo/frontend/src/components/settings/AboutPage.svelte 2 ●●● patch | view | raw | blame | history
VoiceSnapGo/frontend/src/components/settings/GeneralPage.svelte 74 ●●●●● patch | view | raw | blame | history
VoiceSnapGo/frontend/src/lib/i18n/en.json 9 ●●●● patch | view | raw | blame | history
VoiceSnapGo/frontend/src/lib/i18n/zh.json 9 ●●●● patch | view | raw | blame | history
VoiceSnapGo/frontend/src/lib/stores/config.ts 1 ●●●● patch | view | raw | blame | history
VoiceSnapGo/internal/config/config.go 17 ●●●●● patch | view | raw | blame | history
VoiceSnapGo/services/config_service.go 12 ●●●●● patch | view | raw | blame | history
CHANGELOG.md
@@ -1,5 +1,21 @@
# Changelog
## v2.1.3 (2026-05-18)
### 新功能
- **语音输入方式可选**:设置页新增“点按 / 长按”模式。点按模式下,点一下快捷键开始说话,再点一下结束并输入;长按模式下,按住快捷键开始说话,松开后结束并输入。
### 改进
- 热键状态机拆分为清晰的点按与长按逻辑,避免短按/长按自动判断造成交互不确定。
### 构建
- build: `20260518.1651`
---
## v2.1.2 (2026-05-18)
### Bug 修复
TODO.md
@@ -21,6 +21,13 @@
## Done
- [2026-05-18] 增加语音输入交互方式设置。
  - 新增配置 `HotkeyMode`,默认 `hold`。
  - 设置页新增“语音输入方式”分段控件,支持“点按”和“长按”。
  - 点按: 点一下快捷键开始说话,再点一下结束并识别输入。
  - 长按: 长按快捷键开始录音,松开后结束并识别输入。
  - 版本: `2.1.3`
  - build: `20260518.1651`
- [2026-05-18] 修复全屏窗口下语音输入悬浮指示器不显示。
  - 根因: macOS 全屏 Space 下普通 `NSWindow` + `NSFloatingWindowLevel` 不稳定,容易被全屏 App 压在下层。
  - 修复: 悬浮指示器改为非激活 `NSPanel`,窗口层级提升到 `kCGStatusWindowLevelKey`,并补充 `Transient`、`IgnoresCycle`、`FullScreenAuxiliary`、`CanJoinAllSpaces` 行为。
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() {
VoiceSnapGo/build/config.yml
@@ -7,7 +7,7 @@
  description: "Offline voice to text tool"
  copyright: "(c) 2025, VoiceSnap"
  comments: "Press and hold hotkey to record, release to transcribe"
  version: "2.1.2"
  version: "2.1.3"
dev_mode:
  root_path: .
VoiceSnapGo/build/darwin/Info.plist
@@ -15,9 +15,9 @@
    <key>CFBundlePackageType</key>
    <string>APPL</string>
    <key>CFBundleShortVersionString</key>
    <string>2.1.2</string>
    <string>2.1.3</string>
    <key>CFBundleVersion</key>
    <string>20260518.1623</string>
    <string>20260518.1651</string>
    <key>LSMinimumSystemVersion</key>
    <string>11.0</string>
    <key>NSMicrophoneUsageDescription</key>
VoiceSnapGo/build/windows/wails.exe.manifest
@@ -3,7 +3,7 @@
  <assemblyIdentity
    type="win32"
    name="VoiceSnap"
    version="2.1.2.0"
    version="2.1.3.0"
    processorArchitecture="*"/>
  <dependency>
    <dependentAssembly>
VoiceSnapGo/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
  "name": "voicesnap-frontend",
  "version": "2.1.2",
  "version": "2.1.3",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "voicesnap-frontend",
      "version": "2.1.2",
      "version": "2.1.3",
      "dependencies": {
        "@wailsio/runtime": "latest"
      },
VoiceSnapGo/frontend/package.json
@@ -1,7 +1,7 @@
{
  "name": "voicesnap-frontend",
  "private": true,
  "version": "2.1.2",
  "version": "2.1.3",
  "type": "module",
  "scripts": {
    "dev": "vite dev",
VoiceSnapGo/frontend/src/components/settings/AboutPage.svelte
@@ -2,7 +2,7 @@
  import { Call } from '@wailsio/runtime'
  import { t } from '../../lib/i18n'
  let version = $state('2.1.2')
  let version = $state('2.1.3')
  let updateStatus = $state('')
  let updateColor = $state('')
  let checking = $state(false)
VoiceSnapGo/frontend/src/components/settings/GeneralPage.svelte
@@ -2,7 +2,7 @@
  import { Call } from '@wailsio/runtime'
  import ToggleSwitch from '../shared/ToggleSwitch.svelte'
  import { t } from '../../lib/i18n'
  import { autoHide, soundFeedback, copyToClipboard, startAtLogin, hotkeyVK } from '../../lib/stores/config'
  import { autoHide, soundFeedback, copyToClipboard, startAtLogin, hotkeyVK, hotkeyMode } from '../../lib/stores/config'
  import { deviceName, engineStatus, engineHardwareInfo } from '../../lib/stores/app'
  import { hotkeyName } from '../../lib/stores/indicator'
@@ -21,6 +21,7 @@
  let status = $state('loading')
  let hwInfo = $state('')
  let currentKeyName = $state('Ctrl')
  let hotkeyModeVal: 'tap' | 'hold' = $state('hold')
  let isRecording = $state(false)
  let hintText = $state('')
  let devices = $state<InputDevice[]>([])
@@ -34,6 +35,7 @@
  const unsub6 = hotkeyName.subscribe(k => { currentKeyName = k })
  const unsub7 = soundFeedback.subscribe(v => { soundFeedbackVal = v })
  const unsub8 = copyToClipboard.subscribe(v => { copyToClipboardVal = v })
  const unsub9 = hotkeyMode.subscribe(v => { hotkeyModeVal = v })
  // Load actual device name from backend on mount
  async function loadDeviceName() {
@@ -69,6 +71,12 @@
      copyToClipboardVal = copy
    } catch {}
    try {
      const mode: any = await Call.ByName('voicesnap/services.ConfigService.GetHotkeyMode')
      const normalized = mode === 'tap' ? 'tap' : 'hold'
      hotkeyMode.set(normalized)
      hotkeyModeVal = normalized
    } catch {}
    try {
      const hideDock: any = await Call.ByName('voicesnap/services.ConfigService.GetHideDockIcon')
      hideDockIconVal = !!hideDock
    } catch {}
@@ -94,6 +102,14 @@
    copyToClipboard.set(checked)
    try {
      await Call.ByName('voicesnap/services.ConfigService.SetCopyToClipboard', checked)
    } catch {}
  }
  async function onHotkeyModeChange(mode: 'tap' | 'hold') {
    hotkeyMode.set(mode)
    hotkeyModeVal = mode
    try {
      await Call.ByName('voicesnap/services.ConfigService.SetHotkeyMode', mode)
    } catch {}
  }
@@ -212,7 +228,7 @@
  <!-- Header -->
  <div class="header">
    <h1 class="tagline">{t('home.tagline')}</h1>
    <p class="app-desc">{t('home.modeHint')}</p>
    <p class="app-desc">{hotkeyModeVal === 'tap' ? t('home.modeHintTap') : t('home.modeHintHold')}</p>
  </div>
  <!-- Hotkey -->
@@ -232,6 +248,31 @@
    {#if hintText}
      <p class="hint" class:recording={isRecording}>{hintText}</p>
    {/if}
    <div class="divider"></div>
    <div class="setting-row">
      <div class="setting-info">
        <span class="setting-label">{t('settings.hotkeyMode')}</span>
        <span class="setting-desc">
          {hotkeyModeVal === 'tap' ? t('settings.hotkeyModeTapDesc') : t('settings.hotkeyModeHoldDesc')}
        </span>
      </div>
      <div class="segmented" role="group" aria-label={t('settings.hotkeyMode')}>
        <button
          class:active={hotkeyModeVal === 'tap'}
          onclick={() => onHotkeyModeChange('tap')}
        >
          {t('settings.hotkeyModeTap')}
        </button>
        <button
          class:active={hotkeyModeVal === 'hold'}
          onclick={() => onHotkeyModeChange('hold')}
        >
          {t('settings.hotkeyModeHold')}
        </button>
      </div>
    </div>
  </div>
@@ -476,6 +517,35 @@
    color: var(--color-tertiary-label);
  }
  .segmented {
    display: inline-grid;
    grid-template-columns: 1fr 1fr;
    min-width: 148px;
    padding: 2px;
    border-radius: var(--radius-sm);
    background: var(--color-bg-secondary);
    flex-shrink: 0;
  }
  .segmented button {
    min-width: 66px;
    height: 30px;
    padding: 0 12px;
    border: none;
    border-radius: calc(var(--radius-sm) - 2px);
    background: transparent;
    color: var(--color-secondary-label);
    font-size: var(--font-size-sm);
    font-weight: 500;
    cursor: pointer;
    transition: background var(--transition-fast), color var(--transition-fast);
  }
  .segmented button.active {
    background: var(--color-blue);
    color: white;
  }
  .divider {
    height: 1px;
    background: var(--color-separator);
VoiceSnapGo/frontend/src/lib/i18n/en.json
@@ -32,10 +32,17 @@
    "tagline": "Snap · Voice to Text",
    "description": "Hold {key} to speak, release to recognize and type",
    "hotkeyLabel": "Trigger Hotkey",
    "modeHint": "Hold to record, release to type · Tap to start free talk, tap again to stop"
    "modeHint": "Hold to record, release to type · Tap to start free talk, tap again to stop",
    "modeHintTap": "Tap to start speaking, tap again to stop and type",
    "modeHintHold": "Hold to speak, release to stop and type"
  },
  "settings": {
    "inputDevice": "Input Device",
    "hotkeyMode": "Voice Input Mode",
    "hotkeyModeTap": "Tap",
    "hotkeyModeHold": "Hold",
    "hotkeyModeTapDesc": "Tap the hotkey to start speaking, then tap again to stop and type",
    "hotkeyModeHoldDesc": "Hold the hotkey to speak, release to stop and type",
    "autoHide": "Auto-hide Indicator",
    "autoHideDesc": "Automatically hide the floating indicator after completion",
    "soundFeedback": "Sound Feedback",
VoiceSnapGo/frontend/src/lib/i18n/zh.json
@@ -32,10 +32,17 @@
    "tagline": "Snap · 语音即输入",
    "description": "按住 {key} 说话,松开即识别并输入文字",
    "hotkeyLabel": "触发热键",
    "modeHint": "长按录音,松开输入 · 短按开始自由说话,再按结束"
    "modeHint": "长按录音,松开输入 · 短按开始自由说话,再按结束",
    "modeHintTap": "点按开始说话,再点按结束并输入",
    "modeHintHold": "长按开始说话,松开结束并输入"
  },
  "settings": {
    "inputDevice": "输入设备",
    "hotkeyMode": "语音输入方式",
    "hotkeyModeTap": "点按",
    "hotkeyModeHold": "长按",
    "hotkeyModeTapDesc": "点一下快捷键开始说话,再点一下结束并输入",
    "hotkeyModeHoldDesc": "按住快捷键开始说话,松开后结束并输入",
    "autoHide": "自动隐藏指示器",
    "autoHideDesc": "操作完成后自动隐藏浮动指示器",
    "soundFeedback": "提示音",
VoiceSnapGo/frontend/src/lib/stores/config.ts
@@ -5,3 +5,4 @@
export const copyToClipboard = writable<boolean>(true);
export const startAtLogin = writable<boolean>(false);
export const hotkeyVK = writable<number>(0xa5);
export const hotkeyMode = writable<"tap" | "hold">("hold");
VoiceSnapGo/internal/config/config.go
@@ -7,9 +7,15 @@
    "voicesnap/internal/paths"
)
const (
    HotkeyModeTap  = "tap"
    HotkeyModeHold = "hold"
)
// Config holds the application configuration, compatible with the WPF version's config.json.
type Config struct {
    HotkeyVK                 int    `json:"HotkeyVK"`
    HotkeyMode               string `json:"HotkeyMode"`
    AutoHide                 bool   `json:"AutoHide"`
    SoundFeedback            bool   `json:"SoundFeedback"`
    HideDockIcon             bool   `json:"HideDockIcon"`
@@ -25,6 +31,7 @@
func Default() *Config {
    return &Config{
        HotkeyVK:                 0xA5, // Right Alt
        HotkeyMode:               HotkeyModeHold,
        AutoHide:                 true,
        SoundFeedback:            true,
        HideDockIcon:             true,
@@ -55,9 +62,19 @@
    if err := json.Unmarshal(data, cfg); err != nil {
        return nil, err
    }
    cfg.HotkeyMode = NormalizeHotkeyMode(cfg.HotkeyMode)
    return cfg, nil
}
func NormalizeHotkeyMode(mode string) string {
    switch mode {
    case HotkeyModeTap, HotkeyModeHold:
        return mode
    default:
        return HotkeyModeHold
    }
}
// Save writes the config to disk.
func Save(cfg *Config) {
    if err := paths.Ensure(); err != nil {
VoiceSnapGo/services/config_service.go
@@ -42,6 +42,18 @@
    return s.cfg.SoundFeedback
}
// SetHotkeyMode sets the voice input interaction mode.
func (s *ConfigService) SetHotkeyMode(mode string) {
    s.cfg.HotkeyMode = config.NormalizeHotkeyMode(mode)
    config.Save(s.cfg)
}
// GetHotkeyMode returns the voice input interaction mode.
func (s *ConfigService) GetHotkeyMode() string {
    s.cfg.HotkeyMode = config.NormalizeHotkeyMode(s.cfg.HotkeyMode)
    return s.cfg.HotkeyMode
}
// SetCopyToClipboard sets whether recognized text remains in the clipboard after input.
func (s *ConfigService) SetCopyToClipboard(enabled bool) {
    s.cfg.CopyToClipboard = enabled