213 lines
6.2 KiB
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)
|
|
}
|