Preserve literal whitespace and resolved spelling/language state across complete Remote speech assemblies. Isolate cancellation from local status and echo, retire handles on configuration changes or rendering failure, and cover metadata preparation cancellation with private SSIP fixtures. Verified focused application and backend race tests, ten repetitions of isolated-client/rendering cases, shared speech regressions, affected-package vet, reader build and Tentacles checks. Incoming mailbox integration, local priority and live audible acceptance remain pending.
319 lines
9.6 KiB
Go
319 lines
9.6 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
|
|
remote *remoteSpeechVoice
|
|
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()
|
|
speaker.retire_remote_voice(settings)
|
|
return nil
|
|
}
|
|
if software, ok := current.(*echoSpeaker); ok && settings.SpeechServerFactory != "hardwarefactory" {
|
|
if err := software.UpdateConfiguration(ctx, settings); err != nil {
|
|
return err
|
|
}
|
|
speaker.retire_remote_voice(settings)
|
|
return nil
|
|
}
|
|
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.mu.Unlock()
|
|
speaker.retire_remote_voice(settings)
|
|
// 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
|
|
remote := speaker.remote
|
|
speaker.mu.RUnlock()
|
|
var result error
|
|
if voice != nil {
|
|
result = voice.Cancel(ctx)
|
|
}
|
|
if remote != nil {
|
|
result = errors.Join(result, remote.Cancel(ctx))
|
|
}
|
|
return result
|
|
}
|
|
func (speaker *configuredSpeaker) Close(ctx context.Context) error {
|
|
speaker.update.Lock()
|
|
defer speaker.update.Unlock()
|
|
speaker.mu.Lock()
|
|
voice := speaker.active
|
|
remote := speaker.remote
|
|
speaker.active, speaker.closed = nil, true
|
|
speaker.remote = nil
|
|
speaker.mu.Unlock()
|
|
var result error
|
|
if remote != nil {
|
|
result = remote.Close(ctx)
|
|
}
|
|
if voice == nil {
|
|
return result
|
|
}
|
|
return errors.Join(result, 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 {
|
|
if kind == "echo" {
|
|
return speaker.SpeakEcho(ctx, text)
|
|
}
|
|
if kind == "uppercase-echo" {
|
|
return speaker.speak(ctx, text, true, "uppercase")
|
|
}
|
|
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) }
|