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.
136 lines
4.3 KiB
Go
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
|
|
}
|