// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package speech import ( "context" "errors" "fmt" "strings" "unicode/utf8" ) // Fragment is literal text with resolved spelling/language state. A fragment // boundary is a synthesis boundary; callers coalesce transport fragments first. // Empty language selects this client's effective configured default language. type Fragment struct { Text string Characters bool Language string } // SpeakFragmentsAndWait is for an isolated output owner. Unlike ordinary local // speech, it preserves spaces/tabs and line boundaries (canonicalized to CRLF). // Plain-text mode prevents network text being interpreted as synthesis markup. // It validates the entire sequence before any backend operation, then retains // the speaking lock through every END/CANCEL. Cancel still uses its own channel. // This method implements no priority, lease, pause or local-focus policy. func (client *Client) SpeakFragmentsAndWait(ctx context.Context, fragments []Fragment) (result error) { if len(fragments) == 0 || len(fragments) > 8192 { return errors.New("invalid speech fragment count") } bytes := 0 for _, part := range fragments { bytes += len(part.Text) if bytes > 256*1024 || !valid_fragment(part) { return errors.New("invalid speech fragment") } } client.mutex.Lock() defer client.mutex.Unlock() if client.conn == nil { return errors.New("speech client is not connected") } defer func() { if result != nil { result = errors.Join(ctx.Err(), result) } if result != nil && client.conn != nil { _ = client.conn.Close() client.conn, client.reader = nil, nil } }() // Query the effective default after initialization: a selected synthesis // voice may override SET LANGUAGE, and the server default may be unsaved. defaultLanguage, err := client.fragment_parameter(ctx, "LANGUAGE") if err != nil || !valid_fragment_language(defaultLanguage, false) { return errors.Join(ctx.Err(), errors.New("speech default language unavailable")) } module, err := client.fragment_parameter(ctx, "OUTPUT_MODULE") if err != nil || !valid_fragment_language(module, false) { return errors.Join(ctx.Err(), errors.New("speech default module unavailable")) } voiceType, err := client.fragment_parameter(ctx, "VOICE_TYPE") if err != nil { return errors.Join(ctx.Err(), errors.New("speech default voice unavailable")) } switch voiceType { case "MALE1", "MALE2", "MALE3", "FEMALE1", "FEMALE2", "FEMALE3", "CHILD_MALE", "CHILD_FEMALE": case "NO_VOICE": if client.settings.SynthesisVoice == "" { return errors.New("speech default voice unavailable") } // A named voice has no portable gender/category. Explicit remote // languages use the SSIP baseline category instead of that voice name. voiceType = "MALE1" default: return errors.New("speech default voice unavailable") } setVoice := func(language, named string) error { // SET LANGUAGE can select a different output module via server config. // Restore the reader's actual module, then clear any previous named // voice through VOICE_TYPE. A default-language part regains its name. for _, fields := range [][]string{ {"SET", "SELF", "LANGUAGE", language}, {"SET", "SELF", "OUTPUT_MODULE", module}, {"SET", "SELF", "VOICE_TYPE", voiceType}, } { if err := client.sendCommand(ctx, fields); err != nil { return err } } if named != "" { return client.sendCommand(ctx, []string{"SET", "SELF", "SYNTHESIS_VOICE", named}) } return nil } if err := client.sendCommand(ctx, []string{"SET", "SELF", "SSML_MODE", "off"}); err != nil { return err } for _, part := range fragments { language, spelling, named := part.Language, "off", "" if language == "" { language, named = defaultLanguage, client.settings.SynthesisVoice } if err := setVoice(language, named); err != nil { return err } if part.Characters { spelling = "on" } if err := client.sendCommand(ctx, []string{"SET", "SELF", "SPELLING", spelling}); err != nil { return err } text := strings.ReplaceAll(part.Text, "\r\n", "\n") text = strings.ReplaceAll(text, "\r", "\n") text = strings.ReplaceAll(text, "\n", "\r\n") if err := client.speak_and_wait_locked(ctx, func() error { return client.speak_block(ctx, text) }, nil); err != nil { return err } } if err := client.sendCommand(ctx, []string{"SET", "SELF", "SPELLING", "off"}); err != nil { return err } return setVoice(defaultLanguage, client.settings.SynthesisVoice) } // These names are fixed by the caller; response values never become commands // until their bounded token/enum validators have accepted them. func (client *Client) fragment_parameter(ctx context.Context, name string) (string, error) { finish, err := prepareIO(ctx, client.conn, client.ioTimeout) if err != nil { return "", err } defer finish() _, err = fmt.Fprint(client.conn, "GET "+name+"\r\n") var response reply if err == nil { response, err = client.readReply() } if err != nil || response.code != 251 || len(response.data) != 1 { return "", errors.New("speech default unavailable") } return response.data[0], nil } func valid_fragment(part Fragment) bool { if part.Text == "" || !utf8.ValidString(part.Text) || !valid_fragment_language(part.Language, true) { return false } for _, character := range part.Text { if character < 32 && character != '\r' && character != '\n' && character != '\t' || character == 127 { return false } } return true } func valid_fragment_language(language string, empty bool) bool { if language == "" { return empty } if len(language) > 64 { return false } for _, character := range language { if !(character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' || character == '-' || character == '_') { return false } } return true }