// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "errors" "fmt" "strings" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/preferences" ) func (worker *preferencesWorker) preview(ctx context.Context, request preferences.Request) preferences.Response { if request.EchoPreview != "" { return worker.preview_echo_voice(ctx, request) } next := worker.settings if worker.editingActive { next = worker.editing } next.DefaultVoice.Rate = request.Values.Rate next.DefaultVoice.AveragePitch = request.Values.Pitch next.DefaultVoice.Gain = request.Values.Volume next.SpeechServerFactory = request.Values.SpeechServerFactory next.HardwareSpeechDevice = request.Values.HardwareSpeechDevice next.HardwareSpeechBaudRate = request.Values.HardwareSpeechBaudRate next.SpeechOutputModule = request.Values.SpeechOutputModule next.DefaultVoice.Family = request.Values.SpeechVoiceFamily next.UppercaseVoice = request.Values.UppercaseVoice next.HyperlinkVoice = request.Values.HyperlinkVoice next.SystemVoice = request.Values.SystemVoice // GUI feedback auditions the selected type; Cancel restores the committed // configuration, while Apply receives the complete unmodified draft. if kind := request.Values.VoiceType; kind != "" && kind != "default" { next.DefaultVoice = config.EffectiveVoice(next, kind) next.SystemVoice = config.Voice{} } response := worker.response("Speech settings previewed.", false) response.Preview = true if err := config.Validate(next); err != nil { response.Message = "Speech settings were not previewed: " + err.Error() return response } if err := worker.updateSpeech(ctx, next); err != nil { response.Message = "Speech settings were not previewed: " + err.Error() return response } response.Accepted = true return response } func (worker *preferencesWorker) preview_echo_voice(ctx context.Context, request preferences.Request) preferences.Response { response := worker.response("Speech settings previewed.", false) response.Preview = true var value *float64 text := "" label := "" switch request.EchoPreview { case "rate": value = request.Values.EchoVoice.Rate label = "Key echo rate" case "pitch": value = request.Values.EchoVoice.AveragePitch label = "Key echo pitch" case "volume": value = request.Values.EchoVoice.Gain label = "Key echo volume" case "synthesizer": label = "Key echo speech synthesizer" text = request.Values.EchoSpeechServer if text == "" { text = "Default" } case "person": label = "Key echo person" text = "Default" if family := request.Values.EchoVoice.Family; family != nil { text = family.Name if language := strings.TrimSuffix(family.Lang+"-"+family.Dialect, "-"); language != "" { text += " (" + language + ")" } } } if value != nil { text = fmt.Sprintf("%.1f", *value) } if !request.Values.UseCustomEchoVoice || text == "" { response.Message = "Choose a custom echo voice and a valid echo control value." return response } // Preview only echo settings. Control labels and ordinary output retain // the active profile's main voice; the draft is never committed here. next := worker.settings next.UseCustomEchoVoice = true next.UseCustomEchoSpeechServer = request.Values.UseCustomEchoSpeechServer next.EchoSpeechServer = request.Values.EchoSpeechServer next.EchoVoice = request.Values.EchoVoice if err := config.Validate(next); err != nil { response.Message = "Speech settings were not previewed: " + err.Error() return response } if err := worker.updateSpeech(ctx, next); err != nil { response.Message = "Speech settings were not previewed: " + err.Error() return response } if worker.announceEchoPreview != nil { if !request.EchoPreviewFocus { label = "" } worker.announceEchoPreview(label, text) } response.Accepted = true return response } func echo_preview_speech(label, value string) speechRequest { parts := []readAllPart{} if label != "" { parts = append(parts, readAllPart{Text: label, VoiceType: "default"}) } parts = append(parts, readAllPart{Text: value, VoiceType: "echo"}) // Both parts belong to one cancellable request and wait for completion. // No second voice can cancel the first part merely by being queued next. return speechRequest{Text: value, Role: "text", Cause: "echo-voice-preview", ReadAll: parts} } func (worker *preferencesWorker) restorePreview(ctx context.Context) preferences.Response { response := worker.response("Speech settings restored.", false) if err := worker.updateSpeech(ctx, worker.effective_settings(worker.settings)); err != nil { response.Message = "Speech settings could not be restored: " + err.Error() return response } response.Accepted = true response.Close = true return response } func (worker *preferencesWorker) updateSpeech(ctx context.Context, next config.Settings) error { if worker.updater == nil { return errors.New("speech is disabled or this backend cannot change settings") } if updater, ok := worker.updater.(interface { UpdateConfiguration(context.Context, config.Settings) error }); ok { return updater.UpdateConfiguration(ctx, next) } if updater, ok := worker.updater.(speechSettingsUpdater); ok { if next.UseCustomEchoVoice { return errors.New("this speech backend cannot use a custom echo voice") } return updater.UpdateSettings(ctx, speechSettingsFromConfig(next)) } return errors.New("this backend cannot change speech settings") }