package main
|
|
import (
|
"context"
|
"errors"
|
"fmt"
|
"runtime"
|
"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/model"
|
"voicesnap/internal/modelselection"
|
"voicesnap/internal/overlay"
|
"voicesnap/internal/sound"
|
"voicesnap/internal/textoutput"
|
"voicesnap/internal/textproc"
|
"voicesnap/internal/userdict"
|
"voicesnap/services"
|
|
"github.com/wailsapp/wails/v3/pkg/application"
|
"github.com/wailsapp/wails/v3/pkg/events"
|
)
|
|
var (
|
appVersion = "2.2.2"
|
appBuild = "20260703.0335"
|
)
|
|
const (
|
appName = "PrivateVoice Dictation"
|
textOutputUpdated = "text-output:updated"
|
|
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
|
)
|
|
func appDisplayVersion() string {
|
return appVersion + " (build " + appBuild + ")"
|
}
|
|
var (
|
holdStartupSampleWaitTimeout = 2 * time.Second
|
holdStartupSamplePollInterval = 25 * time.Millisecond
|
)
|
|
// App holds all application state and orchestration logic.
|
type App struct {
|
ctx context.Context
|
cancel context.CancelFunc
|
wg sync.WaitGroup
|
|
cfg *config.Config
|
recorder appRecorder
|
eng engine.Engine
|
engineMu sync.Mutex
|
engineInitMu sync.Mutex
|
engineInitSeq atomic.Uint64
|
engineMeta engineMetadata
|
engineFactory engineFactoryFunc
|
hk hotkey.Listener
|
paster input.Paster
|
outputRouter *textoutput.Router
|
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
|
isHoldRecorderStarting bool
|
isHoldStopPending 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
|
holdActivationTimer *time.Timer
|
holdActivationSeq uint64
|
}
|
|
type engineFactoryFunc func(initID uint64) (engine.Engine, engineMetadata, bool, error)
|
|
type appRecorder interface {
|
OnVolume(func(float64))
|
OnDeviceChange(func(string))
|
Start() error
|
Stop()
|
StopAndGetSamples() []float32
|
HasVoiceActivity() bool
|
ReadSamplesSince(offset int) ([]float32, int)
|
Close()
|
}
|
|
type engineMetadata struct {
|
InitID uint64
|
ResolvedModelID string
|
ConfigSelectedModelID string
|
SelectionMode string
|
LanguageMode string
|
ConfigLanguageID string
|
EffectiveLanguageID string
|
BackendKind string
|
Provider string
|
LanguageParam string
|
FallbackReason string
|
NumThreads int
|
HardwareInfo string
|
}
|
|
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.ExistingConfigFallback()
|
}
|
app.cfg = cfg
|
|
// Initialize audio recorder
|
recorder := audio.NewRecorder()
|
app.recorder = recorder
|
|
// Initialize platform-specific hotkey listener
|
app.hk = hotkey.New()
|
|
// Initialize platform-specific paster
|
app.paster = input.NewPaster()
|
app.outputRouter = textoutput.NewRouter(app.paster)
|
|
// Initialize history store
|
app.history = history.New()
|
|
// Initialize user dictionary store
|
app.userdict = userdict.New()
|
|
// Create services for Wails bindings
|
displayVersion := appDisplayVersion()
|
appService := services.NewAppService(app.cfg, displayVersion)
|
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(recorder, app.cfg)
|
historyService := services.NewHistoryService(app.history)
|
userDictService := services.NewUserDictService(app.userdict)
|
correctionCSVService := services.NewCorrectionCSVService(app.history, app.userdict)
|
diagnosticsService := services.NewDiagnosticsService(app.cfg, displayVersion)
|
textOutputService := services.NewTextOutputService(app.outputRouter)
|
|
// 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),
|
application.NewService(diagnosticsService),
|
application.NewService(textOutputService),
|
},
|
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()
|
|
if config.IsHotkeyUnset(a.cfg.HotkeyVK) {
|
if !a.hotkeyPollSeen || a.lastHotkeyDown {
|
a.hotkeyPollSeen = true
|
a.lastHotkeyDown = false
|
logger.Info(
|
"Hotkey poll state: key=unset vk=0x0 mode=%s down=false active=%t recording=%t freetalk=%t engineReady=%t recordingHotkey=%t",
|
config.NormalizeHotkeyMode(a.cfg.HotkeyMode),
|
a.hotkeyActive,
|
a.isRecording,
|
a.isFreetalking,
|
a.eng != nil,
|
a.isRecordingHotkey,
|
)
|
}
|
return
|
}
|
|
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) {
|
if a.isHoldRecordingPending {
|
a.cancelHoldPreCaptureLocked("Escape")
|
a.indicator.SetStatus(overlay.StatusCancelled, "已取消")
|
if a.cfg.SoundFeedback {
|
sound.PlayCancel()
|
}
|
a.delayedHide(1000)
|
return
|
}
|
if a.isHoldRecorderStarting {
|
a.isCombination = true
|
a.isFreetalking = false
|
a.isRecording = false
|
a.isHoldRecordingPending = false
|
a.isHoldStopPending = true
|
a.cancelHoldActivationTimerLocked()
|
a.lastStopTime = time.Now()
|
a.stopLiveCaptionLocked()
|
logger.Info("Recording cancellation deferred until recorder start completes (Escape)")
|
a.indicator.SetStatus(overlay.StatusCancelled, "已取消")
|
if a.cfg.SoundFeedback {
|
sound.PlayCancel()
|
}
|
a.delayedHide(1000)
|
a.recorder.Stop()
|
return
|
}
|
a.isCombination = true
|
a.isFreetalking = false
|
a.isRecording = false
|
a.isHoldRecordingPending = false
|
a.isHoldRecorderStarting = false
|
a.isHoldStopPending = false
|
a.cancelHoldActivationTimerLocked()
|
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.isHoldRecorderStarting &&
|
!a.isFreetalking &&
|
time.Since(a.lastStopTime) > 500*time.Millisecond
|
}
|
|
func (a *App) startHoldPreCaptureLocked() {
|
if a.isRecording || a.isStoppingRecording || a.isFreetalking {
|
return
|
}
|
a.isRecording = true
|
a.isHoldRecordingPending = true
|
a.isHoldRecorderStarting = true
|
a.isHoldStopPending = false
|
a.scheduleHoldActivationTimerLocked()
|
logger.Info("Hold pre-capture start requested")
|
go a.startHoldPreCaptureRecorder()
|
}
|
|
func (a *App) startHoldPreCaptureRecorder() {
|
if err := a.recorder.Start(); err != nil {
|
a.mu.Lock()
|
releasePipelineActive := a.isHoldRecorderStarting && !a.isRecording && !a.isHoldRecordingPending && !a.isHoldStopPending
|
cancelPending := a.isHoldRecorderStarting && !a.isRecording && a.isHoldStopPending
|
wasRecording := a.isRecording || a.isHoldRecordingPending || a.isHoldRecorderStarting
|
a.isRecording = false
|
a.isHoldRecordingPending = false
|
a.isHoldRecorderStarting = false
|
a.isHoldStopPending = false
|
a.cancelHoldActivationTimerLocked()
|
if releasePipelineActive {
|
a.mu.Unlock()
|
logger.Error("Hold pre-capture recorder start failed after release pipeline started: %v", err)
|
return
|
}
|
if cancelPending {
|
a.mu.Unlock()
|
logger.Error("Hold pre-capture recorder start failed after cancellation: %v", err)
|
return
|
}
|
if wasRecording {
|
a.stopLiveCaptionLocked()
|
a.setRecordingStartErrorLocked(err)
|
a.delayedHide(2000)
|
}
|
a.mu.Unlock()
|
logger.Error("Failed to start hold pre-capture: %v", err)
|
return
|
}
|
|
a.mu.Lock()
|
a.isHoldRecorderStarting = false
|
if !a.isRecording && !a.isHoldStopPending {
|
a.mu.Unlock()
|
logger.Info("Hold pre-capture recorder start completed after release pipeline started")
|
return
|
}
|
shouldStop := !a.isRecording || a.isHoldStopPending
|
if shouldStop {
|
a.isRecording = false
|
a.isHoldRecordingPending = false
|
stopPending := a.isHoldStopPending
|
a.isHoldStopPending = false
|
a.lastStopTime = time.Now()
|
a.stopLiveCaptionLocked()
|
a.indicator.SetStatus(overlay.StatusCancelled, "已取消")
|
a.delayedHide(1000)
|
a.mu.Unlock()
|
if !stopPending {
|
a.recorder.Stop()
|
}
|
return
|
}
|
a.mu.Unlock()
|
logger.Info("Hold pre-capture started")
|
}
|
|
func (a *App) scheduleHoldActivationTimerLocked() {
|
a.cancelHoldActivationTimerLocked()
|
a.holdActivationSeq++
|
seq := a.holdActivationSeq
|
a.holdActivationTimer = time.AfterFunc(holdActivationDelay, func() {
|
a.mu.Lock()
|
defer a.mu.Unlock()
|
|
if a.holdActivationSeq != seq {
|
return
|
}
|
a.holdActivationTimer = nil
|
if a.ctx != nil {
|
select {
|
case <-a.ctx.Done():
|
return
|
default:
|
}
|
}
|
if !a.hotkeyActive || !a.isHoldRecordingPending || !a.isRecording {
|
return
|
}
|
if a.hk == nil || !a.hk.IsKeyDown(a.cfg.HotkeyVK) {
|
a.cancelHoldPreCaptureLocked("released before activation")
|
return
|
}
|
if a.eng == nil {
|
a.cancelHoldPreCaptureLocked("engine not ready")
|
return
|
}
|
if !a.isCombination && a.hk != nil && a.hk.IsAnyOtherKeyPressedSince(a.cfg.HotkeyVK, a.hotkeyPressTime) {
|
a.isCombination = true
|
logger.Info("Hotkey marked as combination: key=%s", hotkey.GetKeyName(a.cfg.HotkeyVK))
|
a.cancelHoldPreCaptureLocked("combination key")
|
return
|
}
|
if a.isCombination {
|
return
|
}
|
logger.Info("Hotkey action: confirm hold pre-capture after %dms", time.Since(a.hotkeyPressTime).Milliseconds())
|
a.confirmHoldPreCaptureLocked()
|
})
|
}
|
|
func (a *App) cancelHoldActivationTimerLocked() {
|
a.holdActivationSeq++
|
if a.holdActivationTimer != nil {
|
a.holdActivationTimer.Stop()
|
a.holdActivationTimer = nil
|
}
|
}
|
|
func (a *App) confirmHoldPreCaptureLocked() {
|
if !a.isHoldRecordingPending || !a.isRecording {
|
a.isHoldRecordingPending = false
|
a.cancelHoldActivationTimerLocked()
|
return
|
}
|
a.isHoldRecordingPending = false
|
a.cancelHoldActivationTimerLocked()
|
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
|
a.cancelHoldActivationTimerLocked()
|
if a.isRecording {
|
a.isRecording = false
|
a.isHoldStopPending = a.isHoldRecorderStarting
|
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
|
a.setRecordingStartErrorLocked(err)
|
a.delayedHide(2000)
|
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 a.isHoldRecorderStarting {
|
a.stopLiveCaptionLocked()
|
if cancel {
|
a.isHoldStopPending = true
|
logger.Info("Hold recording stop deferred until recorder start completes")
|
logger.Info("Recording cancelled (combination key)")
|
a.indicator.SetStatus(overlay.StatusCancelled, "已取消")
|
if a.cfg.SoundFeedback {
|
sound.PlayCancel()
|
}
|
a.delayedHide(1000)
|
a.recorder.Stop()
|
return
|
}
|
|
logger.Info("Hold recording stop proceeds while recorder start is pending")
|
a.isStoppingRecording = true
|
a.indicator.SetStatus(overlay.StatusProcessing, "识别中")
|
go func() {
|
perfID := newPerfTraceID()
|
pipelineStart := time.Now()
|
logger.Info("PERF pipeline_start id=%d mode=hold_starting_release", perfID)
|
samples, hasVoice, ok := a.stopRecorderAfterReleaseTailCapture(perfID, pipelineStart, true)
|
if !ok {
|
a.indicator.SetStatus(overlay.StatusError, "录音未就绪")
|
a.delayedHide(2000)
|
return
|
}
|
a.recognizeAndPaste(hasVoice, samples, perfID, pipelineStart)
|
}()
|
return
|
}
|
|
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, ok := a.stopRecorderAfterReleaseTailCapture(perfID, pipelineStart, false)
|
if !ok {
|
a.indicator.SetStatus(overlay.StatusError, "录音未就绪")
|
a.delayedHide(2000)
|
return
|
}
|
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
|
a.setRecordingStartErrorLocked(err)
|
a.delayedHide(2000)
|
return
|
}
|
a.startLiveCaptionLocked(overlay.StatusFreetalking)
|
a.startRecordingTimer(overlay.StatusFreetalking)
|
}
|
|
func (a *App) setRecordingStartErrorLocked(err error) {
|
if errors.Is(err, audio.ErrMicrophonePermission) {
|
a.indicator.SetStatus(overlay.StatusError, "麦克风权限")
|
return
|
}
|
a.indicator.SetStatus(overlay.StatusError, "录音启动失败")
|
}
|
|
// 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:
|
d := time.Since(start)
|
a.mu.Lock()
|
recording := a.isRecording
|
captionActive := a.liveCaptionCancel != nil
|
if recording && d >= audio.MaxRecordingDuration {
|
logger.Error("Recording reached max duration; auto-stopping max_seconds=%d", int(audio.MaxRecordingDuration/time.Second))
|
if a.isFreetalking {
|
a.stopTapRecordingLocked()
|
} else {
|
a.stopRecordingLocked(false)
|
}
|
a.mu.Unlock()
|
return
|
}
|
a.mu.Unlock()
|
if !recording {
|
return
|
}
|
if captionActive {
|
continue
|
}
|
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, ok := a.stopRecorderAfterReleaseTailCapture(perfID, pipelineStart, false)
|
if !ok {
|
a.indicator.SetStatus(overlay.StatusError, "录音未就绪")
|
a.delayedHide(2000)
|
return
|
}
|
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) waitForHoldStartupSamples(perfID int64, pipelineStart time.Time) bool {
|
deadline := time.NewTimer(holdStartupSampleWaitTimeout)
|
defer deadline.Stop()
|
ticker := time.NewTicker(holdStartupSamplePollInterval)
|
defer ticker.Stop()
|
|
for {
|
samples, _ := a.recorder.ReadSamplesSince(0)
|
hasVoice := a.recorder.HasVoiceActivity()
|
|
a.mu.Lock()
|
starting := a.isHoldRecorderStarting
|
a.mu.Unlock()
|
|
if len(samples) > 0 {
|
logger.Info(
|
"PERF hold_startup_sample_wait_done id=%d result=samples samples=%d has_voice=%t since_start_ms=%d",
|
perfID,
|
len(samples),
|
hasVoice,
|
perfSinceMS(pipelineStart),
|
)
|
return true
|
}
|
if !starting {
|
logger.Info("PERF hold_startup_sample_wait_done id=%d result=start_complete_without_samples since_start_ms=%d", perfID, perfSinceMS(pipelineStart))
|
return true
|
}
|
|
select {
|
case <-a.ctx.Done():
|
logger.Info("PERF hold_startup_sample_wait_done id=%d result=app_stopping since_start_ms=%d", perfID, perfSinceMS(pipelineStart))
|
return false
|
case <-deadline.C:
|
logger.Error("Hold release reached audio startup sample wait timeout without PCM")
|
logger.Info("PERF hold_startup_sample_wait_done id=%d result=timeout timeout_ms=%d since_start_ms=%d", perfID, holdStartupSampleWaitTimeout.Milliseconds(), perfSinceMS(pipelineStart))
|
return false
|
case <-ticker.C:
|
}
|
}
|
}
|
|
func (a *App) stopRecorderAfterReleaseTailCapture(perfID int64, pipelineStart time.Time, waitForStartupSamples bool) ([]float32, bool, bool) {
|
a.waitForReleaseTailCapture(perfID)
|
|
if waitForStartupSamples && !a.waitForHoldStartupSamples(perfID, pipelineStart) {
|
stopStart := time.Now()
|
a.recorder.Stop()
|
stopElapsed := time.Since(stopStart)
|
a.mu.Lock()
|
a.isStoppingRecording = false
|
a.mu.Unlock()
|
logger.Info("PERF audio_stop_done id=%d result=audio_not_ready stop_ms=%d samples=0 audio_ms=0 has_voice=false since_start_ms=%d", perfID, perfDurationMS(stopElapsed), perfSinceMS(pipelineStart))
|
return nil, false, false
|
}
|
|
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, true
|
}
|
|
// recognizeAndPaste runs ASR on the recorded samples and routes the result to text output.
|
// 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
|
autoPasteExperiment := a.cfg.AutoPasteExperiment
|
a.mu.Unlock()
|
|
logger.Info(
|
"PERF recognize_pipeline_input id=%d has_voice=%t samples=%d audio_ms=%d copy_to_clipboard=%t auto_paste_experiment=%t since_start_ms=%d",
|
perfID,
|
hasVoice,
|
len(samples),
|
perfAudioDurationMS(samples),
|
copyToClipboard,
|
autoPasteExperiment,
|
perfSinceMS(pipelineStart),
|
)
|
|
outputOptions := textoutput.OutputOptions{
|
KeepClipboard: copyToClipboard,
|
AutoPasteExperiment: autoPasteExperiment,
|
}
|
if autoPasteExperiment {
|
outputOptions.AutoPasteGuard = a.outputRouter.CaptureAutoPasteGuard()
|
logger.Info(
|
"Text output auto-paste experiment guard captured ok=%t identity_present=%t",
|
outputOptions.AutoPasteGuard.OK,
|
outputOptions.AutoPasteGuard.Identity != "",
|
)
|
}
|
|
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
|
engMeta := a.engineMeta
|
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
|
}
|
|
textRunes := len([]rune(text))
|
logger.Info("Recognized text produced: text_runes=%d", textRunes)
|
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),
|
)
|
|
outputStart := time.Now()
|
output := a.outputRouter.OutputWithOptions(text, outputOptions)
|
a.emitTextOutputUpdated(output)
|
if !output.OK {
|
logger.Info(
|
"PERF text_output_done id=%d result=error output_ms=%d text_runes=%d fallback_available=%t since_start_ms=%d",
|
perfID,
|
perfDurationMS(time.Since(outputStart)),
|
textRunes,
|
output.FallbackAvailable,
|
perfSinceMS(pipelineStart),
|
)
|
logger.Error(
|
"Text output failed: needs_permission=%t fallback_available=%t direct_inserted=%t text_runes=%d",
|
output.NeedsPermission,
|
output.FallbackAvailable,
|
output.DirectInserted,
|
textRunes,
|
)
|
if output.FallbackAvailable {
|
a.indicator.SetStatus(overlay.StatusError, "文本已保留")
|
} else {
|
a.indicator.SetStatus(overlay.StatusError, "需辅助权限")
|
}
|
a.delayedHideIf(autoHide, 2500)
|
return
|
}
|
logger.Info(
|
"PERF text_output_done id=%d result=success direct_inserted=%t output_ms=%d text_runes=%d since_start_ms=%d",
|
perfID,
|
output.DirectInserted,
|
perfDurationMS(time.Since(outputStart)),
|
textRunes,
|
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, engMeta, samples, recognizeElapsedMS, totalMS)
|
a.delayedHideIf(autoHide, doneIndicatorHideDelayMs)
|
}
|
|
func logSlowRecognitionIfNeeded(perfID int64, meta engineMetadata, samples []float32, recognizeMS, totalMS int64) {
|
reason := slowRecognitionReason(recognizeMS, totalMS)
|
if reason == "" {
|
return
|
}
|
|
audioMS := perfAudioDurationMS(samples)
|
rtf := 0.0
|
if audioMS > 0 {
|
rtf = float64(recognizeMS) / float64(audioMS)
|
}
|
numCPU := runtime.NumCPU()
|
load, hasLoad := currentSystemLoadAverage()
|
if hasLoad {
|
loadPerCore := 0.0
|
if numCPU > 0 {
|
loadPerCore = load[0] / float64(numCPU)
|
}
|
logger.Info(
|
"PERF slow_recognition id=%d reason=%s model_id=%q resolved_model_id=%q cfg_selected_model_id=%q selection_mode=%q language_mode=%q config_language_id=%q effective_language_id=%q backend=%q provider=%q language_param=%q fallback_reason=%q num_threads=%d engine=%q init_id=%d audio_ms=%d samples=%d recognize_ms=%d total_ms=%d rtf=%.3f load1=%.2f load5=%.2f load15=%.2f load1_per_core=%.2f cpu_count=%d",
|
perfID,
|
reason,
|
meta.ResolvedModelID,
|
meta.ResolvedModelID,
|
meta.ConfigSelectedModelID,
|
meta.SelectionMode,
|
meta.LanguageMode,
|
meta.ConfigLanguageID,
|
meta.EffectiveLanguageID,
|
meta.BackendKind,
|
meta.Provider,
|
meta.LanguageParam,
|
meta.FallbackReason,
|
meta.NumThreads,
|
meta.HardwareInfo,
|
meta.InitID,
|
audioMS,
|
len(samples),
|
recognizeMS,
|
totalMS,
|
rtf,
|
load[0],
|
load[1],
|
load[2],
|
loadPerCore,
|
numCPU,
|
)
|
return
|
}
|
|
logger.Info(
|
"PERF slow_recognition id=%d reason=%s model_id=%q resolved_model_id=%q cfg_selected_model_id=%q selection_mode=%q language_mode=%q config_language_id=%q effective_language_id=%q backend=%q provider=%q language_param=%q fallback_reason=%q num_threads=%d engine=%q init_id=%d audio_ms=%d samples=%d recognize_ms=%d total_ms=%d rtf=%.3f load_unavailable=true cpu_count=%d",
|
perfID,
|
reason,
|
meta.ResolvedModelID,
|
meta.ResolvedModelID,
|
meta.ConfigSelectedModelID,
|
meta.SelectionMode,
|
meta.LanguageMode,
|
meta.ConfigLanguageID,
|
meta.EffectiveLanguageID,
|
meta.BackendKind,
|
meta.Provider,
|
meta.LanguageParam,
|
meta.FallbackReason,
|
meta.NumThreads,
|
meta.HardwareInfo,
|
meta.InitID,
|
audioMS,
|
len(samples),
|
recognizeMS,
|
totalMS,
|
rtf,
|
numCPU,
|
)
|
}
|
|
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 ""
|
}
|
}
|
|
func (a *App) emitTextOutputUpdated(result textoutput.Result) {
|
if a == nil || a.wailsApp == nil {
|
return
|
}
|
a.wailsApp.Event.Emit(textOutputUpdated, textOutputUpdatePayload(result))
|
}
|
|
func textOutputUpdatePayload(result textoutput.Result) map[string]interface{} {
|
return map[string]interface{}{
|
"ok": result.OK,
|
"needsPermission": result.NeedsPermission,
|
"directInserted": result.DirectInserted,
|
"fallbackAvailable": result.FallbackAvailable,
|
"hasText": result.Text != "",
|
}
|
}
|
|
// 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() {
|
initID := a.engineInitSeq.Add(1)
|
|
a.engineInitMu.Lock()
|
defer a.engineInitMu.Unlock()
|
|
if initID != a.engineInitSeq.Load() {
|
logger.Info("Skipping superseded ASR engine init: init_id=%d current_init_id=%d", initID, a.engineInitSeq.Load())
|
return
|
}
|
|
logger.Info("Initializing ASR engine... init_id=%d", initID)
|
a.setEngineStatus("loading", "", "")
|
|
eng, meta, modelExists, err := a.createEngineForInit(initID)
|
if err != nil {
|
logger.Error("Engine initialization failed: init_id=%d model_id=%q error=%v", initID, meta.ResolvedModelID, err)
|
if a.hasEngine() {
|
logger.Info("Keeping existing ASR engine after reload failure")
|
} else {
|
a.replaceEngine(nil)
|
}
|
status := "need_model"
|
if modelExists {
|
status = "error"
|
}
|
a.setEngineStatus(status, "", err.Error())
|
return
|
}
|
|
if initID != a.engineInitSeq.Load() {
|
logger.Info("Discarding stale ASR engine init: init_id=%d current_init_id=%d model_id=%q", initID, a.engineInitSeq.Load(), meta.ResolvedModelID)
|
eng.Close()
|
return
|
}
|
|
a.replaceEngineWithMetadata(eng, meta)
|
|
logger.Info("ASR engine ready: init_id=%d model_id=%q backend=%s provider=%s hardware=%s", initID, meta.ResolvedModelID, meta.BackendKind, meta.Provider, eng.HardwareInfo())
|
a.setEngineStatus("ready", eng.HardwareInfo(), "")
|
|
if a.indicator == nil {
|
return
|
}
|
|
// 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) createEngineForInit(initID uint64) (engine.Engine, engineMetadata, bool, error) {
|
if a.engineFactory != nil {
|
return a.engineFactory(initID)
|
}
|
|
cfg, err := config.Load()
|
if err != nil {
|
logger.Error("Failed to load config for engine init: %v", err)
|
cfg = a.cfg
|
if cfg == nil {
|
cfg = config.ExistingConfigFallback()
|
}
|
}
|
|
current := modelselection.Resolve(cfg, language.NewSystemDetector())
|
meta := engineMetadata{
|
InitID: initID,
|
ResolvedModelID: current.ModelID,
|
ConfigSelectedModelID: cfg.SelectedModelID,
|
SelectionMode: current.SelectionMode,
|
LanguageMode: config.NormalizeLanguageMode(cfg.LanguageMode),
|
ConfigLanguageID: cfg.LanguageID,
|
EffectiveLanguageID: current.LanguageSettings.EffectiveLanguageID,
|
FallbackReason: current.FallbackReason,
|
}
|
|
resolved, err := model.ResolveModel(model.NormalizeModelID(current.ModelID))
|
if err != nil {
|
return nil, meta, false, err
|
}
|
meta.ResolvedModelID = resolved.ModelID
|
meta.BackendKind = resolved.BackendKind
|
meta.LanguageParam = resolved.Profile.LanguageParam
|
meta.NumThreads = resolved.Profile.NumThreads
|
|
modelExists := resolved.IsUsable()
|
if !modelExists {
|
return nil, meta, false, fmt.Errorf("model %s is not usable: %s missing=%v problems=%v", resolved.ModelID, resolved.Status, resolved.Missing, resolved.Problems)
|
}
|
|
eng, err := engine.NewWithResolvedModel(resolved)
|
if err != nil {
|
return nil, meta, modelExists, err
|
}
|
meta.HardwareInfo = eng.HardwareInfo()
|
if providerEng, ok := eng.(engine.ProviderEngine); ok {
|
meta.Provider = providerEng.Provider()
|
}
|
|
return eng, meta, modelExists, nil
|
}
|
|
func (a *App) setEngineStatus(status, hardwareInfo, errText string) {
|
if a.engineService != nil {
|
a.engineService.SetStatus(status, hardwareInfo, errText)
|
}
|
if a.wailsApp == nil {
|
return
|
}
|
payload := map[string]interface{}{
|
"status": status,
|
}
|
if hardwareInfo != "" {
|
payload["hardwareInfo"] = hardwareInfo
|
}
|
if errText != "" {
|
payload["error"] = errText
|
}
|
a.wailsApp.Event.Emit("engine:status", payload)
|
}
|
|
func (a *App) hasEngine() bool {
|
a.engineMu.Lock()
|
defer a.engineMu.Unlock()
|
return a.eng != nil
|
}
|
|
func (a *App) replaceEngine(eng engine.Engine) {
|
a.replaceEngineWithMetadata(eng, engineMetadata{})
|
}
|
|
func (a *App) replaceEngineWithMetadata(eng engine.Engine, meta engineMetadata) {
|
a.releaseTailCaptureNanos.Store(int64(releaseTailCaptureDelayForEngine(eng)))
|
a.holdPreCaptureEnabled.Store(holdPreCaptureEnabledForEngine(eng))
|
|
a.engineMu.Lock()
|
old := a.eng
|
a.eng = eng
|
a.engineMeta = meta
|
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 keyName == "" {
|
return "触发键未设置"
|
}
|
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()
|
}
|
if a.hk != nil {
|
a.hk.Close()
|
}
|
a.replaceEngine(nil)
|
a.wg.Wait()
|
logger.Info("Cleanup complete")
|
logger.Close()
|
}
|
|
// 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)
|
}
|