Simplified speech settings.
This commit is contained in:
1 parent
03e2b31e09
commit
54722e985e
20 files changed
+403
-92
No files matched your search
@@ -51,6 +51,16 @@ 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 preferences
|
||||
|
||||
Speech preferences let you adjust rate, pitch and volume directly, without
|
||||
first disabling a default checkbox. Unconfigured values display Speech
|
||||
Dispatcher's defaults when available, or rate 50, pitch 5 and volume 5.
|
||||
Adjusting a slider creates an explicit setting; untouched values retain their
|
||||
inheritance, including automatic Uppercase pitch. Apply saves the changes;
|
||||
Cancel restores the last applied voice. See
|
||||
[speech preferences verification](docs/testing/speech-preferences.md).
|
||||
|
||||
## Speech interruption
|
||||
|
||||
Incoming announcements queue in order. Focus, selection, state changes,
|
||||
@@ -141,8 +151,8 @@ reading keep their normal presentation.
|
||||
|
||||
The beep matches NVDA's generated 2000 Hz, 50 ms capital-letter tone. It uses
|
||||
the Sounds volume and audio backend, independently of role sounds and themes.
|
||||
The pitch checkbox enables the existing Uppercase voice pitch; that voice's
|
||||
pitch editor still allows an explicit pitch or automatic adjustment. Apply
|
||||
The pitch checkbox enables the existing Uppercase voice pitch. That voice
|
||||
uses automatic adjustment until an explicit pitch is set with its slider. Apply
|
||||
changes the running reader, and saved choices survive restart and can be set
|
||||
per application. Older capitalization styles are imported into the corresponding
|
||||
beep or “cap” checkbox.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
pkgbase=cthulhu-git
|
||||
pkgname=cthulhu-git
|
||||
_pkgname=cthulhu
|
||||
pkgver=2026.10.23.r72.g736f5a0
|
||||
pkgver=2026.10.23.r73.g03e2b31
|
||||
pkgrel=1
|
||||
pkgdesc="Desktop-agnostic screen reader with speech, Braille, and Tentacle support"
|
||||
url="https://git.stormux.org/storm/cthulhu"
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Direct speech preferences controls
|
||||
|
||||
Rate, pitch and volume are always editable. There are no separate server-default
|
||||
or inherited-voice checkboxes and no restore button. An untouched field preserves
|
||||
its existing explicit value or inheritance. Changing a field makes it explicit,
|
||||
even if it is subsequently moved back to its initial value. Uppercase retains
|
||||
automatic pitch until its pitch slider is changed.
|
||||
|
||||
The preferences worker queries Speech Dispatcher over a separate connection,
|
||||
with a 500 ms overall deadline. Unsupported, invalid or unavailable values use
|
||||
display defaults of rate 50, pitch 5 and volume 5. Server values are converted
|
||||
to the reader's existing scales, rounded to one decimal place and constrained
|
||||
by the sliders' existing ranges. Hardware speech uses the display defaults.
|
||||
Merely opening preferences or applying unrelated changes does not save these
|
||||
display values as speech overrides.
|
||||
|
||||
Build an isolated binary and run the focused checks:
|
||||
|
||||
```sh
|
||||
go build -o /tmp/cthulhu-speech-preferences ./cmd/cthulhu
|
||||
go test ./internal/preferences ./internal/speech ./internal/config ./internal/app -count=1
|
||||
CTHULHU_BINARY=/tmp/cthulhu-speech-preferences CTHULHU_PREFS_NO_CONFIG_ONLY=1 /usr/bin/python3 tests/native_preferences.py
|
||||
CTHULHU_BINARY=/tmp/cthulhu-speech-preferences CTHULHU_PREFS_NO_CONFIG_ONLY=1 CTHULHU_PREFS_DEFAULTS_UNSUPPORTED=1 /usr/bin/python3 tests/native_preferences.py
|
||||
CTHULHU_BINARY=/tmp/cthulhu-speech-preferences CTHULHU_PREFS_VOICE_TYPES_ONLY=1 /usr/bin/python3 tests/native_preferences.py
|
||||
CTHULHU_BINARY=/tmp/cthulhu-speech-preferences CTHULHU_PREFS_NUMBERS_ONLY=1 /usr/bin/python3 tests/native_preferences.py
|
||||
CTHULHU_BINARY=/tmp/cthulhu-speech-preferences /usr/bin/python3 tests/native_preferences.py
|
||||
```
|
||||
|
||||
The missing-file checks use the real reader, GTK preferences and a private SSIP
|
||||
server, isolated under Xvfb and D-Bus. They verify initial values with no settings
|
||||
file, direct keyboard edits, Tab and Shift+Tab, untouched inheritance, automatic
|
||||
Uppercase pitch, preview settings reaching SSIP, Apply creating the file,
|
||||
saved values after reopening, and Cancel discarding unsaved changes. Test logs,
|
||||
configuration and speech recordings remain in the reported temporary directory.
|
||||
|
||||
Human acceptance remains separate: with the rebuilt reader, use a temporary
|
||||
configuration and physically adjust all three sliders, listen to their previews,
|
||||
Apply and reopen, then change a value and Cancel. Check the Default, Uppercase,
|
||||
Hyperlink and System voices independently; confirm capital pitch still works,
|
||||
ordinary speech returns to its normal pitch, and Tab/Shift+Tab retain sensible
|
||||
labels and values. Check Braille if used. These checks also cover existing
|
||||
profiles with explicit values; the automated harness does not establish audible
|
||||
speech, physical input or physical Braille acceptance.
|
||||
@@ -78,11 +78,12 @@ type preferencesWorker struct {
|
||||
announceEchoPreview func(string, string)
|
||||
catalog speech.Catalog
|
||||
discover func(context.Context, string) (speech.Catalog, error)
|
||||
readSpeechDefaults func(context.Context, string) (speech.Settings, error)
|
||||
}
|
||||
|
||||
func newPreferencesWorker(logger *slog.Logger, settings config.Settings, updater any) *preferencesWorker {
|
||||
return &preferencesWorker{logger: logger, settings: settings, globalSettings: settings, appSettings: make(map[string]config.Settings), updater: updater,
|
||||
extensionRequests: make(chan readerSettingsCall, 16), extensions: make(map[string]*readerExtension), speechCommands: make(chan input.Command, 16), remoteChanges: make(chan remoteSettingsChange, 16), discover: speech.Discover, open: make(chan string, 1), applicationChanges: make(chan string, 1), done: make(chan struct{})}
|
||||
extensionRequests: make(chan readerSettingsCall, 16), extensions: make(map[string]*readerExtension), speechCommands: make(chan input.Command, 16), remoteChanges: make(chan remoteSettingsChange, 16), discover: speech.Discover, readSpeechDefaults: speech.ReadDefaults, open: make(chan string, 1), applicationChanges: make(chan string, 1), done: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (worker *preferencesWorker) Start(ctx context.Context) error {
|
||||
|
||||
@@ -132,6 +132,14 @@ func (worker *preferencesWorker) initial_response(childCtx context.Context) (pre
|
||||
if err != nil {
|
||||
worker.logger.Debug("discover echo voices", "error", err)
|
||||
}
|
||||
if worker.editing.SpeechServerFactory != "hardwarefactory" {
|
||||
defaultsCtx, cancelDefaults := context.WithTimeout(childCtx, 500*time.Millisecond)
|
||||
initial.SpeechDefaults, err = worker.readSpeechDefaults(defaultsCtx, worker.socket)
|
||||
cancelDefaults()
|
||||
if err != nil {
|
||||
worker.logger.Debug("read speech defaults", "error", err)
|
||||
}
|
||||
}
|
||||
if worker.editApp == "" {
|
||||
if err := worker.load_extension_pages(childCtx); err != nil {
|
||||
worker.logger.Warn("load tentacle preference pages", "error", err)
|
||||
|
||||
@@ -32,6 +32,7 @@ func TestPreferencesInitialRetainsLargeVoiceCatalog(t *testing.T) {
|
||||
}
|
||||
worker := newPreferencesWorker(slog.New(slog.DiscardHandler), config.DefaultSettings(), nil)
|
||||
worker.discover = func(context.Context, string) (speech.Catalog, error) { return catalog, nil }
|
||||
worker.readSpeechDefaults = func(context.Context, string) (speech.Settings, error) { return speech.Settings{}, nil }
|
||||
response, err := worker.initial_response(t.Context())
|
||||
if err != nil || !reflect.DeepEqual(response.Catalog, catalog) {
|
||||
t.Fatalf("initial response lost discovered voices: err=%v", err)
|
||||
@@ -41,6 +42,7 @@ func TestPreferencesInitialRetainsLargeVoiceCatalog(t *testing.T) {
|
||||
func TestPreferencesWindowReportsHelperExit(t *testing.T) {
|
||||
worker := newPreferencesWorker(slog.New(slog.DiscardHandler), config.DefaultSettings(), nil)
|
||||
worker.discover = func(context.Context, string) (speech.Catalog, error) { return nil, nil }
|
||||
worker.readSpeechDefaults = func(context.Context, string) (speech.Settings, error) { return speech.Settings{}, nil }
|
||||
// The test executable does not implement --preferences-ui. It exits before
|
||||
// opening a dialog, as a broken helper would on the affected machine.
|
||||
err := worker.window(t.Context())
|
||||
|
||||
@@ -73,13 +73,14 @@ static gboolean key(GtkWidget *window, GdkEventKey *event, gpointer data) {
|
||||
if (event->keyval == GDK_KEY_Escape) { action = 3; return TRUE; }
|
||||
return FALSE;
|
||||
}
|
||||
static void defaults(GtkToggleButton *button, gpointer spin) {
|
||||
gtk_widget_set_sensitive(GTK_WIDGET(spin), !gtk_toggle_button_get_active(button));
|
||||
}
|
||||
static void voice_value_changed(GtkWidget *control, gpointer data) {
|
||||
(void)control; (void)data;
|
||||
voice_changed = 1;
|
||||
}
|
||||
static void speech_number_changed(GtkWidget *control, gpointer data) {
|
||||
g_object_set_data(G_OBJECT(control), "number-edited", GINT_TO_POINTER(1));
|
||||
voice_value_changed(control, data);
|
||||
}
|
||||
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));
|
||||
@@ -176,11 +177,7 @@ int prefs_init(const char *xml) {
|
||||
g_signal_connect(prefs_widget("window"), "key-press-event", G_CALLBACK(key), NULL);
|
||||
const char *ids[] = {"rate", "pitch", "volume"};
|
||||
for (int i = 0; i < 3; i++) {
|
||||
gchar *id = g_strconcat(ids[i], "_default", NULL);
|
||||
g_signal_connect(prefs_widget(id), "toggled", G_CALLBACK(defaults), prefs_widget(ids[i]));
|
||||
g_signal_connect(prefs_widget(id), "toggled", G_CALLBACK(voice_value_changed), NULL);
|
||||
g_signal_connect(prefs_widget(ids[i]), "value-changed", G_CALLBACK(voice_value_changed), NULL);
|
||||
g_free(id);
|
||||
g_signal_connect(prefs_widget(ids[i]), "value-changed", G_CALLBACK(speech_number_changed), NULL);
|
||||
}
|
||||
const char *voiceScales[] = {"rate", "pitch", "volume", "echoRateScale", "echoPitchScale", "echoVolumeScale"};
|
||||
for (unsigned i = 0; i < G_N_ELEMENTS(voiceScales); i++) {
|
||||
|
||||
@@ -12,10 +12,9 @@ void prefs_busy(int busy, int can_save);
|
||||
void prefs_present(void);
|
||||
void prefs_close(void);
|
||||
void prefs_status(const char *message, int focus);
|
||||
void prefs_number_set(const char *id, double value, int use_default);
|
||||
void prefs_voice_defaults(int kind);
|
||||
void prefs_number_set(const char *id, double value);
|
||||
double prefs_number_value(const char *id);
|
||||
int prefs_default(const char *id);
|
||||
int prefs_number_edited(const char *id);
|
||||
void prefs_choice_set(const char *id, int value);
|
||||
int prefs_choice_get(const char *id);
|
||||
void prefs_hardware_devices_clear(void);
|
||||
|
||||
@@ -3,30 +3,15 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#include "native_internal.h"
|
||||
|
||||
void prefs_voice_defaults(int kind) {
|
||||
const char *ids[] = {"rate_default", "pitch_default", "volume_default"};
|
||||
const char *server[] = {"Use server default rate", "Use server default pitch", "Use server default volume"};
|
||||
const char *inherited[] = {"Use Default voice rate", "Use Default voice pitch", "Use Default voice volume"};
|
||||
for (unsigned i = 0; i < G_N_ELEMENTS(ids); i++) {
|
||||
const char *label = kind == 0 ? server[i] : inherited[i];
|
||||
if (kind == 1 && i == 1) label = "Use automatic uppercase pitch";
|
||||
GtkButton *button = GTK_BUTTON(prefs_widget(ids[i]));
|
||||
if (g_strcmp0(gtk_button_get_label(button), label) != 0) gtk_button_set_label(button, label);
|
||||
}
|
||||
}
|
||||
|
||||
void prefs_number_set(const char *id, double value, int use_default) {
|
||||
gchar *check = g_strconcat(id, "_default", NULL);
|
||||
gtk_range_set_value(GTK_RANGE(prefs_widget(id)), value);
|
||||
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(prefs_widget(check)), use_default);
|
||||
gtk_widget_set_sensitive(prefs_widget(id), !use_default);
|
||||
g_free(check);
|
||||
void prefs_number_set(const char *id, double value) {
|
||||
GtkWidget *control = prefs_widget(id);
|
||||
gtk_range_set_value(GTK_RANGE(control), value);
|
||||
// Loading an effective value must not turn inheritance into an override.
|
||||
g_object_set_data(G_OBJECT(control), "number-edited", NULL);
|
||||
}
|
||||
double prefs_number_value(const char *id) { return gtk_range_get_value(GTK_RANGE(prefs_widget(id))); }
|
||||
int prefs_default(const char *id) {
|
||||
gchar *check = g_strconcat(id, "_default", NULL);
|
||||
int result = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(prefs_widget(check)));
|
||||
g_free(check); return result;
|
||||
int prefs_number_edited(const char *id) {
|
||||
return GPOINTER_TO_INT(g_object_get_data(G_OBJECT(prefs_widget(id)), "number-edited"));
|
||||
}
|
||||
void prefs_choice_set(const char *id, int value) {
|
||||
gchar *name = g_strdup_printf("%s_%d", id, value);
|
||||
|
||||
@@ -114,24 +114,21 @@
|
||||
<child><object class="GtkComboBoxText" id="speechFamilies"><property name="can-focus">False</property><items><item>Default</item></items></object><packing><property name="left-attach">1</property><property name="top-attach">3</property></packing></child>
|
||||
<child><object class="GtkComboBoxText" id="voiceTypesCombo"><property name="can-focus">False</property><items><item>Default</item><item>Uppercase</item><item>Hyperlink</item><item>System</item></items></object><packing><property name="left-attach">1</property><property name="top-attach">4</property></packing></child>
|
||||
<child><object class="GtkLabel"><property name="label">_Voice type:</property><property name="use-underline">True</property><property name="mnemonic-widget">voiceTypesCombo</property><property name="xalign">1</property></object><packing><property name="left-attach">0</property><property name="top-attach">4</property></packing></child>
|
||||
<child><object class="GtkCheckButton" id="volume_default"><property name="label">Use server default volume</property><property name="can-focus">True</property></object><packing><property name="left-attach">0</property><property name="top-attach">5</property><property name="width">2</property></packing></child>
|
||||
<child><object class="GtkLabel" id="volume_label"><property name="label">Vo_lume:</property><property name="use-underline">True</property><property name="mnemonic-widget">volume</property><property name="xalign">1</property></object><packing><property name="left-attach">0</property><property name="top-attach">6</property></packing></child>
|
||||
<child><object class="GtkScale" id="volume"><property name="adjustment">volume_adjustment</property><property name="can-focus">True</property><property name="digits">1</property><property name="hexpand">True</property><property name="value-pos">right</property></object><packing><property name="left-attach">1</property><property name="top-attach">6</property></packing></child>
|
||||
<child><object class="GtkCheckButton" id="pitch_default"><property name="label">Use server default pitch</property><property name="can-focus">True</property></object><packing><property name="left-attach">0</property><property name="top-attach">7</property><property name="width">2</property></packing></child>
|
||||
<child><object class="GtkLabel" id="pitch_label"><property name="label">Pi_tch:</property><property name="use-underline">True</property><property name="mnemonic-widget">pitch</property><property name="xalign">1</property></object><packing><property name="left-attach">0</property><property name="top-attach">8</property></packing></child>
|
||||
<child><object class="GtkScale" id="pitch"><property name="adjustment">pitch_adjustment</property><property name="can-focus">True</property><property name="digits">1</property><property name="hexpand">True</property><property name="value-pos">right</property></object><packing><property name="left-attach">1</property><property name="top-attach">8</property></packing></child>
|
||||
<child><object class="GtkCheckButton" id="rate_default"><property name="label">Use server default rate</property><property name="can-focus">True</property></object><packing><property name="left-attach">0</property><property name="top-attach">9</property><property name="width">2</property></packing></child>
|
||||
<child><object class="GtkLabel" id="rate_label"><property name="label">_Rate:</property><property name="use-underline">True</property><property name="mnemonic-widget">rate</property><property name="xalign">1</property></object><packing><property name="left-attach">0</property><property name="top-attach">10</property></packing></child>
|
||||
<child><object class="GtkScale" id="rate"><property name="adjustment">rate_adjustment</property><property name="can-focus">True</property><property name="digits">1</property><property name="hexpand">True</property><property name="value-pos">right</property></object><packing><property name="left-attach">1</property><property name="top-attach">10</property></packing></child>
|
||||
<child><object class="GtkLabel" id="volume_label"><property name="label">Vo_lume:</property><property name="use-underline">True</property><property name="mnemonic-widget">volume</property><property name="xalign">1</property></object><packing><property name="left-attach">0</property><property name="top-attach">5</property></packing></child>
|
||||
<child><object class="GtkScale" id="volume"><property name="adjustment">volume_adjustment</property><property name="can-focus">True</property><property name="digits">1</property><property name="hexpand">True</property><property name="value-pos">right</property></object><packing><property name="left-attach">1</property><property name="top-attach">5</property></packing></child>
|
||||
<child><object class="GtkLabel" id="pitch_label"><property name="label">Pi_tch:</property><property name="use-underline">True</property><property name="mnemonic-widget">pitch</property><property name="xalign">1</property></object><packing><property name="left-attach">0</property><property name="top-attach">6</property></packing></child>
|
||||
<child><object class="GtkScale" id="pitch"><property name="adjustment">pitch_adjustment</property><property name="can-focus">True</property><property name="digits">1</property><property name="hexpand">True</property><property name="value-pos">right</property></object><packing><property name="left-attach">1</property><property name="top-attach">6</property></packing></child>
|
||||
<child><object class="GtkLabel" id="rate_label"><property name="label">_Rate:</property><property name="use-underline">True</property><property name="mnemonic-widget">rate</property><property name="xalign">1</property></object><packing><property name="left-attach">0</property><property name="top-attach">7</property></packing></child>
|
||||
<child><object class="GtkScale" id="rate"><property name="adjustment">rate_adjustment</property><property name="can-focus">True</property><property name="digits">1</property><property name="hexpand">True</property><property name="value-pos">right</property></object><packing><property name="left-attach">1</property><property name="top-attach">7</property></packing></child>
|
||||
<child><object class="GtkFrame" id="capitalLettersFrame"><property name="label">Capital letters</property><child><object class="GtkBox"><property name="orientation">vertical</property><property name="spacing">6</property><property name="border-width">8</property>
|
||||
<child><object class="GtkCheckButton" id="raisePitchForCapitals"><property name="label">Raise pitch for capital letters</property><property name="can-focus">True</property></object></child>
|
||||
<child><object class="GtkCheckButton" id="beepForCapitals"><property name="label">Beep for capital letters</property><property name="can-focus">True</property></object></child>
|
||||
<child><object class="GtkCheckButton" id="sayCapForCapitals"><property name="label">Say “cap” before capital letters</property><property name="can-focus">True</property></object></child>
|
||||
</object></child></object><packing><property name="left-attach">0</property><property name="top-attach">11</property><property name="width">2</property></packing></child>
|
||||
<child><object class="GtkEntry" id="hardwareDeviceEntry"><property name="can-focus">True</property><property name="tooltip-text">Serial device path; completion offers detected devices</property></object><packing><property name="left-attach">1</property><property name="top-attach">15</property></packing></child>
|
||||
<child><object class="GtkLabel"><property name="label">Serial _device:</property><property name="use-underline">True</property><property name="mnemonic-widget">hardwareDeviceEntry</property><property name="xalign">1</property></object><packing><property name="left-attach">0</property><property name="top-attach">15</property></packing></child>
|
||||
<child><object class="GtkLabel"><property name="label">Serial baud rate:</property><property name="mnemonic-widget">hardwareBaudCombo</property><property name="xalign">1</property></object><packing><property name="left-attach">0</property><property name="top-attach">17</property></packing></child>
|
||||
<child><object class="GtkComboBoxText" id="hardwareBaudCombo"><property name="can-focus">False</property><items><item>1200</item><item>2400</item><item>4800</item><item>9600</item><item>19200</item><item>38400</item><item>57600</item><item>115200</item></items></object><packing><property name="left-attach">1</property><property name="top-attach">17</property></packing></child>
|
||||
</object></child></object><packing><property name="left-attach">0</property><property name="top-attach">8</property><property name="width">2</property></packing></child>
|
||||
<child><object class="GtkEntry" id="hardwareDeviceEntry"><property name="can-focus">True</property><property name="tooltip-text">Serial device path; completion offers detected devices</property></object><packing><property name="left-attach">1</property><property name="top-attach">12</property></packing></child>
|
||||
<child><object class="GtkLabel"><property name="label">Serial _device:</property><property name="use-underline">True</property><property name="mnemonic-widget">hardwareDeviceEntry</property><property name="xalign">1</property></object><packing><property name="left-attach">0</property><property name="top-attach">12</property></packing></child>
|
||||
<child><object class="GtkLabel"><property name="label">Serial baud rate:</property><property name="mnemonic-widget">hardwareBaudCombo</property><property name="xalign">1</property></object><packing><property name="left-attach">0</property><property name="top-attach">14</property></packing></child>
|
||||
<child><object class="GtkComboBoxText" id="hardwareBaudCombo"><property name="can-focus">False</property><items><item>1200</item><item>2400</item><item>4800</item><item>9600</item><item>19200</item><item>38400</item><item>57600</item><item>115200</item></items></object><packing><property name="left-attach">1</property><property name="top-attach">14</property></packing></child>
|
||||
<child>
|
||||
<object class="GtkBox" id="speech_settings">
|
||||
<property name="orientation">vertical</property>
|
||||
@@ -169,7 +166,7 @@
|
||||
<child><object class="GtkRadioButton" id="indentationSpeechAndBeepsButton"><property name="label">Speech and beeps</property><property name="can-focus">True</property><property name="group">indentationSpeechButton</property></object></child>
|
||||
</object></child></object></child>
|
||||
</object>
|
||||
<packing><property name="left-attach">0</property><property name="top-attach">18</property><property name="width">2</property></packing>
|
||||
<packing><property name="left-attach">0</property><property name="top-attach">15</property><property name="width">2</property></packing>
|
||||
</child>
|
||||
</object></child>
|
||||
</object>
|
||||
|
||||
@@ -124,6 +124,7 @@ type Response struct {
|
||||
Tentacles *tentacle.Snapshot `json:",omitempty"`
|
||||
ChooseApplication bool
|
||||
Catalog speech.Catalog
|
||||
SpeechDefaults speech.Settings
|
||||
CatalogPending bool // More catalog parts follow before the UI can initialize.
|
||||
Application string
|
||||
Present bool
|
||||
|
||||
@@ -17,6 +17,7 @@ type uiSession struct {
|
||||
extensionPages []ExtensionPage
|
||||
progressBar config.ProgressBarSettings
|
||||
catalog speech.Catalog
|
||||
speechDefaults speech.Settings
|
||||
contractionTables []braille.TableOption
|
||||
soundThemes []string
|
||||
echoModules []string
|
||||
|
||||
@@ -13,6 +13,7 @@ import "C"
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/config"
|
||||
"git.stormux.org/storm/cthulhu/internal/input"
|
||||
"git.stormux.org/storm/cthulhu/internal/speech"
|
||||
)
|
||||
|
||||
func (session *uiSession) setSpeechVoice(values Values) {
|
||||
@@ -218,8 +220,8 @@ func (session *uiSession) refreshSpeechVoices() {
|
||||
C.prefs_hardware_sensitivity(C.int(system))
|
||||
if system == 0 {
|
||||
session.reconcile_voice_families(session.selectedSpeechModule())
|
||||
session.load_voice_editor()
|
||||
}
|
||||
session.load_voice_editor()
|
||||
}
|
||||
if system == 1 {
|
||||
return
|
||||
@@ -292,24 +294,32 @@ func set_speech_support(values Values) {
|
||||
setCheck("indentationSpeechAndBeepsButton", mode == "both")
|
||||
}
|
||||
|
||||
func set_speech_numbers(values Values, inherited config.Voice) {
|
||||
func set_speech_numbers(values Values, inherited config.Voice, defaults speech.Settings) {
|
||||
for _, item := range []struct {
|
||||
id string
|
||||
value *float64
|
||||
inherited *float64
|
||||
server *int
|
||||
scale float64
|
||||
offset float64
|
||||
fallback float64
|
||||
}{
|
||||
{"rate", values.Rate, inherited.Rate, 50}, {"pitch", values.Pitch, inherited.AveragePitch, 5}, {"volume", values.Volume, inherited.Gain, 5},
|
||||
{"rate", values.Rate, inherited.Rate, defaults.Rate, 2, 98, 50},
|
||||
{"pitch", values.Pitch, inherited.AveragePitch, defaults.Pitch, 20, 90, 5},
|
||||
{"volume", values.Volume, inherited.Gain, defaults.Volume, 15, 35, 5},
|
||||
} {
|
||||
id := C.CString(item.id)
|
||||
value := item.fallback
|
||||
if item.server != nil {
|
||||
value = math.Round((float64(*item.server)+item.offset)/item.scale*10) / 10
|
||||
}
|
||||
if item.inherited != nil {
|
||||
value = *item.inherited
|
||||
}
|
||||
if item.value != nil {
|
||||
value = *item.value
|
||||
}
|
||||
C.prefs_number_set(id, C.double(value), boolean(item.value == nil))
|
||||
C.prefs_number_set(id, C.double(value))
|
||||
C.free(unsafe.Pointer(id))
|
||||
}
|
||||
}
|
||||
@@ -388,15 +398,18 @@ func (session *uiSession) load_voice_editor() {
|
||||
voice := session.voiceDrafts[session.activeVoiceType]
|
||||
settings := config.Settings{RaisePitchForCapitals: true, DefaultVoice: session.voiceDrafts[0], UppercaseVoice: session.voiceDrafts[1], HyperlinkVoice: session.voiceDrafts[2], SystemVoice: session.voiceDrafts[3]}
|
||||
kind := []string{"default", "uppercase", "hyperlink", "system"}[session.activeVoiceType]
|
||||
set_speech_numbers(Values{Rate: voice.Rate, Pitch: voice.AveragePitch, Volume: voice.Gain}, config.EffectiveVoice(settings, kind))
|
||||
C.prefs_voice_defaults(C.int(session.activeVoiceType))
|
||||
defaults := session.speechDefaults
|
||||
if session.activeSpeechSystem == 1 {
|
||||
defaults = speech.Settings{}
|
||||
}
|
||||
set_speech_numbers(Values{Rate: voice.Rate, Pitch: voice.AveragePitch, Volume: voice.Gain}, config.EffectiveVoice(settings, kind), defaults)
|
||||
if session.activeSpeechSystem != 1 {
|
||||
session.populateSpeechLanguages(session.selectedSpeechModule(), "", voice.Family)
|
||||
}
|
||||
}
|
||||
|
||||
func (session *uiSession) read_voice_editor() (config.Voice, error) {
|
||||
var voice config.Voice
|
||||
voice := session.voiceDrafts[session.activeVoiceType]
|
||||
for _, item := range []struct {
|
||||
id string
|
||||
target **float64
|
||||
@@ -405,10 +418,10 @@ func (session *uiSession) read_voice_editor() (config.Voice, error) {
|
||||
{"rate", &voice.Rate, 99}, {"pitch", &voice.AveragePitch, 9}, {"volume", &voice.Gain, 9},
|
||||
} {
|
||||
id := C.CString(item.id)
|
||||
useDefault := C.prefs_default(id) != 0
|
||||
edited := C.prefs_number_edited(id) != 0
|
||||
value := float64(C.prefs_number_value(id))
|
||||
C.free(unsafe.Pointer(id))
|
||||
if useDefault {
|
||||
if !edited {
|
||||
continue
|
||||
}
|
||||
if !(value >= 0 && value <= item.max) {
|
||||
|
||||
@@ -64,7 +64,7 @@ func Run(ctx context.Context, input io.Reader, output io.Writer) error {
|
||||
C.prefs_set_application(name)
|
||||
C.free(unsafe.Pointer(name))
|
||||
}
|
||||
session := &uiSession{catalog: initial.Catalog, soundThemes: []string{"default", "none"}}
|
||||
session := &uiSession{catalog: initial.Catalog, speechDefaults: initial.SpeechDefaults, soundThemes: []string{"default", "none"}}
|
||||
session.setValues(initial.Values)
|
||||
if err := session.set_extension_pages(initial.ExtensionPages); err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package speech
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// ReadDefaults queries a fresh client, leaving the speaking client's settings
|
||||
// alone. Unsupported or invalid fields stay nil so preferences can use sensible
|
||||
// display values. The caller bounds the entire optional query with a deadline.
|
||||
func ReadDefaults(ctx context.Context, socket string) (Settings, error) {
|
||||
var defaults Settings
|
||||
endpoint, err := resolveServerAddress(socket)
|
||||
if err != nil {
|
||||
return defaults, err
|
||||
}
|
||||
conn, err := (&net.Dialer{Timeout: connectTimeout}).DialContext(ctx, endpoint.network, endpoint.address)
|
||||
if err != nil {
|
||||
return defaults, err
|
||||
}
|
||||
defer conn.Close()
|
||||
finish, err := prepareIO(ctx, conn, ioTimeout)
|
||||
if err != nil {
|
||||
return defaults, err
|
||||
}
|
||||
defer finish()
|
||||
client := &Client{conn: conn, reader: bufio.NewReader(conn)}
|
||||
for _, field := range []struct {
|
||||
name string
|
||||
target **int
|
||||
}{
|
||||
{"RATE", &defaults.Rate}, {"PITCH", &defaults.Pitch}, {"VOLUME", &defaults.Volume},
|
||||
} {
|
||||
if _, err := fmt.Fprintf(conn, "GET %s\r\n", field.name); err != nil {
|
||||
return defaults, err
|
||||
}
|
||||
response, err := client.readReply()
|
||||
if err != nil {
|
||||
return defaults, err
|
||||
}
|
||||
if response.code < 200 || response.code >= 300 || len(response.data) != 1 {
|
||||
continue
|
||||
}
|
||||
value, err := strconv.Atoi(response.data[0])
|
||||
if err == nil && value >= -100 && value <= 100 {
|
||||
*field.target = &value
|
||||
}
|
||||
}
|
||||
return defaults, nil
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package speech
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestReadDefaults(t *testing.T) {
|
||||
path, server := newSettingsServer(t, func(command string) string {
|
||||
switch command {
|
||||
case "GET RATE":
|
||||
return "200-20\r\n200 OK\r\n"
|
||||
case "GET PITCH":
|
||||
return "200--10\r\n200 OK\r\n"
|
||||
case "GET VOLUME":
|
||||
return "200-100\r\n200 OK\r\n"
|
||||
}
|
||||
return ""
|
||||
})
|
||||
defaults, err := ReadDefaults(t.Context(), path)
|
||||
if err != nil || defaults.Rate == nil || *defaults.Rate != 20 ||
|
||||
defaults.Pitch == nil || *defaults.Pitch != -10 || defaults.Volume == nil || *defaults.Volume != 100 {
|
||||
t.Fatalf("defaults=%+v, err=%v", defaults, err)
|
||||
}
|
||||
for _, command := range []string{"SET SELF RATE 20", "SET SELF PITCH -10", "SET SELF VOLUME 100", "SPEAK"} {
|
||||
if server.saw(1, command) {
|
||||
t.Fatalf("query changed speech: %s", command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadDefaultsRejectsUnsupportedAndInvalidValues(t *testing.T) {
|
||||
for _, response := range []string{"500 UNSUPPORTED\r\n", "200 OK\r\n", "200-invalid\r\n200 OK\r\n",
|
||||
"200-101\r\n200 OK\r\n", "200-0\r\n200-1\r\n200 OK\r\n"} {
|
||||
t.Run(response, func(t *testing.T) {
|
||||
path, _ := newSettingsServer(t, func(string) string { return response })
|
||||
defaults, err := ReadDefaults(t.Context(), path)
|
||||
if err != nil || defaults.Rate != nil || defaults.Pitch != nil || defaults.Volume != nil {
|
||||
t.Fatalf("invalid defaults=%+v, err=%v", defaults, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadDefaultsRetainsPartialValuesAndHonorsDeadline(t *testing.T) {
|
||||
path, _ := newSettingsServer(t, func(command string) string {
|
||||
if command == "GET RATE" {
|
||||
return "200-0\r\n200 OK\r\n"
|
||||
}
|
||||
if command == "GET PITCH" {
|
||||
return "200-unfinished\r\n"
|
||||
}
|
||||
return ""
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
defaults, err := ReadDefaults(ctx, path)
|
||||
if err == nil || defaults.Rate == nil || *defaults.Rate != 0 || defaults.Pitch != nil || defaults.Volume != nil {
|
||||
t.Fatalf("partial defaults=%+v, err=%v", defaults, err)
|
||||
}
|
||||
}
|
||||
Vendored
+1
-1
@@ -39,7 +39,7 @@ self-voice-dbus-braille,src/cthulhu/dbus_service.py,remote,Display an external p
|
||||
self-voice-dbus-cancel,src/cthulhu/dbus_service.py,remote,Cancel current speech at an external application's request over the session D-Bus service,Storm Dragon,internal/remote internal/app,in-progress,go test ./internal/remote ./internal/app -count=1; CTHULHU_NATIVE_DBUS_TEST=1 go test ./internal/remote -run '^TestNativeSessionBusSelfVoice$' -count=1,Private session-bus proof passes same-user dispatch introspection conflict shutdown release and reacquisition.,,Maintainer confirms native D-Bus integration works in live testing with Toby Doom on 2026-10-22. Individual API calls and output modalities were not itemized. See docs/testing/release-acceptance.md.,,CancelSpeech uses the asynchronous owned speech-worker interruption path and returns false when speech is unavailable. Like Python it does not cancel sound output. Maintainer confirms native D-Bus integration works in live testing with Toby Doom on 2026-10-22. Individual API calls and output modalities were not itemized. See docs/testing/release-acceptance.md.
|
||||
nvda-controller-ssml-rejection,src/cthulhu/dbus_service.py,remote,Reject invalid or unsupported NVDA Controller 2 SSML without claiming mark or completion support,Storm Dragon,internal/remote,implemented,go test ./internal/remote -count=1; CTHULHU_NATIVE_DBUS_TEST=1 go test ./internal/remote -run '^TestNativeSessionBusSelfVoice$' -count=1,Private session-bus proof passes the real SpeakSsml signature and unsupported result.,,,,Malformed XML or a non-speak root returns Windows ERROR_INVALID_PARAMETER 87. Valid speak-root SSML returns ERROR_NOT_SUPPORTED 50 matching Python because mark callbacks and completion semantics are unavailable.
|
||||
flat-review-keys,src/cthulhu/flat_review_presenter.py,review,Navigate and present content with desktop and laptop review keys,Storm Dragon,internal/input internal/review internal/app internal/platform/xorg,in-progress,DISPLAY=:0 go test -race ./internal/atspi ./internal/review ./internal/app ./internal/input ./internal/platform/xorg ./internal/presentation -count=1; DISPLAY=:0 python3 tests/native_atspi.py; DISPLAY=:0 python3 tests/native_x11.py,Private Xvfb tests pass all nine desktop and laptop sequential review commands; both laptop release orders; NumLock numeric keypad typing; laptop keypad pass-through; existing command and shutdown checks.,,Maintainer reports current flat review working well after the previous day's fixes on 2026-10-22. This is general live-use acceptance rather than confirmation of every parity feature or historical fixture. See docs/testing/release-acceptance.md.,Maintainer reports all Braille behavior testable on Orbit passes on 2026-10-22. The display has no routing keys; physical cursor routing is untested. See docs/testing/release-acceptance.md.,Basic previous/current/next line word and code-point character commands are wired to owned window layout and the existing review worker. Sequential line boundaries announce top or bottom of screen. Capture limits are reported explicitly. Native GTK text navigation and edit recovery pass. Mode toggling same-object caret following after leaving review state/value zones advanced bindings mouse review and full application parity remain pending. See docs/testing/flat-review-robustness.md for the current capture comparison and clipped-text regression. Parity review corrections and remaining physical gates: docs/testing/parity-review-follow-up.md. Numpad 2 supports phonetic double press; Numpad 5 and 8 support literal and phonetic spelling on second and third presses within 500 ms. Shared desktop/laptop routing plus private X11 consumption and OCR cursor tests pass; live spelling listening remains pending. See docs/testing/phonetic-review.md.
|
||||
preferences-basic-speech,src/cthulhu/cthulhu_gui_prefs.py,settings,Open accessible basic speech preferences and apply or save settings,Storm Dragon,internal/preferences internal/app internal/config,implemented,go test -race ./... -count=1; python3 tests/native_preferences.py; python3 tests/native_x11.py,Private Xvfb GTK and command checks pass; maintainer accepted save shutdown restart and restored settings on physical Xorg on 2026-09-13; broader desktop traversal pending,,Maintainer confirms speech preferences; previews; Apply and Cancel; saved settings; and interruption-key settings work in live testing on 2026-10-22. See docs/testing/release-acceptance.md.,,Apply and OK update the active schema-v2 profile while preserving other profiles and application mappings; Apply stays open and OK closes. Default-voice rate pitch volume and server-default toggles preview immediately without committing settings; Cancel restores the last applied voice configuration. Independent capital pitch beep and cap checkboxes replace the capitalization style dropdown; default pitch is enabled and all combinations apply to custom echo and spelling. Private GTK traversal and Apply/reload plus SSIP and persistence checks pass; general speech preferences listening is accepted by the maintainer on 2026-10-22; control-specific Braille acceptance was not separately stated. Explicitly selected legacy files remain session-only and cannot be overwritten. Advanced preferences and importer remain pending. Phonetic character hints is an unchecked Speech Reading option; plain Left/Right character navigation can append a cancellable 300 ms phonetic hint. Private GTK default toggle Apply/reload and cancellation checks pass; physical listening remains pending. See docs/testing/phonetic-review.md.
|
||||
preferences-basic-speech,src/cthulhu/cthulhu_gui_prefs.py,settings,Open accessible basic speech preferences and apply or save settings,Storm Dragon,internal/preferences internal/app internal/config,implemented,go test -race ./... -count=1; python3 tests/native_preferences.py; python3 tests/native_x11.py,Private Xvfb GTK and command checks pass; maintainer accepted save shutdown restart and restored settings on physical Xorg on 2026-09-13; broader desktop traversal pending,,Maintainer confirms speech preferences; previews; Apply and Cancel; saved settings; and interruption-key settings work in live testing on 2026-10-22. See docs/testing/release-acceptance.md.,,Apply and OK update the active schema-v2 profile while preserving other profiles and application mappings; Apply stays open and OK closes. Rate pitch and volume sliders are directly editable for every voice type and preview immediately without committing settings; untouched fields retain inheritance and automatic uppercase pitch; optional Speech Dispatcher defaults queries have bounded fallback values; Cancel restores the last applied voice configuration. Independent capital pitch beep and cap checkboxes replace the capitalization style dropdown; default pitch is enabled and all combinations apply to custom echo and spelling. Private GTK traversal and Apply/reload plus SSIP and persistence checks pass; general speech preferences listening is accepted by the maintainer on 2026-10-22; control-specific Braille acceptance was not separately stated. Direct-slider missing-file private reader GTK and SSIP checks are documented in docs/testing/speech-preferences.md; live acceptance of this simplification remains pending. Explicitly selected legacy files remain session-only and cannot be overwritten. Advanced preferences and importer remain pending. Phonetic character hints is an unchecked Speech Reading option; plain Left/Right character navigation can append a cancellable 300 ms phonetic hint. Private GTK default toggle Apply/reload and cancellation checks pass; physical listening remains pending. See docs/testing/phonetic-review.md.
|
||||
review-component-geometry,src/cthulhu/flat_review.py,review,Capture component screen bounds and visibility for flat-review clipping,Storm Dragon,internal/atspi,implemented,DISPLAY=:0 go test ./internal/atspi -count=1; DISPLAY=:0 python3 tests/native_atspi.py,Private Xvfb D-Bus GtkBox tests verify screen bounds resize invalidation changed dimensions hide invalidation and recapture. Physical acceptance deferred by maintainer on 2026-09-14.,,,,Geometry queries run only during owned-tree capture. Bounds and visibility events invalidate geometry without removing identities. Negative coordinates overflow-safe clipping malformed geometry and concurrent invalidation have unit tests. Component bounds do not establish occlusion text ranges zones reading order or flat-review key support.
|
||||
review-visible-text,src/cthulhu/flat_review.py,review,Capture bounded visible text with character offsets and screen clipping,Storm Dragon,internal/atspi,in-progress,DISPLAY=:0 go test -race ./internal/atspi ./internal/review ./internal/app ./internal/parity -count=1; DISPLAY=:0 python3 tests/native_atspi.py,Private ListBox and GtkBox capture edit clipping hidden-text and password-exclusion checks pass. Physical acceptance deferred.,,Maintainer reports current flat review working well after the previous day's fixes on 2026-10-22. This is general live-use acceptance rather than confirmation of every parity feature or historical fixture. See docs/testing/release-acceptance.md.,,Exact Unicode offsets and owned ranges include captured caret offsets. Text caret and geometry events invalidate cached text without retiring identities. Capture budgets are 65536 characters and 256 visible spans per object and 1 MiB per tree. Bounded line-rectangle discovery supports large objects without character rectangles; oversized lines use viewport glyph locations and bounded text spans. Zero-area glyphs no longer discard other visible text. Known unavailable objects and child subtrees leave healthy content reviewable with diagnostic omission counts and standard screen-boundary messages. Verified synthetic Text on controls uses accessible names or child content; tabs retain their label beside Close controls. Chromium address-value geometry remains unavailable. Root failures and aggregate layout budgets remain fatal. Private GTK fixtures and worker tests pass; physical speech and Braille acceptance remain pending. See docs/testing/flat-review-robustness.md.
|
||||
date-time-commands,src/cthulhu/date_and_time_presenter.py,command,Speak local time with Cthulhu+T and date with a double tap using saved formats,Storm Dragon,internal/input internal/app internal/preferences internal/config,implemented,go test ./... -count=1; focused race checks; native keyboard double-tap checks,Private Xvfb press-time and both-release-order checks passed; physical Xorg pending,,Maintainer confirms date/time; spoken version; window-title; and clipboard-reading commands pass live testing on 2026-10-22. See docs/testing/release-acceptance.md.,Pending braille,All 7 Python time and 16 Python date format choices are wired including locale seconds word numeric full and abbreviated variants; see docs/testing/time-date.md.
|
||||
|
||||
|
@@ -12,13 +12,14 @@ from native_x11 import wait_for
|
||||
|
||||
|
||||
class PreviewSpeechServer:
|
||||
def __init__(self, path):
|
||||
def __init__(self, path, defaultsSupported=True):
|
||||
self.listener = socket.socket(socket.AF_UNIX)
|
||||
self.listener.bind(str(path))
|
||||
self.listener.listen()
|
||||
self.lock = threading.RLock()
|
||||
self.clients = {}
|
||||
self.spoken = []
|
||||
self.defaultsSupported = defaultsSupported
|
||||
threading.Thread(target=self.accept_clients, daemon=True).start()
|
||||
|
||||
def accept_clients(self):
|
||||
@@ -30,7 +31,8 @@ class PreviewSpeechServer:
|
||||
with self.lock:
|
||||
clientId = len(self.clients) + 1
|
||||
client = {"socket": connection, "lock": threading.RLock(), "notifications": False,
|
||||
"module": "main", "voice": "", "active": None}
|
||||
"module": "main", "voice": "", "active": None,
|
||||
"settings": {"RATE": 20, "PITCH": -10, "VOLUME": 100}}
|
||||
self.clients[clientId] = client
|
||||
threading.Thread(target=self.serve_client, args=(clientId, client), daemon=True).start()
|
||||
|
||||
@@ -64,6 +66,13 @@ class PreviewSpeechServer:
|
||||
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("GET "):
|
||||
field = command.removeprefix("GET ")
|
||||
response = (f"200-{client['settings'][field]}\r\n200 OK\r\n"
|
||||
if self.defaultsSupported and field in client["settings"] else "500 UNSUPPORTED\r\n")
|
||||
elif any(command.startswith("SET SELF " + field + " ") for field in client["settings"]):
|
||||
_, _, field, value = command.split()
|
||||
client["settings"][field] = int(value)
|
||||
elif command.startswith("SET SELF OUTPUT_MODULE "):
|
||||
client["module"] = command.removeprefix("SET SELF OUTPUT_MODULE ")
|
||||
elif command.startswith("SET SELF SYNTHESIS_VOICE "):
|
||||
@@ -86,7 +95,8 @@ class PreviewSpeechServer:
|
||||
else:
|
||||
parts.append(command.removeprefix("CHAR "))
|
||||
record = {"text": "\n".join(parts), "module": client["module"],
|
||||
"voice": client["voice"], "finished": False, "canceled": False}
|
||||
"voice": client["voice"], "finished": False, "canceled": False,
|
||||
"settings": dict(client["settings"])}
|
||||
with self.lock:
|
||||
self.spoken.append(record)
|
||||
client["active"] = record
|
||||
@@ -213,19 +223,20 @@ def check_interruption_summary(controls, tabs, server):
|
||||
"read-only summary trapped Shift+Tab")
|
||||
|
||||
|
||||
def check_runtime(radioOnly=False, flatReviewOnly=False, interruptionOnly=False):
|
||||
def check_runtime(radioOnly=False, flatReviewOnly=False, interruptionOnly=False, noConfigOnly=False):
|
||||
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")
|
||||
server = PreviewSpeechServer(directory / "speech.sock", defaultsSupported=os.environ.get("CTHULHU_PREFS_DEFAULTS_UNSUPPORTED") != "1")
|
||||
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
|
||||
if not noConfigOnly:
|
||||
configPath.write_text('''schema-version = 2
|
||||
active-profile = "default"
|
||||
[profiles.default.speech]
|
||||
output-module = "main"
|
||||
@@ -254,7 +265,10 @@ lang = "en"
|
||||
"[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:
|
||||
if noConfigOnly:
|
||||
# Preferences checks must not launch Wine Access in live prefixes.
|
||||
readerEnv["CTHULHU_WINE_ACCESS_HELPER"] = "/usr/bin/true"
|
||||
if radioOnly or noConfigOnly:
|
||||
import gi
|
||||
gi.require_version("Gst", "1.0")
|
||||
from gi.repository import Gst
|
||||
@@ -288,6 +302,11 @@ 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 noConfigOnly:
|
||||
from native_speech_defaults import check_no_config
|
||||
check_no_config(window, tabs, server, configPath)
|
||||
print(f"PASS: missing config, editable speech sliders, server defaults/fallback, inheritance, Apply/save/reopen; evidence: {directory}")
|
||||
return
|
||||
if interruptionOnly:
|
||||
check_interruption_summary(controls, tabs, server)
|
||||
print(f"PASS: real reader speaks read-only interruption summary on focus and character arrows; Tab traversal preserved; evidence: {directory}")
|
||||
|
||||
+20
-22
@@ -46,6 +46,10 @@ class ResponseReader:
|
||||
|
||||
|
||||
def inside():
|
||||
if os.environ.get("CTHULHU_PREFS_NO_CONFIG_ONLY") == "1":
|
||||
from native_echo_preview import check_runtime
|
||||
check_runtime(noConfigOnly=True)
|
||||
return
|
||||
if os.environ.get("CTHULHU_PREFS_INTERRUPTION_RUNTIME_ONLY") == "1":
|
||||
from native_echo_preview import check_runtime
|
||||
check_runtime(interruptionOnly=True)
|
||||
@@ -574,7 +578,7 @@ def inside():
|
||||
wait_for(lambda: not slider.getState().contains(pyatspi.STATE_FOCUSED), "Tab trapped on slider")
|
||||
key("shift+Tab")
|
||||
wait_for(lambda: slider.getState().contains(pyatspi.STATE_FOCUSED), "Shift+Tab did not return to slider")
|
||||
focus(named("Use server default rate"))
|
||||
focus(named("Raise pitch for capital letters"))
|
||||
for page in ("Braille", "Key Echo"):
|
||||
key("ctrl+Page_Down")
|
||||
wait_for(lambda: named(page).getState().contains(pyatspi.STATE_SELECTED), page + " page missing")
|
||||
@@ -807,7 +811,6 @@ def inside():
|
||||
key("ctrl+Page_Down")
|
||||
wait_for(lambda page=page: named(page).getState().contains(pyatspi.STATE_SELECTED), page + " missing")
|
||||
voiceType = named("Voice type")
|
||||
pitchDefault = named("Use server default pitch")
|
||||
pitchScale = next(n for n in nodes if n.getRole() == pyatspi.ROLE_SLIDER and "pitch" in n.name.lower())
|
||||
def accept_preview():
|
||||
preview = request()
|
||||
@@ -818,15 +821,14 @@ def inside():
|
||||
assert voiceType.querySelection().selectChild(index)
|
||||
values = accept_preview()
|
||||
assert values["VoiceType"] == kind and values["Pitch"] == 5, values
|
||||
assert pitchDefault.getState().contains(pyatspi.STATE_CHECKED)
|
||||
focus(pitchDefault); key("space")
|
||||
accept_preview()
|
||||
assert values[kind.capitalize() + "Voice"]["AveragePitch"] is None, values
|
||||
assert pitchScale.getState().contains(pyatspi.STATE_SENSITIVE)
|
||||
pitchScale.queryValue().currentValue = pitch
|
||||
values = accept_preview()
|
||||
assert values[kind.capitalize() + "Voice"]["AveragePitch"] == pitch, values
|
||||
assert voiceType.querySelection().selectChild(0)
|
||||
values = accept_preview()
|
||||
assert values["Pitch"] == 5 and not pitchDefault.getState().contains(pyatspi.STATE_CHECKED), values
|
||||
assert values["Pitch"] == 5, values
|
||||
assert pitchScale.queryValue().currentValue == 5
|
||||
for label, mode in (("Beeps", "beeps"), ("Speech and beeps", "both"), ("Speech", "speech")):
|
||||
radio = next(n for n in nodes if n.getRole() == pyatspi.ROLE_RADIO_BUTTON and n.name == label)
|
||||
@@ -884,7 +886,7 @@ def inside():
|
||||
preview = request()
|
||||
send({"Preview": True, "Accepted": True})
|
||||
wait_for(lambda: len(person_items()) == 102, "locale voice list did not recover")
|
||||
focus(named("Use server default rate"))
|
||||
focus(named("Raise pitch for capital letters"))
|
||||
subprocess.run(["xdotool", "windowfocus", xid], check=True)
|
||||
key("Escape")
|
||||
assert process.wait(timeout=2) == 0, "large catalog blocked Escape"
|
||||
@@ -1349,27 +1351,23 @@ def inside():
|
||||
assert named(name).getRole() == pyatspi.ROLE_PUSH_BUTTON
|
||||
# Prove the supported controls accept focus, and that the scale does
|
||||
# not trap forward or reverse keyboard traversal.
|
||||
for name in ("Use server default rate", "Use server default pitch",
|
||||
"Use server default volume", "Apply", "Cancel", "OK"):
|
||||
assert not any(name.startswith("Use server default") for name in controls)
|
||||
for name in ("Apply", "Cancel", "OK"):
|
||||
focus(named(name))
|
||||
for slider in sliders:
|
||||
assert slider.getState().contains(pyatspi.STATE_SENSITIVE)
|
||||
focus(slider)
|
||||
focus(named("Use server default rate"))
|
||||
pitch = next(n for n in sliders if "Pitch" in n.name)
|
||||
focus(pitch)
|
||||
key("Tab")
|
||||
wait_for(lambda: rate.getState().contains(pyatspi.STATE_FOCUSED),
|
||||
"Tab did not enter the rate slider")
|
||||
key("shift+Tab")
|
||||
wait_for(lambda: named("Use server default rate").getState().contains(
|
||||
pyatspi.STATE_FOCUSED), "Shift+Tab did not leave the rate slider")
|
||||
wait_for(lambda: pitch.getState().contains(pyatspi.STATE_FOCUSED),
|
||||
"Shift+Tab did not return to the pitch slider")
|
||||
focus(named("Some")); key("space")
|
||||
wait_for(lambda: named("Some").getState().contains(pyatspi.STATE_CHECKED),
|
||||
"radio activation did not select punctuation")
|
||||
focus(named("Use server default pitch")); key("space")
|
||||
wait_for(lambda: not next(n for n in sliders if "Pitch" in n.name).getState().contains(
|
||||
pyatspi.STATE_SENSITIVE), "default pitch did not disable field")
|
||||
preview = request()
|
||||
assert preview["Preview"] and preview["Values"]["Pitch"] is None, preview
|
||||
send({"Preview": True, "Accepted": True, "Message": "Speech settings previewed."})
|
||||
print("PASS: initial preferences accessibility and keyboard navigation", flush=True)
|
||||
focus(rate)
|
||||
rate.queryValue().currentValue = 72
|
||||
@@ -1382,7 +1380,7 @@ def inside():
|
||||
assert draft["Values"]["SpeechHistoryLimit"] == 75, draft
|
||||
assert draft["Values"]["TimeFormat"] == "12-hour" and draft["Values"]["DateFormat"] == "numbers-md", draft
|
||||
assert draft["Values"]["Rate"] == 72 and draft["Save"] and not draft["Close"], draft
|
||||
assert draft["Values"]["Punctuation"] == 2 and draft["Values"]["Pitch"] is None, draft
|
||||
assert draft["Values"]["Punctuation"] == 2 and draft["Values"]["Pitch"] == 5, draft
|
||||
assert draft["Values"]["EnableSound"] and draft["Values"]["PlaySoundForRole"], draft
|
||||
assert draft["Values"]["PlaySoundForState"] and draft["Values"]["SpatializeObjectSounds"], draft
|
||||
assert draft["Values"]["ProgressBar"]["OutputMode"] == "speak", draft
|
||||
@@ -1410,7 +1408,7 @@ def inside():
|
||||
wait_for(lambda: status.name == accepted["Message"], "success status missing")
|
||||
print("PASS: Apply, rejected draft, OK protocol", flush=True)
|
||||
# GtkScale consumes Ctrl+Page_Down as a value change. Verify that
|
||||
# behavior, then navigate the notebook from the adjacent checkbox.
|
||||
# behavior, then navigate the notebook from the capital pitch checkbox.
|
||||
focus(rate)
|
||||
previous_rate = rate.queryValue().currentValue
|
||||
key("ctrl+Page_Down")
|
||||
@@ -1424,7 +1422,7 @@ def inside():
|
||||
preview = request()
|
||||
assert preview["Preview"] and preview["Values"]["Rate"] == previous_rate, preview
|
||||
send({"Preview": True, "Accepted": True, "Message": "Speech settings previewed."})
|
||||
focus(named("Use server default rate"))
|
||||
focus(named("Raise pitch for capital letters"))
|
||||
for page in ("Braille", "Key Echo"):
|
||||
key("ctrl+Page_Down")
|
||||
wait_for(lambda: named(page).getState().contains(pyatspi.STATE_SELECTED),
|
||||
@@ -1516,7 +1514,7 @@ def inside():
|
||||
key("ctrl+Page_Up", "ctrl+Page_Up")
|
||||
wait_for(lambda: named("Speech").getState().contains(pyatspi.STATE_SELECTED),
|
||||
"Speech page did not reopen")
|
||||
focus(named("Use server default rate"))
|
||||
focus(named("Raise pitch for capital letters"))
|
||||
subprocess.run(["xdotool", "windowfocus", xid], check=True)
|
||||
key("Escape")
|
||||
assert process.wait(timeout=2) == 0, "Escape did not close preferences promptly"
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Missing-file acceptance checks for the real private reader/preferences pair."""
|
||||
import json
|
||||
import subprocess
|
||||
import tomllib
|
||||
|
||||
from native_x11 import wait_for
|
||||
|
||||
|
||||
def check_no_config(window, tabs, server, configPath):
|
||||
import pyatspi
|
||||
|
||||
def walk(node):
|
||||
yield node
|
||||
for child in node:
|
||||
if child is not None:
|
||||
yield from walk(child)
|
||||
|
||||
def controls_for(currentWindow):
|
||||
nodes = list(walk(currentWindow))
|
||||
controls = {node.name.rstrip(":"): node for node in nodes if node.name}
|
||||
controls.update({node.name.rstrip(":"): node for node in nodes
|
||||
if node.getRole() in (pyatspi.ROLE_SLIDER, pyatspi.ROLE_COMBO_BOX)})
|
||||
return controls
|
||||
|
||||
def key(*keys):
|
||||
subprocess.run(["xdotool", "key", "--clearmodifiers", *keys], check=True)
|
||||
|
||||
def focus(control):
|
||||
assert control.queryComponent().grabFocus(), control.name
|
||||
wait_for(lambda: control.getState().contains(pyatspi.STATE_FOCUSED), "focus missing: " + control.name)
|
||||
|
||||
def saved_speech():
|
||||
if configPath.exists():
|
||||
data = tomllib.loads(configPath.read_text())
|
||||
return data["profiles"][data["active-profile"]]["speech"]
|
||||
|
||||
def find_window():
|
||||
for application in pyatspi.Registry.getDesktop(0):
|
||||
for candidate in application:
|
||||
if candidate and candidate.name == "Cthulhu Preferences":
|
||||
return candidate
|
||||
|
||||
assert not configPath.exists(), "opening preferences unexpectedly created a configuration"
|
||||
assert tabs.querySelection().selectChild(2)
|
||||
controls = controls_for(window)
|
||||
assert not any(name.startswith(("Use server default", "Use Default voice", "Use automatic uppercase"))
|
||||
for name in controls)
|
||||
expected = {"Rate": 59, "Pitch": 4, "Volume": 9} if server.defaultsSupported else {"Rate": 50, "Pitch": 5, "Volume": 5}
|
||||
for name, value in expected.items():
|
||||
slider = controls[name]
|
||||
assert slider.getRole() == pyatspi.ROLE_SLIDER, (name, slider.getRoleName())
|
||||
assert slider.getState().contains(pyatspi.STATE_SENSITIVE), name
|
||||
assert slider.queryValue().currentValue == value, (name, slider.queryValue().currentValue, value)
|
||||
for previous, following in (("Volume", "Pitch"), ("Pitch", "Rate")):
|
||||
focus(controls[previous]); key("Tab")
|
||||
wait_for(lambda: controls[following].getState().contains(pyatspi.STATE_FOCUSED), "Tab traversal failed")
|
||||
key("shift+Tab")
|
||||
wait_for(lambda: controls[previous].getState().contains(pyatspi.STATE_FOCUSED), "Shift+Tab traversal failed")
|
||||
|
||||
voiceType = controls["Voice type"]
|
||||
assert voiceType.querySelection().selectChild(1)
|
||||
wait_for(lambda: controls["Pitch"].queryValue().currentValue == 7, "automatic uppercase pitch missing")
|
||||
assert voiceType.querySelection().selectChild(0)
|
||||
wait_for(lambda: controls["Pitch"].queryValue().currentValue == expected["Pitch"], "Default pitch did not return")
|
||||
focus(controls["Apply"]); key("space")
|
||||
wait_for(lambda: saved_speech() is not None, "Apply did not create the missing config")
|
||||
speech = saved_speech()
|
||||
assert not speech.get("default-voice"), speech
|
||||
assert not speech.get("uppercase-voice"), speech
|
||||
wait_for(lambda: controls["Apply"].getState().contains(pyatspi.STATE_SENSITIVE), "Apply stayed busy")
|
||||
|
||||
expectedRaw = {}
|
||||
for name, scale, offset in (("Rate", 2, 98), ("Pitch", 20, 90), ("Volume", 15, 35)):
|
||||
slider = controls[name]
|
||||
focus(slider); key("Down" if name == "Volume" else "Up")
|
||||
expected[name] += -1 if name == "Volume" else 1
|
||||
expectedRaw[name.upper()] = int(scale * expected[name] - offset)
|
||||
wait_for(lambda: slider.queryValue().currentValue == expected[name], name + " did not change directly")
|
||||
wait_for(lambda: any(all(record["settings"][field] == value for field, value in expectedRaw.items())
|
||||
for record in server.snapshot()), name + " preview did not reach speech backend")
|
||||
focus(controls["Apply"]); key("space")
|
||||
def saved_values_match():
|
||||
voice = saved_speech().get("default-voice", {})
|
||||
return all(voice.get(field) == expected[name] for name, field in
|
||||
(("Rate", "rate"), ("Pitch", "average-pitch"), ("Volume", "gain")))
|
||||
wait_for(saved_values_match, "slider values were not saved")
|
||||
assert not saved_speech().get("uppercase-voice"), saved_speech()
|
||||
wait_for(lambda: controls["Apply"].getState().contains(pyatspi.STATE_SENSITIVE), "Apply stayed busy")
|
||||
focus(controls["Cancel"]); key("space")
|
||||
wait_for(lambda: find_window() is None, "preferences did not close")
|
||||
key("Insert+space")
|
||||
reopened = wait_for(find_window, "preferences did not reopen")
|
||||
controls = controls_for(reopened)
|
||||
tabs = next(node for node in walk(reopened) if node.getRole() == pyatspi.ROLE_PAGE_TAB_LIST)
|
||||
assert tabs.querySelection().selectChild(2)
|
||||
for name, value in expected.items():
|
||||
assert controls[name].queryValue().currentValue == value, (name, value)
|
||||
assert controls["Voice type"].querySelection().selectChild(1)
|
||||
wait_for(lambda: controls["Pitch"].queryValue().currentValue == max(7, expected["Pitch"] + 2),
|
||||
"untouched Uppercase pitch stopped following Default")
|
||||
assert controls["Voice type"].querySelection().selectChild(0)
|
||||
focus(controls["Rate"]); key("Up")
|
||||
changedRaw = dict(expectedRaw, RATE=expectedRaw["RATE"] + 2)
|
||||
wait_for(lambda: any(record["settings"] == changedRaw for record in server.snapshot()), "unsaved preview missing")
|
||||
focus(controls["Cancel"]); key("space")
|
||||
wait_for(lambda: find_window() is None, "Cancel did not close preferences")
|
||||
assert saved_values_match(), "Cancel changed the saved settings"
|
||||
key("Insert+space")
|
||||
reopened = wait_for(find_window, "preferences did not reopen after Cancel")
|
||||
controls = controls_for(reopened)
|
||||
assert controls["Rate"].queryValue().currentValue == expected["Rate"], "Cancel retained the unsaved rate"
|
||||
(configPath.parent / "speech-defaults.json").write_text(json.dumps({
|
||||
"initialConfigMissing": True, "serverDefaultsSupported": server.defaultsSupported,
|
||||
"savedValues": expected, "savedBackendValues": expectedRaw}, indent=2))
|
||||
Reference in new issue
Block a user