Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,238 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in new issue
Block a user