// 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" "git.stormux.org/storm/cthulhu/internal/tentaclewire" ) // 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.globalSettings.Path) if err == nil { err = store.Save(worker.globalSettings) } 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 } worker.sync_extensions(ctx) quickSettings := worker.quick_settings() settingIndex := worker.speechSetting if worker.quickSettingKey != "" { settingIndex = 4 found := false for index, setting := range quickSettings { if setting.key == worker.quickSettingKey { settingIndex = 5 + index found = true break } } if !found && (command == input.IncreaseSpeechSetting || command == input.DecreaseSpeechSetting) { worker.quickSettingKey, worker.speechSetting = "", 4 worker.announceSpeechSetting("The selected speech setting is no longer available") return } } switch command { case input.SelectPreviousSpeechSetting: settingIndex = max(0, settingIndex-1) case input.SelectNextSpeechSetting: settingIndex = min(4+len(quickSettings), settingIndex+1) } worker.quickSettingKey = "" if settingIndex >= 5 { worker.quickSettingKey = quickSettings[settingIndex-5].key } else { worker.speechSetting = settingIndex } next := worker.effective_settings(worker.settings) adjust := command == input.IncreaseSpeechSetting || command == input.DecreaseSpeechSetting delta := 1 if command == input.DecreaseSpeechSetting { delta = -1 } if settingIndex >= 5 { worker.handle_extension_quick(ctx, quickSettings[settingIndex-5], adjust, delta) return } 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 = math.Round(**field*10) / 10 } 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 } if worker.speechOverride != nil { value := tentaclewire.SpeechFromSettings(next) worker.speechOverride = &value } else { worker.quickSpeech.settings = next worker.quickSpeech.adjusted[worker.speechSetting] = true global := copyQuickSpeechSetting(worker.globalSettings, next, worker.speechSetting) for application, settings := range worker.appSettings { worker.appSettings[application] = config.RebaseApplicationSettings(worker.globalSettings, global, settings) } worker.globalSettings = global worker.settings = next } if worker.live != nil { worker.live.Store(next) } } worker.announceSpeechSetting(message) } // The preferences worker owns these session-only choices. Apply them after // resolving an application profile so window switches cannot undo a shortcut. // Explicit preferences Apply or reader exit ends this override; saving does not. type quickSpeechSettings struct { settings config.Settings adjusted [5]bool } func (quick quickSpeechSettings) apply(settings config.Settings) config.Settings { for index, adjusted := range quick.adjusted { if adjusted { settings = copyQuickSpeechSetting(settings, quick.settings, index) } } return settings } func copyQuickSpeechSetting(target, source config.Settings, index int) config.Settings { switch index { case 0: target.DefaultVoice.Rate = source.DefaultVoice.Rate case 1: target.DefaultVoice.AveragePitch = source.DefaultVoice.AveragePitch case 2: target.DefaultVoice.Gain = source.DefaultVoice.Gain case 3, 4: // A voice belongs to its synthesizer, and a hardware synthesizer also // needs its transport. Keep this selection valid across app profiles. target.SpeechServerFactory = source.SpeechServerFactory target.SpeechOutputModule = source.SpeechOutputModule target.HardwareSpeechDevice = source.HardwareSpeechDevice target.HardwareSpeechBaudRate = source.HardwareSpeechBaudRate target.DefaultVoice.Family = source.DefaultVoice.Family } return target } // 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) }