Files
cthulhu/internal/app/remote_speech_voice.go
ak47 49be8309fe Enable isolated Remote speech and protect reading exports
Attach incoming software speech to reader sessions with lease checks, local output priority and isolated cancellation. Preserve every document fragment source for privacy checks and export reviewed browse current-line reading, spelling and phonetic output.

Verified focused reading and Remote race tests, complete review and external Remote race suites, private relay integration, speech regressions, affected-package vet, reader build and Tentacles make check. Audible speech, physical input/Braille and live interoperability remain unverified.
2026-11-05 15:41:08 -05:00

136 lines
4.3 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"
"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 := client.SetBackgroundPriority(ctx); err != nil {
_ = client.Close(context.Background())
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
}