// Fenrir console screen reader. // Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later // // This program is free software: you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation, either version 3 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along with // this program. If not, see . // Package speech implements the Speech Dispatcher SSIP boundary and admission. package speech import ( "bufio" "context" "fmt" "net" "strconv" "strings" "sync" "time" "unicode" "unicode/utf8" "git.stormux.org/storm/fenrir/internal/diagnostic" ) // Speech Dispatcher serializes some module queries with speech commands. // Large voice catalogs can delay acknowledgements beyond a fraction of a second. // Keep the wait bounded while allowing those queries to finish. const commandTimeout = 5 * time.Second type Rejection struct{ Code string } func (r *Rejection) Error() string { return "Speech Dispatcher rejected a command: " + r.Code } type wire struct { conn net.Conn reader *bufio.Reader stopContext func() bool } func (w *wire) detach_context() { if w.stopContext != nil { w.stopContext() } } func (w *wire) close() { w.detach_context() _ = w.conn.Close() } func (w *wire) reply() ([]string, error) { var data []string expected := "" total := 0 for count := 0; count < 65536; count++ { part, err := w.reader.ReadSlice('\n') line := string(part) total += len(line) if total > 8<<20 { return nil, fmt.Errorf("SSIP reply exceeds byte limit") } if err != nil { return nil, err } line = strings.TrimRight(line, "\r\n") if len(line) < 4 || line[3] != '-' && line[3] != ' ' { return nil, fmt.Errorf("malformed SSIP reply") } if _, err := strconv.Atoi(line[:3]); err != nil { return nil, fmt.Errorf("invalid SSIP reply code") } if expected == "" { expected = line[:3] } if expected != line[:3] { return nil, fmt.Errorf("inconsistent SSIP reply codes") } if line[3] == ' ' { if line[0] != '2' { return nil, &Rejection{Code: line[:3]} } return data, nil } data = append(data, line[4:]) } return nil, fmt.Errorf("SSIP reply exceeds line limit") } func (w *wire) command(command string) ([]string, error) { if err := w.conn.SetDeadline(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() } // Queries share the address resolver. Only Prepare opts into daemon startup; // ordinary settings discovery never starts a service. func connect_options(options Options) (*wire, error) { endpoint, err := resolve_server_address(options.Socket) if err != nil { return nil, err } return connect_endpoint(options.context, endpoint, options.autospawn, automatic_server_address(options.Socket)) } func connect_endpoint(parent context.Context, endpoint serverAddress, autospawn, systemFallback bool) (*wire, error) { if parent == nil { parent = context.Background() } ctx, cancel := context.WithTimeout(parent, 3*commandTimeout) defer cancel() dialer := &net.Dialer{Timeout: commandTimeout} dial := serverDialer(dialer.DialContext) if systemFallback { dial = dial_with_system_fallback(dial) } var conn net.Conn var err error if autospawn { conn, err = connect_with_autospawn(ctx, endpoint, dial, spawn_server) } else { conn, err = dial(ctx, endpoint.network, endpoint.address) } if err != nil { return nil, err } return &wire{conn: conn, reader: bufio.NewReaderSize(conn, 4096), stopContext: context.AfterFunc(parent, func() { _ = conn.Close() })}, nil } type Client struct { data, control *wire id string sendMu sync.Mutex controlMu sync.Mutex closeOnce sync.Once options Options } type Options struct { // Only Prepare enables startup during its prerequisite selection queries. autospawn bool context context.Context Socket string Rate int Pitch *int Volume *int Module string Language string Voice string Hardware HardwareOptions } type Parameters struct { Rate, Pitch, Volume int } func (p Parameters) Validate() error { if p.Rate < -100 || p.Rate > 100 || p.Pitch < -100 || p.Pitch > 100 || p.Volume < -100 || p.Volume > 100 { return fmt.Errorf("speech rate, pitch and volume must be between -100 and 100") } return nil } func Open(options Options) (*Client, error) { if options.Rate < -100 || options.Rate > 100 { return nil, fmt.Errorf("speech rate must be between -100 and 100") } for _, value := range []*int{options.Pitch, options.Volume} { if value != nil && (*value < -100 || *value > 100) { return nil, fmt.Errorf("speech pitch and volume must be between -100 and 100") } } if err := Validate_selection(options.Module, options.Language, options.Voice); err != nil { return nil, err } endpoint, err := resolve_server_address(options.Socket) if err != nil { return nil, err } data, err := connect_endpoint(options.context, endpoint, true, automatic_server_address(options.Socket)) if err != nil { return nil, fmt.Errorf("connect Speech Dispatcher at %s: %w", endpoint.address, err) } client := &Client{data: data, options: options} fail := func(err error) (*Client, error) { data.close() if client.control != nil { client.control.close() } return nil, err } commands := []string{ `SET SELF CLIENT_NAME "fenrir:fenrir:main"`, "SET SELF PRIORITY MESSAGE", "SET SELF PUNCTUATION NONE", "SET SELF CAP_LET_RECOGN NONE", fmt.Sprintf("SET SELF RATE %d", options.Rate), } if options.Module != "" { commands = append(commands, "SET SELF OUTPUT_MODULE "+options.Module) } if options.Pitch != nil { commands = append(commands, fmt.Sprintf("SET SELF PITCH %d", *options.Pitch)) } if options.Volume != nil { commands = append(commands, fmt.Sprintf("SET SELF VOLUME %d", *options.Volume)) } if options.Language != "" { commands = append(commands, "SET SELF LANGUAGE "+options.Language) } if options.Voice != "" { commands = append(commands, "SET SELF SYNTHESIS_VOICE "+options.Voice) } for _, command := range commands { if _, err := data.command(command); err != nil { return fail(err) } } identifiers, err := data.command("HISTORY GET CLIENT_ID") if err != nil { return fail(err) } if len(identifiers) != 1 { return fail(fmt.Errorf("missing Speech Dispatcher client ID")) } id, err := strconv.ParseUint(identifiers[0], 10, 64) if err != nil { return fail(fmt.Errorf("invalid Speech Dispatcher client ID")) } client.id = strconv.FormatUint(id, 10) // Bind control to the same server selected for data, including fallback. if endpoint.network == "unix" { endpoint.address = data.conn.RemoteAddr().String() } client.control, err = connect_endpoint(options.context, endpoint, false, false) if err != nil { return fail(err) } if _, err := client.control.command(`SET SELF CLIENT_NAME "fenrir:fenrir:control"`); err != nil { return fail(err) } data.detach_context() client.control.detach_context() if options.context != nil && options.context.Err() != nil { client.abort() return nil, options.context.Err() } client.options.context = nil return client, nil } // Reopen reconnects only the selected server and synthesis options. func (c *Client) Reopen(ctx context.Context) (Transport, error) { options := c.options options.context = ctx client, err := Open(options) if err != nil { return nil, err } return client, nil } // Configure shares the speaking connection, so subsequent utterances use the // new values. The service worker calls this away from the input loop. func (c *Client) Configure(parameters Parameters) error { if err := parameters.Validate(); err != nil { return err } c.sendMu.Lock() defer c.sendMu.Unlock() for _, setting := range []struct { name string value int }{{"RATE", parameters.Rate}, {"PITCH", parameters.Pitch}, {"VOLUME", parameters.Volume}} { if _, err := c.data.command(fmt.Sprintf("SET SELF %s %d", setting.name, setting.value)); err != nil { return err } } return nil } func (c *Client) Say(text string) error { c.sendMu.Lock() defer c.sendMu.Unlock() started := time.Now() if char, size := utf8.DecodeRuneInString(text); size == len(text) && size > 0 && utf8.ValidString(text) && !unicode.IsControl(char) && !unicode.IsSpace(char) { // Single-letter feedback needs one command/acknowledgement, rather // than a SPEAK handshake followed by a separate text transaction. _, err := c.data.command("CHAR " + text) diagnostic.Timing("speech CHAR acknowledgement", started) return err } if _, err := c.data.command("SPEAK"); err != nil { return err } diagnostic.Timing("speech SPEAK acknowledgement", started) text = strings.ReplaceAll(text, "\r\n", "\n") text = strings.ReplaceAll(text, "\r", "\n") text = strings.ReplaceAll(text, "\x00", "") lines := strings.Split(text, "\n") for index, line := range lines { if strings.HasPrefix(line, ".") { lines[index] = "." + line } } if err := c.data.conn.SetDeadline(time.Now().Add(commandTimeout)); err != nil { return err } if _, err := fmt.Fprintf(c.data.conn, "%s\r\n.\r\n", strings.Join(lines, "\r\n")); err != nil { return err } _, err := c.data.reply() return err } // Set_pitch is called by the serialized speech worker, never the input loop. func (c *Client) Set_pitch(pitch int) error { if pitch < -100 || pitch > 100 { return fmt.Errorf("speech pitch must be between -100 and 100") } c.sendMu.Lock() defer c.sendMu.Unlock() _, err := c.data.command(fmt.Sprintf("SET SELF PITCH %d", pitch)) return err } // Cancel uses the independent control socket and targets only this client. // Service holds the dispatch barrier for the final cancellation. func (c *Client) Cancel() error { c.controlMu.Lock() defer c.controlMu.Unlock() _, err := c.control.command("CANCEL " + c.id) return err } func (c *Client) Close() { c.closeOnce.Do(func() { // Closing the data socket releases a blocked sender before cancellation. c.data.close() c.Cancel() c.control.close() }) } // abort releases blocked initialization commands without another SSIP request. func (c *Client) abort() { _ = c.data.conn.Close() if c.control != nil { _ = c.control.conn.Close() } }