diff --git a/README.md b/README.md index 6b2456f..4e2a820 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,11 @@ checkbox is needed. Under **Use the custom voice for:**, choose **Key names** These choices select the voice; the corresponding echo settings still control what is spoken. Turn off custom echo voice to use the main speech settings. +Key names finish before the resulting navigation or focus announcement starts. +For example, Right speaks "right" in the selected key echo voice, followed by +the character at the caret in the normal reading voice. A new key press still +interrupts the previous key's speech and discards its pending feedback. + Changing the custom echo synthesizer, person, rate, pitch or volume speaks the selected value using the echo voice and its draft settings, before Apply. Tab and Shift+Tab announce the field label in the main voice, followed by its diff --git a/internal/app/echo_speaker_test.go b/internal/app/echo_speaker_test.go index ed28a41..0ac3f44 100644 --- a/internal/app/echo_speaker_test.go +++ b/internal/app/echo_speaker_test.go @@ -159,6 +159,22 @@ func test_echo_speaker(t *testing.T, wrapped bool) { } 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) + } + } // 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) diff --git a/internal/app/speech_delivery.go b/internal/app/speech_delivery.go index 24e0fc2..9a33e2c 100644 --- a/internal/app/speech_delivery.go +++ b/internal/app/speech_delivery.go @@ -174,6 +174,22 @@ func (worker *speechWorker) deliver(ctx context.Context, request speechRequest) speak = echo.SpeakEcho } } + // A key name and its resulting caret/focus feedback arrive separately. + // Wait for the key name's END before the next request can cancel it, + // including when the echo voice uses a different synthesizer. New input + // still cancels active speech through the worker's cancellation barrier. + if request.Cause == "key-echo" { + if voiced, ok := worker.speaker.(interface { + SpeakVoiceAndWait(context.Context, string, string, func()) error + }); ok { + if request.CustomEcho { + kind = "echo" + } + speak = func(ctx context.Context, text string) error { + return voiced.SpeakVoiceAndWait(ctx, text, kind, nil) + } + } + } chunks := request.Chunks if len(chunks) == 0 { chunks = []string{request.Text} diff --git a/internal/app/speech_key_echo_test.go b/internal/app/speech_key_echo_test.go new file mode 100644 index 0000000..7c2e5c4 --- /dev/null +++ b/internal/app/speech_key_echo_test.go @@ -0,0 +1,87 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "fmt" + "log/slog" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/input" +) + +type keyEchoCompletionSpeaker struct{ controlledSpeaker } + +func (speaker *keyEchoCompletionSpeaker) SpeakEcho(ctx context.Context, text string) error { + return speaker.call(ctx, "queued:echo:"+text) +} + +func (speaker *keyEchoCompletionSpeaker) SpeakVoiceAndWait(ctx context.Context, text, voice string, _ func()) error { + return speaker.call(ctx, "completed:"+voice+":"+text) +} + +func TestSpeechWorkerKeyEchoCompletesBeforeCaretSpeech(t *testing.T) { + for _, custom := range []bool{false, true} { + t.Run(fmt.Sprint(custom), func(t *testing.T) { + speaker := &keyEchoCompletionSpeaker{controlledSpeaker{calls: make(chan speechCall, 8)}} + worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) + if err := worker.Start(t.Context()); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { worker.Stop(context.Background()) }) + worker.Enqueue(speechRequest{Text: "right", Role: "key", Cause: "key-echo", CustomEcho: custom}) + close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done) + voice := "" + if custom { + voice = "echo" + } + echo := expectSpeechCall(t, &speaker.controlledSpeaker, "completed:"+voice+":right") + worker.Enqueue(speechRequest{Text: "h", Role: "entry", Cause: "object:text-caret-moved", Character: true}) + select { + case call := <-speaker.calls: + t.Fatalf("caret output overtook key echo completion: %s", call.text) + default: + } + close(echo.done) + close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done) + close(expectSpeechCall(t, &speaker.controlledSpeaker, "h").done) + }) + } +} + +func TestSpeechWorkerKeyEchoWaitStillAllowsInputInterruption(t *testing.T) { + speaker := &keyEchoCompletionSpeaker{controlledSpeaker{calls: make(chan speechCall, 8)}} + worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) + if err := worker.Start(t.Context()); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { worker.Stop(context.Background()) }) + worker.Enqueue(speechRequest{Text: "right", Role: "key", Cause: "key-echo", CustomEcho: true}) + close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done) + echo := expectSpeechCall(t, &speaker.controlledSpeaker, "completed:echo:right") + worker.Enqueue(speechRequest{Text: "stale character", Cause: "object:text-caret-moved"}) + if !worker.InterruptForInput(input.Event{Pressed: true, Name: "Left", Timestamp: 100}) { + t.Fatal("new input did not interrupt key echo") + } + close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done) + close(echo.done) // Backend reports cancellation of the old echo. + worker.Enqueue(speechRequest{Text: "left", Role: "key", Cause: "key-echo", CustomEcho: true}) + for { + select { + case call := <-speaker.calls: + close(call.done) + if call.text == "completed:echo:left" { + return + } + if call.text != "cancel" { + t.Fatalf("stale speech survived new input: %s", call.text) + } + case <-time.After(time.Second): + t.Fatal("new key echo did not resume after interruption") + } + } +}