366 lines
11 KiB
Go
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()
|
|
}
|
|
}
|