// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package speech import ( "context" "errors" "time" ) // SpeakAndWait uses SSIP BEGIN/END/CANCEL events, not the SPEAK queue // acknowledgement, to pace continuous reading and report its current position. // Protocol: https://github.com/brailcom/speechd/blob/master/doc/ssip.texi func (client *Client) SpeakAndWait(ctx context.Context, text string, began func()) (result error) { client.mutex.Lock() defer client.mutex.Unlock() if client.conn == nil { return errors.New("speech client is not connected") } if err := client.sendCommand(ctx, []string{"SET", "SELF", "NOTIFICATION", "ALL", "on"}); err != nil { return err } defer func() { if client.conn != nil { cleanup, cancel := context.WithTimeout(context.Background(), client.ioTimeout) defer cancel() if err := client.sendCommand(cleanup, []string{"SET", "SELF", "NOTIFICATION", "ALL", "off"}); err != nil { result = errors.Join(result, err) _ = client.conn.Close() client.conn, client.reader = nil, nil } } }() if err := client.speak(ctx, text); err != nil { return err } conn := client.conn // A spoken line may take much longer than the command I/O timeout. if err := conn.SetDeadline(time.Time{}); err != nil { return err } done := make(chan struct{}) stop := context.AfterFunc(ctx, func() { defer close(done) cleanup, cancel := context.WithTimeout(context.Background(), client.ioTimeout) defer cancel() _ = client.Cancel(cleanup) // Allow the final cancellation notification to be drained, preserving // the speaking connection for subsequent focus announcements. _ = conn.SetReadDeadline(time.Now().Add(client.ioTimeout)) }) defer func() { if !stop() { <-done } }() started := false for { event, err := readReply(client.reader) if err != nil { _ = conn.Close() client.conn, client.reader = nil, nil return errors.Join(ctx.Err(), err) } switch event.code { case 701: if !started && ctx.Err() == nil && began != nil { began() } started = true case 702: return ctx.Err() case 703: return context.Canceled } } }