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