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:
1 parent
2c6ab2ce76
commit
82887b3897
64 files changed
+501
-225
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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}) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}) {
|
||||
|
||||
@@ -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:
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from native_test_session import run_private_session
|
||||
|
||||
|
||||
requiredTests = {
|
||||
"TestSpeechWorkerRadioSelectionDoesNotCancelLabel",
|
||||
"TestPrivateWineReviewUpdatesPreserveQueuedTitleSpeech",
|
||||
"TestPrivateWineTitleSurvivesReviewFocusBurst",
|
||||
"TestAutomaticTitleSurvivesMovementRepeatsBeforeAndAfterDelivery",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user