Files

472 lines
14 KiB
Go

// Cthulhu screen reader.
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
//
// 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
import (
"bufio"
"context"
"errors"
"fmt"
"net"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
)
const (
connectTimeout = 2 * time.Second
ioTimeout = 5 * time.Second
)
type Client struct {
socketAddress string
settings Settings
conn net.Conn
reader *bufio.Reader
cancelConn net.Conn
cancelReader *bufio.Reader
clientID int
ioTimeout time.Duration
mutex sync.Mutex
cancelMutex sync.Mutex
settingsMutex sync.Mutex
}
type Settings struct {
OutputModule string
Rate *int
Pitch *int
Volume *int
Language string
SynthesisVoice string
Punctuation string
Capitalization string
}
type reply struct {
code int
text string
data []string
}
func New(socketAddress string) *Client {
return NewWithSettings(socketAddress, Settings{})
}
func NewWithSettings(socketAddress string, settings Settings) *Client {
return &Client{socketAddress: socketAddress, settings: cloneSettings(settings), ioTimeout: ioTimeout}
}
func cloneSettings(settings Settings) Settings {
for _, value := range []**int{&settings.Rate, &settings.Pitch, &settings.Volume} {
if *value != nil {
copy := **value
*value = &copy
}
}
return settings
}
// UpdateSettings replaces the complete speech configuration. A fresh pair of
// connections lets omitted settings regain server defaults and prevents a
// rejected SET command from leaving the active client partially configured.
// Preparation leaves Speak and Cancel available. Commit serializes with Speak,
// cancels the old client's queued speech, and switches the cancellation target.
// Only one update may prepare connections at a time; overlapping calls fail.
// Call this from a settings worker, never from the focus or input event path.
func (client *Client) UpdateSettings(ctx context.Context, settings Settings) error {
if !client.settingsMutex.TryLock() {
return errors.New("speech settings update already in progress")
}
defer client.settingsMutex.Unlock()
ctx, cancel := context.WithTimeout(ctx, ioTimeout)
defer cancel()
client.mutex.Lock()
original := client.conn
client.mutex.Unlock()
if original == nil {
return errors.New("speech client is not connected")
}
candidate := NewWithSettings(client.socketAddress, settings)
defer candidate.Close(context.Background())
if err := candidate.Connect(ctx); err != nil {
return fmt.Errorf("prepare speech settings: %w", err)
}
client.mutex.Lock()
defer client.mutex.Unlock()
client.cancelMutex.Lock()
defer client.cancelMutex.Unlock()
if client.conn != original {
return errors.New("speech connection changed while preparing settings")
}
if err := ctx.Err(); err != nil {
return err
}
if err := client.sendCancelCommand(ctx, "CANCEL "+strconv.Itoa(client.clientID)); err != nil {
return fmt.Errorf("cancel speech before applying settings: %w", err)
}
// Swap ownership so the deferred close releases only the retired pair.
client.conn, candidate.conn = candidate.conn, client.conn
client.reader, candidate.reader = candidate.reader, client.reader
client.cancelConn, candidate.cancelConn = candidate.cancelConn, client.cancelConn
client.cancelReader, candidate.cancelReader = candidate.cancelReader, client.cancelReader
client.clientID, candidate.clientID = candidate.clientID, client.clientID
client.settings = candidate.settings
return nil
}
func (client *Client) Connect(ctx context.Context) error {
client.mutex.Lock()
defer client.mutex.Unlock()
client.cancelMutex.Lock()
defer client.cancelMutex.Unlock()
if client.conn != nil || client.cancelConn != nil {
return errors.New("speech client already connected")
}
endpoint, err := resolveServerAddress(client.socketAddress)
if err != nil {
return err
}
dialer := net.Dialer{Timeout: connectTimeout}
conn, err := connectWithAutospawn(ctx, endpoint, dialer.DialContext, spawnServer)
if err != nil {
return fmt.Errorf("connect to Speech Dispatcher at %s: %w", endpoint.address, err)
}
cancelConn, err := dialer.DialContext(ctx, endpoint.network, endpoint.address)
if err != nil {
_ = conn.Close()
return fmt.Errorf("connect cancellation channel to Speech Dispatcher at %s: %w", endpoint.address, err)
}
client.conn = conn
client.reader = bufio.NewReader(conn)
client.cancelConn = cancelConn
client.cancelReader = bufio.NewReader(cancelConn)
if err := client.initialize(ctx); err != nil {
_ = conn.Close()
_ = cancelConn.Close()
client.conn, client.reader = nil, nil
client.cancelConn, client.cancelReader = nil, nil
client.clientID = 0
return err
}
return nil
}
// initialize runs with both connection locks held. Cancellation always targets
// the speaking connection's ID, never the separate control connection or ALL.
func (client *Client) initialize(ctx context.Context) error {
if err := client.applySettings(ctx); err != nil {
return err
}
finish, err := prepareIO(ctx, client.conn, client.ioTimeout)
if err != nil {
return err
}
defer finish()
if _, err := client.conn.Write([]byte("HISTORY GET CLIENT_ID\r\n")); err != nil {
return fmt.Errorf("request speech client ID: %w", err)
}
response, err := client.readReply()
if err != nil {
return err
}
if response.code != 245 || len(response.data) != 1 {
return fmt.Errorf("invalid Speech Dispatcher client ID reply: %d %s (expected code 245 and one ID)", response.code, response.text)
}
clientID, err := strconv.Atoi(response.data[0])
if err != nil || clientID <= 0 {
return errors.New("invalid Speech Dispatcher client ID")
}
client.clientID = clientID
return client.sendCancelCommand(ctx, "SET SELF CLIENT_NAME cthulhu:cthulhu:cancel")
}
func (client *Client) applySettings(ctx context.Context) error {
commands := [][]string{
{"SET", "SELF", "CLIENT_NAME", "cthulhu:cthulhu:main"},
{"SET", "SELF", "PRIORITY", "MESSAGE"},
}
if client.settings.OutputModule != "" {
commands = append(commands, []string{"SET", "SELF", "OUTPUT_MODULE", client.settings.OutputModule})
}
if client.settings.Punctuation != "" {
commands = append(commands, []string{"SET", "SELF", "PUNCTUATION", client.settings.Punctuation})
}
if client.settings.Capitalization != "" {
commands = append(commands, []string{"SET", "SELF", "CAP_LET_RECOGN", client.settings.Capitalization})
}
for _, setting := range []struct {
name string
value *int
}{
{name: "RATE", value: client.settings.Rate},
{name: "PITCH", value: client.settings.Pitch},
{name: "VOLUME", value: client.settings.Volume},
} {
if setting.value != nil {
commands = append(commands, []string{"SET", "SELF", setting.name, strconv.Itoa(*setting.value)})
}
}
if client.settings.Language != "" {
commands = append(commands, []string{"SET", "SELF", "LANGUAGE", client.settings.Language})
}
if client.settings.SynthesisVoice != "" {
commands = append(commands, []string{"SET", "SELF", "SYNTHESIS_VOICE", client.settings.SynthesisVoice})
}
for _, command := range commands {
if err := client.sendCommand(ctx, command); err != nil {
return fmt.Errorf("configure Speech Dispatcher %s: %w", command[2], err)
}
}
return nil
}
func (client *Client) sendCommand(ctx context.Context, fields []string) error {
for _, field := range fields {
if strings.ContainsAny(field, "\r\n\x00") {
return errors.New("SSIP command contains a control character")
}
}
finish, err := prepareIO(ctx, client.conn, client.ioTimeout)
if err != nil {
return err
}
defer finish()
if _, err := client.conn.Write([]byte(strings.Join(fields, " ") + "\r\n")); err != nil {
return fmt.Errorf("send SSIP command: %w", err)
}
commandReply, err := client.readReply()
if err != nil {
return err
}
if commandReply.code < 200 || commandReply.code >= 300 {
return fmt.Errorf("SSIP command rejected with %d %s", commandReply.code, commandReply.text)
}
return nil
}
func (client *Client) Speak(ctx context.Context, text string) (result error) {
client.mutex.Lock()
defer client.mutex.Unlock()
return client.speak(ctx, text)
}
// SpeakCharacter gives the synthesizer a single character as a character,
// preserving repeated adjacent letters during caret and flat review. Spoken
// names such as "space" still use normal speech.
func (client *Client) SpeakCharacter(ctx context.Context, text string) error {
client.mutex.Lock()
defer client.mutex.Unlock()
if utf8.RuneCountInString(text) != 1 || strings.ContainsAny(text, "\r\n\x00") {
return client.speak(ctx, text)
}
if client.conn == nil {
return errors.New("speech client is not connected")
}
return client.sendCommand(ctx, []string{"CHAR", text})
}
func (client *Client) speak(ctx context.Context, text string) (result error) {
text, err := normalizeSpeakText(text)
if err != nil {
return err
}
if client.conn == nil {
return errors.New("speech client is not connected")
}
finish, err := prepareIO(ctx, client.conn, client.ioTimeout)
if err != nil {
return err
}
defer finish()
defer func() {
if result != nil {
// A partial exchange cannot safely be reused: a late reply could
// otherwise be mistaken for the next command's acknowledgment.
_ = client.conn.Close()
client.conn, client.reader = nil, nil
}
}()
// SSIP doubles a leading dot so it cannot terminate the speech block.
if strings.HasPrefix(text, ".") {
text = "." + text
}
request := "SPEAK\r\n" + text + "\r\n.\r\n"
if _, err := client.conn.Write([]byte(request)); err != nil {
return fmt.Errorf("send speech block: %w", err)
}
accepted, err := client.readReply()
if err != nil {
return err
}
if accepted.code != 230 {
return fmt.Errorf("speech block rejected with %d %s", accepted.code, accepted.text)
}
queued, err := client.readReply()
if err != nil {
return err
}
if queued.code < 200 || queued.code >= 300 {
return fmt.Errorf("speech queue rejected with %d %s", queued.code, queued.text)
}
return nil
}
func (client *Client) Cancel(ctx context.Context) error {
client.cancelMutex.Lock()
defer client.cancelMutex.Unlock()
return client.sendCancelCommand(ctx, "CANCEL "+strconv.Itoa(client.clientID))
}
func (client *Client) Close(context.Context) error {
client.mutex.Lock()
defer client.mutex.Unlock()
client.cancelMutex.Lock()
defer client.cancelMutex.Unlock()
conn := client.conn
client.conn = nil
client.reader = nil
cancelConn := client.cancelConn
client.cancelConn = nil
client.cancelReader = nil
client.clientID = 0
if conn == nil && cancelConn == nil {
return nil
}
closeError := error(nil)
if conn != nil {
if err := conn.Close(); err != nil {
closeError = fmt.Errorf("close Speech Dispatcher connection: %w", err)
}
}
if cancelConn != nil {
if err := cancelConn.Close(); err != nil {
cancelError := fmt.Errorf("close Speech Dispatcher cancellation connection: %w", err)
closeError = errors.Join(closeError, cancelError)
}
}
return closeError
}
// prepareIO makes cancellation interrupt blocked socket I/O. The cleanup waits
// for a started callback before the next operation can install its deadline.
func prepareIO(ctx context.Context, conn net.Conn, timeout time.Duration) (func(), error) {
if err := ctx.Err(); err != nil {
return nil, err
}
deadline := time.Now().Add(timeout)
if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) {
deadline = contextDeadline
}
if err := conn.SetDeadline(deadline); err != nil {
return nil, fmt.Errorf("set Speech Dispatcher deadline: %w", err)
}
done := make(chan struct{})
stop := context.AfterFunc(ctx, func() {
_ = conn.SetDeadline(time.Now())
close(done)
})
return func() {
if !stop() {
<-done
}
}, nil
}
func (client *Client) readReply() (reply, error) {
for {
response, err := readReply(client.reader)
if err != nil || response.code < 700 || response.code >= 800 {
return response, err
}
}
}
func (client *Client) sendCancelCommand(ctx context.Context, command string) (result error) {
if client.cancelConn == nil {
return errors.New("speech cancellation channel is not connected")
}
finish, err := prepareIO(ctx, client.cancelConn, client.ioTimeout)
if err != nil {
return err
}
defer finish()
defer func() {
if result != nil {
_ = client.cancelConn.Close()
client.cancelConn, client.cancelReader = nil, nil
}
}()
if _, err := client.cancelConn.Write([]byte(command + "\r\n")); err != nil {
return fmt.Errorf("send Speech Dispatcher cancellation command: %w", err)
}
cancelReply, err := readReply(client.cancelReader)
if err != nil {
return err
}
if cancelReply.code < 200 || cancelReply.code >= 300 {
return fmt.Errorf("Speech Dispatcher cancellation rejected with %d %s", cancelReply.code, cancelReply.text)
}
return nil
}
func readReply(reader *bufio.Reader) (reply, error) {
var final reply
for {
line, err := reader.ReadString('\n')
if err != nil {
return final, fmt.Errorf("read Speech Dispatcher reply: %w", err)
}
line = strings.TrimRight(line, "\r\n")
if len(line) < 4 || line[3] != '-' && line[3] != ' ' {
return final, fmt.Errorf("invalid Speech Dispatcher reply %q", line)
}
code, err := strconv.Atoi(line[:3])
if err != nil {
return final, fmt.Errorf("parse Speech Dispatcher reply %q: %w", line, err)
}
final.code = code
final.text = strings.TrimSpace(line[4:])
if line[3] == ' ' {
return final, nil
}
final.data = append(final.data, final.text)
}
}
func normalizeSpeakText(text string) (string, error) {
text = strings.ToValidUTF8(text, "�")
text = strings.Join(strings.Fields(text), " ")
if text == "" {
return "", errors.New("speech text is empty")
}
return text, nil
}