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