Freeze R221 2.2.2 source baseline
39 files modified
5 files added
| | |
| | | # OS/editor noise |
| | | .DS_Store |
| | | ._* |
| | | *.swp |
| | | *.swo |
| | | |
| | |
| | | /.codex/ |
| | | /.worktrees/ |
| | | |
| | | # Recovery input snapshots are not active source roots. |
| | | /C1.source/privatevoice-2.2.1-recovered.src/ |
| | | /C1.source/privatevoice-2.2.1-recovered.src.RECOVERY.md |
| | | |
| | | # Release packages, certificates, logs, and upload artifacts are local outputs. |
| | | /Release/ |
| | | |
| | |
| | | |
| | | 如果当前工作目录在 `03-O` 内,先回到项目根入口读取: |
| | | |
| | | 1. `/Users/ar/Projects/PrivateVoice2/AGENTS.md` |
| | | 2. `/Users/ar/Projects/PrivateVoice2/PROJECT.md` |
| | | 3. `/Users/ar/Projects/PrivateVoice2/INDEX.md` |
| | | 4. `/Users/ar/Projects/PrivateVoice2/02-P/进度地图.md` |
| | | 5. `/Users/ar/Projects/PrivateVoice2/03-O/CODEGRAPH.md` |
| | | 1. `/Users/ar/Projects/PrivateVoice/AGENTS.md` |
| | | 2. `/Users/ar/Projects/PrivateVoice/PROJECT.md` |
| | | 3. `/Users/ar/Projects/PrivateVoice/INDEX.md` |
| | | 4. `/Users/ar/Projects/PrivateVoice/02-P/进度地图.md` |
| | | 5. `/Users/ar/Projects/PrivateVoice/03-O/CODEGRAPH.md` |
| | | |
| | | 旧 `K2.项目管理/legacy/` 资料只作证据附件,不能作为活跃任务来源。 |
| | | |
| | | ## 路径边界 |
| | | |
| | | - 项目根:`/Users/ar/Projects/PrivateVoice2` |
| | | - Git 根:`/Users/ar/Projects/PrivateVoice2/03-O` |
| | | - 源码根:`/Users/ar/Projects/PrivateVoice2/03-O/C1.source/privatevoice.src` |
| | | - 构建/发布候选包:`/Users/ar/Projects/PrivateVoice2/03-O/C2.builds` |
| | | - 项目根:`/Users/ar/Projects/PrivateVoice` |
| | | - Git 根:`/Users/ar/Projects/PrivateVoice/03-O` |
| | | - 源码根:`/Users/ar/Projects/PrivateVoice/03-O/C1.source/privatevoice.src` |
| | | - 构建/发布候选包:`/Users/ar/Projects/PrivateVoice/03-O/C2.builds` |
| | | - 构建与 QA 脚本:源码内 `C1.source/privatevoice.src/scripts/`,`C3.tools/` 只放索引和迁移工具说明。 |
| | | - 会话记忆目录:`/Users/ar/Projects/PrivateVoice2/.codex-sessionhistory/` |
| | | - 会话记忆目录:`/Users/ar/Projects/PrivateVoice/.codex-sessionhistory/` |
| | | - 常规会话主题汇编目录:`/Users/ar/Projects/PrivateVoice/.codex-sessionhistory/00-Topics.md` 和 `/Users/ar/Projects/PrivateVoice/.codex-sessionhistory/_topics/`。`K2.项目管理/session-topics/` 只保存旧项目迁移压缩摘要,不得作为新项目常规主题汇编落点。 |
| | | |
| | | ## 工作规则 |
| | | |
| | | - 统一活跃台账是 `/Users/ar/Projects/PrivateVoice2/02-P/进度地图.md`。 |
| | | - 统一活跃台账是 `/Users/ar/Projects/PrivateVoice/02-P/进度地图.md`。 |
| | | - `CODEGRAPH.md` 是人工维护工程地图;自动索引或缓存不能替代它。 |
| | | - `codebase-memory` 状态为 `enabled`;标准刷新命令是 `codebase-memory-mcp cli index_repository '{"repo_path":"/Users/ar/Projects/PrivateVoice/03-O"}'`,共享产物为 `.codebase-memory/graph.db.zst` 和 `.codebase-memory/artifact.json`。 |
| | | - 每次源码冻结、版本冻结、正式可回滚测试包或发布包前必须刷新 `.codebase-memory/`;日常 Coder 中间态变更不强制刷新。 |
| | | - 发布、QA、签名、打包和版本冻结必须遵守项目根入口声明的全局规则路由。 |
| | | - 标准验证命令必须保持原始可执行形式,不在命令本身追加输出截断管道。 |
| | |
| | | import ( |
| | | "context" |
| | | "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/textproc" |
| | |
| | | ) |
| | | |
| | | const ( |
| | | appVersion = "2.2.0" |
| | | appBuild = "20260702.0303" |
| | | appVersion = "2.2.2" |
| | | appBuild = "20260703.0335" |
| | | appDisplayVersion = appVersion + " (build " + appBuild + ")" |
| | | appName = "PrivateVoice Dictation" |
| | | |
| | |
| | | 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 |
| | |
| | | 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 { |
| | |
| | | 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{ |
| | |
| | | application.NewService(historyService), |
| | | application.NewService(userDictService), |
| | | application.NewService(correctionCSVService), |
| | | application.NewService(diagnosticsService), |
| | | }, |
| | | Assets: application.AssetOptions{ |
| | | Handler: application.AssetFileServerFS(assets), |
| | |
| | | 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)) |
| | | } |
| | | } |
| | |
| | | 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( |
| | |
| | | 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") |
| | |
| | | } |
| | | 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, |
| | | ) |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | 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.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() |
| | |
| | | } |
| | | |
| | | 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 { |
| | |
| | | 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. |
| | |
| | | |
| | | import ( |
| | | "strings" |
| | | "sync/atomic" |
| | | "testing" |
| | | "time" |
| | | |
| | | "voicesnap/internal/engine" |
| | | ) |
| | | |
| | | func TestLiveCaptionDisplayTextFitsOverlayBuffer(t *testing.T) { |
| | |
| | | } |
| | | } |
| | | |
| | | func TestInitEngineSerializesAndDiscardsStaleEngine(t *testing.T) { |
| | | first := &closeTrackingTestEngine{name: "first"} |
| | | second := &closeTrackingTestEngine{name: "second"} |
| | | firstStarted := make(chan struct{}) |
| | | allowFirst := make(chan struct{}) |
| | | calls := atomic.Int32{} |
| | | |
| | | a := &App{ |
| | | engineFactory: func(initID uint64) (engine.Engine, engineMetadata, bool, error) { |
| | | call := calls.Add(1) |
| | | switch call { |
| | | case 1: |
| | | close(firstStarted) |
| | | <-allowFirst |
| | | return first, engineMetadata{InitID: initID, ResolvedModelID: "first", HardwareInfo: "first"}, true, nil |
| | | case 2: |
| | | return second, engineMetadata{InitID: initID, ResolvedModelID: "second", HardwareInfo: "second"}, true, nil |
| | | default: |
| | | t.Fatalf("unexpected engine factory call %d", call) |
| | | return nil, engineMetadata{}, false, nil |
| | | } |
| | | }, |
| | | } |
| | | |
| | | doneFirst := make(chan struct{}) |
| | | go func() { |
| | | a.initEngine() |
| | | close(doneFirst) |
| | | }() |
| | | |
| | | select { |
| | | case <-firstStarted: |
| | | case <-time.After(time.Second): |
| | | t.Fatal("first engine init did not start") |
| | | } |
| | | |
| | | doneSecond := make(chan struct{}) |
| | | go func() { |
| | | a.initEngine() |
| | | close(doneSecond) |
| | | }() |
| | | |
| | | waitForCondition(t, time.Second, func() bool { |
| | | return a.engineInitSeq.Load() == 2 |
| | | }) |
| | | close(allowFirst) |
| | | |
| | | select { |
| | | case <-doneFirst: |
| | | case <-time.After(time.Second): |
| | | t.Fatal("first engine init did not finish") |
| | | } |
| | | select { |
| | | case <-doneSecond: |
| | | case <-time.After(time.Second): |
| | | t.Fatal("second engine init did not finish") |
| | | } |
| | | |
| | | a.engineMu.Lock() |
| | | gotEngine := a.eng |
| | | gotMeta := a.engineMeta |
| | | a.engineMu.Unlock() |
| | | |
| | | if gotEngine != second { |
| | | t.Fatalf("final engine = %#v, want second", gotEngine) |
| | | } |
| | | if !first.closed.Load() { |
| | | t.Fatal("stale first engine should be closed") |
| | | } |
| | | if second.closed.Load() { |
| | | t.Fatal("current second engine should remain open") |
| | | } |
| | | if gotMeta.ResolvedModelID != "second" || gotMeta.InitID != 2 { |
| | | t.Fatalf("engine metadata = %+v, want second init metadata", gotMeta) |
| | | } |
| | | } |
| | | |
| | | func waitForCondition(t *testing.T, timeout time.Duration, fn func() bool) { |
| | | t.Helper() |
| | | deadline := time.Now().Add(timeout) |
| | | for time.Now().Before(deadline) { |
| | | if fn() { |
| | | return |
| | | } |
| | | time.Sleep(10 * time.Millisecond) |
| | | } |
| | | t.Fatal("condition was not met before timeout") |
| | | } |
| | | |
| | | func TestShouldStartHoldPreCaptureLocked(t *testing.T) { |
| | | a := &App{} |
| | | a.holdPreCaptureEnabled.Store(true) |
| | |
| | | func (plainTestEngine) HardwareInfo() string { return "test" } |
| | | func (plainTestEngine) Close() {} |
| | | |
| | | type closeTrackingTestEngine struct { |
| | | plainTestEngine |
| | | name string |
| | | closed atomic.Bool |
| | | } |
| | | |
| | | func (e *closeTrackingTestEngine) HardwareInfo() string { return e.name } |
| | | func (e *closeTrackingTestEngine) Close() { e.closed.Store(true) } |
| | | |
| | | type tailCaptureTestEngine struct { |
| | | plainTestEngine |
| | | delay time.Duration |
| | |
| | | description: "Private offline voice input tool" |
| | | copyright: "(c) 2026, PrivateVoice" |
| | | comments: "Private. Offline." |
| | | version: "2.2.0" |
| | | version: "2.2.2" |
| | | |
| | | dev_mode: |
| | | root_path: . |
| | |
| | | <key>CFBundlePackageType</key> |
| | | <string>APPL</string> |
| | | <key>CFBundleShortVersionString</key> |
| | | <string>2.2.0</string> |
| | | <string>2.2.2</string> |
| | | <key>CFBundleVersion</key> |
| | | <string>20260702.0303</string> |
| | | <string>20260703.0335</string> |
| | | <key>ITSAppUsesNonExemptEncryption</key> |
| | | <false/> |
| | | <key>LSApplicationCategoryType</key> |
| New file |
| | |
| | | package main |
| | | |
| | | import ( |
| | | "encoding/binary" |
| | | "flag" |
| | | "fmt" |
| | | "os" |
| | | "time" |
| | | "voicesnap/internal/engine" |
| | | ) |
| | | |
| | | func main() { |
| | | wavPath := flag.String("wav", "", "16kHz mono PCM16 WAV file") |
| | | runs := flag.Int("runs", 3, "number of recognition runs") |
| | | flag.Parse() |
| | | |
| | | if *wavPath == "" { |
| | | fmt.Fprintln(os.Stderr, "missing -wav") |
| | | os.Exit(2) |
| | | } |
| | | if *runs < 1 { |
| | | *runs = 1 |
| | | } |
| | | |
| | | samples, sampleRate, err := readPCM16WAV(*wavPath) |
| | | if err != nil { |
| | | fmt.Fprintf(os.Stderr, "read wav: %v\n", err) |
| | | os.Exit(1) |
| | | } |
| | | if sampleRate != 16000 { |
| | | fmt.Fprintf(os.Stderr, "unsupported sample rate %d: provide 16kHz mono PCM16 WAV\n", sampleRate) |
| | | os.Exit(1) |
| | | } |
| | | |
| | | eng, err := engine.New() |
| | | if err != nil { |
| | | fmt.Fprintf(os.Stderr, "init engine: %v\n", err) |
| | | os.Exit(1) |
| | | } |
| | | defer eng.Close() |
| | | |
| | | audioMS := int64(len(samples)) * 1000 / 16000 |
| | | fmt.Printf("engine=%q samples=%d audio_ms=%d runs=%d\n", eng.HardwareInfo(), len(samples), audioMS, *runs) |
| | | var total int64 |
| | | for i := 1; i <= *runs; i++ { |
| | | start := time.Now() |
| | | text, err := eng.Recognize(samples) |
| | | elapsedMS := time.Since(start).Milliseconds() |
| | | if err != nil { |
| | | fmt.Fprintf(os.Stderr, "run=%d error=%v\n", i, err) |
| | | os.Exit(1) |
| | | } |
| | | total += elapsedMS |
| | | fmt.Printf("run=%d recognize_ms=%d rtf=%.3f text_runes=%d\n", i, elapsedMS, float64(elapsedMS)/float64(audioMS), len([]rune(text))) |
| | | } |
| | | fmt.Printf("avg_recognize_ms=%d\n", total/int64(*runs)) |
| | | } |
| | | |
| | | func readPCM16WAV(path string) ([]float32, int, error) { |
| | | data, err := os.ReadFile(path) |
| | | if err != nil { |
| | | return nil, 0, err |
| | | } |
| | | if len(data) < 12 || string(data[0:4]) != "RIFF" || string(data[8:12]) != "WAVE" { |
| | | return nil, 0, fmt.Errorf("not a RIFF/WAVE file") |
| | | } |
| | | |
| | | var audioFormat, channels, bitsPerSample uint16 |
| | | var sampleRate uint32 |
| | | var pcm []byte |
| | | for offset := 12; offset+8 <= len(data); { |
| | | chunkID := string(data[offset : offset+4]) |
| | | chunkSize := int(binary.LittleEndian.Uint32(data[offset+4 : offset+8])) |
| | | chunkStart := offset + 8 |
| | | chunkEnd := chunkStart + chunkSize |
| | | if chunkEnd > len(data) { |
| | | return nil, 0, fmt.Errorf("truncated %s chunk", chunkID) |
| | | } |
| | | |
| | | switch chunkID { |
| | | case "fmt ": |
| | | if chunkSize < 16 { |
| | | return nil, 0, fmt.Errorf("fmt chunk too small") |
| | | } |
| | | audioFormat = binary.LittleEndian.Uint16(data[chunkStart : chunkStart+2]) |
| | | channels = binary.LittleEndian.Uint16(data[chunkStart+2 : chunkStart+4]) |
| | | sampleRate = binary.LittleEndian.Uint32(data[chunkStart+4 : chunkStart+8]) |
| | | bitsPerSample = binary.LittleEndian.Uint16(data[chunkStart+14 : chunkStart+16]) |
| | | case "data": |
| | | pcm = data[chunkStart:chunkEnd] |
| | | } |
| | | |
| | | offset = chunkEnd |
| | | if offset%2 == 1 { |
| | | offset++ |
| | | } |
| | | } |
| | | |
| | | if audioFormat != 1 || channels != 1 || bitsPerSample != 16 { |
| | | return nil, 0, fmt.Errorf("unsupported WAV format: audio_format=%d channels=%d bits_per_sample=%d; want PCM16 mono", audioFormat, channels, bitsPerSample) |
| | | } |
| | | if len(pcm) == 0 { |
| | | return nil, 0, fmt.Errorf("missing data chunk") |
| | | } |
| | | if len(pcm)%2 != 0 { |
| | | return nil, 0, fmt.Errorf("odd PCM data length") |
| | | } |
| | | |
| | | samples := make([]float32, len(pcm)/2) |
| | | for i := range samples { |
| | | v := int16(binary.LittleEndian.Uint16(pcm[i*2 : i*2+2])) |
| | | samples[i] = float32(v) / 32768.0 |
| | | } |
| | | return samples, int(sampleRate), nil |
| | | } |
| | |
| | | { |
| | | "name": "privatevoice-dictation-frontend", |
| | | "version": "2.2.0", |
| | | "version": "2.2.2", |
| | | "lockfileVersion": 3, |
| | | "requires": true, |
| | | "packages": { |
| | | "": { |
| | | "name": "privatevoice-dictation-frontend", |
| | | "version": "2.2.0", |
| | | "version": "2.2.2", |
| | | "dependencies": { |
| | | "@wailsio/runtime": "latest" |
| | | }, |
| | |
| | | { |
| | | "name": "privatevoice-dictation-frontend", |
| | | "private": true, |
| | | "version": "2.2.0", |
| | | "version": "2.2.2", |
| | | "type": "module", |
| | | "scripts": { |
| | | "dev": "vite dev", |
| | |
| | | let showOnboarding = $state(false) |
| | | |
| | | onMount(() => { |
| | | const offEvents: Array<() => void> = [] |
| | | |
| | | Call.ByName('voicesnap/services.ConfigService.GetLanguageSettings').then((settings: any) => { |
| | | applyLanguageSettings(settings) |
| | | }).catch(() => { |
| | | languageReady = true |
| | | }) |
| | | |
| | | Events.On('indicator:show', (ev: any) => { |
| | | offEvents.push(Events.On('indicator:show', (ev: any) => { |
| | | indicatorVisible.set(true) |
| | | if (ev?.data?.status) { |
| | | indicatorStatus.set(ev.data.status) |
| | | } |
| | | }) |
| | | })) |
| | | |
| | | Events.On('indicator:hide', () => { |
| | | offEvents.push(Events.On('indicator:hide', () => { |
| | | indicatorVisible.set(false) |
| | | }) |
| | | })) |
| | | |
| | | Events.On('indicator:status', (ev: any) => { |
| | | offEvents.push(Events.On('indicator:status', (ev: any) => { |
| | | const data = ev?.data |
| | | if (data?.status) { |
| | | indicatorStatus.set(data.status) |
| | |
| | | if (data?.hotkeyName) { |
| | | hotkeyName.set(data.hotkeyName) |
| | | } |
| | | }) |
| | | })) |
| | | |
| | | Events.On('indicator:volume', (ev: any) => { |
| | | offEvents.push(Events.On('indicator:volume', (ev: any) => { |
| | | indicatorVolume.set(ev?.data ?? 0) |
| | | }) |
| | | })) |
| | | |
| | | Events.On('engine:status', (ev: any) => { |
| | | offEvents.push(Events.On('engine:status', (ev: any) => { |
| | | const data = ev?.data |
| | | if (data?.status) { |
| | | engineStatus.set(data.status as any) |
| | |
| | | if (data?.hardwareInfo) { |
| | | engineHardwareInfo.set(data.hardwareInfo) |
| | | } |
| | | }) |
| | | })) |
| | | |
| | | Events.On('device:changed', (ev: any) => { |
| | | offEvents.push(Events.On('device:changed', (ev: any) => { |
| | | if (ev?.data?.name) { |
| | | deviceName.set(ev.data.name) |
| | | } |
| | | }) |
| | | })) |
| | | |
| | | Events.On('update:available', (ev: any) => { |
| | | offEvents.push(Events.On('update:available', (ev: any) => { |
| | | const data = ev?.data as UpdateInfo |
| | | updateAvailable.set(data) |
| | | showUpdateDialog = true |
| | | }) |
| | | })) |
| | | |
| | | window.addEventListener('hashchange', () => { |
| | | const onHashChange = () => { |
| | | isIndicatorRoute = window.location.hash === '#/indicator' |
| | | }) |
| | | } |
| | | window.addEventListener('hashchange', onHashChange) |
| | | |
| | | // Query engine status on mount (events emitted before frontend load are lost) |
| | | Call.ByName('voicesnap/services.EngineService.GetStatus').then((data: any) => { |
| | |
| | | hotkeyName.set(name) |
| | | } |
| | | }) |
| | | |
| | | return () => { |
| | | for (const off of offEvents) off() |
| | | window.removeEventListener('hashchange', onHashChange) |
| | | } |
| | | }) |
| | | |
| | | function applyLanguageSettings(settings: any) { |
| | |
| | | let currentModelID = $state('') |
| | | |
| | | onMount(() => { |
| | | Events.On('model:download-progress', (ev: any) => { |
| | | const offDownloadProgress = Events.On('model:download-progress', (ev: any) => { |
| | | const data = ev?.data |
| | | if (data?.percent != null) { |
| | | if (data?.modelID) { |
| | |
| | | } |
| | | }) |
| | | |
| | | Events.On('engine:status', (ev: any) => { |
| | | const offEngineStatus = Events.On('engine:status', (ev: any) => { |
| | | const data = ev?.data |
| | | if (data?.status === 'ready') { |
| | | downloadActive = false |
| | |
| | | }) |
| | | |
| | | triggerDownload() |
| | | |
| | | return () => { |
| | | offDownloadProgress() |
| | | offEngineStatus() |
| | | } |
| | | }) |
| | | |
| | | async function triggerDownload() { |
| | |
| | | <script lang="ts"> |
| | | import { onDestroy } from 'svelte' |
| | | import { Call } from '@wailsio/runtime' |
| | | import ToggleSwitch from '../shared/ToggleSwitch.svelte' |
| | | import { t } from '../../lib/i18n' |
| | |
| | | let hintText = $state('') |
| | | let devices = $state<InputDevice[]>([]) |
| | | let showDeviceDropdown = $state(false) |
| | | let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null |
| | | |
| | | const unsub1 = autoHide.subscribe(v => { autoHideVal = v }) |
| | | const unsub2 = startAtLogin.subscribe(v => { startAtLoginVal = v }) |
| | |
| | | const unsub7 = soundFeedback.subscribe(v => { soundFeedbackVal = v }) |
| | | const unsub8 = copyToClipboard.subscribe(v => { copyToClipboardVal = v }) |
| | | const unsub9 = hotkeyMode.subscribe(v => { hotkeyModeVal = v }) |
| | | |
| | | onDestroy(() => { |
| | | unsub1(); unsub2(); unsub3(); unsub4(); unsub5(); unsub6(); unsub7(); unsub8(); unsub9() |
| | | clearHotkeyCapture() |
| | | }) |
| | | |
| | | // Load actual device name from backend on mount |
| | | async function loadDeviceName() { |
| | |
| | | } |
| | | |
| | | async function startRecordingHotkey() { |
| | | clearHotkeyCapture() |
| | | isRecording = true |
| | | hintText = t('hotkeys.pressAnyKey') |
| | | |
| | |
| | | } catch {} |
| | | })() |
| | | |
| | | document.removeEventListener('keydown', onKey) |
| | | clearHotkeyCapture() |
| | | } |
| | | } |
| | | |
| | | activeKeyHandler = onKey |
| | | document.addEventListener('keydown', onKey) |
| | | } |
| | | |
| | | function clearHotkeyCapture() { |
| | | if (activeKeyHandler) { |
| | | document.removeEventListener('keydown', activeKeyHandler) |
| | | activeKeyHandler = null |
| | | } |
| | | if (isRecording) { |
| | | isRecording = false |
| | | void Call.ByName('voicesnap/services.HotkeyService.StopRecordingHotkey').catch(() => {}) |
| | | } |
| | | } |
| | | |
| | | function mapKeyToVK(e: KeyboardEvent): number { |
| | | switch (e.key) { |
| | | case 'Control': return e.location === 1 ? 0xA2 : e.location === 2 ? 0xA3 : 0x11 |
| | |
| | | <script lang="ts"> |
| | | import { onDestroy } from 'svelte' |
| | | import { Call } from '@wailsio/runtime' |
| | | import { t } from '../../lib/i18n' |
| | | import { hotkeyVK } from '../../lib/stores/config' |
| | |
| | | let currentKeyName = $state('Ctrl') |
| | | let isRecording = $state(false) |
| | | let hintText = $state('') |
| | | let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null |
| | | |
| | | const unsub = hotkeyName.subscribe(k => { currentKeyName = k }) |
| | | |
| | | onDestroy(() => { |
| | | unsub() |
| | | clearHotkeyCapture() |
| | | }) |
| | | |
| | | async function startRecordingHotkey() { |
| | | clearHotkeyCapture() |
| | | isRecording = true |
| | | hintText = t('hotkeys.pressAnyKey') |
| | | |
| | |
| | | } catch {} |
| | | })() |
| | | |
| | | document.removeEventListener('keydown', onKey) |
| | | clearHotkeyCapture() |
| | | } |
| | | } |
| | | |
| | | activeKeyHandler = onKey |
| | | document.addEventListener('keydown', onKey) |
| | | } |
| | | |
| | | function clearHotkeyCapture() { |
| | | if (activeKeyHandler) { |
| | | document.removeEventListener('keydown', activeKeyHandler) |
| | | activeKeyHandler = null |
| | | } |
| | | if (isRecording) { |
| | | isRecording = false |
| | | void Call.ByName('voicesnap/services.HotkeyService.StopRecordingHotkey').catch(() => {}) |
| | | } |
| | | } |
| | | |
| | | function mapKeyToVK(e: KeyboardEvent): number { |
| | | // Map browser key codes to Windows VK codes |
| | | switch (e.key) { |
| | |
| | | <script lang="ts"> |
| | | import { onDestroy } from 'svelte' |
| | | import { Call, Events } from '@wailsio/runtime' |
| | | import { t, setLocale } from '../../lib/i18n' |
| | | import { languageMode, languageID, effectiveLanguageID, uiLocale } from '../../lib/stores/config' |
| | |
| | | const unsub2 = languageID.subscribe(v => { languageIDVal = v }) |
| | | const unsub3 = effectiveLanguageID.subscribe(v => { effectiveLanguageIDVal = v }) |
| | | |
| | | Events.On('model:download-progress', (ev: any) => { |
| | | const offDownloadProgress = Events.On('model:download-progress', (ev: any) => { |
| | | const data = ev?.data |
| | | if (data?.modelID && typeof data.percent === 'number') { |
| | | modelBusyID = data.modelID |
| | |
| | | } |
| | | }) |
| | | |
| | | Events.On('model:download-cancelled', (ev: any) => { |
| | | const offDownloadCancelled = Events.On('model:download-cancelled', (ev: any) => { |
| | | const data = ev?.data |
| | | if (data?.modelID) { |
| | | modelBusyID = data.modelID |
| | |
| | | } |
| | | }) |
| | | |
| | | Events.On('model:download-finished', (ev: any) => { |
| | | const offDownloadFinished = Events.On('model:download-finished', (ev: any) => { |
| | | const data = ev?.data |
| | | if (!data?.modelID || data.modelID === modelBusyID) { |
| | | clearModelBusy() |
| | |
| | | } |
| | | }) |
| | | |
| | | onDestroy(() => { |
| | | unsub1() |
| | | unsub2() |
| | | unsub3() |
| | | offDownloadProgress() |
| | | offDownloadCancelled() |
| | | offDownloadFinished() |
| | | }) |
| | | |
| | | async function loadSettings() { |
| | | try { |
| | | const lang: any = await Call.ByName('voicesnap/services.ConfigService.GetLanguageSettings') |
| | |
| | | |
| | | let statuses = $state<PermissionStatus[]>([]) |
| | | let loading = $state(true) |
| | | let refreshTimeout: number | null = null |
| | | |
| | | onMount(() => { |
| | | refresh() |
| | | const timer = window.setInterval(refresh, 2500) |
| | | return () => window.clearInterval(timer) |
| | | return () => { |
| | | window.clearInterval(timer) |
| | | if (refreshTimeout !== null) { |
| | | window.clearTimeout(refreshTimeout) |
| | | refreshTimeout = null |
| | | } |
| | | } |
| | | }) |
| | | |
| | | async function refresh() { |
| | |
| | | await Call.ByName('voicesnap/services.PermissionService.OpenSettings', id) |
| | | } catch {} |
| | | } |
| | | window.setTimeout(refresh, 800) |
| | | if (refreshTimeout !== null) { |
| | | window.clearTimeout(refreshTimeout) |
| | | } |
| | | refreshTimeout = window.setTimeout(() => { |
| | | refreshTimeout = null |
| | | refresh() |
| | | }, 800) |
| | | } |
| | | |
| | | function sortStatuses(items: PermissionStatus[]) { |
| | |
| | | <script lang="ts"> |
| | | import { onDestroy } from 'svelte' |
| | | import { currentPage } from '../../lib/stores/app' |
| | | import { t } from '../../lib/i18n' |
| | | import GeneralPage from './GeneralPage.svelte' |
| | |
| | | let page = $state('general') |
| | | const unsubPage = currentPage.subscribe(p => { page = p }) |
| | | |
| | | onDestroy(() => { |
| | | unsubPage() |
| | | }) |
| | | |
| | | function navigate(id: string) { |
| | | currentPage.set(id) |
| | | } |
| | |
| | | channels = 1 |
| | | bitsPerSample = 16 |
| | | silenceThreshold = 0.05 |
| | | maxRecordingSeconds = 15 * 60 |
| | | maxPCMBufferBytes = sampleRate * channels * (bitsPerSample / 8) * maxRecordingSeconds |
| | | MaxRecordingDuration = time.Duration(maxRecordingSeconds) * time.Second |
| | | maxPendingDeviceStops = 2 |
| | | ) |
| | | |
| | | var pendingDeviceStops = make(chan struct{}, maxPendingDeviceStops) |
| | | |
| | | // InputDevice represents an audio input device. |
| | | type InputDevice struct { |
| | |
| | | |
| | | // State |
| | | isRecording bool |
| | | bufferLimitHit bool |
| | | maxVolume float64 |
| | | currentVolume float64 |
| | | volumeCallback func(float64) |
| | |
| | | } |
| | | |
| | | r.pcmBuf = nil |
| | | r.bufferLimitHit = false |
| | | r.maxVolume = 0 |
| | | r.currentVolume = 0 |
| | | |
| | |
| | | if device == nil { |
| | | return |
| | | } |
| | | select { |
| | | case pendingDeviceStops <- struct{}{}: |
| | | case <-time.After(100 * time.Millisecond): |
| | | logger.Error("Audio device stop queue saturated; stopping synchronously") |
| | | stopAndUninitDevice(device) |
| | | return |
| | | } |
| | | done := make(chan struct{}) |
| | | go func() { |
| | | defer func() { |
| | | <-pendingDeviceStops |
| | | }() |
| | | stopAndUninitDevice(device) |
| | | close(done) |
| | | }() |
| | |
| | | } |
| | | |
| | | // Append raw PCM data |
| | | if len(r.pcmBuf) >= maxPCMBufferBytes { |
| | | if !r.bufferLimitHit { |
| | | logger.Error("Recording buffer limit reached; dropping additional audio max_seconds=%d", maxRecordingSeconds) |
| | | r.bufferLimitHit = true |
| | | } |
| | | r.mu.Unlock() |
| | | return |
| | | } |
| | | if len(r.pcmBuf)+len(input) > maxPCMBufferBytes { |
| | | input = input[:maxPCMBufferBytes-len(r.pcmBuf)] |
| | | if !r.bufferLimitHit { |
| | | logger.Error("Recording buffer limit reached; truncating audio max_seconds=%d", maxRecordingSeconds) |
| | | r.bufferLimitHit = true |
| | | } |
| | | } |
| | | r.pcmBuf = append(r.pcmBuf, input...) |
| | | |
| | | // Calculate RMS volume |
| | |
| | | } |
| | | } |
| | | |
| | | func TestOnDataCapsRecordingBuffer(t *testing.T) { |
| | | r := &Recorder{ |
| | | isRecording: true, |
| | | pcmBuf: make([]byte, maxPCMBufferBytes-2), |
| | | } |
| | | |
| | | r.onData(pcm16LE(1, 2)) |
| | | |
| | | if got := len(r.pcmBuf); got != maxPCMBufferBytes { |
| | | t.Fatalf("pcm buffer length = %d, want capped %d", got, maxPCMBufferBytes) |
| | | } |
| | | if !r.bufferLimitHit { |
| | | t.Fatal("bufferLimitHit should be set after truncating at cap") |
| | | } |
| | | |
| | | r.onData(pcm16LE(3)) |
| | | if got := len(r.pcmBuf); got != maxPCMBufferBytes { |
| | | t.Fatalf("pcm buffer length after cap = %d, want %d", got, maxPCMBufferBytes) |
| | | } |
| | | } |
| | | |
| | | func pcm16LE(values ...int16) []byte { |
| | | buf := make([]byte, len(values)*2) |
| | | for i, value := range values { |
| | |
| | | Close() |
| | | } |
| | | |
| | | // ProviderEngine is implemented by engines that can report the concrete |
| | | // sherpa/onnxruntime provider selected at initialization time. |
| | | type ProviderEngine interface { |
| | | Provider() string |
| | | } |
| | | |
| | | // StreamingEngine is implemented by engines that can expose partial results |
| | | // while audio is still being captured. |
| | | type StreamingEngine interface { |
| | |
| | | return newPlatformEngine(resolved) |
| | | } |
| | | |
| | | func audioDurationMS(samples []float32) int64 { |
| | | return int64(len(samples)) * 1000 / 16000 |
| | | } |
| | | |
| | | func durationMS(duration time.Duration) int64 { |
| | | return duration.Milliseconds() |
| | | } |
| | | |
| | | func isSupportedBackend(backend string) bool { |
| | | switch backend { |
| | | case model.BackendSenseVoice, model.BackendMoonshine, model.BackendTransducer, model.BackendNemoTransducer, model.BackendQwen3ASR, model.BackendXASRStreaming: |
| | |
| | | type sherpaEngine struct { |
| | | recognizer *sherpa.OfflineRecognizer |
| | | backendKind string |
| | | provider string |
| | | hwInfo string |
| | | } |
| | | |
| | | type xasrStreamingEngine struct { |
| | | recognizer *sherpa.OnlineRecognizer |
| | | provider string |
| | | hwInfo string |
| | | mu sync.Mutex |
| | | } |
| | |
| | | return &sherpaEngine{ |
| | | recognizer: recognizer, |
| | | backendKind: resolved.BackendKind, |
| | | provider: p.provider, |
| | | hwInfo: info, |
| | | }, nil |
| | | } |
| | |
| | | logger.Info("Engine initialized: %s", info) |
| | | return &xasrStreamingEngine{ |
| | | recognizer: recognizer, |
| | | provider: p.provider, |
| | | hwInfo: info, |
| | | }, nil |
| | | } |
| | |
| | | } |
| | | |
| | | func (e *sherpaEngine) Recognize(samples []float32) (string, error) { |
| | | totalStart := time.Now() |
| | | streamStart := time.Now() |
| | | stream := sherpa.NewOfflineStream(e.recognizer) |
| | | streamMS := durationMS(time.Since(streamStart)) |
| | | defer sherpa.DeleteOfflineStream(stream) |
| | | |
| | | acceptStart := time.Now() |
| | | stream.AcceptWaveform(asrSampleRate, samples) |
| | | acceptMS := durationMS(time.Since(acceptStart)) |
| | | |
| | | decodeStart := time.Now() |
| | | e.recognizer.Decode(stream) |
| | | decodeMS := durationMS(time.Since(decodeStart)) |
| | | resultStart := time.Now() |
| | | result := stream.GetResult() |
| | | resultMS := durationMS(time.Since(resultStart)) |
| | | logger.Info( |
| | | "PERF engine_recognize_detail backend=%q provider=%q engine=%q samples=%d audio_ms=%d create_stream_ms=%d accept_ms=%d decode_ms=%d result_ms=%d total_ms=%d", |
| | | e.backendKind, |
| | | e.provider, |
| | | e.hwInfo, |
| | | len(samples), |
| | | audioDurationMS(samples), |
| | | streamMS, |
| | | acceptMS, |
| | | decodeMS, |
| | | resultMS, |
| | | durationMS(time.Since(totalStart)), |
| | | ) |
| | | |
| | | return result.Text, nil |
| | | } |
| | | |
| | | func (e *xasrStreamingEngine) Recognize(samples []float32) (string, error) { |
| | | totalStart := time.Now() |
| | | sessionStart := time.Now() |
| | | session, err := e.NewStreamingSession() |
| | | if err != nil { |
| | | return "", err |
| | | } |
| | | sessionMS := durationMS(time.Since(sessionStart)) |
| | | defer session.Close() |
| | | |
| | | acceptStart := time.Now() |
| | | if _, err := session.Accept(samples); err != nil { |
| | | return "", err |
| | | } |
| | | return session.Finish() |
| | | acceptMS := durationMS(time.Since(acceptStart)) |
| | | finishStart := time.Now() |
| | | text, err := session.Finish() |
| | | finishMS := durationMS(time.Since(finishStart)) |
| | | if err == nil { |
| | | logger.Info( |
| | | "PERF engine_recognize_detail backend=%q provider=%q engine=%q samples=%d audio_ms=%d create_session_ms=%d accept_ms=%d finish_ms=%d total_ms=%d", |
| | | model.BackendXASRStreaming, |
| | | e.provider, |
| | | e.hwInfo, |
| | | len(samples), |
| | | audioDurationMS(samples), |
| | | sessionMS, |
| | | acceptMS, |
| | | finishMS, |
| | | durationMS(time.Since(totalStart)), |
| | | ) |
| | | } |
| | | return text, err |
| | | } |
| | | |
| | | func (e *xasrStreamingEngine) NewStreamingSession() (StreamingSession, error) { |
| | |
| | | return e.hwInfo |
| | | } |
| | | |
| | | func (e *sherpaEngine) Provider() string { |
| | | return e.provider |
| | | } |
| | | |
| | | func (e *sherpaEngine) ReleaseTailCaptureDelay() time.Duration { |
| | | if e.backendKind == model.BackendSenseVoice { |
| | | return senseVoiceReleaseTailDelay |
| | |
| | | return e.hwInfo |
| | | } |
| | | |
| | | func (e *xasrStreamingEngine) Provider() string { |
| | | return e.provider |
| | | } |
| | | |
| | | func (e *xasrStreamingEngine) ReleaseTailCaptureDelay() time.Duration { |
| | | return xasrReleaseTailDelay |
| | | } |
| | |
| | | |
| | | type sherpaEngine struct { |
| | | recognizer *sherpa.OfflineRecognizer |
| | | provider string |
| | | hwInfo string |
| | | } |
| | | |
| | |
| | | logger.Info("Engine initialized: %s", info) |
| | | return &sherpaEngine{ |
| | | recognizer: recognizer, |
| | | provider: "cpu", |
| | | hwInfo: info, |
| | | }, nil |
| | | } |
| | |
| | | } |
| | | |
| | | func (e *sherpaEngine) Recognize(samples []float32) (string, error) { |
| | | totalStart := time.Now() |
| | | streamStart := time.Now() |
| | | stream := sherpa.NewOfflineStream(e.recognizer) |
| | | streamMS := durationMS(time.Since(streamStart)) |
| | | defer sherpa.DeleteOfflineStream(stream) |
| | | |
| | | acceptStart := time.Now() |
| | | stream.AcceptWaveform(16000, samples) |
| | | acceptMS := durationMS(time.Since(acceptStart)) |
| | | |
| | | decodeStart := time.Now() |
| | | e.recognizer.Decode(stream) |
| | | decodeMS := durationMS(time.Since(decodeStart)) |
| | | resultStart := time.Now() |
| | | result := stream.GetResult() |
| | | resultMS := durationMS(time.Since(resultStart)) |
| | | logger.Info( |
| | | "PERF engine_recognize_detail backend=%q provider=%q engine=%q samples=%d audio_ms=%d create_stream_ms=%d accept_ms=%d decode_ms=%d result_ms=%d total_ms=%d", |
| | | model.BackendSenseVoice, |
| | | e.provider, |
| | | e.hwInfo, |
| | | len(samples), |
| | | audioDurationMS(samples), |
| | | streamMS, |
| | | acceptMS, |
| | | decodeMS, |
| | | resultMS, |
| | | durationMS(time.Since(totalStart)), |
| | | ) |
| | | |
| | | return result.Text, nil |
| | | } |
| | |
| | | return e.hwInfo |
| | | } |
| | | |
| | | func (e *sherpaEngine) Provider() string { |
| | | return e.provider |
| | | } |
| | | |
| | | func (e *sherpaEngine) ReleaseTailCaptureDelay() time.Duration { |
| | | return senseVoiceReleaseTailDelay |
| | | } |
| | |
| | | |
| | | type sherpaEngine struct { |
| | | recognizer *sherpa.OfflineRecognizer |
| | | provider string |
| | | hwInfo string |
| | | } |
| | | |
| | |
| | | logger.Info("Engine initialized: %s", info) |
| | | return &sherpaEngine{ |
| | | recognizer: recognizer, |
| | | provider: p.provider, |
| | | hwInfo: info, |
| | | }, nil |
| | | } |
| | |
| | | } |
| | | |
| | | func (e *sherpaEngine) Recognize(samples []float32) (string, error) { |
| | | totalStart := time.Now() |
| | | streamStart := time.Now() |
| | | stream := sherpa.NewOfflineStream(e.recognizer) |
| | | streamMS := durationMS(time.Since(streamStart)) |
| | | defer sherpa.DeleteOfflineStream(stream) |
| | | |
| | | acceptStart := time.Now() |
| | | stream.AcceptWaveform(16000, samples) |
| | | acceptMS := durationMS(time.Since(acceptStart)) |
| | | |
| | | decodeStart := time.Now() |
| | | e.recognizer.Decode(stream) |
| | | decodeMS := durationMS(time.Since(decodeStart)) |
| | | resultStart := time.Now() |
| | | result := stream.GetResult() |
| | | resultMS := durationMS(time.Since(resultStart)) |
| | | logger.Info( |
| | | "PERF engine_recognize_detail backend=%q provider=%q engine=%q samples=%d audio_ms=%d create_stream_ms=%d accept_ms=%d decode_ms=%d result_ms=%d total_ms=%d", |
| | | model.BackendSenseVoice, |
| | | e.provider, |
| | | e.hwInfo, |
| | | len(samples), |
| | | audioDurationMS(samples), |
| | | streamMS, |
| | | acceptMS, |
| | | decodeMS, |
| | | resultMS, |
| | | durationMS(time.Since(totalStart)), |
| | | ) |
| | | |
| | | return result.Text, nil |
| | | } |
| | |
| | | return e.hwInfo |
| | | } |
| | | |
| | | func (e *sherpaEngine) Provider() string { |
| | | return e.provider |
| | | } |
| | | |
| | | func (e *sherpaEngine) ReleaseTailCaptureDelay() time.Duration { |
| | | return senseVoiceReleaseTailDelay |
| | | } |
| | |
| | | IsAnyOtherKeyPressed(excludeVK int) bool |
| | | // IsAnyOtherKeyPressedSince returns true if another key was pressed after since. |
| | | IsAnyOtherKeyPressedSince(excludeVK int, since time.Time) bool |
| | | // Close releases listener resources. |
| | | Close() |
| | | } |
| | | |
| | | // New creates a new platform-specific hotkey listener. |
| | |
| | | static volatile double g_lastDownAt[128]; |
| | | static volatile int g_monitorStarted = 0; |
| | | static volatile int g_monitorLaunching = 0; |
| | | static volatile int g_monitorStopping = 0; |
| | | static CFMachPortRef g_eventTap = NULL; |
| | | static CFRunLoopSourceRef g_eventTapSource = NULL; |
| | | static CFRunLoopRef g_eventTapRunLoop = NULL; |
| | | static volatile unsigned long long g_monitorStartAttempts = 0; |
| | | static volatile unsigned long long g_eventTapCreateFailures = 0; |
| | | static volatile unsigned long long g_eventTapDisabledCount = 0; |
| | |
| | | CGEventTapEnable(g_eventTap, true); |
| | | g_monitorStarted = 1; |
| | | g_monitorLaunching = 0; |
| | | g_eventTapRunLoop = CFRunLoopGetCurrent(); |
| | | if (g_eventTapRunLoop != NULL) CFRetain(g_eventTapRunLoop); |
| | | CFRunLoopRun(); |
| | | |
| | | if (g_eventTap != NULL) CGEventTapEnable(g_eventTap, false); |
| | | if (g_eventTapSource != NULL) { |
| | | CFRunLoopRemoveSource(CFRunLoopGetCurrent(), g_eventTapSource, kCFRunLoopCommonModes); |
| | | CFRelease(g_eventTapSource); |
| | | g_eventTapSource = NULL; |
| | | } |
| | | if (g_eventTap != NULL) { |
| | | CFRelease(g_eventTap); |
| | | g_eventTap = NULL; |
| | | } |
| | | if (g_eventTapRunLoop != NULL) { |
| | | CFRelease(g_eventTapRunLoop); |
| | | g_eventTapRunLoop = NULL; |
| | | } |
| | | g_monitorStarted = 0; |
| | | g_monitorStopping = 0; |
| | | } |
| | | |
| | | static int ensureAccessibility(void) { |
| | |
| | | reason:@"PrivateVoice Dictation global hotkey listener"]; |
| | | } |
| | | |
| | | static void endHotkeyActivity(void) { |
| | | if (g_activityToken == nil) return; |
| | | [[NSProcessInfo processInfo] endActivity:g_activityToken]; |
| | | g_activityToken = nil; |
| | | } |
| | | |
| | | static void* eventTapThreadMain(void* arg) { |
| | | (void)arg; |
| | | @autoreleasepool { |
| | |
| | | } |
| | | |
| | | static void startKeyMonitor(void) { |
| | | if (g_monitorStarted || g_monitorLaunching) return; |
| | | if (g_monitorStarted || g_monitorLaunching || g_monitorStopping) return; |
| | | g_monitorLaunching = 1; |
| | | pthread_t thread; |
| | | int err = pthread_create(&thread, NULL, eventTapThreadMain, NULL); |
| | |
| | | return; |
| | | } |
| | | pthread_detach(thread); |
| | | } |
| | | |
| | | static void stopKeyMonitor(void) { |
| | | g_monitorStopping = 1; |
| | | if (g_eventTap != NULL) CGEventTapEnable(g_eventTap, false); |
| | | if (g_eventTapRunLoop != NULL) { |
| | | CFRunLoopStop(g_eventTapRunLoop); |
| | | } |
| | | endHotkeyActivity(); |
| | | } |
| | | |
| | | // isModifierDown polls the current system modifier flags via CGEventSource. |
| | |
| | | return false |
| | | } |
| | | |
| | | func (l *darwinListener) Close() { |
| | | C.stopKeyMonitor() |
| | | } |
| | | |
| | | func (l *darwinListener) wasPressedSince(vk int, since time.Time) bool { |
| | | if since.IsZero() { |
| | | return l.IsKeyDown(vk) |
| | |
| | | func (l *linuxListener) IsAnyOtherKeyPressedSince(excludeVK int, since time.Time) bool { |
| | | return l.IsAnyOtherKeyPressed(excludeVK) |
| | | } |
| | | |
| | | func (l *linuxListener) Close() {} |
| | |
| | | return l.IsAnyOtherKeyPressed(excludeVK) |
| | | } |
| | | |
| | | func (l *windowsListener) Close() {} |
| | | |
| | | func contains(slice []int, val int) bool { |
| | | for _, v := range slice { |
| | | if v == val { |
| | |
| | | logFile *os.File |
| | | ) |
| | | |
| | | const ( |
| | | maxLogBytes = 10 * 1024 * 1024 |
| | | maxLogBackups = 3 |
| | | ) |
| | | |
| | | // Init initializes the file logger in the persistent app data directory. |
| | | func Init() { |
| | | mu.Lock() |
| | | if logFile != nil { |
| | | logFile.Close() |
| | | logFile = nil |
| | | } |
| | | mu.Unlock() |
| | | |
| | | dir := paths.AppSupportDir() |
| | | if err := os.MkdirAll(dir, 0755); err != nil { |
| | | fmt.Fprintf(os.Stderr, "failed to create log dir: %v\n", err) |
| | | return |
| | | } |
| | | path := filepath.Join(dir, "app.log") |
| | | if err := rotateIfNeeded(path); err != nil { |
| | | fmt.Fprintf(os.Stderr, "failed to rotate log file: %v\n", err) |
| | | } |
| | | |
| | | f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) |
| | | if err != nil { |
| | | fmt.Fprintf(os.Stderr, "failed to open log file: %v\n", err) |
| | | return |
| | | } |
| | | mu.Lock() |
| | | defer mu.Unlock() |
| | | logFile = f |
| | | } |
| | | |
| | | func rotateIfNeeded(path string) error { |
| | | info, err := os.Stat(path) |
| | | if err != nil { |
| | | if os.IsNotExist(err) { |
| | | return nil |
| | | } |
| | | return err |
| | | } |
| | | if info.Size() < maxLogBytes { |
| | | return nil |
| | | } |
| | | |
| | | oldest := fmt.Sprintf("%s.%d", path, maxLogBackups) |
| | | if err := os.Remove(oldest); err != nil && !os.IsNotExist(err) { |
| | | return err |
| | | } |
| | | for i := maxLogBackups - 1; i >= 1; i-- { |
| | | from := fmt.Sprintf("%s.%d", path, i) |
| | | to := fmt.Sprintf("%s.%d", path, i+1) |
| | | if err := os.Rename(from, to); err != nil && !os.IsNotExist(err) { |
| | | return err |
| | | } |
| | | } |
| | | return os.Rename(path, path+".1") |
| | | } |
| | | |
| | | func write(level, format string, args ...interface{}) { |
| | | msg := fmt.Sprintf(format, args...) |
| | | ts := time.Now().Format("2006-01-02 15:04:05") |
| | |
| | | |
| | | static NSWindow* g_window = nil; |
| | | static NSWindow* g_dragWindow = nil; |
| | | static int g_windowsOrderedOut = 1; |
| | | |
| | | static float desktopTopY(void) { |
| | | NSScreen* mainScreen = [NSScreen mainScreen]; |
| | |
| | | if (g_dragWindow) { |
| | | [g_dragWindow setFrameOrigin:dragOriginForVisualOrigin(visualOrigin)]; |
| | | } |
| | | } |
| | | |
| | | static void orderOverlayWindowsOutOnce(void) { |
| | | if (g_windowsOrderedOut) return; |
| | | if (g_window) [g_window orderOut:nil]; |
| | | if (g_dragWindow) [g_dragWindow orderOut:nil]; |
| | | g_windowsOrderedOut = 1; |
| | | } |
| | | |
| | | static void markOverlayWindowsVisible(void) { |
| | | g_windowsOrderedOut = 0; |
| | | } |
| | | |
| | | static void storeDragPositionFromVisualOrigin(NSPoint visualOrigin) { |
| | |
| | | [g_window setAlphaValue:eased]; |
| | | |
| | | if (g_state.fadeProgress <= 0 && g_state.fadeTarget == 0) { |
| | | [g_window orderOut:nil]; |
| | | if (g_dragWindow) [g_dragWindow orderOut:nil]; |
| | | orderOverlayWindowsOutOnce(); |
| | | return; |
| | | } |
| | | |
| | |
| | | applyOverlayWindowPolicy(g_dragWindow); |
| | | [g_dragWindow orderFrontRegardless]; |
| | | } |
| | | markOverlayWindowsVisible(); |
| | | } |
| | | |
| | | // Reposition |
| | |
| | | |
| | | g_window = window; |
| | | g_view = view; |
| | | g_windowsOrderedOut = 1; |
| | | |
| | | PrivateVoiceOverlayWindow* dragWindow = [[PrivateVoiceOverlayWindow alloc] |
| | | initWithContentRect:NSMakeRect(0, 0, DRAG_W, DRAG_H) |
| | |
| | | applyOverlayWindowPolicy(g_dragWindow); |
| | | [g_dragWindow orderFrontRegardless]; |
| | | } |
| | | markOverlayWindowsVisible(); |
| | | } |
| | | |
| | | static void overlayShow(void) { |
| | |
| | | import "C" |
| | | |
| | | import ( |
| | | "sync" |
| | | "time" |
| | | "unsafe" |
| | | ) |
| | |
| | | // darwinOverlay uses a native NSWindow + Core Graphics to render |
| | | // the floating indicator, matching the Windows GDI+ implementation. |
| | | type darwinOverlay struct { |
| | | mu sync.Mutex |
| | | dragCb func(int, int) |
| | | created bool |
| | | done chan struct{} |
| | |
| | | } |
| | | |
| | | func (o *darwinOverlay) ensureCreated() { |
| | | if !o.created { |
| | | C.overlayCreate() |
| | | o.mu.Lock() |
| | | if o.created { |
| | | o.mu.Unlock() |
| | | return |
| | | } |
| | | o.created = true |
| | | o.mu.Unlock() |
| | | |
| | | C.overlayCreate() |
| | | time.Sleep(50 * time.Millisecond) |
| | | go o.pollDrag() |
| | | } |
| | | } |
| | | |
| | | func (o *darwinOverlay) Show() { |
| | |
| | | import ( |
| | | "math" |
| | | "runtime" |
| | | "sync" |
| | | "syscall" |
| | | "time" |
| | | "unsafe" |
| | |
| | | posCh chan [2]int |
| | | closeCh chan struct{} |
| | | done chan struct{} |
| | | closeOnce sync.Once |
| | | |
| | | barHeights [nBars]float64 |
| | | animTime float64 |
| | |
| | | func (o *winOverlay) GetPosition() (int, int) { return o.posX, o.posY } |
| | | func (o *winOverlay) Size() (int, int) { return capW, capH } |
| | | func (o *winOverlay) OnDragged(fn func(x, y int)) { o.dragCb = fn } |
| | | func (o *winOverlay) Close() { close(o.closeCh); <-o.done } |
| | | func (o *winOverlay) Close() { |
| | | o.closeOnce.Do(func() { |
| | | close(o.closeCh) |
| | | }) |
| | | <-o.done |
| | | } |
| | | |
| | | func trySend(ch chan bool, v bool) { |
| | | select { |
| New file |
| | |
| | | #!/usr/bin/env bash |
| | | set -euo pipefail |
| | | |
| | | ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" |
| | | WAV_PATH="${1:-"$ROOT_DIR/testdata/perf/baseline.wav"}" |
| | | RUNS="${2:-3}" |
| | | |
| | | if [[ ! -f "$WAV_PATH" ]]; then |
| | | cat >&2 <<EOF |
| | | Missing WAV fixture: $WAV_PATH |
| | | |
| | | Usage: |
| | | scripts/benchmark-recognition.sh /absolute/path/to/baseline.wav 5 |
| | | |
| | | The WAV must be 16kHz mono PCM16. Keep the same fixture across versions for A/B performance comparison. |
| | | EOF |
| | | exit 2 |
| | | fi |
| | | |
| | | cd "$ROOT_DIR" |
| | | go run ./cmd/benchmark-recognition --wav "$WAV_PATH" --runs "$RUNS" |
| | |
| | | ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" |
| | | cd "$ROOT_DIR" |
| | | |
| | | APP_VERSION="2.2.0" |
| | | APP_BUILD="20260702.0303" |
| | | APP_VERSION="2.2.2" |
| | | APP_BUILD="20260703.0335" |
| | | ARCH="${1:-amd64}" |
| | | |
| | | if [[ "$ARCH" != "amd64" ]]; then |
| New file |
| | |
| | | package services |
| | | |
| | | import ( |
| | | "archive/zip" |
| | | "encoding/json" |
| | | "fmt" |
| | | "io" |
| | | "os" |
| | | "path/filepath" |
| | | "runtime" |
| | | "time" |
| | | "voicesnap/internal/config" |
| | | "voicesnap/internal/model" |
| | | "voicesnap/internal/modelselection" |
| | | "voicesnap/internal/paths" |
| | | ) |
| | | |
| | | type DiagnosticsService struct { |
| | | cfg *config.Config |
| | | version string |
| | | } |
| | | |
| | | func NewDiagnosticsService(cfg *config.Config, version string) *DiagnosticsService { |
| | | if cfg == nil { |
| | | cfg = config.Default() |
| | | } |
| | | return &DiagnosticsService{cfg: cfg, version: version} |
| | | } |
| | | |
| | | func (s *DiagnosticsService) GetRuntimeStats() map[string]interface{} { |
| | | var mem runtime.MemStats |
| | | runtime.ReadMemStats(&mem) |
| | | return map[string]interface{}{ |
| | | "version": s.version, |
| | | "goos": runtime.GOOS, |
| | | "goarch": runtime.GOARCH, |
| | | "go_version": runtime.Version(), |
| | | "goroutines": runtime.NumGoroutine(), |
| | | "cpu_count": runtime.NumCPU(), |
| | | "heap_alloc_mb": bytesToMiB(mem.HeapAlloc), |
| | | "heap_sys_mb": bytesToMiB(mem.HeapSys), |
| | | "stack_sys_mb": bytesToMiB(mem.StackSys), |
| | | "next_gc_mb": bytesToMiB(mem.NextGC), |
| | | "num_gc": mem.NumGC, |
| | | "data_dir": paths.AppSupportDir(), |
| | | "models_dir": paths.ModelsRoot(), |
| | | } |
| | | } |
| | | |
| | | func (s *DiagnosticsService) ExportDiagnostics() (string, error) { |
| | | dir := filepath.Join(paths.AppSupportDir(), "diagnostics") |
| | | if err := os.MkdirAll(dir, 0755); err != nil { |
| | | return "", err |
| | | } |
| | | outPath := filepath.Join(dir, "privatevoice-diagnostics-"+time.Now().Format("20060102-150405")+".zip") |
| | | |
| | | out, err := os.Create(outPath) |
| | | if err != nil { |
| | | return "", err |
| | | } |
| | | defer out.Close() |
| | | |
| | | zw := zip.NewWriter(out) |
| | | if err := s.addJSON(zw, "runtime.json", s.GetRuntimeStats()); err != nil { |
| | | zw.Close() |
| | | return "", err |
| | | } |
| | | if err := s.addJSON(zw, "config.redacted.json", redactedConfig(s.cfg)); err != nil { |
| | | zw.Close() |
| | | return "", err |
| | | } |
| | | if err := s.addJSON(zw, "models.json", modelDiagnostics(s.cfg)); err != nil { |
| | | zw.Close() |
| | | return "", err |
| | | } |
| | | if err := addLogFiles(zw); err != nil { |
| | | zw.Close() |
| | | return "", err |
| | | } |
| | | if err := zw.Close(); err != nil { |
| | | return "", err |
| | | } |
| | | return outPath, nil |
| | | } |
| | | |
| | | func (s *DiagnosticsService) addJSON(zw *zip.Writer, name string, value interface{}) error { |
| | | data, err := json.MarshalIndent(value, "", " ") |
| | | if err != nil { |
| | | return err |
| | | } |
| | | w, err := zw.Create(name) |
| | | if err != nil { |
| | | return err |
| | | } |
| | | _, err = w.Write(append(data, '\n')) |
| | | return err |
| | | } |
| | | |
| | | func addLogFiles(zw *zip.Writer) error { |
| | | for _, name := range []string{"app.log", "app.log.1", "app.log.2", "app.log.3"} { |
| | | path := filepath.Join(paths.AppSupportDir(), name) |
| | | if err := addFileIfExists(zw, path, filepath.Join("logs", name)); err != nil { |
| | | return err |
| | | } |
| | | } |
| | | return nil |
| | | } |
| | | |
| | | func addFileIfExists(zw *zip.Writer, path, zipName string) error { |
| | | in, err := os.Open(path) |
| | | if err != nil { |
| | | if os.IsNotExist(err) { |
| | | return nil |
| | | } |
| | | return err |
| | | } |
| | | defer in.Close() |
| | | |
| | | info, err := in.Stat() |
| | | if err != nil { |
| | | return err |
| | | } |
| | | header, err := zip.FileInfoHeader(info) |
| | | if err != nil { |
| | | return err |
| | | } |
| | | header.Name = zipName |
| | | header.Method = zip.Deflate |
| | | w, err := zw.CreateHeader(header) |
| | | if err != nil { |
| | | return err |
| | | } |
| | | _, err = io.Copy(w, in) |
| | | return err |
| | | } |
| | | |
| | | func redactedConfig(cfg *config.Config) map[string]interface{} { |
| | | if cfg == nil { |
| | | cfg = config.Default() |
| | | } |
| | | return map[string]interface{}{ |
| | | "HotkeyVK": cfg.HotkeyVK, |
| | | "HotkeyMode": cfg.HotkeyMode, |
| | | "AutoHide": cfg.AutoHide, |
| | | "SoundFeedback": cfg.SoundFeedback, |
| | | "HideDockIcon": cfg.HideDockIcon, |
| | | "CopyToClipboard": cfg.CopyToClipboard, |
| | | "DeviceNameSet": cfg.DeviceName != "", |
| | | "SelectedModelID": cfg.SelectedModelID, |
| | | "ModelSelectionMode": cfg.ModelSelectionMode, |
| | | "LanguageMode": cfg.LanguageMode, |
| | | "LanguageID": cfg.LanguageID, |
| | | } |
| | | } |
| | | |
| | | func modelDiagnostics(cfg *config.Config) map[string]interface{} { |
| | | current := modelselection.Resolve(cfg, nil) |
| | | items := make([]map[string]interface{}, 0) |
| | | for _, profile := range model.ListModelProfiles() { |
| | | resolved, err := model.ResolveModel(profile.ID) |
| | | item := map[string]interface{}{ |
| | | "model_id": profile.ID, |
| | | "display_name": profile.DisplayName, |
| | | "backend": profile.BackendKind, |
| | | "tier": profile.Tier, |
| | | "num_threads": profile.NumThreads, |
| | | "current": profile.ID == current.ModelID, |
| | | "supported": model.IsModelSupportedInCurrentBuild(profile.ID), |
| | | "download_size": profile.ApproxSize, |
| | | } |
| | | if err != nil { |
| | | item["status"] = "resolve_error" |
| | | item["error"] = err.Error() |
| | | } else { |
| | | item["status"] = string(resolved.Status) |
| | | item["usable"] = resolved.IsUsable() |
| | | item["source_dir_kind"] = resolved.SourceDirKind |
| | | item["missing"] = resolved.Missing |
| | | item["problems"] = resolved.Problems |
| | | } |
| | | items = append(items, item) |
| | | } |
| | | return map[string]interface{}{ |
| | | "current_model_id": current.ModelID, |
| | | "selection_mode": current.SelectionMode, |
| | | "effective_language_id": current.LanguageSettings.EffectiveLanguageID, |
| | | "fallback_reason": current.FallbackReason, |
| | | "has_any_usable_model": model.HasAnyUsableModel(), |
| | | "supported_build_note": fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH), |
| | | "models": items, |
| | | } |
| | | } |
| | | |
| | | func bytesToMiB(value uint64) float64 { |
| | | return float64(value) / 1024.0 / 1024.0 |
| | | } |
| | |
| | | # Changelog |
| | | |
| | | ## v2.2.2 (2026-07-03) |
| | | |
| | | ### 性能与稳定性恢复 |
| | | |
| | | - **恢复 2.2.1 本地体验包中的性能修复**:合入慢识别诊断、流水线耗时记录、识别 benchmark 工具和诊断服务,用于定位模型、录音、识别、粘贴和 UI 生命周期耗时。 |
| | | - **强化引擎生命周期保护**:补入引擎重载串行化、释放保护和跨平台占位实现,降低频繁切换语言 / 模型或长时间运行时的状态竞争风险。 |
| | | - **补入录音、热键、overlay 和事件订阅清理**:收敛 recorder、hotkey、overlay 与前端事件订阅的生命周期泄漏风险,改善长时间真实使用稳定性。 |
| | | - **保留正式版本口径**:本版本恢复的是 `2.2.1 build 20260702.1737` 的修复内容,但正式候选版本升为 `2.2.2`,避免与旧 macOS arm64 local 体验包混淆。 |
| | | |
| | | ### 构建 |
| | | |
| | | - build: `20260703.0335` |
| | | - 说明: 本版本为 2.2.1 性能 / 稳定性修复恢复后的正式候选源码目标;进入四包冻结前仍需代码审核、QA 和发布门禁。 |
| | | |
| | | --- |
| | | |
| | | ## v2.2.0 (2026-07-02) |
| | | |
| | | ### 粤语语言版本 |
| | |
| | | ## Project |
| | | |
| | | - Product: Wails v3 / Go desktop dictation app. |
| | | - Git root: `/Users/ar/Projects/PrivateVoice2/03-O`. |
| | | - Source root: `/Users/ar/Projects/PrivateVoice2/03-O/C1.source/privatevoice.src`. |
| | | - Git root: `/Users/ar/Projects/PrivateVoice/03-O`. |
| | | - Source root: `/Users/ar/Projects/PrivateVoice/03-O/C1.source/privatevoice.src`. |
| | | - Current branch purpose: Round 6 Cantonese language version real-user test package based on `v2.2.0-build20260702.0303`. |
| | | - Protected behavior: existing macOS App Store, local DMG, Monterey compatibility, Windows preview, model download safety, and paste/clipboard protection must not be changed without a scoped task and QA plan. |
| | | |
| | | ## Automatic Codebase Memory |
| | | |
| | | - Status: `enabled`. |
| | | - Tool: `codebase-memory-mcp` 0.8.1. |
| | | - Current project name after path unification: `Users-ar-Projects-PrivateVoice-03-O`. |
| | | - Existing artifact note: `.codebase-memory/artifact.json` may still record legacy project name `Users-ar-Projects-PrivateVoice2-03-O` until the graph is regenerated. |
| | | - Shared artifact: `.codebase-memory/graph.db.zst`; metadata: `.codebase-memory/artifact.json`. |
| | | - Scope: `/Users/ar/Projects/PrivateVoice/03-O`. |
| | | - Refresh command: `codebase-memory-mcp cli index_repository '{"repo_path":"/Users/ar/Projects/PrivateVoice/03-O"}'`. |
| | | - Current `indexed_at`, commit, node count, and edge count are recorded in `.codebase-memory/artifact.json`. |
| | | - Excluded by indexer: `.git`, `K3.运营与发布资料/legacy-运营资料/PrivateVoice-26.0604上架`, `K3.运营与发布资料/legacy-运营资料/运营文档`, `C1.source/privatevoice.src/frontend/dist`, `C1.source/privatevoice.src/frontend/node_modules`. |
| | | - Query example after regenerating graph from Git root: `codebase-memory-mcp cli search_graph '{"project":"Users-ar-Projects-PrivateVoice-03-O","query":"model registry","limit":5}'`. |
| | | - This automatic graph is an exploration aid only; it does not replace source reading, tests, QA evidence, or this `CODEGRAPH.md`. |
| | | - Refresh rule: regenerate `.codebase-memory/` before each source freeze, version freeze, formal rollback-capable test package, or release package; daily Coder work-in-progress changes do not force a refresh. |
| | | - Freeze record requirement: record the refresh command, `.codebase-memory/artifact.json` path, indexed commit, freeze commit, and whether the two commits match or differ only by `.codebase-memory/` artifact updates. |
| | | |
| | | ## Core Entrypoints |
| | | |
| | | - `C1.source/privatevoice.src/main.go`: process startup, data migration, logger, single-instance guard. |
| | |
| | | |
| | | - 当前源码根:`03-O/C1.source/privatevoice.src` |
| | | - 工程地图:`03-O/CODEGRAPH.md` |
| | | - 自动代码知识图谱:`03-O/.codebase-memory/` |
| | | - 主要技术栈:Go、Wails v3、Svelte 5、Vite、sherpa-onnx、malgo。 |
| | | |
| | | ## 产品成果 |
| New file |
| | |
| | | # R221-1 开发自查 |
| | | |
| | | ## 任务 |
| | | |
| | | - 任务 ID:`R221-1` |
| | | - Review Gate ID:`R221-2-GATE-20260703.0335` |
| | | - 目标:把归档 `2.2.1 build 20260702.1737` 中的性能与稳定性修复合入当前活跃源码根,并按 Owner 已确认口径升为 `2.2.2` 新 build。 |
| | | - 不做范围:不生成四包、不创建 release tag、不把归档 arm64 local DMG 改称当前正式候选、不跳过代码审核 / QA / 发布门禁。 |
| | | |
| | | ## 起点 |
| | | |
| | | - 当前 Git 根:`/Users/ar/Projects/PrivateVoice/03-O` |
| | | - 当前活跃源码根:`03-O/C1.source/privatevoice.src` |
| | | - 恢复快照:`03-O/C1.source/privatevoice-2.2.1-recovered.src` |
| | | - 归档仓库:`/Users/ar/Downloads/0702codex-move/02-project2/26.0702-Archive/PrivateVoice` |
| | | - 归档修复 commit:`ada0f669729d7a30b0112fe0af34a65cdfde2a32` |
| | | - 对比基线:`0ffbf1935c9d091cce22a5583275ac0902f7a693` |
| | | |
| | | ## 已执行 |
| | | |
| | | - 将归档 commit 中 `privatevoice.src/` 下的变更文件复制到当前活跃源码根。 |
| | | - 新增 / 恢复: |
| | | - `C1.source/privatevoice.src/cmd/benchmark-recognition/main.go` |
| | | - `C1.source/privatevoice.src/scripts/benchmark-recognition.sh` |
| | | - `C1.source/privatevoice.src/services/diagnostics_service.go` |
| | | - 更新性能 / 稳定性相关代码: |
| | | - App 识别流水线、慢识别日志、诊断服务、benchmark 入口。 |
| | | - Recorder、engine、hotkey、overlay、logger、前端事件订阅和设置页生命周期处理。 |
| | | - 将版本元数据从归档体验包的 `2.2.1 / 20260702.1737` 改为正式候选目标 `2.2.2 / 20260703.0335`: |
| | | - `C1.source/privatevoice.src/app.go` |
| | | - `C1.source/privatevoice.src/build/config.yml` |
| | | - `C1.source/privatevoice.src/build/darwin/Info.plist` |
| | | - `C1.source/privatevoice.src/frontend/package.json` |
| | | - `C1.source/privatevoice.src/frontend/package-lock.json` |
| | | - `C1.source/privatevoice.src/scripts/build-windows-preview.sh` |
| | | - 更新 `CHANGELOG.md`,新增 `v2.2.2 (2026-07-03)` 记录。 |
| | | |
| | | ## 已验证 |
| | | |
| | | 通过: |
| | | |
| | | ```bash |
| | | git -C '03-O' diff --check |
| | | npm --prefix '03-O/C1.source/privatevoice.src/frontend' run build |
| | | gofmt -w <changed-go-files> |
| | | go test ./... |
| | | ``` |
| | | |
| | | 前端 build 通过,仍有既有 Svelte a11y warning: |
| | | |
| | | - `src/components/update/UpdateDialog.svelte` |
| | | - `src/components/shared/ToggleSwitch.svelte` |
| | | |
| | | Go 门禁已在 R221-1A 后续补跑通过;`go test ./...` 输出存在 macOS 链接 warning:当前 Go / CLT 在 macOS 26 环境生成的临时 object 标记高于 `macOS-11.0` 链接目标,且 `@rpath/libonnxruntime.1.24.4.dylib` 标记最低 macOS 14.0。该 warning 未导致测试失败,但代码审核 / 发布前需评估目标系统兼容口径。 |
| | | |
| | | ## R221-1A 补修记录 |
| | | |
| | | - Review Gate ID:`R221-2-GATE-20260703.0335` |
| | | - 处理范围:只处理 R221-2 `CHANGES_REQUESTED` 阻断项;未生成安装包、未创建 tag、未进入 QA、未扩展官网 / Windows 正式版 / Monterey backlog。 |
| | | |
| | | 已处理: |
| | | |
| | | - 已回退默认 UI fallback 从中文改为英文的产品外变更: |
| | | - `C1.source/privatevoice.src/frontend/src/App.svelte` |
| | | - `C1.source/privatevoice.src/frontend/src/components/settings/LanguagePage.svelte` |
| | | - `C1.source/privatevoice.src/frontend/src/lib/i18n/index.ts` |
| | | - 已将新增文件纳入待审 diff / 后续提交范围:使用 `git add -N -- ...` 标记 intent-to-add,复核状态为 ` A`: |
| | | - `C1.source/privatevoice.src/cmd/benchmark-recognition/main.go` |
| | | - `C1.source/privatevoice.src/scripts/benchmark-recognition.sh` |
| | | - `C1.source/privatevoice.src/services/diagnostics_service.go` |
| | | |
| | | 本轮验证: |
| | | |
| | | ```bash |
| | | git diff --check |
| | | gofmt -w <changed-go-files> |
| | | go test ./... |
| | | ``` |
| | | |
| | | 结果: |
| | | |
| | | - `git diff --check`:PASS。 |
| | | - `gofmt -w <changed-go-files>`:PASS,已覆盖当前待审 diff 中的 17 个 `.go` 变更文件。 |
| | | - `go test ./...`:PASS。 |
| | | - 环境:`go version go1.26.4 darwin/arm64`;Apple clang `17.0.0`,Command Line Tools 路径 `/Library/Developer/CommandLineTools`。 |
| | | |
| | | 剩余风险: |
| | | |
| | | - `go test ./...` 输出 macOS 链接 warning:当前 Go / CLT 生成物标记高于 `macOS-11.0` 链接目标,且 `@rpath/libonnxruntime.1.24.4.dylib` 标记最低 macOS 14.0;未导致测试失败,但需代码审核 / 发布流程评估对 Monterey 或更低版本兼容口径的影响。 |
| | | |
| | | ## 风险点 |
| | | |
| | | - 本轮恢复的是历史修复 commit 的源码内容,尚未完成代码审核。 |
| | | - Go 编译 / 测试已在本环境补跑通过,但存在 macOS 链接 warning,需在代码审核 / 发布前判断是否影响目标系统兼容承诺。 |
| | | - `2.2.2` 四包、manifest、SHA256、tag、codebase-memory 刷新均未执行,必须在 R221-2 / R221-3 PASS 后由发布流程完成。 |
| | | |
| | | ## 交接 |
| | | |
| | | - 当前 R221-1A 结论:新增文件纳入 diff、UI fallback 回退、`gofmt` 与 `go test ./...` 均已完成;可针对同一 Review Gate ID `R221-2-GATE-20260703.0335` 请求复审。 |
| | | - 下一步:PMO 重新派发 R221-2 复审;R221-2 PASS 前不得进入 QA / 发布冻结。 |
| | | - 代码审核重点:恢复 diff 是否完整、版本口径是否正确、性能诊断和生命周期修复是否引入回归、Windows preview 构建脚本是否仍符合规则。 |
| New file |
| | |
| | | # R221-3 QA Evidence |
| | | |
| | | ## 结论 |
| | | |
| | | - 任务 ID:`R221-3` |
| | | - Review Gate ID:`R221-2-GATE-20260703.0335` |
| | | - QA 结论:`PASS` |
| | | - 时间:`2026-07-03 05:10:25 +0800` |
| | | - 范围:验证恢复后的当前源码与 `2.2.2 build 20260703.0335` 候选构建路径。 |
| | | - 不做范围:不修改源码、不创建 tag、不生成正式发布包、不把本地 QA evidence 当作发布候选。 |
| | | |
| | | QA 允许 PMO 解锁 `R221-4` 发布冻结流程。该结论不等于 Windows 正式版通过、不等于 Windows 真机 QA 通过、不等于 Monterey 兼容通过、不等于 notarized 官网正式发布通过。 |
| | | |
| | | ## 环境 |
| | | |
| | | - 项目根:`/Users/ar/Projects/PrivateVoice` |
| | | - Git 根:`/Users/ar/Projects/PrivateVoice/03-O` |
| | | - 源码根:`/Users/ar/Projects/PrivateVoice/03-O/C1.source/privatevoice.src` |
| | | - 分支:`codex/x-asr-experiment-2.1.29` |
| | | - HEAD:`abb469859662c285fac758c042812e0da943728f` |
| | | - macOS:`26.1 (25B78)` |
| | | - Go:`go version go1.26.4 darwin/arm64` |
| | | - Node:`v23.11.1` |
| | | - npm:`10.9.2` |
| | | |
| | | ## 工作区核对 |
| | | |
| | | - `git status --short --branch`:当前分支 ahead 1,存在 R221 待审源码 / 文档 diff。 |
| | | - `git diff --cached --name-status`:无输出,当前无 staged diff。 |
| | | - `git diff --stat`:43 files changed,包含 R221 恢复源码、版本元数据、前端生命周期清理、Windows preview 脚本、CODEGRAPH / 管理文档等。 |
| | | - 新增源码文件已在 diff 范围:`cmd/benchmark-recognition/main.go`、`scripts/benchmark-recognition.sh`、`services/diagnostics_service.go`。 |
| | | |
| | | ## 标准源码门禁 |
| | | |
| | | | 项 | 命令 | 结果 | 备注 | |
| | | | --- | --- | --- | --- | |
| | | | 空白检查 | `git diff --check` | PASS | 无输出 | |
| | | | gofmt 状态 | `git diff --name-only -- C1.source/privatevoice.src | grep -E '\.go$' | xargs gofmt -l` | PASS | 无输出,表示当前变更 Go 文件格式干净 | |
| | | | Go 测试 | `go test ./...` | PASS with warning | 复现 macOS 链接 warning,未导致测试失败 | |
| | | | 前端构建 | `npm --prefix frontend run build` | PASS with warning | 复现既有 Svelte a11y warning:`UpdateDialog.svelte`、`ToggleSwitch.svelte` | |
| | | |
| | | ## 核心路径核对 |
| | | |
| | | | 路径 | Evidence | 结论 | |
| | | | --- | --- | --- | |
| | | | 核心语音输入 / 录音 / 识别流水线 | `go test ./...`;`go test ./internal/model ./internal/modelselection ./internal/language ./internal/engine ./internal/audio ./services`;`go test . -run 'TestSlowRecognitionReason|TestRecognize|TestHotkey|TestLiveCaption|TestWaitForHotkeyRelease|TestReload|TestOverlay|TestVersion'` | 自动化层 PASS;未在本机做真实麦克风、全局热键、粘贴上屏手动测试 | |
| | | | 设置 / 热键配置 | 前端 build PASS;diff 核对 `GeneralPage.svelte`、`HotkeysPage.svelte` 增加 `onDestroy` 和 `clearHotkeyCapture()` | 代码级生命周期清理路径通过;未做真实窗口交互测试 | |
| | | | 语言 / 模型选择 | `go test ./internal/model ./internal/modelselection ./internal/language ./internal/engine ./internal/audio ./services` PASS;`go test ./internal/model -run 'Test.*Yue|Test.*Cantonese|Test.*Language'` PASS | 自动化层覆盖语言 / 模型解析 | |
| | | | 粤语 `yue-HK` | `rg` 核对 `CantoneseLanguageID = "yue-HK"`、`SenseVoiceYueModelID = "sensevoice-yue-2025-09-09"`、`LanguageParam: "yue"`;`internal/engine` 测试 PASS | 粤语注册与 SenseVoice `yue` 参数路径通过 | |
| | | | 前端生命周期风险 | diff 核对 `App.svelte` 释放 Wails event off hooks 和 `hashchange`;`LanguagePage.svelte` 释放模型下载事件;`PermissionsPage.svelte` 释放 interval / timeout;`SettingsWindow.svelte` 释放 store subscription | 生命周期清理路径通过构建与代码核对;未做长时间打开 / 反复开关窗口的视觉 smoke | |
| | | |
| | | ## Windows Preview 适配回归 |
| | | |
| | | | 项 | 命令 / 证据 | 结果 | |
| | | | --- | --- | --- | |
| | | | Windows cross smoke | `GOOS=windows GOARCH=amd64 CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc CXX=x86_64-w64-mingw32-g++ go test -exec=/usr/bin/true ./...` | NOT COVERED:本机缺 `x86_64-w64-mingw32-gcc`,命令失败于工具链,不是源码测试失败 | |
| | | | 编译器检查 | `command -v x86_64-w64-mingw32-gcc` | 未找到 | |
| | | | Windows build 脚本语法 | `bash -n scripts/build-windows-preview.sh` | PASS | |
| | | | Windows evidence verifier 语法 | `bash -n scripts/verify-windows-qa-evidence.sh` | PASS | |
| | | | 版本 / build | `scripts/build-windows-preview.sh` 内 `APP_VERSION="2.2.2"`、`APP_BUILD="20260703.0335"` | PASS | |
| | | | preview 口径 | `README-WINDOWS-PREVIEW.txt` 模板声明 unsigned preview、不是安装器、需 Windows 10/11 QA | PASS | |
| | | | evidence 要求 | `verify-windows-qa-evidence.sh` 要求 `qa-results.tsv`、`app.log`、`qa-target-dictation-text.txt`、`system-info.txt`,成功时输出 `WINDOWS_QA_EVIDENCE_PASS` | PASS | |
| | | |
| | | QA 判断:Windows preview 构建路径与 evidence 规则可进入 `R221-4` 发布冻结环节,但发布 / 环境窗口必须补齐 MinGW 工具链并实际生成 / 验证 Windows preview ZIP。不能把 Windows preview 说成正式 Windows 版。 |
| | | |
| | | ## 版本与构建路径 |
| | | |
| | | - `app.go`:`appVersion = "2.2.2"`,`appBuild = "20260703.0335"`。 |
| | | - `build/config.yml`:`version: "2.2.2"`。 |
| | | - `build/darwin/Info.plist`:`CFBundleShortVersionString = 2.2.2`,`CFBundleVersion = 20260703.0335`,`LSMinimumSystemVersion = 14.0`。 |
| | | - `frontend/package.json` / `package-lock.json`:`2.2.2`。 |
| | | - `scripts/build-windows-preview.sh`:`2.2.2 / 20260703.0335`。 |
| | | - `CHANGELOG.md`:已有 `v2.2.2 (2026-07-03)`,明确恢复 `2.2.1` 内容但正式候选升为 `2.2.2`。 |
| | | - `bash -n scripts/build-local-macos.sh`、`bash -n scripts/build-local-macos-universal.sh`、`bash -n scripts/build-release-macos.sh`:PASS。 |
| | | |
| | | ## macOS Warning / Monterey 判断 |
| | | |
| | | `go test ./...` 输出 macOS 链接 warning: |
| | | |
| | | - Go / CLT 临时 object 标记为 macOS 26.0,高于链接目标 macOS 11.0。 |
| | | - `@rpath/libonnxruntime.1.24.4.dylib` 最低 macOS 14.0。 |
| | | |
| | | QA 判断: |
| | | |
| | | - 不阻塞当前主线 `2.2.2`,因为当前 mainline macOS 包口径为 `LSMinimumSystemVersion = 14.0`,且 Go 测试已通过。 |
| | | - 不得据此声称 Monterey 兼容。若 Owner 需要覆盖 macOS 12 / Monterey,必须单独升级 Monterey QA / 构建任务,至少验证 `build-local-macos-monterey.sh`、对应依赖最低系统和真机安装启动。 |
| | | |
| | | ## 未覆盖风险 |
| | | |
| | | - 未做真实麦克风录音、全局热键、Notepad / 输入框粘贴、overlay 视觉稳定性、菜单栏 / Dock 图标策略的手动 smoke。 |
| | | - 未做 Windows 10/11 真机 QA,未收回 `WINDOWS_QA_EVIDENCE_PASS` evidence zip。 |
| | | - 未跑 Windows cross smoke,原因是本机缺 `x86_64-w64-mingw32-gcc`。 |
| | | - 未做 Monterey 验证。 |
| | | - 未做 notarization、Developer ID 官网发布链路、下载页 / 授权 / 更新 / 回滚链路验证。 |
| | | |
| | | 这些风险不阻塞 `R221-4` 进入发布冻结流程,但必须在对应发布 / 平台任务中继续关闭;其中 Windows ZIP 生成必须先补齐 MinGW 工具链。 |
| | |
| | | ## 来源 |
| | | |
| | | - 源项目:`/Users/ar/Projects/PrivateVoice` |
| | | - 新项目:`/Users/ar/Projects/PrivateVoice2` |
| | | - 当前项目:`/Users/ar/Projects/PrivateVoice` |
| | | - 历史命名:`PrivateVoice2` 是迁移期间旧命名,不再作为当前路径。 |
| | | - 源分支:`codex/x-asr-experiment-2.1.29` |
| | | - 固定点:`0ffbf1935c9d091cce22a5583275ac0902f7a693` |
| | | - Tag:`v2.2.0-build20260702.0303-real-user-test` |
| | | |
| | | ## 当前目标 |
| | | |
| | | 让新 Codex 窗口可以只读 `PrivateVoice2` 文件继续工作,不依赖旧项目长会话。 |
| | | 让新 Codex 窗口可以只读 `/Users/ar/Projects/PrivateVoice` 文件继续工作,不依赖旧项目长会话。 |
| | | |
| | | ## 已完成 |
| | | |
| | |
| | | |
| | | ## Reactivation Prompt |
| | | |
| | | 我们从这份 handoff 继续。请先读取本文件,再检查 `/Users/ar/Projects/PrivateVoice2/AGENTS.md`、`PROJECT.md`、`INDEX.md`、`02-P/进度地图.md`、`03-O/CODEGRAPH.md` 和当前 Git 状态,核对哪些结论仍然成立,然后从“下一步”继续推进;不要假设旧 session 上下文仍然可用。 |
| | | 我们从这份 handoff 继续。请先读取本文件,再检查 `/Users/ar/Projects/PrivateVoice/AGENTS.md`、`PROJECT.md`、`INDEX.md`、`02-P/进度地图.md`、`03-O/CODEGRAPH.md` 和当前 Git 状态,核对哪些结论仍然成立,然后从“下一步”继续推进;不要假设旧 session 上下文仍然可用。 |
| | |
| | | |
| | | ## 源项目恢复 |
| | | |
| | | 本次迁移没有修改、移动、删除或归档 `/Users/ar/Projects/PrivateVoice` 内的任何材料。需要恢复旧项目材料时,直接回到源路径读取。 |
| | | 本次迁移不再使用 `PrivateVoice2` 作为当前路径。当前项目根为 `/Users/ar/Projects/PrivateVoice`;旧项目材料已作为历史证据保留在本项目结构和 legacy 区内。 |
| | | |
| | | ## 新项目结构性移动 |
| | | |
| | | 新项目内发生的结构性移动记录在 `moved-assets.jsonl`。如果要恢复旧仓库布局,可在 `03-O` 中用 Git 历史查看迁移前路径: |
| | | |
| | | ```bash |
| | | cd /Users/ar/Projects/PrivateVoice2/03-O |
| | | cd /Users/ar/Projects/PrivateVoice/03-O |
| | | git log --oneline --follow -- C1.source/privatevoice.src/main.go |
| | | ``` |
| | | |
| | |
| | | `C2.builds` 和 `K3.运营与发布资料/legacy-运营资料` 中的本地二进制/素材文件默认不提交 Git。若丢失,可从源项目重新复制: |
| | | |
| | | ```bash |
| | | rsync -a /Users/ar/Projects/PrivateVoice/Release/PrivateVoice-Dictation-2.2.0-build20260702.0303/ /Users/ar/Projects/PrivateVoice2/03-O/C2.builds/PrivateVoice-Dictation-2.2.0-build20260702.0303/ |
| | | rsync -a /Users/ar/Projects/PrivateVoice/Release/PrivateVoice-Dictation-2.2.0-build20260702.0303/ /Users/ar/Projects/PrivateVoice/03-O/C2.builds/PrivateVoice-Dictation-2.2.0-build20260702.0303/ |
| | | ``` |
| | |
| | | # 会话主题索引 |
| | | |
| | | 旧项目原始 session 正文不迁入新项目正文。本目录只保留对接手仍有价值的主题摘要和来源索引。 |
| | | 本目录是旧项目瘦身迁移时生成的压缩主题摘要 / 接手知识包,不是 PrivateVoice 的常规会话主题汇编目录。 |
| | | |
| | | 旧项目原始 session 正文不迁入新项目正文。本目录只保留对接手仍有价值的主题摘要和来源索引。PrivateVoice 未来常规会话主题汇编必须写入 `/Users/ar/Projects/PrivateVoice/.codex-sessionhistory/00-Topics.md` 和 `/Users/ar/Projects/PrivateVoice/.codex-sessionhistory/_topics/`。 |
| | | |
| | | | 主题 | 文档 | 时间范围 | 说明 | |
| | | | --- | --- | --- | --- | |
| | |
| | | |
| | | ## 验证目标 |
| | | |
| | | 验证 `PrivateVoice2` 迁移包是否可接手。该结论不等同产品 QA PASS、正式发布 PASS 或官网销售链路 PASS。 |
| | | 验证当前 `PrivateVoice` 迁移包是否可接手。该结论不等同产品 QA PASS、正式发布 PASS 或官网销售链路 PASS。 |
| | | |
| | | ## 结果 |
| | | |
| | |
| | | |
| | | ## 接手目标 |
| | | |
| | | 从 `PrivateVoice2` 文件系统接手项目,不依赖旧长会话上下文。 |
| | | 从 `/Users/ar/Projects/PrivateVoice` 文件系统接手项目,不依赖旧长会话上下文。`PrivateVoice2` 只是迁移期间旧命名,不再作为当前路径。 |
| | | |
| | | ## 必读顺序 |
| | | |
| | | 1. `/Users/ar/Projects/PrivateVoice2/AGENTS.md` |
| | | 2. `/Users/ar/Projects/PrivateVoice2/PROJECT.md` |
| | | 3. `/Users/ar/Projects/PrivateVoice2/INDEX.md` |
| | | 4. `/Users/ar/Projects/PrivateVoice2/02-P/进度地图.md` |
| | | 5. `/Users/ar/Projects/PrivateVoice2/02-P/事件地图.md` |
| | | 6. `/Users/ar/Projects/PrivateVoice2/03-O/CODEGRAPH.md` |
| | | 7. `/Users/ar/Projects/PrivateVoice2/03-O/K2.项目管理/迁移资产清单.md` |
| | | 1. `/Users/ar/Projects/PrivateVoice/AGENTS.md` |
| | | 2. `/Users/ar/Projects/PrivateVoice/PROJECT.md` |
| | | 3. `/Users/ar/Projects/PrivateVoice/INDEX.md` |
| | | 4. `/Users/ar/Projects/PrivateVoice/02-P/进度地图.md` |
| | | 5. `/Users/ar/Projects/PrivateVoice/02-P/事件地图.md` |
| | | 6. `/Users/ar/Projects/PrivateVoice/03-O/CODEGRAPH.md` |
| | | 7. `/Users/ar/Projects/PrivateVoice/03-O/K2.项目管理/迁移资产清单.md` |
| | | |
| | | ## 当前事实快照 |
| | | |
| | |
| | | ## 接手自检命令 |
| | | |
| | | ```bash |
| | | cd /Users/ar/Projects/PrivateVoice2/03-O |
| | | cd /Users/ar/Projects/PrivateVoice/03-O |
| | | git status --short --branch |
| | | git diff --check |
| | | cd C1.source/privatevoice.src |
| | |
| | | |
| | | ## 来源与目标 |
| | | |
| | | - 来源项目:`/Users/ar/Projects/PrivateVoice` |
| | | - 新项目:`/Users/ar/Projects/PrivateVoice2` |
| | | - 来源项目:旧 PrivateVoice 项目材料 |
| | | - 当前项目:`/Users/ar/Projects/PrivateVoice` |
| | | - 历史命名:`PrivateVoice2` 是迁移期间旧命名,不再作为当前路径。 |
| | | - 迁移时间:2026-07-02 |
| | | - 基线:`0ffbf1935c9d091cce22a5583275ac0902f7a693` / `v2.2.0-build20260702.0303-real-user-test` |
| | | |
| | |
| | | ## 开发入口 |
| | | |
| | | ```bash |
| | | cd /Users/ar/Projects/PrivateVoice2/03-O/C1.source/privatevoice.src |
| | | cd /Users/ar/Projects/PrivateVoice/03-O/C1.source/privatevoice.src |
| | | npm --prefix frontend run build |
| | | go test ./... |
| | | ``` |