Ariver
2026-07-02 0ffbf1935c9d091cce22a5583275ac0902f7a693
privatevoice.src/services/engine_service.go
@@ -1,6 +1,7 @@
package services
import (
   "context"
   "fmt"
   "sync"
   "voicesnap/internal/config"
@@ -15,13 +16,20 @@
// EngineService provides engine status and model management to the frontend.
type EngineService struct {
   cfg          *config.Config
   app          *application.App
   initCallback func()
   mu           sync.RWMutex
   status       string
   hardwareInfo string
   lastError    string
   cfg                *config.Config
   app                *application.App
   initCallback       func()
   mu                 sync.RWMutex
   status             string
   hardwareInfo       string
   lastError          string
   downloadMu         sync.Mutex
   downloadID         string
   downloadCancel     context.CancelFunc
   downloadCancelling bool
   downloadProgress   float64
   downloadDownloaded int64
   downloadTotal      int64
}
func NewEngineService(cfg *config.Config) *EngineService {
@@ -34,6 +42,10 @@
// ModelExists returns true if the ASR model files are present.
func (s *EngineService) ModelExists() bool {
   return engine.ModelExists()
}
func (s *EngineService) HasAnyInstalledModel() bool {
   return model.HasAnyUsableModel()
}
func (s *EngineService) GetStatus() map[string]interface{} {
@@ -53,6 +65,20 @@
func (s *EngineService) ListModelOptions() []map[string]interface{} {
   current := s.currentModel()
   return s.listModelOptionsForCurrent(current)
}
func (s *EngineService) ListModelOptionsForLanguage(languageID string) []map[string]interface{} {
   current := s.currentModel()
   activeLanguageID := current.LanguageSettings.EffectiveLanguageID
   current.LanguageSettings = language.Resolve(config.LanguageModeManual, model.NormalizeLanguageID(languageID), nil)
   if current.LanguageSettings.EffectiveLanguageID != activeLanguageID {
      current.ModelID = ""
   }
   return s.listModelOptionsForCurrent(current)
}
func (s *EngineService) listModelOptionsForCurrent(current modelselection.CurrentModel) []map[string]interface{} {
   languageProfile, err := model.GetLanguageProfile(current.LanguageSettings.EffectiveLanguageID)
   if err != nil {
      return nil
@@ -93,6 +119,25 @@
   return nil
}
func (s *EngineService) SelectModelForLanguage(languageID, modelID string) error {
   languageID = model.NormalizeLanguageID(languageID)
   profile, languageProfile, err := s.allowedModelProfileForLanguage(languageID, modelID)
   if err != nil {
      return err
   }
   resolved, err := model.ResolveModel(profile.ID)
   if err != nil {
      return err
   }
   if !resolved.IsUsable() {
      return fmt.Errorf("model %s is not installed", profile.ID)
   }
   s.persistLanguageModelSelection(languageID, profile, languageProfile)
   s.ReloadCurrentModel()
   return nil
}
func (s *EngineService) DownloadModelByID(modelID string) error {
   profile, err := s.allowedModelProfile(modelID)
   if err != nil {
@@ -102,7 +147,14 @@
      return fmt.Errorf("no download URL configured for model %s", profile.ID)
   }
   err = model.DownloadProfile(profile, profile.DownloadURLs, paths.ModelsRoot(), func(percent float64, downloaded, total int64) {
   ctx, finish, err := s.beginModelDownload(profile.ID)
   if err != nil {
      return err
   }
   defer finish()
   err = model.DownloadProfileWithContext(ctx, profile, profile.DownloadURLs, paths.ModelsRoot(), func(percent float64, downloaded, total int64) {
      s.updateModelDownloadProgress(profile.ID, percent, downloaded, total)
      if s.app != nil {
         s.app.Event.Emit("model:download-progress", map[string]interface{}{
            "percent":    percent,
@@ -124,9 +176,85 @@
   return nil
}
func (s *EngineService) DownloadModelByIDForLanguage(languageID, modelID string) error {
   languageID = model.NormalizeLanguageID(languageID)
   profile, languageProfile, err := s.allowedModelProfileForLanguage(languageID, modelID)
   if err != nil {
      return err
   }
   if len(profile.DownloadURLs) == 0 {
      return fmt.Errorf("no download URL configured for model %s", profile.ID)
   }
   ctx, finish, err := s.beginModelDownload(profile.ID)
   if err != nil {
      return err
   }
   defer finish()
   err = model.DownloadProfileWithContext(ctx, profile, profile.DownloadURLs, paths.ModelsRoot(), func(percent float64, downloaded, total int64) {
      s.updateModelDownloadProgress(profile.ID, percent, downloaded, total)
      if s.app != nil {
         s.app.Event.Emit("model:download-progress", map[string]interface{}{
            "percent":    percent,
            "downloaded": downloaded,
            "total":      total,
            "modelID":    profile.ID,
            "modelName":  profile.DisplayName,
         })
      }
   })
   if err != nil {
      return err
   }
   s.persistLanguageModelSelection(languageID, profile, languageProfile)
   s.ReloadCurrentModel()
   return nil
}
func (s *EngineService) persistLanguageModelSelection(languageID string, profile model.ModelProfile, languageProfile model.LanguageProfile) {
   s.cfg.LanguageMode = config.LanguageModeManual
   s.cfg.LanguageID = languageID
   if profile.ID == languageProfile.DefaultModelID {
      s.cfg.ModelSelectionMode = config.ModelSelectionModeAuto
      s.cfg.SelectedModelID = ""
   } else {
      s.cfg.ModelSelectionMode = config.ModelSelectionModeManual
      s.cfg.SelectedModelID = profile.ID
   }
   config.Save(s.cfg)
}
func (s *EngineService) CancelModelDownload(modelID string) bool {
   s.downloadMu.Lock()
   activeID := s.downloadID
   if s.downloadCancel == nil || (modelID != "" && activeID != modelID) {
      s.downloadMu.Unlock()
      return false
   }
   s.downloadCancelling = true
   s.downloadCancel()
   s.downloadMu.Unlock()
   if s.app != nil {
      s.app.Event.Emit("model:download-cancelled", map[string]interface{}{
         "modelID": activeID,
      })
   }
   return true
}
func (s *EngineService) GetModelDownloadStatus() map[string]interface{} {
   s.downloadMu.Lock()
   defer s.downloadMu.Unlock()
   return s.modelDownloadStatusLocked()
}
func (s *EngineService) modelStatusMap(profile model.ModelProfile, current modelselection.CurrentModel) map[string]interface{} {
   resolved, err := model.ResolveModel(profile.ID)
   installed := err == nil && resolved.IsUsable()
   supportedInBuild := model.IsModelSupportedInCurrentBuild(profile.ID)
   status := model.ModelNotInstalled
   var missing []string
   var problems []string
@@ -154,6 +282,8 @@
      "supportedLanguages":  profile.SupportedLanguageIDs,
      "recommendedLanguage": profile.RecommendedFor,
      "description":         profile.Description,
      "supportedInBuild":    supportedInBuild,
      "unsupportedReason":   model.ModelUnsupportedReason(profile.ID),
   }
}
@@ -175,12 +305,22 @@
// DownloadModel downloads the ASR model with progress events.
func (s *EngineService) DownloadModel(primaryURL, fallbackURL string) error {
   err := model.Download(primaryURL, fallbackURL, paths.ModelsRoot(), func(percent float64, downloaded, total int64) {
   profile := model.DefaultModelProfile()
   ctx, finish, err := s.beginModelDownload(profile.ID)
   if err != nil {
      return err
   }
   defer finish()
   err = model.DownloadProfileWithContext(ctx, profile, []string{primaryURL, fallbackURL}, paths.ModelsRoot(), func(percent float64, downloaded, total int64) {
      s.updateModelDownloadProgress(profile.ID, percent, downloaded, total)
      if s.app != nil {
         s.app.Event.Emit("model:download-progress", map[string]interface{}{
            "percent":    percent,
            "downloaded": downloaded,
            "total":      total,
            "modelID":    profile.ID,
            "modelName":  profile.DisplayName,
         })
      }
   })
@@ -200,7 +340,14 @@
      return fmt.Errorf("no download URL configured for model %s", current.ModelID)
   }
   err := model.DownloadProfile(current.Profile, current.Profile.DownloadURLs, paths.ModelsRoot(), func(percent float64, downloaded, total int64) {
   ctx, finish, err := s.beginModelDownload(current.ModelID)
   if err != nil {
      return err
   }
   defer finish()
   err = model.DownloadProfileWithContext(ctx, current.Profile, current.Profile.DownloadURLs, paths.ModelsRoot(), func(percent float64, downloaded, total int64) {
      s.updateModelDownloadProgress(current.ModelID, percent, downloaded, total)
      if s.app != nil {
         s.app.Event.Emit("model:download-progress", map[string]interface{}{
            "percent":    percent,
@@ -248,24 +395,94 @@
   return modelselection.Resolve(s.cfg, language.NewSystemDetector())
}
func (s *EngineService) beginModelDownload(modelID string) (context.Context, func(), error) {
   s.downloadMu.Lock()
   defer s.downloadMu.Unlock()
   if s.downloadCancel != nil {
      return nil, nil, fmt.Errorf("model %s is already downloading", s.downloadID)
   }
   ctx, cancel := context.WithCancel(context.Background())
   s.downloadID = modelID
   s.downloadCancel = cancel
   s.downloadCancelling = false
   s.downloadProgress = 0
   s.downloadDownloaded = 0
   s.downloadTotal = 0
   finish := func() {
      emitFinished := false
      s.downloadMu.Lock()
      if s.downloadID == modelID {
         s.downloadID = ""
         s.downloadCancel = nil
         s.downloadCancelling = false
         s.downloadProgress = 0
         s.downloadDownloaded = 0
         s.downloadTotal = 0
         emitFinished = true
      }
      s.downloadMu.Unlock()
      if emitFinished && s.app != nil {
         s.app.Event.Emit("model:download-finished", map[string]interface{}{
            "modelID": modelID,
         })
      }
   }
   return ctx, finish, nil
}
func (s *EngineService) updateModelDownloadProgress(modelID string, percent float64, downloaded, total int64) {
   s.downloadMu.Lock()
   if s.downloadID == modelID && s.downloadCancel != nil {
      s.downloadProgress = percent
      s.downloadDownloaded = downloaded
      s.downloadTotal = total
   }
   s.downloadMu.Unlock()
}
func (s *EngineService) modelDownloadStatusLocked() map[string]interface{} {
   return map[string]interface{}{
      "active":     s.downloadCancel != nil,
      "modelID":    s.downloadID,
      "cancelling": s.downloadCancelling,
      "percent":    s.downloadProgress,
      "downloaded": s.downloadDownloaded,
      "total":      s.downloadTotal,
   }
}
func (s *EngineService) allowedModelProfile(modelID string) (model.ModelProfile, error) {
   current := s.currentModel()
   profile, _, err := s.allowedModelProfileForLanguage(current.LanguageSettings.EffectiveLanguageID, modelID)
   return profile, err
}
func (s *EngineService) allowedModelProfileForLanguage(languageID, modelID string) (model.ModelProfile, model.LanguageProfile, error) {
   profile, err := model.GetModelProfile(modelID)
   if err != nil {
      return model.ModelProfile{}, err
      return model.ModelProfile{}, model.LanguageProfile{}, err
   }
   switch model.ModelUnsupportedReason(profile.ID) {
   case "":
   case model.UnsupportedReasonRequiresMacOS14:
      return model.ModelProfile{}, model.LanguageProfile{}, fmt.Errorf("model %s requires macOS 14 or later", profile.ID)
   case model.UnsupportedReasonWindowsPreview:
      return model.ModelProfile{}, model.LanguageProfile{}, fmt.Errorf("model %s is not supported in the Windows preview build; use SenseVoice", profile.ID)
   default:
      return model.ModelProfile{}, model.LanguageProfile{}, fmt.Errorf("model %s is not supported in this build", profile.ID)
   }
   current := s.currentModel()
   languageProfile, err := model.GetLanguageProfile(current.LanguageSettings.EffectiveLanguageID)
   languageProfile, err := model.GetLanguageProfile(model.NormalizeLanguageID(languageID))
   if err != nil {
      return model.ModelProfile{}, err
      return model.ModelProfile{}, model.LanguageProfile{}, err
   }
   if profile.ID == languageProfile.DefaultModelID {
      return profile, nil
      return profile, languageProfile, nil
   }
   for _, id := range languageProfile.UpgradeModelIDs {
      if profile.ID == id {
         return profile, nil
         return profile, languageProfile, nil
      }
   }
   return model.ModelProfile{}, fmt.Errorf("model %s is not available for language %s", profile.ID, current.LanguageSettings.EffectiveLanguageID)
   return model.ModelProfile{}, model.LanguageProfile{}, fmt.Errorf("model %s is not available for language %s", profile.ID, languageProfile.ID)
}