Ariver
2026-06-07 d435cd4b9754de4a567261b1a877add3af977d9a
Add X-ASR experimental backend
7 files modified
1 files added
365 ■■■■■ changed files
privatevoice.src/internal/engine/engine.go 2 ●●● patch | view | raw | blame | history
privatevoice.src/internal/engine/engine_darwin.go 87 ●●●●● patch | view | raw | blame | history
privatevoice.src/internal/engine/engine_darwin_test.go 71 ●●●●● patch | view | raw | blame | history
privatevoice.src/internal/model/model_test.go 83 ●●●●● patch | view | raw | blame | history
privatevoice.src/internal/model/profile.go 1 ●●●● patch | view | raw | blame | history
privatevoice.src/internal/model/registry.go 40 ●●●●● patch | view | raw | blame | history
privatevoice.src/internal/modelselection/selection_test.go 24 ●●●●● patch | view | raw | blame | history
privatevoice.src/scripts/install-x-asr-zh-en-960ms.sh 57 ●●●●● patch | view | raw | blame | history
privatevoice.src/internal/engine/engine.go
@@ -90,7 +90,7 @@
func isSupportedBackend(backend string) bool {
    switch backend {
    case model.BackendSenseVoice, model.BackendMoonshine, model.BackendNemoTransducer, model.BackendQwen3ASR:
    case model.BackendSenseVoice, model.BackendMoonshine, model.BackendNemoTransducer, model.BackendQwen3ASR, model.BackendXASRStreaming:
        return true
    default:
        return false
privatevoice.src/internal/engine/engine_darwin.go
@@ -16,9 +16,17 @@
    hwInfo     string
}
type xasrStreamingEngine struct {
    recognizer *sherpa.OnlineRecognizer
    hwInfo     string
}
func newPlatformEngine(resolved model.ResolvedModel) (Engine, error) {
    if !isSupportedBackend(resolved.BackendKind) {
        return nil, fmt.Errorf("unsupported backend kind: %s", resolved.BackendKind)
    }
    if resolved.BackendKind == model.BackendXASRStreaming {
        return newXASRStreamingEngine(resolved)
    }
    providers := darwinProviders(resolved.ProviderOrder)
@@ -42,6 +50,30 @@
    }
    return nil, fmt.Errorf("failed to initialize sherpa-onnx with any provider")
}
func newXASRStreamingEngine(resolved model.ResolvedModel) (Engine, error) {
    providers := darwinProviders(resolved.ProviderOrder)
    for _, p := range providers {
        config, err := onlineConfigForResolvedModel(resolved, p.provider)
        if err != nil {
            return nil, err
        }
        recognizer := sherpa.NewOnlineRecognizer(&config)
        if recognizer != nil {
            info := fmt.Sprintf("%s · %s", resolved.Profile.DisplayName, p.name)
            logger.Info("Engine initialized: %s", info)
            return &xasrStreamingEngine{
                recognizer: recognizer,
                hwInfo:     info,
            }, nil
        }
        logger.Info("Failed to init X-ASR with %s, trying next provider", p.name)
    }
    return nil, fmt.Errorf("failed to initialize X-ASR streaming recognizer with any provider")
}
func offlineConfigForResolvedModel(resolved model.ResolvedModel, provider string) (sherpa.OfflineRecognizerConfig, error) {
@@ -79,6 +111,29 @@
        config.ModelConfig.Qwen3ASR.Seed = 0
    default:
        return sherpa.OfflineRecognizerConfig{}, fmt.Errorf("unsupported backend kind: %s", resolved.BackendKind)
    }
    return config, nil
}
func onlineConfigForResolvedModel(resolved model.ResolvedModel, provider string) (sherpa.OnlineRecognizerConfig, error) {
    config := sherpa.OnlineRecognizerConfig{}
    config.FeatConfig.SampleRate = 16000
    config.FeatConfig.FeatureDim = 80
    config.ModelConfig.Tokens = resolved.Files["tokens"]
    config.ModelConfig.NumThreads = resolved.Profile.NumThreads
    config.ModelConfig.Provider = provider
    config.ModelConfig.ModelType = "zipformer2"
    config.DecodingMethod = "greedy_search"
    config.EnableEndpoint = 0
    switch resolved.BackendKind {
    case model.BackendXASRStreaming:
        config.ModelConfig.Transducer.Encoder = resolved.Files["encoder"]
        config.ModelConfig.Transducer.Decoder = resolved.Files["decoder"]
        config.ModelConfig.Transducer.Joiner = resolved.Files["joiner"]
    default:
        return sherpa.OnlineRecognizerConfig{}, fmt.Errorf("unsupported online backend kind: %s", resolved.BackendKind)
    }
    return config, nil
@@ -127,7 +182,32 @@
    return result.Text, nil
}
func (e *xasrStreamingEngine) Recognize(samples []float32) (string, error) {
    stream := sherpa.NewOnlineStream(e.recognizer)
    defer sherpa.DeleteOnlineStream(stream)
    if len(samples) > 0 {
        stream.AcceptWaveform(16000, samples)
    }
    stream.InputFinished()
    for e.recognizer.IsReady(stream) {
        e.recognizer.Decode(stream)
    }
    result := e.recognizer.GetResult(stream)
    if result == nil {
        return "", nil
    }
    return result.Text, nil
}
func (e *sherpaEngine) HardwareInfo() string {
    return e.hwInfo
}
func (e *xasrStreamingEngine) HardwareInfo() string {
    return e.hwInfo
}
@@ -137,3 +217,10 @@
        e.recognizer = nil
    }
}
func (e *xasrStreamingEngine) Close() {
    if e.recognizer != nil {
        sherpa.DeleteOnlineRecognizer(e.recognizer)
        e.recognizer = nil
    }
}
privatevoice.src/internal/engine/engine_darwin_test.go
@@ -3,6 +3,7 @@
package engine
import (
    "os"
    "path/filepath"
    "testing"
@@ -96,3 +97,73 @@
        t.Fatalf("tokens = %q, want empty for qwen3-asr", config.ModelConfig.Tokens)
    }
}
func TestOnlineConfigForXASRUsesStreamingTransducer(t *testing.T) {
    root := t.TempDir()
    files := map[string]string{
        "encoder": filepath.Join(root, "encoder-960ms.onnx"),
        "decoder": filepath.Join(root, "decoder-960ms.onnx"),
        "joiner":  filepath.Join(root, "joiner-960ms.onnx"),
        "tokens":  filepath.Join(root, "tokens.txt"),
    }
    resolved := model.ResolvedModel{
        ModelID:     model.XASRZhEn960ModelID,
        BackendKind: model.BackendXASRStreaming,
        Profile: model.ModelProfile{
            DisplayName:   "X-ASR zh-en 960ms",
            BackendKind:   model.BackendXASRStreaming,
            ProviderOrder: []string{"cpu"},
            NumThreads:    1,
        },
        Files: files,
    }
    config, err := onlineConfigForResolvedModel(resolved, "cpu")
    if err != nil {
        t.Fatal(err)
    }
    if config.ModelConfig.Transducer.Encoder != files["encoder"] {
        t.Fatalf("encoder = %q, want %q", config.ModelConfig.Transducer.Encoder, files["encoder"])
    }
    if config.ModelConfig.Transducer.Decoder != files["decoder"] {
        t.Fatalf("decoder = %q, want %q", config.ModelConfig.Transducer.Decoder, files["decoder"])
    }
    if config.ModelConfig.Transducer.Joiner != files["joiner"] {
        t.Fatalf("joiner = %q, want %q", config.ModelConfig.Transducer.Joiner, files["joiner"])
    }
    if config.ModelConfig.Tokens != files["tokens"] {
        t.Fatalf("tokens = %q, want %q", config.ModelConfig.Tokens, files["tokens"])
    }
    if config.ModelConfig.ModelType != "zipformer2" {
        t.Fatalf("model type = %q, want zipformer2", config.ModelConfig.ModelType)
    }
    if config.DecodingMethod != "greedy_search" {
        t.Fatalf("decoding method = %q, want greedy_search", config.DecodingMethod)
    }
}
func TestXASRRealModelSmoke(t *testing.T) {
    if os.Getenv("PRIVATEVOICE_XASR_SMOKE") != "1" {
        t.Skip("set PRIVATEVOICE_XASR_SMOKE=1 to run the real X-ASR model smoke test")
    }
    resolved, err := model.ResolveModel(model.XASRZhEn960ModelID)
    if err != nil {
        t.Fatal(err)
    }
    if !resolved.IsUsable() {
        t.Fatalf("x-asr model is not usable: status=%s missing=%v problems=%v", resolved.Status, resolved.Missing, resolved.Problems)
    }
    eng, err := NewWithResolvedModel(resolved)
    if err != nil {
        t.Fatal(err)
    }
    defer eng.Close()
    text, err := eng.Recognize(make([]float32, 16000))
    if err != nil {
        t.Fatal(err)
    }
    t.Logf("hardware=%s silence_text=%q", eng.HardwareInfo(), text)
}
privatevoice.src/internal/model/model_test.go
@@ -80,6 +80,25 @@
    }
}
func TestRegistryReturnsXASRZhEn960(t *testing.T) {
    profile, err := GetModelProfile(XASRZhEn960ModelID)
    if err != nil {
        t.Fatal(err)
    }
    if profile.BackendKind != BackendXASRStreaming {
        t.Fatalf("backend kind = %q, want %q", profile.BackendKind, BackendXASRStreaming)
    }
    if profile.InstallDirName != "x-asr-zh-en-960ms" {
        t.Fatalf("install dir = %q, want x-asr-zh-en-960ms", profile.InstallDirName)
    }
    if profile.Tier != "experimental" {
        t.Fatalf("tier = %q, want experimental", profile.Tier)
    }
    if len(profile.DownloadURLs) != 0 {
        t.Fatal("expected x-asr experiment to use manual/scripted install, not in-app tar download")
    }
}
func TestChineseLanguageOffersQwen3ASRUpgrade(t *testing.T) {
    profile, err := GetLanguageProfile(DefaultLanguageID)
    if err != nil {
@@ -88,8 +107,8 @@
    if profile.DefaultModelID != DefaultModelID {
        t.Fatalf("Chinese default model = %q, want %q", profile.DefaultModelID, DefaultModelID)
    }
    if len(profile.UpgradeModelIDs) != 1 || profile.UpgradeModelIDs[0] != Qwen3ASRModelID {
        t.Fatalf("Chinese upgrade models = %v, want [%s]", profile.UpgradeModelIDs, Qwen3ASRModelID)
    if !stringSliceContains(profile.UpgradeModelIDs, Qwen3ASRModelID) || !stringSliceContains(profile.UpgradeModelIDs, XASRZhEn960ModelID) {
        t.Fatalf("Chinese upgrade models = %v, want qwen3 and x-asr", profile.UpgradeModelIDs)
    }
}
@@ -101,8 +120,8 @@
    if profile.DefaultModelID != MoonshineModelID {
        t.Fatalf("English default model = %q, want %q", profile.DefaultModelID, MoonshineModelID)
    }
    if len(profile.UpgradeModelIDs) != 1 || profile.UpgradeModelIDs[0] != ParakeetModelID {
        t.Fatalf("English upgrade models = %v, want [%s]", profile.UpgradeModelIDs, ParakeetModelID)
    if !stringSliceContains(profile.UpgradeModelIDs, ParakeetModelID) || !stringSliceContains(profile.UpgradeModelIDs, XASRZhEn960ModelID) {
        t.Fatalf("English upgrade models = %v, want parakeet and x-asr", profile.UpgradeModelIDs)
    }
}
@@ -282,6 +301,42 @@
    result := ValidateModelDir(profile, dir)
    if result.Valid {
        t.Fatal("expected tokenizer file to fail directory validation")
    }
}
func TestValidateXASRZhEn960RequiredFiles(t *testing.T) {
    profile, err := GetModelProfile(XASRZhEn960ModelID)
    if err != nil {
        t.Fatal(err)
    }
    dir := t.TempDir()
    createValidXASRZhEn960(t, dir)
    result := ValidateModelDir(profile, dir)
    if !result.Valid {
        t.Fatalf("expected valid x-asr dir, missing=%v problems=%v", result.Missing, result.Problems)
    }
    for _, role := range []string{"encoder", "decoder", "joiner", "tokens"} {
        if result.Files[role] == "" {
            t.Fatalf("missing resolved file role %q", role)
        }
    }
}
func TestValidateXASRZhEn960RejectsMissingJoiner(t *testing.T) {
    profile, err := GetModelProfile(XASRZhEn960ModelID)
    if err != nil {
        t.Fatal(err)
    }
    dir := t.TempDir()
    createValidXASRZhEn960(t, dir)
    if err := os.Remove(filepath.Join(dir, "joiner-960ms.onnx")); err != nil {
        t.Fatal(err)
    }
    result := ValidateModelDir(profile, dir)
    if result.Valid {
        t.Fatal("expected missing joiner to fail validation")
    }
}
@@ -681,6 +736,26 @@
    writeTestFile(t, filepath.Join(dir, "tokenizer", "vocab.json"), "vocab")
}
func createValidXASRZhEn960(t *testing.T, dir string) {
    t.Helper()
    if err := os.MkdirAll(dir, 0755); err != nil {
        t.Fatal(err)
    }
    writeTestFile(t, filepath.Join(dir, "encoder-960ms.onnx"), "encoder")
    writeTestFile(t, filepath.Join(dir, "decoder-960ms.onnx"), "decoder")
    writeTestFile(t, filepath.Join(dir, "joiner-960ms.onnx"), "joiner")
    writeTestFile(t, filepath.Join(dir, "tokens.txt"), "tokens")
}
func stringSliceContains(values []string, target string) bool {
    for _, value := range values {
        if value == target {
            return true
        }
    }
    return false
}
func tarArchive(t *testing.T, files map[string]string) []byte {
    t.Helper()
    var buf bytes.Buffer
privatevoice.src/internal/model/profile.go
@@ -5,6 +5,7 @@
    MoonshineModelID  = "moonshine-en"
    ParakeetModelID   = "parakeet-en"
    Qwen3ASRModelID   = "qwen3-asr-0.6b"
    XASRZhEn960ModelID = "x-asr-zh-en-960ms"
    DefaultLanguageID = "zh-CN"
    EnglishLanguageID = "en"
)
privatevoice.src/internal/model/registry.go
@@ -10,6 +10,7 @@
    BackendMoonshine      = "moonshine"
    BackendNemoTransducer = "nemo_transducer"
    BackendQwen3ASR       = "qwen3_asr"
    BackendXASRStreaming  = "x_asr_streaming"
)
var modelProfiles = map[string]ModelProfile{
@@ -180,6 +181,41 @@
        ProviderOrder: []string{"cpu"},
        NumThreads:    4,
    },
    XASRZhEn960ModelID: {
        ID:                   XASRZhEn960ModelID,
        DisplayName:          "X-ASR zh-en 960ms",
        BackendKind:          BackendXASRStreaming,
        Tier:                 "experimental",
        SupportedLanguageIDs: []string{DefaultLanguageID, "zh-Hans", "zh-Hant", "yue", EnglishLanguageID},
        RecommendedFor:       []string{DefaultLanguageID, EnglishLanguageID},
        Description:          "实验性中英流式模型,适合评估中英混输和未来边说边出字体验。",
        ApproxSize:           "单个 960 ms 模型目录约 586 MiB",
        InstallDirName:       "x-asr-zh-en-960ms",
        RequiredFiles: []RequiredFileRule{
            {
                Role:     "encoder",
                AllOf:    []string{"encoder-960ms.onnx"},
                Required: true,
            },
            {
                Role:     "decoder",
                AllOf:    []string{"decoder-960ms.onnx"},
                Required: true,
            },
            {
                Role:     "joiner",
                AllOf:    []string{"joiner-960ms.onnx"},
                Required: true,
            },
            {
                Role:     "tokens",
                AllOf:    []string{"tokens.txt"},
                Required: true,
            },
        },
        ProviderOrder: []string{"cpu"},
        NumThreads:    1,
    },
}
var languageProfiles = map[string]LanguageProfile{
@@ -190,7 +226,7 @@
        UILocale:        "zh",
        SystemMatchers:  []string{"zh", "zh-CN", "zh-Hans", "zh-Hant"},
        DefaultModelID:  DefaultModelID,
        UpgradeModelIDs: []string{Qwen3ASRModelID},
        UpgradeModelIDs: []string{Qwen3ASRModelID, XASRZhEn960ModelID},
    },
    EnglishLanguageID: {
        ID:              EnglishLanguageID,
@@ -199,7 +235,7 @@
        UILocale:        "en",
        SystemMatchers:  []string{"en", "en-US", "en-GB", "en-AU", "en-CA"},
        DefaultModelID:  MoonshineModelID,
        UpgradeModelIDs: []string{ParakeetModelID},
        UpgradeModelIDs: []string{ParakeetModelID, XASRZhEn960ModelID},
    },
}
privatevoice.src/internal/modelselection/selection_test.go
@@ -79,6 +79,30 @@
    }
}
func TestResolveManualUsesXASRForChinese(t *testing.T) {
    cfg := config.Default()
    cfg.LanguageMode = config.LanguageModeAuto
    cfg.ModelSelectionMode = config.ModelSelectionModeManual
    cfg.SelectedModelID = model.XASRZhEn960ModelID
    current := Resolve(cfg, fakeDetector{locale: "zh-CN"})
    if current.ModelID != model.XASRZhEn960ModelID {
        t.Fatalf("model id = %q, want manual selected %q", current.ModelID, model.XASRZhEn960ModelID)
    }
}
func TestResolveManualUsesXASRForEnglish(t *testing.T) {
    cfg := config.Default()
    cfg.LanguageMode = config.LanguageModeAuto
    cfg.ModelSelectionMode = config.ModelSelectionModeManual
    cfg.SelectedModelID = model.XASRZhEn960ModelID
    current := Resolve(cfg, fakeDetector{locale: "en-US"})
    if current.ModelID != model.XASRZhEn960ModelID {
        t.Fatalf("model id = %q, want manual selected %q", current.ModelID, model.XASRZhEn960ModelID)
    }
}
func TestResolveManualModelFallsBackWhenLanguageIncompatible(t *testing.T) {
    cfg := config.Default()
    cfg.LanguageMode = config.LanguageModeAuto
privatevoice.src/scripts/install-x-asr-zh-en-960ms.sh
New file
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -euo pipefail
MODEL_ID="x-asr-zh-en-960ms"
CHUNK_DIR="chunk-960ms-model"
BASE_URL="https://huggingface.co/GilgameshWind/X-ASR-zh-en/resolve/main/deployment/models/${CHUNK_DIR}"
APP_SUPPORT_DIR="${PRIVATEVOICE_APP_SUPPORT_DIR:-$HOME/Library/Application Support/PrivateVoice Input}"
MODELS_ROOT="${PRIVATEVOICE_MODELS_ROOT:-$APP_SUPPORT_DIR/models}"
FINAL_DIR="$MODELS_ROOT/$MODEL_ID"
RUN_ID="$(date +%Y%m%d.%H%M%S)"
STAGING_DIR="$MODELS_ROOT/.staging/${MODEL_ID}-${RUN_ID}"
BACKUP_DIR="$FINAL_DIR.backup-${RUN_ID}"
FILES=(
  "encoder-960ms.onnx"
  "decoder-960ms.onnx"
  "joiner-960ms.onnx"
  "tokens.txt"
)
cleanup() {
  rm -rf "$STAGING_DIR"
}
trap cleanup EXIT
mkdir -p "$STAGING_DIR"
for file in "${FILES[@]}"; do
  url="$BASE_URL/$file"
  dest="$STAGING_DIR/$file"
  echo "Downloading $file"
  curl -L -sS --fail --retry 5 --retry-delay 2 --continue-at - --output "$dest" "$url"
  if [[ ! -s "$dest" ]]; then
    echo "Downloaded file is empty: $dest" >&2
    exit 1
  fi
  echo "Downloaded $file"
done
xattr -cr "$STAGING_DIR" 2>/dev/null || true
mkdir -p "$MODELS_ROOT"
if [[ -e "$FINAL_DIR" ]]; then
  mv "$FINAL_DIR" "$BACKUP_DIR"
fi
mv "$STAGING_DIR" "$FINAL_DIR"
trap - EXIT
if [[ -e "$BACKUP_DIR" ]]; then
  rm -rf "$BACKUP_DIR"
fi
echo "Installed $MODEL_ID to:"
echo "$FINAL_DIR"
du -sh "$FINAL_DIR" 2>/dev/null || true