Files

254 lines
7.2 KiB
Go

// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package speech
import (
"context"
"fmt"
"os"
"sort"
"strings"
"time"
"unicode"
"unicode/utf8"
)
type Voice struct{ Name, Language, Variant string }
type Catalog struct {
Modules []string
Voices map[string][]Voice
Errors map[string]error
}
func Validate_selection(module, language, voice string) error {
if !valid_module(module) || !valid_module(language) {
return fmt.Errorf("invalid speech module or language")
}
if !utf8.ValidString(voice) || len(voice) > 1024 || voice != "" && strings.TrimSpace(voice) == "" {
return fmt.Errorf("invalid speech voice")
}
for _, char := range voice {
if unicode.IsControl(char) {
return fmt.Errorf("invalid speech voice")
}
}
return nil
}
// Discover never speaks or changes the active client. Each query has a short
// deadline and the entire catalog has a four-second budget. Partial failures
// retain successful modules and mark unavailable voice lists explicitly.
func Discover(options Options) (Catalog, error) {
result := Catalog{Voices: map[string][]Voice{}, Errors: map[string]error{}}
w, err := connect_options(options)
if err != nil {
return result, err
}
defer w.close()
deadline := time.Now().Add(4 * time.Second)
query := func(command string) ([]string, error) {
if time.Now().After(deadline) {
return nil, fmt.Errorf("speech discovery timed out")
}
if err := w.conn.SetDeadline(min_time(deadline, time.Now().Add(commandTimeout))); err != nil {
return nil, err
}
if _, err := fmt.Fprintf(w.conn, "%s\r\n", command); err != nil {
return nil, err
}
return w.reply()
}
modules, err := query("LIST OUTPUT_MODULES")
if err != nil {
return result, err
}
seen := map[string]bool{}
for _, module := range modules {
if module == "" || !valid_module(module) || seen[module] {
return result, fmt.Errorf("invalid Speech Dispatcher module list")
}
seen[module] = true
}
result.Modules = modules
// Query the server default before selecting any named module.
for _, module := range append([]string{""}, modules...) {
var rows []string
var err error
if module != "" {
_, err = query("SET SELF OUTPUT_MODULE " + module)
}
if err == nil {
rows, err = query("LIST SYNTHESIS_VOICES")
}
if err == nil {
result.Voices[module], err = parse_voices(rows)
}
if err != nil {
result.Errors[module] = err
// A rejected command ends a complete reply. A broken stream must
// never be reused: a late reply could belong to the previous module.
if _, ok := err.(*Rejection); !ok {
break
}
}
}
return result, nil
}
func min_time(a, b time.Time) time.Time {
if a.Before(b) {
return a
}
return b
}
func parse_voices(rows []string) ([]Voice, error) {
voices := make([]Voice, 0, len(rows))
seen := map[string]bool{}
for _, row := range rows {
fields := strings.Split(row, "\t")
if len(fields) < 2 || len(fields) > 3 || fields[0] == "" || fields[1] == "" {
return nil, fmt.Errorf("invalid speech voice list")
}
if err := Validate_selection("", fields[1], fields[0]); err != nil {
return nil, err
}
// Some modules repeat aliases. Stable IDs are the synthesis names.
if seen[fields[0]] {
continue
}
seen[fields[0]] = true
voice := Voice{Name: fields[0], Language: fields[1]}
if len(fields) == 3 {
voice.Variant = fields[2]
}
voices = append(voices, voice)
}
return voices, nil
}
func Normalize_language(value string) string {
value = strings.Split(strings.Split(value, ".")[0], "@")[0]
return strings.ToLower(strings.ReplaceAll(value, "_", "-"))
}
// Language_voices follows Cthulhu's eSpeak selection: exact dialect, then base
// language, with unmodified names before '+' variants. Empty means locale.
// Unknown locales retain the full catalog; explicit languages remain exact.
func Language_voices(voices []Voice, language string) []Voice {
explicit := language != ""
if !explicit {
for _, key := range []string{"LC_ALL", "LC_MESSAGES", "LANG"} {
if language = os.Getenv(key); language != "" {
break
}
}
}
language = Normalize_language(language)
if !explicit && (language == "" || language == "c" || language == "posix") {
language = "en-us"
}
languages := []string{language}
if !explicit {
languages = append(languages, strings.SplitN(language, "-", 2)[0])
}
for _, match := range languages {
var selected []Voice
for _, voice := range voices {
if Normalize_language(voice.Language) == match {
selected = append(selected, voice)
}
}
if len(selected) > 0 {
sort.SliceStable(selected, func(i, j int) bool {
return !strings.Contains(selected[i].Name, "+") && strings.Contains(selected[j].Name, "+")
})
return selected
}
}
if explicit {
return nil
}
return voices
}
// Prepare checks membership because SSIP SET may accept unknown synthesis
// names without verifying them. The resulting client has not spoken anything.
func Prepare(driver string, options Options) (Transport, error) {
return Prepare_context(context.Background(), driver, options)
}
// Prepare_context bounds discovery and connection setup together.
func Prepare_context(ctx context.Context, driver string, options Options) (Transport, error) {
options.context = ctx
if err := Validate_selection(options.Module, options.Language, options.Voice); err != nil {
return nil, err
}
d, err := Find_driver(driver)
if err != nil {
return nil, err
}
if d.Voices == nil {
if options.Module == "" && options.Language == "" && options.Voice == "" {
return Open_driver_context(ctx, driver, options)
}
return nil, fmt.Errorf("speech driver does not support voice discovery")
}
if err = d.Availability(); err != nil {
return nil, err
}
options.autospawn = driver == "speech-dispatcher"
if options.Module != "" {
modules, err := d.Modules(options)
if err != nil {
return nil, err
}
found := false
for _, module := range modules {
found = found || module == options.Module
}
if !found {
return nil, fmt.Errorf("speech module %q is unavailable", options.Module)
}
}
voices, err := d.Voices(options)
if err != nil {
return nil, err
}
languageFound, voiceFound := options.Language == "", options.Voice == ""
for _, voice := range voices {
languageMatches := options.Language == "" || Normalize_language(voice.Language) == Normalize_language(options.Language)
languageFound = languageFound || languageMatches
voiceFound = voiceFound || voice.Name == options.Voice && languageMatches
}
if !languageFound || !voiceFound {
return nil, fmt.Errorf("speech language or voice is unavailable for module %q", options.Module)
}
return Open_driver_context(ctx, driver, options)
}
// A live change queries only its target, so a slow unrelated module cannot
// consume its discovery budget or prevent selecting a working synthesizer.
func discover_voices(options Options) ([]Voice, error) {
if err := Validate_selection(options.Module, options.Language, options.Voice); err != nil {
return nil, err
}
w, err := connect_options(options)
if err != nil {
return nil, err
}
defer w.close()
if options.Module != "" {
if _, err = w.command("SET SELF OUTPUT_MODULE " + options.Module); err != nil {
return nil, err
}
}
rows, err := w.command("LIST SYNTHESIS_VOICES")
if err != nil {
return nil, err
}
return parse_voices(rows)
}