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,212 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/config"
|
||||
"git.stormux.org/storm/cthulhu/internal/input"
|
||||
"git.stormux.org/storm/cthulhu/internal/speech"
|
||||
)
|
||||
|
||||
// Use the settings worker's bounded mailbox, keeping discovery, disk writes,
|
||||
// and speech connection replacement off the input and focus paths.
|
||||
func (worker *preferencesWorker) SpeechCommand(command input.Command) {
|
||||
worker.TrySpeechCommand(command)
|
||||
}
|
||||
|
||||
func (worker *preferencesWorker) TrySpeechCommand(command input.Command) bool {
|
||||
select {
|
||||
case worker.speechCommands <- command:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (worker *preferencesWorker) announceSpeechSetting(message string) {
|
||||
if worker.announce != nil {
|
||||
worker.announce(message)
|
||||
}
|
||||
}
|
||||
|
||||
func (worker *preferencesWorker) handleSpeechCommand(ctx context.Context, command input.Command) {
|
||||
if command == input.CycleProgressBarOutput {
|
||||
worker.cycle_progress_output()
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 6*time.Second)
|
||||
defer cancel()
|
||||
if command == input.SaveSpeechSettings {
|
||||
store, err := config.NewStore(worker.settings.Path)
|
||||
if err == nil {
|
||||
if worker.activeApp != "" {
|
||||
err = store.SaveForApplication(worker.activeApp, worker.settings)
|
||||
} else {
|
||||
err = store.Save(worker.settings)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
worker.logger.Warn("save speech settings", "error", err)
|
||||
worker.announceSpeechSetting("Speech settings could not be saved")
|
||||
} else {
|
||||
worker.announceSpeechSetting("Speech settings saved")
|
||||
}
|
||||
return
|
||||
}
|
||||
switch command {
|
||||
case input.SelectPreviousSpeechSetting:
|
||||
worker.speechSetting = max(0, worker.speechSetting-1)
|
||||
case input.SelectNextSpeechSetting:
|
||||
worker.speechSetting = min(4, worker.speechSetting+1)
|
||||
}
|
||||
next := worker.settings
|
||||
adjust := command == input.IncreaseSpeechSetting || command == input.DecreaseSpeechSetting
|
||||
delta := 1
|
||||
if command == input.DecreaseSpeechSetting {
|
||||
delta = -1
|
||||
}
|
||||
message := ""
|
||||
switch worker.speechSetting {
|
||||
case 0, 1, 2:
|
||||
names := []string{"Rate", "Pitch", "Volume"}
|
||||
fields := []**float64{&next.DefaultVoice.Rate, &next.DefaultVoice.AveragePitch, &next.DefaultVoice.Gain}
|
||||
defaults := []float64{50, 5, 9}
|
||||
limits := []float64{99, 9, 9}
|
||||
steps := []float64{1, 0.1, 0.1}
|
||||
field := fields[worker.speechSetting]
|
||||
value := defaults[worker.speechSetting]
|
||||
if *field != nil {
|
||||
value = **field
|
||||
}
|
||||
if adjust {
|
||||
value = math.Round((value+float64(delta)*steps[worker.speechSetting])*10) / 10
|
||||
value = math.Max(0, math.Min(limits[worker.speechSetting], value))
|
||||
*field = &value
|
||||
}
|
||||
message = fmt.Sprintf("%s %g", names[worker.speechSetting], value)
|
||||
if *field == nil {
|
||||
message = names[worker.speechSetting] + " server default"
|
||||
}
|
||||
case 3, 4:
|
||||
if next.SpeechServerFactory == "hardwarefactory" {
|
||||
if worker.speechSetting == 4 {
|
||||
worker.announceSpeechSetting("Hardware speech has no selectable voices")
|
||||
return
|
||||
}
|
||||
modules := []string{"litetalk", "doubletalk", "tripletalk", "dectalk"}
|
||||
if adjust {
|
||||
for index, module := range modules {
|
||||
if module == next.SpeechOutputModule {
|
||||
next.SpeechOutputModule = modules[max(0, min(len(modules)-1, index+delta))]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
message = "Hardware synthesizer " + next.SpeechOutputModule
|
||||
break
|
||||
}
|
||||
if worker.catalog == nil {
|
||||
catalog, err := worker.discover(ctx, worker.socket)
|
||||
if err != nil {
|
||||
worker.announceSpeechSetting("Speech settings unavailable")
|
||||
return
|
||||
}
|
||||
worker.catalog = catalog
|
||||
}
|
||||
if worker.speechSetting == 3 {
|
||||
modules := []string{}
|
||||
for module := range worker.catalog {
|
||||
if module != "" {
|
||||
modules = append(modules, module)
|
||||
}
|
||||
}
|
||||
sort.Strings(modules)
|
||||
if len(modules) == 0 {
|
||||
worker.announceSpeechSetting("No speech-dispatcher modules available")
|
||||
return
|
||||
}
|
||||
if adjust {
|
||||
index := 0
|
||||
for i, module := range modules {
|
||||
if module == next.SpeechOutputModule {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
// An unspecified module is a server default, not a known list position.
|
||||
if next.SpeechOutputModule != "" {
|
||||
index = max(0, min(len(modules)-1, index+delta))
|
||||
}
|
||||
next.SpeechOutputModule = modules[index]
|
||||
next.DefaultVoice.Family = nil
|
||||
if voices := quickSpeechVoices(next.SpeechOutputModule, worker.catalog[next.SpeechOutputModule]); len(voices) > 0 {
|
||||
next.DefaultVoice.Family = &config.VoiceFamily{Name: voices[0].Name, Lang: voices[0].Language, Variant: voices[0].Variant}
|
||||
}
|
||||
}
|
||||
name := next.SpeechOutputModule
|
||||
if name == "" {
|
||||
name = "server default"
|
||||
}
|
||||
message = "Speech-dispatcher module " + name
|
||||
} else {
|
||||
voices := quickSpeechVoices(next.SpeechOutputModule, worker.catalog[next.SpeechOutputModule])
|
||||
if len(voices) == 0 {
|
||||
worker.announceSpeechSetting("No speech-dispatcher voices available")
|
||||
return
|
||||
}
|
||||
name := "server default"
|
||||
if family := next.DefaultVoice.Family; family != nil && family.Name != "" {
|
||||
name = family.Name
|
||||
}
|
||||
if adjust {
|
||||
index := 0
|
||||
for i, voice := range voices {
|
||||
if voice.Name == name {
|
||||
index = max(0, min(len(voices)-1, i+delta))
|
||||
break
|
||||
}
|
||||
}
|
||||
voice := voices[index]
|
||||
next.DefaultVoice.Family = &config.VoiceFamily{Name: voice.Name, Lang: voice.Language, Variant: voice.Variant}
|
||||
name = voice.Name
|
||||
}
|
||||
message = "Speech-dispatcher voice " + name
|
||||
}
|
||||
}
|
||||
if adjust {
|
||||
err := config.Validate(next)
|
||||
if err == nil {
|
||||
err = worker.updateSpeech(ctx, next)
|
||||
}
|
||||
if err != nil {
|
||||
worker.logger.Warn("adjust speech setting", "error", err)
|
||||
worker.catalog = nil
|
||||
worker.announceSpeechSetting("Speech settings were not applied")
|
||||
return
|
||||
}
|
||||
worker.settings = next
|
||||
if worker.live != nil {
|
||||
worker.live.Store(next)
|
||||
}
|
||||
}
|
||||
worker.announceSpeechSetting(message)
|
||||
}
|
||||
|
||||
// Keep eSpeak NG's language/variant cross product manageable in quick settings.
|
||||
// Prefer an exact dialect, then a language-only voice if that dialect is absent.
|
||||
// Unknown languages retain the catalog so the user can still select a voice.
|
||||
func quickSpeechVoices(module string, voices []speech.Voice) []speech.Voice {
|
||||
if !strings.Contains(strings.ToLower(module), "espeak-ng") {
|
||||
return voices
|
||||
}
|
||||
return speech.LocaleVoices(voices)
|
||||
}
|
||||
Reference in new issue
Block a user