// 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 . 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 }