| | |
| | | |
| | | import ( |
| | | "context" |
| | | "errors" |
| | | "fmt" |
| | | "runtime" |
| | | "strings" |
| | | "sync" |
| | | "sync/atomic" |
| | |
| | | "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/events" |
| | | ) |
| | | |
| | | var ( |
| | | appVersion = "2.2.2" |
| | | appBuild = "20260703.0335" |
| | | ) |
| | | |
| | | const ( |
| | | appVersion = "2.2.0" |
| | | appBuild = "20260702.0303" |
| | | appDisplayVersion = appVersion + " (build " + appBuild + ")" |
| | | 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 |
| | |
| | | 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 *audio.Recorder |
| | | eng engine.Engine |
| | | engineMu sync.Mutex |
| | | hk hotkey.Listener |
| | | paster input.Paster |
| | | history *history.Store |
| | | userdict *userdict.Store |
| | | 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 |
| | |
| | | isRecording bool |
| | | isStoppingRecording bool |
| | | isHoldRecordingPending bool |
| | | isHoldRecorderStarting bool |
| | | isHoldStopPending bool |
| | | isFreetalking bool |
| | | hotkeyActive bool |
| | | hotkeyPressTime time.Time |
| | |
| | | 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 { |
| | |
| | | cfg, err := config.Load() |
| | | if err != nil { |
| | | logger.Error("Failed to load config: %v", err) |
| | | cfg = config.Default() |
| | | cfg = config.ExistingConfigFallback() |
| | | } |
| | | app.cfg = cfg |
| | | |
| | | // Initialize audio recorder |
| | | app.recorder = audio.NewRecorder() |
| | | 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() |
| | |
| | | app.userdict = userdict.New() |
| | | |
| | | // Create services for Wails bindings |
| | | appService := services.NewAppService(app.cfg, appDisplayVersion) |
| | | 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(app.recorder, app.cfg) |
| | | 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{ |
| | |
| | | application.NewService(historyService), |
| | | application.NewService(userDictService), |
| | | application.NewService(correctionCSVService), |
| | | application.NewService(diagnosticsService), |
| | | application.NewService(textOutputService), |
| | | }, |
| | | Assets: application.AssetOptions{ |
| | | Handler: application.AssetFileServerFS(assets), |
| | |
| | | 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 |
| | |
| | | |
| | | // 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() |
| | |
| | | return a.holdPreCaptureEnabled.Load() && |
| | | !a.isRecording && |
| | | !a.isStoppingRecording && |
| | | !a.isHoldRecorderStarting && |
| | | !a.isFreetalking && |
| | | time.Since(a.lastStopTime) > 500*time.Millisecond |
| | | } |
| | |
| | | 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.isRecording = true |
| | | a.isHoldRecordingPending = true |
| | | |
| | | 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") |
| | |
| | | return |
| | | } |
| | | a.isHoldRecordingPending = false |
| | | a.cancelHoldActivationTimerLocked() |
| | | if a.isRecording { |
| | | a.isRecording = false |
| | | a.isHoldStopPending = a.isHoldRecorderStarting |
| | | a.lastStopTime = time.Now() |
| | | a.recorder.Stop() |
| | | } |
| | |
| | | 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.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)") |
| | |
| | | perfID := newPerfTraceID() |
| | | pipelineStart := time.Now() |
| | | logger.Info("PERF pipeline_start id=%d mode=hold", perfID) |
| | | samples, hasVoice := a.stopRecorderAfterReleaseTailCapture(perfID, pipelineStart) |
| | | 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) |
| | | }() |
| | | } |
| | |
| | | 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. |
| | |
| | | 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 |
| | | } |
| | | d := time.Since(start) |
| | | a.indicator.SetStatus(status, fmt.Sprintf("%d:%02d", int(d.Minutes()), int(d.Seconds())%60)) |
| | | } |
| | | } |
| | |
| | | perfID := newPerfTraceID() |
| | | pipelineStart := time.Now() |
| | | logger.Info("PERF pipeline_start id=%d mode=tap", perfID) |
| | | samples, hasVoice := a.stopRecorderAfterReleaseTailCapture(perfID, pipelineStart) |
| | | 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) |
| | | }() |
| | | } |
| | |
| | | return time.Duration(nanos) |
| | | } |
| | | |
| | | func (a *App) stopRecorderAfterReleaseTailCapture(perfID int64, pipelineStart time.Time) ([]float32, bool) { |
| | | 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() |
| | |
| | | perfSinceMS(pipelineStart), |
| | | ) |
| | | |
| | | return samples, hasVoice |
| | | return samples, hasVoice, true |
| | | } |
| | | |
| | | // recognizeAndPaste runs ASR on the recorded samples and pastes the result. |
| | | // 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 |
| | |
| | | hotkeyVK := a.cfg.HotkeyVK |
| | | autoHide := a.cfg.AutoHide |
| | | copyToClipboard := a.cfg.CopyToClipboard |
| | | selectedModelID := a.cfg.SelectedModelID |
| | | languageID := a.cfg.LanguageID |
| | | 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 since_start_ms=%d", |
| | | "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") |
| | |
| | | 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") |
| | |
| | | return |
| | | } |
| | | |
| | | logger.Info("Recognized: %s", text) |
| | | 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)) |
| | |
| | | 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) |
| | | 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 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.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 { |
| | |
| | | } |
| | | 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) |
| | | logSlowRecognitionIfNeeded(perfID, engMeta, samples, recognizeElapsedMS, totalMS) |
| | | a.delayedHideIf(autoHide, doneIndicatorHideDelayMs) |
| | | } |
| | | |
| | | func logSlowRecognitionIfNeeded(perfID int64, eng engine.Engine, modelID, languageID string, samples []float32, recognizeMS, totalMS int64) { |
| | | func logSlowRecognitionIfNeeded(perfID int64, meta engineMetadata, samples []float32, recognizeMS, totalMS int64) { |
| | | reason := slowRecognitionReason(recognizeMS, totalMS) |
| | | if reason == "" { |
| | | return |
| | | } |
| | | |
| | | engineInfo := "" |
| | | if eng != nil { |
| | | engineInfo = eng.HardwareInfo() |
| | | 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 language_id=%q engine=%q audio_ms=%d samples=%d recognize_ms=%d total_ms=%d load1=%.2f load5=%.2f load15=%.2f", |
| | | "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, |
| | | modelID, |
| | | languageID, |
| | | engineInfo, |
| | | perfAudioDurationMS(samples), |
| | | 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 language_id=%q engine=%q audio_ms=%d samples=%d recognize_ms=%d total_ms=%d load_unavailable=true", |
| | | "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, |
| | | modelID, |
| | | languageID, |
| | | engineInfo, |
| | | perfAudioDurationMS(samples), |
| | | 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, |
| | | ) |
| | | } |
| | | |
| | |
| | | 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 != "", |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | 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", |
| | | }) |
| | | initID := a.engineInitSeq.Add(1) |
| | | |
| | | modelExists := engine.ModelExists() |
| | | eng, err := engine.New() |
| | | 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: %v", err) |
| | | 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 { |
| | |
| | | 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(), |
| | | }) |
| | | a.setEngineStatus(status, "", err.Error()) |
| | | return |
| | | } |
| | | |
| | | a.replaceEngine(eng) |
| | | |
| | | logger.Info("ASR engine ready: %s", eng.HardwareInfo()) |
| | | if a.engineService != nil { |
| | | a.engineService.SetStatus("ready", eng.HardwareInfo(), "") |
| | | 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.wailsApp.Event.Emit("engine:status", map[string]interface{}{ |
| | | "status": "ready", |
| | | "hardwareInfo": eng.HardwareInfo(), |
| | | }) |
| | | |
| | | 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() |
| | |
| | | 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() |
| | |
| | | } |
| | | |
| | | 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 { |
| | |
| | | } |
| | | |
| | | func hotkeyReadyText(keyName, mode string) string { |
| | | if keyName == "" { |
| | | return "触发键未设置" |
| | | } |
| | | if mode == config.HotkeyModeTap { |
| | | return "点按" + keyName + "说话" |
| | | } |
| | |
| | | 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. |