Files

251 lines
7.4 KiB
Go

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