Ariver
2026-06-03 62f9a3b1e84455c32971272c03163ca543e2f68f
Implement Round 4 Parakeet model selection
14 files modified
8 files added
905 ■■■■■ changed files
02-P-NBL/round-4/code-review.md 38 ●●●●● patch | view | raw | blame | history
02-P-NBL/round-4/dev-self-check.md 82 ●●●●● patch | view | raw | blame | history
02-P-NBL/round-4/logs/git-diff-check-20260603-round4.log patch | view | raw | blame | history
02-P-NBL/round-4/logs/go-test-all-20260603-round4.log 23 ●●●●● patch | view | raw | blame | history
02-P-NBL/round-4/logs/npm-ci-20260603-round4.log 12 ●●●●● patch | view | raw | blame | history
02-P-NBL/round-4/logs/npm-run-build-20260603-round4.log 38 ●●●●● patch | view | raw | blame | history
02-P-NBL/round-4/qa-handoff.md 73 ●●●●● patch | view | raw | blame | history
02-P-NBL/round-4/todo-list.md 34 ●●●● patch | view | raw | blame | history
privatevoice.src/app.go 2 ●●● patch | view | raw | blame | history
privatevoice.src/frontend/src/components/settings/GeneralPage.svelte 229 ●●●●● patch | view | raw | blame | history
privatevoice.src/frontend/src/lib/i18n/en.json 17 ●●●●● patch | view | raw | blame | history
privatevoice.src/frontend/src/lib/i18n/zh.json 17 ●●●●● patch | view | raw | blame | history
privatevoice.src/internal/engine/engine.go 2 ●●● patch | view | raw | blame | history
privatevoice.src/internal/engine/engine_darwin.go 5 ●●●●● patch | view | raw | blame | history
privatevoice.src/internal/engine/engine_darwin_test.go 51 ●●●●● patch | view | raw | blame | history
privatevoice.src/internal/model/model_test.go 69 ●●●●● patch | view | raw | blame | history
privatevoice.src/internal/model/profile.go 1 ●●●● patch | view | raw | blame | history
privatevoice.src/internal/model/registry.go 40 ●●●●● patch | view | raw | blame | history
privatevoice.src/internal/modelselection/selection.go 21 ●●●●● patch | view | raw | blame | history
privatevoice.src/internal/modelselection/selection_test.go 19 ●●●● patch | view | raw | blame | history
privatevoice.src/services/config_service.go 2 ●●●●● patch | view | raw | blame | history
privatevoice.src/services/engine_service.go 130 ●●●●● patch | view | raw | blame | history
02-P-NBL/round-4/code-review.md
New file
@@ -0,0 +1,38 @@
# Round 4 代码复审记录:Parakeet English 高级模型
状态:未发现研发自测阻断问题,待 QA 真机验证
日期:2026-06-03
## 1. 复审结论
本轮代码满足 Round 4 PRD 的研发交付边界,可以进入 QA。
## 2. 已重点复核
- English 默认模型保持为 `moonshine-en`。
- `parakeet-en` 只挂在 English 的 `UpgradeModelIDs` 下。
- 中文语言 profile 未加入 Parakeet。
- 模型选择 API 会校验目标模型是否属于当前语言。
- 手动选择 Parakeet 后,如果当前有效语言变为中文,resolver 会回落到中文默认 SenseVoice。
- 未安装模型不能直接选择。
- Parakeet 下载失败不会写入 `SelectedModelID`。
- Parakeet required files 缺失时不会被判定为可用模型。
- Nemo transducer config 设置了 encoder、decoder、joiner、tokens、model type。
## 3. 残余风险
- Parakeet 的真实 sherpa-onnx 初始化必须依赖真机下载后的模型文件验证,单元测试只能验证 config wiring。
- 已安装但文件内容损坏的 Parakeet 仍可能在引擎初始化阶段失败;当前行为是显示引擎错误,QA 需要确认用户可以切回 Moonshine。
- Parakeet 约 478 MiB 下载包,首次下载耗时和失败恢复需要真实网络测试。
## 4. 代码范围
- `privatevoice.src/internal/model/*`
- `privatevoice.src/internal/modelselection/*`
- `privatevoice.src/internal/engine/*`
- `privatevoice.src/services/engine_service.go`
- `privatevoice.src/services/config_service.go`
- `privatevoice.src/frontend/src/components/settings/GeneralPage.svelte`
- `privatevoice.src/frontend/src/lib/i18n/en.json`
- `privatevoice.src/frontend/src/lib/i18n/zh.json`
02-P-NBL/round-4/dev-self-check.md
New file
@@ -0,0 +1,82 @@
# Round 4 研发自查报告:Parakeet English 高级模型
状态:研发自测通过,待 QA 真机验证
日期:2026-06-03
分支:`codex/round4-parakeet-english`
基线:Round 3 完成 commit `7bedb18`
## 1. 本轮已实现
- 新增 `parakeet-en` 模型 profile。
- English 默认模型仍为 `moonshine-en`。
- English 高级模型新增 `Parakeet English`。
- 新增 `nemo_transducer` backend kind。
- 新增 Parakeet required files 校验:
  - `encoder.int8.onnx`
  - `decoder.int8.onnx`
  - `joiner.int8.onnx`
  - `tokens.txt`
- 新增 sherpa-onnx Nemo transducer 配置生成。
- 新增模型选择服务 API:
  - `ListModelOptions`
  - `SelectModel`
  - `DownloadModelByID`
- 设置页新增“识别模型”区域。
- English 下显示 Moonshine + Parakeet。
- 中文下只显示 SenseVoice。
- 语言切换会回到该语言默认模型,避免中文环境继续使用英文手动模型。
- 下载 Parakeet 成功后才写入手动模型选择配置,下载失败不改写当前模型配置。
## 2. 自动化验证
已通过:
```bash
cd privatevoice.src
go test ./... -count=1
```
日志:`logs/go-test-all-20260603-round4.log`
已通过:
```bash
cd privatevoice.src/frontend
npm run build
```
日志:`logs/npm-run-build-20260603-round4.log`
已通过:
```bash
git diff --check
```
日志:`logs/git-diff-check-20260603-round4.log`
依赖安装:
```bash
cd privatevoice.src/frontend
npm ci
```
日志:`logs/npm-ci-20260603-round4.log`
## 3. 观察到的非阻断项
- `npm ci` 报告 6 个 npm audit vulnerability,来自现有前端依赖链;本轮未升级依赖,不作为 Round 4 阻断项。
- `npm run build` 仍报告 3 个既有 Svelte a11y warning,位置在 `UpdateDialog.svelte` 和 `ToggleSwitch.svelte`;本轮新增设置页没有构建 warning。
## 4. 待 QA 真机验证
- Parakeet 官方包真实下载。
- Parakeet 文件解压和安装状态识别。
- Parakeet 引擎初始化。
- Parakeet 英文识别。
- Parakeet 断网英文识别。
- Parakeet 切回 Moonshine。
- 切回中文后 SenseVoice 正常。
- 下载失败和中断下载不破坏 Moonshine/SenseVoice。
02-P-NBL/round-4/logs/git-diff-check-20260603-round4.log
02-P-NBL/round-4/logs/go-test-all-20260603-round4.log
New file
@@ -0,0 +1,23 @@
?       voicesnap    [no test files]
?       voicesnap/internal/audio    [no test files]
ok      voicesnap/internal/config    0.809s
ok      voicesnap/internal/correctioncsv    1.301s
?       voicesnap/internal/dock    [no test files]
ok      voicesnap/internal/engine    2.348s
ok      voicesnap/internal/history    0.964s
?       voicesnap/internal/hotkey    [no test files]
?       voicesnap/internal/input    [no test files]
ok      voicesnap/internal/language    1.888s
?       voicesnap/internal/logger    [no test files]
ok      voicesnap/internal/model    1.562s
ok      voicesnap/internal/modelselection    2.767s
?       voicesnap/internal/overlay    [no test files]
?       voicesnap/internal/paths    [no test files]
?       voicesnap/internal/permissions    [no test files]
?       voicesnap/internal/singleinstance    [no test files]
?       voicesnap/internal/sound    [no test files]
?       voicesnap/internal/startup    [no test files]
?       voicesnap/internal/textproc    [no test files]
?       voicesnap/internal/updater    [no test files]
ok      voicesnap/internal/userdict    3.094s
?       voicesnap/services    [no test files]
02-P-NBL/round-4/logs/npm-ci-20260603-round4.log
New file
@@ -0,0 +1,12 @@
added 48 packages, and audited 49 packages in 3s
7 packages are looking for funding
  run `npm fund` for details
6 vulnerabilities (3 moderate, 3 high)
To address all issues, run:
  npm audit fix
Run `npm audit` for details.
02-P-NBL/round-4/logs/npm-run-build-20260603-round4.log
New file
@@ -0,0 +1,38 @@
> privatevoice-input-frontend@2.1.21 build
> vite build
vite v6.4.1 building for production...
transforming...
2:42:18 PM [vite-plugin-svelte] src/components/update/UpdateDialog.svelte:28:0 Elements with the 'dialog' interactive role must have a tabindex value
https://svelte.dev/e/a11y_interactive_supports_focus
26:
27: <!-- svelte-ignore a11y_click_events_have_key_events -->
28: <div class="overlay" onclick={onclose} role="dialog">
          ^
29:   <!-- svelte-ignore a11y_click_events_have_key_events -->
30:   <div class="dialog" onclick={(e) => e.stopPropagation()} role="document">
2:42:18 PM [vite-plugin-svelte] src/components/update/UpdateDialog.svelte:30:2 Non-interactive element `<div>` should not be assigned mouse or keyboard event listeners
https://svelte.dev/e/a11y_no_noninteractive_element_interactions
28: <div class="overlay" onclick={onclose} role="dialog">
29:   <!-- svelte-ignore a11y_click_events_have_key_events -->
30:   <div class="dialog" onclick={(e) => e.stopPropagation()} role="document">
            ^
31:     <h2 class="title">{t('update.title')}</h2>
32:     <p class="version">{t('update.version', { version: info.version })}</p>
2:42:18 PM [vite-plugin-svelte] src/components/shared/ToggleSwitch.svelte:17:0 Buttons and links should either contain text or have an `aria-label`, `aria-labelledby` or `title` attribute
https://svelte.dev/e/a11y_consider_explicit_label
15: </script>
16:
17: <button
           ^
18:   class="toggle"
19:   class:active={checked}
✓ 163 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html                      0.41 kB │ gzip:  0.27 kB
dist/assets/app-icon-Bs-pS5hx.png  400.98 kB
dist/assets/index-Bm3gykhy.css      35.00 kB │ gzip:  5.92 kB
dist/assets/index-DUZkgsrv.js      142.07 kB │ gzip: 47.42 kB
✓ built in 1.30s
02-P-NBL/round-4/qa-handoff.md
New file
@@ -0,0 +1,73 @@
# Round 4 QA Handoff:Parakeet English 高级模型
状态:待 QA 真机验证
日期:2026-06-03
## 1. QA 目标
验证 English 用户可以下载、启用、离线使用 Parakeet English,并且可以回退到 Moonshine;中文 SenseVoice 路径不受影响。
## 2. 关键模型信息
模型 ID:`parakeet-en`
显示名:`Parakeet English`
Backend:`nemo_transducer`
官方下载 URL:
```text
https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-unified-en-0.6b-int8-non-streaming.tar.bz2
```
下载包大小:`501350460 bytes`,约 `478 MiB`
必需文件:
- `encoder.int8.onnx`
- `decoder.int8.onnx`
- `joiner.int8.onnx`
- `tokens.txt`
## 3. QA 前置条件
- 保留现有用户数据备份。
- 当前测试包基于 `codex/round4-parakeet-english`。
- 确认 Round 3 的 Moonshine/SenseVoice 功能在测试机上可用。
- 测试前记录当前模型目录和配置文件状态。
## 4. 建议测试路径
1. 启动 App,进入设置页。
2. 切到 English。
3. 确认“识别模型”区域显示 Moonshine 和 Parakeet。
4. 确认 Moonshine 是默认/当前模型。
5. 点击 Parakeet 下载。
6. 下载期间确认按钮禁用、进度显示、无重复下载。
7. 下载完成后确认当前模型变为 Parakeet。
8. 进行英文识别。
9. 断网后重启 App。
10. 继续进行英文识别,确认离线可用。
11. 切回 Moonshine,确认英文识别仍可用。
12. 切到中文,确认 SenseVoice 成为当前模型。
13. 进行中文识别,确认 SenseVoice 路径无回归。
## 5. 失败保护测试
- 下载中断后,重启 App,确认仍使用原模型。
- 下载中断后,确认 Parakeet 未被判定为已安装。
- 删除 Parakeet 任一必需文件,确认模型状态不应显示为可用。
- 中文状态下不得显示或启用 Parakeet。
- 非法 model ID 调用选择 API 不应写坏配置。
## 6. 通过标准
- English 默认仍是 Moonshine。
- Parakeet 下载成功后可以被选中并完成英文识别。
- 断网后 Parakeet 英文识别仍可用。
- 可以从 Parakeet 切回 Moonshine。
- 切回中文后 SenseVoice 正常识别。
- 失败下载不会破坏原模型。
- 用户历史、词库、权限、热键配置不丢失。
02-P-NBL/round-4/todo-list.md
@@ -1,6 +1,6 @@
# Round 4 Todo List:Parakeet English 高级模型
状态:进行中
状态:研发自测通过,待 QA 真机验证
所属子项目:`02-P-NBL`
Round:Round 4
日期:2026-06-03
@@ -17,25 +17,25 @@
## 2. 研发实现
- [ ] 新增 `parakeet-en` profile
- [ ] 新增 `BackendNemoTransducer`
- [ ] 新增 Parakeet required files 校验
- [ ] 新增 Nemo transducer backend builder
- [ ] 新增模型选择服务 API
- [ ] 设置页新增模型选择 UI
- [ ] English 可从 Moonshine 切到 Parakeet
- [ ] English 可从 Parakeet 切回 Moonshine
- [ ] Parakeet 下载失败不破坏已有模型
- [ ] 中文 SenseVoice 路径不退化
- [x] 新增 `parakeet-en` profile
- [x] 新增 `BackendNemoTransducer`
- [x] 新增 Parakeet required files 校验
- [x] 新增 Nemo transducer backend builder
- [x] 新增模型选择服务 API
- [x] 设置页新增模型选择 UI
- [x] English 可从 Moonshine 切到 Parakeet
- [x] English 可从 Parakeet 切回 Moonshine
- [x] Parakeet 下载失败不破坏已有模型
- [x] 中文 SenseVoice 路径不退化
## 3. 开发自查
- [ ] `go test ./... -count=1`
- [ ] `npm run build`
- [ ] `git diff --check`
- [ ] 研发自查报告
- [ ] 代码复审
- [ ] QA handoff
- [x] `go test ./... -count=1`
- [x] `npm run build`
- [x] `git diff --check`
- [x] 研发自查报告
- [x] 代码复审
- [x] QA handoff
## 4. QA
privatevoice.src/app.go
@@ -112,7 +112,7 @@
    // Create services for Wails bindings
    appService := services.NewAppService(app.cfg, appDisplayVersion)
    configService := services.NewConfigService(app.cfg)
    engineService := services.NewEngineService()
    engineService := services.NewEngineService(app.cfg)
    app.engineService = engineService
    hotkeyService := services.NewHotkeyService(app.cfg)
    permissionService := services.NewPermissionService()
privatevoice.src/frontend/src/components/settings/GeneralPage.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
  import { Call } from '@wailsio/runtime'
  import { Call, Events } from '@wailsio/runtime'
  import ToggleSwitch from '../shared/ToggleSwitch.svelte'
  import { t, setLocale } from '../../lib/i18n'
  import { autoHide, soundFeedback, copyToClipboard, startAtLogin, hotkeyVK, hotkeyMode, languageMode, languageID, effectiveLanguageID, uiLocale } from '../../lib/stores/config'
@@ -9,6 +9,16 @@
  interface InputDevice {
    name: string
    isDefault: boolean
  }
  interface ModelOption {
    modelID: string
    displayName: string
    tier: string
    installed: boolean
    isCurrent: boolean
    isDefault: boolean
    downloadSize?: string
  }
  let autoHideVal = $state(true)
@@ -29,6 +39,10 @@
  let hintText = $state('')
  let devices = $state<InputDevice[]>([])
  let showDeviceDropdown = $state(false)
  let modelOptions = $state<ModelOption[]>([])
  let modelBusyID = $state('')
  let modelProgress = $state(0)
  let modelError = $state('')
  const unsub1 = autoHide.subscribe(v => { autoHideVal = v })
  const unsub2 = startAtLogin.subscribe(v => { startAtLoginVal = v })
@@ -42,6 +56,13 @@
  const unsub10 = languageMode.subscribe(v => { languageModeVal = v })
  const unsub11 = languageID.subscribe(v => { languageIDVal = v })
  const unsub12 = effectiveLanguageID.subscribe(v => { effectiveLanguageIDVal = v })
  Events.On('model:download-progress', (ev: any) => {
    const data = ev?.data
    if (data?.modelID && data.modelID === modelBusyID && typeof data.percent === 'number') {
      modelProgress = data.percent
    }
  })
  // Load actual device name from backend on mount
  async function loadDeviceName() {
@@ -90,6 +111,7 @@
      const lang: any = await Call.ByName('voicesnap/services.ConfigService.GetLanguageSettings')
      applyLanguageSettings(lang)
    } catch {}
    await loadModelOptions()
    settingsLoaded = true
  }
  loadSettings()
@@ -120,6 +142,64 @@
      }
      await Call.ByName('voicesnap/services.EngineService.ReloadCurrentModel')
    } catch {}
  }
  async function loadModelOptions() {
    try {
      const options: any = await Call.ByName('voicesnap/services.EngineService.ListModelOptions')
      modelOptions = Array.isArray(options) ? options : []
    } catch {
      modelOptions = []
    }
  }
  function modelDescription(modelID: string): string {
    switch (modelID) {
      case 'sensevoice-zh': return t('models.sensevoiceDesc')
      case 'moonshine-en': return t('models.moonshineDesc')
      case 'parakeet-en': return t('models.parakeetDesc')
      default: return ''
    }
  }
  function modelTierLabel(option: ModelOption): string {
    if (option.isDefault) return t('settings.modelDefault')
    if (option.tier === 'advanced') return t('settings.modelAdvanced')
    return option.tier || ''
  }
  function modelActionLabel(option: ModelOption): string {
    if (modelBusyID === option.modelID) {
      if (!option.installed && modelProgress > 0) {
        return `${Math.min(100, Math.max(0, modelProgress)).toFixed(0)}%`
      }
      return t('settings.modelWorking')
    }
    if (option.isCurrent) return t('settings.modelCurrent')
    if (option.installed) return t('settings.modelUse')
    return t('settings.modelDownload')
  }
  async function onModelAction(option: ModelOption) {
    if (option.isCurrent || modelBusyID) return
    modelBusyID = option.modelID
    modelProgress = 0
    modelError = ''
    engineStatus.set('loading')
    try {
      if (option.installed) {
        await Call.ByName('voicesnap/services.EngineService.SelectModel', option.modelID)
      } else {
        await Call.ByName('voicesnap/services.EngineService.DownloadModelByID', option.modelID)
      }
      await loadModelOptions()
    } catch (err: any) {
      modelError = err?.message || String(err || t('settings.modelActionFailed'))
      await loadModelOptions()
    } finally {
      modelBusyID = ''
      modelProgress = 0
    }
  }
  async function onAutoHideChange(checked: boolean) {
@@ -169,6 +249,7 @@
        : await Call.ByName('voicesnap/services.ConfigService.SetLanguageManual', selection)
      applyLanguageSettings(settings)
      await syncEngineForCurrentModel()
      await loadModelOptions()
    } catch {
      languageModeVal = prevMode
      languageIDVal = prevID
@@ -374,6 +455,53 @@
        </button>
      </div>
    </div>
  </div>
  {/if}
  {#if settingsLoaded && modelOptions.length > 0}
  <div class="section">
    <div class="setting-row models-header">
      <div class="setting-info">
        <span class="setting-label">{t('settings.recognitionModel')}</span>
        <span class="setting-desc">
          {effectiveLanguageIDVal === 'en' ? t('settings.recognitionModelEnglishDesc') : t('settings.recognitionModelChineseDesc')}
        </span>
      </div>
    </div>
    <div class="model-list">
      {#each modelOptions as option}
        <div class="model-card" class:current={option.isCurrent}>
          <div class="model-main">
            <div class="model-title-row">
              <span class="model-title">{option.displayName}</span>
              <span class="model-pill" class:current={option.isCurrent}>
                {option.isCurrent ? t('settings.modelCurrent') : modelTierLabel(option)}
              </span>
            </div>
            <span class="model-desc">{modelDescription(option.modelID)}</span>
            <span class="model-meta">
              {option.installed ? t('settings.modelInstalled') : t('settings.modelNotInstalled')}
              {#if option.downloadSize}
                · {option.downloadSize}
              {/if}
            </span>
          </div>
          <button
            class="model-action"
            class:primary={!option.isCurrent}
            disabled={option.isCurrent || !!modelBusyID}
            onclick={() => onModelAction(option)}
          >
            {modelActionLabel(option)}
          </button>
        </div>
      {/each}
    </div>
    {#if modelError}
      <p class="model-error">{modelError}</p>
    {/if}
  </div>
  {/if}
@@ -663,6 +791,105 @@
    margin: var(--spacing-sm) 0;
  }
  .models-header {
    align-items: flex-start;
  }
  .model-list {
    display: flex;
    flex-direction: column;
    gap: 8px;
  }
  .model-card {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--spacing-md);
    min-height: 78px;
    padding: 10px 12px;
    border: 1px solid var(--color-separator);
    border-radius: var(--radius-sm);
    background: var(--color-bg-secondary);
  }
  .model-card.current {
    border-color: rgba(0, 122, 255, 0.38);
  }
  .model-main {
    display: flex;
    flex-direction: column;
    gap: 3px;
    min-width: 0;
  }
  .model-title-row {
    display: flex;
    align-items: center;
    gap: 8px;
    min-width: 0;
  }
  .model-title {
    font-size: var(--font-size-base);
    font-weight: 600;
  }
  .model-pill {
    display: inline-flex;
    align-items: center;
    height: 20px;
    padding: 0 7px;
    border-radius: 999px;
    background: var(--color-bg-tertiary);
    color: var(--color-secondary-label);
    font-size: 11px;
    font-weight: 500;
    white-space: nowrap;
  }
  .model-pill.current {
    background: rgba(52, 199, 89, 0.14);
    color: var(--color-green);
  }
  .model-desc,
  .model-meta {
    font-size: var(--font-size-xs);
    color: var(--color-tertiary-label);
  }
  .model-action {
    min-width: 76px;
    height: 30px;
    padding: 0 12px;
    border: none;
    border-radius: var(--radius-sm);
    background: var(--color-bg-tertiary);
    color: var(--color-secondary-label);
    font-size: var(--font-size-sm);
    font-weight: 500;
    cursor: pointer;
    white-space: nowrap;
  }
  .model-action.primary {
    background: var(--color-blue);
    color: white;
  }
  .model-action:disabled {
    cursor: not-allowed;
    opacity: 0.72;
  }
  .model-error {
    margin-top: 8px;
    font-size: var(--font-size-xs);
    color: var(--color-red);
  }
  /* Device selector */
  .device-selector {
    position: relative;
privatevoice.src/frontend/src/lib/i18n/en.json
@@ -51,6 +51,18 @@
    "languageAutoDesc": "Follow the system language, currently choosing only between Chinese and English",
    "languageChineseDesc": "Use the Chinese interface with SenseVoice as the default offline model",
    "languageEnglishDesc": "Use the English interface with Moonshine English as the default offline model",
    "recognitionModel": "Recognition Model",
    "recognitionModelChineseDesc": "Use the default lightweight Chinese offline model",
    "recognitionModelEnglishDesc": "Choose the default lightweight model or a larger high-quality English model",
    "modelDefault": "Default",
    "modelAdvanced": "Advanced",
    "modelCurrent": "Current",
    "modelUse": "Use",
    "modelDownload": "Download",
    "modelWorking": "Working",
    "modelInstalled": "Installed",
    "modelNotInstalled": "Not installed",
    "modelActionFailed": "Model action failed",
    "autoHide": "Auto-hide Indicator",
    "autoHideDesc": "Automatically hide the floating indicator after completion",
    "soundFeedback": "Sound Feedback",
@@ -62,6 +74,11 @@
    "hideDockIcon": "Hide Dock Icon",
    "hideDockIconDesc": "Show PrivateVoice Input only in the menu bar"
  },
  "models": {
    "sensevoiceDesc": "Lightweight Chinese offline model for Chinese and mixed Chinese-English input.",
    "moonshineDesc": "Lightweight English offline model, recommended default.",
    "parakeetDesc": "Higher quality English offline model for longer sentences and technical terms."
  },
  "permissions": {
    "title": "Permission Check",
    "subtitle": "PrivateVoice Input needs these permissions for recording, global hotkeys, and automatic text input",
privatevoice.src/frontend/src/lib/i18n/zh.json
@@ -51,6 +51,18 @@
    "languageAutoDesc": "跟随系统语言,当前只会在中文和 English 之间选择",
    "languageChineseDesc": "使用中文界面,并默认使用 SenseVoice 离线模型",
    "languageEnglishDesc": "使用 English 界面,并默认使用 Moonshine English 离线模型",
    "recognitionModel": "识别模型",
    "recognitionModelChineseDesc": "使用默认轻量中文离线模型",
    "recognitionModelEnglishDesc": "可选择默认轻量英文模型,或下载更高质量的英文模型",
    "modelDefault": "默认",
    "modelAdvanced": "高级",
    "modelCurrent": "当前",
    "modelUse": "使用",
    "modelDownload": "下载",
    "modelWorking": "处理中",
    "modelInstalled": "已安装",
    "modelNotInstalled": "未安装",
    "modelActionFailed": "模型操作失败",
    "autoHide": "自动隐藏指示器",
    "autoHideDesc": "操作完成后自动隐藏浮动指示器",
    "soundFeedback": "提示音",
@@ -62,6 +74,11 @@
    "hideDockIcon": "隐藏 Dock 图标",
    "hideDockIconDesc": "只在菜单栏显示私语输入图标"
  },
  "models": {
    "sensevoiceDesc": "轻量中文离线模型,适合中文和中英混输。",
    "moonshineDesc": "轻量英文离线模型,默认推荐。",
    "parakeetDesc": "更高质量的英文离线模型,适合长句和技术词。"
  },
  "permissions": {
    "title": "权限检查",
    "subtitle": "私语输入需要这些权限才能稳定完成录音、全局热键和自动上屏",
privatevoice.src/internal/engine/engine.go
@@ -90,7 +90,7 @@
func isSupportedBackend(backend string) bool {
    switch backend {
    case model.BackendSenseVoice, model.BackendMoonshine:
    case model.BackendSenseVoice, model.BackendMoonshine, model.BackendNemoTransducer:
        return true
    default:
        return false
privatevoice.src/internal/engine/engine_darwin.go
@@ -62,6 +62,11 @@
        config.ModelConfig.Moonshine.Encoder = resolved.Files["encoder"]
        config.ModelConfig.Moonshine.UncachedDecoder = resolved.Files["uncached_decoder"]
        config.ModelConfig.Moonshine.CachedDecoder = resolved.Files["cached_decoder"]
    case model.BackendNemoTransducer:
        config.ModelConfig.Transducer.Encoder = resolved.Files["encoder"]
        config.ModelConfig.Transducer.Decoder = resolved.Files["decoder"]
        config.ModelConfig.Transducer.Joiner = resolved.Files["joiner"]
        config.ModelConfig.ModelType = model.BackendNemoTransducer
    default:
        return sherpa.OfflineRecognizerConfig{}, fmt.Errorf("unsupported backend kind: %s", resolved.BackendKind)
    }
privatevoice.src/internal/engine/engine_darwin_test.go
New file
@@ -0,0 +1,51 @@
//go:build darwin
package engine
import (
    "path/filepath"
    "testing"
    "voicesnap/internal/model"
)
func TestOfflineConfigForParakeetUsesNemoTransducer(t *testing.T) {
    root := t.TempDir()
    files := map[string]string{
        "encoder": filepath.Join(root, "encoder.int8.onnx"),
        "decoder": filepath.Join(root, "decoder.int8.onnx"),
        "joiner":  filepath.Join(root, "joiner.int8.onnx"),
        "tokens":  filepath.Join(root, "tokens.txt"),
    }
    resolved := model.ResolvedModel{
        ModelID:     model.ParakeetModelID,
        BackendKind: model.BackendNemoTransducer,
        Profile: model.ModelProfile{
            DisplayName:   "Parakeet English",
            BackendKind:   model.BackendNemoTransducer,
            ProviderOrder: []string{"cpu"},
            NumThreads:    4,
        },
        Files: files,
    }
    config, err := offlineConfigForResolvedModel(resolved, "cpu")
    if err != nil {
        t.Fatal(err)
    }
    if config.ModelConfig.Transducer.Encoder != files["encoder"] {
        t.Fatalf("encoder = %q, want %q", config.ModelConfig.Transducer.Encoder, files["encoder"])
    }
    if config.ModelConfig.Transducer.Decoder != files["decoder"] {
        t.Fatalf("decoder = %q, want %q", config.ModelConfig.Transducer.Decoder, files["decoder"])
    }
    if config.ModelConfig.Transducer.Joiner != files["joiner"] {
        t.Fatalf("joiner = %q, want %q", config.ModelConfig.Transducer.Joiner, files["joiner"])
    }
    if config.ModelConfig.Tokens != files["tokens"] {
        t.Fatalf("tokens = %q, want %q", config.ModelConfig.Tokens, files["tokens"])
    }
    if config.ModelConfig.ModelType != model.BackendNemoTransducer {
        t.Fatalf("model type = %q, want %q", config.ModelConfig.ModelType, model.BackendNemoTransducer)
    }
}
privatevoice.src/internal/model/model_test.go
@@ -39,6 +39,25 @@
    }
}
func TestRegistryReturnsParakeetEnglish(t *testing.T) {
    profile, err := GetModelProfile(ParakeetModelID)
    if err != nil {
        t.Fatal(err)
    }
    if profile.BackendKind != BackendNemoTransducer {
        t.Fatalf("backend kind = %q, want %q", profile.BackendKind, BackendNemoTransducer)
    }
    if profile.InstallDirName != "parakeet-en" {
        t.Fatalf("install dir = %q, want parakeet-en", profile.InstallDirName)
    }
    if profile.Tier != "advanced" {
        t.Fatalf("tier = %q, want advanced", profile.Tier)
    }
    if len(profile.DownloadURLs) == 0 {
        t.Fatal("expected parakeet download URL")
    }
}
func TestEnglishLanguageDefaultsToMoonshine(t *testing.T) {
    profile, err := GetLanguageProfile(EnglishLanguageID)
    if err != nil {
@@ -46,6 +65,9 @@
    }
    if profile.DefaultModelID != MoonshineModelID {
        t.Fatalf("English default model = %q, want %q", profile.DefaultModelID, MoonshineModelID)
    }
    if len(profile.UpgradeModelIDs) != 1 || profile.UpgradeModelIDs[0] != ParakeetModelID {
        t.Fatalf("English upgrade models = %v, want [%s]", profile.UpgradeModelIDs, ParakeetModelID)
    }
}
@@ -132,6 +154,42 @@
    result := ValidateModelDir(profile, dir)
    if result.Valid {
        t.Fatal("expected missing cached decoder to fail validation")
    }
}
func TestValidateParakeetRequiredFiles(t *testing.T) {
    profile, err := GetModelProfile(ParakeetModelID)
    if err != nil {
        t.Fatal(err)
    }
    dir := t.TempDir()
    createValidParakeet(t, dir)
    result := ValidateModelDir(profile, dir)
    if !result.Valid {
        t.Fatalf("expected valid parakeet dir, missing=%v problems=%v", result.Missing, result.Problems)
    }
    for _, role := range []string{"encoder", "decoder", "joiner", "tokens"} {
        if result.Files[role] == "" {
            t.Fatalf("missing resolved file role %q", role)
        }
    }
}
func TestValidateParakeetRejectsMissingFile(t *testing.T) {
    profile, err := GetModelProfile(ParakeetModelID)
    if err != nil {
        t.Fatal(err)
    }
    dir := t.TempDir()
    createValidParakeet(t, dir)
    if err := os.Remove(filepath.Join(dir, "joiner.int8.onnx")); err != nil {
        t.Fatal(err)
    }
    result := ValidateModelDir(profile, dir)
    if result.Valid {
        t.Fatal("expected missing joiner to fail validation")
    }
}
@@ -428,6 +486,17 @@
    writeTestFile(t, filepath.Join(dir, "tokens.txt"), "tokens")
}
func createValidParakeet(t *testing.T, dir string) {
    t.Helper()
    if err := os.MkdirAll(dir, 0755); err != nil {
        t.Fatal(err)
    }
    writeTestFile(t, filepath.Join(dir, "encoder.int8.onnx"), "encoder")
    writeTestFile(t, filepath.Join(dir, "decoder.int8.onnx"), "decoder")
    writeTestFile(t, filepath.Join(dir, "joiner.int8.onnx"), "joiner")
    writeTestFile(t, filepath.Join(dir, "tokens.txt"), "tokens")
}
func tarArchive(t *testing.T, files map[string]string) []byte {
    t.Helper()
    var buf bytes.Buffer
privatevoice.src/internal/model/profile.go
@@ -3,6 +3,7 @@
const (
    DefaultModelID    = "sensevoice-zh"
    MoonshineModelID  = "moonshine-en"
    ParakeetModelID   = "parakeet-en"
    DefaultLanguageID = "zh-CN"
    EnglishLanguageID = "en"
)
privatevoice.src/internal/model/registry.go
@@ -8,6 +8,7 @@
const (
    BackendSenseVoice = "sensevoice"
    BackendMoonshine  = "moonshine"
    BackendNemoTransducer = "nemo_transducer"
)
var modelProfiles = map[string]ModelProfile{
@@ -91,6 +92,44 @@
        ProviderOrder: []string{"cpu"},
        NumThreads:    4,
    },
    ParakeetModelID: {
        ID:                   ParakeetModelID,
        DisplayName:          "Parakeet English",
        BackendKind:          BackendNemoTransducer,
        Tier:                 "advanced",
        SupportedLanguageIDs: []string{EnglishLanguageID},
        RecommendedFor:       []string{EnglishLanguageID},
        Description:          "高质量英文离线模型,适合英文长句、技术词和更高准确率需求。",
        ApproxSize:           "约 478 MiB 下载包",
        DownloadURLs: []string{
            "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-unified-en-0.6b-int8-non-streaming.tar.bz2",
        },
        InstallDirName: "parakeet-en",
        RequiredFiles: []RequiredFileRule{
            {
                Role:     "encoder",
                AllOf:    []string{"encoder.int8.onnx"},
                Required: true,
            },
            {
                Role:     "decoder",
                AllOf:    []string{"decoder.int8.onnx"},
                Required: true,
            },
            {
                Role:     "joiner",
                AllOf:    []string{"joiner.int8.onnx"},
                Required: true,
            },
            {
                Role:     "tokens",
                AllOf:    []string{"tokens.txt"},
                Required: true,
            },
        },
        ProviderOrder: []string{"cpu"},
        NumThreads:    4,
    },
}
var languageProfiles = map[string]LanguageProfile{
@@ -109,6 +148,7 @@
        UILocale:       "en",
        SystemMatchers: []string{"en", "en-US", "en-GB", "en-AU", "en-CA"},
        DefaultModelID: MoonshineModelID,
        UpgradeModelIDs: []string{ParakeetModelID},
    },
}
privatevoice.src/internal/modelselection/selection.go
@@ -27,6 +27,7 @@
    if selectionMode == config.ModelSelectionModeManual {
        if profile, err := model.GetModelProfile(cfg.SelectedModelID); err == nil {
            if isModelAvailableForLanguage(profile.ID, settings.EffectiveLanguageID) {
            return CurrentModel{
                ModelID:          profile.ID,
                Profile:          profile,
@@ -34,8 +35,12 @@
                LanguageSettings: settings,
            }
        }
            fallbackReason = "manual model not available for language"
            logger.Info("Manual SelectedModelID %q is not available for language %q, falling back to language default %q", cfg.SelectedModelID, settings.EffectiveLanguageID, modelID)
        } else {
        fallbackReason = "invalid manual model selection"
        logger.Info("Invalid manual SelectedModelID %q, falling back to language default %q", cfg.SelectedModelID, modelID)
        }
    }
    profile, err := model.GetModelProfile(modelID)
@@ -54,3 +59,19 @@
        FallbackReason:   fallbackReason,
    }
}
func isModelAvailableForLanguage(modelID, languageID string) bool {
    profile, err := model.GetLanguageProfile(languageID)
    if err != nil {
        return false
    }
    if profile.DefaultModelID == modelID {
        return true
    }
    for _, upgradeModelID := range profile.UpgradeModelIDs {
        if upgradeModelID == modelID {
            return true
        }
    }
    return false
}
privatevoice.src/internal/modelselection/selection_test.go
@@ -59,11 +59,26 @@
    cfg := config.Default()
    cfg.LanguageMode = config.LanguageModeAuto
    cfg.ModelSelectionMode = config.ModelSelectionModeManual
    cfg.SelectedModelID = model.DefaultModelID
    cfg.SelectedModelID = model.ParakeetModelID
    current := Resolve(cfg, fakeDetector{locale: "en-US"})
    if current.ModelID != model.ParakeetModelID {
        t.Fatalf("model id = %q, want manual selected %q", current.ModelID, model.ParakeetModelID)
    }
}
func TestResolveManualModelFallsBackWhenLanguageIncompatible(t *testing.T) {
    cfg := config.Default()
    cfg.LanguageMode = config.LanguageModeAuto
    cfg.ModelSelectionMode = config.ModelSelectionModeManual
    cfg.SelectedModelID = model.ParakeetModelID
    current := Resolve(cfg, fakeDetector{locale: "zh-CN"})
    if current.ModelID != model.DefaultModelID {
        t.Fatalf("model id = %q, want manual selected %q", current.ModelID, model.DefaultModelID)
        t.Fatalf("model id = %q, want Chinese default %q", current.ModelID, model.DefaultModelID)
    }
    if current.FallbackReason == "" {
        t.Fatal("expected fallback reason")
    }
}
privatevoice.src/services/config_service.go
@@ -38,6 +38,7 @@
// SetLanguageAuto makes the UI language follow the system language.
func (s *ConfigService) SetLanguageAuto() language.LanguageSettings {
    s.cfg.LanguageMode = config.LanguageModeAuto
    s.cfg.ModelSelectionMode = config.ModelSelectionModeAuto
    resolved := language.Resolve(s.cfg.LanguageMode, s.cfg.LanguageID, s.localeDetector)
    s.cfg.LanguageID = resolved.EffectiveLanguageID
    config.Save(s.cfg)
@@ -48,6 +49,7 @@
func (s *ConfigService) SetLanguageManual(languageID string) language.LanguageSettings {
    s.cfg.LanguageMode = config.LanguageModeManual
    s.cfg.LanguageID = model.NormalizeLanguageID(languageID)
    s.cfg.ModelSelectionMode = config.ModelSelectionModeAuto
    config.Save(s.cfg)
    return language.Resolve(s.cfg.LanguageMode, s.cfg.LanguageID, s.localeDetector)
}
privatevoice.src/services/engine_service.go
@@ -15,6 +15,7 @@
// EngineService provides engine status and model management to the frontend.
type EngineService struct {
    cfg          *config.Config
    app          *application.App
    initCallback func()
    mu           sync.RWMutex
@@ -23,8 +24,11 @@
    lastError    string
}
func NewEngineService() *EngineService {
    return &EngineService{status: "loading"}
func NewEngineService(cfg *config.Config) *EngineService {
    if cfg == nil {
        cfg = config.Default()
    }
    return &EngineService{cfg: cfg, status: "loading"}
}
// ModelExists returns true if the ASR model files are present.
@@ -44,7 +48,84 @@
func (s *EngineService) GetCurrentModelStatus() map[string]interface{} {
    current := s.currentModel()
    resolved, err := model.ResolveModel(current.ModelID)
    return s.modelStatusMap(current.Profile, current)
}
func (s *EngineService) ListModelOptions() []map[string]interface{} {
    current := s.currentModel()
    languageProfile, err := model.GetLanguageProfile(current.LanguageSettings.EffectiveLanguageID)
    if err != nil {
        return nil
    }
    ids := make([]string, 0, 1+len(languageProfile.UpgradeModelIDs))
    ids = append(ids, languageProfile.DefaultModelID)
    ids = append(ids, languageProfile.UpgradeModelIDs...)
    options := make([]map[string]interface{}, 0, len(ids))
    for _, id := range ids {
        profile, err := model.GetModelProfile(id)
        if err != nil {
            continue
        }
        options = append(options, s.modelStatusMap(profile, current))
    }
    return options
}
func (s *EngineService) SelectModel(modelID string) error {
    profile, err := s.allowedModelProfile(modelID)
    if err != nil {
        return err
    }
    resolved, err := model.ResolveModel(profile.ID)
    if err != nil {
        return err
    }
    if !resolved.IsUsable() {
        return fmt.Errorf("model %s is not installed", profile.ID)
    }
    s.cfg.ModelSelectionMode = config.ModelSelectionModeManual
    s.cfg.SelectedModelID = profile.ID
    config.Save(s.cfg)
    s.ReloadCurrentModel()
    return nil
}
func (s *EngineService) DownloadModelByID(modelID string) error {
    profile, err := s.allowedModelProfile(modelID)
    if err != nil {
        return err
    }
    if len(profile.DownloadURLs) == 0 {
        return fmt.Errorf("no download URL configured for model %s", profile.ID)
    }
    err = model.DownloadProfile(profile, profile.DownloadURLs, paths.ModelsRoot(), func(percent float64, downloaded, total int64) {
        if s.app != nil {
            s.app.Event.Emit("model:download-progress", map[string]interface{}{
                "percent":    percent,
                "downloaded": downloaded,
                "total":      total,
                "modelID":    profile.ID,
                "modelName":  profile.DisplayName,
            })
        }
    })
    if err != nil {
        return err
    }
    s.cfg.ModelSelectionMode = config.ModelSelectionModeManual
    s.cfg.SelectedModelID = profile.ID
    config.Save(s.cfg)
    s.ReloadCurrentModel()
    return nil
}
func (s *EngineService) modelStatusMap(profile model.ModelProfile, current modelselection.CurrentModel) map[string]interface{} {
    resolved, err := model.ResolveModel(profile.ID)
    installed := err == nil && resolved.IsUsable()
    status := model.ModelNotInstalled
    var missing []string
@@ -56,19 +137,23 @@
    }
    return map[string]interface{}{
        "modelID":             current.ModelID,
        "displayName":         current.Profile.DisplayName,
        "backendKind":         current.Profile.BackendKind,
        "modelID":             profile.ID,
        "displayName":         profile.DisplayName,
        "backendKind":         profile.BackendKind,
        "tier":                profile.Tier,
        "languageID":          current.LanguageSettings.EffectiveLanguageID,
        "selectionMode":       current.SelectionMode,
        "isCurrent":           profile.ID == current.ModelID,
        "isDefault":           profile.ID == current.LanguageSettings.DefaultModelID,
        "installed":           installed,
        "installStatus":       status,
        "missing":             missing,
        "problems":            problems,
        "fallbackReason":      current.FallbackReason,
        "downloadSize":        current.Profile.ApproxSize,
        "supportedLanguages":  current.Profile.SupportedLanguageIDs,
        "recommendedLanguage": current.Profile.RecommendedFor,
        "downloadSize":        profile.ApproxSize,
        "supportedLanguages":  profile.SupportedLanguageIDs,
        "recommendedLanguage": profile.RecommendedFor,
        "description":         profile.Description,
    }
}
@@ -145,9 +230,34 @@
}
func (s *EngineService) currentModel() modelselection.CurrentModel {
    if s.cfg == nil {
    cfg, err := config.Load()
    if err != nil {
        cfg = config.Default()
    }
    return modelselection.Resolve(cfg, language.NewSystemDetector())
        s.cfg = cfg
    }
    return modelselection.Resolve(s.cfg, language.NewSystemDetector())
}
func (s *EngineService) allowedModelProfile(modelID string) (model.ModelProfile, error) {
    profile, err := model.GetModelProfile(modelID)
    if err != nil {
        return model.ModelProfile{}, err
    }
    current := s.currentModel()
    languageProfile, err := model.GetLanguageProfile(current.LanguageSettings.EffectiveLanguageID)
    if err != nil {
        return model.ModelProfile{}, err
    }
    if profile.ID == languageProfile.DefaultModelID {
        return profile, nil
    }
    for _, id := range languageProfile.UpgradeModelIDs {
        if profile.ID == id {
            return profile, nil
        }
    }
    return model.ModelProfile{}, fmt.Errorf("model %s is not available for language %s", profile.ID, current.LanguageSettings.EffectiveLanguageID)
}