From fecea2c7a4ecc48b2354868894e78be361a2ccf1 Mon Sep 17 00:00:00 2001
From: Cw <cw@git.boborobots.com>
Date: Sat, 04 Jul 2026 16:53:53 +0800
Subject: [PATCH] Fix Windows ASR model paths for non-ASCII users

---
 C1.source/privatevoice.src/app.go |  260 +++++++++++++++++++++++++++++++++++++++++-----------
 1 files changed, 205 insertions(+), 55 deletions(-)

diff --git a/C1.source/privatevoice.src/app.go b/C1.source/privatevoice.src/app.go
index 0a0c428..9acb5f6 100755
--- a/C1.source/privatevoice.src/app.go
+++ b/C1.source/privatevoice.src/app.go
@@ -3,6 +3,7 @@
 import (
 	"context"
 	"fmt"
+	"runtime"
 	"strings"
 	"sync"
 	"sync/atomic"
@@ -17,6 +18,8 @@
 	"voicesnap/internal/input"
 	"voicesnap/internal/language"
 	"voicesnap/internal/logger"
+	"voicesnap/internal/model"
+	"voicesnap/internal/modelselection"
 	"voicesnap/internal/overlay"
 	"voicesnap/internal/sound"
 	"voicesnap/internal/textproc"
@@ -28,8 +31,8 @@
 )
 
 const (
-	appVersion        = "2.2.0"
-	appBuild          = "20260702.0303"
+	appVersion        = "2.2.2"
+	appBuild          = "20260703.0335"
 	appDisplayVersion = appVersion + " (build " + appBuild + ")"
 	appName           = "PrivateVoice Dictation"
 
@@ -50,14 +53,18 @@
 	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      *audio.Recorder
+	eng           engine.Engine
+	engineMu      sync.Mutex
+	engineInitMu  sync.Mutex
+	engineInitSeq atomic.Uint64
+	engineMeta    engineMetadata
+	engineFactory engineFactoryFunc
+	hk            hotkey.Listener
+	paster        input.Paster
+	history       *history.Store
+	userdict      *userdict.Store
 
 	engineService  *services.EngineService
 	wailsApp       *application.App
@@ -86,6 +93,24 @@
 	liveCaptionSeq          uint64
 	releaseTailCaptureNanos atomic.Int64
 	holdPreCaptureEnabled   atomic.Bool
+}
+
+type engineFactoryFunc func(initID uint64) (engine.Engine, engineMetadata, bool, error)
+
+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 {
@@ -132,6 +157,7 @@
 	historyService := services.NewHistoryService(app.history)
 	userDictService := services.NewUserDictService(app.userdict)
 	correctionCSVService := services.NewCorrectionCSVService(app.history, app.userdict)
+	diagnosticsService := services.NewDiagnosticsService(app.cfg, appDisplayVersion)
 
 	// Create Wails application
 	wailsApp := application.New(application.Options{
@@ -148,6 +174,7 @@
 			application.NewService(historyService),
 			application.NewService(userDictService),
 			application.NewService(correctionCSVService),
+			application.NewService(diagnosticsService),
 		},
 		Assets: application.AssetOptions{
 			Handler: application.AssetFileServerFS(assets),
@@ -588,9 +615,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 +636,6 @@
 				if captionActive {
 					continue
 				}
-				d := time.Since(start)
 				a.indicator.SetStatus(status, fmt.Sprintf("%d:%02d", int(d.Minutes()), int(d.Seconds())%60))
 			}
 		}
@@ -860,8 +897,6 @@
 	hotkeyVK := a.cfg.HotkeyVK
 	autoHide := a.cfg.AutoHide
 	copyToClipboard := a.cfg.CopyToClipboard
-	selectedModelID := a.cfg.SelectedModelID
-	languageID := a.cfg.LanguageID
 	a.mu.Unlock()
 
 	logger.Info(
@@ -893,6 +928,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")
@@ -982,52 +1018,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,
 	)
 }
 
@@ -1106,18 +1174,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 +1199,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 +1234,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.Default()
+		}
+	}
+
+	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 +1312,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 {
@@ -1391,9 +1537,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