From 3803f0987d7fd38cbd754f3dc37caa29247c852c Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Wed, 01 Jul 2026 23:21:27 +0800
Subject: [PATCH] Merge Windows preview build
---
privatevoice.src/internal/model/build_capabilities.go | 15
privatevoice.src/scripts/build-windows-preview.sh | 91 +++++++++
privatevoice.src/internal/model/build_capabilities_monterey.go | 7
CHANGELOG.md | 10 +
CODEGRAPH.md | 67 ++++++
privatevoice.src/services/engine_service_windows_test.go | 43 ++++
privatevoice.src/services/engine_service_qwen3_default_test.go | 2
privatevoice.src/scripts/QA-WINDOWS-PREVIEW.ps1 | 190 +++++++++++++++++++
privatevoice.src/scripts/RUN-WINDOWS-QA.cmd | 18 +
privatevoice.src/services/engine_service.go | 8
privatevoice.src/internal/model/build_capabilities_default.go | 6
privatevoice.src/internal/model/build_capabilities_windows.go | 10 +
privatevoice.src/frontend/src/components/settings/LanguagePage.svelte | 3
privatevoice.src/scripts/verify-windows-qa-evidence.sh | 96 +++++++++
privatevoice.src/frontend/src/lib/i18n/zh.json | 3
privatevoice.src/frontend/src/lib/i18n/en.json | 3
16 files changed, 556 insertions(+), 16 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 254d47c..e4e5fda 100755
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,16 @@
- build: `20260629.2014`
- 说明: 本版本为性能回退排查诊断候选包,用于真实使用几天后分析慢在识别、尾部等待还是粘贴链路。
+### Windows 技术预览
+
+- **新增 Windows 预览构建脚本**:`privatevoice.src/scripts/build-windows-preview.sh` 可生成 Windows x64 预览目录和 zip。
+- **内置 Windows 真机 QA 采集脚本**:预览包包含 `QA-WINDOWS-PREVIEW.ps1`,用于采集 WebView2 / 音频端点预检信息、启动 App、打开 Notepad 目标文件、提示完成一次听写,并收集日志和目标文本证据。
+- **新增双击 QA 启动器**:预览包包含 `RUN-WINDOWS-QA.cmd`,便于 Windows 测试者直接启动 QA 流程。
+- **新增 Windows QA 证据验证脚本**:`privatevoice.src/scripts/verify-windows-qa-evidence.sh` 可检查返回的 `qa-evidence-*.zip` 是否证明启动、引擎、录音、识别和上屏。
+- **预览版只开放 SenseVoice**:Windows build 中其他模型仍可见但置灰禁用,避免用户选择尚未验证的模型导致启动或识别失败。
+- **打包 sherpa/onnxruntime 运行时 DLL**:预览包包含 `onnxruntime.dll`、`sherpa-onnx-c-api.dll`、`sherpa-onnx-cxx-api.dll`。
+- **交付边界**:该产物是 Windows 技术预览,不是已签名安装器,也未完成 Windows 10/11 真机 QA。
+
---
## v2.1.37 (2026-06-29)
diff --git a/CODEGRAPH.md b/CODEGRAPH.md
new file mode 100644
index 0000000..ca9cb6d
--- /dev/null
+++ b/CODEGRAPH.md
@@ -0,0 +1,67 @@
+# CODEGRAPH
+
+## Project
+
+- Product: Wails v3 / Go desktop dictation app.
+- Current branch purpose: Windows technical preview based on `v2.1.38-build20260629.2014-diagnostic`.
+- Protected macOS behavior: existing macOS App Store / local DMG build lines are not changed by this branch.
+
+## Core Entrypoints
+
+- `privatevoice.src/main.go`: process startup, data migration, logger, single-instance guard.
+- `privatevoice.src/app.go`: Wails app wiring, settings window, tray, recorder, hotkey loop, ASR init, paste orchestration.
+- `privatevoice.src/embed.go`: embeds frontend assets and app/tray icons.
+
+## Platform Modules
+
+- Audio: `privatevoice.src/internal/audio/recorder.go`
+ - Uses `github.com/gen2brain/malgo`.
+ - Cross-platform capture target: 16 kHz, 16-bit, mono.
+- Hotkey:
+ - macOS: `privatevoice.src/internal/hotkey/hotkey_darwin.go`
+ - Windows: `privatevoice.src/internal/hotkey/hotkey_windows.go`
+- Text input:
+ - macOS: `privatevoice.src/internal/input/paste_darwin.go`
+ - Windows: `privatevoice.src/internal/input/paste_windows.go`
+- ASR engine:
+ - macOS: `privatevoice.src/internal/engine/engine_darwin.go`
+ - Windows: `privatevoice.src/internal/engine/engine_windows.go`
+- Overlay:
+ - macOS: `privatevoice.src/internal/overlay/overlay_darwin.go`
+ - Windows: `privatevoice.src/internal/overlay/overlay_windows.go`
+
+## Windows Preview Scope
+
+- Windows preview supports `SenseVoice` only.
+- Non-SenseVoice models remain visible in settings but are disabled through `model.IsModelSupportedInCurrentBuild`.
+- Windows unsupported reason: `windows_preview_unsupported`.
+- Build script: `privatevoice.src/scripts/build-windows-preview.sh`.
+- Evidence verifier: `privatevoice.src/scripts/verify-windows-qa-evidence.sh`.
+- Runtime package includes:
+ - `PrivateVoice Dictation.exe`
+ - `onnxruntime.dll`
+ - `sherpa-onnx-c-api.dll`
+ - `sherpa-onnx-cxx-api.dll`
+ - `RUN-WINDOWS-QA.cmd`
+ - `QA-WINDOWS-PREVIEW.ps1`
+ - `README-WINDOWS-PREVIEW.txt`
+
+## Standard Verification Commands
+
+Run from `privatevoice.src`:
+
+```bash
+npm --prefix frontend run build
+go test ./...
+GOOS=windows GOARCH=amd64 CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc CXX=x86_64-w64-mingw32-g++ go test -exec=/usr/bin/true ./...
+./scripts/build-windows-preview.sh
+./scripts/verify-windows-qa-evidence.sh /path/to/qa-evidence-YYYYMMDD-HHMMSS.zip
+```
+
+## Known Gaps
+
+- Windows runtime QA must still be performed on Windows 10/11 hardware.
+- Run `RUN-WINDOWS-QA.cmd` from the extracted preview package to collect WebView2/audio preflight, startup, engine, recording, recognition, paste, and Notepad target text evidence.
+- After evidence is returned, run `verify-windows-qa-evidence.sh` and require `WINDOWS_QA_EVIDENCE_PASS` before claiming the goal complete.
+- The Windows preview zip is unsigned and not an installer.
+- SmartScreen / Defender prompts are expected for local testing.
diff --git a/privatevoice.src/frontend/src/components/settings/LanguagePage.svelte b/privatevoice.src/frontend/src/components/settings/LanguagePage.svelte
index 5d07007..d486394 100644
--- a/privatevoice.src/frontend/src/components/settings/LanguagePage.svelte
+++ b/privatevoice.src/frontend/src/components/settings/LanguagePage.svelte
@@ -184,6 +184,9 @@
if (option.unsupportedReason === 'requires_macos_14') {
return t('models.qwen3RequiresMacOS14')
}
+ if (option.unsupportedReason === 'windows_preview_unsupported') {
+ return t('models.windowsPreviewUnsupported')
+ }
return t('settings.modelUnavailable')
}
diff --git a/privatevoice.src/frontend/src/lib/i18n/en.json b/privatevoice.src/frontend/src/lib/i18n/en.json
index 6aa47d2..7f8aa66 100755
--- a/privatevoice.src/frontend/src/lib/i18n/en.json
+++ b/privatevoice.src/frontend/src/lib/i18n/en.json
@@ -92,7 +92,8 @@
"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.",
- "qwen3RequiresMacOS14": "Qwen3-ASR requires macOS 14 or later. This compatibility build does not support downloading it."
+ "qwen3RequiresMacOS14": "Qwen3-ASR requires macOS 14 or later. This compatibility build does not support downloading it.",
+ "windowsPreviewUnsupported": "The Windows preview currently supports SenseVoice only. This model is not available yet."
},
"permissions": {
"title": "Permission Check",
diff --git a/privatevoice.src/frontend/src/lib/i18n/zh.json b/privatevoice.src/frontend/src/lib/i18n/zh.json
index 8f29858..5d2e8c5 100755
--- a/privatevoice.src/frontend/src/lib/i18n/zh.json
+++ b/privatevoice.src/frontend/src/lib/i18n/zh.json
@@ -92,7 +92,8 @@
"japaneseDesc": "基于 ReazonSpeech 的日语专项离线模型。",
"koreanDesc": "韩语专项离线模型,适合韩语听写。",
"qwen3Desc": "更高质量的中文离线模型,适合中文长句、技术词和中英混输。",
- "qwen3RequiresMacOS14": "Qwen3-ASR 需要 macOS 14 或更高版本,本兼容包暂不支持下载。"
+ "qwen3RequiresMacOS14": "Qwen3-ASR 需要 macOS 14 或更高版本,本兼容包暂不支持下载。",
+ "windowsPreviewUnsupported": "Windows 预览版当前仅支持 SenseVoice,暂不支持此模型。"
},
"permissions": {
"title": "权限检查",
diff --git a/privatevoice.src/internal/model/build_capabilities.go b/privatevoice.src/internal/model/build_capabilities.go
index 1e75db3..7c27a62 100644
--- a/privatevoice.src/internal/model/build_capabilities.go
+++ b/privatevoice.src/internal/model/build_capabilities.go
@@ -1,17 +1,14 @@
package model
-const UnsupportedReasonRequiresMacOS14 = "requires_macos_14"
+const (
+ UnsupportedReasonRequiresMacOS14 = "requires_macos_14"
+ UnsupportedReasonWindowsPreview = "windows_preview_unsupported"
+)
func IsModelSupportedInCurrentBuild(modelID string) bool {
- if modelID == Qwen3ASRModelID {
- return qwen3ASRSupportedInCurrentBuild
- }
- return true
+ return ModelUnsupportedReason(modelID) == ""
}
func ModelUnsupportedReason(modelID string) string {
- if modelID == Qwen3ASRModelID && !IsModelSupportedInCurrentBuild(modelID) {
- return UnsupportedReasonRequiresMacOS14
- }
- return ""
+ return modelUnsupportedReasonInCurrentBuild(modelID)
}
diff --git a/privatevoice.src/internal/model/build_capabilities_default.go b/privatevoice.src/internal/model/build_capabilities_default.go
index 2314d90..5f0a81a 100644
--- a/privatevoice.src/internal/model/build_capabilities_default.go
+++ b/privatevoice.src/internal/model/build_capabilities_default.go
@@ -1,5 +1,7 @@
-//go:build !monterey_compat
+//go:build !monterey_compat && !windows
package model
-const qwen3ASRSupportedInCurrentBuild = true
+func modelUnsupportedReasonInCurrentBuild(modelID string) string {
+ return ""
+}
diff --git a/privatevoice.src/internal/model/build_capabilities_monterey.go b/privatevoice.src/internal/model/build_capabilities_monterey.go
index 63e944d..1de0e80 100644
--- a/privatevoice.src/internal/model/build_capabilities_monterey.go
+++ b/privatevoice.src/internal/model/build_capabilities_monterey.go
@@ -2,4 +2,9 @@
package model
-const qwen3ASRSupportedInCurrentBuild = false
+func modelUnsupportedReasonInCurrentBuild(modelID string) string {
+ if modelID == Qwen3ASRModelID {
+ return UnsupportedReasonRequiresMacOS14
+ }
+ return ""
+}
diff --git a/privatevoice.src/internal/model/build_capabilities_windows.go b/privatevoice.src/internal/model/build_capabilities_windows.go
new file mode 100644
index 0000000..67de4d4
--- /dev/null
+++ b/privatevoice.src/internal/model/build_capabilities_windows.go
@@ -0,0 +1,10 @@
+//go:build windows
+
+package model
+
+func modelUnsupportedReasonInCurrentBuild(modelID string) string {
+ if modelID == DefaultModelID {
+ return ""
+ }
+ return UnsupportedReasonWindowsPreview
+}
diff --git a/privatevoice.src/scripts/QA-WINDOWS-PREVIEW.ps1 b/privatevoice.src/scripts/QA-WINDOWS-PREVIEW.ps1
new file mode 100644
index 0000000..7f9639b
--- /dev/null
+++ b/privatevoice.src/scripts/QA-WINDOWS-PREVIEW.ps1
@@ -0,0 +1,190 @@
+param(
+ [switch]$CollectOnly,
+ [switch]$NoLaunch,
+ [int]$WaitSeconds = 45
+)
+
+$ErrorActionPreference = "Stop"
+
+function Write-Step {
+ param([string]$Message)
+ Write-Host ""
+ Write-Host "== $Message ==" -ForegroundColor Cyan
+}
+
+function Add-Result {
+ param(
+ [System.Collections.Generic.List[string]]$Results,
+ [string]$Name,
+ [bool]$Passed,
+ [string]$Detail = ""
+ )
+ $state = if ($Passed) { "PASS" } else { "NEEDS_EVIDENCE" }
+ $line = if ($Detail) { "$state`t$Name`t$Detail" } else { "$state`t$Name" }
+ $Results.Add($line) | Out-Null
+ $color = if ($Passed) { "Green" } else { "Yellow" }
+ Write-Host "[$state] $Name $Detail" -ForegroundColor $color
+}
+
+$PackageDir = Split-Path -Parent $MyInvocation.MyCommand.Path
+$ExePath = Join-Path $PackageDir "PrivateVoice Dictation.exe"
+$RequiredFiles = @(
+ "PrivateVoice Dictation.exe",
+ "onnxruntime.dll",
+ "sherpa-onnx-c-api.dll",
+ "sherpa-onnx-cxx-api.dll"
+)
+$AppDataDir = Join-Path $env:APPDATA "PrivateVoice Input"
+$LogPath = Join-Path $AppDataDir "app.log"
+$Timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
+$EvidenceDir = Join-Path $PackageDir "qa-evidence-$Timestamp"
+$TargetPath = Join-Path $EvidenceDir "qa-target.txt"
+$Results = [System.Collections.Generic.List[string]]::new()
+
+New-Item -ItemType Directory -Path $EvidenceDir -Force | Out-Null
+
+Write-Step "PrivateVoice Windows preview QA"
+"GeneratedAt: $(Get-Date -Format o)" | Set-Content -Encoding UTF8 (Join-Path $EvidenceDir "qa-summary.txt")
+"PackageDir: $PackageDir" | Add-Content -Encoding UTF8 (Join-Path $EvidenceDir "qa-summary.txt")
+"AppDataDir: $AppDataDir" | Add-Content -Encoding UTF8 (Join-Path $EvidenceDir "qa-summary.txt")
+
+Write-Step "Checking package files"
+foreach ($file in $RequiredFiles) {
+ $path = Join-Path $PackageDir $file
+ Add-Result $Results "file exists: $file" (Test-Path $path) $path
+}
+
+Write-Step "Collecting system information"
+try {
+ Get-ComputerInfo | Select-Object WindowsProductName, WindowsVersion, OsHardwareAbstractionLayer, CsSystemType, CsProcessors, CsTotalPhysicalMemory |
+ Format-List | Out-String | Set-Content -Encoding UTF8 (Join-Path $EvidenceDir "system-info.txt")
+ Add-Result $Results "system info collected" $true
+} catch {
+ Add-Result $Results "system info collected" $false $_.Exception.Message
+}
+
+Write-Step "Collecting Windows runtime preflight"
+try {
+ $webview2Entries = @()
+ $uninstallRoots = @(
+ "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*",
+ "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*",
+ "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*"
+ )
+ foreach ($root in $uninstallRoots) {
+ $webview2Entries += Get-ItemProperty $root -ErrorAction SilentlyContinue |
+ Where-Object { $_.DisplayName -match "WebView2" } |
+ Select-Object DisplayName, DisplayVersion, Publisher, InstallLocation
+ }
+ if ($webview2Entries.Count -gt 0) {
+ $webview2Entries | Format-List | Out-String | Set-Content -Encoding UTF8 (Join-Path $EvidenceDir "webview2-runtime.txt")
+ } else {
+ "No WebView2 Runtime entry found in standard uninstall registry paths. App startup logs remain authoritative." |
+ Set-Content -Encoding UTF8 (Join-Path $EvidenceDir "webview2-runtime.txt")
+ }
+ Add-Result $Results "WebView2 registry inventory collected" $true "entries=$($webview2Entries.Count)"
+} catch {
+ Add-Result $Results "WebView2 registry inventory collected" $false $_.Exception.Message
+}
+
+try {
+ if (Get-Command Get-PnpDevice -ErrorAction SilentlyContinue) {
+ Get-PnpDevice -Class AudioEndpoint -ErrorAction SilentlyContinue |
+ Select-Object Status, Class, FriendlyName, InstanceId |
+ Format-Table -AutoSize | Out-String |
+ Set-Content -Encoding UTF8 (Join-Path $EvidenceDir "audio-endpoints.txt")
+ } else {
+ Get-CimInstance Win32_SoundDevice -ErrorAction SilentlyContinue |
+ Select-Object Status, Name, Manufacturer, DeviceID |
+ Format-Table -AutoSize | Out-String |
+ Set-Content -Encoding UTF8 (Join-Path $EvidenceDir "audio-endpoints.txt")
+ }
+ Add-Result $Results "audio endpoint inventory collected" $true
+} catch {
+ Add-Result $Results "audio endpoint inventory collected" $false $_.Exception.Message
+}
+
+if (-not $CollectOnly -and -not $NoLaunch) {
+ Write-Step "Launching app"
+ if (-not (Test-Path $ExePath)) {
+ throw "Missing executable: $ExePath"
+ }
+ Start-Process -FilePath $ExePath -WorkingDirectory $PackageDir | Out-Null
+}
+
+Write-Step "Waiting for app log"
+$deadline = (Get-Date).AddSeconds($WaitSeconds)
+while ((Get-Date) -lt $deadline -and -not (Test-Path $LogPath)) {
+ Start-Sleep -Milliseconds 500
+}
+Add-Result $Results "app log exists" (Test-Path $LogPath) $LogPath
+
+Write-Step "Checking process"
+$processes = Get-Process | Where-Object { $_.ProcessName -like "PrivateVoice*" -or $_.Path -eq $ExePath } | Select-Object Id, ProcessName, Path
+$processes | Format-Table -AutoSize | Out-String | Set-Content -Encoding UTF8 (Join-Path $EvidenceDir "processes.txt")
+Add-Result $Results "PrivateVoice process visible" (($processes | Measure-Object).Count -gt 0)
+
+if (-not $CollectOnly) {
+ Write-Step "Manual runtime check"
+ @(
+ "PrivateVoice Windows Preview QA Target",
+ "Click after the marker below, dictate one short Chinese sentence, then press Ctrl+S in Notepad.",
+ "DICTATION_RESULT:"
+ ) | Set-Content -Encoding UTF8 $TargetPath
+ try {
+ Start-Process -FilePath "notepad.exe" -ArgumentList "`"$TargetPath`"" | Out-Null
+ Add-Result $Results "Notepad target launched" $true $TargetPath
+ } catch {
+ Add-Result $Results "Notepad target launched" $false $_.Exception.Message
+ }
+ Write-Host "1. Keep this PowerShell window open." -ForegroundColor White
+ Write-Host "2. In PrivateVoice, make sure the SenseVoice model is installed and the engine is ready." -ForegroundColor White
+ Write-Host "3. Use the Notepad window opened by this script as the target input field." -ForegroundColor White
+ Write-Host "4. Click after DICTATION_RESULT:, use the configured hotkey, and speak one short Chinese sentence." -ForegroundColor White
+ Write-Host "5. Confirm the recognized text appears in Notepad, press Ctrl+S in Notepad, then return here." -ForegroundColor White
+ Read-Host "Press Enter after you save the Notepad target file"
+}
+
+if (Test-Path $TargetPath) {
+ Copy-Item $TargetPath (Join-Path $EvidenceDir "qa-target-captured.txt") -Force
+ $targetText = Get-Content $TargetPath -Raw -ErrorAction SilentlyContinue
+ $marker = "DICTATION_RESULT:"
+ $markerIndex = $targetText.IndexOf($marker)
+ $dictationText = ""
+ if ($markerIndex -ge 0) {
+ $dictationText = $targetText.Substring($markerIndex + $marker.Length).Trim()
+ }
+ $dictationText | Set-Content -Encoding UTF8 (Join-Path $EvidenceDir "qa-target-dictation-text.txt")
+ Add-Result $Results "target file saved with dictation text" ($dictationText.Length -gt 0) "chars=$($dictationText.Length)"
+}
+
+Write-Step "Collecting logs"
+if (Test-Path $LogPath) {
+ Copy-Item $LogPath (Join-Path $EvidenceDir "app.log") -Force
+ $logText = Get-Content $LogPath -Raw -ErrorAction SilentlyContinue
+ Add-Result $Results "startup log found" ($logText -match "PrivateVoice Dictation starting")
+ Add-Result $Results "engine ready log found" ($logText -match "ASR engine ready|Engine initialized")
+ Add-Result $Results "recording log found" ($logText -match "Recording started")
+ Add-Result $Results "recognized text log found" ($logText -match "Recognized:")
+ Add-Result $Results "paste success log found" ($logText -match "PERF paste_done.*result=success|PERF fallback_type_done.*result=success")
+}
+
+$ConfigPath = Join-Path $AppDataDir "config.json"
+if (Test-Path $ConfigPath) {
+ Copy-Item $ConfigPath (Join-Path $EvidenceDir "config.json") -Force
+}
+
+$Results | Set-Content -Encoding UTF8 (Join-Path $EvidenceDir "qa-results.tsv")
+$Results | Add-Content -Encoding UTF8 (Join-Path $EvidenceDir "qa-summary.txt")
+
+Write-Step "Evidence package"
+$ZipPath = "$EvidenceDir.zip"
+if (Test-Path $ZipPath) {
+ Remove-Item $ZipPath -Force
+}
+Compress-Archive -Path (Join-Path $EvidenceDir "*") -DestinationPath $ZipPath
+Write-Host "Evidence directory: $EvidenceDir" -ForegroundColor Green
+Write-Host "Evidence zip: $ZipPath" -ForegroundColor Green
+
+Write-Host ""
+Write-Host "Send back the evidence zip if any check is NEEDS_EVIDENCE or the app does not behave correctly." -ForegroundColor Yellow
diff --git a/privatevoice.src/scripts/RUN-WINDOWS-QA.cmd b/privatevoice.src/scripts/RUN-WINDOWS-QA.cmd
new file mode 100644
index 0000000..ca6d5c8
--- /dev/null
+++ b/privatevoice.src/scripts/RUN-WINDOWS-QA.cmd
@@ -0,0 +1,18 @@
+@echo off
+setlocal
+cd /d "%~dp0"
+echo PrivateVoice Dictation Windows Preview QA
+echo.
+echo This will run QA-WINDOWS-PREVIEW.ps1 and collect evidence files.
+echo.
+powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0QA-WINDOWS-PREVIEW.ps1"
+set EXITCODE=%ERRORLEVEL%
+echo.
+if not "%EXITCODE%"=="0" (
+ echo QA script exited with code %EXITCODE%.
+) else (
+ echo QA script finished.
+)
+echo.
+pause
+exit /b %EXITCODE%
diff --git a/privatevoice.src/scripts/build-windows-preview.sh b/privatevoice.src/scripts/build-windows-preview.sh
new file mode 100755
index 0000000..680afe1
--- /dev/null
+++ b/privatevoice.src/scripts/build-windows-preview.sh
@@ -0,0 +1,91 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$ROOT_DIR"
+
+APP_VERSION="2.1.38"
+APP_BUILD="20260629.2014"
+ARCH="${1:-amd64}"
+
+if [[ "$ARCH" != "amd64" ]]; then
+ echo "Only amd64 is supported for the Windows preview build." >&2
+ exit 2
+fi
+
+for tool in go npm x86_64-w64-mingw32-gcc x86_64-w64-mingw32-g++; do
+ if ! command -v "$tool" >/dev/null 2>&1; then
+ echo "Missing required tool: $tool" >&2
+ exit 127
+ fi
+done
+
+OUT_ROOT="$ROOT_DIR/build/windows-preview"
+OUT_DIR="$OUT_ROOT/PrivateVoice-Dictation-${APP_VERSION}-build${APP_BUILD}-windows-${ARCH}-preview"
+ZIP_PATH="$OUT_ROOT/PrivateVoice-Dictation-${APP_VERSION}-build${APP_BUILD}-windows-${ARCH}-preview.zip"
+
+rm -rf "$OUT_DIR" "$ZIP_PATH"
+mkdir -p "$OUT_DIR"
+
+echo "Building frontend..."
+npm --prefix frontend install
+npm --prefix frontend run build
+
+SHERPA_WINDOWS_DIR="$(go list -m -f '{{.Dir}}' github.com/k2-fsa/sherpa-onnx-go-windows)"
+SHERPA_DLL_DIR="$SHERPA_WINDOWS_DIR/lib/x86_64-pc-windows-gnu"
+for dll in onnxruntime.dll sherpa-onnx-c-api.dll sherpa-onnx-cxx-api.dll; do
+ if [[ ! -f "$SHERPA_DLL_DIR/$dll" ]]; then
+ echo "Missing sherpa runtime DLL: $SHERPA_DLL_DIR/$dll" >&2
+ exit 3
+ fi
+done
+
+echo "Building Windows executable..."
+GOOS=windows \
+GOARCH=amd64 \
+CGO_ENABLED=1 \
+CC=x86_64-w64-mingw32-gcc \
+CXX=x86_64-w64-mingw32-g++ \
+go build \
+ -buildvcs=false \
+ -ldflags="-H windowsgui" \
+ -o "$OUT_DIR/PrivateVoice Dictation.exe" \
+ .
+
+cp "$SHERPA_DLL_DIR/onnxruntime.dll" "$OUT_DIR/"
+cp "$SHERPA_DLL_DIR/sherpa-onnx-c-api.dll" "$OUT_DIR/"
+cp "$SHERPA_DLL_DIR/sherpa-onnx-cxx-api.dll" "$OUT_DIR/"
+cp "$ROOT_DIR/scripts/QA-WINDOWS-PREVIEW.ps1" "$OUT_DIR/"
+cp "$ROOT_DIR/scripts/RUN-WINDOWS-QA.cmd" "$OUT_DIR/"
+
+cat >"$OUT_DIR/README-WINDOWS-PREVIEW.txt" <<EOF
+PrivateVoice Dictation Windows Preview
+Version: ${APP_VERSION}
+Build: ${APP_BUILD}
+Architecture: Windows x64
+
+Scope:
+- Technical preview based on PrivateVoice Dictation ${APP_VERSION}.
+- The first Windows preview supports SenseVoice Chinese recognition only.
+- Other models are visible but disabled in this preview build.
+
+How to run:
+1. Extract the whole folder.
+2. Keep the EXE and DLL files in the same directory.
+3. Double-click "RUN-WINDOWS-QA.cmd".
+4. If Windows blocks the command file, right-click "QA-WINDOWS-PREVIEW.ps1" and choose "Run with PowerShell", or run this command from the extracted folder:
+ powershell -ExecutionPolicy Bypass -File .\\QA-WINDOWS-PREVIEW.ps1
+5. Follow the on-screen checklist. It will collect WebView2/audio preflight data, launch the app, open a Notepad target file, ask you to dictate once, and collect logs plus the saved target text.
+6. You can also run "PrivateVoice Dictation.exe" directly after QA.
+
+Known limitations:
+- This is not a signed Windows installer.
+- Real Windows audio, hotkey, paste, tray, and WebView2 behavior still need QA on Windows 10/11.
+- If Windows Defender or SmartScreen blocks it, allow it only for local testing.
+- The QA evidence zip may contain app logs and the saved Notepad target text. Review before sharing if needed.
+EOF
+
+(cd "$OUT_ROOT" && zip -qr "$(basename "$ZIP_PATH")" "$(basename "$OUT_DIR")")
+
+echo "Built: $OUT_DIR"
+echo "Zip: $ZIP_PATH"
diff --git a/privatevoice.src/scripts/verify-windows-qa-evidence.sh b/privatevoice.src/scripts/verify-windows-qa-evidence.sh
new file mode 100755
index 0000000..42140ca
--- /dev/null
+++ b/privatevoice.src/scripts/verify-windows-qa-evidence.sh
@@ -0,0 +1,96 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [[ $# -ne 1 ]]; then
+ echo "Usage: $0 /path/to/qa-evidence-YYYYMMDD-HHMMSS.zip" >&2
+ exit 2
+fi
+
+evidence_zip="$1"
+if [[ ! -f "$evidence_zip" ]]; then
+ echo "Evidence zip not found: $evidence_zip" >&2
+ exit 2
+fi
+
+workdir="$(mktemp -d)"
+cleanup() {
+ rm -rf "$workdir"
+}
+trap cleanup EXIT
+
+unzip -q "$evidence_zip" -d "$workdir"
+
+find_one() {
+ local name="$1"
+ find "$workdir" -type f -name "$name" -print -quit
+}
+
+require_file() {
+ local name="$1"
+ local path
+ path="$(find_one "$name")"
+ if [[ -z "$path" ]]; then
+ echo "FAIL missing $name" >&2
+ return 1
+ fi
+ echo "$path"
+}
+
+check_pattern() {
+ local label="$1"
+ local file="$2"
+ local pattern="$3"
+ if rg -q "$pattern" "$file"; then
+ echo "PASS $label"
+ return 0
+ fi
+ echo "FAIL $label"
+ return 1
+}
+
+failures=0
+
+qa_results="$(require_file "qa-results.tsv")" || failures=$((failures + 1))
+app_log="$(require_file "app.log")" || failures=$((failures + 1))
+target_text="$(require_file "qa-target-dictation-text.txt")" || failures=$((failures + 1))
+system_info="$(require_file "system-info.txt")" || failures=$((failures + 1))
+
+if [[ -n "${qa_results:-}" ]]; then
+ if rg -q '^NEEDS_EVIDENCE' "$qa_results"; then
+ echo "FAIL qa-results.tsv contains NEEDS_EVIDENCE"
+ rg '^NEEDS_EVIDENCE' "$qa_results" || true
+ failures=$((failures + 1))
+ else
+ echo "PASS qa-results.tsv has no NEEDS_EVIDENCE"
+ fi
+fi
+
+if [[ -n "${app_log:-}" ]]; then
+ check_pattern "startup log" "$app_log" 'PrivateVoice Dictation starting' || failures=$((failures + 1))
+ check_pattern "engine ready log" "$app_log" 'ASR engine ready|Engine initialized' || failures=$((failures + 1))
+ check_pattern "recording log" "$app_log" 'Recording started' || failures=$((failures + 1))
+ check_pattern "recognized text log" "$app_log" 'Recognized:' || failures=$((failures + 1))
+ check_pattern "paste success log" "$app_log" 'PERF paste_done.*result=success|PERF fallback_type_done.*result=success' || failures=$((failures + 1))
+fi
+
+if [[ -n "${target_text:-}" ]]; then
+ if [[ -s "$target_text" ]] && [[ -n "$(tr -d '[:space:]' < "$target_text")" ]]; then
+ echo "PASS Notepad target text captured"
+ else
+ echo "FAIL Notepad target text is empty"
+ failures=$((failures + 1))
+ fi
+fi
+
+if [[ -n "${system_info:-}" ]]; then
+ check_pattern "Windows system info" "$system_info" 'Windows|Microsoft|CsSystemType|WindowsProductName' || failures=$((failures + 1))
+fi
+
+echo
+if [[ "$failures" -eq 0 ]]; then
+ echo "WINDOWS_QA_EVIDENCE_PASS"
+ exit 0
+fi
+
+echo "WINDOWS_QA_EVIDENCE_FAIL failures=$failures"
+exit 1
diff --git a/privatevoice.src/services/engine_service.go b/privatevoice.src/services/engine_service.go
index f7608d5..ecfb3ac 100755
--- a/privatevoice.src/services/engine_service.go
+++ b/privatevoice.src/services/engine_service.go
@@ -373,8 +373,14 @@
if err != nil {
return model.ModelProfile{}, err
}
- if !model.IsModelSupportedInCurrentBuild(profile.ID) {
+ switch model.ModelUnsupportedReason(profile.ID) {
+ case "":
+ case model.UnsupportedReasonRequiresMacOS14:
return model.ModelProfile{}, fmt.Errorf("model %s requires macOS 14 or later", profile.ID)
+ case model.UnsupportedReasonWindowsPreview:
+ return model.ModelProfile{}, fmt.Errorf("model %s is not supported in the Windows preview build; use SenseVoice", profile.ID)
+ default:
+ return model.ModelProfile{}, fmt.Errorf("model %s is not supported in this build", profile.ID)
}
current := s.currentModel()
diff --git a/privatevoice.src/services/engine_service_qwen3_default_test.go b/privatevoice.src/services/engine_service_qwen3_default_test.go
index 9293685..0e4997e 100644
--- a/privatevoice.src/services/engine_service_qwen3_default_test.go
+++ b/privatevoice.src/services/engine_service_qwen3_default_test.go
@@ -1,4 +1,4 @@
-//go:build !monterey_compat
+//go:build !monterey_compat && !windows
package services
diff --git a/privatevoice.src/services/engine_service_windows_test.go b/privatevoice.src/services/engine_service_windows_test.go
new file mode 100644
index 0000000..9700ba3
--- /dev/null
+++ b/privatevoice.src/services/engine_service_windows_test.go
@@ -0,0 +1,43 @@
+//go:build windows
+
+package services
+
+import (
+ "strings"
+ "testing"
+
+ "voicesnap/internal/config"
+ "voicesnap/internal/language"
+ "voicesnap/internal/model"
+ "voicesnap/internal/modelselection"
+)
+
+func TestWindowsPreviewShowsButDisablesNonSenseVoiceModels(t *testing.T) {
+ cfg := config.Default()
+ cfg.LanguageMode = config.LanguageModeManual
+ cfg.LanguageID = model.DefaultLanguageID
+ service := NewEngineService(cfg)
+ profile, err := model.GetModelProfile(model.Qwen3ASRModelID)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ status := service.modelStatusMap(profile, modelselection.CurrentModel{
+ ModelID: model.DefaultModelID,
+ Profile: model.DefaultModelProfile(),
+ SelectionMode: config.ModelSelectionModeAuto,
+ LanguageSettings: language.Resolve(config.LanguageModeManual, model.DefaultLanguageID, nil),
+ })
+
+ if got := status["supportedInBuild"]; got != false {
+ t.Fatalf("supportedInBuild = %v, want false", got)
+ }
+ if got := status["unsupportedReason"]; got != model.UnsupportedReasonWindowsPreview {
+ t.Fatalf("unsupportedReason = %v, want %s", got, model.UnsupportedReasonWindowsPreview)
+ }
+ if _, err := service.allowedModelProfile(model.Qwen3ASRModelID); err == nil {
+ t.Fatal("expected qwen3-asr to be rejected in Windows preview build")
+ } else if !strings.Contains(err.Error(), "Windows preview") {
+ t.Fatalf("error = %q, want Windows preview reason", err.Error())
+ }
+}
--
Gitblit v1.9.3