From 7eb0b4196ce15c8bfbb0514c54b51cf019c78d24 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Tue, 23 Jun 2026 13:15:33 +0800
Subject: [PATCH] Prepare 2.1.34 input settings QA build
---
privatevoice.src/internal/model/profile.go | 24
privatevoice.src/internal/modelselection/selection_test.go | 52 +
privatevoice.src/internal/engine/engine_darwin.go | 4
privatevoice.src/frontend/src/components/settings/GeneralPage.svelte | 346 -----------
privatevoice.src/frontend/src/components/onboarding/OnboardingView.svelte | 22
privatevoice.src/internal/language/language_test.go | 22
privatevoice.src/frontend/src/lib/stores/config.ts | 9
privatevoice.src/frontend/src/lib/i18n/index.ts | 41 +
privatevoice.src/internal/language/detect.go | 17
privatevoice.src/frontend/package.json | 2
privatevoice.src/frontend/src/lib/i18n/fr.json | 15
privatevoice.src/internal/engine/engine.go | 2
privatevoice.src/frontend/src/lib/i18n/ja.json | 15
privatevoice.src/frontend/src/components/settings/LanguagePage.svelte | 458 +++++++++++++++
privatevoice.src/frontend/src/lib/i18n/zh.json | 10
privatevoice.src/frontend/src/lib/i18n/en.json | 10
privatevoice.src/frontend/src/lib/i18n/ko.json | 15
privatevoice.src/internal/engine/engine_darwin_test.go | 41 +
privatevoice.src/frontend/src/components/settings/SettingsWindow.svelte | 11
privatevoice.src/internal/model/registry.go | 226 +++++++
privatevoice.src/scripts/qa-settings-window-macos.sh | 85 ++
CHANGELOG.md | 45 +
privatevoice.src/build/darwin/Info.plist | 60 +-
privatevoice.src/internal/model/model_test.go | 114 +++
privatevoice.src/frontend/src/lib/i18n/it.json | 15
privatevoice.src/frontend/src/lib/i18n/pt.json | 15
privatevoice.src/app.go | 4
privatevoice.src/frontend/src/lib/i18n/de.json | 15
privatevoice.src/frontend/src/App.svelte | 9
privatevoice.src/frontend/package-lock.json | 4
privatevoice.src/frontend/src/lib/i18n/es.json | 15
31 files changed, 1,299 insertions(+), 424 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 30508e8..1c67470 100755
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,50 @@
# Changelog
+## v2.1.34 (2026-06-23)
+
+### 输入配置页签拆分
+
+- **语言选择独立成输入页签**:设置窗口新增“输入”页签,集中承载输入语言和离线识别模型选择。
+- **首页设置减负**:首页保留热键、输入设备、提示音、剪贴板、开机启动和 Dock 图标等通用设置,减少语言区域拥挤感。
+- **保持配置结构不变**:本版本只做阶段 1 信息架构拆分,不新增每语言默认模型、备用模型或自动模型选择配置。
+
+### 构建
+
+- build: `20260623.1312`
+- 说明: 本版本为输入配置页签拆分的本地验收候选包。
+
+---
+
+## v2.1.33 (2026-06-23)
+
+### 首启下载状态修复
+
+- **下载中不再误报失败**:首启模型下载过程中,如果后端仍广播 `need_model` 状态,界面会继续保持下载态;只有下载调用真正失败或后端明确返回 `error` 时才显示失败。
+
+### 构建
+
+- build: `20260623.1242`
+- 说明: 本版本为首启下载状态修复的本地验收候选包,基于 `v2.1.32` 多语种扩展候选包继续修复。
+
+---
+
+## v2.1.32 (2026-06-23)
+
+### 多语种扩展候选包
+
+- **欧洲五语正式语言基础**:新增 French、German、Spanish、Italian、Portuguese 语言版本,并将 Parakeet TDT v3 作为默认离线模型候选。
+- **日语 / 韩语正式语言基础**:新增 Japanese、Korean 语言版本,分别接入日语 Zipformer ReazonSpeech 和韩语 Zipformer 专项模型。
+- **动态语言选择**:设置页语言列表改为由后端语言 profile 动态返回,避免继续硬编码中文 / English。
+- **多语种模型校验**:新增 Parakeet TDT v3、日语 Zipformer、韩语 Zipformer 的模型文件校验和默认模型回退测试。
+- **本地化基础**:新增 `fr`、`de`、`es`、`it`、`pt`、`ja`、`ko` locale 文件,暂以核心设置页文案和英文 fallback 支撑验收。
+
+### 构建
+
+- build: `20260623.0012`
+- 说明: 本版本为多语种扩展本地验收候选包;Canary / Whisper benchmark、真实语音 QA 和完整人工翻译仍需发布前补齐。
+
+---
+
## v2.1.31 (2026-06-14)
### X-ASR 实验修复
diff --git a/privatevoice.src/app.go b/privatevoice.src/app.go
index 5fd76fb..783c115 100755
--- a/privatevoice.src/app.go
+++ b/privatevoice.src/app.go
@@ -28,8 +28,8 @@
)
const (
- appVersion = "2.1.31"
- appBuild = "20260614.2056"
+ appVersion = "2.1.34"
+ appBuild = "20260623.1312"
appDisplayVersion = appVersion + " (build " + appBuild + ")"
appName = "PrivateVoice Dictation"
diff --git a/privatevoice.src/build/darwin/Info.plist b/privatevoice.src/build/darwin/Info.plist
index 95314f9..d212015 100755
--- a/privatevoice.src/build/darwin/Info.plist
+++ b/privatevoice.src/build/darwin/Info.plist
@@ -2,35 +2,35 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
- <key>CFBundleExecutable</key>
- <string>PrivateVoice Dictation</string>
- <key>CFBundleIconFile</key>
- <string>icon.icns</string>
- <key>CFBundleIdentifier</key>
- <string>com.shanghai3168.privatevoicedictation</string>
- <key>CFBundleDevelopmentRegion</key>
- <string>en</string>
- <key>CFBundleName</key>
- <string>PrivateVoice Dictation</string>
- <key>CFBundleDisplayName</key>
- <string>PrivateVoice Dictation</string>
- <key>CFBundlePackageType</key>
- <string>APPL</string>
- <key>CFBundleShortVersionString</key>
- <string>2.1.31</string>
- <key>CFBundleVersion</key>
- <string>20260614.2056</string>
- <key>LSApplicationCategoryType</key>
- <string>public.app-category.productivity</string>
- <key>ITSAppUsesNonExemptEncryption</key>
- <false/>
- <key>LSMinimumSystemVersion</key>
- <string>11.0</string>
- <key>NSMicrophoneUsageDescription</key>
- <string>PrivateVoice Dictation needs microphone access for voice-to-text input.</string>
- <key>NSHighResolutionCapable</key>
- <true/>
- <key>LSUIElement</key>
- <true/>
+ <key>CFBundleDevelopmentRegion</key>
+ <string>en</string>
+ <key>CFBundleDisplayName</key>
+ <string>PrivateVoice Dictation</string>
+ <key>CFBundleExecutable</key>
+ <string>PrivateVoice Dictation</string>
+ <key>CFBundleIconFile</key>
+ <string>icon.icns</string>
+ <key>CFBundleIdentifier</key>
+ <string>com.shanghai3168.privatevoicedictation</string>
+ <key>CFBundleName</key>
+ <string>PrivateVoice Dictation</string>
+ <key>CFBundlePackageType</key>
+ <string>APPL</string>
+ <key>CFBundleShortVersionString</key>
+ <string>2.1.34</string>
+ <key>CFBundleVersion</key>
+ <string>20260623.1312</string>
+ <key>ITSAppUsesNonExemptEncryption</key>
+ <false/>
+ <key>LSApplicationCategoryType</key>
+ <string>public.app-category.productivity</string>
+ <key>LSMinimumSystemVersion</key>
+ <string>11.0</string>
+ <key>LSUIElement</key>
+ <true/>
+ <key>NSHighResolutionCapable</key>
+ <true/>
+ <key>NSMicrophoneUsageDescription</key>
+ <string>PrivateVoice Dictation needs microphone access for voice-to-text input.</string>
</dict>
</plist>
diff --git a/privatevoice.src/frontend/package-lock.json b/privatevoice.src/frontend/package-lock.json
index 4c533ad..324cd90 100755
--- a/privatevoice.src/frontend/package-lock.json
+++ b/privatevoice.src/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "privatevoice-dictation-frontend",
- "version": "2.1.31",
+ "version": "2.1.34",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "privatevoice-dictation-frontend",
- "version": "2.1.31",
+ "version": "2.1.34",
"dependencies": {
"@wailsio/runtime": "latest"
},
diff --git a/privatevoice.src/frontend/package.json b/privatevoice.src/frontend/package.json
index 7fb1459..2c81b68 100755
--- a/privatevoice.src/frontend/package.json
+++ b/privatevoice.src/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "privatevoice-dictation-frontend",
"private": true,
- "version": "2.1.31",
+ "version": "2.1.34",
"type": "module",
"scripts": {
"dev": "vite dev",
diff --git a/privatevoice.src/frontend/src/App.svelte b/privatevoice.src/frontend/src/App.svelte
index d0bfc4e..407dd7e 100755
--- a/privatevoice.src/frontend/src/App.svelte
+++ b/privatevoice.src/frontend/src/App.svelte
@@ -11,6 +11,7 @@
import { deviceName, engineHardwareInfo } from './lib/stores/app'
import { setLocale, localeRevision } from './lib/i18n'
import { languageMode, languageID, effectiveLanguageID, uiLocale } from './lib/stores/config'
+ import type { UILocale } from './lib/stores/config'
let isIndicatorRoute = $state(window.location.hash === '#/indicator')
let showUpdateDialog = $state(false)
@@ -96,13 +97,13 @@
function applyLanguageSettings(settings: any) {
const mode = settings?.languageMode === 'manual' ? 'manual' : 'auto'
- const configured = settings?.languageID === 'en' ? 'en' : 'zh-CN'
- const effective = settings?.effectiveLanguageID === 'en' ? 'en' : 'zh-CN'
- const locale = settings?.uiLocale === 'en' ? 'en' : 'zh'
+ const configured = typeof settings?.languageID === 'string' && settings.languageID ? settings.languageID : 'zh-CN'
+ const effective = typeof settings?.effectiveLanguageID === 'string' && settings.effectiveLanguageID ? settings.effectiveLanguageID : configured
+ const locale = typeof settings?.uiLocale === 'string' && settings.uiLocale ? settings.uiLocale : 'en'
languageMode.set(mode)
languageID.set(configured)
effectiveLanguageID.set(effective)
- uiLocale.set(locale)
+ uiLocale.set(locale as UILocale)
setLocale(locale)
languageReady = true
}
diff --git a/privatevoice.src/frontend/src/components/onboarding/OnboardingView.svelte b/privatevoice.src/frontend/src/components/onboarding/OnboardingView.svelte
index 5116ef5..6535a9a 100755
--- a/privatevoice.src/frontend/src/components/onboarding/OnboardingView.svelte
+++ b/privatevoice.src/frontend/src/components/onboarding/OnboardingView.svelte
@@ -9,11 +9,15 @@
let detailText = $state('')
let progress = $state(0)
let failed = $state(false)
+ let downloadActive = $state(false)
onMount(() => {
Events.On('model:download-progress', (ev: any) => {
const data = ev?.data
if (data?.percent != null) {
+ downloadActive = true
+ failed = false
+ statusText = t('onboarding.syncModel')
progress = data.percent
const downloadedMB = Math.floor((data.downloaded || 0) / 1024 / 1024)
const totalMB = Math.floor((data.total || 0) / 1024 / 1024)
@@ -24,13 +28,19 @@
Events.On('engine:status', (ev: any) => {
const data = ev?.data
if (data?.status === 'ready') {
+ downloadActive = false
+ failed = false
statusText = t('onboarding.complete')
progress = 100
} else if (data?.status === 'need_model') {
- statusText = t('onboarding.failed')
- detailText = data?.error || ''
- failed = true
+ const error = typeof data?.error === 'string' ? data.error.trim() : ''
+ if (error && !downloadActive) {
+ statusText = t('onboarding.failed')
+ detailText = error
+ failed = true
+ }
} else if (data?.status === 'error') {
+ downloadActive = false
statusText = t('onboarding.failed')
detailText = data?.error || ''
failed = true
@@ -42,10 +52,16 @@
async function triggerDownload() {
try {
+ downloadActive = true
+ failed = false
+ statusText = t('onboarding.syncModel')
+ detailText = ''
await Call.ByName('voicesnap/services.EngineService.DownloadCurrentModel')
+ downloadActive = false
statusText = t('onboarding.optimizing')
detailText = t('onboarding.extracting')
} catch (err: any) {
+ downloadActive = false
statusText = t('onboarding.failed')
detailText = err?.message || String(err)
failed = true
diff --git a/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte b/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte
index bf9186e..d54ca49 100755
--- a/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte
+++ b/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte
@@ -1,24 +1,14 @@
<script lang="ts">
- import { Call, Events } from '@wailsio/runtime'
+ import { Call } 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'
+ import { t } from '../../lib/i18n'
+ import { autoHide, soundFeedback, copyToClipboard, startAtLogin, hotkeyVK, hotkeyMode } from '../../lib/stores/config'
import { deviceName, engineStatus, engineHardwareInfo } from '../../lib/stores/app'
import { hotkeyName } from '../../lib/stores/indicator'
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)
@@ -32,17 +22,10 @@
let hwInfo = $state('')
let currentKeyName = $state('Ctrl')
let hotkeyModeVal: 'tap' | 'hold' = $state('hold')
- let languageModeVal: 'auto' | 'manual' = $state('auto')
- let languageIDVal: 'zh-CN' | 'en' = $state('zh-CN')
- let effectiveLanguageIDVal: 'zh-CN' | 'en' = $state('zh-CN')
let isRecording = $state(false)
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 })
@@ -53,16 +36,6 @@
const unsub7 = soundFeedback.subscribe(v => { soundFeedbackVal = v })
const unsub8 = copyToClipboard.subscribe(v => { copyToClipboardVal = v })
const unsub9 = hotkeyMode.subscribe(v => { hotkeyModeVal = v })
- 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() {
@@ -107,102 +80,9 @@
const hideDock: any = await Call.ByName('voicesnap/services.ConfigService.GetHideDockIcon')
hideDockIconVal = !!hideDock
} catch {}
- try {
- const lang: any = await Call.ByName('voicesnap/services.ConfigService.GetLanguageSettings')
- applyLanguageSettings(lang)
- } catch {}
- await loadModelOptions()
settingsLoaded = true
}
loadSettings()
-
- function applyLanguageSettings(settings: any) {
- const mode = settings?.languageMode === 'manual' ? 'manual' : 'auto'
- const configured = settings?.languageID === 'en' ? 'en' : 'zh-CN'
- const effective = settings?.effectiveLanguageID === 'en' ? 'en' : 'zh-CN'
- const locale = settings?.uiLocale === 'en' ? 'en' : 'zh'
- languageMode.set(mode)
- languageID.set(configured)
- effectiveLanguageID.set(effective)
- uiLocale.set(locale)
- languageModeVal = mode
- languageIDVal = configured
- effectiveLanguageIDVal = effective
- setLocale(locale)
- }
-
- async function syncEngineForCurrentModel() {
- try {
- const current: any = await Call.ByName('voicesnap/services.EngineService.GetCurrentModelStatus')
- if (current?.installed) {
- engineStatus.set('loading')
- } else {
- engineHardwareInfo.set('')
- engineStatus.set('need_model')
- }
- 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')
- case 'qwen3-asr-0.6b': return t('models.qwen3Desc')
- 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 = ''
- try {
- if (option.installed) {
- engineStatus.set('loading')
- await Call.ByName('voicesnap/services.EngineService.SelectModel', option.modelID)
- } else {
- await Call.ByName('voicesnap/services.EngineService.DownloadModelByID', option.modelID)
- engineStatus.set('loading')
- }
- await loadModelOptions()
- } catch (err: any) {
- modelError = err?.message || String(err || t('settings.modelActionFailed'))
- await loadModelOptions()
- } finally {
- modelBusyID = ''
- modelProgress = 0
- }
- }
async function onAutoHideChange(checked: boolean) {
autoHide.set(checked)
@@ -231,33 +111,6 @@
try {
await Call.ByName('voicesnap/services.ConfigService.SetHotkeyMode', mode)
} catch {}
- }
-
- async function onLanguageChange(selection: 'auto' | 'zh-CN' | 'en') {
- const prevMode = languageModeVal
- const prevID = languageIDVal
- if (selection === 'auto') {
- languageModeVal = 'auto'
- languageMode.set('auto')
- } else {
- languageModeVal = 'manual'
- languageIDVal = selection
- languageMode.set('manual')
- languageID.set(selection)
- }
- try {
- const settings: any = selection === 'auto'
- ? await Call.ByName('voicesnap/services.ConfigService.SetLanguageAuto')
- : await Call.ByName('voicesnap/services.ConfigService.SetLanguageManual', selection)
- applyLanguageSettings(settings)
- await syncEngineForCurrentModel()
- await loadModelOptions()
- } catch {
- languageModeVal = prevMode
- languageIDVal = prevID
- languageMode.set(prevMode)
- languageID.set(prevID)
- }
}
async function onStartAtLoginChange(checked: boolean) {
@@ -422,90 +275,6 @@
</div>
</div>
-
- {#if settingsLoaded}
- <div class="section">
- <div class="setting-row">
- <div class="setting-info">
- <span class="setting-label">{t('settings.language')}</span>
- <span class="setting-desc">
- {languageModeVal === 'auto'
- ? t('settings.languageAutoDesc')
- : effectiveLanguageIDVal === 'en'
- ? t('settings.languageEnglishDesc')
- : t('settings.languageChineseDesc')}
- </span>
- </div>
- <div class="segmented language" role="group" aria-label={t('settings.language')}>
- <button
- class:active={languageModeVal === 'auto'}
- onclick={() => onLanguageChange('auto')}
- >
- {t('settings.languageAuto')}
- </button>
- <button
- class:active={languageModeVal === 'manual' && languageIDVal === 'zh-CN'}
- onclick={() => onLanguageChange('zh-CN')}
- >
- {t('settings.languageChinese')}
- </button>
- <button
- class:active={languageModeVal === 'manual' && languageIDVal === 'en'}
- onclick={() => onLanguageChange('en')}
- >
- {t('settings.languageEnglish')}
- </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}
<!-- Engine + Device -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
@@ -758,16 +527,6 @@
flex-shrink: 0;
}
- .segmented.language {
- grid-template-columns: repeat(3, 1fr);
- min-width: 246px;
- }
-
- .segmented.language button {
- min-width: 76px;
- padding: 0 10px;
- }
-
.segmented button {
min-width: 66px;
height: 30px;
@@ -791,105 +550,6 @@
height: 1px;
background: var(--color-separator);
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 */
diff --git a/privatevoice.src/frontend/src/components/settings/LanguagePage.svelte b/privatevoice.src/frontend/src/components/settings/LanguagePage.svelte
new file mode 100644
index 0000000..fb9065f
--- /dev/null
+++ b/privatevoice.src/frontend/src/components/settings/LanguagePage.svelte
@@ -0,0 +1,458 @@
+<script lang="ts">
+ import { Call, Events } from '@wailsio/runtime'
+ import { t, setLocale } from '../../lib/i18n'
+ import { languageMode, languageID, effectiveLanguageID, uiLocale } from '../../lib/stores/config'
+ import type { UILocale } from '../../lib/stores/config'
+ import { engineStatus, engineHardwareInfo } from '../../lib/stores/app'
+
+ interface ModelOption {
+ modelID: string
+ displayName: string
+ tier: string
+ installed: boolean
+ isCurrent: boolean
+ isDefault: boolean
+ downloadSize?: string
+ description?: string
+ }
+
+ interface LanguageOption {
+ id: string
+ displayName: string
+ nativeName: string
+ uiLocale: string
+ }
+
+ let settingsLoaded = $state(false)
+ let languageModeVal: 'auto' | 'manual' = $state('auto')
+ let languageIDVal = $state('zh-CN')
+ let effectiveLanguageIDVal = $state('zh-CN')
+ let languageOptions = $state<LanguageOption[]>([])
+ let modelOptions = $state<ModelOption[]>([])
+ let modelBusyID = $state('')
+ let modelProgress = $state(0)
+ let modelError = $state('')
+
+ const unsub1 = languageMode.subscribe(v => { languageModeVal = v })
+ const unsub2 = languageID.subscribe(v => { languageIDVal = v })
+ const unsub3 = 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
+ }
+ })
+
+ async function loadSettings() {
+ try {
+ const lang: any = await Call.ByName('voicesnap/services.ConfigService.GetLanguageSettings')
+ applyLanguageSettings(lang)
+ } catch {}
+ await loadModelOptions()
+ settingsLoaded = true
+ }
+ loadSettings()
+
+ function applyLanguageSettings(settings: any) {
+ const mode = settings?.languageMode === 'manual' ? 'manual' : 'auto'
+ const configured = typeof settings?.languageID === 'string' && settings.languageID ? settings.languageID : 'zh-CN'
+ const effective = typeof settings?.effectiveLanguageID === 'string' && settings.effectiveLanguageID ? settings.effectiveLanguageID : configured
+ const locale = typeof settings?.uiLocale === 'string' && settings.uiLocale ? settings.uiLocale : 'en'
+ const options = Array.isArray(settings?.options) ? settings.options : []
+ languageMode.set(mode)
+ languageID.set(configured)
+ effectiveLanguageID.set(effective)
+ uiLocale.set(locale as UILocale)
+ languageModeVal = mode
+ languageIDVal = configured
+ effectiveLanguageIDVal = effective
+ languageOptions = options
+ setLocale(locale)
+ }
+
+ async function syncEngineForCurrentModel() {
+ try {
+ const current: any = await Call.ByName('voicesnap/services.EngineService.GetCurrentModelStatus')
+ if (current?.installed) {
+ engineStatus.set('loading')
+ } else {
+ engineHardwareInfo.set('')
+ engineStatus.set('need_model')
+ }
+ 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(option: ModelOption): string {
+ switch (option.modelID) {
+ case 'sensevoice-zh': return t('models.sensevoiceDesc')
+ case 'moonshine-en': return t('models.moonshineDesc')
+ case 'parakeet-en': return t('models.parakeetDesc')
+ case 'parakeet-tdt-0.6b-v3': return t('models.parakeetV3Desc')
+ case 'zipformer-ja-reazonspeech': return t('models.japaneseDesc')
+ case 'zipformer-ko': return t('models.koreanDesc')
+ case 'qwen3-asr-0.6b': return t('models.qwen3Desc')
+ default: return option.description || ''
+ }
+ }
+
+ function selectedLanguageName(): string {
+ const id = effectiveLanguageIDVal || languageIDVal
+ const option = languageOptions.find(item => item.id === id)
+ return option?.nativeName || option?.displayName || id
+ }
+
+ 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 = ''
+ try {
+ if (option.installed) {
+ engineStatus.set('loading')
+ await Call.ByName('voicesnap/services.EngineService.SelectModel', option.modelID)
+ } else {
+ await Call.ByName('voicesnap/services.EngineService.DownloadModelByID', option.modelID)
+ engineStatus.set('loading')
+ }
+ await loadModelOptions()
+ } catch (err: any) {
+ modelError = err?.message || String(err || t('settings.modelActionFailed'))
+ await loadModelOptions()
+ } finally {
+ modelBusyID = ''
+ modelProgress = 0
+ }
+ }
+
+ async function onLanguageChange(selection: string) {
+ const prevMode = languageModeVal
+ const prevID = languageIDVal
+ if (selection === 'auto') {
+ languageModeVal = 'auto'
+ languageMode.set('auto')
+ } else {
+ languageModeVal = 'manual'
+ languageIDVal = selection
+ languageMode.set('manual')
+ languageID.set(selection)
+ }
+ try {
+ const settings: any = selection === 'auto'
+ ? await Call.ByName('voicesnap/services.ConfigService.SetLanguageAuto')
+ : await Call.ByName('voicesnap/services.ConfigService.SetLanguageManual', selection)
+ applyLanguageSettings(settings)
+ await syncEngineForCurrentModel()
+ await loadModelOptions()
+ } catch {
+ languageModeVal = prevMode
+ languageIDVal = prevID
+ languageMode.set(prevMode)
+ languageID.set(prevID)
+ }
+ }
+</script>
+
+<div class="page">
+ <div class="header">
+ <h1 class="tagline">{t('settings.inputConfigTitle')}</h1>
+ <p class="app-desc">{t('settings.inputConfigDesc')}</p>
+ </div>
+
+ {#if settingsLoaded}
+ <div class="section">
+ <div class="setting-row">
+ <div class="setting-info">
+ <span class="setting-label">{t('settings.language')}</span>
+ <span class="setting-desc">
+ {languageModeVal === 'auto'
+ ? t('settings.languageAutoDesc')
+ : t('settings.languageManualDesc', { language: selectedLanguageName() })}
+ </span>
+ </div>
+ <div class="segmented language" role="group" aria-label={t('settings.language')}>
+ <button
+ class:active={languageModeVal === 'auto'}
+ onclick={() => onLanguageChange('auto')}
+ >
+ {t('settings.languageAuto')}
+ </button>
+ {#each languageOptions as option}
+ <button
+ class:active={languageModeVal === 'manual' && languageIDVal === option.id}
+ onclick={() => onLanguageChange(option.id)}
+ >
+ {option.nativeName || option.displayName}
+ </button>
+ {/each}
+ </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">
+ {t('settings.recognitionModelDesc', { language: selectedLanguageName() })}
+ </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)}</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}
+</div>
+
+<style>
+ .header {
+ margin-bottom: var(--spacing-xl);
+ }
+
+ .tagline {
+ font-size: 22px;
+ font-weight: 700;
+ }
+
+ .app-desc {
+ font-size: var(--font-size-sm);
+ color: var(--color-secondary-label);
+ margin-top: 6px;
+ }
+
+ .section {
+ background: var(--color-bg-grouped-secondary);
+ border-radius: var(--radius-md);
+ padding: var(--spacing-lg);
+ margin-bottom: var(--spacing-md);
+ }
+
+ .setting-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--spacing-md);
+ padding: var(--spacing-xs) 0;
+ }
+
+ .setting-info {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+ }
+
+ .setting-label {
+ font-size: var(--font-size-base);
+ font-weight: 500;
+ }
+
+ .setting-desc {
+ font-size: var(--font-size-xs);
+ color: var(--color-tertiary-label);
+ }
+
+ .segmented {
+ display: inline-grid;
+ grid-template-columns: 1fr 1fr;
+ min-width: 148px;
+ padding: 2px;
+ border-radius: var(--radius-sm);
+ background: var(--color-bg-secondary);
+ flex-shrink: 0;
+ }
+
+ .segmented.language {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ max-width: 390px;
+ min-width: 246px;
+ }
+
+ .segmented.language button {
+ min-width: 76px;
+ padding: 0 10px;
+ }
+
+ .segmented button {
+ min-width: 66px;
+ height: 30px;
+ padding: 0 12px;
+ border: none;
+ border-radius: calc(var(--radius-sm) - 2px);
+ background: transparent;
+ color: var(--color-secondary-label);
+ font-size: var(--font-size-sm);
+ font-weight: 500;
+ cursor: pointer;
+ transition: background var(--transition-fast), color var(--transition-fast);
+ }
+
+ .segmented button.active {
+ background: var(--color-blue);
+ color: white;
+ }
+
+ .models-header {
+ align-items: flex-start;
+ margin-bottom: var(--spacing-sm);
+ }
+
+ .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);
+ }
+</style>
diff --git a/privatevoice.src/frontend/src/components/settings/SettingsWindow.svelte b/privatevoice.src/frontend/src/components/settings/SettingsWindow.svelte
index 2e18bb0..ce2ee58 100755
--- a/privatevoice.src/frontend/src/components/settings/SettingsWindow.svelte
+++ b/privatevoice.src/frontend/src/components/settings/SettingsWindow.svelte
@@ -2,6 +2,7 @@
import { currentPage } from '../../lib/stores/app'
import { t } from '../../lib/i18n'
import GeneralPage from './GeneralPage.svelte'
+ import LanguagePage from './LanguagePage.svelte'
import PermissionsPage from './PermissionsPage.svelte'
import HistoryPage from './HistoryPage.svelte'
import UserDictPage from './UserDictPage.svelte'
@@ -10,6 +11,7 @@
const navItems = [
{ id: 'general', label: () => t('nav.home') },
+ { id: 'language', label: () => t('nav.language') },
{ id: 'userdict', label: () => t('nav.userdict') },
{ id: 'permissions', label: () => t('nav.permissions') },
{ id: 'history', label: () => t('nav.history') },
@@ -48,6 +50,13 @@
<path d="M2.5 7.5L8 3l5.5 4.5"/>
<path d="M4 6.5V13h3v-3.5h2V13h3V6.5"/>
</svg>
+ {:else if item.id === 'language'}
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">
+ <circle cx="8" cy="8" r="6"/>
+ <path d="M2.5 8h11"/>
+ <path d="M8 2.5c1.5 1.6 2.2 3.4 2.2 5.5S9.5 11.9 8 13.5"/>
+ <path d="M8 2.5C6.5 4.1 5.8 5.9 5.8 8s.7 3.9 2.2 5.5"/>
+ </svg>
{:else if item.id === 'history'}
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">
<circle cx="8" cy="8" r="6"/>
@@ -83,6 +92,8 @@
<main class="content">
{#if page === 'general'}
<GeneralPage />
+ {:else if page === 'language'}
+ <LanguagePage />
{:else if page === 'userdict'}
<UserDictPage />
{:else if page === 'permissions'}
diff --git a/privatevoice.src/frontend/src/lib/i18n/de.json b/privatevoice.src/frontend/src/lib/i18n/de.json
new file mode 100644
index 0000000..9a1a190
--- /dev/null
+++ b/privatevoice.src/frontend/src/lib/i18n/de.json
@@ -0,0 +1,15 @@
+{
+ "settings": {
+ "language": "Sprache",
+ "languageAuto": "Automatisch",
+ "languageAutoDesc": "Folgt der Systemsprache und wählt die passende Sprachversion",
+ "languageManualDesc": "{language} als App-Sprache und Standardsprache für die Erkennung verwenden",
+ "recognitionModel": "Erkennungsmodell",
+ "recognitionModelDesc": "Wählen Sie das Standard-Offlinemodell oder ein verfügbares Modell für {language}"
+ },
+ "models": {
+ "parakeetV3Desc": "Mehrsprachiges europäisches Offlinemodell für Französisch, Deutsch, Spanisch, Italienisch und Portugiesisch.",
+ "japaneseDesc": "Spezielles japanisches Offlinemodell auf Basis von ReazonSpeech.",
+ "koreanDesc": "Spezielles koreanisches Offlinemodell für koreanische Diktate."
+ }
+}
diff --git a/privatevoice.src/frontend/src/lib/i18n/en.json b/privatevoice.src/frontend/src/lib/i18n/en.json
index 8c83064..89ff807 100755
--- a/privatevoice.src/frontend/src/lib/i18n/en.json
+++ b/privatevoice.src/frontend/src/lib/i18n/en.json
@@ -7,6 +7,7 @@
},
"nav": {
"home": "Home",
+ "language": "Input",
"userdict": "Dictionary",
"permissions": "Permissions",
"history": "History",
@@ -40,6 +41,8 @@
"modeHintHold": "Hold to speak, release to stop and type"
},
"settings": {
+ "inputConfigTitle": "Input",
+ "inputConfigDesc": "Set input language and offline recognition models.",
"inputDevice": "Input Device",
"hotkeyMode": "Voice Input Mode",
"hotkeyModeTap": "Tap",
@@ -50,10 +53,12 @@
"languageAuto": "Automatic",
"languageChinese": "Chinese",
"languageEnglish": "English",
- "languageAutoDesc": "Follow the system language, currently choosing only between Chinese and English",
+ "languageAutoDesc": "Follow the system language and choose the matching language version",
+ "languageManualDesc": "Use {language} as the app language and default recognition language",
"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",
+ "recognitionModelDesc": "Choose the default offline model or an available model for {language}",
"recognitionModelChineseDesc": "Choose the default lightweight Chinese model or a larger high-quality Chinese model",
"recognitionModelEnglishDesc": "Choose the default lightweight model or a larger high-quality English model",
"modelDefault": "Default",
@@ -80,6 +85,9 @@
"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.",
+ "parakeetV3Desc": "Multilingual European offline model for French, German, Spanish, Italian, and Portuguese.",
+ "japaneseDesc": "Dedicated Japanese offline model based on ReazonSpeech.",
+ "koreanDesc": "Dedicated Korean offline model for Korean dictation.",
"qwen3Desc": "Higher quality Chinese offline model for long Chinese dictation, technical terms, and mixed Chinese-English input."
},
"permissions": {
diff --git a/privatevoice.src/frontend/src/lib/i18n/es.json b/privatevoice.src/frontend/src/lib/i18n/es.json
new file mode 100644
index 0000000..a5fb459
--- /dev/null
+++ b/privatevoice.src/frontend/src/lib/i18n/es.json
@@ -0,0 +1,15 @@
+{
+ "settings": {
+ "language": "Idioma",
+ "languageAuto": "Automático",
+ "languageAutoDesc": "Sigue el idioma del sistema y elige la versión correspondiente",
+ "languageManualDesc": "Usar {language} como idioma de la app y de reconocimiento predeterminado",
+ "recognitionModel": "Modelo de reconocimiento",
+ "recognitionModelDesc": "Elige el modelo sin conexión predeterminado o un modelo disponible para {language}"
+ },
+ "models": {
+ "parakeetV3Desc": "Modelo europeo multilingüe sin conexión para francés, alemán, español, italiano y portugués.",
+ "japaneseDesc": "Modelo sin conexión dedicado al japonés basado en ReazonSpeech.",
+ "koreanDesc": "Modelo sin conexión dedicado al dictado en coreano."
+ }
+}
diff --git a/privatevoice.src/frontend/src/lib/i18n/fr.json b/privatevoice.src/frontend/src/lib/i18n/fr.json
new file mode 100644
index 0000000..9af9f3c
--- /dev/null
+++ b/privatevoice.src/frontend/src/lib/i18n/fr.json
@@ -0,0 +1,15 @@
+{
+ "settings": {
+ "language": "Langue",
+ "languageAuto": "Automatique",
+ "languageAutoDesc": "Suit la langue du système et choisit la version correspondante",
+ "languageManualDesc": "Utiliser {language} comme langue de l'application et de reconnaissance par défaut",
+ "recognitionModel": "Modèle de reconnaissance",
+ "recognitionModelDesc": "Choisissez le modèle hors ligne par défaut ou un modèle disponible pour {language}"
+ },
+ "models": {
+ "parakeetV3Desc": "Modèle hors ligne européen multilingue pour le français, l'allemand, l'espagnol, l'italien et le portugais.",
+ "japaneseDesc": "Modèle hors ligne dédié au japonais basé sur ReazonSpeech.",
+ "koreanDesc": "Modèle hors ligne dédié à la dictée coréenne."
+ }
+}
diff --git a/privatevoice.src/frontend/src/lib/i18n/index.ts b/privatevoice.src/frontend/src/lib/i18n/index.ts
index 307f660..7c23a6c 100755
--- a/privatevoice.src/frontend/src/lib/i18n/index.ts
+++ b/privatevoice.src/frontend/src/lib/i18n/index.ts
@@ -1,23 +1,42 @@
import zh from "./zh.json";
import en from "./en.json";
+import fr from "./fr.json";
+import de from "./de.json";
+import es from "./es.json";
+import it from "./it.json";
+import pt from "./pt.json";
+import ja from "./ja.json";
+import ko from "./ko.json";
import { writable } from "svelte/store";
-export type Locale = "zh" | "en";
+export type Locale = "zh" | "en" | "fr" | "de" | "es" | "it" | "pt" | "ja" | "ko";
-const translations: Record<Locale, Record<string, any>> = { zh, en };
+const translations: Record<Locale, Record<string, any>> = { zh, en, fr, de, es, it, pt, ja, ko };
export const localeRevision = writable(0);
// Detect system language, default to Chinese
function detectLocale(): Locale {
const lang = navigator.language.toLowerCase();
if (lang.startsWith("zh")) return "zh";
+ if (lang.startsWith("fr")) return "fr";
+ if (lang.startsWith("de")) return "de";
+ if (lang.startsWith("es")) return "es";
+ if (lang.startsWith("it")) return "it";
+ if (lang.startsWith("pt")) return "pt";
+ if (lang.startsWith("ja")) return "ja";
+ if (lang.startsWith("ko")) return "ko";
return "en";
}
let currentLocale: Locale = detectLocale();
function normalizeLocale(locale: string): Locale {
- return locale.toLowerCase().startsWith("zh") ? "zh" : "en";
+ const normalized = locale.toLowerCase();
+ if (normalized.startsWith("zh")) return "zh";
+ for (const candidate of ["fr", "de", "es", "it", "pt", "ja", "ko"] as Locale[]) {
+ if (normalized.startsWith(candidate)) return candidate;
+ }
+ return "en";
}
export function setLocale(locale: Locale | string) {
@@ -38,11 +57,10 @@
*/
export function t(key: string, params?: Record<string, string>): string {
const parts = key.split(".");
- let value: any = translations[currentLocale];
+ let value: any = readPath(translations[currentLocale], parts);
- for (const part of parts) {
- if (value == null) return key;
- value = value[part];
+ if (typeof value !== "string" && currentLocale !== "en") {
+ value = readPath(translations.en, parts);
}
if (typeof value !== "string") return key;
@@ -55,3 +73,12 @@
return value;
}
+
+function readPath(source: Record<string, any>, parts: string[]): any {
+ let value: any = source;
+ for (const part of parts) {
+ if (value == null) return undefined;
+ value = value[part];
+ }
+ return value;
+}
diff --git a/privatevoice.src/frontend/src/lib/i18n/it.json b/privatevoice.src/frontend/src/lib/i18n/it.json
new file mode 100644
index 0000000..e5707ee
--- /dev/null
+++ b/privatevoice.src/frontend/src/lib/i18n/it.json
@@ -0,0 +1,15 @@
+{
+ "settings": {
+ "language": "Lingua",
+ "languageAuto": "Automatico",
+ "languageAutoDesc": "Segue la lingua del sistema e sceglie la versione corrispondente",
+ "languageManualDesc": "Usa {language} come lingua dell'app e lingua di riconoscimento predefinita",
+ "recognitionModel": "Modello di riconoscimento",
+ "recognitionModelDesc": "Scegli il modello offline predefinito o un modello disponibile per {language}"
+ },
+ "models": {
+ "parakeetV3Desc": "Modello offline europeo multilingue per francese, tedesco, spagnolo, italiano e portoghese.",
+ "japaneseDesc": "Modello offline dedicato al giapponese basato su ReazonSpeech.",
+ "koreanDesc": "Modello offline dedicato alla dettatura in coreano."
+ }
+}
diff --git a/privatevoice.src/frontend/src/lib/i18n/ja.json b/privatevoice.src/frontend/src/lib/i18n/ja.json
new file mode 100644
index 0000000..ed09aa5
--- /dev/null
+++ b/privatevoice.src/frontend/src/lib/i18n/ja.json
@@ -0,0 +1,15 @@
+{
+ "settings": {
+ "language": "言語",
+ "languageAuto": "自動",
+ "languageAutoDesc": "システム言語に合わせて対応する言語版を選択します",
+ "languageManualDesc": "{language} をアプリ言語と既定の認識言語として使用します",
+ "recognitionModel": "認識モデル",
+ "recognitionModelDesc": "{language} 用の既定のオフラインモデルまたは利用可能なモデルを選択します"
+ },
+ "models": {
+ "parakeetV3Desc": "フランス語、ドイツ語、スペイン語、イタリア語、ポルトガル語向けの欧州多言語オフラインモデルです。",
+ "japaneseDesc": "ReazonSpeech に基づく日本語専用のオフラインモデルです。",
+ "koreanDesc": "韓国語ディクテーション向けの専用オフラインモデルです。"
+ }
+}
diff --git a/privatevoice.src/frontend/src/lib/i18n/ko.json b/privatevoice.src/frontend/src/lib/i18n/ko.json
new file mode 100644
index 0000000..b71f77a
--- /dev/null
+++ b/privatevoice.src/frontend/src/lib/i18n/ko.json
@@ -0,0 +1,15 @@
+{
+ "settings": {
+ "language": "언어",
+ "languageAuto": "자동",
+ "languageAutoDesc": "시스템 언어를 따라 알맞은 언어 버전을 선택합니다",
+ "languageManualDesc": "{language}을 앱 언어와 기본 인식 언어로 사용합니다",
+ "recognitionModel": "인식 모델",
+ "recognitionModelDesc": "{language}에 사용할 기본 오프라인 모델 또는 사용 가능한 모델을 선택합니다"
+ },
+ "models": {
+ "parakeetV3Desc": "프랑스어, 독일어, 스페인어, 이탈리아어, 포르투갈어를 위한 유럽 다국어 오프라인 모델입니다.",
+ "japaneseDesc": "ReazonSpeech 기반 일본어 전용 오프라인 모델입니다.",
+ "koreanDesc": "한국어 받아쓰기를 위한 전용 오프라인 모델입니다."
+ }
+}
diff --git a/privatevoice.src/frontend/src/lib/i18n/pt.json b/privatevoice.src/frontend/src/lib/i18n/pt.json
new file mode 100644
index 0000000..c0a1721
--- /dev/null
+++ b/privatevoice.src/frontend/src/lib/i18n/pt.json
@@ -0,0 +1,15 @@
+{
+ "settings": {
+ "language": "Idioma",
+ "languageAuto": "Automático",
+ "languageAutoDesc": "Segue o idioma do sistema e escolhe a versão correspondente",
+ "languageManualDesc": "Usar {language} como idioma da aplicação e idioma de reconhecimento predefinido",
+ "recognitionModel": "Modelo de reconhecimento",
+ "recognitionModelDesc": "Escolha o modelo offline predefinido ou um modelo disponível para {language}"
+ },
+ "models": {
+ "parakeetV3Desc": "Modelo europeu multilingue offline para francês, alemão, espanhol, italiano e português.",
+ "japaneseDesc": "Modelo offline dedicado ao japonês baseado no ReazonSpeech.",
+ "koreanDesc": "Modelo offline dedicado ao ditado em coreano."
+ }
+}
diff --git a/privatevoice.src/frontend/src/lib/i18n/zh.json b/privatevoice.src/frontend/src/lib/i18n/zh.json
index 3b24839..423125c 100755
--- a/privatevoice.src/frontend/src/lib/i18n/zh.json
+++ b/privatevoice.src/frontend/src/lib/i18n/zh.json
@@ -7,6 +7,7 @@
},
"nav": {
"home": "首页",
+ "language": "输入",
"userdict": "词库",
"permissions": "权限",
"history": "历史",
@@ -40,6 +41,8 @@
"modeHintHold": "长按开始说话,松开结束并输入"
},
"settings": {
+ "inputConfigTitle": "输入配置",
+ "inputConfigDesc": "设置输入语言和离线识别模型。",
"inputDevice": "输入设备",
"hotkeyMode": "语音输入方式",
"hotkeyModeTap": "点按",
@@ -50,10 +53,12 @@
"languageAuto": "自动",
"languageChinese": "中文",
"languageEnglish": "English",
- "languageAutoDesc": "跟随系统语言,当前只会在中文和 English 之间选择",
+ "languageAutoDesc": "跟随系统语言并选择匹配的语言版本",
+ "languageManualDesc": "使用 {language} 作为界面语言和默认识别语言",
"languageChineseDesc": "使用中文界面,并默认使用 SenseVoice 离线模型",
"languageEnglishDesc": "使用 English 界面,并默认使用 Moonshine English 离线模型",
"recognitionModel": "识别模型",
+ "recognitionModelDesc": "选择适用于 {language} 的默认离线模型或可用模型",
"recognitionModelChineseDesc": "可选择默认轻量中文模型,或下载更高质量的中文模型",
"recognitionModelEnglishDesc": "可选择默认轻量英文模型,或下载更高质量的英文模型",
"modelDefault": "默认",
@@ -80,6 +85,9 @@
"sensevoiceDesc": "轻量中文离线模型,适合中文和中英混输。",
"moonshineDesc": "轻量英文离线模型,默认推荐。",
"parakeetDesc": "更高质量的英文离线模型,适合长句和技术词。",
+ "parakeetV3Desc": "欧洲多语种离线模型,适合法语、德语、西班牙语、意大利语和葡萄牙语。",
+ "japaneseDesc": "基于 ReazonSpeech 的日语专项离线模型。",
+ "koreanDesc": "韩语专项离线模型,适合韩语听写。",
"qwen3Desc": "更高质量的中文离线模型,适合中文长句、技术词和中英混输。"
},
"permissions": {
diff --git a/privatevoice.src/frontend/src/lib/stores/config.ts b/privatevoice.src/frontend/src/lib/stores/config.ts
index 60d8f8e..c9a6e4e 100755
--- a/privatevoice.src/frontend/src/lib/stores/config.ts
+++ b/privatevoice.src/frontend/src/lib/stores/config.ts
@@ -1,5 +1,8 @@
import { writable } from "svelte/store";
+export type LanguageID = string;
+export type UILocale = "zh" | "en" | "fr" | "de" | "es" | "it" | "pt" | "ja" | "ko";
+
export const autoHide = writable<boolean>(true);
export const soundFeedback = writable<boolean>(true);
export const copyToClipboard = writable<boolean>(true);
@@ -7,6 +10,6 @@
export const hotkeyVK = writable<number>(0xa5);
export const hotkeyMode = writable<"tap" | "hold">("hold");
export const languageMode = writable<"auto" | "manual">("auto");
-export const languageID = writable<"zh-CN" | "en">("zh-CN");
-export const effectiveLanguageID = writable<"zh-CN" | "en">("zh-CN");
-export const uiLocale = writable<"zh" | "en">("zh");
+export const languageID = writable<LanguageID>("zh-CN");
+export const effectiveLanguageID = writable<LanguageID>("zh-CN");
+export const uiLocale = writable<UILocale>("zh");
diff --git a/privatevoice.src/internal/engine/engine.go b/privatevoice.src/internal/engine/engine.go
index e2b918d..aed8e7f 100755
--- a/privatevoice.src/internal/engine/engine.go
+++ b/privatevoice.src/internal/engine/engine.go
@@ -118,7 +118,7 @@
func isSupportedBackend(backend string) bool {
switch backend {
- case model.BackendSenseVoice, model.BackendMoonshine, model.BackendNemoTransducer, model.BackendQwen3ASR, model.BackendXASRStreaming:
+ case model.BackendSenseVoice, model.BackendMoonshine, model.BackendTransducer, model.BackendNemoTransducer, model.BackendQwen3ASR, model.BackendXASRStreaming:
return true
default:
return false
diff --git a/privatevoice.src/internal/engine/engine_darwin.go b/privatevoice.src/internal/engine/engine_darwin.go
index 5caceb8..5dd090d 100755
--- a/privatevoice.src/internal/engine/engine_darwin.go
+++ b/privatevoice.src/internal/engine/engine_darwin.go
@@ -118,6 +118,10 @@
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.BackendTransducer:
+ config.ModelConfig.Transducer.Encoder = resolved.Files["encoder"]
+ config.ModelConfig.Transducer.Decoder = resolved.Files["decoder"]
+ config.ModelConfig.Transducer.Joiner = resolved.Files["joiner"]
case model.BackendNemoTransducer:
config.ModelConfig.Transducer.Encoder = resolved.Files["encoder"]
config.ModelConfig.Transducer.Decoder = resolved.Files["decoder"]
diff --git a/privatevoice.src/internal/engine/engine_darwin_test.go b/privatevoice.src/internal/engine/engine_darwin_test.go
index f2db4e4..85191e7 100644
--- a/privatevoice.src/internal/engine/engine_darwin_test.go
+++ b/privatevoice.src/internal/engine/engine_darwin_test.go
@@ -52,6 +52,47 @@
}
}
+func TestOfflineConfigForZipformerUsesPlainTransducer(t *testing.T) {
+ root := t.TempDir()
+ files := map[string]string{
+ "encoder": filepath.Join(root, "encoder-epoch-99-avg-1.int8.onnx"),
+ "decoder": filepath.Join(root, "decoder-epoch-99-avg-1.onnx"),
+ "joiner": filepath.Join(root, "joiner-epoch-99-avg-1.int8.onnx"),
+ "tokens": filepath.Join(root, "tokens.txt"),
+ }
+ resolved := model.ResolvedModel{
+ ModelID: model.JapaneseModelID,
+ BackendKind: model.BackendTransducer,
+ Profile: model.ModelProfile{
+ DisplayName: "Zipformer Japanese",
+ BackendKind: model.BackendTransducer,
+ ProviderOrder: []string{"cpu"},
+ NumThreads: 1,
+ },
+ 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 != "" {
+ t.Fatalf("model type = %q, want empty for plain transducer", config.ModelConfig.ModelType)
+ }
+}
+
func TestOfflineConfigForQwen3ASRUsesQwen3Config(t *testing.T) {
root := t.TempDir()
files := map[string]string{
diff --git a/privatevoice.src/internal/language/detect.go b/privatevoice.src/internal/language/detect.go
index 3d53442..2d3c645 100644
--- a/privatevoice.src/internal/language/detect.go
+++ b/privatevoice.src/internal/language/detect.go
@@ -35,12 +35,15 @@
if idx := strings.Index(normalized, "."); idx >= 0 {
normalized = normalized[:idx]
}
- switch {
- case strings.HasPrefix(normalized, "zh"):
- return model.DefaultLanguageID
- case strings.HasPrefix(normalized, "en"):
- return model.EnglishLanguageID
- default:
- return model.EnglishLanguageID
+
+ for _, profile := range model.ListLanguageProfiles() {
+ for _, matcher := range profile.SystemMatchers {
+ m := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(matcher), "_", "-"))
+ if normalized == m || strings.HasPrefix(normalized, m+"-") {
+ return profile.ID
+ }
+ }
}
+
+ return model.EnglishLanguageID
}
diff --git a/privatevoice.src/internal/language/language_test.go b/privatevoice.src/internal/language/language_test.go
index ca4458c..ffe80c3 100644
--- a/privatevoice.src/internal/language/language_test.go
+++ b/privatevoice.src/internal/language/language_test.go
@@ -21,7 +21,13 @@
"zh_Hans_CN": model.DefaultLanguageID,
"en-US": model.EnglishLanguageID,
"en_GB.UTF-8": model.EnglishLanguageID,
- "fr-FR": model.EnglishLanguageID,
+ "fr-FR": model.FrenchLanguageID,
+ "de-DE": model.GermanLanguageID,
+ "es-MX": model.SpanishLanguageID,
+ "it-IT": model.ItalianLanguageID,
+ "pt-BR": model.PortugueseLanguageID,
+ "ja-JP": model.JapaneseLanguageID,
+ "ko-KR": model.KoreanLanguageID,
"": model.EnglishLanguageID,
}
for input, want := range tests {
@@ -59,15 +65,18 @@
if settings.LanguageMode != config.LanguageModeAuto {
t.Fatalf("mode = %q, want %q", settings.LanguageMode, config.LanguageModeAuto)
}
- if settings.EffectiveLanguageID != model.EnglishLanguageID {
- t.Fatalf("effective language = %q, want %q", settings.EffectiveLanguageID, model.EnglishLanguageID)
+ if settings.EffectiveLanguageID != model.FrenchLanguageID {
+ t.Fatalf("effective language = %q, want %q", settings.EffectiveLanguageID, model.FrenchLanguageID)
+ }
+ if settings.DefaultModelID != model.ParakeetV3ModelID {
+ t.Fatalf("default model = %q, want %q", settings.DefaultModelID, model.ParakeetV3ModelID)
}
}
func TestResolveOptionsAreStable(t *testing.T) {
settings := Resolve(config.LanguageModeAuto, model.DefaultLanguageID, fakeDetector{locale: "zh-CN"})
- if len(settings.Options) != 2 {
- t.Fatalf("options len = %d, want 2", len(settings.Options))
+ if len(settings.Options) != 9 {
+ t.Fatalf("options len = %d, want 9", len(settings.Options))
}
if settings.Options[0].ID != model.DefaultLanguageID {
t.Fatalf("first option = %q, want %q", settings.Options[0].ID, model.DefaultLanguageID)
@@ -75,4 +84,7 @@
if settings.Options[1].ID != model.EnglishLanguageID {
t.Fatalf("second option = %q, want %q", settings.Options[1].ID, model.EnglishLanguageID)
}
+ if settings.Options[2].ID != model.FrenchLanguageID {
+ t.Fatalf("third option = %q, want %q", settings.Options[2].ID, model.FrenchLanguageID)
+ }
}
diff --git a/privatevoice.src/internal/model/model_test.go b/privatevoice.src/internal/model/model_test.go
index 1e01c14..b163161 100644
--- a/privatevoice.src/internal/model/model_test.go
+++ b/privatevoice.src/internal/model/model_test.go
@@ -61,6 +61,54 @@
}
}
+func TestRegistryReturnsParakeetV3ForEuropeanLanguages(t *testing.T) {
+ profile, err := GetModelProfile(ParakeetV3ModelID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if profile.BackendKind != BackendNemoTransducer {
+ t.Fatalf("backend kind = %q, want %q", profile.BackendKind, BackendNemoTransducer)
+ }
+ for _, languageID := range []string{FrenchLanguageID, GermanLanguageID, SpanishLanguageID, ItalianLanguageID, PortugueseLanguageID} {
+ if !stringSliceContains(profile.SupportedLanguageIDs, languageID) {
+ t.Fatalf("parakeet v3 languages = %v, want %s", profile.SupportedLanguageIDs, languageID)
+ }
+ languageProfile, err := GetLanguageProfile(languageID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if languageProfile.DefaultModelID != ParakeetV3ModelID {
+ t.Fatalf("%s default model = %q, want %q", languageID, languageProfile.DefaultModelID, ParakeetV3ModelID)
+ }
+ }
+}
+
+func TestRegistryReturnsJapaneseAndKoreanDefaults(t *testing.T) {
+ tests := []struct {
+ languageID string
+ modelID string
+ }{
+ {JapaneseLanguageID, JapaneseModelID},
+ {KoreanLanguageID, KoreanModelID},
+ }
+ for _, tt := range tests {
+ languageProfile, err := GetLanguageProfile(tt.languageID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if languageProfile.DefaultModelID != tt.modelID {
+ t.Fatalf("%s default model = %q, want %q", tt.languageID, languageProfile.DefaultModelID, tt.modelID)
+ }
+ modelProfile, err := GetModelProfile(tt.modelID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if modelProfile.BackendKind != BackendTransducer {
+ t.Fatalf("%s backend = %q, want %q", tt.modelID, modelProfile.BackendKind, BackendTransducer)
+ }
+ }
+}
+
func TestRegistryReturnsQwen3ASRChinese(t *testing.T) {
profile, err := GetModelProfile(Qwen3ASRModelID)
if err != nil {
@@ -238,6 +286,61 @@
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")
+ }
+}
+
+func TestValidateJapaneseZipformerRequiredFiles(t *testing.T) {
+ profile, err := GetModelProfile(JapaneseModelID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ dir := t.TempDir()
+ createValidZipformerSpecialized(t, dir)
+
+ result := ValidateModelDir(profile, dir)
+ if !result.Valid {
+ t.Fatalf("expected valid japanese zipformer 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 TestValidateKoreanZipformerRequiredFiles(t *testing.T) {
+ profile, err := GetModelProfile(KoreanModelID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ dir := t.TempDir()
+ createValidZipformerSpecialized(t, dir)
+
+ result := ValidateModelDir(profile, dir)
+ if !result.Valid {
+ t.Fatalf("expected valid korean zipformer 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 TestValidateZipformerSpecializedRejectsMissingJoiner(t *testing.T) {
+ profile, err := GetModelProfile(JapaneseModelID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ dir := t.TempDir()
+ createValidZipformerSpecialized(t, dir)
+ if err := os.Remove(filepath.Join(dir, "joiner-epoch-99-avg-1.int8.onnx")); err != nil {
t.Fatal(err)
}
@@ -724,6 +827,17 @@
writeTestFile(t, filepath.Join(dir, "tokens.txt"), "tokens")
}
+func createValidZipformerSpecialized(t *testing.T, dir string) {
+ t.Helper()
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ t.Fatal(err)
+ }
+ writeTestFile(t, filepath.Join(dir, "encoder-epoch-99-avg-1.int8.onnx"), "encoder")
+ writeTestFile(t, filepath.Join(dir, "decoder-epoch-99-avg-1.onnx"), "decoder")
+ writeTestFile(t, filepath.Join(dir, "joiner-epoch-99-avg-1.int8.onnx"), "joiner")
+ writeTestFile(t, filepath.Join(dir, "tokens.txt"), "tokens")
+}
+
func createValidQwen3ASR(t *testing.T, dir string) {
t.Helper()
if err := os.MkdirAll(filepath.Join(dir, "tokenizer"), 0755); err != nil {
diff --git a/privatevoice.src/internal/model/profile.go b/privatevoice.src/internal/model/profile.go
index f0773ae..0c8871d 100644
--- a/privatevoice.src/internal/model/profile.go
+++ b/privatevoice.src/internal/model/profile.go
@@ -1,13 +1,23 @@
package model
const (
- DefaultModelID = "sensevoice-zh"
- MoonshineModelID = "moonshine-en"
- ParakeetModelID = "parakeet-en"
- Qwen3ASRModelID = "qwen3-asr-0.6b"
- XASRZhEn960ModelID = "x-asr-zh-en-960ms"
- DefaultLanguageID = "zh-CN"
- EnglishLanguageID = "en"
+ DefaultModelID = "sensevoice-zh"
+ MoonshineModelID = "moonshine-en"
+ ParakeetModelID = "parakeet-en"
+ ParakeetV3ModelID = "parakeet-tdt-0.6b-v3"
+ JapaneseModelID = "zipformer-ja-reazonspeech"
+ KoreanModelID = "zipformer-ko"
+ Qwen3ASRModelID = "qwen3-asr-0.6b"
+ XASRZhEn960ModelID = "x-asr-zh-en-960ms"
+ DefaultLanguageID = "zh-CN"
+ EnglishLanguageID = "en"
+ FrenchLanguageID = "fr"
+ GermanLanguageID = "de"
+ SpanishLanguageID = "es"
+ ItalianLanguageID = "it"
+ PortugueseLanguageID = "pt"
+ JapaneseLanguageID = "ja"
+ KoreanLanguageID = "ko"
)
type ModelProfile struct {
diff --git a/privatevoice.src/internal/model/registry.go b/privatevoice.src/internal/model/registry.go
index 8e30239..5da545f 100644
--- a/privatevoice.src/internal/model/registry.go
+++ b/privatevoice.src/internal/model/registry.go
@@ -8,10 +8,23 @@
const (
BackendSenseVoice = "sensevoice"
BackendMoonshine = "moonshine"
+ BackendTransducer = "transducer"
BackendNemoTransducer = "nemo_transducer"
BackendQwen3ASR = "qwen3_asr"
BackendXASRStreaming = "x_asr_streaming"
)
+
+var languageProfileOrder = []string{
+ DefaultLanguageID,
+ EnglishLanguageID,
+ FrenchLanguageID,
+ GermanLanguageID,
+ SpanishLanguageID,
+ ItalianLanguageID,
+ PortugueseLanguageID,
+ JapaneseLanguageID,
+ KoreanLanguageID,
+}
var modelProfiles = map[string]ModelProfile{
DefaultModelID: {
@@ -132,6 +145,132 @@
ProviderOrder: []string{"cpu"},
NumThreads: 4,
},
+ ParakeetV3ModelID: {
+ ID: ParakeetV3ModelID,
+ DisplayName: "Parakeet TDT v3",
+ BackendKind: BackendNemoTransducer,
+ Tier: "default",
+ SupportedLanguageIDs: []string{
+ FrenchLanguageID,
+ GermanLanguageID,
+ SpanishLanguageID,
+ ItalianLanguageID,
+ PortugueseLanguageID,
+ },
+ RecommendedFor: []string{
+ FrenchLanguageID,
+ GermanLanguageID,
+ SpanishLanguageID,
+ ItalianLanguageID,
+ PortugueseLanguageID,
+ },
+ Description: "欧洲多语种离线模型,适合法语、德语、西班牙语、意大利语和葡萄牙语听写。",
+ ApproxSize: "约 640 MiB 解压后模型目录",
+ DownloadURLs: []string{
+ "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2",
+ },
+ InstallDirName: "parakeet-tdt-0.6b-v3",
+ 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: 2,
+ },
+ JapaneseModelID: {
+ ID: JapaneseModelID,
+ DisplayName: "Zipformer Japanese",
+ BackendKind: BackendTransducer,
+ Tier: "default",
+ SupportedLanguageIDs: []string{JapaneseLanguageID},
+ RecommendedFor: []string{JapaneseLanguageID},
+ Description: "日语专项离线模型,基于 ReazonSpeech 日语语料。",
+ ApproxSize: "约 165 MiB int8 关键模型文件,解压目录包含 fp32 对照文件",
+ DownloadURLs: []string{
+ "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-zipformer-ja-reazonspeech-2024-08-01.tar.bz2",
+ },
+ InstallDirName: "zipformer-ja-reazonspeech",
+ RequiredFiles: []RequiredFileRule{
+ {
+ Role: "encoder",
+ AllOf: []string{"encoder-epoch-99-avg-1.int8.onnx"},
+ Required: true,
+ },
+ {
+ Role: "decoder",
+ AllOf: []string{"decoder-epoch-99-avg-1.onnx"},
+ Required: true,
+ },
+ {
+ Role: "joiner",
+ AllOf: []string{"joiner-epoch-99-avg-1.int8.onnx"},
+ Required: true,
+ },
+ {
+ Role: "tokens",
+ AllOf: []string{"tokens.txt"},
+ Required: true,
+ },
+ },
+ ProviderOrder: []string{"cpu"},
+ NumThreads: 1,
+ },
+ KoreanModelID: {
+ ID: KoreanModelID,
+ DisplayName: "Zipformer Korean",
+ BackendKind: BackendTransducer,
+ Tier: "default",
+ SupportedLanguageIDs: []string{KoreanLanguageID},
+ RecommendedFor: []string{KoreanLanguageID},
+ Description: "韩语专项离线模型,适合韩语听写。",
+ ApproxSize: "约 73 MiB int8 关键模型文件,解压目录包含 fp32 对照文件",
+ DownloadURLs: []string{
+ "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-zipformer-korean-2024-06-24.tar.bz2",
+ },
+ InstallDirName: "zipformer-ko",
+ RequiredFiles: []RequiredFileRule{
+ {
+ Role: "encoder",
+ AllOf: []string{"encoder-epoch-99-avg-1.int8.onnx"},
+ Required: true,
+ },
+ {
+ Role: "decoder",
+ AllOf: []string{"decoder-epoch-99-avg-1.onnx"},
+ Required: true,
+ },
+ {
+ Role: "joiner",
+ AllOf: []string{"joiner-epoch-99-avg-1.int8.onnx"},
+ Required: true,
+ },
+ {
+ Role: "tokens",
+ AllOf: []string{"tokens.txt"},
+ Required: true,
+ },
+ },
+ ProviderOrder: []string{"cpu"},
+ NumThreads: 2,
+ },
Qwen3ASRModelID: {
ID: Qwen3ASRModelID,
DisplayName: "Qwen3-ASR",
@@ -237,6 +376,62 @@
DefaultModelID: MoonshineModelID,
UpgradeModelIDs: []string{ParakeetModelID, XASRZhEn960ModelID},
},
+ FrenchLanguageID: {
+ ID: FrenchLanguageID,
+ DisplayName: "French",
+ NativeName: "Français",
+ UILocale: "fr",
+ SystemMatchers: []string{"fr", "fr-FR", "fr-BE", "fr-CA", "fr-CH"},
+ DefaultModelID: ParakeetV3ModelID,
+ },
+ GermanLanguageID: {
+ ID: GermanLanguageID,
+ DisplayName: "German",
+ NativeName: "Deutsch",
+ UILocale: "de",
+ SystemMatchers: []string{"de", "de-DE", "de-AT", "de-CH"},
+ DefaultModelID: ParakeetV3ModelID,
+ },
+ SpanishLanguageID: {
+ ID: SpanishLanguageID,
+ DisplayName: "Spanish",
+ NativeName: "Español",
+ UILocale: "es",
+ SystemMatchers: []string{"es", "es-ES", "es-MX", "es-US", "es-AR"},
+ DefaultModelID: ParakeetV3ModelID,
+ },
+ ItalianLanguageID: {
+ ID: ItalianLanguageID,
+ DisplayName: "Italian",
+ NativeName: "Italiano",
+ UILocale: "it",
+ SystemMatchers: []string{"it", "it-IT", "it-CH"},
+ DefaultModelID: ParakeetV3ModelID,
+ },
+ PortugueseLanguageID: {
+ ID: PortugueseLanguageID,
+ DisplayName: "Portuguese",
+ NativeName: "Português",
+ UILocale: "pt",
+ SystemMatchers: []string{"pt", "pt-PT", "pt-BR"},
+ DefaultModelID: ParakeetV3ModelID,
+ },
+ JapaneseLanguageID: {
+ ID: JapaneseLanguageID,
+ DisplayName: "Japanese",
+ NativeName: "日本語",
+ UILocale: "ja",
+ SystemMatchers: []string{"ja", "ja-JP"},
+ DefaultModelID: JapaneseModelID,
+ },
+ KoreanLanguageID: {
+ ID: KoreanLanguageID,
+ DisplayName: "Korean",
+ NativeName: "한국어",
+ UILocale: "ko",
+ SystemMatchers: []string{"ko", "ko-KR"},
+ DefaultModelID: KoreanModelID,
+ },
}
func GetModelProfile(id string) (ModelProfile, error) {
@@ -279,21 +474,38 @@
func ListLanguageProfiles() []LanguageProfile {
profiles := make([]LanguageProfile, 0, len(languageProfiles))
- for _, profile := range languageProfiles {
- profiles = append(profiles, profile)
+ seen := make(map[string]bool, len(languageProfiles))
+ for _, id := range languageProfileOrder {
+ if profile, ok := languageProfiles[id]; ok {
+ profiles = append(profiles, profile)
+ seen[id] = true
+ }
+ }
+ for id, profile := range languageProfiles {
+ if !seen[id] {
+ profiles = append(profiles, profile)
+ }
}
sort.Slice(profiles, func(i, j int) bool {
- if profiles[i].ID == DefaultLanguageID {
- return true
- }
- if profiles[j].ID == DefaultLanguageID {
- return false
+ left := languageOrderIndex(profiles[i].ID)
+ right := languageOrderIndex(profiles[j].ID)
+ if left != right {
+ return left < right
}
return profiles[i].ID < profiles[j].ID
})
return profiles
}
+func languageOrderIndex(id string) int {
+ for i, orderedID := range languageProfileOrder {
+ if id == orderedID {
+ return i
+ }
+ }
+ return len(languageProfileOrder)
+}
+
func NormalizeLanguageID(id string) string {
if _, err := GetLanguageProfile(id); err != nil {
return DefaultLanguageID
diff --git a/privatevoice.src/internal/modelselection/selection_test.go b/privatevoice.src/internal/modelselection/selection_test.go
index 409a0fc..96875d9 100644
--- a/privatevoice.src/internal/modelselection/selection_test.go
+++ b/privatevoice.src/internal/modelselection/selection_test.go
@@ -40,6 +40,43 @@
}
}
+func TestResolveAutoUsesEuropeanDefaultModel(t *testing.T) {
+ cfg := config.Default()
+ cfg.LanguageMode = config.LanguageModeAuto
+ cfg.ModelSelectionMode = config.ModelSelectionModeAuto
+
+ current := Resolve(cfg, fakeDetector{locale: "fr-FR"})
+ if current.ModelID != model.ParakeetV3ModelID {
+ t.Fatalf("model id = %q, want %q", current.ModelID, model.ParakeetV3ModelID)
+ }
+ if current.Profile.BackendKind != model.BackendNemoTransducer {
+ t.Fatalf("backend = %q, want %q", current.Profile.BackendKind, model.BackendNemoTransducer)
+ }
+}
+
+func TestResolveAutoUsesJapaneseAndKoreanDefaults(t *testing.T) {
+ tests := []struct {
+ locale string
+ modelID string
+ }{
+ {"ja-JP", model.JapaneseModelID},
+ {"ko-KR", model.KoreanModelID},
+ }
+ for _, tt := range tests {
+ cfg := config.Default()
+ cfg.LanguageMode = config.LanguageModeAuto
+ cfg.ModelSelectionMode = config.ModelSelectionModeAuto
+
+ current := Resolve(cfg, fakeDetector{locale: tt.locale})
+ if current.ModelID != tt.modelID {
+ t.Fatalf("%s model id = %q, want %q", tt.locale, current.ModelID, tt.modelID)
+ }
+ if current.Profile.BackendKind != model.BackendTransducer {
+ t.Fatalf("%s backend = %q, want %q", tt.locale, current.Profile.BackendKind, model.BackendTransducer)
+ }
+ }
+}
+
func TestResolveOldConfigMissingSelectionModeUsesAuto(t *testing.T) {
cfg := config.Default()
cfg.LanguageMode = config.LanguageModeAuto
@@ -133,6 +170,21 @@
}
}
+func TestResolveManualParakeetV3FallsBackForJapanese(t *testing.T) {
+ cfg := config.Default()
+ cfg.LanguageMode = config.LanguageModeAuto
+ cfg.ModelSelectionMode = config.ModelSelectionModeManual
+ cfg.SelectedModelID = model.ParakeetV3ModelID
+
+ current := Resolve(cfg, fakeDetector{locale: "ja-JP"})
+ if current.ModelID != model.JapaneseModelID {
+ t.Fatalf("model id = %q, want Japanese default %q", current.ModelID, model.JapaneseModelID)
+ }
+ if current.FallbackReason == "" {
+ t.Fatal("expected fallback reason")
+ }
+}
+
func TestResolveInvalidManualFallsBackToLanguageDefault(t *testing.T) {
cfg := config.Default()
cfg.LanguageMode = config.LanguageModeAuto
diff --git a/privatevoice.src/scripts/qa-settings-window-macos.sh b/privatevoice.src/scripts/qa-settings-window-macos.sh
new file mode 100755
index 0000000..7d9fa39
--- /dev/null
+++ b/privatevoice.src/scripts/qa-settings-window-macos.sh
@@ -0,0 +1,85 @@
+#!/usr/bin/env bash
+#
+# qa-settings-window-macos.sh - Launch PrivateVoice and capture the Settings window.
+#
+# Usage:
+# ./scripts/qa-settings-window-macos.sh [APP_PATH] [OUTPUT_DIR]
+#
+set -euo pipefail
+
+APP_NAME="PrivateVoice Dictation"
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
+INFO_PLIST="$PROJECT_DIR/build/darwin/Info.plist"
+
+APP_PATH="${1:-/Applications/$APP_NAME.app}"
+VERSION="$(defaults read "$INFO_PLIST" CFBundleShortVersionString 2>/dev/null || echo "unknown")"
+BUILD_ID="$(defaults read "$INFO_PLIST" CFBundleVersion 2>/dev/null || date +%Y%m%d.%H%M)"
+OUTPUT_DIR="${2:-$PROJECT_DIR/build/qa/settings-window-$VERSION-build$BUILD_ID}"
+SCREENSHOT="$OUTPUT_DIR/settings-input-tab.png"
+BOUNDS_FILE="$OUTPUT_DIR/settings-window-bounds.txt"
+
+fail() {
+ echo "ERROR: $1" >&2
+ exit 1
+}
+
+[[ -d "$APP_PATH" ]] || fail "App bundle not found: $APP_PATH"
+
+mkdir -p "$OUTPUT_DIR"
+
+osascript -e "tell application \"$APP_NAME\" to quit" >/dev/null 2>&1 || true
+pkill -x "$APP_NAME" >/dev/null 2>&1 || true
+sleep 1
+
+open "$APP_PATH"
+
+osascript <<APPLESCRIPT
+tell application "System Events"
+ repeat 60 times
+ if exists process "$APP_NAME" then exit repeat
+ delay 0.25
+ end repeat
+ if not (exists process "$APP_NAME") then error "process did not start"
+
+ tell process "$APP_NAME"
+ set frontmost to true
+ repeat 80 times
+ if exists window 1 then exit repeat
+ delay 0.25
+ end repeat
+ if not (exists window 1) then error "settings window did not appear"
+
+ set position of window 1 to {80, 80}
+ set size of window 1 to {980, 720}
+ delay 0.8
+
+ try
+ click button "输入" of window 1
+ on error
+ set windowPosition to position of window 1
+ click at {item 1 of windowPosition + 92, item 2 of windowPosition + 144}
+ end try
+
+ delay 0.8
+ set windowPosition to position of window 1
+ set windowSize to size of window 1
+ set x to item 1 of windowPosition
+ set y to item 2 of windowPosition
+ set w to item 1 of windowSize
+ set h to item 2 of windowSize
+ do shell script "printf '%s,%s,%s,%s' " & x & " " & y & " " & w & " " & h & " > " & quoted form of "$BOUNDS_FILE"
+ end tell
+end tell
+APPLESCRIPT
+
+BOUNDS="$(cat "$BOUNDS_FILE")"
+screencapture -x -R"$BOUNDS" "$SCREENSHOT"
+
+if [[ ! -s "$SCREENSHOT" ]]; then
+ fail "Screenshot was not created: $SCREENSHOT"
+fi
+
+echo "Settings window screenshot: $SCREENSHOT"
+echo "Window bounds: $BOUNDS"
--
Gitblit v1.9.3