After commit c018f5fd29 Fenrir is being switched over to go. If you do not want to be part of the testing process, please switch to master.
This commit is contained in:
1 parent
c018f5fd29
commit
0e50de4c76
1047 files changed
+58364
-63747
No files matched your search
@@ -0,0 +1,251 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package speech
|
||||
|
||||
import (
|
||||
"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) {
|
||||
if options.Socket == "" {
|
||||
options.Socket = Default_socket()
|
||||
}
|
||||
result := Catalog{Voices: map[string][]Voice{}, Errors: map[string]error{}}
|
||||
w, err := connect(options.Socket)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
defer w.conn.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) {
|
||||
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(driver, options)
|
||||
}
|
||||
return nil, fmt.Errorf("speech driver does not support voice discovery")
|
||||
}
|
||||
if err = d.Availability(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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(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
|
||||
}
|
||||
if options.Socket == "" {
|
||||
options.Socket = Default_socket()
|
||||
}
|
||||
w, err := connect(options.Socket)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer w.conn.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)
|
||||
}
|
||||
Reference in new issue
Block a user