472 lines
14 KiB
Go
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 = ©
|
|
}
|
|
}
|
|
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
|
|
}
|