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:
1 parent
622d2f96a1
commit
ac431dd514
9 files changed
+1370
-11
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user