251 lines
7.4 KiB
Go
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
|
|
}
|