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.38"
|
appBuild = "20260629.2014"
|
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
|
slowRecognizeThresholdMs = int64(2000)
|
slowPipelineThresholdMs = int64(2500)
|
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() {
|
perfID := newPerfTraceID()
|
pipelineStart := time.Now()
|
logger.Info("PERF pipeline_start id=%d mode=hold", perfID)
|
samples, hasVoice := a.stopRecorderAfterReleaseTailCapture(perfID, pipelineStart)
|
a.recognizeAndPaste(hasVoice, samples, perfID, pipelineStart)
|
}()
|
}
|
|
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() {
|
perfID := newPerfTraceID()
|
pipelineStart := time.Now()
|
logger.Info("PERF pipeline_start id=%d mode=tap", perfID)
|
samples, hasVoice := a.stopRecorderAfterReleaseTailCapture(perfID, pipelineStart)
|
a.recognizeAndPaste(hasVoice, samples, perfID, pipelineStart)
|
}()
|
}
|
|
// 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() {
|
perfID := newPerfTraceID()
|
pipelineStart := time.Now()
|
logger.Info("PERF pipeline_start id=%d mode=silence_auto_stop", perfID)
|
|
voiceStart := time.Now()
|
hasVoice := a.recorder.HasVoiceActivity()
|
voiceElapsed := time.Since(voiceStart)
|
|
stopStart := time.Now()
|
samples := a.recorder.StopAndGetSamples()
|
stopElapsed := time.Since(stopStart)
|
logger.Info(
|
"PERF audio_stop_done id=%d mode=silence_auto_stop stop_ms=%d voice_check_ms=%d samples=%d audio_ms=%d has_voice=%t since_start_ms=%d",
|
perfID,
|
perfDurationMS(stopElapsed),
|
perfDurationMS(voiceElapsed),
|
len(samples),
|
perfAudioDurationMS(samples),
|
hasVoice,
|
perfSinceMS(pipelineStart),
|
)
|
|
logger.Info("Free talk stopped (silence auto-stop)")
|
a.indicator.SetStatus(overlay.StatusProcessing, "识别中")
|
a.recognizeAndPaste(hasVoice, samples, perfID, pipelineStart)
|
}
|
|
func (a *App) waitForReleaseTailCapture(perfID int64) {
|
delay := a.releaseTailCaptureDelay()
|
if delay <= 0 {
|
logger.Info("PERF release_tail_wait_done id=%d configured_ms=0 elapsed_ms=0", perfID)
|
return
|
}
|
logger.Info("Release tail capture: waiting %dms before stopping audio", delay.Milliseconds())
|
start := time.Now()
|
time.Sleep(delay)
|
logger.Info("PERF release_tail_wait_done id=%d configured_ms=%d elapsed_ms=%d", perfID, delay.Milliseconds(), perfSinceMS(start))
|
}
|
|
func (a *App) releaseTailCaptureDelay() time.Duration {
|
nanos := a.releaseTailCaptureNanos.Load()
|
if nanos <= 0 {
|
return 0
|
}
|
return time.Duration(nanos)
|
}
|
|
func (a *App) stopRecorderAfterReleaseTailCapture(perfID int64, pipelineStart time.Time) ([]float32, bool) {
|
a.waitForReleaseTailCapture(perfID)
|
|
stopStart := time.Now()
|
samples := a.recorder.StopAndGetSamples()
|
stopElapsed := time.Since(stopStart)
|
|
voiceStart := time.Now()
|
hasVoice := a.recorder.HasVoiceActivity()
|
voiceElapsed := time.Since(voiceStart)
|
|
a.mu.Lock()
|
a.isStoppingRecording = false
|
a.mu.Unlock()
|
|
logger.Info(
|
"PERF audio_stop_done id=%d stop_ms=%d voice_check_ms=%d samples=%d audio_ms=%d has_voice=%t since_start_ms=%d",
|
perfID,
|
perfDurationMS(stopElapsed),
|
perfDurationMS(voiceElapsed),
|
len(samples),
|
perfAudioDurationMS(samples),
|
hasVoice,
|
perfSinceMS(pipelineStart),
|
)
|
|
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, perfID int64, pipelineStart time.Time) {
|
// 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
|
selectedModelID := a.cfg.SelectedModelID
|
languageID := a.cfg.LanguageID
|
a.mu.Unlock()
|
|
logger.Info(
|
"PERF recognize_pipeline_input id=%d has_voice=%t samples=%d audio_ms=%d copy_to_clipboard=%t since_start_ms=%d",
|
perfID,
|
hasVoice,
|
len(samples),
|
perfAudioDurationMS(samples),
|
copyToClipboard,
|
perfSinceMS(pipelineStart),
|
)
|
|
if !hasVoice {
|
logger.Info("No voice activity detected")
|
logger.Info("PERF pipeline_done id=%d result=no_voice total_ms=%d", perfID, perfSinceMS(pipelineStart))
|
a.indicator.SetStatus(overlay.StatusNoVoice, "无语音")
|
a.delayedHideIf(autoHide, 1500)
|
return
|
}
|
|
if len(samples) == 0 {
|
logger.Info("PERF pipeline_done id=%d result=no_samples total_ms=%d", perfID, perfSinceMS(pipelineStart))
|
a.indicator.SetStatus(overlay.StatusNoContent, "无内容")
|
a.delayedHideIf(autoHide, 1500)
|
return
|
}
|
|
engineLockStart := time.Now()
|
a.engineMu.Lock()
|
engineLockElapsed := time.Since(engineLockStart)
|
eng := a.eng
|
if eng == nil {
|
a.engineMu.Unlock()
|
logger.Error("Recognition skipped: engine not ready")
|
logger.Info("PERF pipeline_done id=%d result=engine_not_ready engine_lock_ms=%d total_ms=%d", perfID, perfDurationMS(engineLockElapsed), perfSinceMS(pipelineStart))
|
a.indicator.SetStatus(overlay.StatusError, "引擎未就绪")
|
a.delayedHideIf(autoHide, 2000)
|
return
|
}
|
recognizeStart := time.Now()
|
text, err := eng.Recognize(samples)
|
recognizeElapsed := time.Since(recognizeStart)
|
recognizeElapsedMS := perfDurationMS(recognizeElapsed)
|
a.engineMu.Unlock()
|
logger.Info(
|
"PERF recognize_done id=%d engine_lock_wait_ms=%d recognize_ms=%d samples=%d audio_ms=%d since_start_ms=%d",
|
perfID,
|
perfDurationMS(engineLockElapsed),
|
recognizeElapsedMS,
|
len(samples),
|
perfAudioDurationMS(samples),
|
perfSinceMS(pipelineStart),
|
)
|
if err != nil {
|
logger.Error("Recognition failed: %v", err)
|
logger.Info("PERF pipeline_done id=%d result=recognize_error total_ms=%d", perfID, perfSinceMS(pipelineStart))
|
a.indicator.SetStatus(overlay.StatusError, "错误")
|
a.delayedHideIf(autoHide, 2000)
|
return
|
}
|
|
postProcessStart := time.Now()
|
text = textproc.PostProcess(text)
|
text = a.userdict.Apply(text)
|
postProcessElapsed := time.Since(postProcessStart)
|
logger.Info(
|
"PERF text_postprocess_done id=%d postprocess_ms=%d text_runes=%d since_start_ms=%d",
|
perfID,
|
perfDurationMS(postProcessElapsed),
|
len([]rune(text)),
|
perfSinceMS(pipelineStart),
|
)
|
|
if text == "" {
|
logger.Info("PERF pipeline_done id=%d result=empty_text total_ms=%d", perfID, perfSinceMS(pipelineStart))
|
a.indicator.SetStatus(overlay.StatusNoContent, "无内容")
|
a.delayedHideIf(autoHide, 1500)
|
return
|
}
|
|
logger.Info("Recognized: %s", text)
|
historyStart := time.Now()
|
a.history.Add(text)
|
logger.Info("PERF history_done id=%d history_ms=%d since_start_ms=%d", perfID, perfSinceMS(historyStart), perfSinceMS(pipelineStart))
|
|
hotkeyWaitStart := time.Now()
|
polls, released := a.waitForHotkeyRelease(hotkeyVK)
|
logger.Info(
|
"PERF hotkey_release_wait_done id=%d wait_ms=%d polls=%d released=%t since_start_ms=%d",
|
perfID,
|
perfSinceMS(hotkeyWaitStart),
|
polls,
|
released,
|
perfSinceMS(pipelineStart),
|
)
|
|
pasteStart := time.Now()
|
if err := a.paster.Paste(text, copyToClipboard); err != nil {
|
pasteElapsed := time.Since(pasteStart)
|
logger.Info("PERF paste_done id=%d result=error paste_ms=%d since_start_ms=%d", perfID, perfDurationMS(pasteElapsed), perfSinceMS(pipelineStart))
|
logger.Error("Paste failed, trying fallback: %v", err)
|
typeStart := time.Now()
|
if err := a.paster.TypeText(text); err != nil {
|
logger.Info("PERF fallback_type_done id=%d result=error type_ms=%d total_ms=%d", perfID, perfSinceMS(typeStart), perfSinceMS(pipelineStart))
|
logger.Error("Fallback type also failed: %v", err)
|
a.indicator.SetStatus(overlay.StatusError, "需辅助权限")
|
a.delayedHideIf(autoHide, 2500)
|
return
|
}
|
logger.Info("PERF fallback_type_done id=%d result=success type_ms=%d total_ms=%d", perfID, perfSinceMS(typeStart), perfSinceMS(pipelineStart))
|
} else {
|
logger.Info("PERF paste_done id=%d result=success paste_ms=%d since_start_ms=%d", perfID, perfSinceMS(pasteStart), perfSinceMS(pipelineStart))
|
}
|
|
a.indicator.SetStatus(overlay.StatusDone, "完成")
|
if soundFeedback {
|
sound.PlayDone()
|
}
|
totalMS := perfSinceMS(pipelineStart)
|
logger.Info("PERF pipeline_done id=%d result=success total_ms=%d", perfID, totalMS)
|
logSlowRecognitionIfNeeded(perfID, eng, selectedModelID, languageID, samples, recognizeElapsedMS, totalMS)
|
a.delayedHideIf(autoHide, doneIndicatorHideDelayMs)
|
}
|
|
func logSlowRecognitionIfNeeded(perfID int64, eng engine.Engine, modelID, languageID string, samples []float32, recognizeMS, totalMS int64) {
|
reason := slowRecognitionReason(recognizeMS, totalMS)
|
if reason == "" {
|
return
|
}
|
|
engineInfo := ""
|
if eng != nil {
|
engineInfo = eng.HardwareInfo()
|
}
|
|
load, hasLoad := currentSystemLoadAverage()
|
if hasLoad {
|
logger.Info(
|
"PERF slow_recognition id=%d reason=%s model_id=%q language_id=%q engine=%q audio_ms=%d samples=%d recognize_ms=%d total_ms=%d load1=%.2f load5=%.2f load15=%.2f",
|
perfID,
|
reason,
|
modelID,
|
languageID,
|
engineInfo,
|
perfAudioDurationMS(samples),
|
len(samples),
|
recognizeMS,
|
totalMS,
|
load[0],
|
load[1],
|
load[2],
|
)
|
return
|
}
|
|
logger.Info(
|
"PERF slow_recognition id=%d reason=%s model_id=%q language_id=%q engine=%q audio_ms=%d samples=%d recognize_ms=%d total_ms=%d load_unavailable=true",
|
perfID,
|
reason,
|
modelID,
|
languageID,
|
engineInfo,
|
perfAudioDurationMS(samples),
|
len(samples),
|
recognizeMS,
|
totalMS,
|
)
|
}
|
|
func slowRecognitionReason(recognizeMS, totalMS int64) string {
|
recognizeSlow := recognizeMS > slowRecognizeThresholdMs
|
totalSlow := totalMS > slowPipelineThresholdMs
|
switch {
|
case recognizeSlow && totalSlow:
|
return "recognize,total"
|
case recognizeSlow:
|
return "recognize"
|
case totalSlow:
|
return "total"
|
default:
|
return ""
|
}
|
}
|
|
// waitForHotkeyRelease polls until the hotkey is released (max 500ms),
|
// then waits an additional 50ms settling delay.
|
func (a *App) waitForHotkeyRelease(hotkeyVK int) (int, bool) {
|
polls := 0
|
released := false
|
for i := 0; i < 50; i++ {
|
polls = i + 1
|
if !a.hk.IsKeyDown(hotkeyVK) {
|
released = true
|
break
|
}
|
time.Sleep(10 * time.Millisecond)
|
}
|
time.Sleep(50 * time.Millisecond)
|
return polls, released
|
}
|
|
func newPerfTraceID() int64 {
|
return time.Now().UnixNano()
|
}
|
|
func perfSinceMS(start time.Time) int64 {
|
return perfDurationMS(time.Since(start))
|
}
|
|
func perfDurationMS(duration time.Duration) int64 {
|
return duration.Milliseconds()
|
}
|
|
func perfAudioDurationMS(samples []float32) int64 {
|
return int64(len(samples)) * 1000 / 16000
|
}
|
|
// 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 0
|
}
|
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)
|
}
|