182 lines
6.7 KiB
Go
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
|
|
}
|