package main import ( "context" "fmt" "strings" "sync" "sync/atomic" "time" "voicesnap/internal/audio" "voicesnap/internal/config" "voicesnap/internal/dock" "voicesnap/internal/engine" "voicesnap/internal/history" "voicesnap/internal/hotkey" "voicesnap/internal/input" "voicesnap/internal/language" "voicesnap/internal/logger" "voicesnap/internal/overlay" "voicesnap/internal/sound" "voicesnap/internal/textproc" "voicesnap/internal/userdict" "voicesnap/services" "github.com/wailsapp/wails/v3/pkg/application" "github.com/wailsapp/wails/v3/pkg/events" ) const ( appVersion = "2.1.37" appBuild = "20260629.0059" appDisplayVersion = appVersion + " (build " + appBuild + ")" appName = "PrivateVoice Dictation" 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 defaultReleaseTailDelay = 300 * time.Millisecond doneIndicatorHideDelayMs = 250 liveCaptionInterval = 120 * time.Millisecond liveCaptionMaxBytes = 108 ) // App holds all application state and orchestration logic. type App struct { ctx context.Context cancel context.CancelFunc wg sync.WaitGroup cfg *config.Config recorder *audio.Recorder eng engine.Engine engineMu sync.Mutex hk hotkey.Listener paster input.Paster history *history.Store userdict *userdict.Store engineService *services.EngineService wailsApp *application.App settingsWindow *application.WebviewWindow indicator overlay.Overlay // State mu sync.Mutex isRecording bool isStoppingRecording bool isHoldRecordingPending bool isFreetalking bool hotkeyActive bool hotkeyPressTime time.Time isCombination bool tapStopOnPress bool isRecordingHotkey bool hideGen atomic.Uint64 lastStopTime time.Time hotkeyPollSeen bool lastHotkeyDown bool lastHotkeyBlocked time.Time silenceSince time.Time // when continuous silence started (free-talk only) freeTalkStart time.Time // when free-talk mode started liveCaptionCancel context.CancelFunc liveCaptionSeq uint64 releaseTailCaptureNanos atomic.Int64 holdPreCaptureEnabled atomic.Bool } func RunApp() error { ctx, cancel := context.WithCancel(context.Background()) defer cancel() app := &App{ ctx: ctx, cancel: cancel, } // Load config cfg, err := config.Load() if err != nil { logger.Error("Failed to load config: %v", err) cfg = config.Default() } app.cfg = cfg // Initialize audio recorder app.recorder = audio.NewRecorder() // Initialize platform-specific hotkey listener app.hk = hotkey.New() // Initialize platform-specific paster app.paster = input.NewPaster() // Initialize history store app.history = history.New() // Initialize user dictionary store app.userdict = userdict.New() // Create services for Wails bindings appService := services.NewAppService(app.cfg, appDisplayVersion) configService := services.NewConfigService(app.cfg) engineService := services.NewEngineService(app.cfg) 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{ Name: appName, Icon: appIcon, Services: []application.Service{ application.NewService(appService), application.NewService(configService), application.NewService(engineService), application.NewService(hotkeyService), application.NewService(permissionService), application.NewService(updaterService), application.NewService(audioService), application.NewService(historyService), application.NewService(userDictService), application.NewService(correctionCSVService), }, Assets: application.AssetOptions{ Handler: application.AssetFileServerFS(assets), }, OnShutdown: func() { app.cleanup() }, }) app.wailsApp = wailsApp if app.cfg.HideDockIcon { dock.SetHidden(true) } // Create settings window (visible on startup, close → hide to tray) app.settingsWindow = wailsApp.Window.NewWithOptions(application.WebviewWindowOptions{ Name: "settings", Title: appName, Width: 820, Height: 580, URL: "/", Hidden: false, BackgroundColour: application.NewRGB(242, 242, 247), // #F2F2F7 Apple grouped bg Windows: application.WindowsWindow{ Theme: application.SystemDefault, }, }) // Intercept window close: hide to tray instead of closing app.settingsWindow.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) { e.Cancel() 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) }) // Create system tray tray := wailsApp.SystemTray.New() tray.SetTemplateIcon(trayIcon) showLabel, exitLabel := "Show Settings", "Exit" if language.Resolve(app.cfg.LanguageMode, app.cfg.LanguageID, language.NewSystemDetector()).UILocale == "zh" { showLabel, exitLabel = "显示设置", "退出" } trayMenu := wailsApp.Menu.New() trayMenu.Add(showLabel).OnClick(func(data *application.Context) { app.showSettings() }) trayMenu.AddSeparator() trayMenu.Add(exitLabel).OnClick(func(data *application.Context) { app.cleanup() wailsApp.Quit() }) tray.SetMenu(trayMenu) tray.OnClick(func() { app.toggleSettings() }) // Start background goroutines app.wg.Add(1) go app.hotkeyLoop() // Initialize engine asynchronously go app.initEngine() // Volume callback → native overlay + silence auto-stop app.recorder.OnVolume(func(vol float64) { app.indicator.SetVolume(vol) app.checkSilenceTimeout(vol) }) // Device change callback app.recorder.OnDeviceChange(func(name string) { wailsApp.Event.Emit("device:changed", map[string]string{"name": name}) }) // Store services references for orchestration callbacks appService.SetApp(app) hotkeyService.SetApp(app) engineService.SetApp(wailsApp) engineService.SetInitCallback(app.initEngine) logger.Info("Application initialized, starting Wails") return wailsApp.Run() } // hotkeyLoop polls the hotkey state at 30ms intervals. func (a *App) hotkeyLoop() { defer a.wg.Done() ticker := time.NewTicker(30 * time.Millisecond) defer ticker.Stop() for { select { case <-a.ctx.Done(): return case <-ticker.C: a.pollHotkey() } } } func (a *App) pollHotkey() { a.mu.Lock() defer a.mu.Unlock() isDown := a.hk.IsKeyDown(a.cfg.HotkeyVK) if !a.hotkeyPollSeen || a.lastHotkeyDown != isDown { a.hotkeyPollSeen = true a.lastHotkeyDown = isDown logger.Info( "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, a.isFreetalking, a.eng != nil, a.isRecordingHotkey, ) } engineReady := a.eng != nil if a.isRecordingHotkey || a.isStoppingRecording { if isDown && time.Since(a.lastHotkeyBlocked) > time.Second { a.lastHotkeyBlocked = time.Now() logger.Info("Hotkey ignored: key=%s engineReady=%t recordingHotkey=%t stoppingRecording=%t", hotkey.GetKeyName(a.cfg.HotkeyVK), engineReady, a.isRecordingHotkey, a.isStoppingRecording) } return } // Escape cancels any active recording if a.cfg.HotkeyVK != 0x1B && (a.isRecording || a.isFreetalking) && a.hk.IsKeyDown(0x1B) { a.isCombination = true a.isFreetalking = false a.isRecording = false a.isHoldRecordingPending = false a.lastStopTime = time.Now() a.stopLiveCaptionLocked() a.recorder.Stop() logger.Info("Recording cancelled (Escape)") a.indicator.SetStatus(overlay.StatusCancelled, "已取消") if a.cfg.SoundFeedback { sound.PlayCancel() } a.delayedHide(1000) return } if !engineReady && !a.hotkeyActive && !a.isRecording && !a.isFreetalking { if isDown && time.Since(a.lastHotkeyBlocked) > time.Second { a.lastHotkeyBlocked = time.Now() logger.Info("Hotkey ignored: key=%s engineReady=false recordingHotkey=%t stoppingRecording=%t", hotkey.GetKeyName(a.cfg.HotkeyVK), a.isRecordingHotkey, a.isStoppingRecording) } return } switch config.NormalizeHotkeyMode(a.cfg.HotkeyMode) { case config.HotkeyModeTap: a.pollTapHotkeyLocked(isDown, engineReady) default: a.pollHoldHotkeyLocked(isDown, engineReady) } } func (a *App) pollHoldHotkeyLocked(isDown bool, engineReady 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 mode=%s freetalk=%t", hotkey.GetKeyName(a.cfg.HotkeyVK), a.cfg.HotkeyVK, config.HotkeyModeHold, a.isFreetalking) // If the mode changed while a tap recording was active, let the next press stop it. if a.isFreetalking { logger.Info("Hotkey action: stop tap recording after mode switch") a.stopTapRecordingLocked() return } if engineReady && a.shouldStartHoldPreCaptureLocked() { logger.Info("Hotkey action: start hold pre-capture") a.startHoldPreCaptureLocked() } } else { // Key held down - check for combination keys if !a.isCombination && a.hk.IsAnyOtherKeyPressedSince(a.cfg.HotkeyVK, a.hotkeyPressTime) { a.isCombination = true logger.Info("Hotkey marked as combination: key=%s", hotkey.GetKeyName(a.cfg.HotkeyVK)) if a.isHoldRecordingPending { a.cancelHoldPreCaptureLocked("combination key") } else if a.isRecording { a.stopRecordingLocked(true) } } // If held long enough without combo, start hold-to-talk recording. if engineReady && !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() } else if engineReady && a.isHoldRecordingPending && !a.isCombination && time.Since(a.hotkeyPressTime) > holdActivationDelay { logger.Info("Hotkey action: confirm hold pre-capture after %dms", time.Since(a.hotkeyPressTime).Milliseconds()) a.confirmHoldPreCaptureLocked() } } } else if a.hotkeyActive { // Key released 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.HotkeyModeHold, pressDuration.Milliseconds(), a.isRecording, a.isCombination) a.hotkeyActive = false if a.isHoldRecordingPending { a.cancelHoldPreCaptureLocked("released before activation") return } if a.isRecording { // Hold-to-talk: release stops recording a.stopRecordingLocked(a.isCombination) } } } func (a *App) shouldStartHoldPreCaptureLocked() bool { return a.holdPreCaptureEnabled.Load() && !a.isRecording && !a.isStoppingRecording && !a.isFreetalking && time.Since(a.lastStopTime) > 500*time.Millisecond } func (a *App) startHoldPreCaptureLocked() { if a.isRecording || a.isStoppingRecording || a.isFreetalking { return } if err := a.recorder.Start(); err != nil { logger.Error("Failed to start hold pre-capture: %v", err) return } a.isRecording = true a.isHoldRecordingPending = true logger.Info("Hold pre-capture started") } func (a *App) confirmHoldPreCaptureLocked() { if !a.isHoldRecordingPending || !a.isRecording { a.isHoldRecordingPending = false return } a.isHoldRecordingPending = false a.hideGen.Add(1) logger.Info("Hold pre-capture confirmed") a.positionIndicator() a.indicator.SetStatus(overlay.StatusRecording, "0:00") a.indicator.Show() if a.cfg.SoundFeedback { sound.PlayStart() } a.startLiveCaptionLocked(overlay.StatusRecording) a.startRecordingTimer(overlay.StatusRecording) } func (a *App) cancelHoldPreCaptureLocked(reason string) { if !a.isHoldRecordingPending { return } a.isHoldRecordingPending = false if a.isRecording { a.isRecording = false a.lastStopTime = time.Now() a.recorder.Stop() } logger.Info("Hold pre-capture cancelled: %s", reason) } func (a *App) pollTapHotkeyLocked(isDown bool, engineReady bool) { if isDown { 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 } 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.tapStopOnPress { a.tapStopOnPress = false return } if a.isFreetalking { logger.Info("Hotkey action: stop tap recording") a.stopTapRecordingLocked() return } if engineReady && time.Since(a.lastStopTime) > 500*time.Millisecond { logger.Info("Hotkey action: start tap recording") a.startTapRecordingLocked() } } func (a *App) startRecordingLocked() { if a.isRecording || a.isStoppingRecording { return } a.isRecording = true a.isHoldRecordingPending = false a.hideGen.Add(1) // cancel any pending delayed hide logger.Info("Recording started") a.positionIndicator() a.indicator.SetStatus(overlay.StatusRecording, "0:00") a.indicator.Show() if a.cfg.SoundFeedback { sound.PlayStart() } if err := a.recorder.Start(); err != nil { logger.Error("Failed to start recording: %v", err) a.isRecording = false return } a.startLiveCaptionLocked(overlay.StatusRecording) a.startRecordingTimer(overlay.StatusRecording) } func (a *App) stopRecordingLocked(cancel bool) { if !a.isRecording { return } a.isRecording = false a.isHoldRecordingPending = false a.lastStopTime = time.Now() if cancel { logger.Info("Recording cancelled (combination key)") a.stopLiveCaptionLocked() a.indicator.SetStatus(overlay.StatusCancelled, "已取消") a.recorder.Stop() if a.cfg.SoundFeedback { sound.PlayCancel() } a.delayedHide(1000) return } a.stopLiveCaptionLocked() a.isStoppingRecording = true a.indicator.SetStatus(overlay.StatusProcessing, "识别中") go func() { samples, hasVoice := a.stopRecorderAfterReleaseTailCapture() a.recognizeAndPaste(hasVoice, samples) }() } func (a *App) startTapRecordingLocked() { if a.isRecording || a.isStoppingRecording || a.isFreetalking { return } a.isFreetalking = true a.isRecording = true a.freeTalkStart = time.Now() a.silenceSince = time.Time{} a.hideGen.Add(1) logger.Info("Tap recording started") a.positionIndicator() a.indicator.SetStatus(overlay.StatusFreetalking, "0:00") a.indicator.Show() if a.cfg.SoundFeedback { sound.PlayStart() } if err := a.recorder.Start(); err != nil { logger.Error("Failed to start tap recording: %v", err) a.isFreetalking = false a.isRecording = false return } a.startLiveCaptionLocked(overlay.StatusFreetalking) a.startRecordingTimer(overlay.StatusFreetalking) } // startRecordingTimer updates the indicator text with elapsed time every second. // It exits automatically when a.isRecording becomes false. func (a *App) startRecordingTimer(status overlay.Status) { start := time.Now() go func() { ticker := time.NewTicker(time.Second) defer ticker.Stop() for { select { case <-a.ctx.Done(): return case <-ticker.C: a.mu.Lock() recording := a.isRecording captionActive := a.liveCaptionCancel != nil a.mu.Unlock() if !recording { return } if captionActive { continue } d := time.Since(start) a.indicator.SetStatus(status, fmt.Sprintf("%d:%02d", int(d.Minutes()), int(d.Seconds())%60)) } } }() } func (a *App) startLiveCaptionLocked(status overlay.Status) { if a.liveCaptionCancel != nil { return } a.engineMu.Lock() streamingEng, ok := a.eng.(engine.StreamingEngine) if !ok { a.engineMu.Unlock() return } ctx, cancel := context.WithCancel(a.ctx) a.liveCaptionSeq++ seq := a.liveCaptionSeq a.liveCaptionCancel = cancel go a.runLiveCaption(ctx, seq, status, streamingEng, a.engineMu.Unlock) } func (a *App) stopLiveCaptionLocked() { if a.liveCaptionCancel == nil { return } a.liveCaptionCancel() a.liveCaptionCancel = nil a.liveCaptionSeq++ } func (a *App) clearLiveCaption(seq uint64) { a.mu.Lock() defer a.mu.Unlock() if a.liveCaptionSeq == seq { a.liveCaptionCancel = nil } } func (a *App) runLiveCaption(ctx context.Context, seq uint64, status overlay.Status, streamingEng engine.StreamingEngine, releaseEngine func()) { defer a.clearLiveCaption(seq) defer releaseEngine() session, err := streamingEng.NewStreamingSession() if err != nil { logger.Error("Live caption disabled: %v", err) return } defer session.Close() ticker := time.NewTicker(liveCaptionInterval) defer ticker.Stop() offset := 0 lastDisplay := "" for { select { case <-ctx.Done(): return case <-ticker.C: samples, nextOffset := a.recorder.ReadSamplesSince(offset) offset = nextOffset if len(samples) == 0 { continue } partial, err := session.Accept(samples) if err != nil { logger.Error("Live caption failed: %v", err) return } display := liveCaptionDisplayText(a.userdict.Apply(textproc.PostProcess(partial))) if display == "" || display == lastDisplay { continue } a.mu.Lock() active := a.isRecording && a.liveCaptionSeq == seq a.mu.Unlock() if !active { return } lastDisplay = display a.indicator.SetStatus(status, display) } } } func liveCaptionDisplayText(text string) string { text = strings.TrimSpace(text) if text == "" || len([]byte(text)) <= liveCaptionMaxBytes { return text } runes := []rune(text) for len(runes) > 0 && len([]byte("..."+string(runes))) > liveCaptionMaxBytes { runes = runes[1:] } if len(runes) == 0 { return "" } return "..." + string(runes) } func (a *App) stopTapRecordingLocked() { if !a.isFreetalking { return } a.isFreetalking = false a.isRecording = false a.lastStopTime = time.Now() a.stopLiveCaptionLocked() a.isStoppingRecording = true logger.Info("Tap recording stopped") a.indicator.SetStatus(overlay.StatusProcessing, "识别中") go func() { samples, hasVoice := a.stopRecorderAfterReleaseTailCapture() a.recognizeAndPaste(hasVoice, samples) }() } // 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 || config.NormalizeHotkeyMode(a.cfg.HotkeyMode) == config.HotkeyModeTap { a.silenceSince = time.Time{} return } // Grace period: don't auto-stop in the first 2 seconds if time.Since(a.freeTalkStart) < silenceGracePeriod { return } if vol > silenceThreshold { a.silenceSince = time.Time{} // voice detected, reset return } // Silence detected if a.silenceSince.IsZero() { a.silenceSince = time.Now() return } if time.Since(a.silenceSince) >= silenceTimeoutDuration { logger.Info("Silence timeout in free talk mode, auto-stopping") a.silenceSince = time.Time{} // Mark state immediately to prevent re-triggering from subsequent callbacks // and to block pollHotkey from starting a new recording before the device stops. a.isFreetalking = false a.isRecording = false a.lastStopTime = time.Now() a.stopLiveCaptionLocked() // MUST stop device in a separate goroutine: we are inside the audio // data callback, and device.Stop() waits for in-flight callbacks to // finish — calling it here would deadlock. go a.silenceAutoStop() } } // silenceAutoStop performs the actual device stop and ASR after a silence timeout. // Runs in its own goroutine to avoid deadlocking the audio callback thread. func (a *App) silenceAutoStop() { hasVoice := a.recorder.HasVoiceActivity() samples := a.recorder.StopAndGetSamples() logger.Info("Free talk stopped (silence auto-stop)") a.indicator.SetStatus(overlay.StatusProcessing, "识别中") a.recognizeAndPaste(hasVoice, samples) } func (a *App) waitForReleaseTailCapture() { delay := a.releaseTailCaptureDelay() if delay <= 0 { return } logger.Info("Release tail capture: waiting %dms before stopping audio", delay.Milliseconds()) time.Sleep(delay) } func (a *App) releaseTailCaptureDelay() time.Duration { nanos := a.releaseTailCaptureNanos.Load() if nanos <= 0 { return 0 } return time.Duration(nanos) } func (a *App) stopRecorderAfterReleaseTailCapture() ([]float32, bool) { a.waitForReleaseTailCapture() samples := a.recorder.StopAndGetSamples() hasVoice := a.recorder.HasVoiceActivity() a.mu.Lock() a.isStoppingRecording = false a.mu.Unlock() return samples, hasVoice } // recognizeAndPaste runs ASR on the recorded samples and pastes the result. // 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() soundFeedback := a.cfg.SoundFeedback hotkeyVK := a.cfg.HotkeyVK autoHide := a.cfg.AutoHide copyToClipboard := a.cfg.CopyToClipboard a.mu.Unlock() if !hasVoice { logger.Info("No voice activity detected") a.indicator.SetStatus(overlay.StatusNoVoice, "无语音") a.delayedHideIf(autoHide, 1500) return } if len(samples) == 0 { a.indicator.SetStatus(overlay.StatusNoContent, "无内容") a.delayedHideIf(autoHide, 1500) return } a.engineMu.Lock() eng := a.eng if eng == nil { a.engineMu.Unlock() logger.Error("Recognition skipped: engine not ready") a.indicator.SetStatus(overlay.StatusError, "引擎未就绪") a.delayedHideIf(autoHide, 2000) return } text, err := eng.Recognize(samples) a.engineMu.Unlock() if err != nil { logger.Error("Recognition failed: %v", err) a.indicator.SetStatus(overlay.StatusError, "错误") a.delayedHideIf(autoHide, 2000) return } text = textproc.PostProcess(text) text = a.userdict.Apply(text) if text == "" { a.indicator.SetStatus(overlay.StatusNoContent, "无内容") a.delayedHideIf(autoHide, 1500) return } logger.Info("Recognized: %s", text) a.history.Add(text) a.waitForHotkeyRelease(hotkeyVK) if err := a.paster.Paste(text, copyToClipboard); err != nil { logger.Error("Paste failed, trying fallback: %v", err) if err := a.paster.TypeText(text); err != nil { logger.Error("Fallback type also failed: %v", err) a.indicator.SetStatus(overlay.StatusError, "需辅助权限") a.delayedHideIf(autoHide, 2500) return } } a.indicator.SetStatus(overlay.StatusDone, "完成") if soundFeedback { sound.PlayDone() } a.delayedHideIf(autoHide, doneIndicatorHideDelayMs) } // waitForHotkeyRelease polls until the hotkey is released (max 500ms), // then waits an additional 50ms settling delay. func (a *App) waitForHotkeyRelease(hotkeyVK int) { for i := 0; i < 50; i++ { if !a.hk.IsKeyDown(hotkeyVK) { break } time.Sleep(10 * time.Millisecond) } time.Sleep(50 * time.Millisecond) } // delayedHide hides the indicator after a delay if AutoHide is enabled. // Used from contexts where a.mu is held (reads a.cfg.AutoHide safely). func (a *App) delayedHide(delayMs int) { if a.cfg.AutoHide { a.scheduleHide(delayMs) } } // delayedHideIf hides the indicator after a delay if autoHide is true. // Used from goroutines with a pre-snapshotted config value. func (a *App) delayedHideIf(autoHide bool, delayMs int) { if autoHide { a.scheduleHide(delayMs) } } func (a *App) scheduleHide(delayMs int) { gen := a.hideGen.Load() go func() { time.Sleep(time.Duration(delayMs) * time.Millisecond) if a.hideGen.Load() == gen { a.indicator.Hide() } }() } 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) a.replaceEngine(nil) 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": status, "error": err.Error(), }) return } a.replaceEngine(eng) 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(), }) // 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 a.mu.Unlock() keyName := hotkey.GetKeyName(hotkeyVK) a.positionIndicatorAt(indX, indY) a.indicator.SetStatus(overlay.StatusReady, hotkeyReadyText(keyName, hotkeyMode)) a.indicator.Show() a.delayedHideIf(autoHide, 2000) } func (a *App) replaceEngine(eng engine.Engine) { a.releaseTailCaptureNanos.Store(int64(releaseTailCaptureDelayForEngine(eng))) a.holdPreCaptureEnabled.Store(holdPreCaptureEnabledForEngine(eng)) a.engineMu.Lock() old := a.eng a.eng = eng a.engineMu.Unlock() if old != nil && old != eng { old.Close() } } func releaseTailCaptureDelayForEngine(eng engine.Engine) time.Duration { if eng == nil { return 0 } tailCaptureEng, ok := eng.(engine.ReleaseTailCaptureEngine) if !ok { return defaultReleaseTailDelay } delay := tailCaptureEng.ReleaseTailCaptureDelay() if delay < 0 { return 0 } return delay } func holdPreCaptureEnabledForEngine(eng engine.Engine) bool { if eng == nil { return false } preCaptureEng, ok := eng.(engine.HoldPreCaptureEngine) if !ok { return true } return preCaptureEng.HoldPreCaptureEnabled() } 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() { a.positionIndicatorAt(a.cfg.IndicatorX, a.cfg.IndicatorY) } // 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 } 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 x, y } wa := screen.WorkArea 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() { if a.settingsWindow != nil { a.settingsWindow.Show() a.settingsWindow.Focus() } } func (a *App) toggleSettings() { if a.settingsWindow != nil { if a.settingsWindow.IsVisible() { a.settingsWindow.Hide() } else { a.settingsWindow.Show() a.settingsWindow.Focus() } } } func (a *App) cleanup() { logger.Info("Cleaning up...") a.cancel() if a.indicator != nil { a.indicator.Close() } if a.recorder != nil { a.recorder.Close() } a.replaceEngine(nil) a.wg.Wait() logger.Info("Cleanup complete") } // SetRecordingHotkey is called by the hotkey service when entering hotkey recording mode. func (a *App) SetRecordingHotkey(recording bool) { a.mu.Lock() defer a.mu.Unlock() a.isRecordingHotkey = recording if !recording { a.hotkeyActive = false a.lastStopTime = time.Now() } } // UpdateHotkeyVK updates the hotkey virtual key code. func (a *App) UpdateHotkeyVK(vk int) { a.mu.Lock() defer a.mu.Unlock() a.cfg.HotkeyVK = vk config.Save(a.cfg) } // GetEngine returns the current engine (may be nil). func (a *App) GetEngine() engine.Engine { a.engineMu.Lock() defer a.engineMu.Unlock() return a.eng } // SetEngine sets the engine after model download. func (a *App) SetEngine(eng engine.Engine) { a.replaceEngine(eng) }