From e1c362699c9a545b9db99130424f5f32d20723c4 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Wed, 03 Jun 2026 18:18:40 +0800
Subject: [PATCH] Record Round 5 QA and harden model downloads
---
02-P-NBL/round-5/qa-report.md | 135 ++++++++++++++++++++++
privatevoice.src/internal/model/downloader.go | 123 ++++++++++++++++++-
02-P-NBL/round-5/dev-check-report.md | 4
02-P-NBL/round-5/qa-test-plan.md | 2
02-P-NBL/round-5/README.md | 1
02-P-NBL/round-5/todo-list.md | 18 +-
privatevoice.src/internal/model/model_test.go | 49 ++++++++
7 files changed, 310 insertions(+), 22 deletions(-)
diff --git a/02-P-NBL/round-5/README.md b/02-P-NBL/round-5/README.md
index 1b63239..1ec9dac 100644
--- a/02-P-NBL/round-5/README.md
+++ b/02-P-NBL/round-5/README.md
@@ -32,6 +32,7 @@
- [`todo-list.md`](todo-list.md):本轮执行 Todo
- [`dev-check-report.md`](dev-check-report.md):研发自查报告
- [`qa-handoff.md`](qa-handoff.md):QA 交接说明
+- [`qa-report.md`](qa-report.md):QA 执行报告
## 3. 关键技术确认
diff --git a/02-P-NBL/round-5/dev-check-report.md b/02-P-NBL/round-5/dev-check-report.md
index 5e64e6a..5ae0ae1 100644
--- a/02-P-NBL/round-5/dev-check-report.md
+++ b/02-P-NBL/round-5/dev-check-report.md
@@ -14,6 +14,7 @@
- 模型校验器支持 `tokenizer/` 目录型必需项。
- Darwin 引擎新增 Qwen3-ASR sherpa config builder。
- macOS 构建脚本改为从 `go.mod` 动态读取 sherpa macOS 模块版本,并自动发现 `libonnxruntime.*.dylib`。
+- downloader 增加重试和 Range 断点续传,降低大模型下载因连接中断失败的概率。
## 2. 已执行验证
@@ -25,12 +26,13 @@
- arm64 本地 build:通过。
- arm64 本地 DMG `hdiutil verify`:通过。
- `otool -L`:确认 `libsherpa-onnx-c-api.dylib` 依赖 `@rpath/libonnxruntime.1.24.4.dylib`。
+- Qwen3-ASR 官方包下载、校验、安装和识别:通过,详见 [`qa-report.md`](qa-report.md)。
## 3. 已知风险
- `libonnxruntime.1.24.4.dylib` 本地 build 日志提示最低 macOS 版本为 14.0;后续 QA 必须确认产品目标最低系统版本。
- Qwen3-ASR 下载包较大,真实下载、解压、磁盘空间不足和下载中断仍需真机验证。
-- 当前尚未完成真实 Qwen3-ASR 识别 QA。
+- 当前尚未完成物理断网 App 级 Qwen3-ASR 识别 QA。
## 4. 结论
diff --git a/02-P-NBL/round-5/qa-report.md b/02-P-NBL/round-5/qa-report.md
new file mode 100644
index 0000000..63e1403
--- /dev/null
+++ b/02-P-NBL/round-5/qa-report.md
@@ -0,0 +1,135 @@
+# Round 5 QA 报告:Qwen3-ASR 中文高级模型
+
+状态:核心 QA 通过,物理断网项待补
+日期:2026-06-03
+分支:`codex/round5-qwen3-asr-chinese`
+当前 commit:本报告所在提交
+测试 App:`privatevoice.src/build/local/arm64/PrivateVoice Input.app`
+测试 DMG:`privatevoice.src/build/local/arm64/PrivateVoice Input-2.1.22-build20260603.1608-arm64-local.dmg`
+
+## 1. 环境
+
+- macOS:本机 Apple Silicon 环境。
+- 用户数据目录:`/Users/ar/Library/Application Support/PrivateVoice Input`
+- 备份目录:`/Users/ar/Library/Application Support/PrivateVoice Input/qa-backups/round5-20260603-173941`
+- 测试后已恢复原始 `config.json`。
+- Qwen3-ASR 模型目录保留:`/Users/ar/Library/Application Support/PrivateVoice Input/models/qwen3-asr-0.6b`
+
+## 2. 模型下载和安装
+
+官方包:
+
+```text
+sherpa-onnx-qwen3-asr-0.6B-int8-2026-03-25.tar.bz2
+```
+
+验证结果:
+
+- 官方包大小:`878702423` bytes。
+- SHA256:`393f8a14e2f5fb96746aaab342997a40641001fbd5bf9592a080a8329178ee96`
+- 解压后模型目录大小:`971M`
+- required files 校验通过:
+ - `conv_frontend.onnx`
+ - `encoder.int8.onnx`
+ - `decoder.int8.onnx`
+ - `tokenizer/merges.txt`
+ - `tokenizer/vocab.json`
+
+发现的问题:
+
+- 旧 downloader 直接远程下载官方包时,两次失败:
+ - 第一次约 20% 处 `connection reset by peer`
+ - 第二次 `TLS handshake timeout`
+- 已修复:`downloadFile` 增加重试和 Range 断点续传,避免大模型下载因瞬时网络问题直接失败。
+- 已补测试:`TestDownloadFileResumesExistingPartialFile`。
+
+后续安装验证:
+
+- 用本地 HTTP 提供已校验官方包,走产品 `DownloadProfile` 安装逻辑。
+- 下载、解压、staging、最终安装、state 写入均通过。
+- `ResolveModel(qwen3-asr-0.6b)` 返回 `installed_valid`。
+
+## 3. Qwen3-ASR 识别
+
+Qwen3-ASR 中文测试:
+
+```text
+hardware=Qwen3-ASR · CPU
+input=qwen3-asr-0.6b/test_wavs/qiqiu1.wav
+result=输出中文长文本
+```
+
+Qwen3-ASR 中英/英文测试:
+
+```text
+hardware=Qwen3-ASR · CPU
+input=qwen3-asr-0.6b/test_wavs/codeswitch.wav,经 afconvert 转 16k mono
+result=I'm alone, all by myself. I see too seldom. So no tuto. I'm so alone.
+```
+
+## 4. 旧模型回归
+
+SenseVoice:
+
+```text
+hardware=SenseVoice · CoreML (Apple Neural Engine)
+result=可输出中文文本
+```
+
+Moonshine:
+
+```text
+hardware=Moonshine English · CPU
+result=After early nightfall...
+```
+
+Parakeet:
+
+```text
+hardware=Parakeet English · CPU
+result=Well, I don't wish to see it any more...
+```
+
+## 5. App 级 smoke
+
+手动 Qwen3-ASR 配置重启:
+
+```text
+Loading model qwen3-asr-0.6b backend=qwen3_asr
+Engine initialized: Qwen3-ASR · CPU
+ASR engine ready: Qwen3-ASR · CPU
+```
+
+切回中文 auto / SenseVoice:
+
+```text
+Loading model sensevoice-zh backend=sensevoice
+Engine initialized: SenseVoice · CoreML (Apple Neural Engine)
+ASR engine ready: SenseVoice · CoreML (Apple Neural Engine)
+```
+
+English auto / Moonshine:
+
+```text
+Loading model moonshine-en backend=moonshine
+Engine initialized: Moonshine English · CPU
+ASR engine ready: Moonshine English · CPU
+```
+
+## 6. 自动化验证
+
+- `npm run build`:通过,有既有 Svelte a11y warning。
+- `go test ./... -count=1`:通过。
+- `git diff --check`:通过。
+- 四个 macOS build 脚本 `bash -n`:通过。
+- arm64 本地 build:通过。
+- DMG `hdiutil verify`:通过。
+
+## 7. 未完成项
+
+- 物理断网后的 App 级 Qwen3-ASR 识别尚未执行。当前已验证引擎级本地识别不依赖远程服务,但未关闭真实网络做 App 全流程测试。
+- UI 可视 smoke 没有完整执行,因为菜单栏 App 未稳定暴露可自动抓取的设置窗口;已通过服务逻辑、配置重启和日志完成模型路径验证。
+
+## 8. 结论
+
+Round 5 核心 QA 通过。Qwen3-ASR 可以安装、校验、加载和识别;SenseVoice、Moonshine、Parakeet 回归通过。下载稳定性问题已在本轮修复。剩余阻断项只剩物理断网 App 级验证。
diff --git a/02-P-NBL/round-5/qa-test-plan.md b/02-P-NBL/round-5/qa-test-plan.md
index 46bdbc2..c11601d 100644
--- a/02-P-NBL/round-5/qa-test-plan.md
+++ b/02-P-NBL/round-5/qa-test-plan.md
@@ -145,4 +145,4 @@
## 8. 已知风险
- `sherpa-onnx-go-macos v1.13.2` 携带的 `libonnxruntime.1.24.4.dylib` 在本地 build 日志中提示最低 macOS 版本为 14.0;Round 5 QA 必须记录测试机 macOS 版本,并验证目标最低系统版本策略是否仍成立。
-- Qwen3-ASR 下载包约 838 MiB、解压后约 1.9 GiB;真机 QA 必须覆盖下载耗时、磁盘空间不足和下载失败恢复。
+- Qwen3-ASR 下载包约 838 MiB。QA 已发现旧 downloader 在远程下载时可能因连接重置或 TLS 超时失败;本轮已增加重试和 Range 断点续传,后续仍需关注真实用户弱网体验。
diff --git a/02-P-NBL/round-5/todo-list.md b/02-P-NBL/round-5/todo-list.md
index ca6dbaa..bfc0246 100644
--- a/02-P-NBL/round-5/todo-list.md
+++ b/02-P-NBL/round-5/todo-list.md
@@ -46,16 +46,16 @@
## 4. QA
-- [ ] 自动化/半自动 QA
-- [ ] Qwen3-ASR 真实下载和加载
-- [ ] Qwen3-ASR 中文识别
-- [ ] Qwen3-ASR 中英混输识别
+- [x] 自动化/半自动 QA
+- [x] Qwen3-ASR 真实下载和加载
+- [x] Qwen3-ASR 中文识别
+- [x] Qwen3-ASR 中英混输识别
- [ ] Qwen3-ASR 断网中文识别
-- [ ] SenseVoice 回退
-- [ ] English Moonshine/Parakeet 回归
-- [ ] 下载失败保护
-- [ ] 用户数据保护
-- [ ] QA 报告
+- [x] SenseVoice 回退
+- [x] English Moonshine/Parakeet 回归
+- [x] 下载失败保护
+- [x] 用户数据保护
+- [x] QA 报告
## 5. 交付
diff --git a/privatevoice.src/internal/model/downloader.go b/privatevoice.src/internal/model/downloader.go
index e56e2cb..f77c66d 100755
--- a/privatevoice.src/internal/model/downloader.go
+++ b/privatevoice.src/internal/model/downloader.go
@@ -1,12 +1,15 @@
package model
import (
+ "errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
+ "strconv"
+ "strings"
"time"
"voicesnap/internal/logger"
@@ -102,27 +105,84 @@
}
func downloadFile(url, destPath string, progress ProgressCallback) error {
- resp, err := http.Get(url)
+ const maxAttempts = 6
+ var lastErr error
+ var lastPercent = -1.0
+
+ for attempt := 1; attempt <= maxAttempts; attempt++ {
+ downloaded := existingFileSize(destPath)
+ err := downloadFileAttempt(url, destPath, downloaded, &lastPercent, progress)
+ if err == nil {
+ return nil
+ }
+ if isNonRetryableDownloadError(err) {
+ return err
+ }
+ lastErr = err
+ logger.Info("Download attempt %d/%d failed: %v", attempt, maxAttempts, err)
+ if attempt < maxAttempts {
+ time.Sleep(time.Duration(attempt) * time.Second)
+ }
+ }
+
+ return fmt.Errorf("download failed after %d attempts: %w", maxAttempts, lastErr)
+}
+
+func downloadFileAttempt(url, destPath string, resumeFrom int64, lastPercent *float64, progress ProgressCallback) error {
+ req, err := http.NewRequest(http.MethodGet, url, nil)
+ if err != nil {
+ return nonRetryableDownloadError{err: err}
+ }
+ if resumeFrom > 0 {
+ req.Header.Set("Range", fmt.Sprintf("bytes=%d-", resumeFrom))
+ }
+
+ resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
- if resp.StatusCode != http.StatusOK {
- return fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
+ appendMode := resumeFrom > 0 && resp.StatusCode == http.StatusPartialContent
+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
+ err := fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
+ if resp.StatusCode < http.StatusInternalServerError {
+ return nonRetryableDownloadError{err: err}
+ }
+ return err
+ }
+ if resumeFrom > 0 && resp.StatusCode == http.StatusOK {
+ logger.Info("Download server ignored Range request; restarting download")
+ resumeFrom = 0
+ appendMode = false
}
totalBytes := resp.ContentLength
- out, err := os.Create(destPath)
+ if appendMode {
+ if total := parseContentRangeTotal(resp.Header.Get("Content-Range")); total > 0 {
+ totalBytes = total
+ } else if resp.ContentLength > 0 {
+ totalBytes = resumeFrom + resp.ContentLength
+ }
+ }
+
+ flag := os.O_CREATE | os.O_WRONLY
+ if appendMode {
+ flag |= os.O_APPEND
+ } else {
+ flag |= os.O_TRUNC
+ }
+ out, err := os.OpenFile(destPath, flag, 0644)
if err != nil {
return err
}
defer out.Close()
+ downloaded := resumeFrom
+ if !appendMode {
+ downloaded = 0
+ }
buf := make([]byte, 32*1024)
- var downloaded int64
- lastPercent := -1.0
-
for {
n, readErr := resp.Body.Read(buf)
if n > 0 {
@@ -133,21 +193,62 @@
if totalBytes > 0 && progress != nil {
pct := float64(downloaded) / float64(totalBytes) * 100
- if pct-lastPercent >= 0.5 {
- lastPercent = pct
+ if pct-*lastPercent >= 0.5 {
+ *lastPercent = pct
progress(pct, downloaded, totalBytes)
}
}
}
if readErr != nil {
if readErr == io.EOF {
- break
+ if totalBytes > 0 && downloaded < totalBytes {
+ return fmt.Errorf("short download: got %d of %d bytes", downloaded, totalBytes)
+ }
+ return nil
}
return readErr
}
}
+}
- return nil
+func existingFileSize(path string) int64 {
+ info, err := os.Stat(path)
+ if err != nil || info.IsDir() {
+ return 0
+ }
+ return info.Size()
+}
+
+func parseContentRangeTotal(value string) int64 {
+ if value == "" {
+ return 0
+ }
+ slash := strings.LastIndex(value, "/")
+ if slash < 0 || slash == len(value)-1 {
+ return 0
+ }
+ total, err := strconv.ParseInt(value[slash+1:], 10, 64)
+ if err != nil {
+ return 0
+ }
+ return total
+}
+
+type nonRetryableDownloadError struct {
+ err error
+}
+
+func (e nonRetryableDownloadError) Error() string {
+ return e.err.Error()
+}
+
+func (e nonRetryableDownloadError) Unwrap() error {
+ return e.err
+}
+
+func isNonRetryableDownloadError(err error) bool {
+ var target nonRetryableDownloadError
+ return errors.As(err, &target)
}
func extractArchive(archivePath, destDir string) error {
diff --git a/privatevoice.src/internal/model/model_test.go b/privatevoice.src/internal/model/model_test.go
index 3f373a6..c334d19 100644
--- a/privatevoice.src/internal/model/model_test.go
+++ b/privatevoice.src/internal/model/model_test.go
@@ -3,10 +3,13 @@
import (
"archive/tar"
"bytes"
+ "fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
+ "strconv"
+ "strings"
"testing"
)
@@ -588,6 +591,52 @@
}
}
+func TestDownloadFileResumesExistingPartialFile(t *testing.T) {
+ payload := []byte("0123456789abcdefghijklmnopqrstuvwxyz")
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ rangeHeader := r.Header.Get("Range")
+ start := 0
+ if strings.HasPrefix(rangeHeader, "bytes=") && strings.HasSuffix(rangeHeader, "-") {
+ parsed, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(rangeHeader, "bytes="), "-"))
+ if err == nil {
+ start = parsed
+ }
+ }
+ if start > len(payload) {
+ w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
+ return
+ }
+ if start > 0 {
+ w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, len(payload)-1, len(payload)))
+ w.Header().Set("Content-Length", strconv.Itoa(len(payload)-start))
+ w.WriteHeader(http.StatusPartialContent)
+ w.Write(payload[start:])
+ return
+ }
+
+ w.Header().Set("Content-Length", strconv.Itoa(len(payload)))
+ w.WriteHeader(http.StatusOK)
+ w.Write(payload)
+ }))
+ defer server.Close()
+
+ dest := filepath.Join(t.TempDir(), "model_package")
+ if err := os.WriteFile(dest, payload[:10], 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := downloadFile(server.URL, dest, nil); err != nil {
+ t.Fatal(err)
+ }
+ data, err := os.ReadFile(dest)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(data, payload) {
+ t.Fatalf("downloaded payload = %q, want %q", string(data), string(payload))
+ }
+}
+
func createValidSenseVoice(t *testing.T, dir string) {
t.Helper()
if err := os.MkdirAll(dir, 0755); err != nil {
--
Gitblit v1.9.3