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