Ariver
2026-06-03 e1c362699c9a545b9db99130424f5f32d20723c4
Record Round 5 QA and harden model downloads
6 files modified
1 files added
332 ■■■■■ changed files
02-P-NBL/round-5/README.md 1 ●●●● patch | view | raw | blame | history
02-P-NBL/round-5/dev-check-report.md 4 ●●● patch | view | raw | blame | history
02-P-NBL/round-5/qa-report.md 135 ●●●●● patch | view | raw | blame | history
02-P-NBL/round-5/qa-test-plan.md 2 ●●● patch | view | raw | blame | history
02-P-NBL/round-5/todo-list.md 18 ●●●● patch | view | raw | blame | history
privatevoice.src/internal/model/downloader.go 123 ●●●●● patch | view | raw | blame | history
privatevoice.src/internal/model/model_test.go 49 ●●●●● 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 {