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