Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,471 @@
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user