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.
This commit is contained in:
ak47 committed 2026-11-04 19:56:38 -05:00
1 parent 622d2f96a1
commit ac431dd514
9 files changed
+1370 -11

No files matched your search

+131
View File
@@ -0,0 +1,131 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"errors"
"sync"
"time"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/speech"
)
// remoteSpeechVoice owns a separate SSIP speaking/control pair. Its cancellation
// cannot target local speech, echo or status. This is a backend prerequisite,
// not a Remote lease authorizer or a production mailbox owner.
type remoteSpeechVoice struct {
client *speech.Client
ctx context.Context
cancel context.CancelFunc
once sync.Once
closed chan struct{}
closeErr error
render sync.Mutex // One typed assembly, including metadata I/O.
request sync.Mutex
renderCancel context.CancelFunc // Protected by request; never backend I/O.
}
// open_remote_voice is called only by an output worker, never an input callback.
// Acquisition neither changes the local configuration nor opens a serial device.
// There is at most one handle; a replacement requires its predecessor to close.
// ctx bounds acquisition only. A future lease owner must Close on revocation.
func (speaker *configuredSpeaker) open_remote_voice(ctx context.Context) (*remoteSpeechVoice, error) {
if !speaker.update.TryLock() {
return nil, errors.New("speech configuration is busy")
}
defer speaker.update.Unlock()
if err := ctx.Err(); err != nil {
return nil, err
}
speaker.mu.RLock()
_, software := speaker.active.(*echoSpeaker)
closed, previous := speaker.closed, speaker.remote
settings := speechSettingsFromConfig(speaker.settings)
speaker.mu.RUnlock()
if closed || !software {
return nil, errors.New("isolated Remote speech requires a connected software backend")
}
if previous != nil {
select {
case <-previous.closed:
default:
return nil, errors.New("Remote speech voice is already owned")
}
}
client := speech.NewWithSettings(speaker.socket, settings)
if err := client.Connect(ctx); err != nil {
return nil, err
}
if err := ctx.Err(); err != nil {
_ = client.Close(context.Background())
return nil, err
}
lifetime, cancel := context.WithCancel(context.Background())
voice := &remoteSpeechVoice{client: client, ctx: lifetime, cancel: cancel, closed: make(chan struct{})}
speaker.mu.Lock()
speaker.remote = voice
speaker.mu.Unlock()
return voice, nil
}
// A successful configuration commit retires the old remote voice. A failed
// preparation must leave it usable. Handles never silently adopt new settings.
func (speaker *configuredSpeaker) retire_remote_voice(settings config.Settings) {
speaker.mu.Lock()
speaker.settings = settings
remote := speaker.remote
speaker.mu.Unlock()
if remote != nil {
cleanup, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
// The local configuration has committed; cleanup cannot undo that commit.
_ = remote.Close(cleanup)
}
}
// SpeakAndWait retains ownership through the backend's END/CANCEL notification.
// Both caller cancellation and configuration retirement cancel only this client.
// This plain-text primitive deliberately does not interpret relay metadata.
func (voice *remoteSpeechVoice) SpeakAndWait(ctx context.Context, text string, began func()) error {
if err := voice.ctx.Err(); err != nil {
return err
}
request, cancel := context.WithCancel(voice.ctx)
stop := context.AfterFunc(ctx, cancel)
defer func() { stop(); cancel() }()
if err := ctx.Err(); err != nil {
return err
}
return voice.client.SpeakAndWait(request, text, began)
}
func (voice *remoteSpeechVoice) Cancel(ctx context.Context) error {
voice.request.Lock()
cancel := voice.renderCancel
if cancel != nil {
cancel()
}
voice.request.Unlock()
if cancel != nil {
// Typed cancellation retires the whole assembly even while it is
// preparing metadata and has no SSIP message to cancel yet.
return nil
}
if voice.ctx.Err() != nil {
return nil
}
return voice.client.Cancel(ctx)
}
func (voice *remoteSpeechVoice) Close(ctx context.Context) error {
voice.once.Do(func() {
voice.cancel()
voice.closeErr = errors.Join(voice.client.Cancel(ctx), voice.client.Close(ctx))
close(voice.closed)
})
return voice.closeErr
}