// 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 }