Attempt to fix the rest of speech interrupting when it shouldn't. Also play radio buttons after the ui after they figure out if they are checked or not.

This commit is contained in:
Storm Dragon committed 2026-10-19 19:25:11 -04:00
1 parent 2c6ab2ce76
commit 82887b3897
64 files changed
+501 -225

No files matched your search

+15
View File
@@ -50,6 +50,21 @@ LightDM or audio, restart a reader, or create a restricted login mode.
Native X11 grab-window regression:
`python3 tests/native_test_session.py env CTHULHU_NATIVE_GRAB_FOREGROUND=1 go test ./internal/platform/xorg -run '^TestNativeInputOnlyGrabForeground$' -count=1`.
## Speech interruption
Incoming announcements queue in order. Focus, selection, state changes,
notifications, tooltips and terminal output let earlier speech finish.
Passive announcements also queue behind Say All. A new key press or reader
command stops earlier speech and clears its pending announcements. Delayed
copies of the key that started a command preserve that command's response.
Screen lock, shutdown, speech configuration changes and explicit stop requests
retain their lifecycle behavior. Existing Tentacle requests marked
uninterruptible retain their bounded protection. The speech queue remains
bounded against excessive output.
See [speech queue verification](docs/testing/speech-queue.md) for automated
checks and the listening procedure.
## Key echo
The Key Echo preferences page offers three radio choices: Disable key echo,
+66
View File
@@ -0,0 +1,66 @@
# Speech queue verification
Ordinary output always appends. Cancellation belongs to physical key input,
reader command dispatch and explicit stop requests. An announcement arriving
later from the same action must let earlier speech finish. This includes focus,
selection, state, notification and delayed review output. Accepted Tentacle
speech follows the same rule when its originating focus/session retires;
admission still rejects stale replies and Braille retains its own ownership.
External `SpeakText` announcements also queue regardless of their interrupt
flag; `CancelSpeech` remains an explicit stop request.
Run:
```sh
go test ./internal/app ./internal/input ./internal/speech -count=1
python3 tests/check_speech.py
go build -o /tmp/cthulhu-radio-sound ./cmd/cthulhu
CTHULHU_BINARY=/tmp/cthulhu-radio-sound CTHULHU_PREFS_RADIO_RUNTIME_ONLY=1 /usr/bin/python3 tests/native_preferences.py
```
The required radio-selection test sends Laptop focus and checked-state events
through the event loop and speech worker, with a controlled backend that fails
on an unexpected cancellation. Existing tests cover the cancellation barrier,
Say All, key echo voice ordering, initiating-key timestamps, title repeats,
screen lock, internal UI and Tentacle output. These checks establish software
ordering, without proving audible completion or physical input on a Pi.
The private GTK radio test drives Right/Left between Desktop and Laptop. It
requires one selected sound per move, with the label retained. It uses private
Xvfb, D-Bus and a fake speech server; GStreamer auto audio is restricted to the
fake audio sink in the child reader. It never uses the desktop speech server or
audio devices. Debug sound records identify playback attempts, not audible
output. GStreamer's Python bindings and `fakeaudiosink` are required.
Unchecked radio focus sounds wait up to 250 milliseconds for a following
checked event. That event replaces the pending sound immediately. Speech and
Braille remain immediate, and a radio that stays unchecked gets its original
unchecked cue. Fresh input, changed focus/window, lock and handoff retire the
pending cue. Native state-only snapshots retain the owned mnemonic so a
repeated focus event does not replay the selected sound and label.
Before submission, review the diff and rebuild the reader. Restart the active
reader only with the user's authorization. Then listen on the target desktop
and Pi:
1. In keyboard preferences, change Desktop to Laptop, release the key and wait.
Laptop should finish, followed by the role/state announcements. Repeat in
the other direction and test radio buttons, checkboxes and combo boxes.
2. During a long announcement, press Control. Repeat with typing, an arrow,
Tab and a reader command. Old speech and its queued feedback should stop;
the new action should speak normally. Releasing keys should leave speech
alone. Test rapid presses and held arrows, including automatic titles.
3. Leave a long announcement playing while a notification or terminal output
arrives. Both should finish in order. Repeat during Say All, then stop it
with a fresh key and check that the reading position has stopped advancing.
4. With a custom echo voice, press Right in an entry. The key name should finish
before the caret character. A new key should still stop stale feedback.
5. Exercise browser navigation, Where Am I, action/list dialogs and preferences
Apply. Check that each fresh command responds promptly and delayed results
preserve announcements from the same action. Check Braille focus/state and
confirmation restoration separately.
Screen lock still clears speech for privacy. Disabling speech, replacing its
configuration/backend and shutdown retain their existing behavior. Protected
Tentacle messages retain their explicit timeout and cancellation rules. The
bounded queue can discard pending requests on overflow and logs that loss.
-1
View File
@@ -25,7 +25,6 @@ func (ui *internalUI) browserListMessage(message string) {
if ui.gate.Locked() || ui.gate.Suspended() {
return
}
ui.speech.Interrupt()
ui.speech.Enqueue(speechRequest{Text: message, Role: "system", Cause: "browser-list"})
if ui.braille != nil {
ui.braille.EnqueueMessage(message)
-4
View File
@@ -247,9 +247,6 @@ func (browser *browserModeController) set(mode review.DocumentMode, speech speec
text = "Focus mode"
cue = "focus_mode"
}
if manual {
speech.Interrupt()
}
// Append automatic changes so they do not replace the focused control.
speech.Enqueue(speechRequest{Text: text, Role: "system", Sound: []sound.Cue{{Name: cue}}, Cause: "document-mode", Append: !manual})
}
@@ -466,6 +463,5 @@ func (browser *browserModeController) sticky(mode review.DocumentMode, speech sp
if mode == review.FocusMode {
text = "Focus mode is sticky."
}
speech.Interrupt()
speech.Enqueue(speechRequest{Text: text, Role: "system", Cause: "document-mode"})
}
-1
View File
@@ -113,7 +113,6 @@ func presentCharacterAttributes(speech speechEnqueuer, result characterAttribute
message = "No text attributes to speak."
}
}
speech.Interrupt()
speech.Enqueue(speechRequest{Text: message, Role: "system", Cause: "character-attributes"})
}
-1
View File
@@ -35,7 +35,6 @@ func runClipboardWorker(ctx context.Context, requests <-chan struct{}, speech sp
if err == nil {
message = result
}
speech.Interrupt()
speech.Enqueue(speechRequest{Text: message, Role: "system", Cause: "clipboard"})
}
}
+2 -2
View File
@@ -38,8 +38,8 @@ func TestClipboardWorkerAnnouncesContentAndAccessError(t *testing.T) {
case <-time.After(time.Second):
t.Fatal("clipboard announcement missing")
}
if len(speech.interrupt) != 1 {
t.Fatal("clipboard announcement did not interrupt prior speech")
if len(speech.interrupt) != 0 {
t.Fatal("clipboard result interrupted earlier speech")
}
cancel()
select {
+1 -1
View File
@@ -81,7 +81,7 @@ func TestCommandSpeechSurvivesDelayedInitiatingInput(t *testing.T) {
t.Fatalf("command interrupted by initiating input at %d", timestamp)
}
}
if len(worker.pending) != 1 || worker.generation != 0 {
if len(worker.pending) != 1 || worker.generation != 1 {
t.Fatal("command speech was discarded")
}
if !worker.InterruptForInput(input.Event{Pressed: true, Timestamp: 101}) || len(worker.pending) != 0 {
+27
View File
@@ -298,3 +298,30 @@ func TestFocusedTreeSelectionStateChangesRemainAudible(t *testing.T) {
}
}
}
// Drive focus and checked-state events through the real event loop and speech
// worker. The backend call stays in flight while the selection arrives, so any
// automatic CANCEL (including one after SPEAK returns) fails the expected order.
func TestSpeechWorkerRadioSelectionDoesNotCancelLabel(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
speaker := &controlledSpeaker{calls: make(chan speechCall, 8)}
worker := newSpeechWorker(logger, speaker)
if err := worker.Start(t.Context()); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { worker.Stop(context.Background()) })
adapter := &fakeAdapter{events: make(chan atspi.Event, 2)}
option := atspi.Snapshot{BusName: ":1.7", Path: "/option", Name: "Laptop", Role: "radio button"}
adapter.events <- atspi.NewEvent(atspi.FocusEvent, option)
option.Checked = true
selected := atspi.NewEvent("object:state-changed:checked", option)
selected.Detail1 = 1
adapter.events <- selected
close(adapter.events)
if err := processEvents(t.Context(), logger, worker, adapter, 0); err != nil {
t.Fatal(err)
}
for _, want := range []string{"Laptop", "radio button", "not selected", "selected"} {
close(expectSpeechCall(t, speaker, want).done)
}
}
-1
View File
@@ -21,6 +21,5 @@ func presentDateTime(speech speechEnqueuer, settings config.Settings, command in
} else {
text, _ = datetime.FormatTime(settings.TimeFormat, now)
}
speech.Interrupt()
speech.Enqueue(speechRequest{Text: text, Role: "system", Cause: cause})
}
+2 -2
View File
@@ -68,8 +68,8 @@ func TestDateTimeFormats(t *testing.T) {
if got := <-queue.spoken; got != tc.want {
t.Errorf("%s: %q want %q", tc.format, got, tc.want)
}
if len(queue.interrupt) != 1 {
t.Fatal("command did not interrupt")
if len(queue.interrupt) != 0 {
t.Fatal("delayed command result interrupted speech again")
}
}
}
-2
View File
@@ -286,7 +286,6 @@ func presentDocumentCaretResult(logger *slog.Logger, speech speechEnqueuer, resu
fallbackSpeech := presentation.DocumentLineSpeechWithOptions(line, fallback)
request.FallbackText, request.FallbackChunks = fallbackSpeech.Text, fallbackSpeech.Chunks
}
speech.Interrupt()
if request.Text == "" && len(request.Sound) == 0 {
return
}
@@ -452,7 +451,6 @@ func presentDocumentResult(logger *slog.Logger, speech speechEnqueuer, result re
logger.Warn("document caret synchronization failed", "error", result.CaretErr)
}
}
speech.Interrupt()
voiceType := ancestor_voice(result.DocumentCaretContext)
if result.DocumentPosition.LinkedHeading != nil {
voiceType = "hyperlink"
+2 -4
View File
@@ -73,10 +73,8 @@ func TestDocumentPresentation(t *testing.T) {
if got := <-queue.spoken; got != tc.want {
t.Fatalf("got %q want %q", got, tc.want)
}
select {
case <-queue.interrupt:
default:
t.Fatal("did not interrupt speech")
if len(queue.interrupt) != 0 {
t.Fatal("delayed document result interrupted speech again")
}
}
}
+1 -1
View File
@@ -93,6 +93,7 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) {
"focused", event.Source.Focused,
"browser_tab", event.Source.BrowserTab,
"selected", event.Source.Selected,
"checked", event.Source.Checked,
"radio_group_name", event.Source.RadioGroupName,
"prompt_message_present", event.Source.Text != "" && (event.Source.Role == "frame" || event.Source.Role == "alert") || event.WindowMessage != "",
"snapshot_duration", event.SnapshotDuration,
@@ -217,7 +218,6 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) {
status := strings.TrimSpace(event.Source.Name)
if state.lastFileManagerStatusWindow != state.owner.window || status != state.lastFileManagerStatus {
state.lastFileManagerStatusWindow, state.lastFileManagerStatus = state.owner.window, status
speech.Interrupt()
speech.Enqueue(speechRequest{Text: status, Role: "status bar", Cause: event.Type})
if brailleOutput != nil {
brailleOptions := config.DefaultSettings().Braille
+7
View File
@@ -23,6 +23,13 @@ func (handler *accessibilityEventHandler) acceptFocusedEvent(event atspi.Event,
browser := handler.browser
brailleOutput := handler.brailleOutput
live := handler.live
// Native state-only captures omit mnemonic lookup. Preserve the owned
// value so GTK's following focus event compares against the same metadata
// and does not replay the just-announced state sound and label.
if isStateChangeEvent(event.Type) && event.Source.ID() == state.current.ID() &&
event.Source.Role == state.current.Role && event.Source.Mnemonic == "" {
event.Source.Mnemonic = state.current.Mnemonic
}
// Compare with the owned focus snapshot, not the last speech request:
// a state/value update can intervene between two toolkit focus events.
// Still refresh review and braille below before suppressing presentation.
-1
View File
@@ -54,7 +54,6 @@ func (handler *accessibilityEventHandler) acceptFocusOwnership(event atspi.Event
text += event.WindowMessage
}
if text != "" {
speech.Interrupt()
speech.Enqueue(speechRequest{Text: text, Role: "frame", Cause: event.Type,
Window: eventWindow(event), IntroducesWindow: true,
ExcludeFromHistory: state.historyWindow.Valid() && eventWindow(event) == state.historyWindow})
+4 -1
View File
@@ -195,7 +195,7 @@ func processEventsWithAttributes(
// Wine control revisions reset object review, but a pending title and
// its duplicate history belong to the foreground window's lifetime.
// These notices carry metadata, not an instruction to stop speech.
// Foreground switches and user input already own speech cancellation.
// User input owns speech cancellation; metadata changes preserve it.
clearWineReviewFocus := func() {
ocrSession.Retire()
state.clearObjectFocus(brailleOutput, browser, objectReview)
@@ -299,6 +299,9 @@ func processEventsWithAttributes(
case <-state.pending.browserButtonName.ready:
syncForeground()
eventHandler.flush_browser_button_name()
case <-state.pending.radioSound.ready:
syncForeground()
presenter.flush_radio_sound(focusGate)
case <-state.pending.caret.ready:
if focusGate.Locked() {
state.pending.clear()
-2
View File
@@ -15,7 +15,6 @@ import (
)
func presentNotifications(speech speechEnqueuer, messages []string) {
speech.Interrupt()
for _, message := range messages {
// Notifications remain audible while XTerm owns keyboard handling.
// The output gate still rejects these requests during screen lock.
@@ -53,7 +52,6 @@ func (announcer *notificationAnnouncer) Announce(entry notification.Entry) {
Urgency: entry.Urgency,
DesktopEntry: entry.DesktopEntry,
})
announcer.speech.Interrupt()
message := entry.Message
if announcer.live != nil && !announcer.live.Load().Reading.MessagesAreDetailed && entry.Summary != "" {
message = entry.Summary
+1
View File
@@ -172,6 +172,7 @@ func (presenter *eventPresenter) speak(event atspi.Event, capturedIntent ...inpu
// The control's own focus event will present its role and cue.
cues = nil
}
cues = presenter.coalesce_radio_sound(event, cues)
cueParts := make([]string, 0, len(cues))
for _, cue := range cues {
cueParts = append(cueParts, cue.Name+":"+strconv.FormatFloat(cue.Pan, 'g', -1, 64))
-3
View File
@@ -55,7 +55,6 @@ func (handler *eventReviewHandler) whereAmI() {
text = "Could not find current location."
role = "system"
}
handler.speech.Interrupt()
handler.speech.Enqueue(speechRequest{Text: text, Chunks: spoken.WithText(text).Chunks, Role: role, Cause: "where-am-i"})
handler.logger.Debug("queued where am I presentation", "role", role)
}
@@ -137,7 +136,6 @@ func (handler *eventReviewHandler) handle(result review.Result) {
fallbackOptions := state.presentationOptions
fallbackOptions.RoleSoundPresentation = "sound_and_speech"
fallback := presentation.DocumentLineSpeechWithOptions(result.DocumentLineData, fallbackOptions)
speech.Interrupt()
if text == "" && len(cues) == 0 {
return
}
@@ -262,7 +260,6 @@ func presentObjectReviewResult(logger *slog.Logger, speech speechEnqueuer, resul
role = "system"
}
}
speech.Interrupt()
speech.Enqueue(speechRequest{Text: text, Chunks: spoken.WithText(text).Chunks, Role: role, Cause: "object-review"})
logger.Debug("queued object review presentation", "direction", result.Direction.String(), "moved", result.Moved, "role", role)
}
-1
View File
@@ -133,7 +133,6 @@ func (state *eventLoopState) ownedAutocompletePopup(event atspi.Event) bool {
// closure. Session presentation history and chrome load verification survive.
func (state *eventLoopState) clearOwnedFocus(brailleOutput brailleFrameEnqueuer, browser *browserModeController, objectReview *review.Worker, speech speechEnqueuer) {
state.clearObjectFocus(brailleOutput, browser, objectReview)
speech.Interrupt()
}
// Review metadata can change without new input or a foreground switch. Reset
-1
View File
@@ -23,7 +23,6 @@ func (handler *accessibilityEventHandler) handleTooltip(event atspi.Event) bool
options := presentationSettingsFromConfig(handler.live.Load(), true)
spoken := presentation.FocusSpeechWithOptions(event.Source, options)
if text := strings.TrimSpace(spoken.Text); text != "" && text != "tool tip" {
handler.speech.Interrupt()
handler.speech.Enqueue(speechRequest{Text: text, Chunks: spoken.WithText(text).Chunks, Role: "system", Cause: "tooltip"})
}
return true
-1
View File
@@ -80,7 +80,6 @@ func presentFlatReviewResult(logger *slog.Logger, speech speechEnqueuer, result
} else {
text = presentation.FlatReviewText(result.FlatPosition.Text, result.FlatUnit == review.Character || result.FlatUnit == review.LineEnd)
}
speech.Interrupt()
request := speechRequest{Text: text, Role: "text", Cause: "flat-review", Character: result.Err == nil && (result.FlatUnit == review.Character || result.FlatUnit == review.LineEnd)}
if result.Err == nil && result.FlatUnit == review.Line {
request.Line = &speechLine{Object: result.FlatPosition.Object, Raw: result.FlatPosition.Text}
-6
View File
@@ -64,12 +64,6 @@ func (gate *focusGate) SetSuspended(suspended bool) bool {
return false
}
gate.retireTentacleOutput()
if suspended {
gate.tentacleOutput.Stop()
}
if suspended && !gate.secure.Load() && gate.speech != nil {
gate.speech.Interrupt()
}
if gate.commands != nil {
gate.commands.SetCommandGrabs(!suspended && !gate.secure.Load())
}
+1 -1
View File
@@ -168,7 +168,7 @@ int main(void) {
case <-ctx.Done():
t.Fatal("presentation did not complete")
}
if got := presenter.interrupts.Load(); got != int32(index+1) {
if got := presenter.interrupts.Load(); got != 0 {
t.Fatalf("feedback %d interrupts=%d", index, got)
}
if got := display.current().Text; got != message {
-11
View File
@@ -122,11 +122,6 @@ func (ui *internalUI) present(output interaction.Presentation) {
if ui.gate.Locked() || ui.gate.Suspended() {
return
}
if speech, ok := ui.speech.(interface{ InterruptInternalUI() }); ok {
speech.InterruptInternalUI()
} else {
ui.speech.Interrupt()
}
if output.Speech != "" {
ui.speech.Enqueue(speechRequest{Text: output.Speech, Role: "system", Cause: "internal-ui"})
}
@@ -150,11 +145,6 @@ func (ui *internalUI) run(ctx context.Context) {
closeErr = ui.keys.EndInternalUI(closeCtx, sessionID)
cancel()
}
if speech, ok := ui.speech.(interface{ InterruptInternalUI() }); ok {
speech.InterruptInternalUI()
} else {
ui.speech.Interrupt()
}
if ui.braille != nil {
ui.braille.SetInternalUIFrame(nil)
}
@@ -263,7 +253,6 @@ func (ui *internalUI) run(ctx context.Context) {
if err != nil {
message = "Actions unavailable"
}
ui.speech.Interrupt()
ui.speech.Enqueue(speechRequest{Text: message, Role: "system", Cause: "internal-ui"})
if ui.braille != nil {
ui.braille.EnqueueMessage(message)
-2
View File
@@ -44,7 +44,6 @@ func (presenter *learnModePresenter) handle(event input.CommandEvent) {
if entering {
presenter.active = true
presenter.lastTime = 0
presenter.speech.Interrupt()
presenter.speech.Enqueue(speechRequest{Text: version.String(), Role: "system", Cause: "learn-mode"})
presenter.speech.Enqueue(speechRequest{Text: learnModeInstructions, Role: "system", Cause: "learn-mode"})
presenter.braille.EnqueueMessage("Learn mode. Press Escape, Cthulhu H, or Cthulhu F1 to exit.")
@@ -79,7 +78,6 @@ func (presenter *learnModePresenter) key(event input.CommandEvent) {
if !presenter.active {
return
}
presenter.speech.Interrupt()
name := spokenLearnKey(event)
presenter.present(name, "learn-key")
settings := presenter.live.Load()
+1 -1
View File
@@ -50,7 +50,7 @@ func TestLearnModePresentsKeyCommandsWithoutExecutingThem(t *testing.T) {
t.Fatalf("phonetic double press = %q", got)
}
presenter.handle(input.CommandEvent{Command: input.ToggleLearnMode, Chord: input.CommandChord{Symbol: 'h', CthulhuModifier: true}, KeyName: "h"})
if presenter.active || speech.texts[len(speech.texts)-1] != "Exiting learn mode." || speech.interrupts < 4 {
if presenter.active || speech.texts[len(speech.texts)-1] != "Exiting learn mode." || speech.interrupts != 0 {
t.Fatalf("exit speech=%q interrupts=%d", speech.texts, speech.interrupts)
}
}
-1
View File
@@ -100,6 +100,5 @@ func (handler *eventReviewHandler) presentCellFormula() {
handler.syncForeground()
handler.state.pending.clear()
text := presentation.LibreOfficeFormulaText(handler.state.current)
handler.speech.Interrupt()
handler.speech.Enqueue(speechRequest{Text: text, Role: "system", Cause: "cell-formula"})
}
-1
View File
@@ -58,7 +58,6 @@ func (handler *eventReviewHandler) configureCalcHeader(command input.Command) {
}
}
state.pending.clear()
handler.speech.Interrupt()
handler.speech.Enqueue(speechRequest{Text: text, Role: "system", Cause: "calc-headers"})
}
-1
View File
@@ -102,7 +102,6 @@ func (handler *accessibilityEventHandler) handleLibreOfficeSelection(event atspi
}
if text := strings.Join(messages, " "); text != "" {
state.pending.clear()
handler.speech.Interrupt()
handler.speech.Enqueue(speechRequest{Text: text, Role: "system", Cause: "calc-selection"})
}
}
-6
View File
@@ -144,18 +144,12 @@ func mouseReviewService(logger *slog.Logger, adapter pointReviewAdapter, speech
state.position = position
continue
}
objectChanged := point.Snapshot.ID() != state.last.Snapshot.ID() || point.Window != state.last.Window
dx, dy := position.X-state.position.X, position.Y-state.position.Y
interrupt := objectChanged || dx*dx+dy*dy > 25*25
spoken := state.presentation(point, settings)
message := spoken.Text
state.position = position
if message == "" {
continue
}
if interrupt {
speech.Interrupt()
}
speech.Enqueue(speechRequest{Text: message, Chunks: spoken.Chunks, Role: point.Snapshot.Role, Cause: "mouse-review"})
braille.EnqueueMessage(message)
logger.Debug("mouse review presentation", "bus", point.Snapshot.BusName,
@@ -68,11 +68,6 @@ func TestProcessEventsWhereAmIRepeatsCurrentFocus(t *testing.T) {
}
reviewRequests <- input.WhereAmI
select {
case <-speech.interrupt:
case <-time.After(time.Second):
t.Fatal("where am I did not interrupt stale speech")
}
select {
case text := <-speech.spoken:
if text != "Example, button, Runs the example" {
t.Fatalf("where am I speech = %q", text)
-2
View File
@@ -28,14 +28,12 @@ func TestPauseChunksInterruptRemainingAnnouncement(t *testing.T) {
logger: slog.New(slog.DiscardHandler), speech: worker}
presenter.state.presentationOptions = presentation.DefaultOptions()
presenter.speak(atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{Name: "Save, draft", Role: "button", Description: "Save document"}))
close(expectSpeechCall(t, speaker, "cancel").done)
first := expectSpeechCall(t, speaker, "Save, draft")
worker.InterruptForUser()
close(expectSpeechCall(t, speaker, "cancel").done)
worker.Enqueue(speechRequest{Text: "New focus"})
close(first.done)
close(expectSpeechCall(t, speaker, "cancel").done) // Settle the interrupted call.
close(expectSpeechCall(t, speaker, "cancel").done) // Begin the new announcement.
close(expectSpeechCall(t, speaker, "New focus").done)
}
+2
View File
@@ -49,6 +49,7 @@ type pendingPresentations struct {
terminalFocus pendingPresentation
terminalDeletion pendingPresentation
browserButtonName pendingBrowserButtonName
radioSound pendingRadioSound
}
func (pending *pendingPresentations) clear() {
@@ -56,4 +57,5 @@ func (pending *pendingPresentations) clear() {
pending.terminalFocus.clear()
pending.terminalDeletion.clear()
pending.browserButtonName.clear()
pending.radioSound.clear()
}
@@ -241,7 +241,6 @@ func TestEchoPreviewFocusSpeechWaitsForBothVoices(t *testing.T) {
}
t.Cleanup(func() { worker.Stop(context.Background()) })
worker.Enqueue(echo_preview_speech("Key echo person", "Selected voice"))
close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done)
label := expectSpeechCall(t, &speaker.controlledSpeaker, "default:Key echo person")
worker.Enqueue(speechRequest{Text: "Later control"})
select {
@@ -257,7 +256,6 @@ func TestEchoPreviewFocusSpeechWaitsForBothVoices(t *testing.T) {
default:
}
close(value.done)
close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done)
close(expectSpeechCall(t, &speaker.controlledSpeaker, "Later control").done)
}
@@ -269,7 +267,6 @@ func TestEchoPreviewFocusSpeechRemainsInterruptible(t *testing.T) {
}
t.Cleanup(func() { worker.Stop(context.Background()) })
worker.Enqueue(echo_preview_speech("Key echo person", "Selected voice"))
close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done)
label := expectSpeechCall(t, &speaker.controlledSpeaker, "default:Key echo person")
close(label.done)
value := expectSpeechCall(t, &speaker.controlledSpeaker, "echo:Selected voice")
@@ -36,16 +36,13 @@ func TestPreferencesConfirmationSurvivesAutomaticFocusUntilSpeechEnds(t *testing
}
t.Cleanup(func() { worker.Stop(context.Background()) })
worker.Enqueue(speechRequest{Text: "Settings applied", Confirmation: true})
close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done)
confirmation := expectSpeechCall(t, &speaker.controlledSpeaker, "Settings applied")
worker.Interrupt() // The window activated after OK must not cancel feedback.
worker.Enqueue(speechRequest{Text: "Window title"})
worker.Enqueue(speechRequest{Text: "Focused control"})
close(confirmation.done) // Represents the synth's END event, not SPEAK acknowledgement.
close(expectSpeechCall(t, &speaker.controlledSpeaker, "Window title").done)
close(expectSpeechCall(t, &speaker.controlledSpeaker, "Focused control").done)
worker.Enqueue(speechRequest{Text: "Later output"})
close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done)
close(expectSpeechCall(t, &speaker.controlledSpeaker, "Later output").done)
}
@@ -63,7 +60,6 @@ func TestPreferencesConfirmationAllowsUserInterruption(t *testing.T) {
gate := &focusGate{braille: display}
output := gatedSpeech{speech: worker, gate: gate}
worker.Enqueue(speechRequest{Text: "Settings applied", Confirmation: true})
close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done)
confirmation := expectSpeechCall(t, &speaker.controlledSpeaker, "Settings applied")
worker.Enqueue(speechRequest{Text: "Discarded focus"})
if !output.InterruptForInput(input.Event{Pressed: true, Name: "Tab", Timestamp: 101}) {
+85
View File
@@ -0,0 +1,85 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/presentation"
"git.stormux.org/storm/cthulhu/internal/sound"
)
const radioFocusSoundDelay = 250 * time.Millisecond
// GTK can focus an unchecked radio before selecting it. Retain one state cue
// in the event loop while speech and Braille update immediately. A checked
// event replaces it; focus/input changes, lock and shutdown retire it.
type pendingRadioSound struct {
pendingPresentation
inputSequence uint64
}
func (presenter *eventPresenter) coalesce_radio_sound(event atspi.Event, cues []sound.Cue) []sound.Cue {
pending := &presenter.state.pending.radioSound
if isFocusGainEvent(event) && event.Source.ID() != pending.event.Source.ID() {
pending.clear()
}
if event.Source.Role != "radio button" {
return cues
}
if event.Type == "object:state-changed:checked" || event.Source.Checked {
pending.clear()
return cues
}
if !isFocusGainEvent(event) {
return cues
}
for index, cue := range cues {
if cue.Name != "radiobutton_unchecked" {
continue
}
sequence := recentInput(presenter.inputSource).InputSequence
if pending.event.Source.ID() != event.Source.ID() || pending.inputSequence != sequence || pending.ready == nil {
pending.schedule(event, input.NoIntent, radioFocusSoundDelay)
pending.inputSequence = sequence
presenter.logger.Debug("deferred unchecked radio focus sound")
}
retained := append([]sound.Cue(nil), cues[:index]...)
return append(retained, cues[index+1:]...)
}
return cues
}
func (presenter *eventPresenter) flush_radio_sound(gate *focusGate) {
pending := &presenter.state.pending.radioSound
sequence := pending.inputSequence
event, _ := pending.take()
state := presenter.state
if event.Type == "" || gate.Locked() || gate.Suspended() ||
state.focusedID != event.Source.ID() || state.current.ID() != event.Source.ID() ||
state.current.Defunct || state.owner.window != eventWindow(event) ||
recentInput(presenter.inputSource).InputSequence != sequence {
return
}
options := state.presentationOptions
if presenter.live != nil {
options = presentationSettingsFromConfig(presenter.live.Load(), true)
options.SoundCueAvailable = state.presentationOptions.SoundCueAvailable
}
event.Type = "object:state-changed:checked"
event.Source = state.current
// Admission already verified the cue. Let the output queue provide its
// normal speech fallback if the asset disappears before delayed playback.
options.SoundCueAvailable = nil
cues := presentation.EventSoundCues(event, options)
if len(cues) == 0 {
return
}
fallback := presentationFallbackSpeech(event, options)
presenter.speech.Enqueue(speechRequest{Sound: cues, FallbackText: fallback.Text, FallbackChunks: fallback.Chunks,
Role: event.Source.Role, Cause: "radio-focus-state"})
presenter.logger.Debug("queued settled radio focus sound", "cue", cues[0].Name)
}
+146
View File
@@ -0,0 +1,146 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"log/slog"
"strings"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/presentation"
"git.stormux.org/storm/cthulhu/internal/sound"
)
func radio_sound_fixture(t *testing.T) (*eventPresenter, *fakeSpeechQueue, func(atspi.Event)) {
t.Helper()
logger := slog.New(slog.DiscardHandler)
state := &eventLoopState{}
state.presentationOptions = presentation.DefaultOptions()
state.presentationOptions.EnableSound = true
state.presentationOptions.PlaySoundForState = true
state.presentationOptions.RoleSoundPresentation = "sound_only"
queue := &fakeSpeechQueue{spoken: make(chan string, 8), requests: make(chan speechRequest, 8)}
presenter := &eventPresenter{state: state, logger: logger, speech: queue}
handler := &accessibilityEventHandler{state: state, logger: logger}
t.Cleanup(state.pending.clear)
return presenter, queue, func(event atspi.Event) {
if !handler.acceptFocusedEvent(event, input.Activity{}) {
presenter.speak(event)
}
}
}
func TestRadioFocusSoundCoalescesSelectionWithoutDelayingLabel(t *testing.T) {
presenter, queue, present := radio_sound_fixture(t)
option := atspi.Snapshot{BusName: ":1.7", Path: "/radio", Role: "radio button", Name: "Laptop", Mnemonic: "<Alt>l"}
present(atspi.NewEvent(atspi.FocusEvent, option))
request := <-queue.requests
if !strings.Contains(request.Text, "Laptop") || len(request.Sound) != 0 || presenter.state.pending.radioSound.ready == nil {
t.Fatalf("focus label/sound = %+v", request)
}
// The old member loses selection before GTK checks the focused member.
old := option
old.Path = "/previous"
present(atspi.NewEvent("object:state-changed:checked", old))
if len(queue.requests) != 0 {
t.Fatal("unfocused member emitted a state sound")
}
option.Checked = true
checked := atspi.NewEvent("object:state-changed:checked", option)
checked.Source.Mnemonic = "" // State-only native captures skip mnemonic lookup.
checked.Detail1 = 1
present(checked)
request = <-queue.requests
if len(request.Sound) != 1 || request.Sound[0].Name != "radiobutton_checked" || presenter.state.pending.radioSound.ready != nil {
t.Fatalf("selected sound = %+v", request)
}
if presenter.state.current.Mnemonic != option.Mnemonic {
t.Fatal("state update discarded focus metadata")
}
present(atspi.NewEvent(atspi.FocusEvent, option))
if len(queue.requests) != 0 {
t.Fatal("duplicate focus replayed selected sound")
}
}
func TestRadioFocusSoundRetiresStalePendingState(t *testing.T) {
for _, change := range []string{"none", "focus", "input", "window", "lock", "handoff", "defunct", "disabled"} {
t.Run(change, func(t *testing.T) {
presenter, queue, present := radio_sound_fixture(t)
option := atspi.Snapshot{BusName: ":1.7", Path: "/radio", Role: "radio button", Name: "Option"}
present(atspi.NewEvent(atspi.FocusEvent, option))
<-queue.requests
gate := &focusGate{}
switch change {
case "focus":
present(atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{BusName: ":1.7", Path: "/next", Role: "button", Name: "Next"}))
<-queue.requests
case "input":
presenter.inputSource = fixedInputActivity{activity: input.Activity{InputSequence: 1}}
case "window":
presenter.state.owner.window = atspi.NewObjectID(":1.7", "/other")
case "lock":
gate.secure.Store(true)
case "handoff":
gate.suspended.Store(true)
case "defunct":
presenter.state.current.Defunct = true
case "disabled":
presenter.state.presentationOptions.EnableSound = false
}
presenter.flush_radio_sound(gate)
if change != "none" {
if len(queue.requests) != 0 {
t.Fatal("retired focus emitted a delayed sound")
}
return
}
request := <-queue.requests
if request.Text != "" || request.FallbackText != "not selected" || len(request.Sound) != 1 || request.Sound[0].Name != "radiobutton_unchecked" {
t.Fatalf("settled unchecked sound/fallback = %+v", request)
}
})
}
}
func TestProcessEventsFlushesUncheckedRadioSound(t *testing.T) {
presenter, queue, _ := radio_sound_fixture(t)
adapter := &fakeAdapter{events: make(chan atspi.Event, 1)}
ctx, cancel := context.WithCancel(t.Context())
done := make(chan error, 1)
go func() {
done <- processEvents(ctx, presenter.logger, queue, adapter, 0, presenter.state.presentationOptions)
}()
t.Cleanup(func() { cancel(); <-done })
adapter.events <- atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{BusName: ":1.7", Path: "/radio", Role: "radio button", Name: "Option"})
for index := range 2 {
select {
case request := <-queue.requests:
if index == 0 && (request.Text == "" || len(request.Sound) != 0) {
t.Fatalf("label was deferred or played unchecked sound: %+v", request)
}
if index == 1 && (len(request.Sound) != 1 || request.Sound[0] != (sound.Cue{Name: "radiobutton_unchecked"})) {
t.Fatalf("unchecked focus sound = %+v", request)
}
case <-time.After(time.Second):
t.Fatal("radio focus presentation did not finish")
}
}
}
func TestDeferredRadioSoundPreservesUnavailableCueFallback(t *testing.T) {
presenter, queue, present := radio_sound_fixture(t)
present(atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{BusName: ":1.7", Path: "/radio", Role: "radio button", Name: "Option"}))
<-queue.requests
presenter.state.presentationOptions.SoundCueAvailable = func(string) bool { return false }
presenter.speech = outputQueue{speech: queue, sounds: rejectingSound{}}
presenter.flush_radio_sound(nil)
if request := <-queue.requests; request.Text != "not selected" {
t.Fatalf("missing state-sound fallback: %+v", request)
}
}
+21 -9
View File
@@ -118,7 +118,7 @@ func (speaker *continuousSpeaker) SpeakAndWait(ctx context.Context, text string,
}
}
func TestReadAllPacesSpeechAndInterruptsOnNewInputOrFocus(t *testing.T) {
func TestReadAllQueuesPassiveFocusAndInterruptsOnNewInput(t *testing.T) {
for _, cause := range []string{"key", "focus"} {
t.Run(cause, func(t *testing.T) {
speaker := &continuousSpeaker{started: make(chan string, 4), finish: make(chan struct{})}
@@ -167,15 +167,27 @@ func TestReadAllPacesSpeechAndInterruptsOnNewInputOrFocus(t *testing.T) {
}
}
worker.Enqueue(speechRequest{Text: "New focus", Cause: "focus:"})
if cause == "focus" {
if ctx.Err() != nil {
t.Fatal("passive focus canceled Say All")
}
speaker.finish <- struct{}{}
waitText("Third")
if <-progress != 3 {
t.Fatal("passive focus changed the reading position")
}
speaker.finish <- struct{}{}
} else {
if ctx.Err() == nil {
t.Fatal("new input did not cancel Say All")
}
select {
case <-progress:
t.Fatal("reading position advanced after interruption")
default:
}
}
waitText("New focus")
if ctx.Err() == nil {
t.Fatal("read all was not canceled")
}
select {
case <-progress:
t.Fatal("reading position advanced after interruption")
default:
}
})
}
}
+4 -4
View File
@@ -133,13 +133,13 @@ func (presenter remoteSpeech) presentRequest(request speechRequest) bool {
return accepted
}
func (presenter remoteSpeech) SpeakText(text string, interrupt bool) bool {
// The protocol's interrupt flag does not establish user input. External
// announcements obey the same queue policy as accessibility events; callers
// requesting an explicit stop can use CancelSpeech.
func (presenter remoteSpeech) SpeakText(text string, _ bool) bool {
if presenter.speech == nil || text == "" {
return false
}
if interrupt {
presenter.speech.Interrupt()
}
presenter.speech.Enqueue(speechRequest{
Text: text,
Role: "system",
+3 -3
View File
@@ -106,14 +106,14 @@ func TestRemoteSpeechQueuesWithoutInterruptingByDefault(t *testing.T) {
}
}
func TestRemoteSpeechInterruptsBeforeRequestedSpeech(t *testing.T) {
func TestRemoteSpeechQueuesRegardlessOfInterruptFlag(t *testing.T) {
queue := &remoteSpeechQueue{}
presenter := remoteSpeech{speech: queue}
if !presenter.SpeakText("Now", true) {
t.Fatal("SpeakText rejected available speech")
}
if queue.interrupts != 1 {
t.Fatalf("interrupts = %d, want 1", queue.interrupts)
if queue.interrupts != 0 {
t.Fatalf("interrupts = %d, want 0", queue.interrupts)
}
if len(queue.requests) != 1 || queue.requests[0].Text != "Now" || !queue.requests[0].Append {
t.Fatalf("request = %+v, want queued Now speech", queue.requests)
+3 -9
View File
@@ -34,14 +34,8 @@ func (worker *speechWorker) deliver(ctx context.Context, request speechRequest)
if handled, err := worker.deliver_mode(ctx, request); handled {
return err
}
if !request.Append {
if err := worker.speaker.Cancel(ctx); err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
worker.logger.Warn("cancel previous speech", "role", request.Role, "error", err)
}
}
// Incoming output always queues. Only input/command handling may cancel
// earlier speech; a delayed state or focus event is not another user action.
if len(request.Spelling) != 0 {
// One parent request owns its context; letter recursion must not
// re-announce it or alter the original character's voice choice.
@@ -258,7 +252,7 @@ func (worker *speechWorker) logDelivery(ctx context.Context, request speechReque
}
worker.logger.DebugContext(ctx, "delivering accessible presentation",
"role", request.Role, "cause", request.Cause, "utterance", text,
"append", request.Append, "interrupt_previous", !request.Append && readAllIndex <= 0,
"append", true, "interrupt_previous", false,
"character", request.Character, "custom_echo", request.CustomEcho,
"read_all_index", readAllIndex)
}
+6 -10
View File
@@ -25,15 +25,11 @@ import (
"git.stormux.org/storm/cthulhu/internal/input"
)
// Interrupt drops retained requests and wakes the owned cancellation worker.
// Interrupt is for explicit user actions, never automatic presentation.
// It drops retained requests and wakes the owned cancellation worker.
// It must never perform backend I/O in the caller's event loop.
func (worker *speechWorker) Interrupt() {
worker.mutex.Lock()
defer worker.mutex.Unlock()
if worker.confirmationID != 0 || worker.has_uninterruptible_locked() {
return
}
worker.interruptLocked()
worker.InterruptForUser()
}
// The preferences worker changes speech availability after the backend has
@@ -56,7 +52,6 @@ func (worker *speechWorker) interruptLocked() {
if worker.uninterruptibleCancel != nil {
worker.uninterruptibleCancel()
}
worker.presentationDelivery = nil
worker.confirmationID = 0
if worker.beepCancel != nil {
worker.beepCancel()
@@ -127,7 +122,7 @@ func (worker *speechWorker) InterruptForInput(event input.Event) bool {
}
// ProtectCommand ignores delayed copies of input which already dispatched a
// command. A newly dispatched command also interrupts confirmation speech,
// command. A newly dispatched command interrupts existing speech,
// even when its XRecord input has not arrived yet.
func (worker *speechWorker) ProtectCommand(timestamp uint32) {
worker.mutex.Lock()
@@ -138,7 +133,8 @@ func (worker *speechWorker) ProtectCommand(timestamp uint32) {
if timestamp != 0 {
worker.commandTimestamp = timestamp
}
if worker.confirmationID != 0 && !worker.has_uninterruptible_locked() {
if !worker.has_uninterruptible_locked() {
worker.startupUntil = time.Time{}
worker.interruptLocked()
}
}
-3
View File
@@ -34,7 +34,6 @@ func TestSpeechWorkerKeyEchoCompletesBeforeCaretSpeech(t *testing.T) {
}
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"
@@ -47,7 +46,6 @@ func TestSpeechWorkerKeyEchoCompletesBeforeCaretSpeech(t *testing.T) {
default:
}
close(echo.done)
close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done)
close(expectSpeechCall(t, &speaker.controlledSpeaker, "h").done)
})
}
@@ -61,7 +59,6 @@ func TestSpeechWorkerKeyEchoWaitStillAllowsInputInterruption(t *testing.T) {
}
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}) {
-37
View File
@@ -1,37 +0,0 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import "context"
// revokePresentation drops only the retiring owner's pending speech. If that
// owner has reached delivery, cancel it through the existing backend barrier
// while preserving newer core requests behind the barrier. The next delivered
// request owns backend speech, independently of what is still in the queue.
func (worker *speechWorker) revokePresentation(owner context.Context) {
worker.mutex.Lock()
defer worker.mutex.Unlock()
retained := worker.pending[:0]
for _, request := range worker.pending {
if request.presentationGeneration != nil && request.context == owner {
completeSpeechRequest(request, context.Canceled)
} else {
retained = append(retained, request)
}
}
clear(worker.pending[len(retained):])
worker.pending = retained
if worker.presentationDelivery != owner || !worker.running {
return
}
worker.presentationDelivery = nil
if worker.beepCancel != nil {
worker.beepCancel()
}
worker.generation++
worker.interrupting = true
select {
case worker.interrupt <- struct{}{}:
default:
}
}
+2 -3
View File
@@ -24,8 +24,8 @@ func (worker *speechWorker) has_uninterruptible_locked() bool {
return false
}
// Preserve unrelated queued speech, just as ordinary presentation revocation
// does. The existing cancellation barrier prevents CANCEL overtaking it.
// Preserve unrelated queued speech when explicitly stopping protected output.
// The existing cancellation barrier prevents CANCEL overtaking it.
func (worker *speechWorker) stop_uninterruptible() {
worker.mutex.Lock()
defer worker.mutex.Unlock()
@@ -43,7 +43,6 @@ func (worker *speechWorker) stop_uninterruptible() {
return
}
worker.uninterruptibleCancel()
worker.presentationDelivery = nil
if worker.beepCancel != nil {
worker.beepCancel()
}
+7 -9
View File
@@ -125,7 +125,6 @@ type speechWorker struct {
confirmationID uint64 // Owned under mutex; reset on completion or user interruption.
confirmationSerial uint64
presentationDelivery context.Context // Replaced when the next request reaches delivery.
presentationRevision uint64
uninterruptibleContext context.Context
uninterruptibleCancel context.CancelFunc
@@ -183,10 +182,6 @@ func (worker *speechWorker) Enqueue(request speechRequest) {
completeSpeechRequest(request, context.Canceled)
return
}
if worker.readAllCancel != nil && request.Cause != "read-all" && !request.Append {
worker.readAllCancel()
worker.readAllCancel = nil
}
if request.context != nil && request.context.Err() != nil {
worker.mutex.Unlock()
completeSpeechRequest(request, request.context.Err())
@@ -197,6 +192,13 @@ func (worker *speechWorker) Enqueue(request speechRequest) {
completeSpeechRequest(request, errors.New("speech worker is not running"))
return
}
request.Append = true
// A Tentacle ticket gates admission, not the lifetime of accepted speech.
// Passive focus changes can retire the ticket while this output finishes.
// Fresh input still cancels through the worker generation/barrier.
if request.presentationGeneration != nil && !request.Uninterruptible {
request.context = worker.workerContext
}
if request.Confirmation {
worker.confirmationSerial++
request.confirmationID = worker.confirmationSerial
@@ -404,10 +406,6 @@ func (worker *speechWorker) next() (speechRequest, bool) {
if request.Uninterruptible {
worker.uninterruptibleContext, worker.uninterruptibleCancel = request.context, request.cancel
}
worker.presentationDelivery = nil
if request.presentationGeneration != nil {
worker.presentationDelivery = request.context
}
request.deliveryGeneration = worker.generation
copy(worker.pending, worker.pending[1:])
worker.pending[len(worker.pending)-1] = speechRequest{}
+1 -3
View File
@@ -84,7 +84,6 @@ func TestStartupSpeechSurvivesAutomaticFocusInterruption(t *testing.T) {
case <-time.After(time.Second):
t.Fatal("startup speech did not begin")
}
worker.Interrupt() // Automatic window focus must not cancel the welcome.
worker.Enqueue(speechRequest{Text: "Focused control", Cause: "focus:"})
worker.mutex.Lock()
retained := len(worker.pending) == 1 && worker.pending[0].Text == "Focused control" && worker.pending[0].Append
@@ -259,7 +258,6 @@ func TestInterruptDropsPendingAndOrdersNextSpeech(t *testing.T) {
}
t.Cleanup(func() { worker.Stop(context.Background()) })
worker.Enqueue(speechRequest{Text: "Active"})
close(expectSpeechCall(t, speaker, "cancel").done)
active := expectSpeechCall(t, speaker, "Active")
worker.Enqueue(speechRequest{Text: "Discarded"})
worker.Interrupt()
@@ -355,7 +353,7 @@ func TestTerminalOutputAndPromptDoNotCancelEachOther(t *testing.T) {
t.Fatal(err)
}
}
if got := fmt.Sprint(speaker.calls); got != "[Command output Prompt cancel Save]" {
if got := fmt.Sprint(speaker.calls); got != "[Command output Prompt Save]" {
t.Fatalf("speech order = %s", got)
}
}
+2 -5
View File
@@ -65,8 +65,8 @@ func (owner *tentacleOutput) Retire() {
owner.retireLocked()
}
// Stop additionally revokes accepted protected speech during reader shutdown
// or terminal handoff. Ordinary focus/command retirement only retires replies.
// Stop additionally revokes accepted protected speech during reader shutdown,
// screen lock or explicit disable. Ordinary retirement only retires replies.
func (owner *tentacleOutput) Stop() {
if owner == nil {
return
@@ -95,9 +95,6 @@ func (owner *tentacleOutput) retireLocked() {
return
}
owner.cancel()
if owner.speech != nil {
owner.speech.revokePresentation(owner.current)
}
if owner.braille != nil {
owner.braille.retirePresentation(owner.current)
}
+11 -29
View File
@@ -4,7 +4,6 @@ import (
"context"
"log/slog"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/braille"
"git.stormux.org/storm/cthulhu/internal/config"
@@ -23,6 +22,7 @@ func tentacleOutputFixture(t *testing.T) *tentacleOutput {
display := &brailleWorker{logger: slog.New(slog.DiscardHandler), cancel: func() {}, connected: true, wake: make(chan struct{}, 1),
frame: braille.Frame{Text: "Core focus", Cursor: -1}, baseFrame: braille.Frame{Text: "Core focus", Cursor: -1}}
speech := newSpeechWorker(slog.New(slog.DiscardHandler), nil)
speech.workerContext = t.Context()
speech.running = true // Inspect the bounded queue without a delivery goroutine.
owner := &tentacleOutput{speech: speech, braille: display, gate: newFocusGate(nil), live: live}
owner.gate.tentacleOutput = owner
@@ -62,7 +62,7 @@ func TestTentacleIndependentOutputOwnership(t *testing.T) {
for _, request := range owner.speech.pending {
if request.Text == "Tentacle speech" {
gotSpeech = true
if request.context != ticket.ctx || request.ForceSpeech || request.BypassFocusGate || request.Role != "system" {
if request.context != owner.speech.workerContext || request.ForceSpeech || request.BypassFocusGate || request.Role != "system" {
t.Fatal("unsafe speech request")
}
}
@@ -119,17 +119,16 @@ func TestTentacleAcceptedOutputRetirement(t *testing.T) {
owner.speech.Enqueue(speechRequest{Text: "New core output"})
owner.braille.Flash("New core flash", owner.live.Load().Braille)
}
cancel() // Reply was already consumed; its session still owns output lifetime.
cancel() // Retiring the reply cannot cut off speech already accepted.
if !newer && owner.braille.current().Text != "Core focus" {
t.Fatal("revoked flash survived consumption")
}
owner.RetireRevoked()
if newer {
if owner.braille.current().Text != "New core flash" || len(owner.speech.pending) != 1 || owner.speech.pending[0].Text != "New core output" {
t.Fatal("retirement displaced core output")
}
} else if len(owner.speech.pending) != 0 {
t.Fatal("queued retired speech remains")
if len(owner.speech.pending) == 0 || owner.speech.pending[0].Text != "Queued speech" || owner.speech.pending[0].context.Err() != nil {
t.Fatal("retirement discarded accepted speech")
}
if newer && (owner.braille.current().Text != "New core flash" || len(owner.speech.pending) != 2 || owner.speech.pending[1].Text != "New core output") {
t.Fatal("retirement displaced core output")
}
}
}
@@ -190,26 +189,9 @@ func TestTentacleRevocationPreservesQueuedCoreSpeech(t *testing.T) {
t.Cleanup(func() { owner.speech.Stop(context.Background()) })
ticket := owner.Capture(context.Background())
owner.Present(ticket, tentaclewire.Presentation{Speech: "Tentacle output"}, "tentacle/test/probe")
close(expectSpeechCall(t, speaker, "cancel").done)
_ = expectSpeechCall(t, speaker, "Tentacle output") // Remains in flight until context cancellation.
active := expectSpeechCall(t, speaker, "Tentacle output")
owner.speech.Enqueue(speechRequest{Text: "Later core speech", Append: true})
owner.Retire()
cancellations := 0
deadline := time.After(time.Second)
for {
select {
case call := <-speaker.calls:
close(call.done)
if call.text == "cancel" {
cancellations++
continue
}
if call.text != "Later core speech" || cancellations == 0 {
t.Fatalf("unexpected delivery after revocation: %q", call.text)
}
return
case <-deadline:
t.Fatal("core speech failed to resume after revocation")
}
}
close(active.done)
close(expectSpeechCall(t, speaker, "Later core speech").done)
}
@@ -39,7 +39,6 @@ func TestTentacleUninterruptibleSurvivesInputAndFocusUntilEnd(t *testing.T) {
if owner.speech.InterruptForInput(input.Event{Timestamp: 101, Pressed: true}) {
t.Fatal("queued greeting was interrupted")
}
close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done)
greeting := expectSpeechCall(t, &speaker.controlledSpeaker, "Hello world!")
owner.speech.Interrupt()
owner.speech.InterruptForUser()
@@ -70,7 +69,7 @@ func TestTentacleUninterruptibleSurvivesInputAndFocusUntilEnd(t *testing.T) {
}
func TestTentacleUninterruptibleStillRevokes(t *testing.T) {
for _, action := range []string{"session", "disable", "lock", "handoff", "speech off", "mode", "stop", "lifetime"} {
for _, action := range []string{"session", "disable", "lock", "speech off", "mode", "stop", "lifetime"} {
t.Run(action, func(t *testing.T) {
owner, speaker := protected_output_fixture(t)
parent, cancel := context.WithCancel(t.Context())
@@ -84,7 +83,6 @@ func TestTentacleUninterruptibleStillRevokes(t *testing.T) {
if !bounded || time.Until(deadline) > uninterruptibleSpeechTimeout {
t.Fatal("unbounded protected speech")
}
close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done)
_ = expectSpeechCall(t, &speaker.controlledSpeaker, "Protected greeting")
switch action {
case "session":
@@ -93,8 +91,6 @@ func TestTentacleUninterruptibleStillRevokes(t *testing.T) {
owner.Stop()
case "lock":
owner.gate.SetLocked(true, nil, nil, nil, owner.speech, owner.braille)
case "handoff":
owner.gate.SetSuspended(true)
case "speech off":
owner.speech.SetEnabled(false)
case "mode":
@@ -181,7 +177,6 @@ func TestTentacleUninterruptibleCannotResurrectStaleReply(t *testing.T) {
func TestTentacleProtectedStopPreservesCoreQueue(t *testing.T) {
owner, speaker := protected_output_fixture(t)
owner.Present(owner.Capture(t.Context()), tentaclewire.Presentation{Speech: "Greeting", Uninterruptible: true}, "tentacle/test/hello")
close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done)
_ = expectSpeechCall(t, &speaker.controlledSpeaker, "Greeting")
owner.speech.Enqueue(speechRequest{Text: "Core output"})
owner.Stop()
+2 -2
View File
@@ -258,8 +258,8 @@ func TestReaderTentaclePresentation(t *testing.T) {
t.Fatal("reader did not present the command result")
}
runtime.Disable()
if pending[0].context.Err() == nil || owner.braille.current().Text != "Core focus" {
t.Fatal("disable left accepted output live")
if pending[0].context.Err() != nil || owner.braille.current().Text != "Core focus" {
t.Fatal("disable did not preserve accepted speech and retire the flash")
}
} else {
if len(pending) != 0 || owner.braille.current().Text != "Core focus" {
-1
View File
@@ -7,7 +7,6 @@ package app
import "git.stormux.org/storm/cthulhu/internal/version"
func presentVersion(speech speechEnqueuer, atspiVersion, sessionType, sessionDesktop string) {
speech.Interrupt()
speech.Enqueue(speechRequest{
Text: version.Details(atspiVersion, sessionType, sessionDesktop),
Role: "system",
+1 -1
View File
@@ -19,8 +19,8 @@ func TestPresentVersion(t *testing.T) {
presentVersion(speech, "2.58.0", "x11", "GNOME")
select {
case <-speech.interrupt:
t.Fatal("version result interrupted speech again")
default:
t.Fatal("version presentation did not interrupt speech")
}
request := <-speech.requests
want := version.Details("2.58.0", "x11", "GNOME")
-1
View File
@@ -85,7 +85,6 @@ func (controller *webScriptController) present(messages []string) {
}
controller.braille.Flash(message, options)
}
controller.speech.Interrupt()
controller.speech.Enqueue(speechRequest{
Text: message, Role: "notification", Cause: "steam-notification", BypassFocusGate: true,
})
+5 -8
View File
@@ -28,10 +28,10 @@ func TestWindowTitleQueuesFollowingPresentation(t *testing.T) {
activation := ownerEvent(atspi.WindowActivatedEvent, ":1.70", "/window", "/window")
activation.Source.Name, activation.Source.Role = title, "dialog"
activation.WindowTitle = title
presenter.speak(activation)
close(expectSpeechCall(t, speaker, "cancel").done)
titleCall := expectSpeechCall(t, speaker, title)
worker.ProtectCommand(100)
close(expectSpeechCall(t, speaker, "cancel").done)
presenter.speak(activation)
titleCall := expectSpeechCall(t, speaker, title)
if worker.InterruptForInput(input.Event{Pressed: true, Timestamp: 100}) {
t.Fatal("delayed initiating shortcut interrupted the title")
}
@@ -49,14 +49,13 @@ func TestWindowTitleQueuesFollowingPresentation(t *testing.T) {
focus.Source.Path, focus.Source.Name = "/speech", "Speech"
presenter.speak(focus)
close(expectSpeechCall(t, speaker, "cancel").done)
close(expectSpeechCall(t, speaker, "Speech").done)
close(expectSpeechCall(t, speaker, "page tab").done)
})
}
}
func TestWindowTitlePairingIsScoped(t *testing.T) {
func TestWindowTitlesAndOtherWindowsQueueUntilUserInterruption(t *testing.T) {
for _, test := range []struct {
name string
window atspi.ObjectID
@@ -80,7 +79,6 @@ func TestWindowTitlePairingIsScoped(t *testing.T) {
}
t.Cleanup(func() { worker.Stop(context.Background()) })
worker.Enqueue(speechRequest{Text: "Window title", Window: atspi.NewObjectID(":1.70", "/window"), IntroducesWindow: !test.untitled})
close(expectSpeechCall(t, speaker, "cancel").done)
titleCall := expectSpeechCall(t, speaker, "Window title")
if test.interrupt != nil {
test.interrupt(worker)
@@ -88,10 +86,9 @@ func TestWindowTitlePairingIsScoped(t *testing.T) {
}
worker.Enqueue(speechRequest{Text: "Next control", Window: test.window})
close(titleCall.done)
close(expectSpeechCall(t, speaker, "cancel").done)
if test.interrupt != nil {
// The cancellation barrier settles the in-flight title
// before the new focus request performs its own CANCEL.
// before the new focus request can speak.
close(expectSpeechCall(t, speaker, "cancel").done)
}
close(expectSpeechCall(t, speaker, "Next control").done)
+1 -1
View File
@@ -26,7 +26,7 @@ func TestPresentWindowTitleRepeatsDespiteAutomaticPolicy(t *testing.T) {
for range 2 {
titles.present()
}
if len(queue.requests) != 2 || queue.interruptions != 2 || titles.pending != nil {
if len(queue.requests) != 2 || queue.interruptions != 0 || titles.pending != nil {
t.Fatalf("explicit titles = %+v; interruptions %d; pending %+v", queue.requests, queue.interruptions, titles.pending)
}
for _, request := range queue.requests {
-1
View File
@@ -242,7 +242,6 @@ func (titles *windowTitleController) present() {
} else {
titles.lastTitle = text
}
titles.speech.Interrupt()
titles.speech.Enqueue(speechRequest{Text: text, Role: "system", Cause: "present-window-title",
Window: window, ExcludeFromHistory: titles.state.historyWindow.Valid() && window == titles.state.historyWindow})
}
+4 -4
View File
@@ -97,8 +97,8 @@ func TestXTermFocusSuspendsAndResumesPresentationAndCommandGrabs(t *testing.T) {
t.Helper()
select {
case <-speech.interrupt:
case <-time.After(time.Second):
t.Fatal("XTerm focus did not interrupt Cthulhu speech")
t.Fatal("passive XTerm focus interrupted Cthulhu speech")
default:
}
}
waitForGrabs := func(want []bool) {
@@ -199,8 +199,8 @@ func TestXTermFocusDiscardsPendingSpeech(t *testing.T) {
select {
case <-speech.interrupt:
case <-time.After(time.Second):
t.Fatal("XTerm focus did not interrupt pending speech")
t.Fatal("passive XTerm focus interrupted pending speech")
default:
}
time.Sleep(30 * time.Millisecond)
select {
+4
View File
@@ -264,6 +264,10 @@ func (service *Service) playQueued(ctx context.Context, cue queuedCue) error {
}
service.mutex.Unlock()
}
if err := playContext.Err(); err != nil {
return err
}
service.logger.Debug("playing object sound", "cue", cue.Name)
return player.Play(playContext, data, volume, normalizedPan(cue.Pan))
}
+1
View File
@@ -12,6 +12,7 @@ from native_test_session import run_private_session
requiredTests = {
"TestSpeechWorkerRadioSelectionDoesNotCancelLabel",
"TestPrivateWineReviewUpdatesPreserveQueuedTitleSpeech",
"TestPrivateWineTitleSurvivesReviewFocusBurst",
"TestAutomaticTitleSurvivesMovementRepeatsBeforeAndAfterDelivery",
+55 -2
View File
@@ -111,7 +111,36 @@ class PreviewSpeechServer:
client["socket"].close()
def check_runtime():
def check_radio_buttons(controls, tabs, server, readerLog):
import pyatspi
def radio_cues():
records = [json.loads(line) for line in readerLog.read_text().splitlines() if line.startswith("{")]
return [record["cue"] for record in records if record.get("message") == "playing object sound"
and record.get("cue", "").startswith("radiobutton_")]
assert tabs.querySelection().selectChild(0)
assert controls["Desktop"].queryComponent().grabFocus()
wait_for(lambda: radio_cues(), "initial radio sound did not play")
time.sleep(0.35)
for keyName, target in (("Right", "Laptop"), ("Left", "Desktop"), ("Right", "Laptop")):
cueOffset = len(radio_cues())
speechOffset = len(server.snapshot())
subprocess.run(["xdotool", "key", "--clearmodifiers", keyName], check=True)
wait_for(lambda: controls[target].getState().contains(pyatspi.STATE_CHECKED), target + " was not selected")
wait_for(lambda: len(radio_cues()) > cueOffset, target + " selection sound did not play")
# Cover the deferred focus cue and duplicate focus event after checking.
time.sleep(0.35)
cues = radio_cues()[cueOffset:]
assert cues == ["radiobutton_checked"], (keyName, target, cues)
assert any(target in record["text"] for record in server.snapshot()[speechOffset:]), target + " label was lost"
records = [json.loads(line) for line in readerLog.read_text().splitlines() if line.startswith("{")]
errors = [record for record in records if record.get("message") == "play sound"
and record.get("cue", "").startswith("radiobutton_") and record.get("error") != "context canceled"]
assert not errors, errors
def check_runtime(radioOnly=False):
import pyatspi
directory = Path(tempfile.mkdtemp(prefix="cthulhu-echo-runtime-"))
binary = os.environ["CTHULHU_BINARY"]
@@ -144,10 +173,30 @@ gain = 5
name = "Echo A"
lang = "en"
''')
if radioOnly:
configPath.write_text(configPath.read_text().replace(
"[profiles.default.sound]\nenabled = false",
'[profiles.default.sound]\nenabled = true\nsink = "auto"\nplay-for-state = true\nrole-presentation-strategy = "sound_only"'))
readerEnv = os.environ.copy()
if radioOnly:
import gi
gi.require_version("Gst", "1.0")
from gi.repository import Gst
Gst.init(None)
assert Gst.ElementFactory.find("fakeaudiosink"), "silent GStreamer audio sink is unavailable"
# Only the child reader sees these ranks. Auto audio can choose
# the fake sink; every real audio sink is excluded from discovery.
ranks = []
for factory in Gst.Registry.get().get_feature_list(Gst.ElementFactory):
kind = factory.get_metadata("klass") or ""
if "Audio" in kind and "Sink" in kind:
rank = "MAX" if factory.get_name() == "fakeaudiosink" else "0"
ranks.append(factory.get_name() + ":" + rank)
readerEnv["GST_PLUGIN_FEATURE_RANK"] = ",".join(ranks)
reader = subprocess.Popen([binary, "--config", str(configPath), "--settings", "--no-tentacles",
"--speech-socket", str(directory / "speech.sock"),
"--debug-file", str(directory / "reader.log")],
stdout=subprocess.DEVNULL, stderr=(directory / "stderr.log").open("w"))
env=readerEnv, stdout=subprocess.DEVNULL, stderr=(directory / "stderr.log").open("w"))
def find_window():
for application in pyatspi.Registry.getDesktop(0):
for window in application:
@@ -163,6 +212,10 @@ lang = "en"
xid = subprocess.check_output(["xdotool", "search", "--onlyvisible", "--name", "^Cthulhu Preferences$"], text=True).splitlines()[0]
subprocess.run(["xdotool", "windowactivate", "--sync", xid], check=True)
tabs = next(node for node in walk(window) if node.getRole() == pyatspi.ROLE_PAGE_TAB_LIST)
if radioOnly:
check_radio_buttons(controls, tabs, server, directory / "reader.log")
print(f"PASS: real GTK Desktop/Laptop arrows play only the final selected sound and retain labels; evidence: {directory}")
return
assert tabs.querySelection().selectChild(4)
assert controls["Use custom echo voice"].queryComponent().grabFocus()
wait_for(lambda: any(record["text"] == "checked" and record["finished"]
+4
View File
@@ -46,6 +46,10 @@ class ResponseReader:
def inside():
if os.environ.get("CTHULHU_PREFS_RADIO_RUNTIME_ONLY") == "1":
from native_echo_preview import check_runtime
check_runtime(radioOnly=True)
return
if os.environ.get("CTHULHU_PREFS_ECHO_RUNTIME_ONLY") == "1":
from native_echo_preview import check_runtime
check_runtime()