Files
cthulhu/internal/app/configured_speaker.go

296 lines
9.1 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"
"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) }