Files
cthulhu/internal/app/speech_settings.go

213 lines
6.2 KiB
Go

// 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)
}