| | |
| | | package services |
| | | |
| | | import ( |
| | | "context" |
| | | "fmt" |
| | | "sync" |
| | | "voicesnap/internal/config" |
| | | "voicesnap/internal/engine" |
| | | "voicesnap/internal/language" |
| | | "voicesnap/internal/model" |
| | | "voicesnap/internal/modelselection" |
| | | "voicesnap/internal/paths" |
| | | |
| | | "github.com/wailsapp/wails/v3/pkg/application" |
| | |
| | | |
| | | // EngineService provides engine status and model management to the frontend. |
| | | type EngineService struct { |
| | | 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() *EngineService { |
| | | return &EngineService{status: "loading"} |
| | | func NewEngineService(cfg *config.Config) *EngineService { |
| | | if cfg == nil { |
| | | cfg = config.Default() |
| | | } |
| | | return &EngineService{cfg: cfg, status: "loading"} |
| | | } |
| | | |
| | | // 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{} { |
| | |
| | | } |
| | | } |
| | | |
| | | func (s *EngineService) SetStatus(status, hardwareInfo, lastError string) { |
| | | s.mu.Lock() |
| | | defer s.mu.Unlock() |
| | | s.status = status |
| | | s.hardwareInfo = hardwareInfo |
| | | s.lastError = lastError |
| | | func (s *EngineService) GetCurrentModelStatus() map[string]interface{} { |
| | | current := s.currentModel() |
| | | return s.modelStatusMap(current.Profile, current) |
| | | } |
| | | |
| | | // 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) { |
| | | func (s *EngineService) ListModelOptions() []map[string]interface{} { |
| | | current := s.currentModel() |
| | | languageProfile, err := model.GetLanguageProfile(current.LanguageSettings.EffectiveLanguageID) |
| | | if err != nil { |
| | | return nil |
| | | } |
| | | |
| | | ids := make([]string, 0, 1+len(languageProfile.UpgradeModelIDs)) |
| | | ids = append(ids, languageProfile.DefaultModelID) |
| | | ids = append(ids, languageProfile.UpgradeModelIDs...) |
| | | |
| | | options := make([]map[string]interface{}, 0, len(ids)) |
| | | for _, id := range ids { |
| | | profile, err := model.GetModelProfile(id) |
| | | if err != nil { |
| | | continue |
| | | } |
| | | options = append(options, s.modelStatusMap(profile, current)) |
| | | } |
| | | return options |
| | | } |
| | | |
| | | func (s *EngineService) SelectModel(modelID string) error { |
| | | profile, err := s.allowedModelProfile(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.cfg.ModelSelectionMode = config.ModelSelectionModeManual |
| | | s.cfg.SelectedModelID = profile.ID |
| | | config.Save(s.cfg) |
| | | s.ReloadCurrentModel() |
| | | return nil |
| | | } |
| | | |
| | | func (s *EngineService) DownloadModelByID(modelID string) error { |
| | | profile, err := s.allowedModelProfile(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.cfg.ModelSelectionMode = config.ModelSelectionModeManual |
| | | s.cfg.SelectedModelID = profile.ID |
| | | config.Save(s.cfg) |
| | | s.ReloadCurrentModel() |
| | | return nil |
| | | } |
| | | |
| | | 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 |
| | | if err == nil { |
| | | status = resolved.Status |
| | | missing = resolved.Missing |
| | | problems = resolved.Problems |
| | | } |
| | | |
| | | return map[string]interface{}{ |
| | | "modelID": profile.ID, |
| | | "displayName": profile.DisplayName, |
| | | "backendKind": profile.BackendKind, |
| | | "tier": profile.Tier, |
| | | "languageID": current.LanguageSettings.EffectiveLanguageID, |
| | | "selectionMode": current.SelectionMode, |
| | | "isCurrent": profile.ID == current.ModelID, |
| | | "isDefault": profile.ID == current.LanguageSettings.DefaultModelID, |
| | | "installed": installed, |
| | | "installStatus": status, |
| | | "missing": missing, |
| | | "problems": problems, |
| | | "fallbackReason": current.FallbackReason, |
| | | "downloadSize": profile.ApproxSize, |
| | | "supportedLanguages": profile.SupportedLanguageIDs, |
| | | "recommendedLanguage": profile.RecommendedFor, |
| | | "description": profile.Description, |
| | | "supportedInBuild": supportedInBuild, |
| | | "unsupportedReason": model.ModelUnsupportedReason(profile.ID), |
| | | } |
| | | } |
| | | |
| | | func (s *EngineService) SetStatus(status, hardwareInfo, lastError string) { |
| | | s.mu.Lock() |
| | | s.status = status |
| | | s.hardwareInfo = hardwareInfo |
| | | s.lastError = lastError |
| | | s.mu.Unlock() |
| | | |
| | | if s.app != nil { |
| | | s.app.Event.Emit("engine:status", map[string]interface{}{ |
| | | "status": status, |
| | | "hardwareInfo": hardwareInfo, |
| | | "error": lastError, |
| | | }) |
| | | } |
| | | } |
| | | |
| | | // DownloadModel downloads the ASR model with progress events. |
| | | func (s *EngineService) DownloadModel(primaryURL, fallbackURL string) error { |
| | | 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, |
| | | }) |
| | | } |
| | | }) |
| | |
| | | return nil |
| | | } |
| | | |
| | | func (s *EngineService) DownloadCurrentModel() error { |
| | | current := s.currentModel() |
| | | if len(current.Profile.DownloadURLs) == 0 { |
| | | return fmt.Errorf("no download URL configured for model %s", current.ModelID) |
| | | } |
| | | |
| | | 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, |
| | | "downloaded": downloaded, |
| | | "total": total, |
| | | "modelID": current.ModelID, |
| | | "modelName": current.Profile.DisplayName, |
| | | }) |
| | | } |
| | | }) |
| | | if err != nil { |
| | | return err |
| | | } |
| | | |
| | | if s.initCallback != nil { |
| | | go s.initCallback() |
| | | } |
| | | return nil |
| | | } |
| | | |
| | | func (s *EngineService) ReloadCurrentModel() { |
| | | if s.initCallback != nil { |
| | | go s.initCallback() |
| | | } |
| | | } |
| | | |
| | | // SetInitCallback sets the callback to re-initialize the engine after model download. |
| | | func (s *EngineService) SetInitCallback(cb func()) { |
| | | s.initCallback = cb |
| | |
| | | func (s *EngineService) SetApp(app *application.App) { |
| | | s.app = app |
| | | } |
| | | |
| | | func (s *EngineService) currentModel() modelselection.CurrentModel { |
| | | if s.cfg == nil { |
| | | cfg, err := config.Load() |
| | | if err != nil { |
| | | cfg = config.Default() |
| | | } |
| | | s.cfg = cfg |
| | | } |
| | | 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) { |
| | | profile, err := model.GetModelProfile(modelID) |
| | | if err != nil { |
| | | return model.ModelProfile{}, err |
| | | } |
| | | switch model.ModelUnsupportedReason(profile.ID) { |
| | | case "": |
| | | case model.UnsupportedReasonRequiresMacOS14: |
| | | return model.ModelProfile{}, fmt.Errorf("model %s requires macOS 14 or later", profile.ID) |
| | | case model.UnsupportedReasonWindowsPreview: |
| | | return model.ModelProfile{}, fmt.Errorf("model %s is not supported in the Windows preview build; use SenseVoice", profile.ID) |
| | | default: |
| | | return model.ModelProfile{}, fmt.Errorf("model %s is not supported in this build", profile.ID) |
| | | } |
| | | |
| | | current := s.currentModel() |
| | | languageProfile, err := model.GetLanguageProfile(current.LanguageSettings.EffectiveLanguageID) |
| | | if err != nil { |
| | | return model.ModelProfile{}, err |
| | | } |
| | | if profile.ID == languageProfile.DefaultModelID { |
| | | return profile, nil |
| | | } |
| | | for _, id := range languageProfile.UpgradeModelIDs { |
| | | if profile.ID == id { |
| | | return profile, nil |
| | | } |
| | | } |
| | | return model.ModelProfile{}, fmt.Errorf("model %s is not available for language %s", profile.ID, current.LanguageSettings.EffectiveLanguageID) |
| | | } |