// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "errors" "reflect" "sync" "unicode" "unicode/utf8" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/speech" ) // echoSpeaker prepares all voices before publishing any. Speech and // cancellation remain available while a preferences change is prepared. type echoSpeaker struct { socket string initial config.Settings mu sync.RWMutex update sync.Mutex voices map[string]*speech.Client closed bool } func newEchoSpeaker(socket string, settings config.Settings) *echoSpeaker { return &echoSpeaker{socket: socket, initial: settings} } func (speaker *echoSpeaker) Connect(ctx context.Context) error { return speaker.UpdateConfiguration(ctx, speaker.initial) } func (speaker *echoSpeaker) UpdateConfiguration(ctx context.Context, settings config.Settings) error { if !speaker.update.TryLock() { return errors.New("speech settings update already in progress") } defer speaker.update.Unlock() candidates := make(map[string]*speech.Client) closeClients := func(clients map[string]*speech.Client) error { var result error for _, client := range clients { result = errors.Join(result, client.Close(context.Background())) } return result } voiceSettings := map[string]speech.Settings{"default": speechSettingsFromConfig(settings)} for _, kind := range []string{"uppercase", "hyperlink", "system"} { if kind == "hyperlink" && reflect.DeepEqual(settings.HyperlinkVoice, config.Voice{}) || kind == "system" && reflect.DeepEqual(settings.SystemVoice, config.Voice{}) { continue } candidate := settings candidate.DefaultVoice = config.EffectiveVoice(settings, kind) voiceSettings[kind] = speechSettingsFromConfig(candidate) } if settings.UseCustomEchoVoice { voiceSettings["echo"] = echoSpeechSettings(settings) echoSettings := settings echoSettings.DefaultVoice = config.EffectiveVoice(settings, "default") merge_echo_voice(&echoSettings.DefaultVoice, settings.EchoVoice) echoSettings.DefaultVoice = config.EffectiveVoice(echoSettings, "uppercase") if settings.UseCustomEchoSpeechServer { echoSettings.SpeechOutputModule = settings.EchoSpeechServer } // Echo endpoints allow 100/10/10; use the existing clamping mapper. echoSettings.EchoVoice = echoSettings.DefaultVoice voiceSettings["uppercase-echo"] = echoSpeechSettings(echoSettings) } for _, kind := range []string{"default", "echo", "uppercase", "uppercase-echo", "hyperlink", "system"} { values, exists := voiceSettings[kind] if !exists { continue } client := speech.NewWithSettings(speaker.socket, values) candidates[kind] = client if err := client.Connect(ctx); err != nil { closeClients(candidates) return err } } speaker.mu.Lock() defer speaker.mu.Unlock() if speaker.closed { closeClients(candidates) return errors.New("speech is closed") } if err := ctx.Err(); err != nil { closeClients(candidates) return err } for _, client := range speaker.voices { if err := client.Cancel(ctx); err != nil { closeClients(candidates) return err } } retired := speaker.voices speaker.voices = candidates _ = closeClients(retired) return nil } func (speaker *echoSpeaker) client(kind string) *speech.Client { if client := speaker.voices[kind]; client != nil { return client } return speaker.voices["default"] } func (speaker *echoSpeaker) Speak(ctx context.Context, text string) error { return speaker.SpeakVoice(ctx, text, "default") } func (speaker *echoSpeaker) SpeakVoice(ctx context.Context, text, kind string) error { speaker.mu.RLock() defer speaker.mu.RUnlock() client := speaker.client(kind) if client == nil { return errors.New("speech is not connected") } return client.Speak(ctx, text) } func (speaker *echoSpeaker) SpeakCharacter(ctx context.Context, text string) error { return speaker.speak_character(ctx, text, false) } func (speaker *echoSpeaker) SpeakEchoCharacter(ctx context.Context, text string) error { return speaker.speak_character(ctx, text, true) } func (speaker *echoSpeaker) speak_character(ctx context.Context, text string, custom bool) error { speaker.mu.RLock() defer speaker.mu.RUnlock() kind := "default" if custom { kind = "echo" } r, size := utf8.DecodeRuneInString(text) if size == len(text) && unicode.IsUpper(r) { kind = "uppercase" if custom && speaker.voices["uppercase-echo"] != nil { kind = "uppercase-echo" } } client := speaker.client(kind) if client == nil { return errors.New("speech is not connected") } return client.SpeakCharacter(ctx, text) } func (speaker *echoSpeaker) SpeakEcho(ctx context.Context, text string) error { return speaker.SpeakVoice(ctx, text, "echo") } func (speaker *echoSpeaker) SpeakAndWait(ctx context.Context, text string, began func()) error { return speaker.SpeakVoiceAndWait(ctx, text, "default", began) } func (speaker *echoSpeaker) speak_uninterruptible(ctx context.Context, text string) error { return speaker.SpeakVoiceAndWait(ctx, text, "system", nil) } func (speaker *echoSpeaker) SpeakVoiceAndWait(ctx context.Context, text, kind string, began func()) error { speaker.mu.RLock() client := speaker.client(kind) speaker.mu.RUnlock() if client == nil { return errors.New("speech is not connected") } return client.SpeakAndWait(ctx, text, began) } func (speaker *echoSpeaker) Cancel(ctx context.Context) error { speaker.mu.RLock() defer speaker.mu.RUnlock() var result error for _, client := range speaker.voices { result = errors.Join(result, client.Cancel(ctx)) } return result } func (speaker *echoSpeaker) Close(ctx context.Context) error { speaker.update.Lock() defer speaker.update.Unlock() speaker.mu.Lock() defer speaker.mu.Unlock() speaker.closed = true var result error for _, client := range speaker.voices { result = errors.Join(result, client.Close(ctx)) } speaker.voices = nil return result } func merge_echo_voice(voice *config.Voice, echo config.Voice) { if echo.Rate != nil { voice.Rate = echo.Rate } if echo.AveragePitch != nil { voice.AveragePitch = echo.AveragePitch } if echo.Gain != nil { voice.Gain = echo.Gain } if echo.Family != nil { voice.Family = echo.Family } } func echoSpeechSettings(settings config.Settings) speech.Settings { voice := settings.DefaultVoice if v := settings.EchoVoice.Rate; v != nil { voice.Rate = v } if v := settings.EchoVoice.AveragePitch; v != nil { voice.AveragePitch = v } if v := settings.EchoVoice.Gain; v != nil { voice.Gain = v } if v := settings.EchoVoice.Family; v != nil { voice.Family = v } // Cthulhu accepts UI endpoints 100/10/10 and clamps for SSIP. for _, field := range []struct { value **float64 max float64 }{{&voice.Rate, 99}, {&voice.AveragePitch, 9}, {&voice.Gain, 9}} { if *field.value != nil { value := min(**field.value, field.max) *field.value = &value } } settings.DefaultVoice = voice result := speechSettingsFromConfig(settings) if settings.UseCustomEchoSpeechServer { result.OutputModule = settings.EchoSpeechServer } return result } func customEcho(settings config.Settings, kind string) bool { if !settings.EnableKeyEcho || !settings.UseCustomEchoVoice { return false } switch kind { case "key": return settings.UseCustomEchoForKey case "character": return settings.UseCustomEchoForCharacter case "word": return settings.UseCustomEchoForWord case "sentence": return settings.UseCustomEchoForSentence } return false }