diff --git a/README.md b/README.md index 954659e..4e8f276 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/docs/testing/speech-queue.md b/docs/testing/speech-queue.md new file mode 100644 index 0000000..1ef0585 --- /dev/null +++ b/docs/testing/speech-queue.md @@ -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. diff --git a/internal/app/browser_lists.go b/internal/app/browser_lists.go index 0fc8bbb..21fdd02 100644 --- a/internal/app/browser_lists.go +++ b/internal/app/browser_lists.go @@ -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) diff --git a/internal/app/browser_mode.go b/internal/app/browser_mode.go index 343d463..b4acf81 100644 --- a/internal/app/browser_mode.go +++ b/internal/app/browser_mode.go @@ -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"}) } diff --git a/internal/app/character_attributes.go b/internal/app/character_attributes.go index b0331fd..dcc7703 100644 --- a/internal/app/character_attributes.go +++ b/internal/app/character_attributes.go @@ -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"}) } diff --git a/internal/app/clipboard.go b/internal/app/clipboard.go index 9e767f7..c4be918 100644 --- a/internal/app/clipboard.go +++ b/internal/app/clipboard.go @@ -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"}) } } diff --git a/internal/app/clipboard_test.go b/internal/app/clipboard_test.go index a0142aa..2129cca 100644 --- a/internal/app/clipboard_test.go +++ b/internal/app/clipboard_test.go @@ -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 { diff --git a/internal/app/command_input_test.go b/internal/app/command_input_test.go index 77ceaf1..65c05b0 100644 --- a/internal/app/command_input_test.go +++ b/internal/app/command_input_test.go @@ -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 { diff --git a/internal/app/control_events_test.go b/internal/app/control_events_test.go index cceabd5..30ab9b9 100644 --- a/internal/app/control_events_test.go +++ b/internal/app/control_events_test.go @@ -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) + } +} diff --git a/internal/app/date_time.go b/internal/app/date_time.go index 6bf89ff..ff9ebaa 100644 --- a/internal/app/date_time.go +++ b/internal/app/date_time.go @@ -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}) } diff --git a/internal/app/date_time_test.go b/internal/app/date_time_test.go index 9422e2b..2a40402 100644 --- a/internal/app/date_time_test.go +++ b/internal/app/date_time_test.go @@ -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") } } } diff --git a/internal/app/document_review.go b/internal/app/document_review.go index 90a9cf6..15cbe11 100644 --- a/internal/app/document_review.go +++ b/internal/app/document_review.go @@ -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" diff --git a/internal/app/document_review_test.go b/internal/app/document_review_test.go index 5b06e4d..0a35fb6 100644 --- a/internal/app/document_review_test.go +++ b/internal/app/document_review_test.go @@ -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") } } } diff --git a/internal/app/event_accessibility.go b/internal/app/event_accessibility.go index 63e37b3..ee5811e 100644 --- a/internal/app/event_accessibility.go +++ b/internal/app/event_accessibility.go @@ -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 diff --git a/internal/app/event_focus.go b/internal/app/event_focus.go index 53da3b2..4adb7aa 100644 --- a/internal/app/event_focus.go +++ b/internal/app/event_focus.go @@ -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. diff --git a/internal/app/event_focus_ownership.go b/internal/app/event_focus_ownership.go index 1598f30..e0e111e 100644 --- a/internal/app/event_focus_ownership.go +++ b/internal/app/event_focus_ownership.go @@ -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}) diff --git a/internal/app/event_loop.go b/internal/app/event_loop.go index 339dc7b..d93b564 100644 --- a/internal/app/event_loop.go +++ b/internal/app/event_loop.go @@ -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() diff --git a/internal/app/event_notification.go b/internal/app/event_notification.go index 9645a2d..246188b 100644 --- a/internal/app/event_notification.go +++ b/internal/app/event_notification.go @@ -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 diff --git a/internal/app/event_presentation.go b/internal/app/event_presentation.go index 83475d5..c10e5c0 100644 --- a/internal/app/event_presentation.go +++ b/internal/app/event_presentation.go @@ -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)) diff --git a/internal/app/event_review_results.go b/internal/app/event_review_results.go index 375e354..d0be47f 100644 --- a/internal/app/event_review_results.go +++ b/internal/app/event_review_results.go @@ -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) } diff --git a/internal/app/event_state.go b/internal/app/event_state.go index fd4d1d2..39ae0c5 100644 --- a/internal/app/event_state.go +++ b/internal/app/event_state.go @@ -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 diff --git a/internal/app/event_tooltip.go b/internal/app/event_tooltip.go index 6039be8..d9cb1d7 100644 --- a/internal/app/event_tooltip.go +++ b/internal/app/event_tooltip.go @@ -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 diff --git a/internal/app/flat_review.go b/internal/app/flat_review.go index f8f2d73..c234928 100644 --- a/internal/app/flat_review.go +++ b/internal/app/flat_review.go @@ -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} diff --git a/internal/app/focus_gate.go b/internal/app/focus_gate.go index 9bacfb3..d6a6f7c 100644 --- a/internal/app/focus_gate.go +++ b/internal/app/focus_gate.go @@ -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()) } diff --git a/internal/app/i38lock_feedback_native_test.go b/internal/app/i38lock_feedback_native_test.go index e1ad895..ed52e8e 100644 --- a/internal/app/i38lock_feedback_native_test.go +++ b/internal/app/i38lock_feedback_native_test.go @@ -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 { diff --git a/internal/app/internal_ui.go b/internal/app/internal_ui.go index 74b8c3b..7a64559 100644 --- a/internal/app/internal_ui.go +++ b/internal/app/internal_ui.go @@ -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) diff --git a/internal/app/learn_mode.go b/internal/app/learn_mode.go index 891eca9..867f872 100644 --- a/internal/app/learn_mode.go +++ b/internal/app/learn_mode.go @@ -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() diff --git a/internal/app/learn_mode_test.go b/internal/app/learn_mode_test.go index 7c9b23d..1bc11d9 100644 --- a/internal/app/learn_mode_test.go +++ b/internal/app/learn_mode_test.go @@ -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) } } diff --git a/internal/app/libreoffice.go b/internal/app/libreoffice.go index 1726568..26f7751 100644 --- a/internal/app/libreoffice.go +++ b/internal/app/libreoffice.go @@ -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"}) } diff --git a/internal/app/libreoffice_headers.go b/internal/app/libreoffice_headers.go index 9df2033..433fb26 100644 --- a/internal/app/libreoffice_headers.go +++ b/internal/app/libreoffice_headers.go @@ -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"}) } diff --git a/internal/app/libreoffice_selection.go b/internal/app/libreoffice_selection.go index a0c3c23..f0a5ab9 100644 --- a/internal/app/libreoffice_selection.go +++ b/internal/app/libreoffice_selection.go @@ -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"}) } } diff --git a/internal/app/mouse_review.go b/internal/app/mouse_review.go index 291a1b4..1482626 100644 --- a/internal/app/mouse_review.go +++ b/internal/app/mouse_review.go @@ -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, diff --git a/internal/app/object_review_events_test.go b/internal/app/object_review_events_test.go index 7b17f82..d063f71 100644 --- a/internal/app/object_review_events_test.go +++ b/internal/app/object_review_events_test.go @@ -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) diff --git a/internal/app/pause_chunks_test.go b/internal/app/pause_chunks_test.go index c6ec355..b795bb6 100644 --- a/internal/app/pause_chunks_test.go +++ b/internal/app/pause_chunks_test.go @@ -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) } diff --git a/internal/app/pending_presentation.go b/internal/app/pending_presentation.go index 9d8bdd2..ba69b71 100644 --- a/internal/app/pending_presentation.go +++ b/internal/app/pending_presentation.go @@ -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() } diff --git a/internal/app/preferences_echo_preview_test.go b/internal/app/preferences_echo_preview_test.go index b15f4ee..312e0b1 100644 --- a/internal/app/preferences_echo_preview_test.go +++ b/internal/app/preferences_echo_preview_test.go @@ -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") diff --git a/internal/app/preferences_feedback_test.go b/internal/app/preferences_feedback_test.go index 44a405c..47da097 100644 --- a/internal/app/preferences_feedback_test.go +++ b/internal/app/preferences_feedback_test.go @@ -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}) { diff --git a/internal/app/radio_sound.go b/internal/app/radio_sound.go new file mode 100644 index 0000000..6a04615 --- /dev/null +++ b/internal/app/radio_sound.go @@ -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) +} diff --git a/internal/app/radio_sound_test.go b/internal/app/radio_sound_test.go new file mode 100644 index 0000000..a1e0387 --- /dev/null +++ b/internal/app/radio_sound_test.go @@ -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: "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) + } +} diff --git a/internal/app/read_all_test.go b/internal/app/read_all_test.go index d874434..fb13622 100644 --- a/internal/app/read_all_test.go +++ b/internal/app/read_all_test.go @@ -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: - } }) } } diff --git a/internal/app/remote.go b/internal/app/remote.go index 65497ae..64592d9 100644 --- a/internal/app/remote.go +++ b/internal/app/remote.go @@ -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", diff --git a/internal/app/remote_test.go b/internal/app/remote_test.go index 3cca6dd..b1ef743 100644 --- a/internal/app/remote_test.go +++ b/internal/app/remote_test.go @@ -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) diff --git a/internal/app/speech_delivery.go b/internal/app/speech_delivery.go index ac5b24c..2162424 100644 --- a/internal/app/speech_delivery.go +++ b/internal/app/speech_delivery.go @@ -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) } diff --git a/internal/app/speech_interrupt.go b/internal/app/speech_interrupt.go index ed916ff..b28ca78 100644 --- a/internal/app/speech_interrupt.go +++ b/internal/app/speech_interrupt.go @@ -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() } } diff --git a/internal/app/speech_key_echo_test.go b/internal/app/speech_key_echo_test.go index 7c2e5c4..c130ca4 100644 --- a/internal/app/speech_key_echo_test.go +++ b/internal/app/speech_key_echo_test.go @@ -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}) { diff --git a/internal/app/speech_presentation.go b/internal/app/speech_presentation.go deleted file mode 100644 index 0719f82..0000000 --- a/internal/app/speech_presentation.go +++ /dev/null @@ -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: - } -} diff --git a/internal/app/speech_uninterruptible.go b/internal/app/speech_uninterruptible.go index a2a9195..7e787c3 100644 --- a/internal/app/speech_uninterruptible.go +++ b/internal/app/speech_uninterruptible.go @@ -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() } diff --git a/internal/app/speech_worker.go b/internal/app/speech_worker.go index 4c345b9..fbbfd2e 100644 --- a/internal/app/speech_worker.go +++ b/internal/app/speech_worker.go @@ -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{} diff --git a/internal/app/speech_worker_test.go b/internal/app/speech_worker_test.go index 8ce891b..f8d080a 100644 --- a/internal/app/speech_worker_test.go +++ b/internal/app/speech_worker_test.go @@ -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) } } diff --git a/internal/app/tentacle_output.go b/internal/app/tentacle_output.go index f7a93b4..ae9512a 100644 --- a/internal/app/tentacle_output.go +++ b/internal/app/tentacle_output.go @@ -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) } diff --git a/internal/app/tentacle_output_test.go b/internal/app/tentacle_output_test.go index 97ede5a..becbdcc 100644 --- a/internal/app/tentacle_output_test.go +++ b/internal/app/tentacle_output_test.go @@ -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) } diff --git a/internal/app/tentacle_uninterruptible_test.go b/internal/app/tentacle_uninterruptible_test.go index 8f1fc68..07e8aa2 100644 --- a/internal/app/tentacle_uninterruptible_test.go +++ b/internal/app/tentacle_uninterruptible_test.go @@ -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() diff --git a/internal/app/tentacles_test.go b/internal/app/tentacles_test.go index 5ee5769..8b862fd 100644 --- a/internal/app/tentacles_test.go +++ b/internal/app/tentacles_test.go @@ -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" { diff --git a/internal/app/version.go b/internal/app/version.go index a985b07..0afeb53 100644 --- a/internal/app/version.go +++ b/internal/app/version.go @@ -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", diff --git a/internal/app/version_test.go b/internal/app/version_test.go index 80a2d12..61e2abd 100644 --- a/internal/app/version_test.go +++ b/internal/app/version_test.go @@ -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") diff --git a/internal/app/webscripts.go b/internal/app/webscripts.go index 607dd3d..d3b799b 100644 --- a/internal/app/webscripts.go +++ b/internal/app/webscripts.go @@ -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, }) diff --git a/internal/app/window_speech_test.go b/internal/app/window_speech_test.go index 13e7cf5..0bb3b69 100644 --- a/internal/app/window_speech_test.go +++ b/internal/app/window_speech_test.go @@ -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) diff --git a/internal/app/window_title_command_test.go b/internal/app/window_title_command_test.go index 48bbfbf..3c249e0 100644 --- a/internal/app/window_title_command_test.go +++ b/internal/app/window_title_command_test.go @@ -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 { diff --git a/internal/app/window_titles.go b/internal/app/window_titles.go index 72f86dc..f1de029 100644 --- a/internal/app/window_titles.go +++ b/internal/app/window_titles.go @@ -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}) } diff --git a/internal/app/xterm_handoff_test.go b/internal/app/xterm_handoff_test.go index 3e4b457..3df1e3f 100644 --- a/internal/app/xterm_handoff_test.go +++ b/internal/app/xterm_handoff_test.go @@ -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 { diff --git a/internal/sound/service.go b/internal/sound/service.go index 51cf569..39f18d3 100644 --- a/internal/sound/service.go +++ b/internal/sound/service.go @@ -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)) } diff --git a/tests/check_speech.py b/tests/check_speech.py index 36ab73f..5fa1eba 100755 --- a/tests/check_speech.py +++ b/tests/check_speech.py @@ -12,6 +12,7 @@ from native_test_session import run_private_session requiredTests = { + "TestSpeechWorkerRadioSelectionDoesNotCancelLabel", "TestPrivateWineReviewUpdatesPreserveQueuedTitleSpeech", "TestPrivateWineTitleSurvivesReviewFocusBurst", "TestAutomaticTitleSurvivesMovementRepeatsBeforeAndAfterDelivery", diff --git a/tests/native_echo_preview.py b/tests/native_echo_preview.py index 5767399..e5800a8 100644 --- a/tests/native_echo_preview.py +++ b/tests/native_echo_preview.py @@ -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"] diff --git a/tests/native_preferences.py b/tests/native_preferences.py index b380b3e..7501267 100644 --- a/tests/native_preferences.py +++ b/tests/native_preferences.py @@ -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()