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.
This commit is contained in:
1 parent
622d2f96a1
commit
ac431dd514
9 files changed
+1370
-11
No files matched your search
@@ -138,6 +138,120 @@ warnings remain in place.
|
||||
|
||||
## Verification and remaining work
|
||||
|
||||
### Isolated synthesis prerequisite
|
||||
|
||||
The configured software backend now has an internal `open_remote_voice`
|
||||
primitive. It lazily creates one independent Speech Dispatcher speaking/control
|
||||
pair using the last successfully committed default voice settings. Ordinary
|
||||
reader startup creates no additional connections. Hardware and unconnected or
|
||||
closed backends reject acquisition; another acquisition is rejected until the
|
||||
previous handle has finished closing. This is not a lease authorizer and is not
|
||||
attached to the production incoming mailbox.
|
||||
|
||||
The handle offers plain-text `SpeakAndWait`, scoped `Cancel`, and idempotent
|
||||
`Close`. Speech remains outstanding through END/CANCEL, rather than just queue
|
||||
acknowledgement. Caller cancellation affects only the isolated client. Acquisition
|
||||
context cancellation after a successful open does not retire that handle: a
|
||||
future lease owner must explicitly close it on revoke, policy loss and shutdown.
|
||||
Core user interruption cancels both local output and this separate client.
|
||||
|
||||
A successful settings commit retires the old handle; it never silently adopts
|
||||
the replacement configuration. Failed preparation preserves it. Closing an old
|
||||
handle cannot cancel its replacement. Configuration updates now also retain the
|
||||
committed settings snapshot when reusing a software backend, so subsequent
|
||||
acquisition uses the current module and default voice rather than startup values.
|
||||
|
||||
Private Unix-socket SSIP tests keep local status and custom echo queued while
|
||||
remote cancellation, caller cancellation and handle closure run. They check
|
||||
independent client identities, END/CANCEL pacing, connection reuse, exclusive
|
||||
ownership, configuration success/failure and stale cleanup. These model protocol
|
||||
ownership; they do not establish real Speech Dispatcher scheduling, audible
|
||||
cancellation, or priority arbitration. The original plain-text primitive retains
|
||||
ordinary normalization. The typed renderer below adds whitespace and
|
||||
character/language handling; pause and NVDA priority still need stream policy.
|
||||
|
||||
No command invokes this primitive yet. The rendering prerequisite below adds
|
||||
typed utterances; binding them to the bounded reader mailbox still requires
|
||||
delivery-time lease/focus checks and local priority. Keep mailbox budgets until
|
||||
delivery/discard and retire queued work on cancellation. No live Remote speech
|
||||
acceptance test is available from these prerequisites alone.
|
||||
|
||||
### Literal utterance rendering prerequisite
|
||||
|
||||
`prepare_remote_utterance` now validates a complete speak assembly, retaining its
|
||||
single priority for a future stream owner. It rejects non-speak effects, mixed
|
||||
priorities and assemblies beyond the existing 64-frame/256-KiB limits. It joins
|
||||
adjacent text with the same language/spelling state across IPC fragment
|
||||
boundaries without adding separators. An end marker or metadata change creates
|
||||
a synthesis boundary; repeated empty end markers produce no speech. It neither
|
||||
owns nor releases mailbox budget and cannot authorize presentation.
|
||||
|
||||
The isolated handle's `SpeakUtteranceAndWait` renders that prepared sequence
|
||||
through a new bounded `speech.Client.SpeakFragmentsAndWait` path. Every literal
|
||||
fragment is checked before backend I/O. Invalid UTF-8, NUL, C0 controls other than
|
||||
tab/CR/LF, DEL and invalid language tokens reject the whole sequence without a
|
||||
spoken prefix. Spaces and tabs remain intact; CR, LF and CRLF become SSIP CRLF
|
||||
line boundaries. Every dot-prefixed line is escaped, including a lone dot.
|
||||
`SSML_MODE off` makes markup and command-looking text literal data. This changes
|
||||
no ordinary local-text normalization.
|
||||
|
||||
Each fragment uses `SPELLING on/off` and its resolved language. Before rendering,
|
||||
the client reads its effective default language, output module and symbolic voice
|
||||
category; unsupported/malformed defaults fail closed. It restores the original
|
||||
module after every language change, because Speech Dispatcher language mappings
|
||||
may change modules. Remote language overrides clear a configured named voice and
|
||||
use the captured symbolic category. If a named voice has no category, explicit
|
||||
remote languages use SSIP category MALE1; default-language parts restore the
|
||||
configured voice name. Unknown categories without a configured named voice are
|
||||
rejected. The accepted module/default-language tokens are limited to 64 ASCII
|
||||
letters, digits, hyphens or underscores. Backend acceptance of a language request
|
||||
does not establish that the module can synthesize that language.
|
||||
|
||||
The speaking connection remains owned through each END/CANCEL and the complete
|
||||
sequence. Success restores default voice/language and spelling off. A backend
|
||||
failure or cancellation discards the remaining suffix and retires the isolated
|
||||
handle, cancelling only its client. A replacement requires explicit acquisition;
|
||||
there is no automatic retry or replay. Atomic IPC assembly does not make several
|
||||
backend fragments atomic: a later backend rejection can follow an already-spoken
|
||||
earlier fragment.
|
||||
|
||||
Only one typed assembly can render at a time. Scoped cancellation cancels its
|
||||
context as well as outstanding synthesis, so a cancel during a stalled metadata
|
||||
query cannot be lost before the first SPEAK. It never waits for metadata I/O in
|
||||
the caller. The renderer completes cleanup before returning ownership.
|
||||
|
||||
This implementation follows the [SSIP speech, spelling and language
|
||||
contract](https://github.com/brailcom/speechd/blob/master/doc/ssip.texi) and checks
|
||||
default-query/module-switch behavior against the upstream
|
||||
[parser](https://github.com/brailcom/speechd/blob/master/src/server/parse.c) and
|
||||
[settings implementation](https://github.com/brailcom/speechd/blob/master/src/server/set.c).
|
||||
It makes no NVDA priority or pause decision. A production owner still has to
|
||||
apply priority once, recheck original-lease/focus/mode policy, yield to local
|
||||
output, process cancel/pause while synthesis is outstanding, and retain mailbox
|
||||
budget until completion/discard. The source Remote command remains relay-only
|
||||
for incoming speech; no active or installed reader was replaced.
|
||||
|
||||
### Automated evidence
|
||||
|
||||
For the isolated synthesis prerequisite, focused application/backend race checks
|
||||
passed, including ten repetitions of the new cases. Existing speech regression
|
||||
checks, affected-package vet, a separate reader build and Tentacles `make check`
|
||||
also passed. No installed or active reader was replaced, and no audible or
|
||||
physical-input acceptance was performed.
|
||||
|
||||
The literal rendering increment passed the complete speech-backend race suite
|
||||
and ten repetitions of the isolated-client/rendering race cases. Private SSIP
|
||||
fixtures cover exact whitespace, mixed CR/LF, dot-only/command-looking lines,
|
||||
markup as literal text, repeated-character spelling, language/module/default
|
||||
restoration, named-voice fallback, long split text, end boundaries, assembly
|
||||
limits, invalid later fragments, unavailable defaults, rejected settings and
|
||||
remote/caller/configuration cancellation with local status/echo retained. The
|
||||
stalled-metadata cancellation case also rejects overlapping typed assemblies
|
||||
and verifies that neither a prefix nor suffix reaches synthesis. The
|
||||
existing speech regression harness, affected-package vet, separate reader build
|
||||
and Tentacles `make check` passed. These are protocol fixtures, without real
|
||||
Speech Dispatcher, audio, physical input or Braille acceptance.
|
||||
|
||||
Run from this checkout:
|
||||
|
||||
```sh
|
||||
@@ -172,8 +286,8 @@ host/wire/both-SDK race suites (host: about 127 seconds), focused application
|
||||
speech/Remote/Tentacle race checks, affected-package vet, a separate reader
|
||||
build, the existing speech regression harness and Tentacles `make check`.
|
||||
|
||||
Next: add incoming speech ownership, delivery-time policy, local priority and
|
||||
remote-only cancellation. Explicit status detachment is implemented; physical
|
||||
Next: bind the isolated renderer to incoming speech ownership, delivery-time
|
||||
policy, local priority and stream cancellation. Explicit status detachment is implemented; physical
|
||||
input capture/release and ordinary reading while connected still need acceptance.
|
||||
Review the remaining output producers before assigning exportable origins.
|
||||
Atomic IPC assembly is implemented; it does not establish backend
|
||||
|
||||
@@ -31,6 +31,7 @@ type configuredSpeaker struct {
|
||||
mu sync.RWMutex
|
||||
update sync.Mutex
|
||||
active configuredVoice
|
||||
remote *remoteSpeechVoice
|
||||
socket string
|
||||
settings config.Settings
|
||||
closed bool
|
||||
@@ -70,10 +71,15 @@ func (speaker *configuredSpeaker) UpdateConfiguration(ctx context.Context, setti
|
||||
}
|
||||
hardware.settings = settings
|
||||
hardware.mu.Unlock()
|
||||
speaker.retire_remote_voice(settings)
|
||||
return nil
|
||||
}
|
||||
if software, ok := current.(*echoSpeaker); ok && settings.SpeechServerFactory != "hardwarefactory" {
|
||||
return software.UpdateConfiguration(ctx, settings)
|
||||
if err := software.UpdateConfiguration(ctx, settings); err != nil {
|
||||
return err
|
||||
}
|
||||
speaker.retire_remote_voice(settings)
|
||||
return nil
|
||||
}
|
||||
var candidate configuredVoice
|
||||
if settings.SpeechServerFactory == "hardwarefactory" {
|
||||
@@ -99,8 +105,8 @@ func (speaker *configuredSpeaker) UpdateConfiguration(ctx context.Context, setti
|
||||
}
|
||||
}
|
||||
speaker.active = candidate
|
||||
speaker.settings = settings
|
||||
speaker.mu.Unlock()
|
||||
speaker.retire_remote_voice(settings)
|
||||
// The new backend is committed. A retired connection's cleanup error must
|
||||
// not report this successful swap as a rejected preferences transaction.
|
||||
if current != nil {
|
||||
@@ -182,23 +188,34 @@ func (speaker *configuredSpeaker) speak_uninterruptible(ctx context.Context, tex
|
||||
func (speaker *configuredSpeaker) Cancel(ctx context.Context) error {
|
||||
speaker.mu.RLock()
|
||||
voice := speaker.active
|
||||
remote := speaker.remote
|
||||
speaker.mu.RUnlock()
|
||||
if voice == nil {
|
||||
return nil
|
||||
var result error
|
||||
if voice != nil {
|
||||
result = voice.Cancel(ctx)
|
||||
}
|
||||
return voice.Cancel(ctx)
|
||||
if remote != nil {
|
||||
result = errors.Join(result, remote.Cancel(ctx))
|
||||
}
|
||||
return result
|
||||
}
|
||||
func (speaker *configuredSpeaker) Close(ctx context.Context) error {
|
||||
speaker.update.Lock()
|
||||
defer speaker.update.Unlock()
|
||||
speaker.mu.Lock()
|
||||
voice := speaker.active
|
||||
remote := speaker.remote
|
||||
speaker.active, speaker.closed = nil, true
|
||||
speaker.remote = nil
|
||||
speaker.mu.Unlock()
|
||||
if voice == nil {
|
||||
return nil
|
||||
var result error
|
||||
if remote != nil {
|
||||
result = remote.Close(ctx)
|
||||
}
|
||||
return voice.Close(ctx)
|
||||
if voice == nil {
|
||||
return result
|
||||
}
|
||||
return errors.Join(result, voice.Close(ctx))
|
||||
}
|
||||
|
||||
type hardwareSpeaker struct {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/config"
|
||||
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
|
||||
)
|
||||
|
||||
func remote_render_frame(priority int, parts ...tentaclewire.RemoteSpeechPart) tentaclewire.RemoteSpeech {
|
||||
return tentaclewire.RemoteSpeech{Kind: "speak", Priority: priority, Parts: parts}
|
||||
}
|
||||
|
||||
func (server *isolatedVoiceServer) next_utterance(t *testing.T) isolatedVoiceUtterance {
|
||||
t.Helper()
|
||||
select {
|
||||
case utterance := <-server.queued:
|
||||
return utterance
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("synthesis did not receive a fragment")
|
||||
return isolatedVoiceUtterance{}
|
||||
}
|
||||
}
|
||||
|
||||
func (server *isolatedVoiceServer) end_utterance(t *testing.T, utterance isolatedVoiceUtterance) {
|
||||
t.Helper()
|
||||
server.mutex.Lock()
|
||||
connection := server.connections[utterance.id]
|
||||
connection.pending = ""
|
||||
server.mutex.Unlock()
|
||||
connection.send(fmt.Sprintf("702-1\r\n702-%d\r\n702 END\r\n", utterance.id))
|
||||
}
|
||||
|
||||
func remote_render_result(t *testing.T, finished <-chan error) error {
|
||||
t.Helper()
|
||||
select {
|
||||
case err := <-finished:
|
||||
return err
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("rendering did not finish")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteSpeechRenderingPreservesAssemblyAndLiteralMetadata(t *testing.T) {
|
||||
speaker, server := isolated_voice_fixture(t)
|
||||
voice, err := speaker.open_remote_voice(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
frames := []tentaclewire.RemoteSpeech{
|
||||
remote_render_frame(2, tentaclewire.RemoteSpeechPart{Text: " hello"}),
|
||||
remote_render_frame(2,
|
||||
tentaclewire.RemoteSpeechPart{Text: " world\t"},
|
||||
tentaclewire.RemoteSpeechPart{Text: "aa", Characters: true},
|
||||
tentaclewire.RemoteSpeechPart{Text: "é\n.\nQUIT\r\n..literal\rend <speak> ", Language: "fr"},
|
||||
tentaclewire.RemoteSpeechPart{End: true},
|
||||
tentaclewire.RemoteSpeechPart{Text: " again "}),
|
||||
}
|
||||
utterance, err := prepare_remote_utterance(frames)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if utterance.priority != 2 || len(utterance.fragments) != 4 {
|
||||
t.Fatal("assembly lost priority or metadata boundaries")
|
||||
}
|
||||
finished := make(chan error, 1)
|
||||
go func() { finished <- voice.SpeakUtteranceAndWait(t.Context(), utterance) }()
|
||||
for index, expected := range []struct{ text, language, spelling string }{
|
||||
{" hello world\t", "en-GB", "off"},
|
||||
{"aa", "en-GB", "on"},
|
||||
{"é\n.\nQUIT\n..literal\nend <speak> ", "fr", "off"},
|
||||
{" again ", "en-GB", "off"},
|
||||
} {
|
||||
got := server.next_utterance(t)
|
||||
if got.text != expected.text || got.language != expected.language || got.spelling != expected.spelling || got.ssml != "off" || got.module != "default" || got.voiceType != "FEMALE2" || got.synthesis != "" {
|
||||
t.Fatalf("fragment %d = %#v; want %#v in plain-text mode", index, got, expected)
|
||||
}
|
||||
select {
|
||||
case <-finished:
|
||||
t.Fatal("queue acknowledgement finished assembly")
|
||||
default:
|
||||
}
|
||||
server.end_utterance(t, got)
|
||||
}
|
||||
if err := remote_render_result(t, finished); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Successful rendering restores the effective server default and leaves
|
||||
// the handle reusable. Transport splits are not synthesis boundaries.
|
||||
large := strings.Repeat("界 ", 1200)
|
||||
frames = nil
|
||||
for _, part := range []string{large[:3000], large[3000:]} {
|
||||
frames = append(frames, remote_render_frame(0, tentaclewire.RemoteSpeechPart{Text: part}))
|
||||
}
|
||||
utterance, err = prepare_remote_utterance(frames)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go func() { finished <- voice.SpeakUtteranceAndWait(t.Context(), utterance) }()
|
||||
got := server.next_utterance(t)
|
||||
if got.text != large || got.language != "en-GB" || got.spelling != "off" {
|
||||
t.Fatal("next assembly inherited state or lost split text")
|
||||
}
|
||||
server.end_utterance(t, got)
|
||||
if err := remote_render_result(t, finished); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteSpeechRenderingRestoresNamedVoice(t *testing.T) {
|
||||
speaker, server := isolated_voice_fixture(t)
|
||||
settings := speaker.settings
|
||||
settings.DefaultVoice.Family = &config.VoiceFamily{Name: "configured voice"}
|
||||
if err := speaker.UpdateConfiguration(t.Context(), settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
voice, err := speaker.open_remote_voice(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
utterance, err := prepare_remote_utterance([]tentaclewire.RemoteSpeech{remote_render_frame(0,
|
||||
tentaclewire.RemoteSpeechPart{Text: "default"},
|
||||
tentaclewire.RemoteSpeechPart{Text: "foreign", Language: "fr"},
|
||||
tentaclewire.RemoteSpeechPart{Text: "restored"})})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
finished := make(chan error, 1)
|
||||
go func() { finished <- voice.SpeakUtteranceAndWait(t.Context(), utterance) }()
|
||||
for _, named := range []string{"configured voice", "", "configured voice"} {
|
||||
got := server.next_utterance(t)
|
||||
if got.synthesis != named || got.module != "default" || named == "" && got.voiceType != "MALE1" {
|
||||
t.Fatalf("language switch did not scope the named voice: %#v", got)
|
||||
}
|
||||
server.end_utterance(t, got)
|
||||
}
|
||||
if err := remote_render_result(t, finished); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteSpeechRenderingRejectsUnknownBackendDefaults(t *testing.T) {
|
||||
for _, test := range []struct{ command, reply string }{
|
||||
{"GET LANGUAGE", "400 Unsupported\r\n"},
|
||||
{"GET LANGUAGE", "251-en GB\r\n251 OK\r\n"},
|
||||
{"GET LANGUAGE", "251-en\r\n251-fr\r\n251 OK\r\n"},
|
||||
{"GET OUTPUT_MODULE", "251-unsafe module\r\n251 OK\r\n"},
|
||||
{"GET VOICE_TYPE", "251-unknown\r\n251 OK\r\n"},
|
||||
{"GET VOICE_TYPE", "251-NO_VOICE\r\n251 OK\r\n"},
|
||||
} {
|
||||
t.Run(test.command+test.reply, func(t *testing.T) {
|
||||
speaker, server := isolated_voice_fixture(t)
|
||||
voice, err := speaker.open_remote_voice(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server.mutex.Lock()
|
||||
server.queryReplies = map[string]string{test.command: test.reply}
|
||||
server.mutex.Unlock()
|
||||
utterance, err := prepare_remote_utterance([]tentaclewire.RemoteSpeech{remote_render_frame(0, tentaclewire.RemoteSpeechPart{Text: "never queued"})})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := voice.SpeakUtteranceAndWait(t.Context(), utterance); err == nil {
|
||||
t.Fatal("unresolved backend defaults admitted")
|
||||
}
|
||||
if voice.ctx.Err() == nil {
|
||||
t.Fatal("unresolved backend handle survived")
|
||||
}
|
||||
select {
|
||||
case <-server.queued:
|
||||
t.Fatal("unresolved defaults synthesized output")
|
||||
default:
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteSpeechRenderingCancellationDuringMetadata(t *testing.T) {
|
||||
speaker, server := isolated_voice_fixture(t)
|
||||
if err := speaker.SpeakVoice(t.Context(), "local status", "system"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server.next_utterance(t)
|
||||
voice, err := speaker.open_remote_voice(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
held, resume := make(chan struct{}), make(chan struct{})
|
||||
defer close(resume)
|
||||
server.mutex.Lock()
|
||||
server.holdCommand, server.held, server.resume = "GET LANGUAGE", held, resume
|
||||
server.mutex.Unlock()
|
||||
utterance, err := prepare_remote_utterance([]tentaclewire.RemoteSpeech{remote_render_frame(0, tentaclewire.RemoteSpeechPart{Text: "never queued"})})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
finished := make(chan error, 1)
|
||||
go func() { finished <- voice.SpeakUtteranceAndWait(t.Context(), utterance) }()
|
||||
select {
|
||||
case <-held:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("metadata query did not start")
|
||||
}
|
||||
if err := voice.SpeakUtteranceAndWait(t.Context(), utterance); err == nil {
|
||||
t.Fatal("overlapping typed assembly admitted")
|
||||
}
|
||||
if err := voice.Cancel(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := remote_render_result(t, finished); !errors.Is(err, context.Canceled) {
|
||||
t.Fatal("metadata cancellation was lost", err)
|
||||
}
|
||||
if id, _ := server.pending("local status"); id == 0 {
|
||||
t.Fatal("metadata cancellation discarded local status")
|
||||
}
|
||||
select {
|
||||
case <-server.queued:
|
||||
t.Fatal("metadata cancellation synthesized output")
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteSpeechRenderingCancellationKeepsLocalOutputAndDropsSuffix(t *testing.T) {
|
||||
for _, operation := range []string{"remote", "caller", "configuration"} {
|
||||
t.Run(operation, func(t *testing.T) {
|
||||
speaker, server := isolated_voice_fixture(t)
|
||||
if err := speaker.SpeakVoice(t.Context(), "local status", "system"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server.next_utterance(t)
|
||||
if err := speaker.SpeakEcho(t.Context(), "local echo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server.next_utterance(t)
|
||||
voice, err := speaker.open_remote_voice(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
utterance, err := prepare_remote_utterance([]tentaclewire.RemoteSpeech{remote_render_frame(0,
|
||||
tentaclewire.RemoteSpeechPart{Text: "prefix", Characters: true, Language: "fr"},
|
||||
tentaclewire.RemoteSpeechPart{End: true}, tentaclewire.RemoteSpeechPart{Text: "suffix"})})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
finished := make(chan error, 1)
|
||||
go func() { finished <- voice.SpeakUtteranceAndWait(ctx, utterance) }()
|
||||
server.next_utterance(t)
|
||||
switch operation {
|
||||
case "remote":
|
||||
err = voice.Cancel(t.Context())
|
||||
case "caller":
|
||||
cancel()
|
||||
case "configuration":
|
||||
// A committed replacement is modeled without cancelling local
|
||||
// speech: retirement must target only the old isolated handle.
|
||||
speaker.retire_remote_voice(speaker.settings)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := remote_render_result(t, finished); !errors.Is(err, context.Canceled) {
|
||||
t.Fatal("rendering survived cancellation", err)
|
||||
}
|
||||
for _, text := range []string{"local status", "local echo"} {
|
||||
if id, _ := server.pending(text); id == 0 {
|
||||
t.Fatal("Remote discarded local output", text)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-server.queued:
|
||||
t.Fatal("cancelled assembly exposed its suffix")
|
||||
default:
|
||||
}
|
||||
if voice.ctx.Err() == nil {
|
||||
t.Fatal("partially rendered handle was reused")
|
||||
}
|
||||
if _, err := speaker.open_remote_voice(t.Context()); err != nil {
|
||||
t.Fatal("retired renderer retained ownership", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteSpeechRenderingRejectsWholeInvalidAssembly(t *testing.T) {
|
||||
valid := remote_render_frame(0, tentaclewire.RemoteSpeechPart{Text: "prefix"})
|
||||
for _, frames := range [][]tentaclewire.RemoteSpeech{
|
||||
nil,
|
||||
{valid, {Kind: "cancel"}},
|
||||
{valid, remote_render_frame(1, tentaclewire.RemoteSpeechPart{Text: "changed priority"})},
|
||||
{valid, remote_render_frame(0, tentaclewire.RemoteSpeechPart{Text: "bad", Language: "fr\nQUIT"})},
|
||||
{valid, remote_render_frame(0, tentaclewire.RemoteSpeechPart{Text: strings.Repeat("x", 6200)})},
|
||||
append(make([]tentaclewire.RemoteSpeech, 64), valid),
|
||||
} {
|
||||
if _, err := prepare_remote_utterance(frames); err == nil {
|
||||
t.Fatal("invalid assembly admitted")
|
||||
}
|
||||
}
|
||||
// Each frame fits IPC, but the retained assembly exceeds the byte budget.
|
||||
frames := make([]tentaclewire.RemoteSpeech, 64)
|
||||
for index := range frames {
|
||||
frames[index] = remote_render_frame(0, tentaclewire.RemoteSpeechPart{Text: strings.Repeat("x", 5000)})
|
||||
}
|
||||
if _, err := prepare_remote_utterance(frames); err == nil {
|
||||
t.Fatal("oversized assembly admitted")
|
||||
}
|
||||
for _, invalid := range []string{"bad\x00text", "bad\x1btext", "bad\x7ftext"} {
|
||||
speaker, server := isolated_voice_fixture(t)
|
||||
voice, err := speaker.open_remote_voice(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
utterance, err := prepare_remote_utterance([]tentaclewire.RemoteSpeech{valid, remote_render_frame(0,
|
||||
tentaclewire.RemoteSpeechPart{End: true}, tentaclewire.RemoteSpeechPart{Text: invalid})})
|
||||
if err != nil {
|
||||
t.Fatal("wire-valid fixture rejected too early", err)
|
||||
}
|
||||
if err := voice.SpeakUtteranceAndWait(t.Context(), utterance); err == nil {
|
||||
t.Fatal("unsafe text rendered")
|
||||
}
|
||||
select {
|
||||
case <-server.queued:
|
||||
t.Fatal("invalid later fragment exposed an earlier prefix")
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteSpeechRenderingBackendRejectionAndEmptyBoundaries(t *testing.T) {
|
||||
speaker, server := isolated_voice_fixture(t)
|
||||
voice, err := speaker.open_remote_voice(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
utterance, err := prepare_remote_utterance([]tentaclewire.RemoteSpeech{remote_render_frame(0,
|
||||
tentaclewire.RemoteSpeechPart{End: true}, tentaclewire.RemoteSpeechPart{End: true})})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := voice.SpeakUtteranceAndWait(t.Context(), utterance); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
utterance, err = prepare_remote_utterance([]tentaclewire.RemoteSpeech{remote_render_frame(0,
|
||||
tentaclewire.RemoteSpeechPart{Text: "never queued", Language: "rejected"})})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := voice.SpeakUtteranceAndWait(t.Context(), utterance); err == nil {
|
||||
t.Fatal("rejected metadata ignored")
|
||||
}
|
||||
if voice.ctx.Err() == nil {
|
||||
t.Fatal("failed metadata handle survived")
|
||||
}
|
||||
select {
|
||||
case <-server.queued:
|
||||
t.Fatal("empty boundary or rejected metadata synthesized text")
|
||||
default:
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// 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 := 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
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/config"
|
||||
)
|
||||
|
||||
type isolatedVoiceConnection struct {
|
||||
net.Conn
|
||||
write sync.Mutex
|
||||
// The following fields belong to isolatedVoiceServer.mutex.
|
||||
module, pending string
|
||||
notifications bool
|
||||
language, spelling, ssml string
|
||||
voiceType, synthesis string
|
||||
}
|
||||
|
||||
type isolatedVoiceUtterance struct {
|
||||
id int
|
||||
text, language, spelling, ssml string
|
||||
module, voiceType, synthesis string
|
||||
}
|
||||
|
||||
func (connection *isolatedVoiceConnection) send(message string) {
|
||||
connection.write.Lock()
|
||||
defer connection.write.Unlock()
|
||||
_, _ = fmt.Fprint(connection.Conn, message)
|
||||
}
|
||||
|
||||
// Keep local and remote utterances outstanding until an explicit END/CANCEL.
|
||||
// This models cancellation ownership, not synthesized audio or server priority.
|
||||
type isolatedVoiceServer struct {
|
||||
mutex sync.Mutex
|
||||
connections map[int]*isolatedVoiceConnection
|
||||
accepted int
|
||||
queued chan isolatedVoiceUtterance
|
||||
queryReplies map[string]string
|
||||
holdCommand string
|
||||
held, resume chan struct{}
|
||||
}
|
||||
|
||||
func isolated_voice_fixture(t *testing.T) (*configuredSpeaker, *isolatedVoiceServer) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "speech.sock")
|
||||
listener, err := net.Listen("unix", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := &isolatedVoiceServer{connections: make(map[int]*isolatedVoiceConnection), queued: make(chan isolatedVoiceUtterance, 128)}
|
||||
var handlers sync.WaitGroup
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
connection := &isolatedVoiceConnection{Conn: conn, module: "default", language: "en-GB", spelling: "off", ssml: "on", voiceType: "FEMALE2"}
|
||||
server.mutex.Lock()
|
||||
server.accepted++
|
||||
id := server.accepted
|
||||
server.connections[id] = connection
|
||||
server.mutex.Unlock()
|
||||
handlers.Add(1)
|
||||
go func() {
|
||||
defer handlers.Done()
|
||||
defer conn.Close()
|
||||
reader := bufio.NewReader(conn)
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
command := strings.TrimSpace(line)
|
||||
response := "200 OK\r\n"
|
||||
var queued *isolatedVoiceUtterance
|
||||
server.mutex.Lock()
|
||||
switch {
|
||||
case command == "HISTORY GET CLIENT_ID":
|
||||
response = fmt.Sprintf("245-%d\r\n245 OK\r\n", id)
|
||||
case command == "GET LANGUAGE":
|
||||
response = "251-" + connection.language + "\r\n251 OK\r\n"
|
||||
case command == "GET OUTPUT_MODULE":
|
||||
response = "251-" + connection.module + "\r\n251 OK\r\n"
|
||||
case command == "GET VOICE_TYPE":
|
||||
response = "251-" + connection.voiceType + "\r\n251 OK\r\n"
|
||||
case strings.HasPrefix(command, "SET SELF LANGUAGE "):
|
||||
connection.language = strings.TrimPrefix(command, "SET SELF LANGUAGE ")
|
||||
// Server LanguageDefaultModule mappings must not silently
|
||||
// replace the reader's chosen synthesis module.
|
||||
connection.module = "language-mapped"
|
||||
if connection.language == "rejected" {
|
||||
response = "400 Rejected\r\n"
|
||||
}
|
||||
case strings.HasPrefix(command, "SET SELF SPELLING "):
|
||||
connection.spelling = strings.TrimPrefix(command, "SET SELF SPELLING ")
|
||||
case strings.HasPrefix(command, "SET SELF SSML_MODE "):
|
||||
connection.ssml = strings.TrimPrefix(command, "SET SELF SSML_MODE ")
|
||||
case strings.HasPrefix(command, "SET SELF VOICE_TYPE "):
|
||||
connection.voiceType = strings.TrimPrefix(command, "SET SELF VOICE_TYPE ")
|
||||
connection.synthesis = ""
|
||||
case strings.HasPrefix(command, "SET SELF SYNTHESIS_VOICE "):
|
||||
connection.synthesis = strings.TrimPrefix(command, "SET SELF SYNTHESIS_VOICE ")
|
||||
connection.voiceType = "NO_VOICE"
|
||||
case strings.HasPrefix(command, "SET SELF OUTPUT_MODULE "):
|
||||
connection.module = strings.TrimPrefix(command, "SET SELF OUTPUT_MODULE ")
|
||||
connection.synthesis = ""
|
||||
if connection.module == "rejected" {
|
||||
response = "400 Rejected\r\n"
|
||||
}
|
||||
case strings.HasPrefix(command, "SET SELF NOTIFICATION ALL "):
|
||||
connection.notifications = strings.HasSuffix(command, " on")
|
||||
case strings.HasPrefix(command, "CANCEL "):
|
||||
target, _ := strconv.Atoi(strings.TrimPrefix(command, "CANCEL "))
|
||||
speaking := server.connections[target]
|
||||
if speaking == nil {
|
||||
server.mutex.Unlock()
|
||||
t.Error("cancellation did not target a speaking client")
|
||||
return
|
||||
}
|
||||
notify := speaking.notifications && speaking.pending != ""
|
||||
speaking.pending = ""
|
||||
server.mutex.Unlock()
|
||||
connection.send(response)
|
||||
if notify {
|
||||
speaking.send(fmt.Sprintf("703-1\r\n703-%d\r\n703 CANCELED\r\n", target))
|
||||
}
|
||||
continue
|
||||
case command == "SPEAK":
|
||||
server.mutex.Unlock()
|
||||
var lines []string
|
||||
for {
|
||||
part, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if part == ".\r\n" {
|
||||
break
|
||||
}
|
||||
part = strings.TrimSuffix(part, "\r\n")
|
||||
if strings.HasPrefix(part, "..") {
|
||||
part = part[1:]
|
||||
}
|
||||
lines = append(lines, part)
|
||||
}
|
||||
server.mutex.Lock()
|
||||
connection.pending = strings.Join(lines, "\n")
|
||||
queued = &isolatedVoiceUtterance{id: id, text: connection.pending, language: connection.language, spelling: connection.spelling, ssml: connection.ssml, module: connection.module, voiceType: connection.voiceType, synthesis: connection.synthesis}
|
||||
response = "230 OK\r\n225 QUEUED\r\n"
|
||||
if connection.notifications {
|
||||
response += fmt.Sprintf("701-1\r\n701-%d\r\n701 BEGIN\r\n", id)
|
||||
}
|
||||
}
|
||||
if override := server.queryReplies[command]; override != "" {
|
||||
response = override
|
||||
}
|
||||
hold := command == server.holdCommand
|
||||
held, resume := server.held, server.resume
|
||||
server.mutex.Unlock()
|
||||
if hold {
|
||||
close(held)
|
||||
<-resume
|
||||
}
|
||||
connection.send(response)
|
||||
if queued != nil {
|
||||
server.queued <- *queued
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
}()
|
||||
settings := config.DefaultSettings()
|
||||
settings.UseCustomEchoVoice = true
|
||||
settings.UseCustomEchoSpeechServer = true
|
||||
settings.EchoSpeechServer = "echo"
|
||||
speaker := newConfiguredSpeaker(path, settings)
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := speaker.Close(ctx); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
_ = listener.Close()
|
||||
<-done
|
||||
server.mutex.Lock()
|
||||
for _, conn := range server.connections {
|
||||
_ = conn.Close()
|
||||
}
|
||||
server.mutex.Unlock()
|
||||
handlers.Wait()
|
||||
})
|
||||
if err := speaker.Connect(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return speaker, server
|
||||
}
|
||||
|
||||
func (server *isolatedVoiceServer) pending(text string) (int, string) {
|
||||
server.mutex.Lock()
|
||||
defer server.mutex.Unlock()
|
||||
for id, conn := range server.connections {
|
||||
if conn.pending == text {
|
||||
return id, conn.module
|
||||
}
|
||||
}
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
func begin_isolated_speech(t *testing.T, voice *remoteSpeechVoice, ctx context.Context, text string) <-chan error {
|
||||
t.Helper()
|
||||
began := make(chan struct{})
|
||||
finished := make(chan error, 1)
|
||||
go func() { finished <- voice.SpeakAndWait(ctx, text, func() { close(began) }) }()
|
||||
select {
|
||||
case <-began:
|
||||
case err := <-finished:
|
||||
t.Fatal("speech failed before BEGIN", err)
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("speech did not begin")
|
||||
}
|
||||
return finished
|
||||
}
|
||||
|
||||
func finish_isolated_speech(t *testing.T, finished <-chan error) {
|
||||
t.Helper()
|
||||
select {
|
||||
case err := <-finished:
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatal("speech was not cancelled", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("speech did not retire")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteSpeechVoiceCancellationKeepsLocalStatusAndEcho(t *testing.T) {
|
||||
for _, operation := range []string{"cancel", "caller", "close"} {
|
||||
t.Run(operation, func(t *testing.T) {
|
||||
speaker, server := isolated_voice_fixture(t)
|
||||
if err := speaker.SpeakVoice(t.Context(), "local status", "system"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := speaker.SpeakEcho(t.Context(), "local echo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
voice, err := speaker.open_remote_voice(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
finished := begin_isolated_speech(t, voice, ctx, "remote")
|
||||
remoteID, _ := server.pending("remote")
|
||||
statusID, _ := server.pending("local status")
|
||||
echoID, _ := server.pending("local echo")
|
||||
if remoteID == 0 || statusID == 0 || echoID == 0 || remoteID == statusID || remoteID == echoID {
|
||||
t.Fatal("Remote shared a speaking connection with local output")
|
||||
}
|
||||
select {
|
||||
case <-finished:
|
||||
t.Fatal("queue acknowledgement completed remote speech")
|
||||
default:
|
||||
}
|
||||
switch operation {
|
||||
case "cancel":
|
||||
err = voice.Cancel(t.Context())
|
||||
case "caller":
|
||||
cancel()
|
||||
case "close":
|
||||
err = voice.Close(t.Context())
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
finish_isolated_speech(t, finished)
|
||||
if id, _ := server.pending("local status"); id != statusID {
|
||||
t.Fatal("remote cancellation discarded local status")
|
||||
}
|
||||
if id, _ := server.pending("local echo"); id != echoID {
|
||||
t.Fatal("remote cancellation discarded custom echo")
|
||||
}
|
||||
if id, _ := server.pending("remote"); id != 0 {
|
||||
t.Fatal("remote speech survived cancellation")
|
||||
}
|
||||
if operation != "close" {
|
||||
// Notification cleanup leaves the same isolated connection reusable.
|
||||
finished = begin_isolated_speech(t, voice, t.Context(), "remote again")
|
||||
if err := speaker.Cancel(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
finish_isolated_speech(t, finished)
|
||||
if id, _ := server.pending("local status"); id != 0 {
|
||||
t.Fatal("core cancellation failed to stop local output")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteSpeechVoiceConfigurationAndOwnership(t *testing.T) {
|
||||
speaker, server := isolated_voice_fixture(t)
|
||||
voice, err := speaker.open_remote_voice(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := speaker.open_remote_voice(t.Context()); err == nil {
|
||||
t.Fatal("two simultaneous Remote voices admitted")
|
||||
}
|
||||
finished := begin_isolated_speech(t, voice, t.Context(), "old settings")
|
||||
settings := config.DefaultSettings()
|
||||
settings.SpeechOutputModule = "rejected"
|
||||
if err := speaker.UpdateConfiguration(t.Context(), settings); err == nil {
|
||||
t.Fatal("rejected configuration committed")
|
||||
}
|
||||
if id, _ := server.pending("old settings"); id == 0 || voice.ctx.Err() != nil {
|
||||
t.Fatal("failed preparation cancelled the existing Remote voice")
|
||||
}
|
||||
settings.SpeechOutputModule = "replacement"
|
||||
if err := speaker.UpdateConfiguration(t.Context(), settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
finish_isolated_speech(t, finished)
|
||||
if err := voice.SpeakAndWait(t.Context(), "stale", nil); !errors.Is(err, context.Canceled) {
|
||||
t.Fatal("retired handle reused new configuration", err)
|
||||
}
|
||||
replacement, err := speaker.open_remote_voice(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
finished = begin_isolated_speech(t, replacement, t.Context(), "new settings")
|
||||
if _, module := server.pending("new settings"); module != "replacement" {
|
||||
t.Fatal("isolated voice used stale settings", module)
|
||||
}
|
||||
if err := voice.Close(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id, _ := server.pending("new settings"); id == 0 {
|
||||
t.Fatal("stale close cancelled replacement voice")
|
||||
}
|
||||
if err := speaker.Close(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
finish_isolated_speech(t, finished)
|
||||
if _, err := speaker.open_remote_voice(t.Context()); err == nil {
|
||||
t.Fatal("closed speaker admitted Remote")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteSpeechVoiceCompletionAndAcquisitionLifetime(t *testing.T) {
|
||||
speaker, server := isolated_voice_fixture(t)
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
server.mutex.Lock()
|
||||
before := server.accepted
|
||||
server.mutex.Unlock()
|
||||
voice, err := speaker.open_remote_voice(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Acquisition cancellation must not become accidental session revocation.
|
||||
cancel()
|
||||
finished := begin_isolated_speech(t, voice, t.Context(), "complete")
|
||||
server.mutex.Lock()
|
||||
if server.accepted != before+2 {
|
||||
t.Error("isolated voice did not open exactly one speaking/control pair")
|
||||
}
|
||||
var speaking *isolatedVoiceConnection
|
||||
for _, connection := range server.connections {
|
||||
if connection.pending == "complete" {
|
||||
speaking = connection
|
||||
connection.pending = ""
|
||||
break
|
||||
}
|
||||
}
|
||||
server.mutex.Unlock()
|
||||
if speaking == nil {
|
||||
t.Fatal("missing outstanding utterance")
|
||||
}
|
||||
speaking.send("702-1\r\n702-1\r\n702 END\r\n")
|
||||
select {
|
||||
case err := <-finished:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("END did not complete speech")
|
||||
}
|
||||
if err := voice.Close(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := speaker.open_remote_voice(t.Context()); err != nil {
|
||||
t.Fatal("closed voice retained exclusive ownership", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteSpeechVoiceRejectsUnavailableBackendsAndCancelledAcquisition(t *testing.T) {
|
||||
for _, active := range []configuredVoice{nil, &hardwareSpeaker{}} {
|
||||
speaker := &configuredSpeaker{active: active}
|
||||
if _, err := speaker.open_remote_voice(t.Context()); err == nil {
|
||||
t.Fatal("unavailable software backend admitted Remote")
|
||||
}
|
||||
}
|
||||
speaker, server := isolated_voice_fixture(t)
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
server.mutex.Lock()
|
||||
before := server.accepted
|
||||
server.mutex.Unlock()
|
||||
if _, err := speaker.open_remote_voice(ctx); !errors.Is(err, context.Canceled) {
|
||||
t.Fatal("cancelled acquisition succeeded", err)
|
||||
}
|
||||
server.mutex.Lock()
|
||||
defer server.mutex.Unlock()
|
||||
if server.accepted != before {
|
||||
t.Fatal("cancelled acquisition opened connections")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package speech
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Fragment is literal text with resolved spelling/language state. A fragment
|
||||
// boundary is a synthesis boundary; callers coalesce transport fragments first.
|
||||
// Empty language selects this client's effective configured default language.
|
||||
type Fragment struct {
|
||||
Text string
|
||||
Characters bool
|
||||
Language string
|
||||
}
|
||||
|
||||
// SpeakFragmentsAndWait is for an isolated output owner. Unlike ordinary local
|
||||
// speech, it preserves spaces/tabs and line boundaries (canonicalized to CRLF).
|
||||
// Plain-text mode prevents network text being interpreted as synthesis markup.
|
||||
// It validates the entire sequence before any backend operation, then retains
|
||||
// the speaking lock through every END/CANCEL. Cancel still uses its own channel.
|
||||
// This method implements no priority, lease, pause or local-focus policy.
|
||||
func (client *Client) SpeakFragmentsAndWait(ctx context.Context, fragments []Fragment) (result error) {
|
||||
if len(fragments) == 0 || len(fragments) > 8192 {
|
||||
return errors.New("invalid speech fragment count")
|
||||
}
|
||||
bytes := 0
|
||||
for _, part := range fragments {
|
||||
bytes += len(part.Text)
|
||||
if bytes > 256*1024 || !valid_fragment(part) {
|
||||
return errors.New("invalid speech fragment")
|
||||
}
|
||||
}
|
||||
client.mutex.Lock()
|
||||
defer client.mutex.Unlock()
|
||||
if client.conn == nil {
|
||||
return errors.New("speech client is not connected")
|
||||
}
|
||||
defer func() {
|
||||
if result != nil {
|
||||
result = errors.Join(ctx.Err(), result)
|
||||
}
|
||||
if result != nil && client.conn != nil {
|
||||
_ = client.conn.Close()
|
||||
client.conn, client.reader = nil, nil
|
||||
}
|
||||
}()
|
||||
// Query the effective default after initialization: a selected synthesis
|
||||
// voice may override SET LANGUAGE, and the server default may be unsaved.
|
||||
defaultLanguage, err := client.fragment_parameter(ctx, "LANGUAGE")
|
||||
if err != nil || !valid_fragment_language(defaultLanguage, false) {
|
||||
return errors.Join(ctx.Err(), errors.New("speech default language unavailable"))
|
||||
}
|
||||
module, err := client.fragment_parameter(ctx, "OUTPUT_MODULE")
|
||||
if err != nil || !valid_fragment_language(module, false) {
|
||||
return errors.Join(ctx.Err(), errors.New("speech default module unavailable"))
|
||||
}
|
||||
voiceType, err := client.fragment_parameter(ctx, "VOICE_TYPE")
|
||||
if err != nil {
|
||||
return errors.Join(ctx.Err(), errors.New("speech default voice unavailable"))
|
||||
}
|
||||
switch voiceType {
|
||||
case "MALE1", "MALE2", "MALE3", "FEMALE1", "FEMALE2", "FEMALE3", "CHILD_MALE", "CHILD_FEMALE":
|
||||
case "NO_VOICE":
|
||||
if client.settings.SynthesisVoice == "" {
|
||||
return errors.New("speech default voice unavailable")
|
||||
}
|
||||
// A named voice has no portable gender/category. Explicit remote
|
||||
// languages use the SSIP baseline category instead of that voice name.
|
||||
voiceType = "MALE1"
|
||||
default:
|
||||
return errors.New("speech default voice unavailable")
|
||||
}
|
||||
setVoice := func(language, named string) error {
|
||||
// SET LANGUAGE can select a different output module via server config.
|
||||
// Restore the reader's actual module, then clear any previous named
|
||||
// voice through VOICE_TYPE. A default-language part regains its name.
|
||||
for _, fields := range [][]string{
|
||||
{"SET", "SELF", "LANGUAGE", language},
|
||||
{"SET", "SELF", "OUTPUT_MODULE", module},
|
||||
{"SET", "SELF", "VOICE_TYPE", voiceType},
|
||||
} {
|
||||
if err := client.sendCommand(ctx, fields); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if named != "" {
|
||||
return client.sendCommand(ctx, []string{"SET", "SELF", "SYNTHESIS_VOICE", named})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := client.sendCommand(ctx, []string{"SET", "SELF", "SSML_MODE", "off"}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, part := range fragments {
|
||||
language, spelling, named := part.Language, "off", ""
|
||||
if language == "" {
|
||||
language, named = defaultLanguage, client.settings.SynthesisVoice
|
||||
}
|
||||
if err := setVoice(language, named); err != nil {
|
||||
return err
|
||||
}
|
||||
if part.Characters {
|
||||
spelling = "on"
|
||||
}
|
||||
if err := client.sendCommand(ctx, []string{"SET", "SELF", "SPELLING", spelling}); err != nil {
|
||||
return err
|
||||
}
|
||||
text := strings.ReplaceAll(part.Text, "\r\n", "\n")
|
||||
text = strings.ReplaceAll(text, "\r", "\n")
|
||||
text = strings.ReplaceAll(text, "\n", "\r\n")
|
||||
if err := client.speak_and_wait_locked(ctx, func() error { return client.speak_block(ctx, text) }, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := client.sendCommand(ctx, []string{"SET", "SELF", "SPELLING", "off"}); err != nil {
|
||||
return err
|
||||
}
|
||||
return setVoice(defaultLanguage, client.settings.SynthesisVoice)
|
||||
}
|
||||
|
||||
// These names are fixed by the caller; response values never become commands
|
||||
// until their bounded token/enum validators have accepted them.
|
||||
func (client *Client) fragment_parameter(ctx context.Context, name string) (string, error) {
|
||||
finish, err := prepareIO(ctx, client.conn, client.ioTimeout)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer finish()
|
||||
_, err = fmt.Fprint(client.conn, "GET "+name+"\r\n")
|
||||
var response reply
|
||||
if err == nil {
|
||||
response, err = client.readReply()
|
||||
}
|
||||
if err != nil || response.code != 251 || len(response.data) != 1 {
|
||||
return "", errors.New("speech default unavailable")
|
||||
}
|
||||
return response.data[0], nil
|
||||
}
|
||||
|
||||
func valid_fragment(part Fragment) bool {
|
||||
if part.Text == "" || !utf8.ValidString(part.Text) || !valid_fragment_language(part.Language, true) {
|
||||
return false
|
||||
}
|
||||
for _, character := range part.Text {
|
||||
if character < 32 && character != '\r' && character != '\n' && character != '\t' || character == 127 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func valid_fragment_language(language string, empty bool) bool {
|
||||
if language == "" {
|
||||
return empty
|
||||
}
|
||||
if len(language) > 64 {
|
||||
return false
|
||||
}
|
||||
for _, character := range language {
|
||||
if !(character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' || character == '-' || character == '_') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -16,6 +16,11 @@ import (
|
||||
func (client *Client) SpeakAndWait(ctx context.Context, text string, began func()) (result error) {
|
||||
client.mutex.Lock()
|
||||
defer client.mutex.Unlock()
|
||||
return client.speak_and_wait_locked(ctx, func() error { return client.speak(ctx, text) }, began)
|
||||
}
|
||||
|
||||
// The caller owns the speaking connection until the terminal notification.
|
||||
func (client *Client) speak_and_wait_locked(ctx context.Context, speak func() error, began func()) (result error) {
|
||||
if client.conn == nil {
|
||||
return errors.New("speech client is not connected")
|
||||
}
|
||||
@@ -33,7 +38,7 @@ func (client *Client) SpeakAndWait(ctx context.Context, text string, began func(
|
||||
}
|
||||
}
|
||||
}()
|
||||
if err := client.speak(ctx, text); err != nil {
|
||||
if err := speak(); err != nil {
|
||||
return err
|
||||
}
|
||||
conn := client.conn
|
||||
|
||||
@@ -293,6 +293,12 @@ func (client *Client) speak(ctx context.Context, text string) (result error) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return client.speak_block(ctx, text)
|
||||
}
|
||||
|
||||
// text is validated by the caller. Dot escaping applies to every data line,
|
||||
// including lines containing what would otherwise be the SSIP terminator.
|
||||
func (client *Client) speak_block(ctx context.Context, text string) (result error) {
|
||||
|
||||
if client.conn == nil {
|
||||
return errors.New("speech client is not connected")
|
||||
@@ -315,6 +321,7 @@ func (client *Client) speak(ctx context.Context, text string) (result error) {
|
||||
if strings.HasPrefix(text, ".") {
|
||||
text = "." + text
|
||||
}
|
||||
text = strings.ReplaceAll(text, "\r\n.", "\r\n..")
|
||||
request := "SPEAK\r\n" + text + "\r\n.\r\n"
|
||||
if _, err := client.conn.Write([]byte(request)); err != nil {
|
||||
return fmt.Errorf("send speech block: %w", err)
|
||||
|
||||
Reference in new issue
Block a user