415 lines
12 KiB
Go
415 lines
12 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 app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
"git.stormux.org/storm/cthulhu/internal/config"
|
|
"git.stormux.org/storm/cthulhu/internal/sound"
|
|
)
|
|
|
|
// speechQueueCapacity bounds work retained while the speech backend is slow.
|
|
// Requests remain FIFO. On overflow, the oldest pending request is discarded
|
|
// so that current focus is favored, and the loss is reported without content.
|
|
const speechQueueCapacity = 64
|
|
|
|
type speechRequest struct {
|
|
presentationGeneration *uint64 // Checked under the speech owner mutex.
|
|
presentationRevision *uint64
|
|
|
|
Window atspi.ObjectID
|
|
IntroducesWindow bool
|
|
ReadAll []readAllPart
|
|
CustomEcho bool
|
|
Character bool
|
|
VoiceType string
|
|
Line *speechLine
|
|
Context []atspi.Snapshot
|
|
Spelling []string // One cancellable queue item, with separate letter utterances.
|
|
SpellingVoices []string
|
|
Sound []sound.Cue
|
|
Chunks []string // Presentation pause boundaries; one cancellable queue item.
|
|
FallbackChunks []string
|
|
Text string
|
|
FallbackText string
|
|
Role string
|
|
Cause string
|
|
Append bool
|
|
ClipboardTimestamp uint32
|
|
BypassFocusGate bool
|
|
ExcludeFromHistory bool
|
|
ForceSpeech bool // Only the speech-mode announcement bypasses the mode.
|
|
Confirmation bool // Wait for speech completion before automatic output.
|
|
Uninterruptible bool // Accepted tentacle message; ordinary input cannot cancel it.
|
|
confirmationID uint64
|
|
deliveryGeneration uint64
|
|
context context.Context
|
|
cancel context.CancelFunc
|
|
result chan error
|
|
}
|
|
|
|
type readAllPart struct {
|
|
Text string
|
|
VoiceType string
|
|
Line *speechLine
|
|
Context []atspi.Snapshot
|
|
Began func()
|
|
}
|
|
|
|
// speechWorker owns the pending queue under mutex. Delivery and cancellation
|
|
// have separate goroutines and joins; activeDone and generation form their
|
|
// barrier so cancellation cannot overtake newly accepted output.
|
|
type speechWorker struct {
|
|
logger *slog.Logger
|
|
speaker Speaker
|
|
history *speechHistory
|
|
beep func(context.Context) error
|
|
textTransform func(string) string
|
|
reading func() config.ReadingSettings
|
|
verbosity func() int
|
|
indentation indentationHistory // Owned by the serialized delivery goroutine.
|
|
speechContext []atspi.Snapshot // Same owner; replaced on each known navigation context.
|
|
indentationCue func(sound.Cue)
|
|
|
|
mutex sync.Mutex
|
|
enabled bool
|
|
mode speechMode
|
|
beepCancel context.CancelFunc
|
|
pending []speechRequest
|
|
wake chan struct{}
|
|
interrupt chan struct{}
|
|
runDone chan struct{}
|
|
cancelDone chan struct{}
|
|
cancel context.CancelFunc
|
|
secure bool
|
|
started bool
|
|
running bool
|
|
workerContext context.Context
|
|
overflow uint64
|
|
reported uint64
|
|
generation uint64
|
|
interrupting bool
|
|
activeDone chan struct{}
|
|
startupUntil time.Time
|
|
readAllCancel context.CancelFunc
|
|
readAllTimestamp uint32
|
|
commandTimestamp uint32
|
|
lastInputTimestamp uint32
|
|
titleWindow atspi.ObjectID
|
|
titleInput titleInputPolicy
|
|
confirmationID uint64 // Owned under mutex; reset on completion or user interruption.
|
|
confirmationSerial uint64
|
|
|
|
presentationDelivery context.Context // Replaced when the next request reaches delivery.
|
|
presentationRevision uint64
|
|
uninterruptibleContext context.Context
|
|
uninterruptibleCancel context.CancelFunc
|
|
}
|
|
|
|
func newSpeechWorker(logger *slog.Logger, speaker Speaker) *speechWorker {
|
|
return &speechWorker{
|
|
logger: logger,
|
|
speaker: speaker,
|
|
enabled: true,
|
|
wake: make(chan struct{}, 1),
|
|
interrupt: make(chan struct{}, 1),
|
|
}
|
|
}
|
|
|
|
func (worker *speechWorker) Start(ctx context.Context) error {
|
|
worker.mutex.Lock()
|
|
defer worker.mutex.Unlock()
|
|
if worker.started {
|
|
return errors.New("speech worker already started")
|
|
}
|
|
workerContext, cancel := context.WithCancel(ctx)
|
|
worker.workerContext = workerContext
|
|
worker.cancel = cancel
|
|
worker.started = true
|
|
worker.running = true
|
|
worker.runDone = make(chan struct{})
|
|
worker.cancelDone = make(chan struct{})
|
|
go worker.run(workerContext)
|
|
go worker.cancelActiveSpeech(workerContext)
|
|
return nil
|
|
}
|
|
|
|
func (worker *speechWorker) Enqueue(request speechRequest) {
|
|
worker.mutex.Lock()
|
|
if request.presentationGeneration != nil && (*request.presentationGeneration != worker.generation ||
|
|
request.presentationRevision == nil || *request.presentationRevision != worker.presentationRevision) {
|
|
worker.mutex.Unlock()
|
|
completeSpeechRequest(request, context.Canceled)
|
|
return
|
|
}
|
|
if !worker.enabled {
|
|
worker.mutex.Unlock()
|
|
completeSpeechRequest(request, context.Canceled)
|
|
return
|
|
}
|
|
// Equal millisecond timestamps can belong to the copy key and its result.
|
|
if request.ClipboardTimestamp != 0 && worker.lastInputTimestamp != 0 && int32(request.ClipboardTimestamp-worker.lastInputTimestamp) < 0 {
|
|
worker.mutex.Unlock()
|
|
completeSpeechRequest(request, context.Canceled)
|
|
return
|
|
}
|
|
if worker.secure && !strings.HasPrefix(request.Cause, "remote-") {
|
|
worker.mutex.Unlock()
|
|
completeSpeechRequest(request, context.Canceled)
|
|
return
|
|
}
|
|
if worker.readAllCancel != nil && request.Cause != "read-all" && !request.Append {
|
|
worker.readAllCancel()
|
|
worker.readAllCancel = nil
|
|
}
|
|
if request.context != nil && request.context.Err() != nil {
|
|
worker.mutex.Unlock()
|
|
completeSpeechRequest(request, request.context.Err())
|
|
return
|
|
}
|
|
if !worker.running {
|
|
worker.mutex.Unlock()
|
|
completeSpeechRequest(request, errors.New("speech worker is not running"))
|
|
return
|
|
}
|
|
if request.Confirmation {
|
|
worker.confirmationSerial++
|
|
request.confirmationID = worker.confirmationSerial
|
|
worker.confirmationID = request.confirmationID
|
|
} else if worker.confirmationID != 0 {
|
|
request.Append = true
|
|
}
|
|
if worker.has_uninterruptible_locked() {
|
|
request.Append = true
|
|
}
|
|
// Pair a title with the next announcement from its window. User input
|
|
// and explicit interruption retire this pairing; another window starts
|
|
// normally. This queues the continuation without protecting the title
|
|
// against navigation or estimating how long it will take to speak.
|
|
if worker.titleWindow.Valid() && request.Window == worker.titleWindow && !request.IntroducesWindow {
|
|
request.Append = true
|
|
}
|
|
worker.titleWindow = atspi.ObjectID{}
|
|
if request.IntroducesWindow && request.Text != "" {
|
|
worker.titleWindow = request.Window
|
|
}
|
|
if time.Now().Before(worker.startupUntil) && request.Cause != "startup" {
|
|
request.Append = true
|
|
}
|
|
if len(worker.pending) == speechQueueCapacity {
|
|
if worker.pending[0].confirmationID == worker.confirmationID {
|
|
worker.confirmationID = 0
|
|
}
|
|
completeSpeechRequest(worker.pending[0], errors.New("speech request dropped after queue overflow"))
|
|
copy(worker.pending, worker.pending[1:])
|
|
worker.pending = worker.pending[:speechQueueCapacity-1]
|
|
worker.overflow++
|
|
}
|
|
worker.titleInput.observe(request)
|
|
worker.presentationRevision++
|
|
worker.pending = append(worker.pending, request)
|
|
worker.mutex.Unlock()
|
|
|
|
select {
|
|
case worker.wake <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
func (worker *speechWorker) EnqueueAndWait(ctx context.Context, request speechRequest) error {
|
|
worker.mutex.Lock()
|
|
if worker.running {
|
|
requestContext, cancelRequest := context.WithCancel(worker.workerContext)
|
|
request.context = requestContext
|
|
request.cancel = cancelRequest
|
|
}
|
|
worker.mutex.Unlock()
|
|
|
|
request.result = make(chan error, 1)
|
|
worker.Enqueue(request)
|
|
stopCallerWatch := context.AfterFunc(ctx, func() {
|
|
if request.cancel != nil {
|
|
request.cancel()
|
|
}
|
|
})
|
|
select {
|
|
case err := <-request.result:
|
|
if request.cancel != nil {
|
|
request.cancel()
|
|
}
|
|
stopCallerWatch()
|
|
return err
|
|
case <-ctx.Done():
|
|
if request.cancel != nil {
|
|
request.cancel()
|
|
}
|
|
stopCallerWatch()
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
|
|
func (worker *speechWorker) Stop(ctx context.Context) error {
|
|
worker.mutex.Lock()
|
|
if !worker.started {
|
|
worker.mutex.Unlock()
|
|
return nil
|
|
}
|
|
worker.running = false
|
|
worker.confirmationID = 0
|
|
if worker.uninterruptibleCancel != nil {
|
|
worker.uninterruptibleCancel()
|
|
}
|
|
if worker.readAllCancel != nil {
|
|
worker.readAllCancel()
|
|
worker.readAllCancel = nil
|
|
}
|
|
completeSpeechRequests(worker.pending, context.Canceled)
|
|
worker.pending = nil
|
|
cancel := worker.cancel
|
|
runDone := worker.runDone
|
|
cancelDone := worker.cancelDone
|
|
worker.mutex.Unlock()
|
|
|
|
cancel()
|
|
for _, done := range []chan struct{}{runDone, cancelDone} {
|
|
select {
|
|
case <-done:
|
|
case <-ctx.Done():
|
|
worker.reportOverflow()
|
|
return fmt.Errorf("wait for speech worker: %w", ctx.Err())
|
|
}
|
|
}
|
|
worker.reportOverflow()
|
|
return nil
|
|
}
|
|
|
|
func (worker *speechWorker) run(ctx context.Context) {
|
|
defer close(worker.runDone)
|
|
defer func() {
|
|
worker.mutex.Lock()
|
|
worker.pending = nil
|
|
worker.running = false
|
|
worker.mutex.Unlock()
|
|
}()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-worker.wake:
|
|
}
|
|
worker.reportOverflow()
|
|
|
|
for {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
request, ok := worker.next()
|
|
if !ok {
|
|
break
|
|
}
|
|
deliveryContext := ctx
|
|
if request.context != nil {
|
|
deliveryContext = request.context
|
|
}
|
|
err := worker.deliver(deliveryContext, request)
|
|
completeSpeechRequest(request, err)
|
|
worker.mutex.Lock()
|
|
worker.uninterruptibleContext, worker.uninterruptibleCancel = nil, nil
|
|
if request.confirmationID == worker.confirmationID {
|
|
worker.confirmationID = 0
|
|
}
|
|
close(worker.activeDone)
|
|
worker.activeDone = nil
|
|
worker.mutex.Unlock()
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
worker.logger.Error("deliver accessible presentation", "role", request.Role, "error", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func completeSpeechRequests(requests []speechRequest, err error) {
|
|
for _, request := range requests {
|
|
completeSpeechRequest(request, err)
|
|
}
|
|
}
|
|
|
|
func completeSpeechRequest(request speechRequest, err error) {
|
|
if request.Uninterruptible && request.cancel != nil {
|
|
request.cancel()
|
|
}
|
|
if request.result == nil {
|
|
return
|
|
}
|
|
select {
|
|
case request.result <- err:
|
|
default:
|
|
}
|
|
}
|
|
|
|
func (worker *speechWorker) reportOverflow() {
|
|
worker.mutex.Lock()
|
|
if worker.overflow == worker.reported {
|
|
worker.mutex.Unlock()
|
|
return
|
|
}
|
|
dropped := worker.overflow - worker.reported
|
|
worker.reported = worker.overflow
|
|
total := worker.overflow
|
|
worker.mutex.Unlock()
|
|
worker.logger.Warn(
|
|
"speech queue overflow",
|
|
"dropped_requests", dropped,
|
|
"total_dropped_requests", total,
|
|
)
|
|
}
|
|
|
|
func (worker *speechWorker) next() (speechRequest, bool) {
|
|
worker.mutex.Lock()
|
|
defer worker.mutex.Unlock()
|
|
if !worker.running || worker.interrupting || len(worker.pending) == 0 {
|
|
return speechRequest{}, false
|
|
}
|
|
worker.activeDone = make(chan struct{})
|
|
request := worker.pending[0]
|
|
if request.Uninterruptible {
|
|
worker.uninterruptibleContext, worker.uninterruptibleCancel = request.context, request.cancel
|
|
}
|
|
worker.presentationDelivery = nil
|
|
if request.presentationGeneration != nil {
|
|
worker.presentationDelivery = request.context
|
|
}
|
|
request.deliveryGeneration = worker.generation
|
|
copy(worker.pending, worker.pending[1:])
|
|
worker.pending[len(worker.pending)-1] = speechRequest{}
|
|
worker.pending = worker.pending[:len(worker.pending)-1]
|
|
return request, true
|
|
}
|