// 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 app import ( "context" "errors" "fmt" "log/slog" "unicode/utf8" ) func (worker *speechWorker) deliver(ctx context.Context, request speechRequest) error { if err := ctx.Err(); err != nil { return err } // Suppressed utterances must not cancel the spoken mode announcement. // Mode changes already interrupt the synth through the worker barrier. if handled, err := worker.deliver_mode(ctx, request); handled { return err } if !request.Append { if err := worker.speaker.Cancel(ctx); err != nil { if ctx.Err() != nil { return ctx.Err() } worker.logger.Warn("cancel previous speech", "role", request.Role, "error", err) } } if len(request.Spelling) != 0 { // One parent request owns its context; letter recursion must not // re-announce it or alter the original character's voice choice. if err := worker.deliver_context(ctx, request); err != nil { return err } request.Context = nil parts := request.Spelling voices := request.SpellingVoices request.Spelling = nil request.SpellingVoices = nil request.Append = true for index, part := range parts { worker.mutex.Lock() interrupted := worker.generation != request.deliveryGeneration worker.mutex.Unlock() if interrupted { return context.Canceled } request.Text = part request.VoiceType = "" if index < len(voices) { request.VoiceType = voices[index] } if err := worker.deliver(ctx, request); err != nil { return err } } return nil } if err := worker.deliver_context(ctx, request); err != nil { return err } if len(request.ReadAll) != 0 { reader, ok := worker.speaker.(interface { SpeakAndWait(context.Context, string, func()) error }) if !ok { return errors.New("speech backend does not support continuous reading") } for index, part := range request.ReadAll { if err := ctx.Err(); err != nil { return err } if part.Context != nil { if err := worker.deliver_context_for(ctx, part.Context, true); err != nil { return err } } text := worker.transformText(worker.line_text(part.Text, part.Line)) worker.logDelivery(ctx, request, text, index) if worker.history != nil && !request.ExcludeFromHistory && request.Cause != "speech-history" { worker.history.add(text, request.Role) } var err error if voiced, ok := worker.speaker.(interface { SpeakVoiceAndWait(context.Context, string, string, func()) error }); ok { err = voiced.SpeakVoiceAndWait(ctx, text, part.VoiceType, part.Began) } else { err = reader.SpeakAndWait(ctx, text, part.Began) } if err != nil { return err } } return nil } if request.Uninterruptible { reader, ok := worker.speaker.(interface { speak_uninterruptible(context.Context, string) error }) if !ok { return errors.New("speech backend does not support uninterruptible completion") } text := worker.transformText(worker.line_text(request.Text, request.Line)) worker.record_delivery(ctx, request, text) return reader.speak_uninterruptible(ctx, text) } if request.Confirmation { text := worker.transformText(worker.line_text(request.Text, request.Line)) worker.record_delivery(ctx, request, text) if voiced, ok := worker.speaker.(interface { SpeakVoiceAndWait(context.Context, string, string, func()) error }); ok { return voiced.SpeakVoiceAndWait(ctx, text, "system", nil) } reader, ok := worker.speaker.(interface { SpeakAndWait(context.Context, string, func()) error }) if !ok { return errors.New("speech backend does not support confirmation completion") } return reader.SpeakAndWait(ctx, text, nil) } speak := worker.speaker.Speak kind := request.VoiceType if kind == "" { switch request.Role { case "system": kind = "system" case "link": kind = "hyperlink" } } if voiced, ok := worker.speaker.(interface { SpeakVoice(context.Context, string, string) error }); ok { speak = func(ctx context.Context, text string) error { return voiced.SpeakVoice(ctx, text, kind) } } if request.Character { if character, ok := worker.speaker.(interface { SpeakCharacter(context.Context, string) error }); ok { speak = character.SpeakCharacter } } if request.CustomEcho && request.Character { if echo, ok := worker.speaker.(interface { SpeakEchoCharacter(context.Context, string) error }); ok { speak = echo.SpeakEchoCharacter } } else if request.CustomEcho { if echo, ok := worker.speaker.(interface { SpeakEcho(context.Context, string) error }); ok { speak = echo.SpeakEcho } } chunks := request.Chunks if len(chunks) == 0 { chunks = []string{request.Text} } for index, chunk := range chunks { if err := ctx.Err(); err != nil { return err } worker.mutex.Lock() interrupted := worker.generation != request.deliveryGeneration worker.mutex.Unlock() if interrupted { return context.Canceled } if index == 0 { chunk = worker.line_text(chunk, request.Line) } text := chunk // Character navigation and literal spelling must retain the original // letter and case. History rows contain the transformed delivery. // Spoken character names still accept pronunciation replacements. historyRow := request.ExcludeFromHistory && request.Role == "table cell" if !historyRow && !(request.Character && utf8.RuneCountInString(chunk) == 1) { text = worker.transformText(chunk) } part := request part.Append = request.Append || index > 0 worker.record_delivery(ctx, part, text) if err := speak(ctx, text); err != nil { return fmt.Errorf("speak: %w", err) } } return nil } func (worker *speechWorker) record_delivery(ctx context.Context, request speechRequest, text string) { worker.logDelivery(ctx, request, text, -1) if worker.history != nil && !request.ExcludeFromHistory && request.Cause != "speech-history" { worker.history.add(text, request.Role) } } func (worker *speechWorker) transformText(text string) string { if worker.textTransform != nil { return worker.textTransform(text) } return text } // Log the text passed to the backend, including each Say All part rather than // the entire queued batch. A delivery record is an attempt, not audible proof. func (worker *speechWorker) logDelivery(ctx context.Context, request speechRequest, text string, readAllIndex int) { if !worker.logger.Enabled(ctx, slog.LevelDebug) { return } if request.Role == "password text" { text = "[REDACTED]" } worker.logger.DebugContext(ctx, "delivering accessible presentation", "role", request.Role, "cause", request.Cause, "utterance", text, "append", request.Append, "interrupt_previous", !request.Append && readAllIndex <= 0, "character", request.Character, "custom_echo", request.CustomEcho, "read_all_index", readAllIndex) }