From d435cd4b9754de4a567261b1a877add3af977d9a Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Sun, 07 Jun 2026 01:41:26 +0800
Subject: [PATCH] Add X-ASR experimental backend
---
privatevoice.src/internal/model/profile.go | 13 +
privatevoice.src/internal/modelselection/selection_test.go | 24 +++
privatevoice.src/internal/engine/engine_darwin.go | 87 ++++++++++++
privatevoice.src/internal/engine/engine_darwin_test.go | 71 ++++++++++
privatevoice.src/internal/model/registry.go | 40 +++++
privatevoice.src/internal/engine/engine.go | 2
privatevoice.src/scripts/install-x-asr-zh-en-960ms.sh | 57 ++++++++
privatevoice.src/internal/model/model_test.go | 83 +++++++++++
8 files changed, 364 insertions(+), 13 deletions(-)
diff --git a/privatevoice.src/internal/engine/engine.go b/privatevoice.src/internal/engine/engine.go
index 74a0066..6473cb6 100755
--- a/privatevoice.src/internal/engine/engine.go
+++ b/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
diff --git a/privatevoice.src/internal/engine/engine_darwin.go b/privatevoice.src/internal/engine/engine_darwin.go
index be5b525..d18dd48 100755
--- a/privatevoice.src/internal/engine/engine_darwin.go
+++ b/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
+ }
+}
diff --git a/privatevoice.src/internal/engine/engine_darwin_test.go b/privatevoice.src/internal/engine/engine_darwin_test.go
index 8310a03..50e2a44 100644
--- a/privatevoice.src/internal/engine/engine_darwin_test.go
+++ b/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)
+}
diff --git a/privatevoice.src/internal/model/model_test.go b/privatevoice.src/internal/model/model_test.go
index c334d19..1e01c14 100644
--- a/privatevoice.src/internal/model/model_test.go
+++ b/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
diff --git a/privatevoice.src/internal/model/profile.go b/privatevoice.src/internal/model/profile.go
index 1751aeb..f0773ae 100644
--- a/privatevoice.src/internal/model/profile.go
+++ b/privatevoice.src/internal/model/profile.go
@@ -1,12 +1,13 @@
package model
const (
- DefaultModelID = "sensevoice-zh"
- MoonshineModelID = "moonshine-en"
- ParakeetModelID = "parakeet-en"
- Qwen3ASRModelID = "qwen3-asr-0.6b"
- DefaultLanguageID = "zh-CN"
- EnglishLanguageID = "en"
+ DefaultModelID = "sensevoice-zh"
+ MoonshineModelID = "moonshine-en"
+ ParakeetModelID = "parakeet-en"
+ Qwen3ASRModelID = "qwen3-asr-0.6b"
+ XASRZhEn960ModelID = "x-asr-zh-en-960ms"
+ DefaultLanguageID = "zh-CN"
+ EnglishLanguageID = "en"
)
type ModelProfile struct {
diff --git a/privatevoice.src/internal/model/registry.go b/privatevoice.src/internal/model/registry.go
index f561172..8e30239 100644
--- a/privatevoice.src/internal/model/registry.go
+++ b/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},
},
}
diff --git a/privatevoice.src/internal/modelselection/selection_test.go b/privatevoice.src/internal/modelselection/selection_test.go
index db311aa..409a0fc 100644
--- a/privatevoice.src/internal/modelselection/selection_test.go
+++ b/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
diff --git a/privatevoice.src/scripts/install-x-asr-zh-en-960ms.sh b/privatevoice.src/scripts/install-x-asr-zh-en-960ms.sh
new file mode 100755
index 0000000..fad1a2d
--- /dev/null
+++ b/privatevoice.src/scripts/install-x-asr-zh-en-960ms.sh
@@ -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
--
Gitblit v1.9.3