diff --git a/README.md b/README.md
index 43e26aa..2ff3e0c 100644
--- a/README.md
+++ b/README.md
@@ -59,6 +59,23 @@ matching current X11 foreground metadata; without that evidence it stays quiet.
Wine insertion feedback uses the custom key voice when character echo is off,
and the custom character voice when character echo is on.
+## Capital letters
+
+Speech preferences group three independent checkboxes under Capital letters:
+Raise pitch for capital letters (on by default), Beep for capital letters,
+and Say “cap” before capital letters (both off by default). Any combination,
+including all off, is supported. These settings apply to character navigation,
+spelling, and typing feedback, including a custom key echo voice. Word and line
+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
+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.
+
## Browser button updates
Web buttons announce their current label when focused. Automatic label changes
diff --git a/internal/app/app.go b/internal/app/app.go
index b353304..37dfcaa 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -243,6 +243,10 @@ func Run(ctx context.Context, options Options) error {
gatedSpeech{speech: speechOutput, gate: focusGate}, brailleOutput, live, focusGate))
}
if speechMessages != nil {
+ speechMessages.capitalization = func() (bool, bool) {
+ settings := live.Load()
+ return settings.BeepForCapitals, settings.SayCapForCapitals
+ }
speechMessages.reading = func() config.ReadingSettings { return live.Load().Reading }
speechMessages.verbosity = func() int { return live.Load().SpeechVerbosityLevel }
speechMessages.indentationCue = func(cue sound.Cue) {
diff --git a/internal/app/capitalization.go b/internal/app/capitalization.go
new file mode 100644
index 0000000..1d2a951
--- /dev/null
+++ b/internal/app/capitalization.go
@@ -0,0 +1,52 @@
+// Copyright (c) 2026 Storm Dragon
+// Authors: Storm Dragon, Angus Kola
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app
+
+import (
+ "context"
+ "unicode"
+ "unicode/utf8"
+)
+
+func capitalCharacter(text string) bool {
+ letter, size := utf8.DecodeRuneInString(text)
+ return size == len(text) && unicode.IsUpper(letter)
+}
+
+func capitalVoice(customEcho bool) string {
+ if customEcho {
+ return "uppercase-echo"
+ }
+ return "uppercase"
+}
+
+// Called only by serialized speech delivery, after mode and context handling.
+// No cue outlives its owning request's cancellation context.
+func (worker *speechWorker) present_capital(ctx context.Context, request speechRequest) error {
+ if worker.capitalization == nil {
+ return nil
+ }
+ beep, sayCap := worker.capitalization()
+ if beep && worker.capitalBeep != nil {
+ if err := worker.capitalBeep(ctx); err != nil {
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+ worker.logger.Warn("capital letter beep", "error", err)
+ }
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ if sayCap {
+ if voice, ok := worker.speaker.(interface {
+ SpeakVoiceAndWait(context.Context, string, string, func()) error
+ }); ok {
+ return voice.SpeakVoiceAndWait(ctx, "cap", capitalVoice(request.CustomEcho), nil)
+ }
+ return worker.speaker.Speak(ctx, "cap")
+ }
+ return nil
+}
diff --git a/internal/app/capitalization_test.go b/internal/app/capitalization_test.go
new file mode 100644
index 0000000..ce3d263
--- /dev/null
+++ b/internal/app/capitalization_test.go
@@ -0,0 +1,37 @@
+// Copyright (c) 2026 Storm Dragon
+// Authors: Storm Dragon, Angus Kola
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app
+
+import (
+ "context"
+ "errors"
+ "log/slog"
+ "testing"
+)
+
+func TestCapitalBeepCancellationAndFailure(t *testing.T) {
+ for _, interrupt := range []bool{false, true} {
+ speaker := &spellingTestSpeaker{}
+ worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
+ worker.capitalization = func() (bool, bool) { return true, true }
+ ctx, cancel := context.WithCancel(context.Background())
+ worker.capitalBeep = func(beepContext context.Context) error {
+ if interrupt {
+ cancel()
+ return beepContext.Err()
+ }
+ return errors.New("audio unavailable")
+ }
+ err := worker.deliver(ctx, speechRequest{Text: "A", Character: true})
+ cancel()
+ if interrupt {
+ if !errors.Is(err, context.Canceled) || len(speaker.parts) != 0 {
+ t.Fatalf("canceled capital: %v %v", err, speaker.parts)
+ }
+ } else if err != nil || len(speaker.parts) != 2 || speaker.parts[0] != "cap" || speaker.parts[1] != "A" {
+ t.Fatalf("failed beep suppressed speech: %v %v", err, speaker.parts)
+ }
+ }
+}
diff --git a/internal/app/configured_speaker.go b/internal/app/configured_speaker.go
index 517139f..fbbc9a1 100644
--- a/internal/app/configured_speaker.go
+++ b/internal/app/configured_speaker.go
@@ -244,6 +244,9 @@ func (speaker *hardwareSpeaker) SpeakVoice(ctx context.Context, text, kind strin
if kind == "echo" {
return speaker.SpeakEcho(ctx, text)
}
+ if kind == "uppercase-echo" {
+ return speaker.speak(ctx, text, true, "uppercase")
+ }
return speaker.speak(ctx, text, false, kind)
}
func (speaker *hardwareSpeaker) speak(ctx context.Context, text string, echo bool, kind string) error {
diff --git a/internal/app/echo_speaker_test.go b/internal/app/echo_speaker_test.go
index 0ac3f44..57d6199 100644
--- a/internal/app/echo_speaker_test.go
+++ b/internal/app/echo_speaker_test.go
@@ -330,6 +330,109 @@ func test_echo_speaker(t *testing.T, wrapped bool) {
t.Fatalf("custom character echo = %+v; want pitch %s", got, wantPitch)
}
}
+
+ capitalPreferences := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, speaker)
+ capitalLive := &liveSettings{}
+ capitalLive.Store(settings)
+ capitalPreferences.live = capitalLive
+ for mask := 0; mask < 8; mask++ {
+ choices := settings
+ echoPitch := 6.0
+ choices.EchoVoice.AveragePitch = &echoPitch
+ choices.RaisePitchForCapitals = mask&1 != 0
+ choices.BeepForCapitals = mask&2 != 0
+ choices.SayCapForCapitals = mask&4 != 0
+ if response := capitalPreferences.apply(ctx, preferences.Request{Values: preferences.FromSettings(choices)}); !response.Accepted {
+ t.Fatalf("capital choices Apply failed: %+v", response)
+ }
+ capitals := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
+ capitals.capitalization = func() (bool, bool) {
+ current := capitalLive.Load()
+ return current.BeepForCapitals, current.SayCapForCapitals
+ }
+ beeps := 0
+ capitals.capitalBeep = func(context.Context) error { beeps++; return nil }
+ for _, custom := range []bool{false, true} {
+ for _, tc := range []struct {
+ text, cause, voice string
+ character, capital bool
+ }{
+ {"A", "character-echo", "", true, true},
+ {"É", "document-caret-character", "", true, true},
+ {"𐐀", "flat-review", "", true, true},
+ {"A", "key-echo", "", true, true},
+ {"a", "character-echo", "", true, false},
+ {"ABC", "word-echo", "", false, false},
+ {"A", "focus", "", false, false},
+ } {
+ before := beeps
+ request := speechRequest{Text: tc.text, Cause: tc.cause, VoiceType: tc.voice, Character: tc.character, CustomEcho: custom}
+ if err := capitals.deliver(ctx, request); err != nil {
+ t.Fatal(err)
+ }
+ wantPitch, wantModule := "", ""
+ if custom {
+ wantPitch, wantModule = "30", "echo-module"
+ }
+ if tc.capital && choices.RaisePitchForCapitals {
+ wantPitch = "50"
+ if custom {
+ wantPitch = "70"
+ }
+ }
+ if tc.capital && choices.SayCapForCapitals {
+ got := <-spoken
+ if got.text != "cap" || got.pitch != wantPitch || got.module != wantModule {
+ t.Fatalf("mask %d cap prefix: %+v", mask, got)
+ }
+ }
+ got := <-spoken
+ if got.text != tc.text || got.pitch != wantPitch || got.module != wantModule {
+ t.Fatalf("mask %d custom=%v request=%+v: %+v", mask, custom, request, got)
+ }
+ wantBeeps := 0
+ if tc.capital && choices.BeepForCapitals {
+ wantBeeps = 1
+ }
+ if beeps-before != wantBeeps {
+ t.Fatalf("mask %d beeps=%d", mask, beeps-before)
+ }
+ }
+ }
+ // Phonetic spelling carries uppercase metadata despite lowercase words.
+ for _, character := range []bool{false, true} {
+ text := "alpha"
+ if character {
+ text = "A"
+ }
+ request := speechRequest{Spelling: []string{text, "b"}, SpellingVoices: []string{"uppercase", ""}, Character: character}
+ before := beeps
+ if err := capitals.deliver(ctx, request); err != nil {
+ t.Fatal(err)
+ }
+ if choices.SayCapForCapitals {
+ if got := <-spoken; got.text != "cap" {
+ t.Fatalf("spelled prefix: %+v", got)
+ }
+ }
+ if got := <-spoken; got.text != text {
+ t.Fatalf("spelled capital: %+v", got)
+ }
+ if got := <-spoken; got.text != "b" || got.pitch != "" {
+ t.Fatalf("spelled lowercase: %+v", got)
+ }
+ wantBeeps := 0
+ if choices.BeepForCapitals {
+ wantBeeps = 1
+ }
+ if beeps-before != wantBeeps {
+ t.Fatal("spelling beep count")
+ }
+ }
+ }
+ if err := speaker.UpdateConfiguration(ctx, settings); err != nil {
+ t.Fatal(err)
+ }
upperPitch, linkPitch, systemPitch := 8.0, 1.0, 2.0
settings.UppercaseVoice.AveragePitch = &upperPitch
settings.HyperlinkVoice.AveragePitch = &linkPitch
diff --git a/internal/app/preferences_apply.go b/internal/app/preferences_apply.go
index eb5f33f..bf472a8 100644
--- a/internal/app/preferences_apply.go
+++ b/internal/app/preferences_apply.go
@@ -115,7 +115,8 @@ func (worker *preferencesWorker) applyRuntime(ctx context.Context, next config.S
}
}()
}
- speechChanged := next.SpeechServerFactory != worker.settings.SpeechServerFactory ||
+ speechChanged := next.RaisePitchForCapitals != worker.settings.RaisePitchForCapitals ||
+ next.SpeechServerFactory != worker.settings.SpeechServerFactory ||
!reflect.DeepEqual(next.UppercaseVoice, worker.settings.UppercaseVoice) ||
!reflect.DeepEqual(next.HyperlinkVoice, worker.settings.HyperlinkVoice) ||
!reflect.DeepEqual(next.SystemVoice, worker.settings.SystemVoice) ||
diff --git a/internal/app/remote_control.go b/internal/app/remote_control.go
index 631d3f6..aeecb03 100644
--- a/internal/app/remote_control.go
+++ b/internal/app/remote_control.go
@@ -85,6 +85,16 @@ func remoteValue(settings config.Settings, module, property string) (dbus.Varian
return dbus.MakeVariant(float64(10)), true
case "SpeechIsEnabled":
return dbus.MakeVariant(settings.EnableSpeech), true
+ case "CapitalizationStyle":
+ // The old API cannot represent both cues; prefer the spoken cue.
+ style := "none"
+ if settings.BeepForCapitals {
+ style = "icon"
+ }
+ if settings.SayCapForCapitals {
+ style = "spell"
+ }
+ return dbus.MakeVariant(style), true
case "PunctuationLevel":
return dbus.MakeVariant([]string{"all", "most", "some", "none"}[settings.VerbalizePunctuationStyle]), true
}
@@ -240,6 +250,13 @@ func setRemoteValue(settings *config.Settings, module, property string, value db
}
settings.DefaultVoice.Family.Name = v
return true
+ case "CapitalizationStyle":
+ v, ok := value.Value().(string)
+ if !ok || (v != "none" && v != "spell" && v != "icon") {
+ return false
+ }
+ settings.SetCapitalizationStyle(v)
+ return true
case "PunctuationLevel":
v, ok := value.Value().(string)
if !ok {
diff --git a/internal/app/remote_control_test.go b/internal/app/remote_control_test.go
index 48013d5..1af6490 100644
--- a/internal/app/remote_control_test.go
+++ b/internal/app/remote_control_test.go
@@ -68,3 +68,27 @@ func TestRemoteSettingsUseWorkerWithoutSaving(t *testing.T) {
t.Fatalf("remote setter wrote settings file: %v", err)
}
}
+
+func TestLegacyRemoteCapitalStyleControlsIndependentCues(t *testing.T) {
+ settings := config.DefaultSettings()
+ for _, style := range []string{"icon", "spell", "none"} {
+ if !setRemoteValue(&settings, "SpeechManager", "CapitalizationStyle", dbus.MakeVariant(style)) {
+ t.Fatal("style rejected")
+ }
+ if !settings.RaisePitchForCapitals || settings.BeepForCapitals != (style == "icon") || settings.SayCapForCapitals != (style == "spell") {
+ t.Fatalf("style %s cue mapping", style)
+ }
+ value, ok := remoteValue(settings, "SpeechManager", "CapitalizationStyle")
+ if !ok || value.Value() != style {
+ t.Fatalf("style %s getter: %v", style, value)
+ }
+ }
+ settings.BeepForCapitals, settings.SayCapForCapitals = true, true
+ value, ok := remoteValue(settings, "SpeechManager", "CapitalizationStyle")
+ if !ok || value.Value() != "spell" {
+ t.Fatal("combined style compatibility")
+ }
+ if setRemoteValue(&settings, "SpeechManager", "CapitalizationStyle", dbus.MakeVariant("invalid")) {
+ t.Fatal("invalid style accepted")
+ }
+}
diff --git a/internal/app/runtime_backends.go b/internal/app/runtime_backends.go
index e56fc5b..adb711b 100644
--- a/internal/app/runtime_backends.go
+++ b/internal/app/runtime_backends.go
@@ -89,6 +89,9 @@ func newRuntimeBackends(options *Options, settings config.Settings, logger *slog
if beeper, ok := backends.sounds.(interface{ SpeechBeep(context.Context) error }); ok {
backends.speechMessages.beep = beeper.SpeechBeep
}
+ if beeper, ok := backends.sounds.(interface{ CapitalBeep(context.Context) error }); ok {
+ backends.speechMessages.capitalBeep = beeper.CapitalBeep
+ }
backends.history = newSpeechHistory(settings.SpeechHistoryLimit)
backends.speechMessages.history = backends.history
backends.speechOutput = backends.speechMessages
diff --git a/internal/app/runtime_settings.go b/internal/app/runtime_settings.go
index e74e66a..8d854bc 100644
--- a/internal/app/runtime_settings.go
+++ b/internal/app/runtime_settings.go
@@ -29,7 +29,7 @@ func speechSettingsFromConfig(settings config.Settings) speech.Settings {
speechSettings := speech.Settings{
OutputModule: settings.SpeechOutputModule,
Punctuation: map[int]string{0: "all", 1: "most", 2: "some", 3: "none"}[settings.VerbalizePunctuationStyle],
- Capitalization: settings.CapitalizationStyle,
+ Capitalization: "none",
}
if settings.DefaultVoice.Rate != nil {
value := int(2*(*settings.DefaultVoice.Rate) - 98)
diff --git a/internal/app/runtime_settings_test.go b/internal/app/runtime_settings_test.go
index 676cce6..7a8be7a 100644
--- a/internal/app/runtime_settings_test.go
+++ b/internal/app/runtime_settings_test.go
@@ -40,7 +40,7 @@ func TestSpeechSettingsFromConfig(t *testing.T) {
got.Volume == nil || *got.Volume != 40 {
t.Fatalf("converted voice values = rate %v pitch %v volume %v", got.Rate, got.Pitch, got.Volume)
}
- if got.Punctuation != "some" || got.Capitalization != "icon" ||
+ if got.Punctuation != "some" || got.Capitalization != "none" ||
got.Language != "en-US" || got.SynthesisVoice != "Generic Voice" {
t.Fatalf("converted speech settings = %#v", got)
}
diff --git a/internal/app/speech_delivery.go b/internal/app/speech_delivery.go
index 9a33e2c..ac5b24c 100644
--- a/internal/app/speech_delivery.go
+++ b/internal/app/speech_delivery.go
@@ -185,6 +185,9 @@ func (worker *speechWorker) deliver(ctx context.Context, request speechRequest)
if request.CustomEcho {
kind = "echo"
}
+ if request.Character && capitalCharacter(request.Text) {
+ kind = capitalVoice(request.CustomEcho)
+ }
speak = func(ctx context.Context, text string) error {
return voiced.SpeakVoiceAndWait(ctx, text, kind, nil)
}
@@ -215,6 +218,11 @@ func (worker *speechWorker) deliver(ctx context.Context, request speechRequest)
if !historyRow && !(request.Character && utf8.RuneCountInString(chunk) == 1) {
text = worker.transformText(chunk)
}
+ if (request.Character && capitalCharacter(chunk)) || request.VoiceType == "uppercase" {
+ if err := worker.present_capital(ctx, request); err != nil {
+ return err
+ }
+ }
part := request
part.Append = request.Append || index > 0
worker.record_delivery(ctx, part, text)
diff --git a/internal/app/speech_worker.go b/internal/app/speech_worker.go
index 8691271..4c345b9 100644
--- a/internal/app/speech_worker.go
+++ b/internal/app/speech_worker.go
@@ -87,6 +87,8 @@ type speechWorker struct {
speaker Speaker
history *speechHistory
beep func(context.Context) error
+ capitalBeep func(context.Context) error
+ capitalization func() (beep, sayCap bool)
textTransform func(string) string
reading func() config.ReadingSettings
verbosity func() int
diff --git a/internal/config/capitalization.go b/internal/config/capitalization.go
new file mode 100644
index 0000000..b3548e4
--- /dev/null
+++ b/internal/config/capitalization.go
@@ -0,0 +1,13 @@
+// Copyright (c) 2026 Storm Dragon
+// Authors: Storm Dragon, Angus Kola
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package config
+
+// SetCapitalizationStyle adapts the old exclusive choice to independent cues.
+// Pitch has always been configured separately and is left untouched.
+func (settings *Settings) SetCapitalizationStyle(style string) {
+ settings.CapitalizationStyle = style
+ settings.BeepForCapitals = style == "icon"
+ settings.SayCapForCapitals = style == "spell"
+}
diff --git a/internal/config/capitalization_test.go b/internal/config/capitalization_test.go
new file mode 100644
index 0000000..fa39235
--- /dev/null
+++ b/internal/config/capitalization_test.go
@@ -0,0 +1,104 @@
+// Copyright (c) 2026 Storm Dragon
+// Authors: Storm Dragon, Angus Kola
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package config
+
+import (
+ "fmt"
+ "path/filepath"
+ "testing"
+)
+
+func TestCapitalChoicesPersistIndependently(t *testing.T) {
+ defaults := DefaultSettings()
+ if !defaults.RaisePitchForCapitals || defaults.BeepForCapitals || defaults.SayCapForCapitals {
+ t.Fatal("only capital pitch should default on")
+ }
+ store, err := NewStore(filepath.Join(t.TempDir(), "settings.toml"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for mask := 0; mask < 8; mask++ {
+ settings := defaults
+ settings.RaisePitchForCapitals = mask&1 != 0
+ settings.BeepForCapitals = mask&2 != 0
+ settings.SayCapForCapitals = mask&4 != 0
+ if err := store.Save(settings); err != nil {
+ t.Fatal(err)
+ }
+ loaded, err := Load(store.path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ checkCapitalChoices(t, loaded, settings)
+ app := defaults
+ if err := store.SaveForApplication("editor", app); err != nil {
+ t.Fatal(err)
+ }
+ loaded, found, err := store.LoadForApplication("editor")
+ if err != nil || !found {
+ t.Fatalf("application load: %v, %v", found, err)
+ }
+ checkCapitalChoices(t, loaded, app)
+ loaded, err = store.Load()
+ if err != nil {
+ t.Fatal(err)
+ }
+ checkCapitalChoices(t, loaded, settings)
+ }
+}
+
+func checkCapitalChoices(t *testing.T, got, want Settings) {
+ t.Helper()
+ if got.RaisePitchForCapitals != want.RaisePitchForCapitals ||
+ got.BeepForCapitals != want.BeepForCapitals || got.SayCapForCapitals != want.SayCapForCapitals {
+ t.Fatalf("capital choices: pitch=%v beep=%v cap=%v; want %v %v %v",
+ got.RaisePitchForCapitals, got.BeepForCapitals, got.SayCapForCapitals,
+ want.RaisePitchForCapitals, want.BeepForCapitals, want.SayCapForCapitals)
+ }
+}
+
+func TestOldCapitalStylesMigrate(t *testing.T) {
+ for _, style := range []string{"none", "spell", "icon"} {
+ for _, versioned := range []bool{false, true} {
+ contents := fmt.Sprintf("[general]\ncapitalizationStyle = %q\n", style)
+ if versioned {
+ contents = fmt.Sprintf("schema-version = 2\nactive-profile = 'default'\n[profiles.default.speech]\ncapitalization-style = %q\n", style)
+ }
+ settings, err := Load(writeConfig(t, contents))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !settings.RaisePitchForCapitals || settings.BeepForCapitals != (style == "icon") || settings.SayCapForCapitals != (style == "spell") {
+ t.Fatalf("old %s, versioned=%v: %+v", style, versioned, settings)
+ }
+ }
+ }
+ settings, err := Load(writeConfig(t, "schema-version = 2\nactive-profile = 'default'\n[profiles.default.speech]\ncapitalization-style = 'spell'\nsay-cap-for-capitals = false\nbeep-for-capitals = true\nraise-pitch-for-capitals = false\n"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if settings.RaisePitchForCapitals || settings.SayCapForCapitals || !settings.BeepForCapitals {
+ t.Fatal("explicit independent choices did not override old style")
+ }
+}
+
+func TestCapitalPitchCanBeDisabledWithExplicitUppercaseVoice(t *testing.T) {
+ settings := DefaultSettings()
+ normal, upper := 4.0, 8.0
+ settings.DefaultVoice.AveragePitch = &normal
+ settings.UppercaseVoice.AveragePitch = &upper
+ settings.RaisePitchForCapitals = false
+ if got := *EffectiveVoice(settings, "uppercase").AveragePitch; got != normal {
+ t.Fatalf("pitch=%v", got)
+ }
+ settings.DefaultVoice.AveragePitch = nil
+ if EffectiveVoice(settings, "uppercase").AveragePitch != nil {
+ t.Fatal("server default pitch was lost")
+ }
+ settings.RaisePitchForCapitals = true
+ if got := *EffectiveVoice(settings, "uppercase").AveragePitch; got != upper {
+ t.Fatalf("pitch=%v", got)
+ }
+}
diff --git a/internal/config/defaults.go b/internal/config/defaults.go
index 127a05b..8f15288 100644
--- a/internal/config/defaults.go
+++ b/internal/config/defaults.go
@@ -68,6 +68,7 @@ func DefaultSettings() Settings {
},
VerbalizePunctuationStyle: 1,
CapitalizationStyle: "none",
+ RaisePitchForCapitals: true,
KeyboardLayout: 1,
CthulhuModifierKeys: []string{"Insert", "KP_Insert"},
UseCustomEchoForKey: true,
diff --git a/internal/config/legacy_mapping.go b/internal/config/legacy_mapping.go
index 155dbd3..2de74c6 100644
--- a/internal/config/legacy_mapping.go
+++ b/internal/config/legacy_mapping.go
@@ -227,7 +227,7 @@ func apply_legacy_speech_formats(settings *Settings, values rawSettings) {
settings.VerbalizePunctuationStyle = *values.VerbalizePunctuationStyle
}
if values.CapitalizationStyle != nil {
- settings.CapitalizationStyle = *values.CapitalizationStyle
+ settings.SetCapitalizationStyle(*values.CapitalizationStyle)
}
if values.TimeFormat != nil {
settings.TimeFormat = *values.TimeFormat
diff --git a/internal/config/profile_mapping.go b/internal/config/profile_mapping.go
index 84460c5..bbdd13d 100644
--- a/internal/config/profile_mapping.go
+++ b/internal/config/profile_mapping.go
@@ -112,11 +112,14 @@ func profileFromSettings(settings Settings) writableProfile {
Enabled: boolPointer(settings.EnableSpeech), ServerFactory: stringPointer(settings.SpeechServerFactory),
HistoryLimit: intPointer(settings.SpeechHistoryLimit),
Punctuation: intPointer(settings.VerbalizePunctuationStyle), Capitalization: stringPointer(settings.CapitalizationStyle),
- DefaultVoice: voicePointer(settings.DefaultVoice),
- UppercaseVoice: voicePointer(settings.UppercaseVoice),
- HyperlinkVoice: voicePointer(settings.HyperlinkVoice),
- SystemVoice: voicePointer(settings.SystemVoice),
- OutputModule: stringPointer(settings.SpeechOutputModule),
+ RaisePitchForCapitals: boolPointer(settings.RaisePitchForCapitals),
+ BeepForCapitals: boolPointer(settings.BeepForCapitals),
+ SayCapForCapitals: boolPointer(settings.SayCapForCapitals),
+ DefaultVoice: voicePointer(settings.DefaultVoice),
+ UppercaseVoice: voicePointer(settings.UppercaseVoice),
+ HyperlinkVoice: voicePointer(settings.HyperlinkVoice),
+ SystemVoice: voicePointer(settings.SystemVoice),
+ OutputModule: stringPointer(settings.SpeechOutputModule),
},
Braille: writableBraille{Enabled: boolPointer(settings.EnableBraille),
Contracted: boolPointer(settings.EnableContractedBraille),
@@ -440,7 +443,16 @@ func apply_profile_speech(settings *Settings, profile writableProfile) {
settings.VerbalizePunctuationStyle = *value
}
if value := profile.Speech.Capitalization; value != nil {
- settings.CapitalizationStyle = *value
+ settings.SetCapitalizationStyle(*value)
+ }
+ if value := profile.Speech.RaisePitchForCapitals; value != nil {
+ settings.RaisePitchForCapitals = *value
+ }
+ if value := profile.Speech.BeepForCapitals; value != nil {
+ settings.BeepForCapitals = *value
+ }
+ if value := profile.Speech.SayCapForCapitals; value != nil {
+ settings.SayCapForCapitals = *value
}
if value := profile.Speech.OutputModule; value != nil {
settings.SpeechOutputModule = *value
diff --git a/internal/config/schema.go b/internal/config/schema.go
index 8306771..8359c23 100644
--- a/internal/config/schema.go
+++ b/internal/config/schema.go
@@ -117,18 +117,21 @@ type writableReading struct {
}
type writableSpeech struct {
- HardwareDevice *string `toml:"hardware-device,omitempty"`
- HardwareBaudRate *int `toml:"hardware-baud-rate,omitempty"`
- OutputModule *string `toml:"output-module,omitempty"`
- Enabled *bool `toml:"enabled,omitempty"`
- HistoryLimit *int `toml:"history-limit,omitempty"`
- ServerFactory *string `toml:"server-factory,omitempty"`
- Punctuation *int `toml:"punctuation-style,omitempty"`
- Capitalization *string `toml:"capitalization-style,omitempty"`
- DefaultVoice *Voice `toml:"default-voice,omitempty"`
- UppercaseVoice *Voice `toml:"uppercase-voice,omitempty"`
- HyperlinkVoice *Voice `toml:"hyperlink-voice,omitempty"`
- SystemVoice *Voice `toml:"system-voice,omitempty"`
+ HardwareDevice *string `toml:"hardware-device,omitempty"`
+ HardwareBaudRate *int `toml:"hardware-baud-rate,omitempty"`
+ OutputModule *string `toml:"output-module,omitempty"`
+ Enabled *bool `toml:"enabled,omitempty"`
+ HistoryLimit *int `toml:"history-limit,omitempty"`
+ ServerFactory *string `toml:"server-factory,omitempty"`
+ Punctuation *int `toml:"punctuation-style,omitempty"`
+ Capitalization *string `toml:"capitalization-style,omitempty"`
+ RaisePitchForCapitals *bool `toml:"raise-pitch-for-capitals,omitempty"`
+ BeepForCapitals *bool `toml:"beep-for-capitals,omitempty"`
+ SayCapForCapitals *bool `toml:"say-cap-for-capitals,omitempty"`
+ DefaultVoice *Voice `toml:"default-voice,omitempty"`
+ UppercaseVoice *Voice `toml:"uppercase-voice,omitempty"`
+ HyperlinkVoice *Voice `toml:"hyperlink-voice,omitempty"`
+ SystemVoice *Voice `toml:"system-voice,omitempty"`
}
type writableBraille struct {
diff --git a/internal/config/settings.go b/internal/config/settings.go
index 7c37100..486de02 100644
--- a/internal/config/settings.go
+++ b/internal/config/settings.go
@@ -145,7 +145,10 @@ type Settings struct {
Pronunciations []PronunciationEntry
TextAttributes TextAttributeSettings
VerbalizePunctuationStyle int
- CapitalizationStyle string
+ CapitalizationStyle string // Compatibility with older settings and the remote API.
+ RaisePitchForCapitals bool
+ BeepForCapitals bool
+ SayCapForCapitals bool
KeyBindings []input.BindingOverride
KeyboardLayout int
CthulhuModifierKeys []string
diff --git a/internal/config/voices.go b/internal/config/voices.go
index 27aa6f0..7fa9a33 100644
--- a/internal/config/voices.go
+++ b/internal/config/voices.go
@@ -5,7 +5,7 @@
package config
// EffectiveVoice resolves a sparse voice over Default. Uppercase retains the
-// established pitch of 7 unless the user explicitly chooses another pitch.
+// established automatic pitch unless overridden or capital pitch is disabled.
func EffectiveVoice(settings Settings, kind string) Voice {
voice := cloneVoice(settings.DefaultVoice)
var override Voice
@@ -15,8 +15,13 @@ func EffectiveVoice(settings Settings, kind string) Voice {
if voice.AveragePitch != nil {
pitch = min(9, max(pitch, *voice.AveragePitch+2))
}
- voice.AveragePitch = &pitch
+ if settings.RaisePitchForCapitals {
+ voice.AveragePitch = &pitch
+ }
override = settings.UppercaseVoice
+ if !settings.RaisePitchForCapitals {
+ override.AveragePitch = nil
+ }
case "hyperlink":
override = settings.HyperlinkVoice
case "system":
diff --git a/internal/preferences/native.c b/internal/preferences/native.c
index 08ba9b3..ea84518 100644
--- a/internal/preferences/native.c
+++ b/internal/preferences/native.c
@@ -205,8 +205,8 @@ int prefs_init(const char *xml) {
gtk_entry_set_completion(GTK_ENTRY(prefs_widget("hardwareDeviceEntry")), completion);
g_object_unref(completion);
g_object_unref(devices);
- const char *labelled[] = {"rate", "pitch", "volume", "capitalization"};
- for (int i = 0; i < 4; i++) {
+ const char *labelled[] = {"rate", "pitch", "volume"};
+ for (unsigned i = 0; i < G_N_ELEMENTS(labelled); i++) {
gchar *id = g_strconcat(labelled[i], "_label", NULL);
name_widget(labelled[i], gtk_label_get_text(GTK_LABEL(prefs_widget(id))));
g_free(id);
@@ -226,7 +226,6 @@ int prefs_init(const char *xml) {
{"speechFamilies", "Person"},
{"speechHistoryLimitSpinButton", "Speech history entries"},
{"voiceTypesCombo", "Voice type"},
- {"capitalization", "Capitalization style"},
{"hardwareDeviceEntry", "Serial device"},
{"hardwareBaudCombo", "Serial baud rate"},
{"contractionTableCombo", "Contraction table"},
diff --git a/internal/preferences/preferences.ui b/internal/preferences/preferences.ui
index 8b56bcb..83b6b03 100644
--- a/internal/preferences/preferences.ui
+++ b/internal/preferences/preferences.ui
@@ -123,8 +123,11 @@
092
010
110
- 011
- 111
+ 0112
TrueSerial device path; completion offers detected devices115
Serial _device:TruehardwareDeviceEntry1015
Serial baud rate:hardwareBaudCombo1017
diff --git a/internal/preferences/protocol.go b/internal/preferences/protocol.go
index 6c2b425..6bbfab3 100644
--- a/internal/preferences/protocol.go
+++ b/internal/preferences/protocol.go
@@ -76,7 +76,9 @@ type Values struct {
SystemVoice config.Voice
VoiceType string // Preview selection; never persisted as reader state.
Punctuation int
- Capitalization string
+ RaisePitchForCapitals bool
+ BeepForCapitals bool
+ SayCapForCapitals bool
Reading config.ReadingSettings
SpeakDescription bool
}
diff --git a/internal/preferences/protocol_test.go b/internal/preferences/protocol_test.go
index 01b1099..ebde143 100644
--- a/internal/preferences/protocol_test.go
+++ b/internal/preferences/protocol_test.go
@@ -140,3 +140,20 @@ func TestKeyboardValuesMergeAndOwnership(t *testing.T) {
t.Fatalf("cannot disable all modifiers: %v", err)
}
}
+
+func TestCapitalCheckboxesRoundTrip(t *testing.T) {
+ for mask := 0; mask < 8; mask++ {
+ settings := config.DefaultSettings()
+ values := FromSettings(settings)
+ values.RaisePitchForCapitals = mask&1 != 0
+ values.BeepForCapitals = mask&2 != 0
+ values.SayCapForCapitals = mask&4 != 0
+ merged, err := values.Merge(settings)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := FromSettings(merged); !reflect.DeepEqual(got, values) {
+ t.Fatalf("mask %d did not round trip", mask)
+ }
+ }
+}
diff --git a/internal/preferences/speech.go b/internal/preferences/speech.go
index 3f2b919..a099d9a 100644
--- a/internal/preferences/speech.go
+++ b/internal/preferences/speech.go
@@ -306,7 +306,9 @@ func set_speech_numbers(values Values, inherited config.Voice) {
func set_speech_presentation(values Values) {
setChoice("punctuation", values.Punctuation)
- setCombo("capitalization", map[string]int{"none": 0, "spell": 1, "icon": 2}[values.Capitalization])
+ setCheck("raisePitchForCapitals", values.RaisePitchForCapitals)
+ setCheck("beepForCapitals", values.BeepForCapitals)
+ setCheck("sayCapForCapitals", values.SayCapForCapitals)
setReading(values)
}
@@ -357,17 +359,15 @@ func (session *uiSession) get_speech_values(values *Values) error {
values.HardwareSpeechBaudRate = hardwareBaudRates[indexBaud]
values.SpeechOutputModule = session.selectedSpeechModule()
values.Punctuation = getChoice("punctuation")
- index := getCombo("capitalization")
- if index < 0 || index > 2 {
- return errors.New("Choose a capitalization style.")
- }
- values.Capitalization = []string{"none", "spell", "icon"}[index]
+ values.RaisePitchForCapitals = getCheck("raisePitchForCapitals")
+ values.BeepForCapitals = getCheck("beepForCapitals")
+ values.SayCapForCapitals = getCheck("sayCapForCapitals")
return nil
}
func (session *uiSession) load_voice_editor() {
voice := session.voiceDrafts[session.activeVoiceType]
- settings := config.Settings{DefaultVoice: session.voiceDrafts[0], UppercaseVoice: session.voiceDrafts[1], HyperlinkVoice: session.voiceDrafts[2], SystemVoice: session.voiceDrafts[3]}
+ 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))
diff --git a/internal/preferences/values.go b/internal/preferences/values.go
index e5ace3a..d7d6fcf 100644
--- a/internal/preferences/values.go
+++ b/internal/preferences/values.go
@@ -30,7 +30,10 @@ func FromSettings(settings config.Settings) Values {
ProgressBar: settings.ProgressBar,
RoleSoundPresentation: settings.RoleSoundPresentation,
Rate: settings.DefaultVoice.Rate, Pitch: settings.DefaultVoice.AveragePitch,
- Volume: settings.DefaultVoice.Gain, Punctuation: settings.VerbalizePunctuationStyle, Capitalization: settings.CapitalizationStyle,
+ Volume: settings.DefaultVoice.Gain, Punctuation: settings.VerbalizePunctuationStyle,
+ RaisePitchForCapitals: settings.RaisePitchForCapitals,
+ BeepForCapitals: settings.BeepForCapitals,
+ SayCapForCapitals: settings.SayCapForCapitals,
SpeechOutputModule: settings.SpeechOutputModule,
SpeechServerFactory: settings.SpeechServerFactory,
HardwareSpeechDevice: settings.HardwareSpeechDevice,
@@ -126,7 +129,10 @@ func (values Values) Merge(settings config.Settings) (config.Settings, error) {
settings.HyperlinkVoice = values.HyperlinkVoice
settings.SystemVoice = values.SystemVoice
settings.VerbalizePunctuationStyle = values.Punctuation
- settings.CapitalizationStyle = values.Capitalization
+ settings.CapitalizationStyle = "none"
+ settings.RaisePitchForCapitals = values.RaisePitchForCapitals
+ settings.BeepForCapitals = values.BeepForCapitals
+ settings.SayCapForCapitals = values.SayCapForCapitals
settings.Reading = values.Reading
settings.SpeakDescription = values.SpeakDescription
return settings, config.Validate(settings)
diff --git a/internal/sound/progress.go b/internal/sound/progress.go
index fb620d7..df75078 100644
--- a/internal/sound/progress.go
+++ b/internal/sound/progress.go
@@ -33,12 +33,17 @@ func progress_percentage(cue string) (float64, bool) {
// Reference: nvaccess/nvda a4a89720ccbf3973a0e1e50a76dd8bff38db3d15,
// source/NVDAObjects/behaviors.py and nvdaHelper/local/beeps.cpp.
func progress_tone_wav(percentage float64) []byte {
+ return nvda_tone_wav(110*math.Pow(2, percentage/25), 40)
+}
+
+// nvda_tone_wav also supplies the capital-letter beep (2000 Hz, 50 ms).
+func nvda_tone_wav(hz float64, milliseconds int) []byte {
const sampleRate = 44100
- frequency := float64(float32(110 * math.Pow(2, percentage/25)))
+ frequency := float64(float32(hz))
// sampleRate/hz is evaluated as a float in NVDA's C++ generator.
cycleLength := float64(float32(sampleRate) / float32(frequency))
samplesPerCycle := int(cycleLength)
- sampleCount := int(0.040 / (1.0 / sampleRate))
+ sampleCount := int((float64(milliseconds) / 1000) / (1.0 / sampleRate))
sampleCount += samplesPerCycle - sampleCount%samplesPerCycle
dataSize := sampleCount * 4
buffer := bytes.NewBuffer(make([]byte, 0, 44+dataSize))
diff --git a/internal/sound/speech_beep.go b/internal/sound/speech_beep.go
index dd1fc1e..a549d13 100644
--- a/internal/sound/speech_beep.go
+++ b/internal/sound/speech_beep.go
@@ -10,15 +10,26 @@ import (
)
var speechBeepWAV = tone_wav(440, 40*time.Millisecond, 0.35, 5*time.Millisecond)
+var capitalBeepWAV = nvda_tone_wav(2000, 50)
// SpeechBeep replaces an utterance, independently of role-sound enablement or
// theme availability. The speech worker owns its ordering and cancellation;
// sound preferences still supply the output sink and volume.
func (service *Service) SpeechBeep(ctx context.Context) error {
+ return service.play_speech_beep(ctx, speechBeepWAV)
+}
+
+// CapitalBeep is ordered and canceled by the speech worker, just like speech.
+// It uses sound volume and sink, independently of the role sound theme.
+func (service *Service) CapitalBeep(ctx context.Context) error {
+ return service.play_speech_beep(ctx, capitalBeepWAV)
+}
+
+func (service *Service) play_speech_beep(ctx context.Context, data []byte) error {
service.mutex.Lock()
player, volume := service.player, service.volume
service.mutex.Unlock()
beepContext, cancelBeep := context.WithTimeout(ctx, 3*time.Second)
defer cancelBeep()
- return player.Play(beepContext, speechBeepWAV, volume, 0)
+ return player.Play(beepContext, data, volume, 0)
}
diff --git a/internal/sound/speech_beep_test.go b/internal/sound/speech_beep_test.go
index a813f41..a15e2be 100644
--- a/internal/sound/speech_beep_test.go
+++ b/internal/sound/speech_beep_test.go
@@ -6,7 +6,10 @@ package sound
import (
"context"
+ "crypto/sha256"
+ "encoding/binary"
"errors"
+ "fmt"
"log/slog"
"testing"
"time"
@@ -26,3 +29,23 @@ func TestSpeechBeepDecodesWithRoleSoundsDisabled(t *testing.T) {
t.Fatalf("canceled beep = %v", err)
}
}
+
+func TestCapitalBeepMatchesNVDAAndDecodes(t *testing.T) {
+ // PCM from NVDA's C++ generateBeep(2000, 50, 50, 50), compiled separately.
+ if got := fmt.Sprintf("%x", sha256.Sum256(capitalBeepWAV[44:])); got != "3385e0c7e50f2b4abd8da47606999f27b651e999bcc04ba853e3a7338f469367" {
+ t.Fatalf("capital beep differs from NVDA: %s", got)
+ }
+ if binary.LittleEndian.Uint32(capitalBeepWAV[24:]) != 44100 || binary.LittleEndian.Uint16(capitalBeepWAV[22:]) != 2 {
+ t.Fatal("capital beep must be 44100 Hz stereo")
+ }
+ service := New(slog.New(slog.DiscardHandler), NativePlayer{Sink: "test"}, "none", 0.5)
+ service.Configure(Configuration{Enabled: false, Theme: "none", Sink: "test", Volume: 0.4})
+ if err := service.CapitalBeep(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ if err := service.CapitalBeep(ctx); !errors.Is(err, context.Canceled) {
+ t.Fatalf("canceled capital beep: %v", err)
+ }
+}
diff --git a/testdata/parity-ledger.csv b/testdata/parity-ledger.csv
index 751723e..dc58098 100644
--- a/testdata/parity-ledger.csv
+++ b/testdata/parity-ledger.csv
@@ -38,7 +38,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.,,Pending CancelSpeech interruption and subsequent-delivery listening acceptance,,CancelSpeech uses the asynchronous owned speech-worker interruption path and returns false when speech is unavailable. Like Python it does not cancel sound output.
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 accepted all currently implemented terminal flat-review behavior on physical Xorg on 2026-09-23; broader application listening remains pending,Pending braille reading gate,Basic previous/current/next line word and code-point character commands are wired to owned window layout and the existing review worker. Window boundaries stop silently as in Python. Capture limits are reported explicitly. Native GTK text navigation and edit recovery pass. Viewport seeking 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.md. Parity review corrections and remaining physical gates: docs/testing/parity-review-follow-up.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,in-progress,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,,Pending human listening for immediate rate pitch and volume previews plus punctuation and capitalization,,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. Explicitly selected legacy files remain session-only and cannot be overwritten. Advanced preferences and importer remain pending.
+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,in-progress,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,,Pending human listening for immediate rate pitch and volume previews plus punctuation and independent capital cues,,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; live listening and Braille remain pending. Explicitly selected legacy files remain session-only and cannot be overwritten. Advanced preferences and importer remain pending.
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.,,Pending navigation and listening gate,,Exact Unicode offsets and owned ranges now include captured caret offsets. Text caret and geometry events make cached text stale without removing identities. Maximum 4096 characters and 256 lines or ranges per object and 1 MiB per tree. Oversized objects remain limited with no partial text. Basic zones and review commands now consume this data; large-document viewport seeking and physical listening remain pending.
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,in-progress,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,,Pending human listening,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.
diff --git a/tests/native_clipboard_preferences.py b/tests/native_clipboard_preferences.py
index 80f8516..b94f517 100644
--- a/tests/native_clipboard_preferences.py
+++ b/tests/native_clipboard_preferences.py
@@ -180,7 +180,7 @@ def inside():
"Values": {"KeyboardLayout": 1, "CthulhuModifierKeys": ["KP_Insert"],
"TimeFormat": "24-hour", "DateFormat": "numbers-mdy",
"SpeechHistoryLimit": 50, "Punctuation": 1,
- "Capitalization": "none"}})
+ "RaisePitchForCapitals": True}})
wait_for(lambda: find_window("Cthulhu Preferences for game.exe"), "chosen application preferences missing")
focus_window("Cthulhu Preferences for game.exe")
key("Escape")
diff --git a/tests/native_preferences.py b/tests/native_preferences.py
index 01386e7..b63b302 100644
--- a/tests/native_preferences.py
+++ b/tests/native_preferences.py
@@ -80,7 +80,8 @@ def inside():
"SpeechHistoryLimit": 50,
"Reading": {"AutomaticallyReadTitleChanges": True},
"KeyboardLayout": 1, "CthulhuModifierKeys": ["Insert", "KP_Insert"],
- "Punctuation": 1, "Capitalization": "none",
+ "Punctuation": 1, "RaisePitchForCapitals": True,
+ "BeepForCapitals": False, "SayCapForCapitals": False,
"TimeFormat": "24-hour", "DateFormat": "ymd",
"EnableSound": True, "SoundVolume": 0.5,
"SpeechVerbosityLevel": 1,
@@ -643,6 +644,46 @@ def inside():
assert process.wait(timeout=5) == 0
print("PASS: hardware system/synth/device/baud, Apply, software restore, serial keyboard navigation, Cancel")
return
+ if os.environ.get("CTHULHU_PREFS_CAPITALS_ONLY") == "1":
+ focus(named("Desktop"))
+ for page in ("Sounds", "Speech"):
+ key("ctrl+Page_Down")
+ wait_for(lambda page=page: named(page).getState().contains(pyatspi.STATE_SELECTED), page + " missing")
+ labels = ("Raise pitch for capital letters", "Beep for capital letters", "Say “cap” before capital letters")
+ fields = ("RaisePitchForCapitals", "BeepForCapitals", "SayCapForCapitals")
+ checks = [named(label) for label in labels]
+ assert all(check.getRole() == pyatspi.ROLE_CHECK_BOX for check in checks)
+ assert checks[0].getState().contains(pyatspi.STATE_CHECKED)
+ assert not any(check.getState().contains(pyatspi.STATE_CHECKED) for check in checks[1:])
+ focus(checks[0])
+ key("shift+Tab")
+ previous = next(n for n in nodes if n.getState().contains(pyatspi.STATE_FOCUSED))
+ assert previous.getRole() == pyatspi.ROLE_SLIDER and "Rate" in previous.name, previous.name
+ key("Tab")
+ for check in checks:
+ wait_for(lambda check=check: check.getState().contains(pyatspi.STATE_FOCUSED), "capital checkbox Tab order")
+ key("Tab")
+ following = next(n for n in nodes if n.getState().contains(pyatspi.STATE_FOCUSED))
+ assert following not in checks
+ for check in reversed(checks):
+ key("shift+Tab")
+ wait_for(lambda check=check: check.getState().contains(pyatspi.STATE_FOCUSED), "capital checkbox Shift+Tab order")
+ for mask in range(8):
+ for bit, check in enumerate(checks):
+ if check.getState().contains(pyatspi.STATE_CHECKED) != bool(mask & (1 << bit)):
+ focus(check); key("space")
+ focus(named("Apply")); key("space")
+ applied = request()
+ assert not applied.get("Preview"), applied
+ assert all(applied["Values"][field] == bool(mask & (1 << bit)) for bit, field in enumerate(fields)), applied
+ send(dict(initial, Values=applied["Values"], Message="Capital letter settings saved."))
+ wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "Apply busy")
+ for bit, check in enumerate(checks):
+ assert check.getState().contains(pyatspi.STATE_CHECKED) == bool(mask & (1 << bit))
+ focus(named("Cancel")); key("space")
+ assert process.wait(timeout=5) == 0
+ print("PASS: capital checkboxes, default pitch, Tab/Shift+Tab, eight independent combinations, Apply/reload")
+ return
if os.environ.get("CTHULHU_PREFS_VOICE_TYPES_ONLY") == "1":
focus(named("Desktop"))
for page in ("Sounds", "Speech"):
@@ -1072,23 +1113,25 @@ def inside():
selected_time = time_selection.getSelectedChild(0).name
assert time_format.description == selected_time, (
"combo value missing", time_format.description, selected_time)
- capitalization = next(n for n in nodes if n.name == "Capitalization style" and
- n.getRole() == pyatspi.ROLE_COMBO_BOX)
- capitalization_selection = capitalization.querySelection()
- assert capitalization.description == capitalization_selection.getSelectedChild(0).name
+ capital_pitch = named("Raise pitch for capital letters")
+ capital_beep = named("Beep for capital letters")
+ capital_cap = named("Say “cap” before capital letters")
+ assert capital_pitch.getState().contains(pyatspi.STATE_CHECKED)
+ assert not capital_beep.getState().contains(pyatspi.STATE_CHECKED)
+ assert not capital_cap.getState().contains(pyatspi.STATE_CHECKED)
date_format = next(n for n in nodes if n.name == "Date format" and
n.getRole() == pyatspi.ROLE_COMBO_BOX)
- changed = dict(initial, Values=dict(initial["Values"], Capitalization="spell", TimeFormat="12-hour", DateFormat="dmy"))
+ changed = dict(initial, Values=dict(initial["Values"], BeepForCapitals=True, SayCapForCapitals=True, TimeFormat="12-hour", DateFormat="dmy"))
send(changed)
- wait_for(lambda: capitalization.description == "Spell",
- "changed combo value was not exposed")
- assert capitalization.querySelection().getSelectedChild(0).name == "Spell"
+ wait_for(lambda: capital_cap.getState().contains(pyatspi.STATE_CHECKED),
+ "changed capital checkbox value was not exposed")
+ assert capital_beep.getState().contains(pyatspi.STATE_CHECKED)
wait_for(lambda: time_format.description == time_selection.getSelectedChild(0).name,
"time format did not initialize")
assert date_format.description == date_format.querySelection().getSelectedChild(0).name
send(initial)
- wait_for(lambda: capitalization.description == "None",
- "restored combo value was not exposed")
+ wait_for(lambda: not capital_cap.getState().contains(pyatspi.STATE_CHECKED),
+ "restored capital checkbox value was not exposed")
progress = named("Progress bar output")
assert progress.description == "Beep", progress.description
background = named("Report background progress bars")