| | |
| | | |
| | | import ( |
| | | "context" |
| | | "errors" |
| | | "fmt" |
| | | "runtime" |
| | | "strings" |
| | |
| | | "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.2" |
| | | appBuild = "20260703.0335" |
| | | 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 |
| | |
| | | wg sync.WaitGroup |
| | | |
| | | cfg *config.Config |
| | | recorder *audio.Recorder |
| | | recorder appRecorder |
| | | eng engine.Engine |
| | | engineMu sync.Mutex |
| | | engineInitMu sync.Mutex |
| | |
| | | engineFactory engineFactoryFunc |
| | | hk hotkey.Listener |
| | | paster input.Paster |
| | | outputRouter *textoutput.Router |
| | | history *history.Store |
| | | userdict *userdict.Store |
| | | |
| | |
| | | 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 |
| | |
| | | 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, appDisplayVersion) |
| | | diagnosticsService := services.NewDiagnosticsService(app.cfg, displayVersion) |
| | | textOutputService := services.NewTextOutputService(app.outputRouter) |
| | | |
| | | // Create Wails application |
| | | wailsApp := application.New(application.Options{ |
| | |
| | | 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. |
| | |
| | | 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 |
| | | 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") |
| | |
| | | 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 { |
| | |
| | | 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 hotkeyReadyText(keyName, mode string) string { |
| | | if keyName == "" { |
| | | return "触发键未设置" |
| | | } |
| | | if mode == config.HotkeyModeTap { |
| | | return "点按" + keyName + "说话" |
| | | } |