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