Files
ak47 ac431dd514 Add isolated Remote speech synthesis and typed rendering
Preserve literal whitespace and resolved spelling/language state across complete Remote speech assemblies. Isolate cancellation from local status and echo, retire handles on configuration changes or rendering failure, and cover metadata preparation cancellation with private SSIP fixtures.

Verified focused application and backend race tests, ten repetitions of isolated-client/rendering cases, shared speech regressions, affected-package vet, reader build and Tentacles checks. Incoming mailbox integration, local priority and live audible acceptance remain pending.
2026-11-04 19:56:38 -05:00

174 lines
5.8 KiB
Go

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