254 lines
7.2 KiB
Go
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)
|
|
}
|