Files
cthulhu/internal/app/speech_delivery.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)
}