Add X-ASR experimental backend
7 files modified
1 files added
| | |
| | | |
| | | 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 |
| | |
| | | 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) |
| | |
| | | } |
| | | |
| | | 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) { |
| | |
| | | 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 |
| | |
| | | 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 |
| | | } |
| | | |
| | |
| | | e.recognizer = nil |
| | | } |
| | | } |
| | | |
| | | func (e *xasrStreamingEngine) Close() { |
| | | if e.recognizer != nil { |
| | | sherpa.DeleteOnlineRecognizer(e.recognizer) |
| | | e.recognizer = nil |
| | | } |
| | | } |
| | |
| | | package engine |
| | | |
| | | import ( |
| | | "os" |
| | | "path/filepath" |
| | | "testing" |
| | | |
| | |
| | | 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) |
| | | } |
| | |
| | | } |
| | | } |
| | | |
| | | 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 { |
| | |
| | | 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) |
| | | } |
| | | } |
| | | |
| | |
| | | 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) |
| | | } |
| | | } |
| | | |
| | |
| | | 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") |
| | | } |
| | | } |
| | | |
| | |
| | | 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 |
| | |
| | | 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 { |
| | |
| | | BackendMoonshine = "moonshine" |
| | | BackendNemoTransducer = "nemo_transducer" |
| | | BackendQwen3ASR = "qwen3_asr" |
| | | BackendXASRStreaming = "x_asr_streaming" |
| | | ) |
| | | |
| | | var modelProfiles = map[string]ModelProfile{ |
| | |
| | | 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{ |
| | |
| | | UILocale: "zh", |
| | | SystemMatchers: []string{"zh", "zh-CN", "zh-Hans", "zh-Hant"}, |
| | | DefaultModelID: DefaultModelID, |
| | | UpgradeModelIDs: []string{Qwen3ASRModelID}, |
| | | UpgradeModelIDs: []string{Qwen3ASRModelID, XASRZhEn960ModelID}, |
| | | }, |
| | | EnglishLanguageID: { |
| | | ID: EnglishLanguageID, |
| | |
| | | UILocale: "en", |
| | | SystemMatchers: []string{"en", "en-US", "en-GB", "en-AU", "en-CA"}, |
| | | DefaultModelID: MoonshineModelID, |
| | | UpgradeModelIDs: []string{ParakeetModelID}, |
| | | UpgradeModelIDs: []string{ParakeetModelID, XASRZhEn960ModelID}, |
| | | }, |
| | | } |
| | | |
| | |
| | | } |
| | | } |
| | | |
| | | 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 |
| New file |
| | |
| | | #!/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 |