Forward numeric keypad identities with Num Lock enabled and preserve held-key pairing across lock changes. Suppress local lock feedback during remote capture so it cannot interrupt incoming speech. Add debug speech and capture-end diagnostics, expand private keypad checks, and repair the suspension probe source list. Validated affected Go packages, focused remote speech race checks, the private X11 Remote input harness, and a reader build. Live physical keyboard, audible PowerShell speech, Braille, and disconnect-cause verification remain pending; the active reader session was left running.
258 lines
8.7 KiB
Go
258 lines
8.7 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/tentacle"
|
|
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
|
|
)
|
|
|
|
type remoteSpeechStreamItem struct {
|
|
delivery tentacle.RemoteSpeechDelivery
|
|
utterance remoteSpeechUtterance
|
|
bytes int
|
|
context context.Context
|
|
cleanup func()
|
|
current func() bool
|
|
}
|
|
|
|
type remoteSpeechPermit struct {
|
|
context context.Context
|
|
current func() bool // Synchronous generation check, before cancellation callbacks run.
|
|
release func()
|
|
}
|
|
|
|
type remoteSpeechAdmission func(tentacle.RemoteSpeechDelivery) (remoteSpeechPermit, bool)
|
|
|
|
type remoteSpeechStreamError struct {
|
|
lease tentaclewire.RemoteLease
|
|
cause error
|
|
}
|
|
|
|
func (err *remoteSpeechStreamError) Error() string { return err.cause.Error() }
|
|
func (err *remoteSpeechStreamError) Unwrap() error { return err.cause }
|
|
|
|
// run_remote_speech_playback supplies the isolated backend to the stream owner.
|
|
// The caller supplies original-lease policy and optional local-output admission.
|
|
func run_remote_speech_playback(ctx context.Context, speaker *configuredSpeaker,
|
|
incoming <-chan tentacle.RemoteSpeechDelivery, allow func(tentacle.RemoteSpeechDelivery) bool,
|
|
admit remoteSpeechAdmission, logger *slog.Logger) error {
|
|
if speaker == nil || allow == nil {
|
|
return errors.New("Remote speech playback requires a backend and delivery policy")
|
|
}
|
|
var voice *remoteSpeechVoice // Only the renderer accesses it until run joins.
|
|
var voiceLease tentaclewire.RemoteLease
|
|
var stopLease func() bool
|
|
closeVoice := func() {
|
|
if stopLease != nil {
|
|
stopLease()
|
|
}
|
|
if voice != nil {
|
|
cleanup, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
_ = voice.Close(cleanup)
|
|
}
|
|
}
|
|
defer closeVoice()
|
|
return run_remote_speech_stream(ctx, incoming, allow, admit, func(request context.Context, delivery tentacle.RemoteSpeechDelivery, utterance remoteSpeechUtterance) error {
|
|
if voice == nil || voice.ctx.Err() != nil || voiceLease != delivery.Lease {
|
|
closeVoice()
|
|
var err error
|
|
voice, err = speaker.open_remote_voice(request)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
voiceLease = delivery.Lease
|
|
owned := voice
|
|
// Even an idle handle belongs to its original lease. Close it on
|
|
// revoke, on a backend worker rather than the policy/input caller.
|
|
stopLease = context.AfterFunc(delivery.Context, func() {
|
|
cleanup, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
_ = owned.Close(cleanup)
|
|
})
|
|
}
|
|
// Connecting/querying the backend can take time. Admission cannot
|
|
// substitute for the original authority at synthesis handoff.
|
|
if request.Err() != nil {
|
|
return request.Err()
|
|
}
|
|
if !allow(delivery) {
|
|
return errors.New("Remote speech delivery policy changed")
|
|
}
|
|
if logger != nil && logger.Enabled(request, slog.LevelDebug) {
|
|
for _, fragment := range utterance.fragments {
|
|
logger.DebugContext(request, "delivering remote speech", "utterance", fragment.Text,
|
|
"characters", fragment.Characters, "language", fragment.Language, "priority", utterance.priority)
|
|
}
|
|
}
|
|
err := voice.SpeakUtteranceAndWait(request, utterance)
|
|
if err != nil && request.Err() == nil && logger != nil {
|
|
logger.Debug("remote speech rendering failed", "error", err)
|
|
}
|
|
return err
|
|
})
|
|
}
|
|
|
|
// run_remote_speech_stream retains complete deliveries until cleanup. allow must
|
|
// recheck original lease, focus and output policy without backend I/O. render
|
|
// must honor cancellation, recheck policy after acquisition, and complete scoped
|
|
// backend cleanup before returning. Neither callback may observe this output.
|
|
// The caller must retire the session on error. A dequeue retains mailbox budget
|
|
// until completion/discard, including while a cancelled renderer is closing.
|
|
func run_remote_speech_stream(ctx context.Context, incoming <-chan tentacle.RemoteSpeechDelivery,
|
|
allow func(tentacle.RemoteSpeechDelivery) bool,
|
|
admit remoteSpeechAdmission,
|
|
render func(context.Context, tentacle.RemoteSpeechDelivery, remoteSpeechUtterance) error) error {
|
|
if allow == nil || render == nil {
|
|
return errors.New("Remote speech owner requires delivery policy and renderer")
|
|
}
|
|
var pending []*remoteSpeechStreamItem
|
|
var active *remoteSpeechStreamItem
|
|
var finished chan error
|
|
var cancel context.CancelFunc
|
|
var activeContext context.Context
|
|
var lease tentaclewire.RemoteLease
|
|
var leaseDone <-chan struct{}
|
|
items, bytes := 0, 0
|
|
valid := func(delivery tentacle.RemoteSpeechDelivery) bool {
|
|
return ctx.Err() == nil && delivery.Context != nil && delivery.Context.Err() == nil && allow(delivery)
|
|
}
|
|
release := func(item *remoteSpeechStreamItem) {
|
|
item.cleanup()
|
|
items -= len(item.delivery.Speeches)
|
|
bytes -= item.bytes
|
|
item.delivery.Release()
|
|
}
|
|
discard := func() {
|
|
for _, item := range pending {
|
|
release(item)
|
|
}
|
|
pending = nil
|
|
}
|
|
interrupt := func() {
|
|
discard()
|
|
if cancel != nil {
|
|
cancel()
|
|
}
|
|
}
|
|
defer func() {
|
|
interrupt()
|
|
if active != nil {
|
|
<-finished // Renderer owns cleanup; no replacement can overtake it.
|
|
release(active)
|
|
}
|
|
}()
|
|
for {
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
for active == nil && len(pending) != 0 {
|
|
item := pending[0]
|
|
pending[0] = nil
|
|
pending = pending[1:]
|
|
if !valid(item.delivery) || item.context.Err() != nil || !item.current() {
|
|
release(item)
|
|
continue
|
|
}
|
|
request, stop := context.WithCancel(item.context)
|
|
cancel, active, activeContext, finished = stop, item, request, make(chan error, 1)
|
|
go func(result chan<- error) { result <- render(request, item.delivery, item.utterance) }(finished)
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-leaseDone:
|
|
interrupt()
|
|
leaseDone = nil
|
|
case err := <-finished:
|
|
// A deliberate stream interruption is not a backend failure. Never
|
|
// retry its suffix; the renderer may have spoken a prefix already.
|
|
interrupted := activeContext.Err() != nil || !active.current()
|
|
failedLease := active.delivery.Lease
|
|
cancel()
|
|
release(active)
|
|
active, cancel, finished = nil, nil, nil
|
|
if err != nil && !interrupted && !errors.Is(err, context.Canceled) {
|
|
return &remoteSpeechStreamError{lease: failedLease, cause: errors.New("Remote speech renderer failed")}
|
|
}
|
|
case delivery, ok := <-incoming:
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if delivery.Release == nil {
|
|
return errors.New("Remote speech delivery has no budget owner")
|
|
}
|
|
if !valid(delivery) {
|
|
delivery.Release()
|
|
continue
|
|
}
|
|
if lease != delivery.Lease {
|
|
interrupt()
|
|
lease, leaseDone = delivery.Lease, delivery.Context.Done()
|
|
}
|
|
if len(delivery.Speeches) == 1 {
|
|
effect := delivery.Speeches[0]
|
|
if tentaclewire.ValidRemoteSpeech(effect) && (effect.Kind == "cancel" || effect.Kind == "pause") {
|
|
if effect.Kind == "cancel" || *effect.Paused {
|
|
interrupt()
|
|
}
|
|
// Pause cancels the current assembly because this backend
|
|
// cannot resume it. It must not mute future utterances: NVDA
|
|
// clears pause on cancel or fresh speech, without requiring
|
|
// a separate pause(false) message on the relay.
|
|
delivery.Release()
|
|
continue
|
|
}
|
|
}
|
|
utterance, err := prepare_remote_utterance(delivery.Speeches)
|
|
if err != nil {
|
|
delivery.Release()
|
|
return &remoteSpeechStreamError{lease: delivery.Lease, cause: err}
|
|
}
|
|
permit := remoteSpeechPermit{context: delivery.Context, release: func() {}, current: func() bool { return true }}
|
|
if admit != nil {
|
|
admitted, ok := admit(delivery)
|
|
if !ok {
|
|
delivery.Release()
|
|
continue
|
|
}
|
|
permit = admitted
|
|
}
|
|
item := &remoteSpeechStreamItem{delivery: delivery, utterance: utterance, context: permit.context, cleanup: permit.release, current: permit.current}
|
|
for _, frame := range delivery.Speeches {
|
|
encoded, _ := json.Marshal(frame)
|
|
item.bytes += len(encoded)
|
|
}
|
|
if items+len(delivery.Speeches) > tentaclewire.RemoteSpeechQueueSize || bytes+item.bytes > tentaclewire.RemoteSpeechQueueBytes {
|
|
permit.release()
|
|
delivery.Release()
|
|
return &remoteSpeechStreamError{lease: delivery.Lease, cause: errors.New("Remote speech owner budget exceeded")}
|
|
}
|
|
items += len(delivery.Speeches)
|
|
bytes += item.bytes
|
|
// NEXT waits for the current assembly. NOW interrupts only lower
|
|
// priority work; equal priorities remain FIFO. Interrupted speech
|
|
// is discarded, because this backend cannot resume without replay.
|
|
if utterance.priority == 2 && active != nil && active.utterance.priority < 2 {
|
|
cancel()
|
|
}
|
|
position := len(pending)
|
|
for position > 0 && pending[position-1].utterance.priority < utterance.priority {
|
|
position--
|
|
}
|
|
pending = append(pending, nil)
|
|
copy(pending[position+1:], pending[position:])
|
|
pending[position] = item
|
|
}
|
|
}
|
|
}
|