Files
cthulhu/internal/app/configured_speaker.go
ak47 ac431dd514 Add isolated Remote speech synthesis and typed rendering
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.
2026-11-04 19:56:38 -05:00

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