// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "errors" "sync" "unicode" "unicode/utf8" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/speech" ) type configuredVoice interface { Speaker SpeakCharacter(context.Context, string) error SpeakEcho(context.Context, string) error SpeakAndWait(context.Context, string, func()) error SpeakVoice(context.Context, string, string) error SpeakVoiceAndWait(context.Context, string, string, func()) error SpeakEchoCharacter(context.Context, string) error } // Keep backend replacement on the preferences worker. Input cancellation can // still reach the active backend while its replacement connects. type configuredSpeaker struct { mu sync.RWMutex update sync.Mutex active configuredVoice socket string settings config.Settings closed bool } func newConfiguredSpeaker(socket string, settings config.Settings) *configuredSpeaker { return &configuredSpeaker{socket: socket, settings: settings} } func (speaker *configuredSpeaker) Connect(ctx context.Context) error { return speaker.UpdateConfiguration(ctx, speaker.settings) } func (speaker *configuredSpeaker) 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() if err := config.Validate(settings); err != nil { return err } speaker.mu.RLock() current, closed := speaker.active, speaker.closed speaker.mu.RUnlock() if closed { return errors.New("speech is closed") } // Reuse the existing descriptor when only serial voice parameters change. if hardware, ok := current.(*hardwareSpeaker); ok && settings.SpeechServerFactory == "hardwarefactory" && hardware.device == settings.HardwareSpeechDevice && hardware.baud == settings.HardwareSpeechBaudRate && hardware.synth == settings.SpeechOutputModule { if err := hardware.Cancel(ctx); err != nil { return err } hardware.mu.Lock() if err := ctx.Err(); err != nil { hardware.mu.Unlock() return err } hardware.settings = settings hardware.mu.Unlock() return nil } if software, ok := current.(*echoSpeaker); ok && settings.SpeechServerFactory != "hardwarefactory" { return software.UpdateConfiguration(ctx, settings) } var candidate configuredVoice if settings.SpeechServerFactory == "hardwarefactory" { candidate = &hardwareSpeaker{device: settings.HardwareSpeechDevice, baud: settings.HardwareSpeechBaudRate, synth: settings.SpeechOutputModule, settings: settings} } else { candidate = newEchoSpeaker(speaker.socket, settings) } if err := candidate.Connect(ctx); err != nil { return err } speaker.mu.Lock() if speaker.closed || ctx.Err() != nil { speaker.mu.Unlock() candidate.Close(context.Background()) return errors.New("speech configuration was canceled") } if current != nil { if err := current.Cancel(ctx); err != nil { speaker.mu.Unlock() candidate.Close(context.Background()) return err } } speaker.active = candidate speaker.settings = settings speaker.mu.Unlock() // The new backend is committed. A retired connection's cleanup error must // not report this successful swap as a rejected preferences transaction. if current != nil { _ = current.Close(context.Background()) } return nil } func (speaker *configuredSpeaker) voice() (configuredVoice, error) { speaker.mu.RLock() defer speaker.mu.RUnlock() if speaker.active == nil || speaker.closed { return nil, errors.New("speech is not connected") } return speaker.active, nil } func (speaker *configuredSpeaker) Speak(ctx context.Context, text string) error { voice, err := speaker.voice() if err != nil { return err } return voice.Speak(ctx, text) } func (speaker *configuredSpeaker) SpeakEcho(ctx context.Context, text string) error { voice, err := speaker.voice() if err != nil { return err } return voice.SpeakEcho(ctx, text) } func (speaker *configuredSpeaker) SpeakVoice(ctx context.Context, text, kind string) error { voice, err := speaker.voice() if err != nil { return err } return voice.SpeakVoice(ctx, text, kind) } func (speaker *configuredSpeaker) SpeakVoiceAndWait(ctx context.Context, text, kind string, began func()) error { voice, err := speaker.voice() if err != nil { return err } return voice.SpeakVoiceAndWait(ctx, text, kind, began) } func (speaker *configuredSpeaker) SpeakEchoCharacter(ctx context.Context, text string) error { voice, err := speaker.voice() if err != nil { return err } return voice.SpeakEchoCharacter(ctx, text) } func (speaker *configuredSpeaker) SpeakCharacter(ctx context.Context, text string) error { voice, err := speaker.voice() if err != nil { return err } return voice.SpeakCharacter(ctx, text) } func (speaker *configuredSpeaker) SpeakAndWait(ctx context.Context, text string, began func()) error { voice, err := speaker.voice() if err != nil { return err } return voice.SpeakAndWait(ctx, text, began) } func (speaker *configuredSpeaker) speak_uninterruptible(ctx context.Context, text string) error { voice, err := speaker.voice() if err != nil { return err } reader, ok := voice.(interface { speak_uninterruptible(context.Context, string) error }) if !ok { return errors.New("speech backend does not report utterance completion") } return reader.speak_uninterruptible(ctx, text) } func (speaker *configuredSpeaker) Cancel(ctx context.Context) error { speaker.mu.RLock() voice := speaker.active speaker.mu.RUnlock() if voice == nil { return nil } return voice.Cancel(ctx) } func (speaker *configuredSpeaker) Close(ctx context.Context) error { speaker.update.Lock() defer speaker.update.Unlock() speaker.mu.Lock() voice := speaker.active speaker.active, speaker.closed = nil, true speaker.mu.Unlock() if voice == nil { return nil } return voice.Close(ctx) } type hardwareSpeaker struct { mu sync.RWMutex device, synth string baud int client *speech.Hardware settings config.Settings } func (speaker *hardwareSpeaker) Connect(ctx context.Context) error { if err := ctx.Err(); err != nil { return err } client, err := speech.NewHardware(speaker.device, speaker.synth, speaker.baud) if err != nil { return err } speaker.client = client return nil } func (speaker *hardwareSpeaker) Speak(ctx context.Context, text string) error { return speaker.speak(ctx, text, false, "default") } func (speaker *hardwareSpeaker) SpeakEcho(ctx context.Context, text string) error { return speaker.speak(ctx, text, true, "default") } func (speaker *hardwareSpeaker) SpeakCharacter(ctx context.Context, text string) error { return speaker.speak_character(ctx, text, false) } func (speaker *hardwareSpeaker) SpeakEchoCharacter(ctx context.Context, text string) error { return speaker.speak_character(ctx, text, true) } func (speaker *hardwareSpeaker) speak_character(ctx context.Context, text string, echo bool) error { r, size := utf8.DecodeRuneInString(text) kind := "default" if size == len(text) && unicode.IsUpper(r) { kind = "uppercase" } return speaker.speak(ctx, text, echo, kind) } func (speaker *hardwareSpeaker) SpeakVoice(ctx context.Context, text, kind string) error { return speaker.speak(ctx, text, false, kind) } func (speaker *hardwareSpeaker) speak(ctx context.Context, text string, echo bool, kind string) error { speaker.mu.RLock() defer speaker.mu.RUnlock() settings := speaker.settings voice := config.EffectiveVoice(settings, "default") if echo && settings.UseCustomEchoVoice { if value := settings.EchoVoice.Rate; value != nil { voice.Rate = value } if value := settings.EchoVoice.AveragePitch; value != nil { voice.AveragePitch = value } if value := settings.EchoVoice.Gain; value != nil { voice.Gain = value } } settings.DefaultVoice = voice voice = config.EffectiveVoice(settings, kind) // Hardware has a single serial engine; echo uses that same synth with its // custom numeric voice settings. Restore the default values each utterance. if voice.Rate == nil { value := 50.0 voice.Rate = &value } if voice.AveragePitch == nil { value := 5.0 voice.AveragePitch = &value } if voice.Gain == nil { value := 5.0 voice.Gain = &value } return speaker.client.Speak(ctx, text, speech.HardwareVoice{Rate: voice.Rate, Pitch: voice.AveragePitch, Volume: voice.Gain}) } func (speaker *hardwareSpeaker) SpeakAndWait(ctx context.Context, text string, began func()) error { return speaker.SpeakVoiceAndWait(ctx, text, "default", began) } func (speaker *hardwareSpeaker) SpeakVoiceAndWait(ctx context.Context, text, kind string, began func()) error { // These protocols provide no utterance-completion event. Match Python's // queued Say All behavior; progress denotes transmission, not audible end. if err := speaker.SpeakVoice(ctx, text, kind); err != nil { return err } if began != nil { began() } return nil } func (speaker *hardwareSpeaker) Cancel(ctx context.Context) error { return speaker.client.Cancel(ctx) } func (speaker *hardwareSpeaker) Close(ctx context.Context) error { return speaker.client.Close(ctx) }