From 33f00a1136c9f7e501b989d432b709d632905304 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Sun, 12 Jul 2026 02:09:04 +0800
Subject: [PATCH] chore(mas): initialize split project metadata
---
C1.source/privatevoice.src/app.go | 675 +++++++++++++++++++++++++++++++++++++++++++++++++------
1 files changed, 592 insertions(+), 83 deletions(-)
diff --git a/C1.source/privatevoice.src/app.go b/C1.source/privatevoice.src/app.go
index 0a0c428..7f22693 100755
--- a/C1.source/privatevoice.src/app.go
+++ b/C1.source/privatevoice.src/app.go
@@ -2,7 +2,9 @@
import (
"context"
+ "errors"
"fmt"
+ "runtime"
"strings"
"sync"
"sync/atomic"
@@ -17,8 +19,11 @@
"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"
@@ -27,11 +32,14 @@
"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
@@ -44,20 +52,34 @@
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
@@ -69,6 +91,8 @@
isRecording bool
isStoppingRecording bool
isHoldRecordingPending bool
+ isHoldRecorderStarting bool
+ isHoldStopPending bool
isFreetalking bool
hotkeyActive bool
hotkeyPressTime time.Time
@@ -86,6 +110,37 @@
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 {
@@ -101,18 +156,20 @@
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()
@@ -121,17 +178,20 @@
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{
@@ -148,6 +208,8 @@
application.NewService(historyService),
application.NewService(userDictService),
application.NewService(correctionCSVService),
+ application.NewService(diagnosticsService),
+ application.NewService(textOutputService),
},
Assets: application.AssetOptions{
Handler: application.AssetFileServerFS(assets),
@@ -267,6 +329,23 @@
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
@@ -296,10 +375,40 @@
// 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()
@@ -390,6 +499,7 @@
return a.holdPreCaptureEnabled.Load() &&
!a.isRecording &&
!a.isStoppingRecording &&
+ !a.isHoldRecorderStarting &&
!a.isFreetalking &&
time.Since(a.lastStopTime) > 500*time.Millisecond
}
@@ -398,21 +508,133 @@
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")
@@ -431,8 +653,10 @@
return
}
a.isHoldRecordingPending = false
+ a.cancelHoldActivationTimerLocked()
if a.isRecording {
a.isRecording = false
+ a.isHoldStopPending = a.isHoldRecorderStarting
a.lastStopTime = time.Now()
a.recorder.Stop()
}
@@ -510,6 +734,8 @@
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)
@@ -523,6 +749,39 @@
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)")
@@ -543,7 +802,12 @@
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)
}()
}
@@ -570,10 +834,20 @@
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.
@@ -588,9 +862,20 @@
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
@@ -598,7 +883,6 @@
if captionActive {
continue
}
- d := time.Since(start)
a.indicator.SetStatus(status, fmt.Sprintf("%d:%02d", int(d.Minutes()), int(d.Seconds())%60))
}
}
@@ -722,7 +1006,12 @@
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)
}()
}
@@ -822,8 +1111,61 @@
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()
@@ -848,10 +1190,10 @@
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
@@ -860,19 +1202,32 @@
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")
@@ -893,6 +1248,7 @@
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")
@@ -942,7 +1298,8 @@
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))
@@ -958,23 +1315,41 @@
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 {
@@ -982,52 +1357,84 @@
}
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,
)
}
@@ -1043,6 +1450,23 @@
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 != "",
}
}
@@ -1106,18 +1530,22 @@
}
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 {
@@ -1127,26 +1555,24 @@
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()
@@ -1164,6 +1590,77 @@
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()
@@ -1171,12 +1668,17 @@
}
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 {
@@ -1211,6 +1713,9 @@
}
func hotkeyReadyText(keyName, mode string) string {
+ if keyName == "" {
+ return "触发键未设置"
+ }
if mode == config.HotkeyModeTap {
return "点按" + keyName + "说话"
}
@@ -1391,9 +1896,13 @@
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.
--
Gitblit v1.9.3