Files
cthulhu/internal/app/remote_speech_render.go
ak47 ac431dd514 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.
2026-11-04 19:56:38 -05:00

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
}