Files

366 lines
11 KiB
Go

// 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 <https://www.gnu.org/licenses/gpl-3.0.html>.
// 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()
}
}