Files

182 lines
6.7 KiB
Go

// 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
}