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 { 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 } 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{} { s.mu.RLock() defer s.mu.RUnlock() return map[string]interface{}{ "status": s.status, "hardwareInfo": s.hardwareInfo, "error": s.lastError, } } func (s *EngineService) GetCurrentModelStatus() map[string]interface{} { current := s.currentModel() return s.modelStatusMap(current.Profile, current) } 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) { 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.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) modelStatusMap(profile model.ModelProfile, current modelselection.CurrentModel) map[string]interface{} { resolved, err := model.ResolveModel(profile.ID) installed := err == nil && resolved.IsUsable() 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, } } 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) { 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 } if s.initCallback != nil { go s.initCallback() } 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) { 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 } // SetApp sets the Wails app reference for event emission. 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 finish := func() { s.downloadMu.Lock() if s.downloadID == modelID { s.downloadID = "" s.downloadCancel = nil } s.downloadMu.Unlock() } return ctx, finish, nil } func (s *EngineService) allowedModelProfile(modelID string) (model.ModelProfile, error) { profile, err := model.GetModelProfile(modelID) if err != nil { return model.ModelProfile{}, err } 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) }