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
|
downloadCancelling bool
|
downloadProgress float64
|
downloadDownloaded int64
|
downloadTotal int64
|
}
|
|
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()
|
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
|
}
|
|
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) 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 {
|
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) 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
|
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,
|
})
|
}
|
})
|
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) {
|
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
|
}
|
|
// 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.ExistingConfigFallback()
|
}
|
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) {
|
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{}, 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)
|
}
|
|
languageProfile, err := model.GetLanguageProfile(model.NormalizeLanguageID(languageID))
|
if err != nil {
|
return model.ModelProfile{}, model.LanguageProfile{}, err
|
}
|
if profile.ID == languageProfile.DefaultModelID {
|
return profile, languageProfile, nil
|
}
|
for _, id := range languageProfile.UpgradeModelIDs {
|
if profile.ID == id {
|
return profile, languageProfile, nil
|
}
|
}
|
return model.ModelProfile{}, model.LanguageProfile{}, fmt.Errorf("model %s is not available for language %s", profile.ID, languageProfile.ID)
|
}
|