Ariver
2026-07-03 5fbe9789faf05ce99bbff6ec4cd0b905519acbd0
Freeze R221 2.2.2 source baseline
39 files modified
5 files added
1484 ■■■■■ changed files
.gitignore 5 ●●●●● patch | view | raw | blame | history
AGENTS.md 25 ●●●●● patch | view | raw | blame | history
C1.source/privatevoice.src/app.go 260 ●●●● patch | view | raw | blame | history
C1.source/privatevoice.src/app_live_caption_test.go 101 ●●●●● patch | view | raw | blame | history
C1.source/privatevoice.src/build/config.yml 2 ●●● patch | view | raw | blame | history
C1.source/privatevoice.src/build/darwin/Info.plist 4 ●●●● patch | view | raw | blame | history
C1.source/privatevoice.src/cmd/benchmark-recognition/main.go 115 ●●●●● patch | view | raw | blame | history
C1.source/privatevoice.src/frontend/package-lock.json 4 ●●●● patch | view | raw | blame | history
C1.source/privatevoice.src/frontend/package.json 2 ●●● patch | view | raw | blame | history
C1.source/privatevoice.src/frontend/src/App.svelte 40 ●●●●● patch | view | raw | blame | history
C1.source/privatevoice.src/frontend/src/components/onboarding/OnboardingView.svelte 9 ●●●● patch | view | raw | blame | history
C1.source/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte 22 ●●●●● patch | view | raw | blame | history
C1.source/privatevoice.src/frontend/src/components/settings/HotkeysPage.svelte 22 ●●●●● patch | view | raw | blame | history
C1.source/privatevoice.src/frontend/src/components/settings/LanguagePage.svelte 16 ●●●● patch | view | raw | blame | history
C1.source/privatevoice.src/frontend/src/components/settings/PermissionsPage.svelte 17 ●●●● patch | view | raw | blame | history
C1.source/privatevoice.src/frontend/src/components/settings/SettingsWindow.svelte 5 ●●●●● patch | view | raw | blame | history
C1.source/privatevoice.src/internal/audio/recorder.go 41 ●●●●● patch | view | raw | blame | history
C1.source/privatevoice.src/internal/audio/recorder_test.go 21 ●●●●● patch | view | raw | blame | history
C1.source/privatevoice.src/internal/engine/engine.go 14 ●●●●● patch | view | raw | blame | history
C1.source/privatevoice.src/internal/engine/engine_darwin.go 58 ●●●●● patch | view | raw | blame | history
C1.source/privatevoice.src/internal/engine/engine_linux.go 28 ●●●●● patch | view | raw | blame | history
C1.source/privatevoice.src/internal/engine/engine_windows.go 28 ●●●●● patch | view | raw | blame | history
C1.source/privatevoice.src/internal/hotkey/hotkey.go 2 ●●●●● patch | view | raw | blame | history
C1.source/privatevoice.src/internal/hotkey/hotkey_darwin.go 41 ●●●●● patch | view | raw | blame | history
C1.source/privatevoice.src/internal/hotkey/hotkey_linux.go 2 ●●●●● patch | view | raw | blame | history
C1.source/privatevoice.src/internal/hotkey/hotkey_windows.go 2 ●●●●● patch | view | raw | blame | history
C1.source/privatevoice.src/internal/logger/logger.go 43 ●●●●● patch | view | raw | blame | history
C1.source/privatevoice.src/internal/overlay/overlay_darwin.go 35 ●●●● patch | view | raw | blame | history
C1.source/privatevoice.src/internal/overlay/overlay_windows.go 21 ●●●●● patch | view | raw | blame | history
C1.source/privatevoice.src/scripts/benchmark-recognition.sh 21 ●●●●● patch | view | raw | blame | history
C1.source/privatevoice.src/scripts/build-windows-preview.sh 4 ●●●● patch | view | raw | blame | history
C1.source/privatevoice.src/services/diagnostics_service.go 196 ●●●●● patch | view | raw | blame | history
CHANGELOG.md 16 ●●●●● patch | view | raw | blame | history
CODEGRAPH.md 20 ●●●●● patch | view | raw | blame | history
K1.业务知识/成果地图.md 1 ●●●● patch | view | raw | blame | history
K2.项目管理/R221-1-开发自查.md 101 ●●●●● patch | view | raw | blame | history
K2.项目管理/R221-3-QA-evidence.md 96 ●●●●● patch | view | raw | blame | history
K2.项目管理/handoffs/2026-07-02-迁移交接.md 7 ●●●●● patch | view | raw | blame | history
K2.项目管理/restore-notes.md 6 ●●●● patch | view | raw | blame | history
K2.项目管理/session-topics/00-Topics.md 4 ●●● patch | view | raw | blame | history
K2.项目管理/接收验证记录.md 2 ●●● patch | view | raw | blame | history
K2.项目管理/新窗口接手指南.md 18 ●●●● patch | view | raw | blame | history
K2.项目管理/迁移资产清单.md 5 ●●●●● patch | view | raw | blame | history
README.md 2 ●●● patch | view | raw | blame | history
.gitignore
@@ -1,5 +1,6 @@
# OS/editor noise
.DS_Store
._*
*.swp
*.swo
@@ -57,6 +58,10 @@
/.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/
AGENTS.md
@@ -7,26 +7,29 @@
如果当前工作目录在 `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、签名、打包和版本冻结必须遵守项目根入口声明的全局规则路由。
- 标准验证命令必须保持原始可执行形式,不在命令本身追加输出截断管道。
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.
C1.source/privatevoice.src/app_live_caption_test.go
@@ -2,8 +2,11 @@
import (
    "strings"
    "sync/atomic"
    "testing"
    "time"
    "voicesnap/internal/engine"
)
func TestLiveCaptionDisplayTextFitsOverlayBuffer(t *testing.T) {
@@ -131,6 +134,95 @@
    }
}
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)
@@ -189,6 +281,15 @@
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
C1.source/privatevoice.src/build/config.yml
@@ -7,7 +7,7 @@
  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: .
C1.source/privatevoice.src/build/darwin/Info.plist
@@ -17,9 +17,9 @@
    <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>
C1.source/privatevoice.src/cmd/benchmark-recognition/main.go
New file
@@ -0,0 +1,115 @@
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
}
C1.source/privatevoice.src/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
  "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"
      },
C1.source/privatevoice.src/frontend/package.json
@@ -1,7 +1,7 @@
{
  "name": "privatevoice-dictation-frontend",
  "private": true,
  "version": "2.2.0",
  "version": "2.2.2",
  "type": "module",
  "scripts": {
    "dev": "vite dev",
C1.source/privatevoice.src/frontend/src/App.svelte
@@ -19,24 +19,26 @@
  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)
@@ -44,13 +46,13 @@
      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)
@@ -61,23 +63,24 @@
      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) => {
@@ -100,6 +103,11 @@
        hotkeyName.set(name)
      }
    })
    return () => {
      for (const off of offEvents) off()
      window.removeEventListener('hashchange', onHashChange)
    }
  })
  function applyLanguageSettings(settings: any) {
C1.source/privatevoice.src/frontend/src/components/onboarding/OnboardingView.svelte
@@ -14,7 +14,7 @@
  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) {
@@ -31,7 +31,7 @@
      }
    })
    Events.On('engine:status', (ev: any) => {
    const offEngineStatus = Events.On('engine:status', (ev: any) => {
      const data = ev?.data
      if (data?.status === 'ready') {
        downloadActive = false
@@ -56,6 +56,11 @@
    })
    triggerDownload()
    return () => {
      offDownloadProgress()
      offEngineStatus()
    }
  })
  async function triggerDownload() {
C1.source/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
  import { onDestroy } from 'svelte'
  import { Call } from '@wailsio/runtime'
  import ToggleSwitch from '../shared/ToggleSwitch.svelte'
  import { t } from '../../lib/i18n'
@@ -26,6 +27,7 @@
  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 })
@@ -36,6 +38,11 @@
  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() {
@@ -160,6 +167,7 @@
  }
  async function startRecordingHotkey() {
    clearHotkeyCapture()
    isRecording = true
    hintText = t('hotkeys.pressAnyKey')
@@ -191,13 +199,25 @@
          } 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
C1.source/privatevoice.src/frontend/src/components/settings/HotkeysPage.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
  import { onDestroy } from 'svelte'
  import { Call } from '@wailsio/runtime'
  import { t } from '../../lib/i18n'
  import { hotkeyVK } from '../../lib/stores/config'
@@ -7,10 +8,17 @@
  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')
@@ -44,13 +52,25 @@
          } 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) {
C1.source/privatevoice.src/frontend/src/components/settings/LanguagePage.svelte
@@ -1,4 +1,5 @@
<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'
@@ -42,7 +43,7 @@
  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
@@ -54,7 +55,7 @@
    }
  })
  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
@@ -63,7 +64,7 @@
    }
  })
  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()
@@ -71,6 +72,15 @@
    }
  })
  onDestroy(() => {
    unsub1()
    unsub2()
    unsub3()
    offDownloadProgress()
    offDownloadCancelled()
    offDownloadFinished()
  })
  async function loadSettings() {
    try {
      const lang: any = await Call.ByName('voicesnap/services.ConfigService.GetLanguageSettings')
C1.source/privatevoice.src/frontend/src/components/settings/PermissionsPage.svelte
@@ -14,11 +14,18 @@
  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() {
@@ -40,7 +47,13 @@
        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[]) {
C1.source/privatevoice.src/frontend/src/components/settings/SettingsWindow.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
  import { onDestroy } from 'svelte'
  import { currentPage } from '../../lib/stores/app'
  import { t } from '../../lib/i18n'
  import GeneralPage from './GeneralPage.svelte'
@@ -21,6 +22,10 @@
  let page = $state('general')
  const unsubPage = currentPage.subscribe(p => { page = p })
  onDestroy(() => {
    unsubPage()
  })
  function navigate(id: string) {
    currentPage.set(id)
  }
C1.source/privatevoice.src/internal/audio/recorder.go
@@ -10,11 +10,17 @@
)
const (
    sampleRate       = 16000
    channels         = 1
    bitsPerSample    = 16
    silenceThreshold = 0.05
    sampleRate            = 16000
    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 {
@@ -37,6 +43,7 @@
    // State
    isRecording    bool
    bufferLimitHit bool
    maxVolume      float64
    currentVolume  float64
    volumeCallback func(float64)
@@ -106,6 +113,7 @@
    }
    r.pcmBuf = nil
    r.bufferLimitHit = false
    r.maxVolume = 0
    r.currentVolume = 0
@@ -249,8 +257,18 @@
    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)
    }()
@@ -296,6 +314,21 @@
    }
    // 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
C1.source/privatevoice.src/internal/audio/recorder_test.go
@@ -36,6 +36,27 @@
    }
}
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 {
C1.source/privatevoice.src/internal/engine/engine.go
@@ -21,6 +21,12 @@
    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 {
@@ -116,6 +122,14 @@
    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:
C1.source/privatevoice.src/internal/engine/engine_darwin.go
@@ -29,11 +29,13 @@
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
}
@@ -69,6 +71,7 @@
            return &sherpaEngine{
                recognizer:  recognizer,
                backendKind: resolved.BackendKind,
                provider:    p.provider,
                hwInfo:      info,
            }, nil
        }
@@ -93,6 +96,7 @@
            logger.Info("Engine initialized: %s", info)
            return &xasrStreamingEngine{
                recognizer: recognizer,
                provider:   p.provider,
                hwInfo:     info,
            }, nil
        }
@@ -196,28 +200,72 @@
}
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) {
@@ -316,6 +364,10 @@
    return e.hwInfo
}
func (e *sherpaEngine) Provider() string {
    return e.provider
}
func (e *sherpaEngine) ReleaseTailCaptureDelay() time.Duration {
    if e.backendKind == model.BackendSenseVoice {
        return senseVoiceReleaseTailDelay
@@ -327,6 +379,10 @@
    return e.hwInfo
}
func (e *xasrStreamingEngine) Provider() string {
    return e.provider
}
func (e *xasrStreamingEngine) ReleaseTailCaptureDelay() time.Duration {
    return xasrReleaseTailDelay
}
C1.source/privatevoice.src/internal/engine/engine_linux.go
@@ -14,6 +14,7 @@
type sherpaEngine struct {
    recognizer *sherpa.OfflineRecognizer
    provider   string
    hwInfo     string
}
@@ -36,6 +37,7 @@
        logger.Info("Engine initialized: %s", info)
        return &sherpaEngine{
            recognizer: recognizer,
            provider:   "cpu",
            hwInfo:     info,
        }, nil
    }
@@ -44,13 +46,35 @@
}
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
}
@@ -59,6 +83,10 @@
    return e.hwInfo
}
func (e *sherpaEngine) Provider() string {
    return e.provider
}
func (e *sherpaEngine) ReleaseTailCaptureDelay() time.Duration {
    return senseVoiceReleaseTailDelay
}
C1.source/privatevoice.src/internal/engine/engine_windows.go
@@ -14,6 +14,7 @@
type sherpaEngine struct {
    recognizer *sherpa.OfflineRecognizer
    provider   string
    hwInfo     string
}
@@ -45,6 +46,7 @@
            logger.Info("Engine initialized: %s", info)
            return &sherpaEngine{
                recognizer: recognizer,
                provider:   p.provider,
                hwInfo:     info,
            }, nil
        }
@@ -55,13 +57,35 @@
}
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
}
@@ -70,6 +94,10 @@
    return e.hwInfo
}
func (e *sherpaEngine) Provider() string {
    return e.provider
}
func (e *sherpaEngine) ReleaseTailCaptureDelay() time.Duration {
    return senseVoiceReleaseTailDelay
}
C1.source/privatevoice.src/internal/hotkey/hotkey.go
@@ -13,6 +13,8 @@
    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.
C1.source/privatevoice.src/internal/hotkey/hotkey_darwin.go
@@ -12,8 +12,10 @@
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;
@@ -138,8 +140,26 @@
    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) {
@@ -154,6 +174,12 @@
        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 {
@@ -164,7 +190,7 @@
}
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);
@@ -174,6 +200,15 @@
        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.
@@ -522,6 +557,10 @@
    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)
C1.source/privatevoice.src/internal/hotkey/hotkey_linux.go
@@ -96,3 +96,5 @@
func (l *linuxListener) IsAnyOtherKeyPressedSince(excludeVK int, since time.Time) bool {
    return l.IsAnyOtherKeyPressed(excludeVK)
}
func (l *linuxListener) Close() {}
C1.source/privatevoice.src/internal/hotkey/hotkey_windows.go
@@ -91,6 +91,8 @@
    return l.IsAnyOtherKeyPressed(excludeVK)
}
func (l *windowsListener) Close() {}
func contains(slice []int, val int) bool {
    for _, v := range slice {
        if v == val {
C1.source/privatevoice.src/internal/logger/logger.go
@@ -14,23 +14,66 @@
    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")
C1.source/privatevoice.src/internal/overlay/overlay_darwin.go
@@ -151,6 +151,7 @@
static NSWindow* g_window = nil;
static NSWindow* g_dragWindow = nil;
static int g_windowsOrderedOut = 1;
static float desktopTopY(void) {
    NSScreen* mainScreen = [NSScreen mainScreen];
@@ -180,6 +181,17 @@
    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) {
@@ -669,8 +681,7 @@
    [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;
    }
@@ -683,6 +694,7 @@
            applyOverlayWindowPolicy(g_dragWindow);
            [g_dragWindow orderFrontRegardless];
        }
        markOverlayWindowsVisible();
    }
    // Reposition
@@ -724,6 +736,7 @@
    g_window = window;
    g_view = view;
    g_windowsOrderedOut = 1;
    PrivateVoiceOverlayWindow* dragWindow = [[PrivateVoiceOverlayWindow alloc]
        initWithContentRect:NSMakeRect(0, 0, DRAG_W, DRAG_H)
@@ -789,6 +802,7 @@
        applyOverlayWindowPolicy(g_dragWindow);
        [g_dragWindow orderFrontRegardless];
    }
    markOverlayWindowsVisible();
}
static void overlayShow(void) {
@@ -882,6 +896,7 @@
import "C"
import (
    "sync"
    "time"
    "unsafe"
)
@@ -894,6 +909,7 @@
// 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{}
@@ -906,12 +922,17 @@
}
func (o *darwinOverlay) ensureCreated() {
    if !o.created {
        C.overlayCreate()
        o.created = true
        time.Sleep(50 * time.Millisecond)
        go o.pollDrag()
    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() {
C1.source/privatevoice.src/internal/overlay/overlay_windows.go
@@ -5,6 +5,7 @@
import (
    "math"
    "runtime"
    "sync"
    "syscall"
    "time"
    "unsafe"
@@ -257,12 +258,13 @@
}
type winOverlay struct {
    showCh   chan bool
    statusCh chan statusCmd
    volCh    chan float64
    posCh    chan [2]int
    closeCh  chan struct{}
    done     chan struct{}
    showCh    chan bool
    statusCh  chan statusCmd
    volCh     chan float64
    posCh     chan [2]int
    closeCh   chan struct{}
    done      chan struct{}
    closeOnce sync.Once
    barHeights [nBars]float64
    animTime   float64
@@ -307,7 +309,12 @@
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 {
C1.source/privatevoice.src/scripts/benchmark-recognition.sh
New file
@@ -0,0 +1,21 @@
#!/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"
C1.source/privatevoice.src/scripts/build-windows-preview.sh
@@ -4,8 +4,8 @@
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
C1.source/privatevoice.src/services/diagnostics_service.go
New file
@@ -0,0 +1,196 @@
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.md
@@ -1,5 +1,21 @@
# 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)
### 粤语语言版本
CODEGRAPH.md
@@ -3,11 +3,27 @@
## 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.
K1.业务知识/成果地图.md
@@ -4,6 +4,7 @@
- 当前源码根:`03-O/C1.source/privatevoice.src`
- 工程地图:`03-O/CODEGRAPH.md`
- 自动代码知识图谱:`03-O/.codebase-memory/`
- 主要技术栈:Go、Wails v3、Svelte 5、Vite、sherpa-onnx、malgo。
## 产品成果
K2.项目管理/R221-1-开发自查.md
New file
@@ -0,0 +1,101 @@
# 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 构建脚本是否仍符合规则。
K2.项目管理/R221-3-QA-evidence.md
New file
@@ -0,0 +1,96 @@
# 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 工具链。
K2.项目管理/handoffs/2026-07-02-迁移交接.md
@@ -3,14 +3,15 @@
## 来源
- 源项目:`/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` 文件继续工作,不依赖旧项目长会话。
## 已完成
@@ -44,4 +45,4 @@
## 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 上下文仍然可用。
K2.项目管理/restore-notes.md
@@ -2,14 +2,14 @@
## 源项目恢复
本次迁移没有修改、移动、删除或归档 `/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
```
@@ -18,5 +18,5 @@
`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/
```
K2.项目管理/session-topics/00-Topics.md
@@ -1,6 +1,8 @@
# 会话主题索引
旧项目原始 session 正文不迁入新项目正文。本目录只保留对接手仍有价值的主题摘要和来源索引。
本目录是旧项目瘦身迁移时生成的压缩主题摘要 / 接手知识包,不是 PrivateVoice 的常规会话主题汇编目录。
旧项目原始 session 正文不迁入新项目正文。本目录只保留对接手仍有价值的主题摘要和来源索引。PrivateVoice 未来常规会话主题汇编必须写入 `/Users/ar/Projects/PrivateVoice/.codex-sessionhistory/00-Topics.md` 和 `/Users/ar/Projects/PrivateVoice/.codex-sessionhistory/_topics/`。
| 主题 | 文档 | 时间范围 | 说明 |
| --- | --- | --- | --- |
K2.项目管理/接收验证记录.md
@@ -2,7 +2,7 @@
## 验证目标
验证 `PrivateVoice2` 迁移包是否可接手。该结论不等同产品 QA PASS、正式发布 PASS 或官网销售链路 PASS。
验证当前 `PrivateVoice` 迁移包是否可接手。该结论不等同产品 QA PASS、正式发布 PASS 或官网销售链路 PASS。
## 结果
K2.项目管理/新窗口接手指南.md
@@ -2,17 +2,17 @@
## 接手目标
从 `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`
## 当前事实快照
@@ -25,7 +25,7 @@
## 接手自检命令
```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
K2.项目管理/迁移资产清单.md
@@ -2,8 +2,9 @@
## 来源与目标
- 来源项目:`/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`
README.md
@@ -37,7 +37,7 @@
## 开发入口
```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 ./...
```