// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package preferences import ( "slices" "strings" "git.stormux.org/storm/fenrir/internal/config" "git.stormux.org/storm/fenrir/internal/interaction" "git.stormux.org/storm/fenrir/internal/speech" ) type Discovery struct { Driver string Catalog speech.Catalog Err error } // Discover_choices runs only in a worker, never on the input loop. func Discover_choices(settings config.Settings) Discovery { driver, err := speech.Find_driver(settings.Presentation.Speech.Driver) if err == nil { err = driver.Availability() } if err != nil { return Discovery{Driver: settings.Presentation.Speech.Driver, Err: err} } if driver.Catalog == nil { return Discovery{Driver: driver.ID} } catalog, err := driver.Catalog(speech.Options{}) return Discovery{Driver: driver.ID, Catalog: catalog, Err: err} } func (d Discovery) Choices(settings config.Settings) Choices { selected := settings.Presentation.Speech // Driver changes apply on relaunch. A catalog captured for the old driver // must never supply Speech Dispatcher voices to a hardware selection. if d.Driver != "" && d.Driver != selected.Driver { d = Discovery{} } result := Choices{} modules := []config.Choice{{ID: "", Label: "default"}} for _, module := range d.Catalog.Modules { modules = append(modules, config.Choice{ID: module, Label: module}) } result["presentation.speech.module"] = preserve_choice(modules, selected.Module, d.Err) voices := d.Catalog.Voices[selected.Module] err := d.Err if moduleErr := d.Catalog.Errors[selected.Module]; moduleErr != nil { err = moduleErr } languages := []config.Choice{{ID: "", Label: "default"}} seen := map[string]bool{} for _, voice := range voices { if !seen[voice.Language] { languages = append(languages, config.Choice{ID: voice.Language, Label: voice.Language}) seen[voice.Language] = true } } result["presentation.speech.language"] = preserve_choice(languages, selected.Language, err) filtered := voices if selected.Language != "" { filtered = speech.Language_voices(voices, selected.Language) } else { espeak := strings.Contains(strings.ToLower(selected.Module), "espeak") if selected.Module == "" { for module, candidates := range d.Catalog.Voices { if strings.Contains(strings.ToLower(module), "espeak") && slices.Equal(voices, candidates) { espeak = true break } } } if espeak { filtered = speech.Language_voices(voices, "") } } voiceChoices := []config.Choice{{ID: "", Label: "default"}} for _, voice := range filtered { voiceChoices = append(voiceChoices, config.Choice{ID: voice.Name, Label: voice.Name}) } // Locale filtering must not turn a valid saved voice into an offline choice. for _, voice := range voices { if selected.Language == "" && voice.Name == selected.Voice && !slices.ContainsFunc(voiceChoices, func(c config.Choice) bool { return c.ID == voice.Name }) { voiceChoices = append(voiceChoices, config.Choice{ID: voice.Name, Label: voice.Name}) } } result["presentation.speech.voice"] = preserve_choice(voiceChoices, selected.Voice, err) return result } func preserve_choice(choices []config.Choice, saved string, err error) []config.Choice { if err != nil { choices[0].Unavailable = err.Error() } if slices.ContainsFunc(choices, func(c config.Choice) bool { return c.ID == saved }) { return choices } reason := "not reported by the selected speech driver" if err != nil { reason = err.Error() } return append(choices, config.Choice{ID: saved, Label: saved, Unavailable: reason}) } // Refresh_speech updates driver visibility and dependent choices. Driver changes // keep hidden edits; module/language changes clear their dependent selections. // The result tells the host when a different driver's catalog may be needed. func (d Discovery) Refresh_speech(session *interaction.Session, previous *config.Settings) bool { next := *previous values := session.Values() for path, target := range map[string]*string{ "presentation.speech.driver": &next.Presentation.Speech.Driver, "presentation.speech.module": &next.Presentation.Speech.Module, "presentation.speech.language": &next.Presentation.Speech.Language, "presentation.speech.voice": &next.Presentation.Speech.Voice, } { control, ok := values[path] if !ok && path == "presentation.speech.driver" { continue } if !ok || control.Selected < 0 || control.Selected >= len(control.Choices) { return false } *target = control.Choices[control.Selected].ID } moduleChanged := next.Presentation.Speech.Module != previous.Presentation.Speech.Module languageChanged := next.Presentation.Speech.Language != previous.Presentation.Speech.Language driverChanged := next.Presentation.Speech.Driver != previous.Presentation.Speech.Driver *previous = next if driverChanged { driver, _ := speech.Find_driver(next.Presentation.Speech.Driver) session.Set_hidden("presentation", "speech.test", !driver.Hardware) for _, definition := range config.Definitions() { if definition.SpeechCapability != "" { session.Set_hidden("presentation", strings.TrimPrefix(definition.Path, "presentation."), !definition.Available(next)) } } // Keep each backend's hidden selections intact while another driver is // selected. The host supplies that driver's cached/discovered catalog. return true } if !driverChanged && !moduleChanged && !languageChanged { return false } if moduleChanged { next.Presentation.Speech.Language = "" } next.Presentation.Speech.Voice = "" choices := d.Choices(next) if moduleChanged { session.Replace_choices("presentation", "speech.language", interaction_choices(choices["presentation.speech.language"]), "") } session.Replace_choices("presentation", "speech.voice", interaction_choices(choices["presentation.speech.voice"]), "") *previous = next return false } // Refresh_catalog applies a worker result to the current selector draft. It // never moves focus or resets a selection made while discovery was pending. func (d Discovery) Refresh_catalog(session *interaction.Session, selected config.Settings) { choices := d.Choices(selected) for path, value := range map[string]string{ "presentation.speech.module": selected.Presentation.Speech.Module, "presentation.speech.language": selected.Presentation.Speech.Language, "presentation.speech.voice": selected.Presentation.Speech.Voice, } { session.Replace_choices("presentation", strings.TrimPrefix(path, "presentation."), interaction_choices(choices[path]), value) } } func interaction_choices(choices []config.Choice) []interaction.Choice { result := make([]interaction.Choice, 0, len(choices)) for _, choice := range choices { result = append(result, interaction.Choice{ID: choice.ID, Label: choice.Label, Unavailable: choice.Unavailable}) } return result }