Files
cthulhu/internal/app/remote_speech_voice_test.go
ak47 49be8309fe Enable isolated Remote speech and protect reading exports
Attach incoming software speech to reader sessions with lease checks, local output priority and isolated cancellation. Preserve every document fragment source for privacy checks and export reviewed browse current-line reading, spelling and phonetic output.

Verified focused reading and Remote race tests, complete review and external Remote race suites, private relay integration, speech regressions, affected-package vet, reader build and Tentacles make check. Audible speech, physical input/Braille and live interoperability remain unverified.
2026-11-05 15:41:08 -05:00

438 lines
14 KiB
Go

// 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
priority string
notifications bool
language, spelling, ssml string
voiceType, synthesis string
}
type isolatedVoiceUtterance struct {
id int
text, language, spelling, ssml string
module, voiceType, synthesis string
priority 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 PRIORITY "):
connection.priority = strings.TrimPrefix(command, "SET SELF PRIORITY ")
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, priority: connection.priority}
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")
}
}