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