5 files modified
1 files deleted
346 files renamed
32 files added
| New file |
| | |
| | | # Auto-generated by codebase-memory-mcp |
| | | # Prevent merge conflicts on compressed artifact |
| | | graph.db.zst merge=ours binary |
| New file |
| | |
| | | { |
| | | "schema_version": 1, |
| | | "commit": "72b84399db9001a3b26ddad00c307438d940d8ee", |
| | | "indexed_at": "2026-07-02T21:25:42Z", |
| | | "project": "Users-ar-Projects-PrivateVoice-03-O", |
| | | "nodes": 3601, |
| | | "edges": 7844, |
| | | "original_size": 10551296, |
| | | "compressed_size": 1694833, |
| | | "compression_level": 3 |
| | | } |
| | |
| | | # OS/editor noise |
| | | .DS_Store |
| | | ._* |
| | | *.swp |
| | | *.swo |
| | | |
| | |
| | | VoiceSnapGo/frontend/dist/ |
| | | privatevoice.src/frontend/node_modules/ |
| | | privatevoice.src/frontend/dist/ |
| | | C1.source/privatevoice.src/frontend/node_modules/ |
| | | C1.source/privatevoice.src/frontend/dist/ |
| | | |
| | | # Go/build artifacts |
| | | VoiceSnapGo/build/local/ |
| | |
| | | privatevoice.src/build/windows-preview/ |
| | | privatevoice.src/build/*.log |
| | | privatevoice.src/PrivateVoice |
| | | C1.source/privatevoice.src/build/local/ |
| | | C1.source/privatevoice.src/build/appstore-local/ |
| | | C1.source/privatevoice.src/build/deps/ |
| | | C1.source/privatevoice.src/build/local-build-logs/ |
| | | C1.source/privatevoice.src/build/macappstore*/ |
| | | C1.source/privatevoice.src/build/qa/ |
| | | C1.source/privatevoice.src/build/windows-preview/ |
| | | C1.source/privatevoice.src/build/*.log |
| | | C1.source/privatevoice.src/PrivateVoice |
| | | *.app/ |
| | | *.dmg |
| | | |
| | |
| | | VoiceSnapGo/build/darwin/approved_app_icon.iconset/ |
| | | privatevoice.src/build/darwin/icon.iconset/ |
| | | privatevoice.src/build/darwin/approved_app_icon.iconset/ |
| | | C1.source/privatevoice.src/build/darwin/icon.iconset/ |
| | | C1.source/privatevoice.src/build/darwin/approved_app_icon.iconset/ |
| | | |
| | | # Runtime data should live in Application Support, not source control. |
| | | config.json |
| | |
| | | /.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/ |
| | | |
| | | # Migrated local build packages are present in this project folder for handoff, |
| | | # but binary packages are not committed into the slim Git repo. |
| | | /C2.builds/**/*.dmg |
| | | /C2.builds/**/*.zip |
| | | |
| | | # Local product/operation reference assets. |
| | | /X-运营资料/ |
| | | |
| | | # Migrated operation assets are local handoff materials; keep only the index. |
| | | /K3.运营与发布资料/legacy-运营资料/** |
| | | !/K3.运营与发布资料/legacy-运营资料/ |
| | | !/K3.运营与发布资料/legacy-运营资料/README.md |
| | |
| | | ## 项目属性 |
| | | |
| | | 项目类型:研发项目 |
| | | 主要产物:Wails / Go 桌面语音输入 App |
| | | 主要产物:Wails v3 / Go 桌面语音输入 App |
| | | |
| | | ## 1. 命令输出保护 |
| | | ## 读取顺序 |
| | | |
| | | 保护上下文用量。 |
| | | 如果当前工作目录在 `03-O` 内,先回到项目根入口读取: |
| | | |
| | | 任何输出量未知或可能很大的命令,都必须限制输出字节数。 |
| | | 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/` 资料只作证据附件,不能作为活跃任务来源。 |
| | | |
| | | ```bash |
| | | COMMAND 2>&1 | head -c 6000 |
| | | ``` |
| | | ## 路径边界 |
| | | |
| | | ## 2. 发布版本规则 |
| | | - 项目根:`/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/PrivateVoice/.codex-sessionhistory/` |
| | | - 常规会话主题汇编目录:`/Users/ar/Projects/PrivateVoice/.codex-sessionhistory/00-Topics.md` 和 `/Users/ar/Projects/PrivateVoice/.codex-sessionhistory/_topics/`。`K2.项目管理/session-topics/` 只保存旧项目迁移压缩摘要,不得作为新项目常规主题汇编落点。 |
| | | |
| | | 本项目声明为 `研发项目`,发布或分发安装包前默认遵守 `~/.codex/rules/release-versioning.md`;如本项目 release 文档或构建脚本另有更具体要求,以本项目规则为准。 |
| | | ## 工作规则 |
| | | |
| | | ## 3. 测试通过后的默认打包规则 |
| | | |
| | | 当某一轮改动已经完成开发自测,并且 QA 验证也通过后,默认进入测试包交付流程,不需要再单独询问用户是否打包。 |
| | | |
| | | 执行打包前仍必须遵守发布版本规则:递增 patch 版本号、生成新的 `YYYYMMDD.HHMM` build 编号、更新 App 元数据与 `CHANGELOG.md`,并生成可分发的 macOS `.dmg`。 |
| | | |
| | | 如果只有开发自测通过,但 QA 尚未验证通过,则不得把该版本作为测试人员可用包交付;此时只说明当前处于待 QA 状态。 |
| | | |
| | | ## 4. Mac App Store 出口合规规则 |
| | | |
| | | 如果本 App 未实现、未打包、未接入非 Apple 操作系统提供的加密算法,Mac App Store 构建的 `Info.plist` 应声明: |
| | | |
| | | ```xml |
| | | <key>ITSAppUsesNonExemptEncryption</key> |
| | | <false/> |
| | | ``` |
| | | |
| | | 提交 App Store Connect 前必须检查该键是否存在并为 `false`,以避免每次上传 build 后出现 `Missing Compliance` 并需要手动回答加密出口合规问题。 |
| | | |
| | | 如果后续新增自实现加密、第三方加密库、非系统提供的标准加密算法或专有加密算法,必须重新评估出口合规,不得沿用上述声明。 |
| | | |
| | | ## 5. 对话记忆保存规则 |
| | | |
| | | 本项目需要保存重要会话记忆,但不得为了普通会话记录频繁打断用户审批。 |
| | | |
| | | - 首选保存目录:项目根目录下的 `.codex-sessionhistory/`,即 `/Users/ar/Projects/PrivateVoice/.codex-sessionhistory/`。 |
| | | - 只在发生以下情况时保存会话记录:关键决策、文件修改、发布 / 构建 / QA 结论、重大问题排查、用户明确要求保存、阶段性收尾。 |
| | | - 纯询问、路径确认、状态查询、简单说明、无实质决策或无文件变更的对话,不强制保存。 |
| | | - 文件名格式:`YYYY-MM-DD-主题.md`;主题必须概括本轮 session 的核心内容,控制在 20 个字符以内,优先使用中文短语,避免空格、`/`、`:` 等不适合作为文件名的字符。 |
| | | - 如果同一天同主题已有记录文件,必须创建新文件并追加序号,避免覆盖:`YYYY-MM-DD-主题-01.md`、`YYYY-MM-DD-主题-02.md`。 |
| | | - 保存内容至少包括:用户目标、已完成事项、关键决策、文件变更、未决问题、后续建议。 |
| | | - 如果首选目录在当前沙盒下不可写,不得发起额外权限审批;必须改存到当前 Codex 工作区的 `.codex-sessionhistory/PrivateVoice/`,并在最终答复中说明实际保存位置。 |
| | | - 只有首选目录和当前工作区备用目录都不可写时,才允许跳过保存,并在最终答复中说明原因。 |
| | | - 如果确需保存,应一次性保存本轮完整摘要,避免为了会话记录反复修正。 |
| | | - 跨项目对话以主要项目保存;如果多个项目都有实质决策或改动,应按同样的首选目录 / 当前工作区备用目录规则分别保存摘要。 |
| | | - 统一活跃台账是 `/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、签名、打包和版本冻结必须遵守项目根入口声明的全局规则路由。 |
| | | - 标准验证命令必须保持原始可执行形式,不在命令本身追加输出截断管道。 |
| File was renamed from privatevoice.src/app.go |
| | |
| | | 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" |
| | | |
| | |
| | | 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 |
| | |
| | | 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. |
| File was renamed from privatevoice.src/app_live_caption_test.go |
| | |
| | | |
| | | 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 |
| File was renamed from privatevoice.src/build/config.yml |
| | |
| | | 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: . |
| File was renamed from privatevoice.src/build/darwin/Info.plist |
| | |
| | | <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 |
| | | } |
| File was renamed from privatevoice.src/frontend/package-lock.json |
| | |
| | | { |
| | | "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" |
| | | }, |
| File was renamed from privatevoice.src/frontend/package.json |
| | |
| | | { |
| | | "name": "privatevoice-dictation-frontend", |
| | | "private": true, |
| | | "version": "2.2.0", |
| | | "version": "2.2.2", |
| | | "type": "module", |
| | | "scripts": { |
| | | "dev": "vite dev", |
| File was renamed from privatevoice.src/frontend/src/App.svelte |
| | |
| | | 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) { |
| File was renamed from privatevoice.src/frontend/src/components/onboarding/OnboardingView.svelte |
| | |
| | | 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() { |
| File was renamed from privatevoice.src/frontend/src/components/settings/GeneralPage.svelte |
| | |
| | | <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 |
| File was renamed from privatevoice.src/frontend/src/components/settings/HotkeysPage.svelte |
| | |
| | | <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) { |
| File was renamed from privatevoice.src/frontend/src/components/settings/LanguagePage.svelte |
| | |
| | | <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') |
| File was renamed from privatevoice.src/frontend/src/components/settings/PermissionsPage.svelte |
| | |
| | | |
| | | 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[]) { |
| File was renamed from privatevoice.src/frontend/src/components/settings/SettingsWindow.svelte |
| | |
| | | <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) |
| | | } |
| File was renamed from privatevoice.src/internal/audio/recorder.go |
| | |
| | | ) |
| | | |
| | | 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 { |
| | |
| | | |
| | | // 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 |
| File was renamed from privatevoice.src/internal/audio/recorder_test.go |
| | |
| | | } |
| | | } |
| | | |
| | | 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 { |
| File was renamed from privatevoice.src/internal/engine/engine.go |
| | |
| | | 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: |
| File was renamed from privatevoice.src/internal/engine/engine_darwin.go |
| | |
| | | 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 |
| | | } |
| File was renamed from privatevoice.src/internal/engine/engine_linux.go |
| | |
| | | |
| | | 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 |
| | | } |
| File was renamed from privatevoice.src/internal/engine/engine_windows.go |
| | |
| | | |
| | | 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 |
| | | } |
| File was renamed from privatevoice.src/internal/hotkey/hotkey.go |
| | |
| | | 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. |
| File was renamed from privatevoice.src/internal/hotkey/hotkey_darwin.go |
| | |
| | | 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) |
| File was renamed from privatevoice.src/internal/hotkey/hotkey_linux.go |
| | |
| | | func (l *linuxListener) IsAnyOtherKeyPressedSince(excludeVK int, since time.Time) bool { |
| | | return l.IsAnyOtherKeyPressed(excludeVK) |
| | | } |
| | | |
| | | func (l *linuxListener) Close() {} |
| File was renamed from privatevoice.src/internal/hotkey/hotkey_windows.go |
| | |
| | | return l.IsAnyOtherKeyPressed(excludeVK) |
| | | } |
| | | |
| | | func (l *windowsListener) Close() {} |
| | | |
| | | func contains(slice []int, val int) bool { |
| | | for _, v := range slice { |
| | | if v == val { |
| New file |
| | |
| | | package logger |
| | | |
| | | import ( |
| | | "fmt" |
| | | "os" |
| | | "path/filepath" |
| | | "sync" |
| | | "time" |
| | | "voicesnap/internal/paths" |
| | | ) |
| | | |
| | | var ( |
| | | mu sync.Mutex |
| | | 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") |
| | | line := fmt.Sprintf("[%s] %s %s\n", ts, level, msg) |
| | | |
| | | mu.Lock() |
| | | defer mu.Unlock() |
| | | |
| | | if logFile != nil { |
| | | logFile.WriteString(line) |
| | | } |
| | | fmt.Fprint(os.Stderr, line) |
| | | } |
| | | |
| | | // Info logs an informational message. |
| | | func Info(format string, args ...interface{}) { |
| | | write("INFO", format, args...) |
| | | } |
| | | |
| | | // Error logs an error message. |
| | | func Error(format string, args ...interface{}) { |
| | | write("ERROR", format, args...) |
| | | } |
| | | |
| | | // Close closes the log file. |
| | | func Close() { |
| | | mu.Lock() |
| | | defer mu.Unlock() |
| | | if logFile != nil { |
| | | logFile.Close() |
| | | logFile = nil |
| | | } |
| | | } |
| File was renamed from privatevoice.src/internal/overlay/overlay_darwin.go |
| | |
| | | |
| | | 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.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() { |
| File was renamed from privatevoice.src/internal/overlay/overlay_windows.go |
| | |
| | | import ( |
| | | "math" |
| | | "runtime" |
| | | "sync" |
| | | "syscall" |
| | | "time" |
| | | "unsafe" |
| | |
| | | } |
| | | |
| | | 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 |
| | |
| | | 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" |
| File was renamed from privatevoice.src/scripts/build-windows-preview.sh |
| | |
| | | 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 |
| | | } |
| New file |
| | |
| | | # PrivateVoice Dictation 2.2.0 Build 20260702.0303 |
| | | |
| | | ## Release Scope |
| | | |
| | | - Type: real-user test candidate |
| | | - Version: 2.2.0 |
| | | - Build: 20260702.0303 |
| | | - Branch: codex/x-asr-experiment-2.1.29 |
| | | - Commit: 0ffbf1935c9d091cce22a5583275ac0902f7a693 |
| | | - Tag: v2.2.0-build20260702.0303-real-user-test |
| | | - Generated: 2026-07-02 03:09:18 CST |
| | | - Operator: Codex |
| | | |
| | | This package set contains three macOS DMGs and one Windows preview ZIP for real-user testing. It is not an App Store package set. macOS DMGs are locally signed and not notarized. The Windows ZIP is an unsigned preview package. |
| | | |
| | | ## Artifacts |
| | | |
| | | | Platform | Artifact | Size | |
| | | | --- | --- | --- | |
| | | | macOS Apple Silicon | `PrivateVoice-Dictation-2.2.0-build20260702.0303-macos-arm64.dmg` | 19M | |
| | | | macOS Intel | `PrivateVoice-Dictation-2.2.0-build20260702.0303-macos-x86_64.dmg` | 19M | |
| | | | macOS Universal | `PrivateVoice-Dictation-2.2.0-build20260702.0303-macos-universal.dmg` | 37M | |
| | | | Windows amd64 preview | `PrivateVoice-Dictation-2.2.0-build20260702.0303-windows-amd64-preview.zip` | 20M | |
| | | |
| | | ## SHA256 |
| | | |
| | | ```text |
| | | db7b7a0c5ac14dd664c8430f9033ce7adb227ee1f30cc3cd38b6ce0bf44eb784 PrivateVoice-Dictation-2.2.0-build20260702.0303-macos-arm64.dmg |
| | | f72aca7f0c6a41ba15e8d618a924eda59c1238e6cd780844bf1bfad5ca75b539 PrivateVoice-Dictation-2.2.0-build20260702.0303-macos-universal.dmg |
| | | 4ef0fc8afe40a9a2a843bc320a940b4f29d536da918dafaf5b0e0407c831703b PrivateVoice-Dictation-2.2.0-build20260702.0303-macos-x86_64.dmg |
| | | 82118105340a187bdc954eff01ef0ac2824e86037342e0606821dbf86d9a6b42 PrivateVoice-Dictation-2.2.0-build20260702.0303-windows-amd64-preview.zip |
| | | ``` |
| | | |
| | | ## Build Commands |
| | | |
| | | ```bash |
| | | cd /Users/ar/Projects/PrivateVoice/privatevoice.src |
| | | go test ./... -count=1 |
| | | npm --prefix frontend run build |
| | | 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 ./... |
| | | ./scripts/build-local-macos-universal.sh |
| | | ./scripts/build-windows-preview.sh |
| | | ``` |
| | | |
| | | ## Validation |
| | | |
| | | - `go test ./... -count=1`: passed |
| | | - `npm --prefix frontend run build`: passed with existing Svelte accessibility warnings |
| | | - Windows cross-compile smoke test: passed |
| | | - `git diff --check`: passed |
| | | - `hdiutil verify` for all three DMGs: passed |
| | | - `unzip -t` for Windows ZIP: passed |
| | | - `shasum -a 256 -c SHA256SUMS.txt`: passed |
| | | - macOS binary architectures: |
| | | - arm64 DMG app: `arm64` |
| | | - x86_64 DMG app: `x86_64` |
| | | - universal DMG app: `x86_64 arm64` |
| | | - `codesign --verify --deep --strict --verbose=2` for all three local macOS apps: passed |
| | | - `com.apple.quarantine`: not present on release artifacts |
| | | |
| | | ## Notes |
| | | |
| | | - This build includes Round 6 Cantonese language/model option work and prior Windows overlay/P1 download workflow changes from the current branch. |
| | | - Real Windows machine QA is still required before calling the Windows package stable. |
| | | - Monterey compatibility has not been revalidated in this package set. |
| | | - The release directory also contains `SHA256SUMS.txt` for independent checksum verification. |
| New file |
| | |
| | | db7b7a0c5ac14dd664c8430f9033ce7adb227ee1f30cc3cd38b6ce0bf44eb784 PrivateVoice-Dictation-2.2.0-build20260702.0303-macos-arm64.dmg |
| | | f72aca7f0c6a41ba15e8d618a924eda59c1238e6cd780844bf1bfad5ca75b539 PrivateVoice-Dictation-2.2.0-build20260702.0303-macos-universal.dmg |
| | | 4ef0fc8afe40a9a2a843bc320a940b4f29d536da918dafaf5b0e0407c831703b PrivateVoice-Dictation-2.2.0-build20260702.0303-macos-x86_64.dmg |
| | | 82118105340a187bdc954eff01ef0ac2824e86037342e0606821dbf86d9a6b42 PrivateVoice-Dictation-2.2.0-build20260702.0303-windows-amd64-preview.zip |
| New file |
| | |
| | | # PrivateVoice Dictation 2.2.2 Build 20260703.0335 |
| | | |
| | | ## Release Scope |
| | | |
| | | - Type: real-user test candidate |
| | | - Version: 2.2.2 |
| | | - Build: 20260703.0335 |
| | | - Branch: codex/x-asr-experiment-2.1.29 |
| | | - Source freeze commit: 5fbe9789faf05ce99bbff6ec4cd0b905519acbd0 |
| | | - Package build commit: 72b84399db9001a3b26ddad00c307438d940d8ee |
| | | - Release tag commit: a075aec123e1f7118f138738f196c2995aa96f98 |
| | | - Tag: v2.2.2-build20260703.0335-real-user-test |
| | | - Generated: 2026-07-03 05:27:09 CST |
| | | - Operator: Codex, R221-4 operations / release |
| | | |
| | | This package set contains three locally signed macOS DMGs and one unsigned Windows amd64 preview ZIP for real-user testing. It is not a notarized official website release. The Windows package is a preview ZIP, not a signed installer or stable Windows release. |
| | | |
| | | ## Artifacts |
| | | |
| | | | Platform | Artifact | Size | |
| | | | --- | --- | --- | |
| | | | macOS Apple Silicon | `PrivateVoice-Dictation-2.2.2-build20260703.0335-macos-arm64.dmg` | 20294112 bytes | |
| | | | macOS Universal | `PrivateVoice-Dictation-2.2.2-build20260703.0335-macos-universal.dmg` | 39249190 bytes | |
| | | | macOS Intel | `PrivateVoice-Dictation-2.2.2-build20260703.0335-macos-x86_64.dmg` | 19902000 bytes | |
| | | | Windows amd64 preview | `PrivateVoice-Dictation-2.2.2-build20260703.0335-windows-amd64-preview.zip` | 20585998 bytes | |
| | | |
| | | ## SHA256 |
| | | |
| | | ```text |
| | | 9dba061d838ea3c4448eecb09d276ed2143b6e73d66c9fb8e5be87d6244f8737 PrivateVoice-Dictation-2.2.2-build20260703.0335-macos-arm64.dmg |
| | | 117a6efa81be3fc9b79f12d0b03b1b778f8570b6207336b97180b28b96c39259 PrivateVoice-Dictation-2.2.2-build20260703.0335-macos-universal.dmg |
| | | fbc915059f364bfc88323d6f14b607d82b3c88c1a14de0c8af9b500e726a7fea PrivateVoice-Dictation-2.2.2-build20260703.0335-macos-x86_64.dmg |
| | | da44563e83c8806d2e5a848a309dd627cb1abc040b7232d9aa098ac774486858 PrivateVoice-Dictation-2.2.2-build20260703.0335-windows-amd64-preview.zip |
| | | ``` |
| | | |
| | | ## Build Commands |
| | | |
| | | Run from `/Users/ar/Projects/PrivateVoice/03-O/C1.source/privatevoice.src`: |
| | | |
| | | ```bash |
| | | PATH="/opt/homebrew/opt/openssl@3/bin:$PATH" ./scripts/build-local-macos.sh arm64 |
| | | PATH="/opt/homebrew/opt/openssl@3/bin:$PATH" ./scripts/build-local-macos.sh x86_64 |
| | | PATH="/opt/homebrew/opt/openssl@3/bin:$PATH" ./scripts/build-local-macos-universal.sh |
| | | ./scripts/build-windows-preview.sh |
| | | ``` |
| | | |
| | | Environment tools added during R221-4: |
| | | |
| | | - `mingw-w64` via Homebrew, required for Windows preview ZIP generation. |
| | | - `openssl@3` via Homebrew, required by the local signing identity creation path in `build-local-macos.sh`. |
| | | |
| | | ## Validation |
| | | |
| | | Run from `/Users/ar/Projects/PrivateVoice/03-O/C1.source/privatevoice.src` before packaging: |
| | | |
| | | ```bash |
| | | git diff --check |
| | | bash -n scripts/build-local-macos.sh && bash -n scripts/build-local-macos-universal.sh && bash -n scripts/build-windows-preview.sh && bash -n scripts/verify-windows-qa-evidence.sh |
| | | 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 ./... |
| | | go test ./... |
| | | npm --prefix frontend run build |
| | | ``` |
| | | |
| | | Run from this release directory after packaging: |
| | | |
| | | ```bash |
| | | shasum -a 256 -c SHA256SUMS.txt |
| | | hdiutil verify 'PrivateVoice-Dictation-2.2.2-build20260703.0335-macos-arm64.dmg' |
| | | hdiutil verify 'PrivateVoice-Dictation-2.2.2-build20260703.0335-macos-x86_64.dmg' |
| | | hdiutil verify 'PrivateVoice-Dictation-2.2.2-build20260703.0335-macos-universal.dmg' |
| | | unzip -t 'PrivateVoice-Dictation-2.2.2-build20260703.0335-windows-amd64-preview.zip' |
| | | ``` |
| | | |
| | | Results: |
| | | |
| | | - `git diff --check`: passed. |
| | | - Script syntax checks: passed. |
| | | - Windows cross smoke: passed after installing MinGW. |
| | | - `go test ./...`: passed with existing macOS linker warnings. |
| | | - `npm --prefix frontend run build`: passed with existing Svelte accessibility warnings. |
| | | - macOS DMG builds: passed; local codesign verification and `hdiutil verify` passed in scripts. |
| | | - Windows preview ZIP build: passed. |
| | | - `shasum -a 256 -c SHA256SUMS.txt`: passed for all four artifacts. |
| | | - Final `hdiutil verify`: passed for all three copied DMGs. |
| | | - Final `unzip -t`: passed for Windows preview ZIP. |
| | | - macOS binary architectures: arm64 DMG app `arm64`; x86_64 DMG app `x86_64`; universal DMG app `x86_64 arm64`. |
| | | - `com.apple.quarantine`: not present on release artifacts. |
| | | |
| | | ## CODEGRAPH And Codebase Memory |
| | | |
| | | - CODEGRAPH checked: `/Users/ar/Projects/PrivateVoice/03-O/CODEGRAPH.md`. |
| | | - CODEGRAPH result: updated for R221 `2.2.2 build20260703.0335`, codebase-memory project path, excluded recovery snapshot, and current release gaps. |
| | | - codebase-memory status: refreshed. |
| | | - Refresh command: `codebase-memory-mcp cli index_repository '{"repo_path":"/Users/ar/Projects/PrivateVoice/03-O"}'`. |
| | | - Artifact path: `/Users/ar/Projects/PrivateVoice/03-O/.codebase-memory/artifact.json` and `/Users/ar/Projects/PrivateVoice/03-O/.codebase-memory/graph.db.zst`. |
| | | - Indexed commit recorded by artifact: 72b84399db9001a3b26ddad00c307438d940d8ee. |
| | | - Release tag commit: a075aec123e1f7118f138738f196c2995aa96f98. |
| | | - Difference check: `git diff --name-only 72b84399db9001a3b26ddad00c307438d940d8ee..a075aec123e1f7118f138738f196c2995aa96f98 -- C1.source/privatevoice.src` produced no output, so the tag commit has no build-affecting source difference from the package build commit. |
| | | |
| | | ## Known Gaps |
| | | |
| | | - macOS DMGs are locally signed and not notarized. |
| | | - Windows package is an unsigned preview ZIP, not a stable Windows release. |
| | | - Windows 10/11 real-machine QA evidence is still required before calling Windows stable. |
| | | - Monterey compatibility was not validated for this package set. |
| | | - Developer ID notarization, official download page, authorization / activation, update, support, and rollback flows remain separate release tasks. |
| New file |
| | |
| | | 9dba061d838ea3c4448eecb09d276ed2143b6e73d66c9fb8e5be87d6244f8737 PrivateVoice-Dictation-2.2.2-build20260703.0335-macos-arm64.dmg |
| | | 117a6efa81be3fc9b79f12d0b03b1b778f8570b6207336b97180b28b96c39259 PrivateVoice-Dictation-2.2.2-build20260703.0335-macos-universal.dmg |
| | | fbc915059f364bfc88323d6f14b607d82b3c88c1a14de0c8af9b500e726a7fea PrivateVoice-Dictation-2.2.2-build20260703.0335-macos-x86_64.dmg |
| | | da44563e83c8806d2e5a848a309dd627cb1abc040b7232d9aa098ac774486858 PrivateVoice-Dictation-2.2.2-build20260703.0335-windows-amd64-preview.zip |
| New file |
| | |
| | | # C2.builds |
| | | |
| | | 本目录保存当前候选包和迁移时保留的历史候选包。 |
| | | |
| | | 当前最新: |
| | | |
| | | - `PrivateVoice-Dictation-2.2.2-build20260703.0335/` |
| | | |
| | | 历史保留: |
| | | |
| | | - `PrivateVoice-Dictation-2.2.0-build20260702.0303/` |
| | | |
| | | 二进制 `.dmg` 和 `.zip` 默认不提交 Git;本地文件用于当前机器接手、校验和分发准备。长期事实以 `RELEASE_MANIFEST.md`、`SHA256SUMS.txt`、`K2.项目管理/发布地图.md` 和会话主题摘要为准。 |
| New file |
| | |
| | | # C3.tools |
| | | |
| | | 工具入口索引。实际构建、打包和 QA 脚本仍跟随源码维护,位于: |
| | | |
| | | ```text |
| | | ../C1.source/privatevoice.src/scripts/ |
| | | ``` |
| | | |
| | | 常用脚本: |
| | | |
| | | - `build-local-macos.sh` |
| | | - `build-local-macos-universal.sh` |
| | | - `build-local-macos-monterey.sh` |
| | | - `build-release-macos.sh` |
| | | - `build-windows-preview.sh` |
| | | - `verify-windows-qa-evidence.sh` |
| | | - `qa-settings-window-macos.sh` |
| | | |
| | | 不要在本目录复制一份脚本实现,避免脚本漂移。 |
| | |
| | | # 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. |
| | | - Current branch purpose: Round 6 Cantonese language version real-user test package based on `v2.2.0-build20260702.0303`. |
| | | - Protected macOS behavior: existing macOS App Store / local DMG build lines are not changed by this branch. |
| | | - Git root: `/Users/ar/Projects/PrivateVoice/03-O`. |
| | | - Source root: `/Users/ar/Projects/PrivateVoice/03-O/C1.source/privatevoice.src`. |
| | | - Current branch purpose: R221 recovery freeze for `2.2.2 build20260703.0335`, restoring reviewed `2.2.1` fixes into the current active source and producing four real-user test candidate packages. |
| | | - 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`. |
| | | - Current artifact note: `.codebase-memory/artifact.json` must record project `Users-ar-Projects-PrivateVoice-03-O` before each freeze / release package. |
| | | - 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-2.2.1-recovered.src`, `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 |
| | | |
| | | - `privatevoice.src/main.go`: process startup, data migration, logger, single-instance guard. |
| | | - `privatevoice.src/app.go`: Wails app wiring, settings window, tray, recorder, hotkey loop, ASR init, paste orchestration. |
| | | - `privatevoice.src/embed.go`: embeds frontend assets and app/tray icons. |
| | | - `C1.source/privatevoice.src/main.go`: process startup, data migration, logger, single-instance guard. |
| | | - `C1.source/privatevoice.src/app.go`: Wails app wiring, settings window, tray, recorder, hotkey loop, ASR init, paste orchestration. |
| | | - `C1.source/privatevoice.src/embed.go`: embeds frontend assets and app/tray icons. |
| | | |
| | | ## Language And Model Registry |
| | | |
| | | - `privatevoice.src/internal/model/profile.go`: model and language IDs, model profile shape, install status types. |
| | | - `privatevoice.src/internal/model/registry.go`: language profile order, model profiles, language profiles, and normalization. |
| | | - `privatevoice.src/internal/modelselection/selection.go`: resolves current language and model, including manual-model fallback. |
| | | - `C1.source/privatevoice.src/internal/model/profile.go`: model and language IDs, model profile shape, install status types. |
| | | - `C1.source/privatevoice.src/internal/model/registry.go`: language profile order, model profiles, language profiles, and normalization. |
| | | - `C1.source/privatevoice.src/internal/modelselection/selection.go`: resolves current language and model, including manual-model fallback. |
| | | - Round 6 Cantonese path: |
| | | - Language ID: `yue-HK`, displayed as `粤语`. |
| | | - Default model: `sensevoice-yue-2025-09-09`. |
| | |
| | | |
| | | ## Platform Modules |
| | | |
| | | - Audio: `privatevoice.src/internal/audio/recorder.go` |
| | | - Uses `github.com/gen2brain/malgo`. |
| | | - Cross-platform capture target: 16 kHz, 16-bit, mono. |
| | | - Audio: `C1.source/privatevoice.src/internal/audio/recorder.go` |
| | | - Hotkey: |
| | | - macOS: `privatevoice.src/internal/hotkey/hotkey_darwin.go` |
| | | - Windows: `privatevoice.src/internal/hotkey/hotkey_windows.go` |
| | | - macOS: `C1.source/privatevoice.src/internal/hotkey/hotkey_darwin.go` |
| | | - Windows: `C1.source/privatevoice.src/internal/hotkey/hotkey_windows.go` |
| | | - Text input: |
| | | - macOS: `privatevoice.src/internal/input/paste_darwin.go` |
| | | - Windows: `privatevoice.src/internal/input/paste_windows.go` |
| | | - macOS: `C1.source/privatevoice.src/internal/input/paste_darwin.go` |
| | | - Windows: `C1.source/privatevoice.src/internal/input/paste_windows.go` |
| | | - ASR engine: |
| | | - macOS: `privatevoice.src/internal/engine/engine_darwin.go` |
| | | - Windows: `privatevoice.src/internal/engine/engine_windows.go` |
| | | - Linux: `privatevoice.src/internal/engine/engine_linux.go` |
| | | - SenseVoice profiles may set `Profile.LanguageParam`; Cantonese uses `yue`. |
| | | - macOS: `C1.source/privatevoice.src/internal/engine/engine_darwin.go` |
| | | - Windows: `C1.source/privatevoice.src/internal/engine/engine_windows.go` |
| | | - Linux: `C1.source/privatevoice.src/internal/engine/engine_linux.go` |
| | | - Overlay: |
| | | - macOS: `privatevoice.src/internal/overlay/overlay_darwin.go` |
| | | - Windows: `privatevoice.src/internal/overlay/overlay_windows.go` |
| | | - macOS: `C1.source/privatevoice.src/internal/overlay/overlay_darwin.go` |
| | | - Windows: `C1.source/privatevoice.src/internal/overlay/overlay_windows.go` |
| | | |
| | | ## Windows Preview Scope |
| | | |
| | |
| | | - Non-SenseVoice models remain visible in settings but are disabled through `model.IsModelSupportedInCurrentBuild`. |
| | | - Windows unsupported reason: `windows_preview_unsupported`. |
| | | - Windows overlay remains native Win32/GDI+, with a larger translucent spectrum-style capsule indicator for recording states. |
| | | - Build script: `privatevoice.src/scripts/build-windows-preview.sh`. |
| | | - Evidence verifier: `privatevoice.src/scripts/verify-windows-qa-evidence.sh`. |
| | | - Runtime package includes: |
| | | - `PrivateVoice Dictation.exe` |
| | | - `onnxruntime.dll` |
| | | - `sherpa-onnx-c-api.dll` |
| | | - `sherpa-onnx-cxx-api.dll` |
| | | - `RUN-WINDOWS-QA.cmd` |
| | | - `QA-WINDOWS-PREVIEW.ps1` |
| | | - `README-WINDOWS-PREVIEW.txt` |
| | | - Build script: `C1.source/privatevoice.src/scripts/build-windows-preview.sh`. |
| | | - Evidence verifier: `C1.source/privatevoice.src/scripts/verify-windows-qa-evidence.sh`. |
| | | |
| | | ## macOS Local Package Scope |
| | | ## macOS Package Scope |
| | | |
| | | - Local DMG script: `privatevoice.src/scripts/build-local-macos.sh`. |
| | | - Universal DMG script: `privatevoice.src/scripts/build-local-macos-universal.sh`. |
| | | - Local macOS package set for this branch: |
| | | - `arm64` DMG for Apple Silicon. |
| | | - `x86_64` DMG for Intel Mac. |
| | | - `universal` DMG for Intel + Apple Silicon. |
| | | - Mainline macOS local packages keep `LSMinimumSystemVersion=14.0` and the default Qwen3-capable dependency line. |
| | | - Monterey-compatible packages remain a separate build line through `privatevoice.src/scripts/build-local-macos-monterey.sh`. |
| | | - Local DMG script: `C1.source/privatevoice.src/scripts/build-local-macos.sh`. |
| | | - Universal DMG script: `C1.source/privatevoice.src/scripts/build-local-macos-universal.sh`. |
| | | - Monterey compatibility script: `C1.source/privatevoice.src/scripts/build-local-macos-monterey.sh`. |
| | | - Developer ID / notarization script: `C1.source/privatevoice.src/scripts/build-release-macos.sh`. |
| | | - Mainline macOS local packages keep `LSMinimumSystemVersion=14.0`. |
| | | - Monterey-compatible packages remain a separate build line. |
| | | |
| | | ## Standard Verification Commands |
| | | |
| | | Run from `privatevoice.src`: |
| | | Run from `C1.source/privatevoice.src`: |
| | | |
| | | ```bash |
| | | npm --prefix frontend run build |
| | |
| | | |
| | | ## Known Gaps |
| | | |
| | | - Windows runtime QA must still be performed on Windows 10/11 hardware. |
| | | - Run `RUN-WINDOWS-QA.cmd` from the extracted preview package to collect WebView2/audio preflight, startup, engine, recording, recognition, paste, and Notepad target text evidence. |
| | | - After evidence is returned, run `verify-windows-qa-evidence.sh` and require `WINDOWS_QA_EVIDENCE_PASS` before claiming the goal complete. |
| | | - The Windows preview zip is unsigned and not an installer. |
| | | - SmartScreen / Defender prompts are expected for local testing. |
| | | - Windows runtime QA must still be performed on Windows 10/11 hardware before calling Windows stable. |
| | | - macOS 2.2.2 real-user test DMGs are locally signed and not notarized. |
| | | - Monterey compatibility was not revalidated for the 2.2.2 package set. |
| | | - Independent-site sales still need Developer ID release packaging, download page, authorization/activation, update strategy, support path, release manifest, and rollback policy. |
| New file |
| | |
| | | # 成果地图 |
| | | |
| | | ## 代码成果 |
| | | |
| | | - 当前源码根:`03-O/C1.source/privatevoice.src` |
| | | - 工程地图:`03-O/CODEGRAPH.md` |
| | | - 自动代码知识图谱:`03-O/.codebase-memory/` |
| | | - 主要技术栈:Go、Wails v3、Svelte 5、Vite、sherpa-onnx、malgo。 |
| | | |
| | | ## 产品成果 |
| | | |
| | | - 离线语音输入桌面 App。 |
| | | - 正式语言:中文、English、French、German、Spanish、Italian、Portuguese、Japanese、Korean。 |
| | | - 真实用户测试候选:粤语 `yue-HK`,默认 `SenseVoice Yue 2025-09-09`。 |
| | | - Windows preview:当前只支持 SenseVoice。 |
| | | |
| | | ## QA 成果 |
| | | |
| | | - Round 6 粤语实现:`go test ./... -count=1`、前端 build、Windows cross test、`git diff --check` 通过。 |
| | | - 2.2.0 四包候选:DMG verify、ZIP test、SHA256、架构和 codesign 校验通过。 |
| | | - 详见 `03-O/K2.项目管理/QA地图.md`。 |
| | | |
| | | ## 发布成果 |
| | | |
| | | - 最新候选包:`03-O/C2.builds/PrivateVoice-Dictation-2.2.0-build20260702.0303/` |
| | | - 包类型:真实用户测试候选包。 |
| | | - 不是官网正式发布包;未 notarize。 |
| | | - 详见 `03-O/K2.项目管理/发布地图.md`。 |
| | | |
| | | ## 不作为当前成果的材料 |
| | | |
| | | - `03-O/K2.项目管理/legacy/` 下旧 Round 文档只作历史证据。 |
| | | - 旧原始 session 正文不迁入新项目正文。 |
| | | - `03-O/K3.运营与发布资料/legacy-运营资料/` 是本地运营素材,不等同当前发布事实。 |
| | | |
| | | ## 当前缺口 |
| | | |
| | | - 官网独立站销售闭环。 |
| | | - Windows 正式签名安装器与完整 QA。 |
| | | - Monterey 覆盖验证。 |
| | | - 自动更新策略重定向到官网发布链路。 |
| New file |
| | |
| | | # QA 地图 |
| | | |
| | | ## 当前 QA 结论 |
| | | |
| | | 迁移接收验证与产品 QA 结论必须区分。 |
| | | |
| | | - 迁移接收验证:由 `接收验证记录.md` 记录。 |
| | | - Round 6 产品 QA:已有会话和 release manifest 证明基础自动化/包校验通过。 |
| | | - Windows 真机 QA:仍未由本迁移补跑。 |
| | | - 官网正式发布 QA:仍未完成 notarization、下载页、授权/激活、更新和回滚链路验证。 |
| | | |
| | | ## 标准源码验证 |
| | | |
| | | 从 `03-O/C1.source/privatevoice.src` 执行: |
| | | |
| | | ```bash |
| | | npm --prefix frontend run build |
| | | go test ./... |
| | | 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 ./... |
| | | ``` |
| | | |
| | | ## 包验证 |
| | | |
| | | 从 `03-O/C2.builds/PrivateVoice-Dictation-2.2.0-build20260702.0303` 执行: |
| | | |
| | | ```bash |
| | | shasum -a 256 -c SHA256SUMS.txt |
| | | hdiutil verify 'PrivateVoice-Dictation-2.2.0-build20260702.0303-macos-arm64.dmg' |
| | | hdiutil verify 'PrivateVoice-Dictation-2.2.0-build20260702.0303-macos-x86_64.dmg' |
| | | hdiutil verify 'PrivateVoice-Dictation-2.2.0-build20260702.0303-macos-universal.dmg' |
| | | unzip -t 'PrivateVoice-Dictation-2.2.0-build20260702.0303-windows-amd64-preview.zip' |
| | | ``` |
| | | |
| | | ## Windows QA 入口 |
| | | |
| | | 在 Windows preview 包解压后运行: |
| | | |
| | | ```text |
| | | RUN-WINDOWS-QA.cmd |
| | | ``` |
| | | |
| | | 收回 evidence zip 后,在源码根执行: |
| | | |
| | | ```bash |
| | | ./scripts/verify-windows-qa-evidence.sh /path/to/qa-evidence-YYYYMMDD-HHMMSS.zip |
| | | ``` |
| | | |
| | | 只有出现 `WINDOWS_QA_EVIDENCE_PASS`,才能声称 Windows preview evidence 通过。 |
| 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 工具链。 |
| New file |
| | |
| | | # 2026-07-02 迁移交接 |
| | | |
| | | ## 来源 |
| | | |
| | | - 源项目:`/Users/ar/Projects/PrivateVoice` |
| | | - 当前项目:`/Users/ar/Projects/PrivateVoice` |
| | | - 历史命名:`PrivateVoice2` 是迁移期间旧命名,不再作为当前路径。 |
| | | - 源分支:`codex/x-asr-experiment-2.1.29` |
| | | - 固定点:`0ffbf1935c9d091cce22a5583275ac0902f7a693` |
| | | - Tag:`v2.2.0-build20260702.0303-real-user-test` |
| | | |
| | | ## 当前目标 |
| | | |
| | | 让新 Codex 窗口可以只读 `/Users/ar/Projects/PrivateVoice` 文件继续工作,不依赖旧项目长会话。 |
| | | |
| | | ## 已完成 |
| | | |
| | | - 新项目结构 `01-I / 02-P / 03-O` 已建立。 |
| | | - Git 历史 clone 到 `03-O`,源码迁入 `03-O/C1.source/privatevoice.src`。 |
| | | - 最新 2.2.0 候选包迁入 `03-O/C2.builds`。 |
| | | - 旧过程文档降级到 `03-O/K2.项目管理/legacy/`。 |
| | | - 原始 session 正文未迁入;已整理主题摘要。 |
| | | - 入口文档、台账、事件地图、成果地图、QA/发布地图和迁移清单已建立。 |
| | | |
| | | ## 已知错误或未跑检查 |
| | | |
| | | - 本迁移不补跑 Windows 真机 QA。 |
| | | - 本迁移不补跑 notarization。 |
| | | - 本迁移不重新构建 2.2.0 包。 |
| | | - Monterey 兼容未在迁移中重新验证。 |
| | | |
| | | ## 不可触碰区域 |
| | | |
| | | - 不修改 `/Users/ar/Projects/PrivateVoice` 旧项目。 |
| | | - 不删除源项目旧 release、证书、worktree 或 session。 |
| | | - 不把 `legacy` 旧任务清单当作当前活跃任务。 |
| | | |
| | | ## 下一步 |
| | | |
| | | 1. 完成迁移接收验证并更新 `接收验证记录.md`。 |
| | | 2. 如要继续产品工作,先在 `02-P/进度地图.md` 领取或新增活跃任务。 |
| | | 3. 若目标是官网销售,先补独立站 P0 发布计划。 |
| | | 4. 若目标是 Windows 正式版,先补签名、安装器和真机 QA evidence。 |
| | | 5. 若目标是粤语真实用户测试,先确认测试人、平台、反馈渠道和包分发口径。 |
| | | |
| | | ## Reactivation Prompt |
| | | |
| | | 我们从这份 handoff 继续。请先读取本文件,再检查 `/Users/ar/Projects/PrivateVoice/AGENTS.md`、`PROJECT.md`、`INDEX.md`、`02-P/进度地图.md`、`03-O/CODEGRAPH.md` 和当前 Git 状态,核对哪些结论仍然成立,然后从“下一步”继续推进;不要假设旧 session 上下文仍然可用。 |
| New file |
| | |
| | | {"time":"2026-07-02","scope":"new-project","source":"/Users/ar/Projects/PrivateVoice2/03-O/privatevoice.src","target":"/Users/ar/Projects/PrivateVoice2/03-O/C1.source/privatevoice.src","reason":"set source root to SOP C1.source path; source project was not modified"} |
| | | {"time":"2026-07-02","scope":"new-project","source":"/Users/ar/Projects/PrivateVoice2/03-O/02-P-NBL","target":"/Users/ar/Projects/PrivateVoice2/03-O/K2.项目管理/legacy/02-P-NBL","reason":"downgrade old process documents to historical evidence; source project was not modified"} |
| | | {"time":"2026-07-02","scope":"new-project","source":"/Users/ar/Projects/PrivateVoice2/03-O/Docs","target":"/Users/ar/Projects/PrivateVoice2/03-O/K2.项目管理/legacy/Docs","reason":"place old project management docs under legacy evidence; source project was not modified"} |
| | | {"time":"2026-07-02","scope":"new-project","source":"/Users/ar/Projects/PrivateVoice2/03-O/TODO.md","target":"/Users/ar/Projects/PrivateVoice2/03-O/K2.项目管理/legacy/TODO-旧任务清单.md","reason":"old TODO is not the active ledger; source project was not modified"} |
| | | {"time":"2026-07-02","scope":"new-project","source":"/Users/ar/Projects/PrivateVoice2/03-O/screenshot.png","target":"/Users/ar/Projects/PrivateVoice2/03-O/K3.运营与发布资料/screenshot.png","reason":"place product screenshot under operation/release materials; source project was not modified"} |
| New file |
| | |
| | | # Restore Notes |
| | | |
| | | ## 源项目恢复 |
| | | |
| | | 本次迁移不再使用 `PrivateVoice2` 作为当前路径。当前项目根为 `/Users/ar/Projects/PrivateVoice`;旧项目材料已作为历史证据保留在本项目结构和 legacy 区内。 |
| | | |
| | | ## 新项目结构性移动 |
| | | |
| | | 新项目内发生的结构性移动记录在 `moved-assets.jsonl`。如果要恢复旧仓库布局,可在 `03-O` 中用 Git 历史查看迁移前路径: |
| | | |
| | | ```bash |
| | | 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/PrivateVoice/03-O/C2.builds/PrivateVoice-Dictation-2.2.0-build20260702.0303/ |
| | | ``` |
| New file |
| | |
| | | # 会话主题索引 |
| | | |
| | | 本目录是旧项目瘦身迁移时生成的压缩主题摘要 / 接手知识包,不是 PrivateVoice 的常规会话主题汇编目录。 |
| | | |
| | | 旧项目原始 session 正文不迁入新项目正文。本目录只保留对接手仍有价值的主题摘要和来源索引。PrivateVoice 未来常规会话主题汇编必须写入 `/Users/ar/Projects/PrivateVoice/.codex-sessionhistory/00-Topics.md` 和 `/Users/ar/Projects/PrivateVoice/.codex-sessionhistory/_topics/`。 |
| | | |
| | | | 主题 | 文档 | 时间范围 | 说明 | |
| | | | --- | --- | --- | --- | |
| | | | 多语种与粤语 | `多语种与粤语.md` | 2026-05-29 至 2026-07-02 | 模型路线、Round 6 粤语、语言切换安全 | |
| | | | Windows 预览 | `Windows预览.md` | 2026-06-30 至 2026-07-02 | Windows preview 合并、限制、包和 QA 缺口 | |
| | | | 发布与打包 | `发布与打包.md` | 2026-06-04 至 2026-07-02 | App Store、本地包、2.2.0 四包候选 | |
| | | | App Store 与独立站 | `AppStore与独立站.md` | 2026-06-10 至 2026-07-02 | 渠道转向和独立站缺口 | |
| | | | 性能与稳定性 | `性能与稳定性.md` | 2026-06-28 至 2026-07-01 | 慢识别、尾音、下载/取消/首尾字修复 | |
| | | |
| | | 原始记录仍保留在源项目 `/Users/ar/Projects/PrivateVoice/.codex-sessionhistory/`。 |
| New file |
| | |
| | | # App Store 与独立站 |
| | | |
| | | ## 当前状态 |
| | | |
| | | 项目曾围绕 Mac App Store 审核、pkg、合规和启动问题推进。2026-07-02 的结论是:主发布出口转为独立站销售。 |
| | | |
| | | ## 当前结论 |
| | | |
| | | - App Store 特有链路不再是当前主线门禁。 |
| | | - 建议采用“功能冻结,不做二进制绝对冻结”的发布策略。 |
| | | - 首发建议先做 macOS 官网直发,不同步追求完整账号体系和复杂自动更新。 |
| | | |
| | | ## P0 缺口 |
| | | |
| | | 1. 固定官网发布基线。 |
| | | 2. Developer ID signed + notarized DMG。 |
| | | 3. 下载页、版本页、SHA256 和 manifest。 |
| | | 4. 最小授权 / 激活 / 交付闭环。 |
| | | 5. 更新策略:首发是否关闭自动更新,或跳官网。 |
| | | 6. 支持、诊断导出和退款/订单查询路径。 |
| | | |
| | | ## 来源索引 |
| | | |
| | | - `/Users/ar/Projects/PrivateVoice/.codex-sessionhistory/2026-06-10-审核拒绝排查.md` |
| | | - `/Users/ar/Projects/PrivateVoice/.codex-sessionhistory/2026-06-10-审核处理方案.md` |
| | | - `/Users/ar/Projects/PrivateVoice/.codex-sessionhistory/2026-06-13-AppStore三审驳回.md` |
| | | - `/Users/ar/Projects/PrivateVoice/.codex-sessionhistory/2026-06-23-AppStore四审会诊.md` |
| | | - `/Users/ar/Projects/PrivateVoice/.codex-sessionhistory/2026-07-02-独立站发布清单.md` |
| New file |
| | |
| | | # Windows 预览 |
| | | |
| | | ## 当前状态 |
| | | |
| | | Windows preview 已合入当前分支,并包含在 2.2.0 四包候选中。它仍是 unsigned preview ZIP,不是正式 Windows 安装器。 |
| | | |
| | | ## 核心事实 |
| | | |
| | | - Windows preview 当前只支持 SenseVoice。 |
| | | - 非 SenseVoice 模型在设置中可见但禁用。 |
| | | - Windows overlay 使用原生 Win32/GDI+ 胶囊指示器。 |
| | | - QA 脚本在包内:`RUN-WINDOWS-QA.cmd`、`QA-WINDOWS-PREVIEW.ps1`。 |
| | | - 收回 evidence 后用 `scripts/verify-windows-qa-evidence.sh` 验证。 |
| | | |
| | | ## 缺口 |
| | | |
| | | - 需要真实 Windows 10/11 硬件 QA。 |
| | | - 需要签名、安装器和 SmartScreen 体验处理,才能推进正式 PC 版本。 |
| | | |
| | | ## 来源索引 |
| | | |
| | | - `/Users/ar/Projects/PrivateVoice/.codex-sessionhistory/2026-06-30-Windows预览版.md` |
| | | - `/Users/ar/Projects/PrivateVoice/.codex-sessionhistory/2026-07-01-Windows合并.md` |
| | | - `/Users/ar/Projects/PrivateVoice/.codex-sessionhistory/2026-07-01-Windows动效打包.md` |
| New file |
| | |
| | | # 发布与打包 |
| | | |
| | | ## 当前最新包 |
| | | |
| | | - Version:`2.2.0` |
| | | - Build:`20260702.0303` |
| | | - Commit:`0ffbf1935c9d091cce22a5583275ac0902f7a693` |
| | | - Tag:`v2.2.0-build20260702.0303-real-user-test` |
| | | - 类型:真实用户测试候选包。 |
| | | |
| | | ## 包组成 |
| | | |
| | | - macOS arm64 DMG |
| | | - macOS x86_64 DMG |
| | | - macOS universal DMG |
| | | - Windows amd64 preview ZIP |
| | | |
| | | ## 验证摘要 |
| | | |
| | | 源会话记录表明以下检查通过:`go test ./... -count=1`、`npm --prefix frontend run build`、Windows cross smoke、`git diff --check`、DMG verify、ZIP test、SHA256、架构和 codesign。 |
| | | |
| | | ## 边界 |
| | | |
| | | - macOS 包未 notarize。 |
| | | - Windows 包未签名。 |
| | | - Monterey 兼容未在 2.2.0 四包中重新验证。 |
| | | |
| | | ## 来源索引 |
| | | |
| | | - `/Users/ar/Projects/PrivateVoice/.codex-sessionhistory/2026-07-02-多平台打包整理.md` |
| | | - `/Users/ar/Projects/PrivateVoice/.codex-sessionhistory/2026-07-02-粤语四包交付.md` |
| | | - `/Users/ar/Projects/PrivateVoice/Release/PrivateVoice-Dictation-2.2.0-build20260702.0303/RELEASE_MANIFEST.md` |
| New file |
| | |
| | | # 多语种与粤语 |
| | | |
| | | ## 当前状态 |
| | | |
| | | PrivateVoice 已从中文/English 双语言扩展到多语种 profile。Round 6 已新增独立粤语语言版本。 |
| | | |
| | | ## 最近结论 |
| | | |
| | | - `yue-HK` 是独立语言 profile,显示为 `粤语`。 |
| | | - 默认模型为 `sensevoice-yue-2025-09-09` / `SenseVoice Yue`。 |
| | | - SenseVoice Yue engine 参数固定为 `LanguageParam="yue"`。 |
| | | - 粤语 profile 不默认展示 Qwen3-ASR / X-ASR 作为粤语专项模型。 |
| | | - `zh-HK` 不自动映射到粤语,避免香港用户被误切到粤语。 |
| | | - 未安装目标语言只做 UI 预览;下载成功后才持久化语言并 reload engine。 |
| | | |
| | | ## 来源索引 |
| | | |
| | | - `/Users/ar/Projects/PrivateVoice/.codex-sessionhistory/2026-05-29-关于FunASR等模型的技术选型.md` |
| | | - `/Users/ar/Projects/PrivateVoice/.codex-sessionhistory/2026-06-22-多语种扩展.md` |
| | | - `/Users/ar/Projects/PrivateVoice/.codex-sessionhistory/2026-06-22-README语言表.md` |
| | | - `/Users/ar/Projects/PrivateVoice/.codex-sessionhistory/2026-07-02-粤语版本需求.md` |
| | | - `/Users/ar/Projects/PrivateVoice/.codex-sessionhistory/2026-07-02-粤语开发实现.md` |
| | | - `/Users/ar/Projects/PrivateVoice/.codex-sessionhistory/2026-07-02-粤语四包交付.md` |
| New file |
| | |
| | | # 性能与稳定性 |
| | | |
| | | ## 当前状态 |
| | | |
| | | 2026-07-01 慢识别复盘显示,用户感知慢主要来自 `recognize_ms` 抖动和系统负载峰值,不是上屏、历史写入或 engine lock。 |
| | | |
| | | ## 已落地 |
| | | |
| | | - 新增 `PERF slow_recognition` 日志。 |
| | | - SenseVoice 尾音保护为 100ms。 |
| | | - X-ASR 尾音保护为 300ms。 |
| | | - 其他 sherpa 离线模型不再默认加尾音保护。 |
| | | |
| | | ## 后续观察 |
| | | |
| | | - 系统负载异常时需要区分 Codex/Spotlight/WindowServer 等外部负载影响。 |
| | | - 如继续优化,先补更细日志和真实用户样本,不直接盲调模型线程或 provider。 |
| | | |
| | | ## 来源索引 |
| | | |
| | | - `/Users/ar/Projects/PrivateVoice/.codex-sessionhistory/2026-06-28-首尾字修复.md` |
| | | - `/Users/ar/Projects/PrivateVoice/.codex-sessionhistory/2026-06-29-性能日志.md` |
| | | - `/Users/ar/Projects/PrivateVoice/.codex-sessionhistory/2026-07-01-识别变慢复盘.md` |
| New file |
| | |
| | | # 发布地图 |
| | | |
| | | ## 当前最新候选包 |
| | | |
| | | - Version:`2.2.2` |
| | | - Build:`20260703.0335` |
| | | - Source freeze commit:`5fbe9789faf05ce99bbff6ec4cd0b905519acbd0` |
| | | - Package build commit:`72b84399db9001a3b26ddad00c307438d940d8ee` |
| | | - Release tag commit:`a075aec123e1f7118f138738f196c2995aa96f98` |
| | | - Tag:`v2.2.2-build20260703.0335-real-user-test` |
| | | - 类型:真实用户测试候选包 |
| | | - 路径:`03-O/C2.builds/PrivateVoice-Dictation-2.2.2-build20260703.0335/` |
| | | |
| | | ## 包列表 |
| | | |
| | | - `PrivateVoice-Dictation-2.2.2-build20260703.0335-macos-arm64.dmg` |
| | | - `PrivateVoice-Dictation-2.2.2-build20260703.0335-macos-x86_64.dmg` |
| | | - `PrivateVoice-Dictation-2.2.2-build20260703.0335-macos-universal.dmg` |
| | | - `PrivateVoice-Dictation-2.2.2-build20260703.0335-windows-amd64-preview.zip` |
| | | - `RELEASE_MANIFEST.md` |
| | | - `SHA256SUMS.txt` |
| | | |
| | | ## 当前边界 |
| | | |
| | | - macOS 包是本地签名 DMG,未 notarize。 |
| | | - Windows 包是 unsigned preview ZIP,不是安装器,不是稳定 Windows 正式版。 |
| | | - 该包可用于真实用户测试,不等同正式官网发布。 |
| | | - Monterey 兼容未在本包集验证;如需覆盖 macOS 12,需单独任务。 |
| | | |
| | | ## 本轮验证记录 |
| | | |
| | | - R221-2 代码审核:`R221-2-GATE-20260703.0335` PASS。 |
| | | - R221-3 QA:`03-O/K2.项目管理/R221-3-QA-evidence.md` PASS。 |
| | | - R221-4 发布冻结:四包生成、SHA256、DMG 完整性、Windows ZIP 解压测试、CODEGRAPH 检查、codebase-memory 刷新和 tag 创建已完成;详见当前包目录 `RELEASE_MANIFEST.md`。 |
| | | |
| | | ## 官网独立站 P0 缺口 |
| | | |
| | | 1. 明确官网首发固定 commit/build。 |
| | | 2. 生成 Developer ID signed + notarized DMG。 |
| | | 3. 建立下载页、版本页、SHA256、release manifest。 |
| | | 4. 决定授权/试用/激活/换机/重装最小方案。 |
| | | 5. 决定首发是否关闭应用内自动更新;如关闭,检查更新应跳官网。 |
| | | 6. 定义支持、诊断导出、退款/订单查询最小流程。 |
| New file |
| | |
| | | # 接收验证记录 |
| | | |
| | | ## 验证目标 |
| | | |
| | | 验证当前 `PrivateVoice` 迁移包是否可接手。该结论不等同产品 QA PASS、正式发布 PASS 或官网销售链路 PASS。 |
| | | |
| | | ## 结果 |
| | | |
| | | | 项 | 命令或检查 | 结果 | 备注 | |
| | | | --- | --- | --- | --- | |
| | | | Git 状态 | `git status --short --branch` | PASS | 显示预期迁移变更;提交后需再看最终 clean 状态 | |
| | | | Diff 空白检查 | `git diff --check` | PASS | 无输出 | |
| | | | 依赖安装 | `npm --prefix frontend ci` | PASS with warning | 安装 48 packages;仍有既有 `6 vulnerabilities (3 moderate, 3 high)` | |
| | | | 前端 build | `npm --prefix frontend run build` | PASS with warning | 生成 `frontend/dist`;仍有既有 Svelte a11y warning | |
| | | | Go 测试 | `go test ./...` | PASS with warning | 通过;仍有既有 macOS linker warning | |
| | | | 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 ./...` | PASS | 通过 | |
| | | | SHA256 | `shasum -a 256 -c SHA256SUMS.txt` | PASS | 四个候选包均 OK | |
| | | | macOS DMG 完整性 | `hdiutil verify ...arm64.dmg` | PASS | VALID | |
| | | | macOS DMG 完整性 | `hdiutil verify ...x86_64.dmg` | PASS | VALID | |
| | | | macOS DMG 完整性 | `hdiutil verify ...universal.dmg` | PASS | VALID | |
| | | | Windows ZIP 完整性 | `unzip -t ...windows-amd64-preview.zip` | PASS | No errors detected | |
| | | |
| | | ## 未跑项默认解释 |
| | | |
| | | - Windows 真机 QA 不属于本次迁移接收验证。 |
| | | - Notarization 不属于本次迁移接收验证。 |
| | | - 重新打包不属于本次迁移接收验证。 |
| | | - 本次迁移验证不处理 `npm audit` 中既有漏洞提示;如要处理,需单独进入依赖安全任务。 |
| New file |
| | |
| | | # 新窗口接手指南 |
| | | |
| | | ## 接手目标 |
| | | |
| | | 从 `/Users/ar/Projects/PrivateVoice` 文件系统接手项目,不依赖旧长会话上下文。`PrivateVoice2` 只是迁移期间旧命名,不再作为当前路径。 |
| | | |
| | | ## 必读顺序 |
| | | |
| | | 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` |
| | | |
| | | ## 当前事实快照 |
| | | |
| | | - 当前工作不是继续旧会话,而是从新项目文件接手。 |
| | | - 源码根:`03-O/C1.source/privatevoice.src` |
| | | - 当前基线:`v2.2.0-build20260702.0303-real-user-test` |
| | | - Round 6 粤语实现和四包候选已完成。 |
| | | - Windows preview 与官网正式发布仍有缺口。 |
| | | |
| | | ## 接手自检命令 |
| | | |
| | | ```bash |
| | | cd /Users/ar/Projects/PrivateVoice/03-O |
| | | git status --short --branch |
| | | git diff --check |
| | | cd C1.source/privatevoice.src |
| | | npm --prefix frontend run build |
| | | go test ./... |
| | | ``` |
| | | |
| | | ## 不迁移内容 |
| | | |
| | | - 原始长会话正文。 |
| | | - 旧 worktree。 |
| | | - 旧构建包全集。 |
| | | - 证书目录。 |
| | | - node_modules、dist、构建缓存和用户级缓存。 |
| | | |
| | | ## 成功判定 |
| | | |
| | | 新窗口只读新项目文件后,应能说明: |
| | | |
| | | - 项目是什么。 |
| | | - 当前做到哪里。 |
| | | - Git 根、源码根、QA 入口和发布入口在哪里。 |
| | | - 下一步需要谁决策。 |
| | | - 哪些材料是 legacy,不可直接作为当前任务来源。 |
| New file |
| | |
| | | # 迁移资产清单 |
| | | |
| | | ## 来源与目标 |
| | | |
| | | - 来源项目:旧 PrivateVoice 项目材料 |
| | | - 当前项目:`/Users/ar/Projects/PrivateVoice` |
| | | - 历史命名:`PrivateVoice2` 是迁移期间旧命名,不再作为当前路径。 |
| | | - 迁移时间:2026-07-02 |
| | | - 基线:`0ffbf1935c9d091cce22a5583275ac0902f7a693` / `v2.2.0-build20260702.0303-real-user-test` |
| | | |
| | | ## 已迁入 |
| | | |
| | | | 来源 | 目标 | 说明 | |
| | | | --- | --- | --- | |
| | | | 旧 Git 仓库 | `03-O` | 使用 `git clone --no-hardlinks` 保留历史 | |
| | | | `privatevoice.src/` | `03-O/C1.source/privatevoice.src/` | 源码根迁入固定 C1.source | |
| | | | `02-P-NBL/` | `03-O/K2.项目管理/legacy/02-P-NBL/` | 旧 Round/计划/QA 证据,降级为历史 | |
| | | | `Docs/` | `03-O/K2.项目管理/legacy/Docs/` | 旧项目管理经验文档 | |
| | | | `TODO.md` | `03-O/K2.项目管理/legacy/TODO-旧任务清单.md` | 旧 TODO,只作历史 | |
| | | | `Release/PrivateVoice-Dictation-2.2.0-build20260702.0303/` | `03-O/C2.builds/PrivateVoice-Dictation-2.2.0-build20260702.0303/` | 最新真实用户测试候选包 | |
| | | | `X-运营资料/PrivateVoice-26.0604上架` 等 | `03-O/K3.运营与发布资料/legacy-运营资料/` | 本地运营素材,默认不提交 Git | |
| | | | `.codex-sessionhistory/` 关键信息 | `03-O/K2.项目管理/session-topics/`、`handoffs/` | 只迁移压缩摘要和交接,不迁原始正文 | |
| | | |
| | | ## 明确排除 |
| | | |
| | | - 旧项目 `.git/worktrees/` 与 `.worktrees/`。 |
| | | - 旧项目 `.codex/` 与原始长会话正文。 |
| | | - `privatevoice.src/frontend/node_modules/`。 |
| | | - `privatevoice.src/frontend/dist/`。 |
| | | - `privatevoice.src/build/local/`、`build/macappstore*`、`build/windows-preview/` 等本地构建输出。 |
| | | - `Release/X-旧版build成果/` 历史包。 |
| | | - `Release/01-证书/` 证书目录。 |
| | | - 用户级缓存、系统噪声和 `.DS_Store`。 |
| | | |
| | | ## Git 策略 |
| | | |
| | | - `03-O` 是新项目 Git 根。 |
| | | - 最新包和运营素材已在本地目录保留,但大二进制文件默认由 `.gitignore` 排除,不进入迁移提交。 |
| | | - `RELEASE_MANIFEST.md`、`SHA256SUMS.txt`、地图和清单可以提交。 |
| | | |
| | | ## 风险说明 |
| | | |
| | | - 新路径把源码移动到 `C1.source/privatevoice.src`;已有脚本通常使用相对路径,后续执行命令需从新源码根进入。 |
| | | - 历史文档里的旧路径保留为证据,不机械替换。 |
| | | - 本次迁移不生成正式 release,不补跑 Windows 真机 QA,不补 notarization。 |
| New file |
| | |
| | | # 运营与发布资料 |
| | | |
| | | ## 当前资料 |
| | | |
| | | - `screenshot.png`:产品截图。 |
| | | - `legacy-运营资料/`:从旧项目选择性复制的运营素材和历史上架资料,本地保留,默认不提交 Git。 |
| | | |
| | | ## 规则 |
| | | |
| | | - 运营素材不能覆盖 `PROJECT.md`、`发布地图.md` 或 release manifest 的当前发布事实。 |
| | | - 名称带 `X-`、`旧`、`归档` 的资料只作历史参考。 |
| | | - 如要对外发布官网内容,必须先在 `02-P/进度地图.md` 建立活跃任务并明确发布基线。 |
| New file |
| | |
| | | # legacy 运营资料 |
| | | |
| | | 本目录为旧项目运营资料的本地副本,来源: |
| | | |
| | | - `/Users/ar/Projects/PrivateVoice/X-运营资料/PrivateVoice-26.0604上架` |
| | | - `/Users/ar/Projects/PrivateVoice/X-运营资料/运营文档` |
| | | - `/Users/ar/Projects/PrivateVoice/X-运营资料/OP资料-Privatevoice.MD` |
| | | - `/Users/ar/Projects/PrivateVoice/X-运营资料/26.0602-备忘.markdown` |
| | | |
| | | 这些材料用于追溯历史上架、截图、文案和运营准备,不是当前发布事实源。 |
old mode 100755
new mode 100644
| | |
| | | # PrivateVoice |
| | | |
| | | > 长按说话,松手即输 —— 离线 · 极速 · 跨平台 |
| | | PrivateVoice 是一款离线语音转文字桌面 App:按住快捷键说话,松开后本地识别并输入到当前应用。当前源码位于 `C1.source/privatevoice.src`。 |
| | | |
| | |  |
| | |  |
| | | |
| | | PrivateVoice 是一款离线语音转文字工具。按住快捷键说话,松开即识别并输入文字到任意应用。无需联网,无需注册,开箱即用。 |
| | | ## 当前迁移基线 |
| | | |
| | | ## 核心特性 |
| | | - 分支:`codex/x-asr-experiment-2.1.29` |
| | | - Commit:`0ffbf1935c9d091cce22a5583275ac0902f7a693` |
| | | - Tag:`v2.2.0-build20260702.0303-real-user-test` |
| | | - 包类型:真实用户测试候选包,不是 notarized 官网正式发布包。 |
| | | - 迁移后源码根:`C1.source/privatevoice.src` |
| | | - 最新候选包本地路径:`C2.builds/PrivateVoice-Dictation-2.2.0-build20260702.0303/` |
| | | |
| | | - **完全离线** — SenseVoice 模型本地推理,无数据上传 |
| | | - **DirectML GPU 加速** — 自动检测显卡,CPU 自动回退 |
| | | - **两种输入模式** — 长按说话 + 短按自由说话 |
| | | - **自定义快捷键** — 支持 Ctrl / Alt / Shift / 功能键 / 字母键 |
| | | - **麦克风选择** — 下拉切换输入设备 |
| | | - **录音音效** — 马林巴风格提示音(可关闭) |
| | | - **录音计时** — 指示器实时显示录音时长(0:00 → 0:03 → ...) |
| | | - **Escape 取消** — 录音中按 Esc 立即取消 |
| | | - **剪贴板保护** — 粘贴后自动恢复原剪贴板内容 |
| | | - **识别历史** — 自动保存最近 50 条,可复制、删除、设置保留时长 |
| | | - **语气词过滤** — 自动去除"嗯、啊、呃"及 um/uh/hmm 等填充词 |
| | | - **静音自动停止** — 自由说话模式下连续 3 秒无声自动结束并识别 |
| | | - **中英混合优化** — 自动在中英文之间加空格,句首大写 |
| | | - **DPI 自适应** — 指示器根据系统缩放自动调整大小 |
| | | - **指示器可拖拽** — 拖动浮动指示器到任意位置,自动记忆 |
| | | - **中英双语界面** — 自动跟随系统语言 |
| | | - **应用内更新** — 自动检查新版本并提示下载 |
| | | - **系统托盘常驻** — 关闭窗口不退出 |
| | | - **开机自启** — 可选 |
| | | ## 核心能力 |
| | | |
| | | ## 支持的输入语言 |
| | | - 完全离线识别,模型本地推理。 |
| | | - 长按说话与短按自由说话两种输入模式。 |
| | | - 自定义快捷键、麦克风选择、录音音效、录音计时、Esc 取消。 |
| | | - 剪贴板保护、识别历史、用户词库、语气词过滤和中英混合文本后处理。 |
| | | - macOS 本地签名 DMG、Windows amd64 preview ZIP。 |
| | | - 当前 Round 6 已新增独立 `粤语` 语言选项,默认模型为 `SenseVoice Yue 2025-09-09`。 |
| | | |
| | | 当前 PrivateVoice 的产品语言版本为 **中文**、**English**、**French**、**German**、**Spanish**、**Italian**、**Portuguese**、**Japanese** 和 **Korean**。识别模型已经扩展到 SenseVoice、Moonshine English、Parakeet English、Parakeet TDT v3、日语 Zipformer、韩语 Zipformer、Qwen3-ASR 和 X-ASR zh-en 960ms;不同模型覆盖的输入语言和成熟度不同。 |
| | | ## 语言与模型状态 |
| | | |
| | | | 输入语言 / 场景 | 当前支持状态 | 默认 / 可选模型 | 说明 | |
| | | |---|---|---|---| |
| | | | 中文(普通话、简体 / 繁体) | 正式支持 | 默认:SenseVoice;可选高级:Qwen3-ASR;实验:X-ASR zh-en 960ms | 中文语言版本默认使用 SenseVoice,适合中文听写和日常输入。 | |
| | | | English | 正式支持 | 默认:Moonshine English;可选高级:Parakeet English;实验:X-ASR zh-en 960ms | English 语言版本默认使用 Moonshine English,Parakeet 适合更高准确率的英文输入。 | |
| | | | French / German / Spanish / Italian / Portuguese | 正式支持 | 默认:Parakeet TDT v3 | 欧洲五语统一使用 NVIDIA / sherpa-onnx 的 Parakeet TDT v3 多语种离线模型。Portuguese 覆盖 `pt-PT` 和 `pt-BR` 的基础识别路径。 | |
| | | | 中英混输 | 正式支持场景 | SenseVoice、Qwen3-ASR、X-ASR zh-en 960ms | 适合中文句子中夹杂英文术语、产品名、代码词等场景。 | |
| | | | 粤语(Cantonese) | 模型支持,尚非独立语言版本 | SenseVoice、Qwen3-ASR、X-ASR zh-en 960ms | 底层模型包含粤语能力;当前建议在中文语言版本下使用。 | |
| | | | 日语(日本語) | 正式支持 | 默认:Zipformer Japanese | 日语语言版本默认使用 ReazonSpeech 日语专项 Zipformer 离线模型。 | |
| | | | 韩语(한국어) | 正式支持 | 默认:Zipformer Korean | 韩语语言版本默认使用韩语专项 Zipformer 离线模型。 | |
| | | | 欧洲其他语种 | 模型支持,尚非独立语言版本 | Parakeet TDT v3 | Parakeet TDT v3 支持 25 种欧洲语言;当前只把 French、German、Spanish、Italian、Portuguese 作为正式产品语言版本。 | |
| | | | 其他语言 | 暂未正式支持 | 无独立默认模型 | 系统语言不在当前正式语言范围内时,当前产品会回落到 English 语言版本。 | |
| | | | --- | --- | --- | --- | |
| | | | 中文 | 正式支持 | 默认 SenseVoice;可选 Qwen3-ASR;实验 X-ASR zh-en 960ms | 中文听写和中英混输主路径。 | |
| | | | 粤语 | 真实用户测试候选 | 默认 SenseVoice Yue 2025-09-09 | 独立语言 profile:`yue-HK`,engine 参数固定 `yue`。 | |
| | | | English | 正式支持 | 默认 Moonshine English;可选 Parakeet English;实验 X-ASR zh-en 960ms | 英文听写主路径。 | |
| | | | French / German / Spanish / Italian / Portuguese | 正式支持 | Parakeet TDT v3 | 欧洲五语离线识别。 | |
| | | | Japanese | 正式支持 | Zipformer Japanese | 日语专项模型。 | |
| | | | Korean | 正式支持 | Zipformer Korean | 韩语专项模型。 | |
| | | | Windows preview | 技术预览 | SenseVoice only | 非 SenseVoice 模型可见但禁用;Windows 包未签名。 | |
| | | |
| | | | 识别模型 | 模型状态 | 覆盖的输入语言 / 场景 | 所属语言版本 | 说明 | |
| | | |---|---|---|---|---| |
| | | | SenseVoice | 默认轻量模型 | 中文、粤语、English、日语、韩语 | 中文默认 | 轻量模型,当前推荐用于中文和中英混输,也提供日语、韩语等底层识别能力。 | |
| | | | Qwen3-ASR | 高级模型 | 中文、简繁中文、粤语、中英混输 | 中文可选 | 体积更大,适合中文长句、技术词和更高准确率需求。 | |
| | | | Moonshine English | 默认轻量模型 | English | English 默认 | 轻量英文离线模型,适合英文日常听写。 | |
| | | | Parakeet English | 高级模型 | English | English 可选 | 当前接入的是 `parakeet-unified-en-0.6b` 英文模型,适合长句、技术词和更高准确率需求。 | |
| | | | Parakeet TDT v3 | 默认多语种模型 | French、German、Spanish、Italian、Portuguese | 欧洲五语默认 | 一个模型包覆盖欧洲五语,适合离线听写;发布前基准对比 Canary-1B-v2 和 Whisper large-v3-turbo。 | |
| | | | Zipformer Japanese | 默认专项模型 | Japanese | 日语默认 | 基于 ReazonSpeech 的日语专项离线模型。 | |
| | | | Zipformer Korean | 默认专项模型 | Korean | 韩语默认 | 韩语专项离线模型。 | |
| | | | X-ASR zh-en 960ms | 实验模型 | 中文、English、粤语、中英混输 | 中文 / English 可选实验项 | 实验性中英流式模型,用于评估更低延迟和未来“边说边出字”体验。 | |
| | | |
| | | ## 快速开始 |
| | | |
| | | ### 用户 |
| | | |
| | | 1. 从项目 Releases 页面下载最新版本 |
| | | 2. **Windows**:解压到任意目录,双击 `privatevoice.exe` |
| | | 3. **macOS**:打开 `.dmg`,拖入 Applications,首次启动需授予辅助功能权限 |
| | | 4. 首次启动自动下载语音模型(~200 MB) |
| | | 5. 就绪后,在任意输入框中按住 **右 Ctrl**(macOS: **右 Command**)说话,松开即输入 |
| | | |
| | | ### 开发者 |
| | | ## 开发入口 |
| | | |
| | | ```bash |
| | | # 环境要求 |
| | | # - Go 1.22+ |
| | | # - Node.js 20+ |
| | | # - CGO 编译器 (Windows: LLVM MinGW UCRT, macOS: Xcode Command Line Tools) |
| | | |
| | | # 克隆 |
| | | git clone <PrivateVoice 仓库地址> |
| | | cd PrivateVoice/privatevoice.src |
| | | |
| | | # 安装前端依赖 |
| | | cd frontend && npm install && cd .. |
| | | |
| | | # 开发模式 |
| | | wails3 dev |
| | | |
| | | # 生产构建 (Windows) |
| | | windres privatevoice.rc -o privatevoice.syso |
| | | CGO_ENABLED=1 go build -ldflags "-H windowsgui -s -w" -o privatevoice.exe . |
| | | |
| | | # 生产构建 (macOS) |
| | | CGO_ENABLED=1 go build -o privatevoice . |
| | | cd /Users/ar/Projects/PrivateVoice/03-O/C1.source/privatevoice.src |
| | | npm --prefix frontend run build |
| | | go test ./... |
| | | ``` |
| | | |
| | | ## 使用方法 |
| | | 常用打包和 QA 命令见 `CODEGRAPH.md`、`K2.项目管理/QA地图.md` 和 `K2.项目管理/发布地图.md`。 |
| | | |
| | | 1. 系统托盘出现 PrivateVoice 图标,引擎加载完成后就绪 |
| | | 2. **长按模式**:按住快捷键说话,松开即识别并粘贴 |
| | | 3. **自由说话**:短按一下开始,连续说话,再短按一下停止 |
| | | 4. **取消录音**:录音中按 Esc |
| | | 5. 右键托盘图标可打开设置或退出 |
| | | ## 目录 |
| | | |
| | | ## 运行文件 |
| | | |
| | | **Windows:** |
| | | |
| | | | 文件 | 大小 | 说明 | |
| | | |---|---|---| |
| | | | `privatevoice.exe` | ~15 MB | 主程序 | |
| | | | `onnxruntime.dll` | ~15 MB | ONNX Runtime | |
| | | | `sherpa-onnx-c-api.dll` | ~4 MB | sherpa-onnx C API | |
| | | | `sherpa-onnx-cxx-api.dll` | ~248 KB | sherpa-onnx C++ API | |
| | | | `models/sensevoice/` | ~200 MB | 语音模型(首次自动下载) | |
| | | |
| | | **macOS:** |
| | | |
| | | | 文件 | 大小 | 说明 | |
| | | |---|---|---| |
| | | | `PrivateVoice Dictation.app` | ~15 MB | 应用包(含 dylib) | |
| | | | `models/sensevoice/` | ~200 MB | 语音模型(首次自动下载) | |
| | | |
| | | ## 项目结构 |
| | | |
| | | ```text |
| | | 03-O/ |
| | | ├── C1.source/privatevoice.src/ 源码根 |
| | | ├── C2.builds/ 本地迁移的最新候选包,二进制包不纳入 Git 提交 |
| | | ├── C3.tools/ 工具索引 |
| | | ├── K1.业务知识/ 成果地图和长期业务/技术知识 |
| | | ├── K2.项目管理/ QA、发布、迁移、handoff、legacy 资料 |
| | | ├── K3.运营与发布资料/ 当前运营资料索引和本地素材 |
| | | └── CODEGRAPH.md 人工工程地图 |
| | | ``` |
| | | privatevoice.src/ |
| | | ├── main.go # 入口:单实例 + Wails 启动 |
| | | ├── app.go # 编排:热键 → 录音 → 识别 → 粘贴 |
| | | ├── internal/ |
| | | │ ├── audio/ # malgo 录音 + 设备枚举 |
| | | │ ├── engine/ # sherpa-onnx 推理 |
| | | │ ├── hotkey/ # 全局热键轮询 |
| | | │ ├── input/ # 剪贴板 + 粘贴 + 剪贴板保护 |
| | | │ ├── config/ # JSON 配置 |
| | | │ ├── history/ # 识别历史(JSON 持久化) |
| | | │ ├── textproc/ # 中英混合文本后处理 |
| | | │ ├── sound/ # 录音音效(合成 WAV) |
| | | │ ├── overlay/ # 浮动指示器(Win32 GDI+ / macOS AppKit) |
| | | │ └── ... |
| | | ├── services/ # Wails 服务(前端绑定) |
| | | └── frontend/ # Svelte + Vite |
| | | ├── src/components/ # 设置窗口 + 指示器 |
| | | └── src/lib/ # stores + i18n |
| | | ``` |
| | | |
| | | ## 技术栈 |
| | | |
| | | | 组件 | 技术 | |
| | | |---|---| |
| | | | 桌面框架 | [Wails v3](https://wails.io/) | |
| | | | 前端 | [Svelte 5](https://svelte.dev/) + Vite + TypeScript | |
| | | | 音频录制 | [malgo](https://github.com/gen2brain/malgo) (miniaudio) | |
| | | | 语音识别 | [sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx) (SenseVoice) | |
| | | | 全局热键 | Windows: GetAsyncKeyState 轮询 / macOS: NSEvent + CGEventSource | |
| | | | 文字输入 | Windows: 剪贴板 + SendInput / macOS: 剪贴板 + CGEvent | |
| | | | 浮动指示器 | Windows: GDI+ Layered Window / macOS: AppKit + CoreGraphics | |
| | | | 音效 | 程序合成 WAV + 平台原生播放 | |
| | | |
| | | ## 许可证 |
| | | |
| | | MIT License |
| | | |
| | | ## 致谢 |
| | | |
| | | - [SherpaOnnx](https://github.com/k2-fsa/sherpa-onnx) — 语音识别引擎 |
| | | - [Wails](https://wails.io/) — Go 桌面应用框架 |
| | | - [SenseVoice](https://github.com/FunAudioLLM/SenseVoice) — 语音模型 |