Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,11 @@ tmux attach -t yap
- **OpenAI**: Uses OpenAI Whisper API (requires API key)

### Models (Local)
- tiny.en (~75MB) - Fastest
- base.en (~150MB) - Default, good balance
- small.en (~500MB) - Better accuracy
- medium.en (~1.5GB) - High accuracy
- large-v3 (~3GB) - Best accuracy
Supported models, their sizes, and their speed and accuracy ratings are defined
in one catalog in `internal/models/catalog.go`. Users browse, download, activate,
and delete them on the **Models** page. Only one download runs at a time.
Downloading a model never activates it. The one exception is onboarding, which
activates the model the user picked there.

## Building

Expand All @@ -80,6 +80,24 @@ wails dev
wails build
```

## Testing

```bash
go test ./...
cd frontend && npm test
```

### Isolated development data

Set `YAP_CONFIG_DIR` to keep config, models, history, and logs out of the
installed app's data directory. Set `YAP_MODEL_BASE_URL` to download models from
a local mirror, e.g. to exercise failed or truncated downloads:

```bash
YAP_CONFIG_DIR=/tmp/yap-dev YAP_MODEL_BASE_URL=http://127.0.0.1:34199 \
wails dev -devserver localhost:34135
```

## Requirements

- Go 1.21+
Expand Down
152 changes: 108 additions & 44 deletions app.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,20 @@ type HistoryItem struct {
HasAudio bool `json:"hasAudio"`
}

// ModelInfo for frontend
// ModelInfo describes a catalog model and its local availability for the frontend.
type ModelInfo struct {
Name string `json:"name"`
DisplayName string `json:"displayName"`
Size string `json:"size"`
Downloaded bool `json:"downloaded"`
EnglishOnly bool `json:"englishOnly"`
Name string `json:"name"`
DisplayName string `json:"displayName"`
Size string `json:"size"`
SizeBytes int64 `json:"sizeBytes"`
Downloaded bool `json:"downloaded"`
EnglishOnly bool `json:"englishOnly"`
Provider string `json:"provider"`
Speed int `json:"speed"`
Accuracy int `json:"accuracy"`
UseCase string `json:"useCase"`
Recommendation string `json:"recommendation"`
Favorite bool `json:"favorite"`
}

// UsageStats for frontend
Expand All @@ -89,6 +96,7 @@ type App struct {
openaiEngine *transcribe.OpenAIEngine
configManager *models.ConfigManager
modelManager *models.Manager
downloader *models.Downloader
statsManager *models.StatsManager
hotkeyManager *hotkey.Manager
overlay *overlay.Overlay
Expand Down Expand Up @@ -180,6 +188,9 @@ func (a *App) startup(ctx context.Context) {
return
}
a.modelManager = modelManager
a.downloader = models.NewDownloader(modelManager, func(state models.DownloadState) {
runtime.EventsEmit(a.ctx, "modelDownloadState", state)
}, a.onModelDownloadFinished)

// Initialize transcription engines
a.localEngine = transcribe.NewLocalEngine(configManager.GetModelsDir())
Expand Down Expand Up @@ -275,6 +286,9 @@ func (a *App) domReady(ctx context.Context) {
// shutdown is called when the app closes
func (a *App) shutdown(ctx context.Context) {
runtime.LogInfo(ctx, "OnShutdown: starting cleanup")
if a.downloader != nil {
a.downloader.Shutdown(5 * time.Second)
}
if a.hotkeyManager != nil {
a.hotkeyManager.Unregister()
}
Expand Down Expand Up @@ -840,29 +854,37 @@ func (a *App) emitState() {
}
}

// GetModels returns available models with download status
// GetModels returns every catalog model with its download status
func (a *App) GetModels() []ModelInfo {
if a.WaitForStartup() != nil {
return nil
}
available := transcribe.AvailableModels()
result := make([]ModelInfo, len(available))

for i, m := range available {
catalog := models.Catalog()
result := make([]ModelInfo, len(catalog))
for i, spec := range catalog {
result[i] = ModelInfo{
Name: string(m.Name),
DisplayName: m.DisplayName,
Size: m.Size,
Downloaded: a.modelManager.IsModelDownloaded(string(m.Name)),
EnglishOnly: m.EnglishOnly,
Name: spec.Name,
DisplayName: spec.DisplayName,
Size: spec.Size,
SizeBytes: spec.SizeBytes,
Downloaded: a.modelManager != nil && a.modelManager.IsModelDownloaded(spec.Name),
EnglishOnly: spec.EnglishOnly,
Provider: spec.Provider,
Speed: spec.Speed,
Accuracy: spec.Accuracy,
UseCase: spec.UseCase,
Recommendation: spec.Recommendation,
Favorite: a.configManager != nil && a.configManager.IsFavoriteModel(spec.Name),
}
}

return result
}

// SetModel changes the current model
// SetModel activates a downloaded catalog model. It never downloads.
func (a *App) SetModel(model string) error {
if _, err := models.RequireModel(model); err != nil {
return err
}
if a.configManager.Get().Provider == "local" && !a.modelManager.IsModelDownloaded(model) {
return fmt.Errorf("model %q is not downloaded", model)
}
Expand All @@ -888,7 +910,9 @@ func (a *App) SetProvider(provider string) error {
}
if provider == "local" {
a.localEngine.Open()
a.prewarmLocalEngine()
if a.modelManager != nil && a.modelManager.IsModelDownloaded(a.configManager.Get().Model) {
a.prewarmLocalEngine()
}
} else {
_ = a.localEngine.Close()
}
Expand Down Expand Up @@ -1127,36 +1151,76 @@ func (a *App) ReregisterHotkey() error {
return nil
}

// DownloadModel downloads a model
// DownloadModel starts downloading a model in the background. It validates the
// model and the single-download constraint synchronously, and never changes the
// active model.
func (a *App) DownloadModel(model string) error {
go func() {
defer crashlog.LogPanic("model download")
err := a.modelManager.DownloadModel(model, func(downloaded, total int64) {
progress := float64(downloaded) / float64(total) * 100
runtime.EventsEmit(a.ctx, "downloadProgress", map[string]interface{}{
"model": model,
"downloaded": downloaded,
"total": total,
"progress": progress,
})
})

if err != nil {
runtime.EventsEmit(a.ctx, "downloadError", map[string]interface{}{
"model": model,
"error": err.Error(),
})
} else {
runtime.EventsEmit(a.ctx, "downloadComplete", map[string]interface{}{
"model": model,
})
a.emitState()
}
}()
if a.downloader == nil {
return fmt.Errorf("model manager is not ready")
}
if err := a.downloader.Start(model); err != nil {
runtime.LogWarning(a.ctx, fmt.Sprintf("Model download rejected: model=%s error=%v", model, err))
return err
}
runtime.LogInfo(a.ctx, fmt.Sprintf("Model download started: model=%s", model))
return nil
}

// SetModelFavorite stars or unstars a catalog model in the library.
func (a *App) SetModelFavorite(model string, favorite bool) error {
if _, err := models.RequireModel(model); err != nil {
return err
}
if a.WaitForStartup() != nil || a.configManager == nil {
return fmt.Errorf("configuration is not ready")
}
return a.configManager.SetModelFavorite(model, favorite)
}

// GetModelDownloadState returns the current or most recent download state.
func (a *App) GetModelDownloadState() models.DownloadState {
if a.WaitForStartup() != nil || a.downloader == nil {
return models.DownloadState{Status: models.DownloadIdle}
}
return a.downloader.State()
}

// CancelModelDownload cancels the active download if it matches model.
func (a *App) CancelModelDownload(model string) error {
if a.downloader == nil {
return fmt.Errorf("model manager is not ready")
}
return a.downloader.Cancel(model)
}

// DeleteModel removes a downloaded model that is neither active nor downloading.
func (a *App) DeleteModel(model string) error {
if a.downloader == nil || a.configManager == nil {
return fmt.Errorf("model manager is not ready")
}
if err := a.downloader.Delete(model, a.configManager.Get().Model); err != nil {
return err
}
runtime.LogInfo(a.ctx, fmt.Sprintf("Model deleted: model=%s", model))
a.emitState()
return nil
}

func (a *App) onModelDownloadFinished(state models.DownloadState) {
if state.Status == models.DownloadFailed {
runtime.LogWarning(a.ctx, fmt.Sprintf("Model download failed: model=%s error=%s", state.Model, state.Error))
} else {
runtime.LogInfo(a.ctx, fmt.Sprintf("Model download finished: model=%s status=%s", state.Model, state.Status))
}
if state.Status == models.DownloadCompleted && a.configManager != nil {
config := a.configManager.Get()
if config.Provider == "local" && config.Model == state.Model {
a.prewarmLocalEngine()
}
}
a.emitState()
}

// IsModelDownloaded checks if a model is downloaded
func (a *App) IsModelDownloaded(model string) bool {
return a.modelManager.IsModelDownloaded(model)
Expand Down
Loading
Loading