Echo voice now used for its adjusters so prieview can be done while setting the voice.

This commit is contained in:
Storm Dragon committed 2026-10-15 23:33:02 -04:00
1 parent 70d447667e
commit 0834b47b55
26 files changed
+1080 -52

No files matched your search

+21
View File
@@ -20,6 +20,27 @@ and custom echo voice settings, and Apply takes effect immediately. Existing
configurations keep their previous enabled or disabled behavior. The new setting configurations keep their previous enabled or disabled behavior. The new setting
is `edit-fields-only` in the profile's `key-echo` table. is `edit-fields-only` in the profile's `key-echo` table.
Enable **Use custom echo voice** to choose the echo synthesizer, voice, rate,
pitch and volume. The synthesizer starts with the main speech synthesizer unless
a separate echo synthesizer was previously configured. No additional synthesizer
checkbox is needed. Under **Use the custom voice for:**, choose **Key names**
(such as Enter, Shift and arrows), **Typed characters**, **Words** or **Sentences**.
These choices select the voice; the corresponding echo settings still control
what is spoken. Turn off custom echo voice to use the main speech settings.
Changing the custom echo synthesizer, person, rate, pitch or volume speaks the
selected value using the echo voice and its draft settings, before Apply.
Tab and Shift+Tab announce the field label in the main voice, followed by its
current value in the echo voice. Other controls use the main voice.
Apply saves the changes; Cancel restores the previous settings.
For the combined reader, GTK and recorded-speech regression check, build a test
binary and run `CTHULHU_BINARY=/path/to/test-binary CTHULHU_PREFS_ECHO_RUNTIME_ONLY=1 /usr/bin/python3 tests/native_preferences.py`.
This uses private Xvfb, D-Bus and SSIP services. Live listening still needs a
separate check: Tab and Shift+Tab across all five fields, change synthesizer
and person with arrows and their popups, and confirm labels use the main voice
while values finish in the selected echo voice.
Native controls must expose editable accessibility metadata. This includes Native controls must expose editable accessibility metadata. This includes
standard entries, text editors and editable browser controls. Read-only fields, standard entries, text editors and editable browser controls. Read-only fields,
password contents, buttons, menus and terminals do not enable this mode. Controls password contents, buttons, menus and terminals do not enable this mode. Controls
+3
View File
@@ -310,6 +310,9 @@ func Run(ctx context.Context, options Options) error {
speechOutput.Enqueue(speechRequest{Text: message, Role: "system", Cause: "preferences-applied", Confirmation: true}) speechOutput.Enqueue(speechRequest{Text: message, Role: "system", Cause: "preferences-applied", Confirmation: true})
brailleOutput.FlashConfirmation(message, live.Load().Braille) brailleOutput.FlashConfirmation(message, live.Load().Braille)
} }
preferences.announceEchoPreview = func(label, value string) {
speechOutput.Enqueue(echo_preview_speech(label, value))
}
services = append(services, lifecycle.Service{Name: "preferences", Start: preferences.Start, Stop: preferences.Stop}) services = append(services, lifecycle.Service{Name: "preferences", Start: preferences.Start, Stop: preferences.Stop})
notifications, onNotificationList, notificationServices := newNotificationRuntime(logger, speechOutput, brailleOutput, live, options.onNotificationList) notifications, onNotificationList, notificationServices := newNotificationRuntime(logger, speechOutput, brailleOutput, live, options.onNotificationList)
+3
View File
@@ -241,6 +241,9 @@ func (speaker *hardwareSpeaker) speak_character(ctx context.Context, text string
return speaker.speak(ctx, text, echo, kind) return speaker.speak(ctx, text, echo, kind)
} }
func (speaker *hardwareSpeaker) SpeakVoice(ctx context.Context, text, kind string) error { func (speaker *hardwareSpeaker) SpeakVoice(ctx context.Context, text, kind string) error {
if kind == "echo" {
return speaker.SpeakEcho(ctx, text)
}
return speaker.speak(ctx, text, false, kind) return speaker.speak(ctx, text, false, kind)
} }
func (speaker *hardwareSpeaker) speak(ctx context.Context, text string, echo bool, kind string) error { func (speaker *hardwareSpeaker) speak(ctx context.Context, text string, echo bool, kind string) error {
+63 -4
View File
@@ -18,6 +18,7 @@ import (
"git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/preferences"
"git.stormux.org/storm/cthulhu/internal/remote" "git.stormux.org/storm/cthulhu/internal/remote"
) )
@@ -34,8 +35,9 @@ func test_echo_speaker(t *testing.T, wrapped bool) {
t.Fatal(err) t.Fatal(err)
} }
type utterance struct { type utterance struct {
module, text string module, text string
rate, pitch string rate, pitch string
volume, voice string
} }
spoken := make(chan utterance, 10) spoken := make(chan utterance, 10)
var handlers sync.WaitGroup var handlers sync.WaitGroup
@@ -53,6 +55,8 @@ func test_echo_speaker(t *testing.T, wrapped bool) {
defer conn.Close() defer conn.Close()
reader := bufio.NewReader(conn) reader := bufio.NewReader(conn)
module, rate, pitch := "", "", "" module, rate, pitch := "", "", ""
volume, voice := "", ""
notifications := false
for { for {
line, err := reader.ReadString('\n') line, err := reader.ReadString('\n')
if err != nil { if err != nil {
@@ -60,6 +64,11 @@ func test_echo_speaker(t *testing.T, wrapped bool) {
} }
command := strings.TrimSpace(line) command := strings.TrimSpace(line)
response := "200 OK\r\n" response := "200 OK\r\n"
if command == "SET SELF NOTIFICATION ALL on" {
notifications = true
} else if command == "SET SELF NOTIFICATION ALL off" {
notifications = false
}
if strings.HasPrefix(command, "SET SELF OUTPUT_MODULE ") { if strings.HasPrefix(command, "SET SELF OUTPUT_MODULE ") {
module = strings.TrimPrefix(command, "SET SELF OUTPUT_MODULE ") module = strings.TrimPrefix(command, "SET SELF OUTPUT_MODULE ")
} }
@@ -69,6 +78,12 @@ func test_echo_speaker(t *testing.T, wrapped bool) {
if strings.HasPrefix(command, "SET SELF PITCH ") { if strings.HasPrefix(command, "SET SELF PITCH ") {
pitch = strings.TrimPrefix(command, "SET SELF PITCH ") pitch = strings.TrimPrefix(command, "SET SELF PITCH ")
} }
if strings.HasPrefix(command, "SET SELF VOLUME ") {
volume = strings.TrimPrefix(command, "SET SELF VOLUME ")
}
if strings.HasPrefix(command, "SET SELF SYNTHESIS_VOICE ") {
voice = strings.TrimPrefix(command, "SET SELF SYNTHESIS_VOICE ")
}
if command == "SET SELF OUTPUT_MODULE rejected" { if command == "SET SELF OUTPUT_MODULE rejected" {
response = "400 Module unavailable\r\n" response = "400 Module unavailable\r\n"
} }
@@ -76,7 +91,7 @@ func test_echo_speaker(t *testing.T, wrapped bool) {
response = fmt.Sprintf("245-%d\r\n245 OK\r\n", id) response = fmt.Sprintf("245-%d\r\n245 OK\r\n", id)
} }
if strings.HasPrefix(command, "CHAR ") { if strings.HasPrefix(command, "CHAR ") {
spoken <- utterance{module, strings.TrimPrefix(command, "CHAR "), rate, pitch} spoken <- utterance{module, strings.TrimPrefix(command, "CHAR "), rate, pitch, volume, voice}
} }
if command == "SPEAK" { if command == "SPEAK" {
var text strings.Builder var text strings.Builder
@@ -90,8 +105,11 @@ func test_echo_speaker(t *testing.T, wrapped bool) {
} }
text.WriteString(strings.TrimSuffix(part, "\r\n")) text.WriteString(strings.TrimSuffix(part, "\r\n"))
} }
spoken <- utterance{module, text.String(), rate, pitch} spoken <- utterance{module, text.String(), rate, pitch, volume, voice}
response = "230 OK\r\n200 QUEUED\r\n" response = "230 OK\r\n200 QUEUED\r\n"
if notifications {
response += fmt.Sprintf("701-1\r\n701-%d\r\n701 BEGIN\r\n702-1\r\n702-%d\r\n702 END\r\n", id, id)
}
} }
if _, err := conn.Write([]byte(response)); err != nil { if _, err := conn.Write([]byte(response)); err != nil {
return return
@@ -141,6 +159,47 @@ func test_echo_speaker(t *testing.T, wrapped bool) {
} }
check(false, "", "") check(false, "", "")
check(true, "echo-module", "62") check(true, "echo-module", "62")
// Preview unsaved echo selections through the real SSIP clients. Only the
// numeric request uses that endpoint; subsequent dialog speech stays main.
previewWorker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, speaker)
previewValues := preferences.FromSettings(settings)
previewRate, previewPitch, previewVolume := 70.0, 6.0, 10.0
previewValues.EchoSpeechServer = "preview-module"
previewValues.EchoVoice = config.Voice{Rate: &previewRate, AveragePitch: &previewPitch, Gain: &previewVolume,
Family: &config.VoiceFamily{Name: "Preview voice", Lang: "en"}}
previewSpeech := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
previewWorker.announceEchoPreview = func(label, value string) {
if err := previewSpeech.deliver(ctx, echo_preview_speech(label, value)); err != nil {
t.Fatal(err)
}
}
for _, test := range []struct{ slider, text string }{{"rate", "70.0"}, {"pitch", "6.0"}, {"volume", "10.0"}, {"synthesizer", "preview-module"}, {"person", "Preview voice (en)"}} {
if response := previewWorker.preview(ctx, preferences.Request{Values: previewValues, Preview: true, EchoPreview: test.slider, EchoPreviewFocus: true}); !response.Accepted {
t.Fatalf("echo preview rejected: %+v", response)
}
select {
case got := <-spoken:
if !strings.HasPrefix(got.text, "Key echo ") || got.module != "" || got.voice != "" {
t.Fatalf("echo focus label did not use the normal voice: %+v", got)
}
case <-ctx.Done():
t.Fatal("echo focus label missing")
}
select {
case got := <-spoken:
if got.text != test.text || got.module != "preview-module" || got.voice != "Preview voice" ||
got.rate != "42" || got.pitch != "30" || got.volume != "100" {
t.Fatalf("echo slider SSIP request = %+v", got)
}
case <-ctx.Done():
t.Fatal("echo slider SSIP request missing")
}
check(false, "", "")
}
if response := previewWorker.restorePreview(ctx); !response.Accepted {
t.Fatalf("restore echo preview: %+v", response)
}
check(true, "echo-module", "62")
checkCharacters := func(wantNormal string) { checkCharacters := func(wantNormal string) {
t.Helper() t.Helper()
worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
+6
View File
@@ -37,6 +37,12 @@ func (presenter *eventPresenter) speak(event atspi.Event, capturedIntent ...inpu
foreground := presenter.foreground foreground := presenter.foreground
inputSource := presenter.inputSource inputSource := presenter.inputSource
live := presenter.live live := presenter.live
if live != nil && state.echoPreview.owns(event, live.preferencesPID.Load()) {
state.comboHistory.observe(event)
// Focus labels and values are serialized by the private preview path.
// GTK selection descendants belong to that same presentation.
return
}
browser := presenter.browser browser := presenter.browser
ownedAutocompletePopup := state.ownedAutocompletePopup ownedAutocompletePopup := state.ownedAutocompletePopup
if foreground != nil && !eventBypassesXTermHandoff(event) && if foreground != nil && !eventBypassesXTermHandoff(event) &&
+2
View File
@@ -98,6 +98,7 @@ type eventPresentationState struct {
lastLiveText string lastLiveText string
lastLiveAt time.Time lastLiveAt time.Time
comboHistory comboPresentation comboHistory comboPresentation
echoPreview echoPreviewPresentation
// Retain this identity after the history window closes so delayed GTK // Retain this identity after the history window closes so delayed GTK
// events cannot enter speech history. // events cannot enter speech history.
historyWindow atspi.ObjectID historyWindow atspi.ObjectID
@@ -167,6 +168,7 @@ func (state *eventLoopState) clearObjectFocus(brailleOutput brailleFrameEnqueuer
state.lastSpokenPath, state.lastSpokenText, state.lastSound = "", "", "" state.lastSpokenPath, state.lastSpokenText, state.lastSound = "", "", ""
state.lastLiveText, state.lastLiveAt = "", time.Time{} state.lastLiveText, state.lastLiveAt = "", time.Time{}
state.comboHistory = comboPresentation{} state.comboHistory = comboPresentation{}
state.echoPreview = echoPreviewPresentation{}
state.typingPath, state.typingText, state.suppressNextCaretPath = "", "", "" state.typingPath, state.typingText, state.suppressNextCaretPath = "", "", ""
state.lastAutocompletePopupItem = atspi.ObjectID{} state.lastAutocompletePopupItem = atspi.ObjectID{}
state.autocompleteTyping = autocompleteEcho{} state.autocompleteTyping = autocompleteEcho{}
+1
View File
@@ -15,6 +15,7 @@ import (
type liveSettings struct { type liveSettings struct {
editFieldFocused func() bool // Installed before workers start; reads cached focus only. editFieldFocused func() bool // Installed before workers start; reads cached focus only.
current atomic.Pointer[config.Settings] current atomic.Pointer[config.Settings]
preferencesPID atomic.Int64 // Owned by the preferences window lifetime; zero when closed.
application atomic.Pointer[string] application atomic.Pointer[string]
requestedApplication atomic.Pointer[string] requestedApplication atomic.Pointer[string]
clipboardChanges chan struct{} clipboardChanges chan struct{}
+11 -10
View File
@@ -55,16 +55,17 @@ type preferencesWorker struct {
bindings interface { bindings interface {
SetKeyBindings(context.Context, []input.BindingOverride) error SetKeyBindings(context.Context, []input.BindingOverride) error
} }
braille brailleSettingsUpdater braille brailleSettingsUpdater
documentReview *review.Worker documentReview *review.Worker
socket string socket string
speechCommands chan input.Command speechCommands chan input.Command
remoteChanges chan remoteSettingsChange remoteChanges chan remoteSettingsChange
speechSetting int speechSetting int
announce func(string) announce func(string)
announceApply func(string) announceApply func(string)
catalog speech.Catalog announceEchoPreview func(string, string)
discover func(context.Context, string) (speech.Catalog, error) catalog speech.Catalog
discover func(context.Context, string) (speech.Catalog, error)
} }
func newPreferencesWorker(logger *slog.Logger, settings config.Settings, updater any) *preferencesWorker { func newPreferencesWorker(logger *slog.Logger, settings config.Settings, updater any) *preferencesWorker {
@@ -0,0 +1,279 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"errors"
"log/slog"
"reflect"
"strings"
"testing"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/braille"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/preferences"
)
type echoPreviewUpdater func(context.Context, config.Settings) error
func (update echoPreviewUpdater) UpdateConfiguration(ctx context.Context, settings config.Settings) error {
return update(ctx, settings)
}
func TestPreferencesEchoSliderPreviewsDraftWithoutChangingMainVoice(t *testing.T) {
settings := config.DefaultSettings()
settings.SpeechOutputModule = "main-module"
updater := &voiceConfigurationRecorder{}
worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, updater)
live := &liveSettings{}
live.Store(settings)
worker.live = live
values := preferences.FromSettings(settings)
rate, pitch, volume := 78.2, 6.3, 10.0
values.UseCustomEchoVoice, values.UseCustomEchoSpeechServer = true, true
values.EchoSpeechServer = "echo-module"
values.EchoVoice = config.Voice{Rate: &rate, AveragePitch: &pitch, Gain: &volume,
Family: &config.VoiceFamily{Name: "Echo voice", Lang: "en"}}
// Unapplied Speech-tab choices must not become the normal dialog voice.
values.SpeechOutputModule = "unapplied-module"
values.VoiceType = "uppercase"
values.Rate = &rate
for _, test := range []struct{ slider, want string }{{"rate", "78.2"}, {"pitch", "6.3"}, {"volume", "10.0"}, {"synthesizer", "echo-module"}, {"person", "Echo voice (en)"}} {
announced := ""
worker.announceEchoPreview = func(_ string, value string) {
if updater.calls == 0 || !reflect.DeepEqual(updater.settings.EchoVoice, values.EchoVoice) {
t.Fatal("value announced before installing draft echo voice")
}
announced = value
}
response := worker.preview(t.Context(), preferences.Request{Values: values, Preview: true, EchoPreview: test.slider})
if !response.Accepted || !response.Preview || announced != test.want {
t.Fatalf("%s: response=%+v speech=%q", test.slider, response, announced)
}
if updater.settings.SpeechOutputModule != settings.SpeechOutputModule ||
!reflect.DeepEqual(updater.settings.DefaultVoice, settings.DefaultVoice) ||
!reflect.DeepEqual(updater.settings.SystemVoice, settings.SystemVoice) {
t.Fatal("echo preview changed ordinary voices")
}
if updater.settings.EchoSpeechServer != "echo-module" || !updater.settings.UseCustomEchoVoice {
t.Fatal("echo preview ignored the selected module")
}
if !reflect.DeepEqual(worker.settings, settings) || !reflect.DeepEqual(live.Load(), settings) {
t.Fatal("preview committed session or live settings")
}
}
if response := worker.restorePreview(t.Context()); !response.Accepted || !reflect.DeepEqual(updater.settings, settings) {
t.Fatal("Cancel did not restore committed speech settings")
}
}
func TestPreferencesEchoPreviewRejectsInvalidDraftAndBackendFailure(t *testing.T) {
for _, test := range []string{"unknown slider", "disabled", "missing value", "out of range", "backend failure"} {
t.Run(test, func(t *testing.T) {
calls := 0
settings := config.DefaultSettings()
worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, echoPreviewUpdater(func(context.Context, config.Settings) error {
calls++
return errors.New("preview unavailable")
}))
worker.announceEchoPreview = func(string, string) { t.Fatal("rejected preview spoke a value") }
values := preferences.FromSettings(settings)
volume := 5.0
values.UseCustomEchoVoice = true
values.EchoVoice.Gain = &volume
request := preferences.Request{Values: values, Preview: true, EchoPreview: "volume"}
switch test {
case "unknown slider":
request.EchoPreview = "other"
case "disabled":
request.Values.UseCustomEchoVoice = false
case "missing value":
request.Values.EchoVoice.Gain = nil
case "out of range":
volume = 11
}
if response := worker.preview(t.Context(), request); response.Accepted || !response.Preview {
t.Fatalf("invalid preview response: %+v", response)
}
wantCalls := 0
if test == "backend failure" {
wantCalls = 1
}
if calls != wantCalls {
t.Fatalf("backend updates=%d, want %d", calls, wantCalls)
}
})
}
}
func TestEchoPreviewValueKeepsBrailleWithoutDuplicateSpeech(t *testing.T) {
settings := config.DefaultSettings()
live := &liveSettings{}
live.Store(settings)
live.preferencesPID.Store(123)
source := atspi.Snapshot{BusName: ":1.42", Path: "/echo-volume", Name: "Key echo volume",
Role: "slider", Focused: true, ValueText: "5.0", ApplicationPID: 123}
state := &eventLoopState{}
state.current, state.focusedID = source, source.ID()
queue := &remoteSpeechQueue{}
frames := frameRecorder{frames: make(chan braille.Frame, 2)}
logger := slog.New(slog.DiscardHandler)
presenter := eventPresenter{state: state, logger: logger, speech: queue, live: live}
handler := accessibilityEventHandler{state: state, logger: logger, live: live, brailleOutput: frames}
for _, kind := range []string{atspi.ValueChangedEvent, atspi.AccessibleValueEvent} {
source.ValueText = "5.1"
event := atspi.NewEvent(kind, source)
handler.acceptFocusedEvent(event, input.Activity{})
presenter.speak(event)
if len(queue.requests) != 0 {
t.Fatal("AT-SPI repeated the echo preview in the main voice")
}
if len(frames.frames) != 1 {
t.Fatal("echo preview suppressed Braille value updates")
}
if frame := <-frames.frames; !strings.Contains(frame.Text, "5.1") {
t.Fatalf("Braille did not receive the updated value: %+v", frame)
}
}
presenter.speak(atspi.NewEvent(atspi.FocusEvent, source))
if len(queue.requests) != 0 {
t.Fatal("AT-SPI repeated the separately queued focus preview")
}
}
func TestPreferencesEchoComboDefaultValuesUseDraftVoice(t *testing.T) {
settings := config.DefaultSettings()
updater := &voiceConfigurationRecorder{}
worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, updater)
values := preferences.FromSettings(settings)
values.UseCustomEchoVoice, values.UseCustomEchoSpeechServer = true, true
values.EchoSpeechServer, values.EchoVoice.Family = "", nil
announced := ""
worker.announceEchoPreview = func(_ string, value string) { announced = value }
for _, control := range []string{"synthesizer", "person"} {
if response := worker.preview(t.Context(), preferences.Request{Values: values, Preview: true, EchoPreview: control}); !response.Accepted || announced != "Default" {
t.Fatalf("%s default preview: response=%+v text=%q", control, response, announced)
}
}
}
func TestEchoPreviewGTKSelectedMenuItemCannotRepeat(t *testing.T) {
// Reproduce the native log sequence: combo focus, name update, then a
// selected menu-item snapshot whose OriginPath is the original combo.
for _, control := range []string{"Key echo speech synthesizer", "Key echo person"} {
settings := config.DefaultSettings()
live := &liveSettings{}
live.Store(settings)
live.preferencesPID.Store(123)
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
source := atspi.Snapshot{BusName: ":1.42", Path: "/combo", Role: "combo box",
Name: control, ValueText: "Default", Focused: true, ApplicationPID: 123}
adapter.events <- atspi.NewEvent(atspi.FocusEvent, source)
source.ValueText = "Selected voice"
adapter.events <- atspi.NewEvent(atspi.AccessibleNameEvent, source)
selected := atspi.NewEvent(atspi.SelectionChangedEvent, atspi.Snapshot{BusName: source.BusName,
Path: "/selected", Name: "Selected voice", Role: "menu item", Selected: true})
selected.OriginPath = source.Path
adapter.events <- selected
adapter.events <- atspi.NewEvent(atspi.AccessibleNameEvent, source)
adapter.events <- atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{BusName: source.BusName,
Path: "/apply", Name: "Apply", Role: "button", Focused: true})
close(adapter.events)
queue := &remoteSpeechQueue{}
if err := processEventsWithSettings(t.Context(), slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, live); err != nil {
t.Fatal(err)
}
if len(queue.requests) != 1 || queue.requests[0].Text != "Apply, button" {
t.Fatalf("%s: native duplicate escaped preview ownership: %+v", control, queue.requests)
}
}
}
func TestEchoPreviewSelectionOwnershipIsBoundedToChildAndControls(t *testing.T) {
preview := echoPreviewPresentation{}
source := atspi.Snapshot{BusName: ":1.42", Path: "/combo", Name: "Key echo person", Role: "combo box", ApplicationPID: 123}
if !preview.owns(atspi.NewEvent(atspi.FocusEvent, source), 123) {
t.Fatal("owned focus was not recognized")
}
selected := atspi.NewEvent(atspi.SelectionChangedEvent, atspi.Snapshot{BusName: source.BusName, Path: "/item", Role: "menu item"})
selected.OriginPath = source.Path
if !preview.owns(selected, 123) {
t.Fatal("selected descendant was not recognized")
}
selected.Source.BusName = ":1.43"
if preview.owns(selected, 123) {
t.Fatal("selection in another application was suppressed")
}
selected.Source.BusName = source.BusName
selected.OriginPath = "/other-combo"
if preview.owns(selected, 123) {
t.Fatal("selection in another combo was suppressed")
}
selected.OriginPath = source.Path
if preview.owns(selected, 0) || preview.owns(selected, 456) {
t.Fatal("selection ownership survived preferences exit or replacement")
}
if preview.owns(atspi.NewEvent(atspi.FocusEvent, source), 456) {
t.Fatal("old child acquired the new preferences session")
}
}
type echoPreviewWaitSpeaker struct{ controlledSpeaker }
func (speaker *echoPreviewWaitSpeaker) SpeakAndWait(ctx context.Context, text string, _ func()) error {
return speaker.call(ctx, "default:"+text)
}
func (speaker *echoPreviewWaitSpeaker) SpeakVoiceAndWait(ctx context.Context, text, voice string, _ func()) error {
return speaker.call(ctx, voice+":"+text)
}
func TestEchoPreviewFocusSpeechWaitsForBothVoices(t *testing.T) {
speaker := &echoPreviewWaitSpeaker{controlledSpeaker{calls: make(chan speechCall, 8)}}
worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
if err := worker.Start(t.Context()); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { worker.Stop(context.Background()) })
worker.Enqueue(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 {
case call := <-speaker.calls:
t.Fatalf("queued output overtook the focus label: %s", call.text)
default:
}
close(label.done)
value := expectSpeechCall(t, &speaker.controlledSpeaker, "echo:Selected voice")
select {
case call := <-speaker.calls:
t.Fatalf("queued output cut off the echo value: %s", call.text)
default:
}
close(value.done)
close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done)
close(expectSpeechCall(t, &speaker.controlledSpeaker, "Later control").done)
}
func TestEchoPreviewFocusSpeechRemainsInterruptible(t *testing.T) {
speaker := &echoPreviewWaitSpeaker{controlledSpeaker{calls: make(chan speechCall, 8)}}
worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
if err := worker.Start(t.Context()); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { worker.Stop(context.Background()) })
worker.Enqueue(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")
worker.InterruptForInput(input.Event{Pressed: true, Name: "Tab", Timestamp: 100})
close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done)
close(value.done)
}
+80
View File
@@ -7,12 +7,17 @@ package app
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"strings"
"git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/preferences" "git.stormux.org/storm/cthulhu/internal/preferences"
) )
func (worker *preferencesWorker) preview(ctx context.Context, request preferences.Request) preferences.Response { func (worker *preferencesWorker) preview(ctx context.Context, request preferences.Request) preferences.Response {
if request.EchoPreview != "" {
return worker.preview_echo_voice(ctx, request)
}
next := worker.settings next := worker.settings
if worker.editingActive { if worker.editingActive {
next = worker.editing next = worker.editing
@@ -48,6 +53,81 @@ func (worker *preferencesWorker) preview(ctx context.Context, request preference
return response return response
} }
func (worker *preferencesWorker) preview_echo_voice(ctx context.Context, request preferences.Request) preferences.Response {
response := worker.response("Speech settings previewed.", false)
response.Preview = true
var value *float64
text := ""
label := ""
switch request.EchoPreview {
case "rate":
value = request.Values.EchoVoice.Rate
label = "Key echo rate"
case "pitch":
value = request.Values.EchoVoice.AveragePitch
label = "Key echo pitch"
case "volume":
value = request.Values.EchoVoice.Gain
label = "Key echo volume"
case "synthesizer":
label = "Key echo speech synthesizer"
text = request.Values.EchoSpeechServer
if text == "" {
text = "Default"
}
case "person":
label = "Key echo person"
text = "Default"
if family := request.Values.EchoVoice.Family; family != nil {
text = family.Name
if language := strings.TrimSuffix(family.Lang+"-"+family.Dialect, "-"); language != "" {
text += " (" + language + ")"
}
}
}
if value != nil {
text = fmt.Sprintf("%.1f", *value)
}
if !request.Values.UseCustomEchoVoice || text == "" {
response.Message = "Choose a custom echo voice and a valid echo control value."
return response
}
// Preview only echo settings. Control labels and ordinary output retain
// the active profile's main voice; the draft is never committed here.
next := worker.settings
next.UseCustomEchoVoice = true
next.UseCustomEchoSpeechServer = request.Values.UseCustomEchoSpeechServer
next.EchoSpeechServer = request.Values.EchoSpeechServer
next.EchoVoice = request.Values.EchoVoice
if err := config.Validate(next); err != nil {
response.Message = "Speech settings were not previewed: " + err.Error()
return response
}
if err := worker.updateSpeech(ctx, next); err != nil {
response.Message = "Speech settings were not previewed: " + err.Error()
return response
}
if worker.announceEchoPreview != nil {
if !request.EchoPreviewFocus {
label = ""
}
worker.announceEchoPreview(label, text)
}
response.Accepted = true
return response
}
func echo_preview_speech(label, value string) speechRequest {
parts := []readAllPart{}
if label != "" {
parts = append(parts, readAllPart{Text: label, VoiceType: "default"})
}
parts = append(parts, readAllPart{Text: value, VoiceType: "echo"})
// Both parts belong to one cancellable request and wait for completion.
// No second voice can cancel the first part merely by being queued next.
return speechRequest{Text: value, Role: "text", Cause: "echo-voice-preview", ReadAll: parts}
}
func (worker *preferencesWorker) restorePreview(ctx context.Context) preferences.Response { func (worker *preferencesWorker) restorePreview(ctx context.Context) preferences.Response {
response := worker.response("Speech settings restored.", false) response := worker.response("Speech settings restored.", false)
if err := worker.updateSpeech(ctx, worker.settings); err != nil { if err := worker.updateSpeech(ctx, worker.settings); err != nil {
@@ -0,0 +1,47 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import "git.stormux.org/storm/cthulhu/internal/atspi"
// The event loop remembers exactly five control identities for one preferences
// child. A new child or focus reset retires them. GTK reports combo selections
// as menu-item descendants with the owning combo in OriginPath.
type echoPreviewPresentation struct {
pid int64
controls [5]atspi.ObjectID
}
func (preview *echoPreviewPresentation) owns(event atspi.Event, pid int64) bool {
if preview.pid != pid {
*preview = echoPreviewPresentation{pid: pid}
}
if pid <= 0 {
return false
}
index := -1
switch event.Source.Name {
case "Key echo rate":
index = 0
case "Key echo pitch":
index = 1
case "Key echo volume":
index = 2
case "Key echo speech synthesizer":
index = 3
case "Key echo person":
index = 4
}
if index >= 0 && int64(event.Source.ApplicationPID) == pid &&
(index < 3 && event.Source.Role == "slider" || index >= 3 && event.Source.Role == "combo box") {
preview.controls[index] = event.Source.ID()
return true
}
if event.Type == atspi.SelectionChangedEvent && event.Source.Role == "menu item" {
owner := atspi.NewObjectID(event.Source.BusName, event.OriginPath)
return owner.Valid() && (owner == preview.controls[3] || owner == preview.controls[4])
}
return false
}
+4
View File
@@ -65,6 +65,10 @@ func (worker *preferencesWorker) window(ctx context.Context) (result error) {
out.Close() out.Close()
return err return err
} }
if worker.live != nil {
worker.live.preferencesPID.Store(int64(command.Process.Pid))
defer worker.live.preferencesPID.Store(0)
}
defer func() { defer func() {
stop() stop()
in.Close() in.Close()
+3 -1
View File
@@ -92,7 +92,9 @@ func (worker *speechWorker) deliver(ctx context.Context, request speechRequest)
} }
} }
text := worker.transformText(worker.line_text(part.Text, part.Line)) text := worker.transformText(worker.line_text(part.Text, part.Line))
worker.logDelivery(ctx, request, text, index) delivery := request
delivery.VoiceType, delivery.CustomEcho = part.VoiceType, part.VoiceType == "echo"
worker.logDelivery(ctx, delivery, text, index)
if worker.history != nil && !request.ExcludeFromHistory && request.Cause != "speech-history" { if worker.history != nil && !request.ExcludeFromHistory && request.Cause != "speech-history" {
worker.history.add(text, request.Role) worker.history.add(text, request.Role)
} }
@@ -111,6 +111,10 @@ func (adapter *Adapter) captureNativeEventSnapshot(event *C.AtspiEvent, target *
} }
snapshot := snapshotFromNativeWithCaret(target, captureCaret, eventType == CaretEvent, false, adapter.captureSound.Load(), adapter.captureTextAttributes.Load()) snapshot := snapshotFromNativeWithCaret(target, captureCaret, eventType == CaretEvent, false, adapter.captureSound.Load(), adapter.captureTextAttributes.Load())
snapshot.ApplicationProcess = adapter.applicationProcess(target) snapshot.ApplicationProcess = adapter.applicationProcess(target)
if snapshot.Role == "slider" ||
snapshot.Role == "combo box" && (snapshot.Name == "Key echo speech synthesizer" || snapshot.Name == "Key echo person") {
snapshot.ApplicationPID = int(C.atspi_accessible_get_process_id(target, nil))
}
if snapshot.Role == "combo box" && strings.EqualFold(snapshot.ApplicationProcess, "firefox") { if snapshot.Role == "combo box" && strings.EqualFold(snapshot.ApplicationProcess, "firefox") {
snapshot.Autocomplete = snapshot.Autocomplete || snapshot.Autocomplete = snapshot.Autocomplete ||
isFirefoxChromeAutocomplete(snapshot, C.cthulhu_in_active_document(target) != 0) isFirefoxChromeAutocomplete(snapshot, C.cthulhu_in_active_document(target) != 0)
+2
View File
@@ -122,6 +122,8 @@ type Snapshot struct {
ApplicationName string ApplicationName string
// ApplicationProcess is resolved from the provider PID, not page content. // ApplicationProcess is resolved from the provider PID, not page content.
ApplicationProcess string ApplicationProcess string
// ApplicationPID identifies owned preferences controls during voice previews.
ApplicationPID int
// ToolkitName is captured for name-change events to select web policy // ToolkitName is captured for name-change events to select web policy
// even before document focus or an address-bar event has arrived. // even before document focus or an address-bar event has arrived.
ToolkitName string ToolkitName string
+1 -1
View File
@@ -137,7 +137,7 @@ func validate(settings Settings) error {
} }
if settings.SpeechServerFactory == "hardwarefactory" { if settings.SpeechServerFactory == "hardwarefactory" {
if settings.UseCustomEchoVoice && settings.UseCustomEchoSpeechServer { if settings.UseCustomEchoVoice && settings.UseCustomEchoSpeechServer {
return errors.New("a separate echo synthesizer requires Speech Dispatcher; disable that option for hardware speech") return errors.New("a separate echo synthesizer requires Speech Dispatcher; disable custom echo voice for hardware speech")
} }
switch settings.SpeechOutputModule { switch settings.SpeechOutputModule {
case "litetalk", "doubletalk", "tripletalk", "dectalk": case "litetalk", "doubletalk", "tripletalk", "dectalk":
+9 -14
View File
@@ -39,7 +39,6 @@ func echoChecks(values *Values) []struct {
{"enableEchoByWordCheckButton", &values.EnableEchoByWord}, {"enableEchoByWordCheckButton", &values.EnableEchoByWord},
{"enableEchoBySentenceCheckButton", &values.EnableEchoBySentence}, {"enableEchoBySentenceCheckButton", &values.EnableEchoBySentence},
{"useCustomEchoVoiceCheckButton", &values.UseCustomEchoVoice}, {"useCustomEchoVoiceCheckButton", &values.UseCustomEchoVoice},
{"useCustomEchoSpeechServerCheckButton", &values.UseCustomEchoSpeechServer},
{"useCustomEchoForKeyCheckButton", &values.UseCustomEchoForKey}, {"useCustomEchoForKeyCheckButton", &values.UseCustomEchoForKey},
{"useCustomEchoForCharacterCheckButton", &values.UseCustomEchoForCharacter}, {"useCustomEchoForCharacterCheckButton", &values.UseCustomEchoForCharacter},
{"useCustomEchoForWordCheckButton", &values.UseCustomEchoForWord}, {"useCustomEchoForWordCheckButton", &values.UseCustomEchoForWord},
@@ -48,6 +47,10 @@ func echoChecks(values *Values) []struct {
} }
func (session *uiSession) setEchoVoice(values Values) { func (session *uiSession) setEchoVoice(values Values) {
module := values.SpeechOutputModule
if values.UseCustomEchoSpeechServer {
module = values.EchoSpeechServer
}
session.echoModules = []string{""} session.echoModules = []string{""}
for module := range session.catalog { for module := range session.catalog {
if module != "" { if module != "" {
@@ -56,26 +59,21 @@ func (session *uiSession) setEchoVoice(values Values) {
} }
sort.Strings(session.echoModules[1:]) sort.Strings(session.echoModules[1:])
selected := 0 selected := 0
if values.EchoSpeechServer != "" { if module != "" {
selected = -1 selected = -1
for i, module := range session.echoModules { for i, candidate := range session.echoModules {
if module == values.EchoSpeechServer { if candidate == module {
selected = i selected = i
} }
} }
if selected < 0 { if selected < 0 {
selected = len(session.echoModules) selected = len(session.echoModules)
session.echoModules = append(session.echoModules, values.EchoSpeechServer) session.echoModules = append(session.echoModules, module)
} }
} }
labels := append([]string(nil), session.echoModules...) labels := append([]string(nil), session.echoModules...)
labels[0] = "Default" labels[0] = "Default"
comboItems("echoSpeechServers", labels, selected) comboItems("echoSpeechServers", labels, selected)
// The toggle state is installed by setValues before the next GTK iteration.
module := ""
if values.UseCustomEchoSpeechServer {
module = values.EchoSpeechServer
}
session.populateEchoFamilies(module, values.EchoVoice.Family) session.populateEchoFamilies(module, values.EchoVoice.Family)
for _, item := range []struct { for _, item := range []struct {
id string id string
@@ -129,10 +127,7 @@ func (session *uiSession) selectedEchoModule() string {
} }
func (session *uiSession) refreshEchoVoices() { func (session *uiSession) refreshEchoVoices() {
module := "" module := session.selectedEchoModule()
if getCheck("useCustomEchoSpeechServerCheckButton") {
module = session.selectedEchoModule()
}
if module != session.activeEchoModule { if module != session.activeEchoModule {
session.populateEchoFamilies(module, nil) session.populateEchoFamilies(module, nil)
} }
+41 -4
View File
@@ -6,6 +6,10 @@
static GtkBuilder *builder; static GtkBuilder *builder;
static int action; static int action;
static int voice_changed; static int voice_changed;
static int echoPreview;
static gboolean echoPreviewFocus;
static const char *echoControls[] = {"echoRateScale", "echoPitchScale", "echoVolumeScale",
"echoSpeechServers", "echoSpeechFamilies"};
// Borrowed from the builder for the duration of one Apply/OK request. // Borrowed from the builder for the duration of one Apply/OK request.
static GtkWidget *busy_focus; static GtkWidget *busy_focus;
void prefs_set_action(int value) { action = value; } void prefs_set_action(int value) { action = value; }
@@ -73,6 +77,29 @@ static void voice_value_changed(GtkWidget *control, gpointer data) {
(void)control; (void)data; (void)control; (void)data;
voice_changed = 1; voice_changed = 1;
} }
static gboolean echo_control_focused(GtkWidget *control) {
GtkWidget *focus = gtk_window_get_focus(GTK_WINDOW(prefs_widget("window")));
return focus && (focus == control || gtk_widget_is_ancestor(focus, control));
}
static void echo_value_changed(GtkWidget *control, gpointer data) {
if (echo_control_focused(control) && prefs_check_get("useCustomEchoVoiceCheckButton")) {
echoPreview = GPOINTER_TO_INT(data);
echoPreviewFocus = FALSE;
}
}
static void echo_focus_changed(GtkWindow *window, GtkWidget *focus, gpointer data) {
(void)window; (void)data;
echoPreview = 0;
if (!focus || !prefs_check_get("useCustomEchoVoiceCheckButton")) return;
for (unsigned i = 0; i < G_N_ELEMENTS(echoControls); i++) {
GtkWidget *control = prefs_widget(echoControls[i]);
if (focus == control || gtk_widget_is_ancestor(focus, control)) {
echoPreview = i + 1;
echoPreviewFocus = TRUE;
return;
}
}
}
static gboolean voice_scale_key_press(GtkWidget *control, GdkEventKey *event, gpointer data) { static gboolean voice_scale_key_press(GtkWidget *control, GdkEventKey *event, gpointer data) {
(void)data; (void)data;
if (event->state & gtk_accelerator_get_default_mod_mask()) return FALSE; if (event->state & gtk_accelerator_get_default_mod_mask()) return FALSE;
@@ -99,9 +126,8 @@ static void echo_sensitivity(GtkToggleButton *button, gpointer data) {
"enableDiacriticalKeysCheckButton"}; "enableDiacriticalKeysCheckButton"};
for (unsigned i = 0; i < G_N_ELEMENTS(categories); i++) gtk_widget_set_sensitive(prefs_widget(categories[i]), enabled); for (unsigned i = 0; i < G_N_ELEMENTS(categories); i++) gtk_widget_set_sensitive(prefs_widget(categories[i]), enabled);
int custom = prefs_check_get("useCustomEchoVoiceCheckButton"); int custom = prefs_check_get("useCustomEchoVoiceCheckButton");
const char *voice[] = {"useCustomEchoSpeechServerCheckButton", "echoSpeechFamilies", "echoRateScale", "echoPitchScale", "echoVolumeScale"}; const char *voice[] = {"echoSpeechServers", "echoSpeechFamilies", "echoRateScale", "echoPitchScale", "echoVolumeScale"};
for (unsigned i = 0; i < G_N_ELEMENTS(voice); i++) gtk_widget_set_sensitive(prefs_widget(voice[i]), custom); for (unsigned i = 0; i < G_N_ELEMENTS(voice); i++) gtk_widget_set_sensitive(prefs_widget(voice[i]), custom);
gtk_widget_set_sensitive(prefs_widget("echoSpeechServers"), custom && prefs_check_get("useCustomEchoSpeechServerCheckButton"));
gtk_widget_set_sensitive(prefs_widget("useCustomEchoForKeyCheckButton"), custom && enabled); gtk_widget_set_sensitive(prefs_widget("useCustomEchoForKeyCheckButton"), custom && enabled);
gtk_widget_set_sensitive(prefs_widget("useCustomEchoForCharacterCheckButton"), custom && prefs_check_get("enableEchoByCharacterCheckButton")); gtk_widget_set_sensitive(prefs_widget("useCustomEchoForCharacterCheckButton"), custom && prefs_check_get("enableEchoByCharacterCheckButton"));
gtk_widget_set_sensitive(prefs_widget("useCustomEchoForWordCheckButton"), custom && prefs_check_get("enableEchoByWordCheckButton")); gtk_widget_set_sensitive(prefs_widget("useCustomEchoForWordCheckButton"), custom && prefs_check_get("enableEchoByWordCheckButton"));
@@ -159,9 +185,12 @@ int prefs_init(const char *xml) {
atk_object_set_description(gtk_widget_get_accessible(control), atk_object_set_description(gtk_widget_get_accessible(control),
"Up and Down change by 1; Left and Right change by 0.1."); "Up and Down change by 1; Left and Right change by 0.1.");
} }
for (unsigned i = 0; i < G_N_ELEMENTS(echoControls); i++)
g_signal_connect(prefs_widget(echoControls[i]), i < 3 ? "value-changed" : "changed",
G_CALLBACK(echo_value_changed), GINT_TO_POINTER(i + 1));
g_signal_connect(prefs_widget("window"), "set-focus", G_CALLBACK(echo_focus_changed), NULL);
const char *voiceCombos[] = { const char *voiceCombos[] = {
"speechSystems", "speechServers", "speechLanguages", "speechFamilies", "voiceTypesCombo", "hardwareBaudCombo", "speechSystems", "speechServers", "speechLanguages", "speechFamilies", "voiceTypesCombo", "hardwareBaudCombo",
"echoSpeechServers", "echoSpeechFamilies",
}; };
for (unsigned i = 0; i < G_N_ELEMENTS(voiceCombos); i++) { for (unsigned i = 0; i < G_N_ELEMENTS(voiceCombos); i++) {
g_signal_connect(prefs_widget(voiceCombos[i]), "changed", g_signal_connect(prefs_widget(voiceCombos[i]), "changed",
@@ -231,7 +260,7 @@ int prefs_init(const char *xml) {
}; };
set_focus_chain("keyboard_layout_controls", keyboard_focus, set_focus_chain("keyboard_layout_controls", keyboard_focus,
G_N_ELEMENTS(keyboard_focus)); G_N_ELEMENTS(keyboard_focus));
const char *echo_toggles[] = {"keyEchoDisabledRadio", "keyEchoEnabledRadio", "keyEchoEditFieldsRadio", "useCustomEchoVoiceCheckButton", "useCustomEchoSpeechServerCheckButton", "enableEchoByCharacterCheckButton", "enableEchoByWordCheckButton", "enableEchoBySentenceCheckButton"}; const char *echo_toggles[] = {"keyEchoDisabledRadio", "keyEchoEnabledRadio", "keyEchoEditFieldsRadio", "useCustomEchoVoiceCheckButton", "enableEchoByCharacterCheckButton", "enableEchoByWordCheckButton", "enableEchoBySentenceCheckButton"};
for (unsigned i = 0; i < G_N_ELEMENTS(echo_toggles); i++) g_signal_connect(prefs_widget(echo_toggles[i]), "toggled", G_CALLBACK(echo_sensitivity), NULL); for (unsigned i = 0; i < G_N_ELEMENTS(echo_toggles); i++) g_signal_connect(prefs_widget(echo_toggles[i]), "toggled", G_CALLBACK(echo_sensitivity), NULL);
echo_sensitivity(NULL, NULL); echo_sensitivity(NULL, NULL);
g_signal_connect(prefs_widget("enableSoundCheckButton"), "toggled", G_CALLBACK(sound_sensitivity), NULL); g_signal_connect(prefs_widget("enableSoundCheckButton"), "toggled", G_CALLBACK(sound_sensitivity), NULL);
@@ -262,6 +291,14 @@ void prefs_set_application(const char *name) {
void prefs_iterate(void) { for (int i = 0; i < 64 && g_main_context_pending(NULL); i++) g_main_context_iteration(NULL, FALSE); } void prefs_iterate(void) { for (int i = 0; i < 64 && g_main_context_pending(NULL); i++) g_main_context_iteration(NULL, FALSE); }
int prefs_action(void) { int result = action; action = 0; return result; } int prefs_action(void) { int result = action; action = 0; return result; }
int prefs_voice_changed(void) { int result = voice_changed; voice_changed = 0; return result; } int prefs_voice_changed(void) { int result = voice_changed; voice_changed = 0; return result; }
int prefs_echo_preview(int *focused) {
int result = echoPreview;
echoPreview = 0;
if (focused) *focused = echoPreviewFocus;
if (result && (!echo_control_focused(prefs_widget(echoControls[result - 1])) ||
!prefs_check_get("useCustomEchoVoiceCheckButton"))) return 0;
return result;
}
void prefs_busy(int busy, int can_save) { void prefs_busy(int busy, int can_save) {
gtk_widget_set_sensitive(prefs_widget("notebook"), !busy); gtk_widget_set_sensitive(prefs_widget("notebook"), !busy);
gtk_widget_set_sensitive(prefs_widget("apply"), !busy); gtk_widget_set_sensitive(prefs_widget("apply"), !busy);
+1
View File
@@ -7,6 +7,7 @@ void prefs_set_application(const char *name);
void prefs_iterate(void); void prefs_iterate(void);
int prefs_action(void); int prefs_action(void);
int prefs_voice_changed(void); int prefs_voice_changed(void);
int prefs_echo_preview(int *focused);
void prefs_busy(int busy, int can_save); void prefs_busy(int busy, int can_save);
void prefs_present(void); void prefs_present(void);
void prefs_close(void); void prefs_close(void);
+8 -5
View File
@@ -209,16 +209,19 @@
<child><object class="GtkCheckButton" id="enableNavigationKeysCheckButton"><property name="label">Enable navigation keys</property><property name="can-focus">True</property></object></child> <child><object class="GtkCheckButton" id="enableNavigationKeysCheckButton"><property name="label">Enable navigation keys</property><property name="can-focus">True</property></object></child>
<child><object class="GtkCheckButton" id="enableDiacriticalKeysCheckButton"><property name="label">Enable diacritical keys</property><property name="can-focus">True</property></object></child> <child><object class="GtkCheckButton" id="enableDiacriticalKeysCheckButton"><property name="label">Enable diacritical keys</property><property name="can-focus">True</property></object></child>
<child><object class="GtkCheckButton" id="useCustomEchoVoiceCheckButton"><property name="label">Use custom echo voice</property><property name="can-focus">True</property></object></child> <child><object class="GtkCheckButton" id="useCustomEchoVoiceCheckButton"><property name="label">Use custom echo voice</property><property name="can-focus">True</property></object></child>
<child><object class="GtkCheckButton" id="useCustomEchoSpeechServerCheckButton"><property name="label">Use custom speech synthesizer for key echo</property><property name="can-focus">True</property></object></child> <child><object class="GtkLabel"><property name="label">Speech synthesizer</property><property name="mnemonic-widget">echoSpeechServers</property><property name="xalign">0</property></object></child>
<child><object class="GtkComboBoxText" id="echoSpeechServers"><property name="can-focus">False</property><items><item>Default</item></items></object></child> <child><object class="GtkComboBoxText" id="echoSpeechServers"><property name="can-focus">False</property><items><item>Default</item></items></object></child>
<child><object class="GtkLabel"><property name="label">Voice</property><property name="mnemonic-widget">echoSpeechFamilies</property><property name="xalign">0</property></object></child>
<child><object class="GtkComboBoxText" id="echoSpeechFamilies"><property name="can-focus">False</property><items><item>Default</item></items></object></child> <child><object class="GtkComboBoxText" id="echoSpeechFamilies"><property name="can-focus">False</property><items><item>Default</item></items></object></child>
<child><object class="GtkScale" id="echoRateScale"><property name="digits">1</property><property name="adjustment">echo_rate_adjustment</property><property name="can-focus">True</property><property name="hexpand">True</property><property name="tooltip-text">Key echo rate</property></object></child> <child><object class="GtkScale" id="echoRateScale"><property name="digits">1</property><property name="adjustment">echo_rate_adjustment</property><property name="can-focus">True</property><property name="hexpand">True</property><property name="tooltip-text">Key echo rate</property></object></child>
<child><object class="GtkScale" id="echoPitchScale"><property name="digits">1</property><property name="adjustment">echo_pitch_adjustment</property><property name="can-focus">True</property><property name="hexpand">True</property><property name="tooltip-text">Key echo pitch</property></object></child> <child><object class="GtkScale" id="echoPitchScale"><property name="digits">1</property><property name="adjustment">echo_pitch_adjustment</property><property name="can-focus">True</property><property name="hexpand">True</property><property name="tooltip-text">Key echo pitch</property></object></child>
<child><object class="GtkScale" id="echoVolumeScale"><property name="digits">1</property><property name="adjustment">echo_volume_adjustment</property><property name="can-focus">True</property><property name="hexpand">True</property><property name="tooltip-text">Key echo volume</property></object></child> <child><object class="GtkScale" id="echoVolumeScale"><property name="digits">1</property><property name="adjustment">echo_volume_adjustment</property><property name="can-focus">True</property><property name="hexpand">True</property><property name="tooltip-text">Key echo volume</property></object></child>
<child><object class="GtkCheckButton" id="useCustomEchoForKeyCheckButton"><property name="label">Use custom voice for key echo</property><property name="can-focus">True</property></object></child> <child><object class="GtkFrame"><property name="label">Use the custom voice for:</property><child><object class="GtkBox"><property name="orientation">vertical</property><property name="spacing">7</property><property name="border-width">8</property>
<child><object class="GtkCheckButton" id="useCustomEchoForCharacterCheckButton"><property name="label">Use custom voice for character echo</property><property name="can-focus">True</property></object></child> <child><object class="GtkCheckButton" id="useCustomEchoForKeyCheckButton"><property name="label">Key names</property><property name="can-focus">True</property></object></child>
<child><object class="GtkCheckButton" id="useCustomEchoForWordCheckButton"><property name="label">Use custom voice for word echo</property><property name="can-focus">True</property></object></child> <child><object class="GtkCheckButton" id="useCustomEchoForCharacterCheckButton"><property name="label">Typed characters</property><property name="can-focus">True</property></object></child>
<child><object class="GtkCheckButton" id="useCustomEchoForSentenceCheckButton"><property name="label">Use custom voice for sentence echo</property><property name="can-focus">True</property></object></child> <child><object class="GtkCheckButton" id="useCustomEchoForWordCheckButton"><property name="label">Words</property><property name="can-focus">True</property></object></child>
<child><object class="GtkCheckButton" id="useCustomEchoForSentenceCheckButton"><property name="label">Sentences</property><property name="can-focus">True</property></object></child>
</object></child></object></child>
<child><object class="GtkCheckButton" id="enableEchoByCharacterCheckButton"><property name="label">Enable echo by character</property><property name="can-focus">True</property></object></child> <child><object class="GtkCheckButton" id="enableEchoByCharacterCheckButton"><property name="label">Enable echo by character</property><property name="can-focus">True</property></object></child>
<child><object class="GtkCheckButton" id="enableEchoByWordCheckButton"><property name="label">Enable echo by word</property><property name="can-focus">True</property></object></child> <child><object class="GtkCheckButton" id="enableEchoByWordCheckButton"><property name="label">Enable echo by word</property><property name="can-focus">True</property></object></child>
<child><object class="GtkCheckButton" id="enableEchoBySentenceCheckButton"><property name="label">Enable echo by sentence</property><property name="can-focus">True</property></object></child> <child><object class="GtkCheckButton" id="enableEchoBySentenceCheckButton"><property name="label">Enable echo by sentence</property><property name="can-focus">True</property></object></child>
+5
View File
@@ -90,6 +90,11 @@ type Request struct {
// Preview applies the default voice controls to the running speech // Preview applies the default voice controls to the running speech
// backend without committing session or persistent settings. // backend without committing session or persistent settings.
Preview bool Preview bool
// EchoPreview identifies the focused echo control (rate, pitch, volume,
// synthesizer or person). Only its value uses the draft echo voice.
EchoPreview string `json:",omitempty"`
// Focus previews include the control label in the normal voice before its value.
EchoPreviewFocus bool `json:",omitempty"`
// Cancel restores the last committed settings after a live preview. // Cancel restores the last committed settings after a live preview.
Cancel bool Cancel bool
} }
+3
View File
@@ -342,6 +342,9 @@ func (session *uiSession) get_speech_values(values *Values) error {
values.UppercaseVoice, values.HyperlinkVoice, values.SystemVoice = session.voiceDrafts[1], session.voiceDrafts[2], session.voiceDrafts[3] values.UppercaseVoice, values.HyperlinkVoice, values.SystemVoice = session.voiceDrafts[1], session.voiceDrafts[2], session.voiceDrafts[3]
values.VoiceType = []string{"default", "uppercase", "hyperlink", "system"}[session.activeVoiceType] values.VoiceType = []string{"default", "uppercase", "hyperlink", "system"}[session.activeVoiceType]
values.EchoVoice, values.EchoSpeechServer = session.getEchoVoice() values.EchoVoice, values.EchoSpeechServer = session.getEchoVoice()
// The synthesizer dropdown now owns this choice directly. Retain the
// existing settings field so older profiles keep their meaning on load.
values.UseCustomEchoSpeechServer = true
values.SpeechServerFactory = "speechdispatcherfactory" values.SpeechServerFactory = "speechdispatcherfactory"
if session.activeSpeechSystem == 1 { if session.activeSpeechSystem == 1 {
values.SpeechServerFactory = "hardwarefactory" values.SpeechServerFactory = "hardwarefactory"
+12 -2
View File
@@ -70,6 +70,7 @@ func Run(ctx context.Context, input io.Reader, output io.Writer) error {
tentacles.populate(initial.Tentacles) tentacles.populate(initial.Tentacles)
var tentaclePending *tentacle.ManagementRequest var tentaclePending *tentacle.ManagementRequest
C.prefs_voice_changed() C.prefs_voice_changed()
C.prefs_echo_preview(nil)
status(initial.Message, false) status(initial.Message, false)
canSave, busy := initial.CanSave, false canSave, busy := initial.CanSave, false
previewPending, previewDirty := false, false previewPending, previewDirty := false, false
@@ -122,6 +123,7 @@ func Run(ctx context.Context, input io.Reader, output io.Writer) error {
if response.Accepted { if response.Accepted {
session.setValues(response.Values) session.setValues(response.Values)
C.prefs_voice_changed() C.prefs_voice_changed()
C.prefs_echo_preview(nil)
} }
C.prefs_busy(0, boolean(canSave)) C.prefs_busy(0, boolean(canSave))
status(response.Message, !response.Accepted) status(response.Message, !response.Accepted)
@@ -182,7 +184,15 @@ func Run(ctx context.Context, input io.Reader, output io.Writer) error {
C.prefs_busy(1, boolean(canSave)) C.prefs_busy(1, boolean(canSave))
continue continue
} }
if !previewDirty || previewPending || busy { if previewPending || busy {
continue
}
echoPreview := ""
var echoFocus C.int
if control := int(C.prefs_echo_preview(&echoFocus)); control != 0 {
echoPreview = []string{"rate", "pitch", "volume", "synthesizer", "person"}[control-1]
}
if !previewDirty && echoPreview == "" {
continue continue
} }
values, err := session.getValues() values, err := session.getValues()
@@ -191,7 +201,7 @@ func Run(ctx context.Context, input io.Reader, output io.Writer) error {
previewDirty = false previewDirty = false
continue continue
} }
if err := encoder.Encode(Request{Values: values, Preview: true}); err != nil { if err := encoder.Encode(Request{Values: values, Preview: true, EchoPreview: echoPreview, EchoPreviewFocus: echoFocus != 0}); err != nil {
return err return err
} }
previewPending = true previewPending = true
+209
View File
@@ -0,0 +1,209 @@
"""Real reader/preferences/GTK flow against a private, completion-aware SSIP server."""
import json
import os
from pathlib import Path
import socket
import subprocess
import tempfile
import threading
import time
from native_x11 import wait_for
class PreviewSpeechServer:
def __init__(self, path):
self.listener = socket.socket(socket.AF_UNIX)
self.listener.bind(str(path))
self.listener.listen()
self.lock = threading.RLock()
self.clients = {}
self.spoken = []
threading.Thread(target=self.accept_clients, daemon=True).start()
def accept_clients(self):
while True:
try:
connection, _ = self.listener.accept()
except OSError:
return
with self.lock:
clientId = len(self.clients) + 1
client = {"socket": connection, "lock": threading.RLock(), "notifications": False,
"module": "main", "voice": "", "active": None}
self.clients[clientId] = client
threading.Thread(target=self.serve_client, args=(clientId, client), daemon=True).start()
def reply(self, client, text):
with client["lock"]:
try:
client["socket"].sendall(text.encode())
except OSError:
pass
def finish(self, clientId, client, record, canceled=False):
with self.lock:
if client["active"] is not record:
return
client["active"] = None
record["finished"], record["canceled"] = True, canceled
if client["notifications"]:
code = 703 if canceled else 702
self.reply(client, f"{code}-1\r\n{code}-{clientId}\r\n{code} DONE\r\n")
def serve_client(self, clientId, client):
try:
with client["socket"].makefile("rb") as stream:
for raw in stream:
command = raw.decode().strip()
response = "200 OK\r\n"
if command == "HISTORY GET CLIENT_ID":
response = f"245-{clientId}\r\n245 OK\r\n"
elif command == "LIST OUTPUT_MODULES":
response = "200-echo-a\r\n200-echo-b\r\n200-main\r\n200 OK\r\n"
elif command == "LIST SYNTHESIS_VOICES":
voice = {"echo-a": "Echo A", "echo-b": "Echo B", "main": "Main"}[client["module"]]
response = f"200-{voice}\ten\tnone\r\n200 OK\r\n"
elif command.startswith("SET SELF OUTPUT_MODULE "):
client["module"] = command.removeprefix("SET SELF OUTPUT_MODULE ")
elif command.startswith("SET SELF SYNTHESIS_VOICE "):
client["voice"] = command.removeprefix("SET SELF SYNTHESIS_VOICE ")
elif command.startswith("SET SELF NOTIFICATION ALL "):
client["notifications"] = command.endswith(" on")
elif command.startswith("CANCEL "):
with self.lock:
targetId = int(command.split()[1])
target = self.clients[targetId]
if target["active"] is not None:
self.finish(targetId, target, target["active"], canceled=True)
elif command == "SPEAK":
parts = []
for line in stream:
if line == b".\r\n":
break
parts.append(line.decode().rstrip("\r\n"))
record = {"text": "\n".join(parts), "module": client["module"],
"voice": client["voice"], "finished": False, "canceled": False}
with self.lock:
self.spoken.append(record)
client["active"] = record
self.reply(client, "230 OK\r\n200 QUEUED\r\n")
if client["notifications"]:
self.reply(client, f"701-1\r\n701-{clientId}\r\n701 BEGIN\r\n")
timer = threading.Timer(0.25, self.finish, args=(clientId, client, record))
timer.daemon = True
timer.start()
continue
self.reply(client, response)
except (OSError, ValueError):
pass
def snapshot(self):
with self.lock:
return [dict(record) for record in self.spoken]
def close(self):
self.listener.close()
for client in self.clients.values():
client["socket"].close()
def check_runtime():
import pyatspi
directory = Path(tempfile.mkdtemp(prefix="cthulhu-echo-runtime-"))
binary = os.environ["CTHULHU_BINARY"]
wmConfig = directory / "i3.config"
wmConfig.write_text(f"font pango:monospace 10\nipc-socket {directory / 'i3.sock'}\n")
server = PreviewSpeechServer(directory / "speech.sock")
wm = subprocess.Popen(["i3", "-c", str(wmConfig)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
reader = None
try:
wait_for(lambda: "0x" in subprocess.check_output(["xprop", "-root", "_NET_SUPPORTING_WM_CHECK"], text=True), "private WM missing")
configPath = directory / "settings.toml"
configPath.write_text('''schema-version = 2
active-profile = "default"
[profiles.default.speech]
output-module = "main"
[profiles.default.braille]
enabled = false
[profiles.default.sound]
enabled = false
[profiles.default.key-echo]
enabled = false
custom-voice = true
custom-speech-server = true
speech-server = "echo-a"
[profiles.default.key-echo.voice]
rate = 50
average-pitch = 5
gain = 5
[profiles.default.key-echo.voice.family]
name = "Echo A"
lang = "en"
''')
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"))
def find_window():
for application in pyatspi.Registry.getDesktop(0):
for window in application:
if window and window.name == "Cthulhu Preferences":
return window
window = wait_for(find_window, "reader's preferences did not open")
def walk(node):
yield node
for child in node:
if child is not None:
yield from walk(child)
controls = {node.name: node for node in walk(window) if node.name}
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)
assert tabs.querySelection().selectChild(4)
assert controls["Use custom echo voice"].queryComponent().grabFocus()
wait_for(lambda: any(record["text"] == "checked" and record["finished"]
for record in server.snapshot()), "initial checkbox announcement did not finish")
time.sleep(0.3)
def key(*keys):
subprocess.run(["xdotool", "key", "--clearmodifiers", *keys], check=True)
def check_output(keys, expected):
offset = len(server.snapshot())
key(*keys)
def completed():
records = server.snapshot()[offset:]
matching = [record for record in records if record["text"] in {text for text, _, _ in expected}]
return len(matching) >= len(expected) and all(record["finished"] for record in matching)
wait_for(completed, f"preview did not complete: {keys}")
# The original delayed menu-item event followed its preview by up
# to 160 ms. Require quiescence beyond that delay, then inspect all
# speech requests, including duplicates in the main voice.
time.sleep(0.25)
records = server.snapshot()[offset:]
got = [(record["text"], record["module"], record["voice"]) for record in records]
assert got == expected, (keys, got, expected)
assert not any(record["canceled"] for record in records), (keys, records)
check_output(("Tab",), [("Key echo speech synthesizer", "main", ""), ("echo-a", "echo-a", "Echo A")])
check_output(("Down",), [("echo-b", "echo-b", "")])
check_output(("Tab",), [("Key echo person", "main", ""), ("Default", "echo-b", "")])
check_output(("Down",), [("Echo B (en)", "echo-b", "Echo B")])
for control in ("rate", "pitch", "volume"):
value = "50.0" if control == "rate" else "5.0"
check_output(("Tab",), [("Key echo " + control, "main", ""), (value, "echo-b", "Echo B")])
for control in ("pitch", "rate", "person", "synthesizer"):
value = {"pitch": "5.0", "rate": "50.0", "person": "Echo B (en)", "synthesizer": "echo-b"}[control]
label = "Key echo speech synthesizer" if control == "synthesizer" else "Key echo " + control
check_output(("shift+Tab",), [(label, "main", ""), (value, "echo-b", "Echo B")])
print(f"PASS: real reader + GTK + SSIP focus/selection previews, voice completion, no duplicate menu-item speech; evidence: {directory}")
finally:
if reader is not None:
reader.terminate()
try:
reader.wait(timeout=5)
except subprocess.TimeoutExpired:
reader.kill()
reader.wait(timeout=5)
wm.terminate()
wm.wait(timeout=5)
server.close()
(directory / "speech.json").write_text(json.dumps(server.snapshot(), indent=2))
+203 -11
View File
@@ -13,7 +13,42 @@ sys.dont_write_bytecode = True
from native_x11 import wait_for from native_x11 import wait_for
class ResponseReader:
"""Read complete JSON lines with one deadline, preserving read-ahead bytes."""
def __init__(self, stream):
self.stream = stream
self.pendingData = bytearray()
def request(self, timeout=5):
deadline = time.monotonic() + timeout
while b"\n" not in self.pendingData:
remaining = deadline - time.monotonic()
if remaining <= 0 or not select.select([self.stream], [], [], remaining)[0]:
raise TimeoutError("no complete preferences request within response timeout")
# Never mix buffered reads with select: TextIO can retain another
# complete reply even when the underlying pipe is empty.
chunk = os.read(self.stream.fileno(), 65536)
if not chunk:
raise EOFError("preferences helper closed stdout before a complete request")
self.pendingData.extend(chunk)
end = self.pendingData.index(b"\n") + 1
line = self.pendingData[:end]
del self.pendingData[:end]
return json.loads(line)
def read_remaining(self):
# Used only after helper exit; include any bytes read ahead by request.
remaining = bytes(self.pendingData) + self.stream.buffer.read()
self.pendingData.clear()
return remaining.decode("utf-8")
def inside(): def inside():
if os.environ.get("CTHULHU_PREFS_ECHO_RUNTIME_ONLY") == "1":
from native_echo_preview import check_runtime
check_runtime()
return
if os.environ.get("CTHULHU_PREFS_ADAPTER_ONLY") == "1": if os.environ.get("CTHULHU_PREFS_ADAPTER_ONLY") == "1":
environment = dict(os.environ, CTHULHU_NATIVE_PREFERENCES="1") environment = dict(os.environ, CTHULHU_NATIVE_PREFERENCES="1")
environment.setdefault("GOCACHE", "/tmp/cthulhu-cache") environment.setdefault("GOCACHE", "/tmp/cthulhu-cache")
@@ -40,6 +75,7 @@ def inside():
binary = os.environ.get("CTHULHU_BINARY", str(Path(__file__).resolve().parents[1] / "cthulhu")) binary = os.environ.get("CTHULHU_BINARY", str(Path(__file__).resolve().parents[1] / "cthulhu"))
process = subprocess.Popen([binary, "--preferences-ui"], stdin=subprocess.PIPE, process = subprocess.Popen([binary, "--preferences-ui"], stdin=subprocess.PIPE,
stdout=subprocess.PIPE, text=True) stdout=subprocess.PIPE, text=True)
responses = ResponseReader(process.stdout)
initial = {"Values": {"Rate": 50, "Pitch": 5, "Volume": 5, initial = {"Values": {"Rate": 50, "Pitch": 5, "Volume": 5,
"SpeechHistoryLimit": 50, "SpeechHistoryLimit": 50,
"Reading": {"AutomaticallyReadTitleChanges": True}, "Reading": {"AutomaticallyReadTitleChanges": True},
@@ -71,6 +107,17 @@ def inside():
"test-module": [{"Name": "English test voice", "Language": "en", "Variant": "none"}, "test-module": [{"Name": "English test voice", "Language": "en", "Variant": "none"},
{"Name": "Spanish test voice", "Language": "es", "Variant": "none"}]}, {"Name": "Spanish test voice", "Language": "es", "Variant": "none"}]},
"CanSave": True, "Accepted": True, "Message": "Preferences test ready."} "CanSave": True, "Accepted": True, "Message": "Preferences test ready."}
echoFocusMode = os.environ.get("CTHULHU_PREFS_ECHO_PREVIEW_ONLY") == "1"
echo_mode = os.environ.get("CTHULHU_PREFS_KEY_ECHO_ONLY") or ("custom" if echoFocusMode else None)
if echo_mode:
initial["Catalog"]["echo-module"] = [
{"Name": "Echo test voice", "Language": "en", "Variant": "none"}]
initial["Values"].update(
SpeechOutputModule="test-module", EchoSpeechServer="echo-module",
UseCustomEchoVoice=echo_mode == "custom",
UseCustomEchoSpeechServer=echo_mode == "custom",
EnableEchoByCharacter=True, EnableEchoBySentence=True,
UseCustomEchoForCharacter=True)
if os.environ.get("CTHULHU_PREFS_SPEECH_ONLY") == "1": if os.environ.get("CTHULHU_PREFS_SPEECH_ONLY") == "1":
initial["Catalog"]["espeak-ng"] = list(initial["Catalog"]["test-module"]) initial["Catalog"]["espeak-ng"] = list(initial["Catalog"]["test-module"])
if os.environ.get("CTHULHU_PREFS_NUMBERS_ONLY") == "1": if os.environ.get("CTHULHU_PREFS_NUMBERS_ONLY") == "1":
@@ -129,8 +176,12 @@ def inside():
assert node.queryComponent().grabFocus(), ("cannot focus", node.name) assert node.queryComponent().grabFocus(), ("cannot focus", node.name)
wait_for(lambda: any(child.getState().contains(pyatspi.STATE_FOCUSED) for child in walk(node)), "focus missing") wait_for(lambda: any(child.getState().contains(pyatspi.STATE_FOCUSED) for child in walk(node)), "focus missing")
def request(): def request():
assert select.select([process.stdout], [], [], 5)[0], "no Apply request" while True:
return json.loads(process.stdout.readline()) value = responses.request()
if value.get("EchoPreviewFocus") and not echoFocusMode:
send({"Preview": True, "Accepted": True})
continue
return value
try: try:
send_initial(initial) send_initial(initial)
window = wait_for(find_window, "preferences accessible window missing") window = wait_for(find_window, "preferences accessible window missing")
@@ -141,6 +192,141 @@ def inside():
xid = subprocess.check_output(["xdotool", "search", "--onlyvisible", "--name", "^" + window_name + "$"], text=True).splitlines()[0] xid = subprocess.check_output(["xdotool", "search", "--onlyvisible", "--name", "^" + window_name + "$"], text=True).splitlines()[0]
subprocess.run(["xdotool", "windowfocus", xid], check=True) subprocess.run(["xdotool", "windowfocus", xid], check=True)
assert not any(node.name == "Profiles" for node in nodes), "removed profile controls remain in preferences" assert not any(node.name == "Profiles" for node in nodes), "removed profile controls remain in preferences"
if echoFocusMode:
tabs = next(n for n in nodes if n.getRole() == pyatspi.ROLE_PAGE_TAB_LIST)
assert tabs.querySelection().selectChild(4), "cannot select Key Echo tab"
def check_preview(control, isFocus):
value = request()
assert value["Preview"] and value.get("EchoPreview") == control, value
assert bool(value.get("EchoPreviewFocus")) == isFocus, value
send({"Preview": True, "Accepted": True})
return value["Values"]
focus(named("Use custom echo voice"))
key("Tab")
check_preview("synthesizer", True)
key("Down")
value = check_preview("synthesizer", False)
assert value["EchoSpeechServer"] == "test-module", value
key("Up")
value = check_preview("synthesizer", False)
assert value["EchoSpeechServer"] == "echo-module", value
key("Tab")
check_preview("person", True)
key("Down")
value = check_preview("person", False)
assert value["EchoVoice"]["Family"]["Name"] == "Echo test voice", value
for control in ("rate", "pitch", "volume"):
key("Tab")
check_preview(control, True)
key("Right")
check_preview(control, False)
for control in ("pitch", "rate", "person", "synthesizer"):
key("shift+Tab")
check_preview(control, True)
# Popup Escape restores the same combo without trapping focus.
key("alt+Down", "Escape", "Tab")
check_preview("person", True)
focus(named("Cancel")); key("space")
assert process.wait(timeout=5) == 0
print("PASS: all five echo controls preview their current values on Tab/Shift+Tab and changed values on arrows; popup Escape and Cancel")
return
if echo_mode:
assert "Use custom speech synthesizer for key echo" not in controls
assert "Use custom voice for key echo" not in controls
assert "Use the custom voice for:" in controls
tabs = next(n for n in nodes if n.getRole() == pyatspi.ROLE_PAGE_TAB_LIST)
assert tabs.querySelection().selectChild(4), "cannot select Key Echo tab"
custom = named("Use custom echo voice")
synth = named("Key echo speech synthesizer")
family = named("Key echo person")
rate = named("Key echo rate")
expected_module = "echo-module" if echo_mode == "custom" else "test-module"
assert synth.querySelection().getSelectedChild(0).name == expected_module
if echo_mode != "custom":
# An unused saved echo module must not override the main one.
assert not synth.getState().contains(pyatspi.STATE_SENSITIVE)
assert not family.getState().contains(pyatspi.STATE_SENSITIVE)
focus(custom); key("space")
wait_for(lambda: synth.getState().contains(pyatspi.STATE_SENSITIVE),
"custom echo did not enable the synthesizer directly")
def focused(node):
return any(child.getState().contains(pyatspi.STATE_FOCUSED) for child in walk(node))
# GTK's internal combo button does not consistently expose its
# focused state. Exercise the popup, value and following Tab stop.
focus(custom); key("Tab", "alt+Down", "Home", "Down")
if echo_mode != "custom":
key("Down")
key("Return")
wait_for(lambda: synth.description == expected_module, "Tab did not reach echo synthesizer")
key("Tab", "Tab")
wait_for(lambda: focused(rate), "voice selector trapped Tab")
key("shift+Tab", "shift+Tab")
key("shift+Tab")
wait_for(lambda: focused(custom), "synthesizer trapped Shift+Tab")
assert named("Key names").getState().contains(pyatspi.STATE_CHECKED)
assert named("Typed characters").getState().contains(pyatspi.STATE_CHECKED)
for name in ("Key names", "Typed characters", "Words", "Sentences"):
assert named(name).getRole() == pyatspi.ROLE_CHECK_BOX
assert named(name).getState().contains(pyatspi.STATE_SENSITIVE)
focus(named("Words")); key("space")
def apply_echo():
focus(named("Apply")); key("space")
while True:
applied = request()
if applied["Preview"]:
send({"Preview": True, "Accepted": True})
continue
assert applied["Save"] and not applied["Close"], applied
values = applied["Values"]
send(dict(initial, Values=values, Message="Echo settings saved."))
wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE),
"echo Apply stayed busy")
return values
saved = apply_echo()
assert saved["SpeechOutputModule"] == "test-module", saved
assert saved["UseCustomEchoVoice"] and saved["UseCustomEchoSpeechServer"], saved
assert saved["EchoSpeechServer"] == expected_module, saved
assert saved["UseCustomEchoForKey"] and saved["UseCustomEchoForCharacter"], saved
assert saved["UseCustomEchoForWord"] and not saved["UseCustomEchoForSentence"], saved
# Select Default first to guarantee a change in either startup
# mode. Only the selected combo value gets an echo preview.
def accept_echo_preview(control, module):
preview = request()
assert preview["Preview"] and preview.get("EchoPreview") == control, preview
assert preview["Values"]["EchoSpeechServer"] == module, preview
send({"Preview": True, "Accepted": True})
return preview["Values"]
focus(custom); key("Tab", "alt+Down", "Home", "Return")
preview = accept_echo_preview("synthesizer", "")
assert preview["EchoVoice"]["Family"] is None, preview
# Change the synthesizer using its keyboard popup, then select a
# person and verify each sends its newly selected draft voice.
focus(custom); key("Tab", "alt+Down", "Home", "Down", "Return")
accept_echo_preview("synthesizer", "echo-module")
wait_for(lambda: synth.description == "echo-module", "echo synthesizer did not change")
wait_for(lambda: any(child.name == "Echo test voice (en)" for child in walk(family)),
"echo voices did not follow the selected synthesizer")
key("Tab", "alt+Down", "Home", "Down", "Return")
preview = accept_echo_preview("person", "echo-module")
assert preview["EchoVoice"]["Family"]["Name"] == "Echo test voice", preview
saved = apply_echo()
assert saved["EchoSpeechServer"] == "echo-module", saved
assert saved["EchoVoice"]["Family"]["Name"] == "Echo test voice", saved
assert synth.querySelection().getSelectedChild(0).name == "echo-module"
assert family.querySelection().getSelectedChild(0).name == "Echo test voice (en)"
focus(custom); key("space")
wait_for(lambda: not synth.getState().contains(pyatspi.STATE_SENSITIVE),
"custom echo off left the synthesizer enabled")
saved = apply_echo()
assert not saved["UseCustomEchoVoice"] and saved["EchoSpeechServer"] == "echo-module", saved
focus(custom); key("space")
saved = apply_echo()
assert saved["UseCustomEchoVoice"] and saved["EchoSpeechServer"] == "echo-module", saved
assert saved["EchoVoice"]["Family"]["Name"] == "Echo test voice", saved
focus(named("Cancel")); key("space")
assert process.wait(timeout=5) == 0
print("PASS: echo synthesizer initialization, keyboard navigation, synthesizer/person previews, routing choices, Apply/reload, disable/re-enable")
return
if os.environ.get("CTHULHU_PREFS_TENTACLE_SETTINGS_ONLY") == "1": if os.environ.get("CTHULHU_PREFS_TENTACLE_SETTINGS_ONLY") == "1":
from native_tentacle_settings import check_page from native_tentacle_settings import check_page
check_page(pyatspi, window, nodes, named, walk, focus, key, request, send, initial) check_page(pyatspi, window, nodes, named, walk, focus, key, request, send, initial)
@@ -190,7 +376,7 @@ def inside():
focus(automatic); key("space") focus(automatic); key("space")
key("Escape") key("Escape")
assert process.wait(timeout=2) == 0, "Escape did not close OCR preferences" assert process.wait(timeout=2) == 0, "Escape did not close OCR preferences"
assert process.stdout.read() == "", "cancel submitted changed OCR settings" assert responses.read_remaining() == "", "cancel submitted changed OCR settings"
print("PASS: OCR layout Tab/Shift+Tab, popup selection, automatic reading checkbox, Apply, rollback, Cancel") print("PASS: OCR layout Tab/Shift+Tab, popup selection, automatic reading checkbox, Apply, rollback, Cancel")
return return
if os.environ.get("CTHULHU_PREFS_TEXT_ONLY") == "1": if os.environ.get("CTHULHU_PREFS_TEXT_ONLY") == "1":
@@ -240,9 +426,10 @@ def inside():
key("ctrl+Page_Down", "ctrl+Page_Down") key("ctrl+Page_Down", "ctrl+Page_Down")
wait_for(lambda: named("Speech").getState().contains(pyatspi.STATE_SELECTED), "Speech page missing") wait_for(lambda: named("Speech").getState().contains(pyatspi.STATE_SELECTED), "Speech page missing")
values = initial["Values"] values = initial["Values"]
def accept_preview(): def accept_preview(echoPreview=""):
preview = request() preview = request()
assert preview["Preview"], preview assert preview["Preview"], preview
assert preview.get("EchoPreview", "") == echoPreview, preview
send({"Preview": True, "Accepted": True}) send({"Preview": True, "Accepted": True})
return preview["Values"] return preview["Values"]
for name, field in (("Rate", "Rate"), ("Pitch", "Pitch"), ("Volume", "Volume")): for name, field in (("Rate", "Rate"), ("Pitch", "Pitch"), ("Volume", "Volume")):
@@ -276,6 +463,7 @@ def inside():
for name, field in (("Key echo rate", "Rate"), ("Key echo pitch", "AveragePitch"), ("Key echo volume", "Gain")): for name, field in (("Key echo rate", "Rate"), ("Key echo pitch", "AveragePitch"), ("Key echo volume", "Gain")):
slider = named(name) slider = named(name)
focus(slider) focus(slider)
echoPreview = {"Rate": "rate", "AveragePitch": "pitch", "Gain": "volume"}[field]
assert abs(slider.queryValue().minimumIncrement - 0.1) < 1e-8, (name, "accessible increment") assert abs(slider.queryValue().minimumIncrement - 0.1) < 1e-8, (name, "accessible increment")
for arrow, expected in (("Right", 5.1 if field != "Rate" else 50.1), for arrow, expected in (("Right", 5.1 if field != "Rate" else 50.1),
("Up", 6.1 if field != "Rate" else 51.1), ("Up", 6.1 if field != "Rate" else 51.1),
@@ -283,15 +471,19 @@ def inside():
("Down", 5 if field != "Rate" else 50), ("Down", 5 if field != "Rate" else 50),
("Left", 4.9 if field != "Rate" else 49.9)): ("Left", 4.9 if field != "Rate" else 49.9)):
key(arrow) key(arrow)
values = accept_preview(echoPreview)
wait_for(lambda: abs(slider.queryValue().currentValue - expected) < 1e-8, wait_for(lambda: abs(slider.queryValue().currentValue - expected) < 1e-8,
f"{name}: {arrow} did not produce {expected}") f"{name}: {arrow} did not produce {expected}")
assert abs(values["EchoVoice"][field] - expected) < 1e-8, values
finalValue = expected finalValue = expected
for bound, arrows in ((0, ("Left", "Down")), for bound, arrows in ((0, ("Left", "Down")),
(slider.queryValue().maximumValue, ("Right", "Up"))): (slider.queryValue().maximumValue, ("Right", "Up"))):
slider.queryValue().currentValue = bound slider.queryValue().currentValue = bound
accept_preview(echoPreview)
key(*arrows) key(*arrows)
assert slider.queryValue().currentValue == bound, (name, "boundary", bound) assert slider.queryValue().currentValue == bound, (name, "boundary", bound)
slider.queryValue().currentValue = finalValue slider.queryValue().currentValue = finalValue
accept_preview(echoPreview)
focus(named("Apply")); key("space") focus(named("Apply")); key("space")
applied = request() applied = request()
assert applied["Save"], applied assert applied["Save"], applied
@@ -494,7 +686,7 @@ def inside():
values = accept_preview() values = accept_preview()
assert values["Pitch"] == 5 and pitchScale.queryValue().currentValue == 8 assert values["Pitch"] == 5 and pitchScale.queryValue().currentValue == 8
focus(named("Cancel")); key("space") focus(named("Cancel")); key("space")
assert process.wait(timeout=2) == 0 and process.stdout.read() == "" assert process.wait(timeout=2) == 0 and responses.read_remaining() == ""
print("PASS: independent voice editors, inheritance, previews, indentation modes, Apply/reload, Cancel") print("PASS: independent voice editors, inheritance, previews, indentation modes, Apply/reload, Cancel")
return return
if os.environ.get("CTHULHU_PREFS_SPEECH_ONLY") == "1": if os.environ.get("CTHULHU_PREFS_SPEECH_ONLY") == "1":
@@ -592,7 +784,7 @@ def inside():
assert process.poll() is None, "dropdown Escape closed the whole preferences window" assert process.poll() is None, "dropdown Escape closed the whole preferences window"
key("Escape") key("Escape")
assert process.wait(timeout=2) == 0, "Escape did not close preferences promptly" assert process.wait(timeout=2) == 0, "Escape did not close preferences promptly"
assert process.stdout.read() == "", "Escape submitted changes" assert responses.read_remaining() == "", "Escape submitted changes"
print("PASS: synthesizer and voice previews, locale filtering, language preserved across synthesizers, Apply/reload, dropdown and window Escape") print("PASS: synthesizer and voice previews, locale filtering, language preserved across synthesizers, Apply/reload, dropdown and window Escape")
return return
if os.environ.get("CTHULHU_PREFS_READING_ONLY") == "1": if os.environ.get("CTHULHU_PREFS_READING_ONLY") == "1":
@@ -672,7 +864,7 @@ def inside():
if readingTable.getAccessibleAt(row, 1).name == "Speak object under mouse"), 0).name == "checked" if readingTable.getAccessibleAt(row, 1).name == "Speak object under mouse"), 0).name == "checked"
focus(named("Cancel")); key("space") focus(named("Cancel")); key("space")
assert process.wait(timeout=5) == 0 assert process.wait(timeout=5) == 0
assert process.stdout.read() == "", "Cancel submitted reading changes" assert responses.read_remaining() == "", "Cancel submitted reading changes"
print("PASS: reading table, Say All, tooltip and mouse choices, Space, arrows, Tab/Shift+Tab, Apply/reload, Cancel") print("PASS: reading table, Say All, tooltip and mouse choices, Space, arrows, Tab/Shift+Tab, Apply/reload, Cancel")
return return
if os.environ.get("CTHULHU_PREFS_MODIFIERS_ONLY") == "1": if os.environ.get("CTHULHU_PREFS_MODIFIERS_ONLY") == "1":
@@ -727,7 +919,7 @@ def inside():
assert_modifiers({"Insert", "Numpad Insert"}) assert_modifiers({"Insert", "Numpad Insert"})
focus(named("Cancel")); key("space") focus(named("Cancel")); key("space")
assert process.wait(timeout=5) == 0 assert process.wait(timeout=5) == 0
assert process.stdout.read() == "", "Cancel submitted modifier changes" assert responses.read_remaining() == "", "Cancel submitted modifier changes"
print("PASS: modifier checkbox table, accessible states, Space, Tab/Shift+Tab, desktop/laptop defaults, multiple/empty selections, Apply/reload, Cancel") print("PASS: modifier checkbox table, accessible states, Space, Tab/Shift+Tab, desktop/laptop defaults, multiple/empty selections, Apply/reload, Cancel")
return return
if os.environ.get("CTHULHU_PREFS_BINDINGS_ONLY") == "1": if os.environ.get("CTHULHU_PREFS_BINDINGS_ONLY") == "1":
@@ -804,7 +996,7 @@ def inside():
set_binding("F9") set_binding("F9")
focus(named("Cancel")); key("space") focus(named("Cancel")); key("space")
assert process.wait(timeout=5) == 0 assert process.wait(timeout=5) == 0
assert process.stdout.read() == "", "Cancel submitted bindings" assert responses.read_remaining() == "", "Cancel submitted bindings"
print("PASS: accessible binding editor, Tab/Shift+Tab, conflict, save/reload, clear, reset, laptop browse, reset all, Cancel") print("PASS: accessible binding editor, Tab/Shift+Tab, conflict, save/reload, clear, reset, laptop browse, reset all, Cancel")
return return
if os.environ.get("CTHULHU_PREFS_BRAILLE_ONLY") == "1": if os.environ.get("CTHULHU_PREFS_BRAILLE_ONLY") == "1":
@@ -1095,7 +1287,7 @@ def inside():
focus(named("Key echo volume")); named("Key echo volume").queryValue().currentValue = 10 focus(named("Key echo volume")); named("Key echo volume").queryValue().currentValue = 10
family = named("Key echo person") family = named("Key echo person")
family.querySelection().selectChild(1) family.querySelection().selectChild(1)
word_voice = named("Use custom voice for word echo") word_voice = named("Words")
assert not word_voice.getState().contains(pyatspi.STATE_CHECKED) assert not word_voice.getState().contains(pyatspi.STATE_CHECKED)
focus(word_voice); key("space") focus(word_voice); key("space")
wait_for(lambda: word_voice.getState().contains(pyatspi.STATE_CHECKED), wait_for(lambda: word_voice.getState().contains(pyatspi.STATE_CHECKED),
@@ -1128,7 +1320,7 @@ def inside():
subprocess.run(["xdotool", "windowfocus", xid], check=True) subprocess.run(["xdotool", "windowfocus", xid], check=True)
key("Escape") key("Escape")
assert process.wait(timeout=2) == 0, "Escape did not close preferences promptly" assert process.wait(timeout=2) == 0, "Escape did not close preferences promptly"
assert process.stdout.read() == "", "Escape submitted extra settings" assert responses.read_remaining() == "", "Escape submitted extra settings"
print("PASS: GTK notebook, roles, label buddies, Tab/Shift+Tab, Apply, failed apply, OK, Escape") print("PASS: GTK notebook, roles, label buddies, Tab/Shift+Tab, Apply, failed apply, OK, Escape")
finally: finally:
if reader_process is not None: if reader_process is not None:
+59
View File
@@ -0,0 +1,59 @@
"""Pipe-level regressions for the native preferences harness response reader."""
import os
import select
import unittest
from unittest.mock import patch
from native_preferences import ResponseReader
class ResponseReaderTests(unittest.TestCase):
def setUp(self):
readFd, writeFd = os.pipe()
self.stream = os.fdopen(readFd, encoding="utf-8")
self.writer = os.fdopen(writeFd, "wb", buffering=0)
self.addCleanup(self.stream.close)
self.addCleanup(self.writer.close)
self.reader = ResponseReader(self.stream)
def test_batched_preview_and_save(self):
self.writer.write(b'{"Preview": true}\n{"Save": true}\n')
self.assertEqual(self.reader.request(), {"Preview": True})
self.assertFalse(select.select([self.stream], [], [], 0)[0])
self.assertEqual(self.reader.request(timeout=0), {"Save": True})
def test_missing_response_times_out(self):
with self.assertRaises(TimeoutError):
self.reader.request(timeout=0.01)
def test_partial_response_times_out_and_can_resume(self):
self.writer.write(b'{"Name": "\xc3')
with self.assertRaises(TimeoutError):
self.reader.request(timeout=0.01)
self.writer.write(b'\xa9"}\n')
self.assertEqual(self.reader.request(), {"Name": "\u00e9"})
def test_partial_reads_share_one_deadline(self):
self.writer.write(b'{"Save":')
with patch("native_preferences.time.monotonic", side_effect=[10, 10, 16]):
with self.assertRaises(TimeoutError):
self.reader.request(timeout=5)
def test_eof_is_reported_after_complete_responses(self):
self.writer.write(b'{"Save": true}\n{"Close":')
self.writer.close()
self.assertEqual(self.reader.request(), {"Save": True})
with self.assertRaises(EOFError):
self.reader.request()
def test_remaining_output_includes_read_ahead_and_pipe_bytes(self):
self.writer.write(b'{"Preview": true}\n{"Name": "\xc3')
self.assertEqual(self.reader.request(), {"Preview": True})
self.writer.write(b'\xa9"}\n')
self.writer.close()
self.assertEqual(self.reader.read_remaining(), '{"Name": "\u00e9"}\n')
self.assertEqual(self.reader.read_remaining(), "")
if __name__ == "__main__":
unittest.main()