| File was renamed from VoiceSnapGo/app.go |
| | |
| | | ) |
| | | |
| | | const ( |
| | | appVersion = "2.1.4" |
| | | appBuild = "20260518.1748" |
| | | appVersion = "2.1.18" |
| | | appBuild = "20260601.1905" |
| | | appDisplayVersion = appVersion + " (build " + appBuild + ")" |
| | | appName = "VoiceSnap" |
| | | appName = "PrivateVoice Input" |
| | | |
| | | silenceThreshold = 0.05 // RMS below this = silence (matches HasVoiceActivity) |
| | | silenceTimeoutDuration = 3 * time.Second // auto-stop after this much silence in free-talk |
| | | silenceGracePeriod = 2 * time.Second // don't auto-stop within first 2s of free-talk |
| | | holdActivationDelay = 180 * time.Millisecond |
| | | doneIndicatorHideDelayMs = 250 |
| | | ) |
| | | |
| | | // App holds all application state and orchestration logic. |
| | |
| | | history *history.Store |
| | | userdict *userdict.Store |
| | | |
| | | engineService *services.EngineService |
| | | wailsApp *application.App |
| | | settingsWindow *application.WebviewWindow |
| | | indicator overlay.Overlay |
| | |
| | | hotkeyActive bool |
| | | hotkeyPressTime time.Time |
| | | isCombination bool |
| | | tapStopOnPress bool |
| | | isRecordingHotkey bool |
| | | hideGen atomic.Uint64 |
| | | lastStopTime time.Time |
| | |
| | | appService := services.NewAppService(app.cfg, appDisplayVersion) |
| | | configService := services.NewConfigService(app.cfg) |
| | | engineService := services.NewEngineService() |
| | | app.engineService = engineService |
| | | hotkeyService := services.NewHotkeyService(app.cfg) |
| | | permissionService := services.NewPermissionService() |
| | | updaterService := services.NewUpdaterService(appVersion) |
| | | audioService := services.NewAudioService(app.recorder, app.cfg) |
| | | historyService := services.NewHistoryService(app.history) |
| | | userDictService := services.NewUserDictService(app.userdict) |
| | | correctionCSVService := services.NewCorrectionCSVService(app.history, app.userdict) |
| | | |
| | | // Create Wails application |
| | | wailsApp := application.New(application.Options{ |
| | |
| | | application.NewService(audioService), |
| | | application.NewService(historyService), |
| | | application.NewService(userDictService), |
| | | application.NewService(correctionCSVService), |
| | | }, |
| | | Assets: application.AssetOptions{ |
| | | Handler: application.AssetFileServerFS(assets), |
| | |
| | | // Create settings window (visible on startup, close → hide to tray) |
| | | app.settingsWindow = wailsApp.Window.NewWithOptions(application.WebviewWindowOptions{ |
| | | Name: "settings", |
| | | Title: "VoiceSnap", |
| | | Title: appName, |
| | | Width: 820, |
| | | Height: 580, |
| | | URL: "/", |
| | |
| | | app.settingsWindow.Hide() |
| | | }) |
| | | userDictService.SetApp(wailsApp, app.settingsWindow) |
| | | correctionCSVService.SetApp(wailsApp, app.settingsWindow) |
| | | |
| | | // Create overlay indicator (native Win32 on Windows, native AppKit on macOS) |
| | | app.indicator = overlay.New() |
| | | app.indicator.OnDragged(func(x, y int) { |
| | | x, y = app.clampIndicatorPosition(x, y) |
| | | app.indicator.SetPosition(x, y) |
| | | app.cfg.IndicatorX = x |
| | | app.cfg.IndicatorY = y |
| | | config.Save(app.cfg) |
| | |
| | | } |
| | | } |
| | | |
| | | // If held > 300ms without combo, start hold-to-talk recording |
| | | if !a.isRecording && !a.isCombination && time.Since(a.hotkeyPressTime) > 300*time.Millisecond && time.Since(a.lastStopTime) > 500*time.Millisecond { |
| | | // 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 { |
| | | logger.Info("Hotkey action: start hold-to-talk after %dms", time.Since(a.hotkeyPressTime).Milliseconds()) |
| | | a.startRecordingLocked() |
| | | } |
| | |
| | | if !a.hotkeyActive { |
| | | a.hotkeyActive = true |
| | | a.isCombination = false |
| | | a.tapStopOnPress = 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) |
| | | if a.isFreetalking { |
| | | logger.Info("Hotkey action: stop tap recording on press") |
| | | a.tapStopOnPress = true |
| | | a.stopTapRecordingLocked() |
| | | } |
| | | return |
| | | } |
| | | |
| | |
| | | a.hotkeyActive = false |
| | | |
| | | if a.isCombination { |
| | | return |
| | | } |
| | | |
| | | if a.tapStopOnPress { |
| | | a.tapStopOnPress = false |
| | | return |
| | | } |
| | | |
| | |
| | | a.lastStopTime = time.Now() |
| | | |
| | | if cancel { |
| | | a.recorder.Stop() |
| | | logger.Info("Recording cancelled (combination key)") |
| | | a.indicator.SetStatus(overlay.StatusCancelled, "已取消") |
| | | a.recorder.Stop() |
| | | if a.cfg.SoundFeedback { |
| | | sound.PlayCancel() |
| | | } |
| | |
| | | } |
| | | |
| | | hasVoice := a.recorder.HasVoiceActivity() |
| | | samples := a.recorder.StopAndGetSamples() |
| | | |
| | | a.indicator.SetStatus(overlay.StatusProcessing, "识别中") |
| | | go a.recognizeAndPaste(hasVoice, samples) |
| | | go func() { |
| | | samples := a.recorder.StopAndGetSamples() |
| | | a.recognizeAndPaste(hasVoice, samples) |
| | | }() |
| | | } |
| | | |
| | | func (a *App) startTapRecordingLocked() { |
| | |
| | | a.lastStopTime = time.Now() |
| | | |
| | | hasVoice := a.recorder.HasVoiceActivity() |
| | | samples := a.recorder.StopAndGetSamples() |
| | | |
| | | logger.Info("Tap recording stopped") |
| | | a.indicator.SetStatus(overlay.StatusProcessing, "识别中") |
| | | go a.recognizeAndPaste(hasVoice, samples) |
| | | go func() { |
| | | samples := a.recorder.StopAndGetSamples() |
| | | a.recognizeAndPaste(hasVoice, samples) |
| | | }() |
| | | } |
| | | |
| | | // checkSilenceTimeout monitors legacy free-talk recordings. The explicit tap |
| | |
| | | if soundFeedback { |
| | | sound.PlayDone() |
| | | } |
| | | a.delayedHideIf(autoHide, 2000) |
| | | a.delayedHideIf(autoHide, doneIndicatorHideDelayMs) |
| | | } |
| | | |
| | | // waitForHotkeyRelease polls until the hotkey is released (max 500ms), |
| | |
| | | |
| | | func (a *App) initEngine() { |
| | | logger.Info("Initializing ASR engine...") |
| | | if a.engineService != nil { |
| | | a.engineService.SetStatus("loading", "", "") |
| | | } |
| | | a.wailsApp.Event.Emit("engine:status", map[string]interface{}{ |
| | | "status": "loading", |
| | | }) |
| | | |
| | | modelExists := engine.ModelExists() |
| | | eng, err := engine.New() |
| | | if err != nil { |
| | | logger.Error("Engine initialization failed: %v", err) |
| | | status := "need_model" |
| | | if modelExists { |
| | | status = "error" |
| | | } |
| | | if a.engineService != nil { |
| | | a.engineService.SetStatus(status, "", err.Error()) |
| | | } |
| | | a.wailsApp.Event.Emit("engine:status", map[string]interface{}{ |
| | | "status": "need_model", |
| | | "status": status, |
| | | "error": err.Error(), |
| | | }) |
| | | return |
| | |
| | | a.mu.Unlock() |
| | | |
| | | logger.Info("ASR engine ready: %s", eng.HardwareInfo()) |
| | | if a.engineService != nil { |
| | | a.engineService.SetStatus("ready", eng.HardwareInfo(), "") |
| | | } |
| | | a.wailsApp.Event.Emit("engine:status", map[string]interface{}{ |
| | | "status": "ready", |
| | | "hardwareInfo": eng.HardwareInfo(), |
| | |
| | | |
| | | // positionIndicatorAt places the indicator at (x, y), or bottom-center if both are 0. |
| | | func (a *App) positionIndicatorAt(x, y int) { |
| | | indicatorW, indicatorH := a.indicator.Size() |
| | | if indicatorW <= 0 { |
| | | indicatorW = 170 |
| | | } |
| | | if indicatorH <= 0 { |
| | | indicatorH = 48 |
| | | } |
| | | |
| | | // On macOS, place the overlay near the currently focused input window each |
| | | // time instead of reusing a stale saved position from another display. |
| | | if autoPositioner, ok := a.indicator.(interface{ AutoPosition() }); ok { |
| | | autoPositioner.AutoPosition() |
| | | return |
| | | } |
| | | |
| | | screen := a.wailsApp.Screen.GetPrimary() |
| | | if screen == nil { |
| | | if x != 0 || y != 0 { |
| | | a.indicator.SetPosition(x, y) |
| | | } |
| | | return |
| | | } |
| | | |
| | | wa := screen.WorkArea |
| | | if x == 0 && y == 0 { |
| | | x = wa.X + (wa.Width-indicatorW)/2 |
| | | y = wa.Y + wa.Height - indicatorH - 100 |
| | | x, y = clampOverlayPosition(x, y, indicatorW, indicatorH, wa.X, wa.Y, wa.Width, wa.Height) |
| | | a.indicator.SetPosition(x, y) |
| | | return |
| | | } |
| | | // Try Screen.GetPrimary(); on macOS this may return nil during early startup. |
| | | // The darwin overlay has its own autoPosition fallback in Show(). |
| | | screen := a.wailsApp.Screen.GetPrimary() |
| | | x, y = a.clampIndicatorPositionWithSize(x, y, indicatorW, indicatorH) |
| | | a.indicator.SetPosition(x, y) |
| | | } |
| | | |
| | | func (a *App) clampIndicatorPosition(x, y int) (int, int) { |
| | | if a.wailsApp == nil || a.indicator == nil { |
| | | return x, y |
| | | } |
| | | indicatorW, indicatorH := a.indicator.Size() |
| | | if indicatorW <= 0 { |
| | | indicatorW = 170 |
| | | } |
| | | if indicatorH <= 0 { |
| | | indicatorH = 48 |
| | | } |
| | | return a.clampIndicatorPositionWithSize(x, y, indicatorW, indicatorH) |
| | | } |
| | | |
| | | func (a *App) clampIndicatorPositionWithSize(x, y, indicatorW, indicatorH int) (int, int) { |
| | | screen := a.bestScreenForOverlay(x, y, indicatorW, indicatorH) |
| | | if screen == nil { |
| | | return |
| | | return x, y |
| | | } |
| | | wa := screen.WorkArea |
| | | cx := wa.X + (wa.Width-170)/2 |
| | | cy := wa.Y + wa.Height - 48 - 100 |
| | | a.indicator.SetPosition(cx, cy) |
| | | return clampOverlayPosition(x, y, indicatorW, indicatorH, wa.X, wa.Y, wa.Width, wa.Height) |
| | | } |
| | | |
| | | func (a *App) bestScreenForOverlay(x, y, overlayW, overlayH int) *application.Screen { |
| | | if a.wailsApp == nil { |
| | | return nil |
| | | } |
| | | |
| | | screens := a.wailsApp.Screen.GetAll() |
| | | if len(screens) == 0 { |
| | | if primary := a.wailsApp.Screen.GetPrimary(); primary != nil { |
| | | return primary |
| | | } |
| | | return nil |
| | | } |
| | | |
| | | centerX := x + overlayW/2 |
| | | centerY := y + overlayH/2 |
| | | for _, screen := range screens { |
| | | wa := screen.WorkArea |
| | | if pointInRect(centerX, centerY, wa.X, wa.Y, wa.Width, wa.Height) { |
| | | return screen |
| | | } |
| | | } |
| | | |
| | | var best *application.Screen |
| | | bestScore := 0 |
| | | bestDistance := 0 |
| | | for _, screen := range screens { |
| | | wa := screen.WorkArea |
| | | intersection := intersectionArea(x, y, overlayW, overlayH, wa.X, wa.Y, wa.Width, wa.Height) |
| | | distance := rectDistanceSquared(x, y, overlayW, overlayH, wa.X, wa.Y, wa.Width, wa.Height) |
| | | if best == nil || intersection > bestScore || (intersection == bestScore && distance < bestDistance) { |
| | | best = screen |
| | | bestScore = intersection |
| | | bestDistance = distance |
| | | } |
| | | } |
| | | return best |
| | | } |
| | | |
| | | func clampOverlayPosition(x, y, overlayW, overlayH, areaX, areaY, areaW, areaH int) (int, int) { |
| | | return clampOverlayAxis(x, areaX, areaW, overlayW), clampOverlayAxis(y, areaY, areaH, overlayH) |
| | | } |
| | | |
| | | func clampOverlayAxis(pos, areaStart, areaSize, overlaySize int) int { |
| | | if areaSize <= 0 || overlaySize <= 0 { |
| | | return pos |
| | | } |
| | | if overlaySize >= areaSize { |
| | | return areaStart + (areaSize-overlaySize)/2 |
| | | } |
| | | minPos := areaStart |
| | | maxPos := areaStart + areaSize - overlaySize |
| | | if pos < minPos { |
| | | return minPos |
| | | } |
| | | if pos > maxPos { |
| | | return maxPos |
| | | } |
| | | return pos |
| | | } |
| | | |
| | | func pointInRect(x, y, rx, ry, rw, rh int) bool { |
| | | return x >= rx && x < rx+rw && y >= ry && y < ry+rh |
| | | } |
| | | |
| | | func intersectionArea(ax, ay, aw, ah, bx, by, bw, bh int) int { |
| | | left := max(ax, bx) |
| | | top := max(ay, by) |
| | | right := min(ax+aw, bx+bw) |
| | | bottom := min(ay+ah, by+bh) |
| | | if right <= left || bottom <= top { |
| | | return 0 |
| | | } |
| | | return (right - left) * (bottom - top) |
| | | } |
| | | |
| | | func rectDistanceSquared(ax, ay, aw, ah, bx, by, bw, bh int) int { |
| | | dx := max(0, max(bx-(ax+aw), ax-(bx+bw))) |
| | | dy := max(0, max(by-(ay+ah), ay-(by+bh))) |
| | | return dx*dx + dy*dy |
| | | } |
| | | |
| | | func (a *App) showSettings() { |