// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "bufio" "context" "fmt" "log/slog" "net" "path/filepath" "strings" "sync" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/preferences" "git.stormux.org/storm/cthulhu/internal/remote" "git.stormux.org/storm/cthulhu/internal/tentaclewire" ) func TestEchoSpeakerSwitchesBothVoicesTransactionally(t *testing.T) { for _, wrapped := range []bool{false, true} { t.Run(fmt.Sprintf("configured=%v", wrapped), func(t *testing.T) { test_echo_speaker(t, wrapped) }) } } func test_echo_speaker(t *testing.T, wrapped bool) { path := filepath.Join(t.TempDir(), "speech.sock") listener, err := net.Listen("unix", path) if err != nil { t.Fatal(err) } type utterance struct { module, text string rate, pitch string volume, voice string } spoken := make(chan utterance, 10) var handlers sync.WaitGroup acceptDone := make(chan struct{}) go func() { defer close(acceptDone) for id := 1; ; id++ { conn, err := listener.Accept() if err != nil { return } handlers.Add(1) go func(id int) { defer handlers.Done() defer conn.Close() reader := bufio.NewReader(conn) module, rate, pitch := "", "", "" volume, voice := "", "" notifications := false for { line, err := reader.ReadString('\n') if err != nil { return } command := strings.TrimSpace(line) response := "200 OK\r\n" if command == "SET SELF NOTIFICATION ALL on" { notifications = true } else if command == "SET SELF NOTIFICATION ALL off" { notifications = false } if strings.HasPrefix(command, "SET SELF OUTPUT_MODULE ") { module = strings.TrimPrefix(command, "SET SELF OUTPUT_MODULE ") } if strings.HasPrefix(command, "SET SELF RATE ") { rate = strings.TrimPrefix(command, "SET SELF RATE ") } if strings.HasPrefix(command, "SET SELF PITCH ") { pitch = strings.TrimPrefix(command, "SET SELF PITCH ") } if strings.HasPrefix(command, "SET SELF VOLUME ") { volume = strings.TrimPrefix(command, "SET SELF VOLUME ") } if strings.HasPrefix(command, "SET SELF SYNTHESIS_VOICE ") { voice = strings.TrimPrefix(command, "SET SELF SYNTHESIS_VOICE ") } if command == "SET SELF OUTPUT_MODULE rejected" { response = "400 Module unavailable\r\n" } if command == "HISTORY GET CLIENT_ID" { response = fmt.Sprintf("245-%d\r\n245 OK\r\n", id) } if strings.HasPrefix(command, "CHAR ") { spoken <- utterance{module, strings.TrimPrefix(command, "CHAR "), rate, pitch, volume, voice} } if command == "SPEAK" { var text strings.Builder for { part, err := reader.ReadString('\n') if err != nil { return } if part == ".\r\n" { break } text.WriteString(strings.TrimSuffix(part, "\r\n")) } spoken <- utterance{module, text.String(), rate, pitch, volume, voice} response = "230 OK\r\n200 QUEUED\r\n" if notifications { response += fmt.Sprintf("701-1\r\n701-%d\r\n701 BEGIN\r\n702-1\r\n702-%d\r\n702 END\r\n", id, id) } } if _, err := conn.Write([]byte(response)); err != nil { return } } }(id) } }() ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() settings := config.DefaultSettings() settings.UseCustomEchoVoice = true settings.UseCustomEchoSpeechServer = true settings.EchoSpeechServer = "echo-module" rate := 80.0 settings.EchoVoice.Rate = &rate var speaker interface { configuredVoice UpdateConfiguration(context.Context, config.Settings) error } = newEchoSpeaker(path, settings) if wrapped { speaker = newConfiguredSpeaker(path, settings) } defer func() { speaker.Close(ctx); listener.Close(); <-acceptDone; handlers.Wait() }() if err := speaker.Connect(ctx); err != nil { t.Fatal(err) } check := func(custom bool, wantModule, wantRate string) { t.Helper() var err error if custom { err = speaker.SpeakEcho(ctx, "test") } else { err = speaker.Speak(ctx, "test") } if err != nil { t.Fatal(err) } select { case got := <-spoken: if got.module != wantModule || got.rate != wantRate { t.Fatalf("utterance=%+v, want module %q rate %q", got, wantModule, wantRate) } case <-ctx.Done(): t.Fatal("speech missing") } } check(false, "", "") check(true, "echo-module", "62") if wrapped { // Exercise the new request path through the actual configured backend // and private SSIP connections, including a failure in the echo client // after preparation of the new main client has already succeeded. readerWorker, session, _ := reader_extension_fixture(t, "normal") base := settings base.Path = readerWorker.settings.Path readerWorker.settings, readerWorker.globalSettings = base, base readerWorker.updater = speaker readerWorker.live.Store(base) configuration := tentaclewire.SpeechFromSettings(base) configuration.Module = "tentacle-module" profileRate := 65.0 configuration.DefaultVoice.Rate = &profileRate if _, err := readerWorker.apply_reader_request(ctx, session, tentaclewire.ReaderRequest{Speech: &configuration}, false); err != nil { t.Fatal(err) } check(false, "tentacle-module", "32") check(true, "echo-module", "62") configuration.Module, configuration.EchoServer = "candidate-module", "rejected" if _, err := readerWorker.apply_reader_request(ctx, session, tentaclewire.ReaderRequest{Speech: &configuration}, false); err == nil { t.Fatal("failed echo client accepted through reader API") } check(false, "tentacle-module", "32") check(true, "echo-module", "62") if _, err := readerWorker.apply_reader_request(ctx, session, tentaclewire.ReaderRequest{RestoreSpeech: true}, false); err != nil { t.Fatal(err) } check(false, "", "") check(true, "echo-module", "62") } // Key names use completion notifications on the selected voice's real // SSIP connection, so following navigation cannot cancel an active echo. keySpeech := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) for _, custom := range []bool{false, true} { if err := keySpeech.deliver(ctx, speechRequest{Text: "right", Role: "key", Cause: "key-echo", CustomEcho: custom}); err != nil { t.Fatal(err) } got := <-spoken wantModule, wantRate := "", "" if custom { wantModule, wantRate = "echo-module", "62" } if got.text != "right" || got.module != wantModule || got.rate != wantRate { t.Fatalf("key echo custom=%v: %+v", custom, got) } } // Drive the insertion presenter through the real SSIP clients for every // echo kind. Per-kind voice selection must not affect other echo kinds. for _, kind := range []string{"key", "character", "word", "sentence"} { for _, selected := range []bool{false, true} { current := settings current.EnableEchoByCharacter = kind == "character" current.EnableEchoByWord = kind == "word" current.EnableEchoBySentence = kind == "sentence" current.UseCustomEchoForKey = selected current.UseCustomEchoForCharacter = selected current.UseCustomEchoForWord = selected current.UseCustomEchoForSentence = selected live := &liveSettings{} live.Store(current) queue := &remoteSpeechQueue{} presenter := eventPresenter{state: &eventLoopState{}, live: live, speech: queue, logger: slog.New(slog.DiscardHandler)} payload, contextText, want := "é", "Café", "é" if kind == "word" { payload, contextText, want = " ", "Café ", "Café" } else if kind == "sentence" { payload, contextText, want = " ", "First. Second. ", "Second." } presenter.speak(atspi.Event{Type: atspi.TextInsertedEvent, Payload: payload, Source: atspi.Snapshot{Role: "entry", Editable: true, TextBeforeCaret: contextText}}) if len(queue.requests) != 1 { t.Fatalf("%s: requests=%+v", kind, queue.requests) } if err := keySpeech.deliver(ctx, queue.requests[0]); err != nil { t.Fatal(err) } got := <-spoken wantModule, wantRate := "", "" if selected { wantModule, wantRate = "echo-module", "62" } if got.text != want || got.module != wantModule || got.rate != wantRate { t.Fatalf("%s custom=%v: %+v", kind, selected, got) } } } // Preview unsaved echo selections through the real SSIP clients. Only the // numeric request uses that endpoint; subsequent dialog speech stays main. previewWorker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, speaker) previewValues := preferences.FromSettings(settings) previewRate, previewPitch, previewVolume := 70.0, 6.0, 10.0 previewValues.EchoSpeechServer = "preview-module" previewValues.EchoVoice = config.Voice{Rate: &previewRate, AveragePitch: &previewPitch, Gain: &previewVolume, Family: &config.VoiceFamily{Name: "Preview voice", Lang: "en"}} previewSpeech := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) previewWorker.announceEchoPreview = func(label, value string) { if err := previewSpeech.deliver(ctx, echo_preview_speech(label, value)); err != nil { t.Fatal(err) } } for _, test := range []struct{ slider, text string }{{"rate", "70.0"}, {"pitch", "6.0"}, {"volume", "10.0"}, {"synthesizer", "preview-module"}, {"person", "Preview voice (en)"}} { if response := previewWorker.preview(ctx, preferences.Request{Values: previewValues, Preview: true, EchoPreview: test.slider, EchoPreviewFocus: true}); !response.Accepted { t.Fatalf("echo preview rejected: %+v", response) } select { case got := <-spoken: if !strings.HasPrefix(got.text, "Key echo ") || got.module != "" || got.voice != "" { t.Fatalf("echo focus label did not use the normal voice: %+v", got) } case <-ctx.Done(): t.Fatal("echo focus label missing") } select { case got := <-spoken: if got.text != test.text || got.module != "preview-module" || got.voice != "Preview voice" || got.rate != "42" || got.pitch != "30" || got.volume != "100" { t.Fatalf("echo slider SSIP request = %+v", got) } case <-ctx.Done(): t.Fatal("echo slider SSIP request missing") } check(false, "", "") } if response := previewWorker.restorePreview(ctx); !response.Accepted { t.Fatalf("restore echo preview: %+v", response) } check(true, "echo-module", "62") checkCharacters := func(wantNormal string) { t.Helper() worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) for _, tc := range []struct { text string character bool pitch string }{ {"A", true, "50"}, {"a", true, wantNormal}, {"É", true, "50"}, {"o", true, wantNormal}, {"o", true, wantNormal}, {"猫", true, wantNormal}, {"7", true, wantNormal}, {"space", true, wantNormal}, {"ABC", false, wantNormal}, {"A", false, wantNormal}, } { if err := worker.deliver(ctx, speechRequest{Text: tc.text, Character: tc.character}); err != nil { t.Fatal(err) } select { case got := <-spoken: if got.pitch != tc.pitch || got.text != tc.text || got.module != "" { t.Fatalf("character %v: got %+v, want text %q pitch %q", tc.character, got, tc.text, tc.pitch) } case <-ctx.Done(): t.Fatal("character speech missing") } } } checkCharacters("") worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) // Exercise deletion from event presentation through the real SSIP client, // checking both uppercase pitch and restoration on the following letter. for _, custom := range []bool{false, true} { deletionSettings := settings deletionSettings.UseCustomEchoVoice = custom live := &liveSettings{} live.Store(deletionSettings) queue := &fakeSpeechQueue{spoken: make(chan string, 1), requests: make(chan speechRequest, 1)} presenter := eventPresenter{state: &eventLoopState{}, logger: slog.New(slog.DiscardHandler), speech: queue, live: live} for _, text := range []string{"A", "a", "É", "é"} { snapshot := atspi.Snapshot{BusName: ":1.42", Path: "/editor", Role: "entry", Editable: true} presenter.speak(atspi.NewEventWithPayload(atspi.TextDeletedEvent, snapshot, text)) request := <-queue.requests <-queue.spoken if err := worker.deliver(ctx, request); err != nil { t.Fatal(err) } got := <-spoken wantPitch, wantModule, wantRate := "", "", "" if text == "A" || text == "É" { wantPitch = "50" } if custom { wantModule, wantRate = "echo-module", "62" } if got.text != text || got.pitch != wantPitch || got.module != wantModule || got.rate != wantRate { t.Fatalf("deleted %q, custom=%v: %+v", text, custom, got) } } } // Wine insertion must use the same character voice as native typing. for _, custom := range []bool{false, true} { wineSettings := settings wineSettings.UseCustomEchoVoice = custom live := &liveSettings{} live.Store(wineSettings) queue := &remoteSpeechQueue{} braille := &remoteBrailleQueue{running: true} presenter := remoteSpeech{speech: queue, braille: braille, live: live} owner := newWineEventPresenter(func(message string) bool { return presenter.presentMessage(message, "wine-accessible-event") }) presenter.wine = owner presenter.PresentAccessibleEvent(remote.AccessibleEvent{SourceID: "42:text", EventType: "focus", Role: "edit"}) owner.flush() queue.requests, braille.text = nil, nil for _, text := range []string{"A", "a", "É", "é", "é", "𐐀", "𐐨", "😀", "space", "new line"} { if !presenter.PresentAccessibleEvent(remote.AccessibleEvent{ SourceID: "42:text", EventType: "text", Name: text, States: "start=1 end=1 kind=8", }) { t.Fatalf("Wine insertion %q rejected", text) } request := queue.requests[len(queue.requests)-1] if err := worker.deliver(ctx, request); err != nil { t.Fatal(err) } got := <-spoken wantPitch, wantModule, wantRate := "", "", "" if text == "A" || text == "É" || text == "𐐀" { wantPitch = "50" } if custom { wantModule, wantRate = "echo-module", "62" } if got.text != text || got.pitch != wantPitch || got.module != wantModule || got.rate != wantRate { t.Fatalf("Wine insertion %q, custom=%v: %+v; want pitch %q module %q rate %q", text, custom, got, wantPitch, wantModule, wantRate) } if braille.text[len(braille.text)-1] != text { t.Fatalf("Wine braille changed character case: %v", braille.text) } } owner.Close() } for _, text := range []string{"A", "a", "É"} { if err := worker.deliver(ctx, speechRequest{Text: text, Character: true, CustomEcho: true}); err != nil { t.Fatal(err) } got := <-spoken wantPitch := "50" if text == "a" { wantPitch = "" } if got.module != "echo-module" || got.rate != "62" || got.pitch != wantPitch { t.Fatalf("custom character echo = %+v; want pitch %s", got, wantPitch) } } capitalPreferences := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, speaker) capitalLive := &liveSettings{} capitalLive.Store(settings) capitalPreferences.live = capitalLive for mask := 0; mask < 8; mask++ { choices := settings echoPitch := 6.0 choices.EchoVoice.AveragePitch = &echoPitch choices.RaisePitchForCapitals = mask&1 != 0 choices.BeepForCapitals = mask&2 != 0 choices.SayCapForCapitals = mask&4 != 0 if response := capitalPreferences.apply(ctx, preferences.Request{Values: preferences.FromSettings(choices)}); !response.Accepted { t.Fatalf("capital choices Apply failed: %+v", response) } capitals := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) capitals.capitalization = func() (bool, bool) { current := capitalLive.Load() return current.BeepForCapitals, current.SayCapForCapitals } beeps := 0 capitals.capitalBeep = func(context.Context) error { beeps++; return nil } for _, custom := range []bool{false, true} { for _, tc := range []struct { text, cause, voice string character, capital bool }{ {"A", "character-echo", "", true, true}, {"É", "document-caret-character", "", true, true}, {"𐐀", "flat-review", "", true, true}, {"A", "key-echo", "", true, true}, {"a", "character-echo", "", true, false}, {"ABC", "word-echo", "", false, false}, {"A", "focus", "", false, false}, } { before := beeps request := speechRequest{Text: tc.text, Cause: tc.cause, VoiceType: tc.voice, Character: tc.character, CustomEcho: custom} if err := capitals.deliver(ctx, request); err != nil { t.Fatal(err) } wantPitch, wantModule := "", "" if custom { wantPitch, wantModule = "30", "echo-module" } if tc.capital && choices.RaisePitchForCapitals { wantPitch = "50" if custom { wantPitch = "70" } } if tc.capital && choices.SayCapForCapitals { got := <-spoken if got.text != "cap" || got.pitch != wantPitch || got.module != wantModule { t.Fatalf("mask %d cap prefix: %+v", mask, got) } } got := <-spoken if got.text != tc.text || got.pitch != wantPitch || got.module != wantModule { t.Fatalf("mask %d custom=%v request=%+v: %+v", mask, custom, request, got) } wantBeeps := 0 if tc.capital && choices.BeepForCapitals { wantBeeps = 1 } if beeps-before != wantBeeps { t.Fatalf("mask %d beeps=%d", mask, beeps-before) } } } // Phonetic spelling carries uppercase metadata despite lowercase words. for _, character := range []bool{false, true} { text := "alpha" if character { text = "A" } request := speechRequest{Spelling: []string{text, "b"}, SpellingVoices: []string{"uppercase", ""}, Character: character} before := beeps if err := capitals.deliver(ctx, request); err != nil { t.Fatal(err) } if choices.SayCapForCapitals { if got := <-spoken; got.text != "cap" { t.Fatalf("spelled prefix: %+v", got) } } if got := <-spoken; got.text != text { t.Fatalf("spelled capital: %+v", got) } if got := <-spoken; got.text != "b" || got.pitch != "" { t.Fatalf("spelled lowercase: %+v", got) } wantBeeps := 0 if choices.BeepForCapitals { wantBeeps = 1 } if beeps-before != wantBeeps { t.Fatal("spelling beep count") } } // Delayed arrow hints must not repeat any capital cues. Exercise all // capital combinations through SSIP, including a specialized source role. capitals.reading = func() config.ReadingSettings { return config.ReadingSettings{PhoneticCharacterHints: true} } capitals.inputActivity = func() input.Activity { return input.Activity{PlainCharacterArrow: true, InputSequence: 1} } before := beeps if err := capitals.deliver(ctx, speechRequest{Text: "T", Role: "system", Character: true, PhoneticHint: true}); err != nil { t.Fatal(err) } wantPitch := "" if choices.RaisePitchForCapitals { wantPitch = "50" } if choices.SayCapForCapitals { if got := <-spoken; got.text != "cap" || got.pitch != wantPitch { t.Fatalf("mask %d initial character cap prefix: %+v", mask, got) } } if got := <-spoken; got.text != "T" || got.pitch != wantPitch { t.Fatalf("mask %d initial character: %+v", mask, got) } if got := <-spoken; got.text != "tango" || got.pitch != "" || got.module != "" { t.Fatalf("mask %d delayed hint repeated capital or specialized voice: %+v", mask, got) } wantBeeps := 0 if choices.BeepForCapitals { wantBeeps = 1 } if beeps-before != wantBeeps { t.Fatalf("mask %d delayed hint repeated capital beep: %d", mask, beeps-before) } } if err := speaker.UpdateConfiguration(ctx, settings); err != nil { t.Fatal(err) } upperPitch, linkPitch, systemPitch := 8.0, 1.0, 2.0 settings.UppercaseVoice.AveragePitch = &upperPitch settings.HyperlinkVoice.AveragePitch = &linkPitch settings.SystemVoice.AveragePitch = &systemPitch if err := speaker.UpdateConfiguration(ctx, settings); err != nil { t.Fatal(err) } for _, tc := range []struct { request speechRequest pitch string }{ {speechRequest{Text: "A", Character: true}, "70"}, {speechRequest{Text: "A", Character: true, CustomEcho: true}, "70"}, {speechRequest{Text: "link text", Role: "link"}, "-70"}, {speechRequest{Text: "status text", Role: "system"}, "-50"}, {speechRequest{Text: "ordinary text"}, ""}, {speechRequest{Spelling: []string{"alpha", "bravo"}, SpellingVoices: []string{"uppercase", ""}}, "70"}, } { if err := worker.deliver(ctx, tc.request); err != nil { t.Fatal(err) } got := <-spoken if got.pitch != tc.pitch { t.Fatalf("voice selection for %+v = %+v", tc.request, got) } if len(tc.request.Spelling) != 0 { if got := <-spoken; got.pitch != "" { t.Fatalf("uppercase spelling pitch leaked: %+v", got) } } } // Verify real SSIP SPEAK requests from focus presentation, including source // punctuation, the selected special voice, and disabling pause chunks. for _, enabled := range []bool{true, false} { pauseSettings := settings pauseSettings.Reading.EnablePauseBreaks = enabled pauseSettings.EnableSound = false live := &liveSettings{} live.Store(pauseSettings) queue := &fakeSpeechQueue{spoken: make(chan string, 1), requests: make(chan speechRequest, 1)} presenter := eventPresenter{state: &eventLoopState{}, logger: slog.New(slog.DiscardHandler), speech: queue, live: live} presenter.speak(atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{Name: "Read, draft. Next!", Role: "link", Description: "Open document"})) if err := worker.deliver(ctx, <-queue.requests); err != nil { t.Fatal(err) } want := []string{"Read, draft. Next!", "link", "Open document"} if !enabled { want = []string{"Read, draft. Next! link Open document"} } for _, text := range want { select { case got := <-spoken: if got.text != text || got.pitch != "-70" || got.module != "" { t.Fatalf("pause chunks enabled=%v: SSIP utterance=%+v, want text %q with hyperlink pitch", enabled, got, text) } case <-ctx.Done(): t.Fatal("pause chunk SSIP request missing") } } if len(spoken) != 0 { t.Fatal("unexpected extra SSIP request") } } settings.UppercaseVoice, settings.HyperlinkVoice, settings.SystemVoice = config.Voice{}, config.Voice{}, config.Voice{} if err := speaker.UpdateConfiguration(ctx, settings); err != nil { t.Fatal(err) } settings.EchoSpeechServer = "rejected" if err := speaker.UpdateConfiguration(ctx, settings); err == nil { t.Fatal("invalid module accepted") } check(false, "", "") check(true, "echo-module", "62") settings.UseCustomEchoVoice = false if err := speaker.UpdateConfiguration(ctx, settings); err != nil { t.Fatal(err) } check(true, "", "") pitch := 3.0 settings.DefaultVoice.AveragePitch = &pitch if err := speaker.UpdateConfiguration(ctx, settings); err != nil { t.Fatal(err) } checkCharacters("-30") if wrapped { test_hardware_switch(t, speaker.(*configuredSpeaker), settings) check(false, "", "") checkCharacters("-30") } if err := speaker.Cancel(ctx); err != nil { t.Fatal(err) } if err := speaker.Close(ctx); err != nil { t.Fatal(err) } if err := speaker.UpdateConfiguration(ctx, settings); err == nil { t.Fatal("closed speaker revived") } }