239 lines
7.4 KiB
Go
239 lines
7.4 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"
|
|
"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)
|
|
}
|