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