| 02-P-NBL/round-5/README.md | ●●●●● patch | view | raw | blame | history | |
| 02-P-NBL/round-5/dev-check-report.md | ●●●●● patch | view | raw | blame | history | |
| 02-P-NBL/round-5/qa-report.md | ●●●●● patch | view | raw | blame | history | |
| 02-P-NBL/round-5/qa-test-plan.md | ●●●●● patch | view | raw | blame | history | |
| 02-P-NBL/round-5/todo-list.md | ●●●●● patch | view | raw | blame | history | |
| privatevoice.src/internal/model/downloader.go | ●●●●● patch | view | raw | blame | history | |
| privatevoice.src/internal/model/model_test.go | ●●●●● patch | view | raw | blame | history |
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. 关键技术确认 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. 结论 02-P-NBL/round-5/qa-report.md
New file @@ -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 级验证。 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 断点续传,后续仍需关注真实用户弱网体验。 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. 交付 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 { 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 {