Simplified speech settings.

This commit is contained in:
Storm Dragon committed 2026-10-24 05:27:16 -04:00
1 parent 03e2b31e09
commit 54722e985e
20 files changed
+403 -92

No files matched your search

+12 -2
View File
@@ -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"
+43
View File
@@ -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.
+2 -1
View File
@@ -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 {
+8
View File
@@ -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)
+2
View File
@@ -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())
+5 -8
View File
@@ -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++) {
+2 -3
View File
@@ -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);
+7 -22
View File
@@ -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);
+12 -15
View File
@@ -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>
+1
View File
@@ -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
+1
View File
@@ -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
+22 -9
View File
@@ -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) {
+1 -1
View File
@@ -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
+57
View File
@@ -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
}
+66
View File
@@ -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)
}
}
+1 -1
View File
@@ -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.
1 id python_source subsystem behavior owner go_destination status automated_evidence x11_evidence wayland_evidence speech_evidence braille_evidence notes
39 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.
40 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.
41 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.
42 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. 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.
43 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.
44 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.
45 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.
+26 -7
View File
@@ -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
View File
@@ -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"
+114
View File
@@ -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))