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.
109 lines
3.3 KiB
Go
109 lines
3.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"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/speech"
|
|
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
|
|
)
|
|
|
|
// One complete reader assembly. The future mailbox owner must apply priority
|
|
// once, recheck delivery policy and retain its budget until rendering finishes.
|
|
// This value grants no authority and owns no lease or mailbox budget.
|
|
type remoteSpeechUtterance struct {
|
|
priority int
|
|
fragments []speech.Fragment
|
|
}
|
|
|
|
func prepare_remote_utterance(frames []tentaclewire.RemoteSpeech) (remoteSpeechUtterance, error) {
|
|
var utterance remoteSpeechUtterance
|
|
if len(frames) == 0 || len(frames) > tentaclewire.RemoteSpeechQueueSize {
|
|
return utterance, errors.New("invalid Remote speech assembly")
|
|
}
|
|
utterance.priority = frames[0].Priority
|
|
bytes := 0
|
|
// Validate every frame and the combined budget before retaining text or
|
|
// exposing a prefix to synthesis. Cancel/pause belong to the stream owner.
|
|
for _, frame := range frames {
|
|
if !tentaclewire.ValidRemoteSpeech(frame) || frame.Kind != "speak" || frame.Priority != utterance.priority {
|
|
return remoteSpeechUtterance{}, errors.New("invalid Remote speech assembly")
|
|
}
|
|
encoded, _ := json.Marshal(frame)
|
|
bytes += len(encoded)
|
|
if bytes > tentaclewire.RemoteSpeechQueueBytes {
|
|
return remoteSpeechUtterance{}, errors.New("Remote speech assembly exceeds its budget")
|
|
}
|
|
}
|
|
var text strings.Builder
|
|
var state speech.Fragment
|
|
flush := func() {
|
|
if text.Len() != 0 {
|
|
state.Text = text.String()
|
|
utterance.fragments = append(utterance.fragments, state)
|
|
text.Reset()
|
|
}
|
|
}
|
|
for _, frame := range frames {
|
|
for _, part := range frame.Parts {
|
|
if part.End {
|
|
flush()
|
|
continue
|
|
}
|
|
if state.Characters != part.Characters || state.Language != part.Language {
|
|
flush()
|
|
}
|
|
state.Characters, state.Language = part.Characters, part.Language
|
|
text.WriteString(part.Text)
|
|
}
|
|
}
|
|
flush()
|
|
return utterance, nil
|
|
}
|
|
|
|
// SpeakUtteranceAndWait renders an already-assembled utterance. It does not
|
|
// authorize delivery or interpret NVDA stream priority. Its caller must do so
|
|
// before invoking it. The backend validates all literal fragments before I/O.
|
|
func (voice *remoteSpeechVoice) SpeakUtteranceAndWait(ctx context.Context, utterance remoteSpeechUtterance) error {
|
|
if !voice.render.TryLock() {
|
|
return errors.New("Remote utterance is already rendering")
|
|
}
|
|
defer voice.render.Unlock()
|
|
if err := voice.ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
request, cancel := context.WithCancel(voice.ctx)
|
|
stop := context.AfterFunc(ctx, cancel)
|
|
defer func() { stop(); cancel() }()
|
|
voice.request.Lock()
|
|
voice.renderCancel = cancel
|
|
voice.request.Unlock()
|
|
defer func() {
|
|
voice.request.Lock()
|
|
voice.renderCancel = nil
|
|
voice.request.Unlock()
|
|
}()
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if len(utterance.fragments) == 0 {
|
|
return nil
|
|
}
|
|
err := voice.client.SpeakFragmentsAndWait(request, utterance.fragments)
|
|
if err != nil {
|
|
// A rejected setting, interrupted block or cancelled fragment cannot
|
|
// seed the next assembly with partially changed metadata or output.
|
|
cleanup, finish := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer finish()
|
|
_ = voice.Close(cleanup)
|
|
}
|
|
return err
|
|
}
|