From 6ee0454fbe32fef0a91dd0f5993360a8405a5b45 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sun, 25 Oct 2026 19:35:55 -0400 Subject: [PATCH] Speech settings updated, no large decimals for settings. Also changing with quick settings keys now applies across window changes. --- README.md | 7 + .../Arch-Linux/cthulhu-git/PKGBUILD | 2 +- docs/testing/speech-preferences.md | 6 + internal/app/preferences.go | 1 + internal/app/preferences_application.go | 4 +- internal/app/preferences_application_test.go | 4 +- internal/app/preferences_apply.go | 4 + internal/app/speech_settings.go | 54 ++++- internal/app/speech_settings_test.go | 210 ++++++++++++++++++ internal/config/profile_mapping.go | 1 + internal/config/voices.go | 16 ++ internal/config/voices_test.go | 65 ++++++ internal/preferences/echo.go | 3 +- internal/preferences/protocol_test.go | 33 +++ internal/preferences/speech.go | 2 +- internal/preferences/values.go | 5 +- tests/native_preferences.py | 4 + 17 files changed, 406 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 6cdfa6f..9e1f5b9 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,13 @@ Native X11 grab-window regression: ## Speech preferences +Use Cthulhu+Left/Right to select a quick speech setting and Cthulhu+Up/Down +to adjust it. These changes follow you across windows, including application +profiles, for the current session. Cthulhu+Control+Shift+S saves the choices to +the global profile. Switching windows does not save or discard them. Applying +preferences replaces the temporary quick speech choices; restarting discards +unsaved changes. Saved application overrides still apply on the next startup. + 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. diff --git a/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD b/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD index be1f650..8db9bc3 100644 --- a/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD +++ b/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD @@ -4,7 +4,7 @@ pkgbase=cthulhu-git pkgname=cthulhu-git _pkgname=cthulhu -pkgver=2026.10.25.r562.gf035287 +pkgver=2026.10.25.r566.g7ae2801 pkgrel=1 pkgdesc="Desktop-agnostic screen reader with speech, Braille, and Tentacle support" url="https://git.stormux.org/storm/cthulhu" diff --git a/docs/testing/speech-preferences.md b/docs/testing/speech-preferences.md index 0d5abaf..54ac65f 100644 --- a/docs/testing/speech-preferences.md +++ b/docs/testing/speech-preferences.md @@ -41,3 +41,9 @@ 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. + +Speech numbers use at most one decimal place. After repeated slider changes, +check pitch and volume with the quick speech keys, then save and reopen the +settings. Values such as 4.5 must remain 4.5 in announcements and TOML rather +than exposing floating-point tails. Check all voice types and custom key echo; +untouched inherited values must remain unset. diff --git a/internal/app/preferences.go b/internal/app/preferences.go index afb53fc..98dff3f 100644 --- a/internal/app/preferences.go +++ b/internal/app/preferences.go @@ -46,6 +46,7 @@ type preferencesWorker struct { settings config.Settings globalSettings config.Settings appSettings map[string]config.Settings + quickSpeech quickSpeechSettings activeApp string editApp string editing config.Settings diff --git a/internal/app/preferences_application.go b/internal/app/preferences_application.go index eeddd58..20a5af1 100644 --- a/internal/app/preferences_application.go +++ b/internal/app/preferences_application.go @@ -13,7 +13,7 @@ import ( func (worker *preferencesWorker) applicationSettings(application string) (config.Settings, error) { if settings, ok := worker.appSettings[application]; ok { - return settings, nil + return worker.quickSpeech.apply(settings), nil } store, err := config.NewStore(worker.globalSettings.Path) if err != nil { @@ -32,7 +32,7 @@ func (worker *preferencesWorker) applicationSettings(application string) (config } settings = config.RebaseApplicationSettings(persistedGlobal, worker.globalSettings, settings) worker.appSettings[application] = settings - return settings, nil + return worker.quickSpeech.apply(settings), nil } func (worker *preferencesWorker) switchApplication(ctx context.Context, application string) { diff --git a/internal/app/preferences_application_test.go b/internal/app/preferences_application_test.go index 4ace5f4..526683a 100644 --- a/internal/app/preferences_application_test.go +++ b/internal/app/preferences_application_test.go @@ -70,8 +70,8 @@ func TestApplicationPreferencesSwitchAndPersistIndependently(t *testing.T) { if err != nil || !mapped || appLoaded.TimeFormat != "24-hour" || appLoaded.DateFormat != "numbers-mdy" || appLoaded.DefaultVoice.Rate == nil || !appLoaded.Reading.AutomaticallyReadClipboardChanges { t.Fatalf("application profile not saved: %+v, %t, %v", appLoaded, mapped, err) } - if loaded.DefaultVoice.Rate != nil || loaded.Reading.AutomaticallyReadClipboardChanges { - t.Fatal("application speech shortcut overwrote global voice") + if loaded.DefaultVoice.Rate == nil || *loaded.DefaultVoice.Rate != 51 || loaded.Reading.AutomaticallyReadClipboardChanges { + t.Fatal("speech shortcut did not save globally or leaked unrelated application settings") } restarted := newPreferencesWorker(slog.New(slog.DiscardHandler), loaded, settingsUpdateFunc(func(context.Context, speech.Settings) error { return nil })) diff --git a/internal/app/preferences_apply.go b/internal/app/preferences_apply.go index f6e05c0..90ae05f 100644 --- a/internal/app/preferences_apply.go +++ b/internal/app/preferences_apply.go @@ -68,6 +68,10 @@ func (worker *preferencesWorker) applyNext(ctx context.Context, next config.Sett if err := worker.applyRuntime(ctx, next); err != nil { return worker.response(err.Error(), false) } + // An explicit preferences Apply replaces the session's quick speech choices. + if worker.editingActive { + worker.quickSpeech = quickSpeechSettings{} + } if worker.editingActive { worker.editing = next } diff --git a/internal/app/speech_settings.go b/internal/app/speech_settings.go index 8169f5a..035d74a 100644 --- a/internal/app/speech_settings.go +++ b/internal/app/speech_settings.go @@ -47,13 +47,9 @@ func (worker *preferencesWorker) handleSpeechCommand(ctx context.Context, comman ctx, cancel := context.WithTimeout(ctx, 6*time.Second) defer cancel() if command == input.SaveSpeechSettings { - store, err := config.NewStore(worker.settings.Path) + store, err := config.NewStore(worker.globalSettings.Path) if err == nil { - if worker.activeApp != "" { - err = store.SaveForApplication(worker.activeApp, worker.settings) - } else { - err = store.Save(worker.settings) - } + err = store.Save(worker.globalSettings) } if err != nil { worker.logger.Warn("save speech settings", "error", err) @@ -115,7 +111,7 @@ func (worker *preferencesWorker) handleSpeechCommand(ctx context.Context, comman field := fields[worker.speechSetting] value := defaults[worker.speechSetting] if *field != nil { - value = **field + value = math.Round(**field*10) / 10 } if adjust { value = math.Round((value+float64(delta)*steps[worker.speechSetting])*10) / 10 @@ -227,6 +223,13 @@ func (worker *preferencesWorker) handleSpeechCommand(ctx context.Context, comman value := tentaclewire.SpeechFromSettings(next) worker.speechOverride = &value } else { + worker.quickSpeech.settings = next + worker.quickSpeech.adjusted[worker.speechSetting] = true + global := copyQuickSpeechSetting(worker.globalSettings, next, worker.speechSetting) + for application, settings := range worker.appSettings { + worker.appSettings[application] = config.RebaseApplicationSettings(worker.globalSettings, global, settings) + } + worker.globalSettings = global worker.settings = next } if worker.live != nil { @@ -236,6 +239,43 @@ func (worker *preferencesWorker) handleSpeechCommand(ctx context.Context, comman worker.announceSpeechSetting(message) } +// The preferences worker owns these session-only choices. Apply them after +// resolving an application profile so window switches cannot undo a shortcut. +// Explicit preferences Apply or reader exit ends this override; saving does not. +type quickSpeechSettings struct { + settings config.Settings + adjusted [5]bool +} + +func (quick quickSpeechSettings) apply(settings config.Settings) config.Settings { + for index, adjusted := range quick.adjusted { + if adjusted { + settings = copyQuickSpeechSetting(settings, quick.settings, index) + } + } + return settings +} + +func copyQuickSpeechSetting(target, source config.Settings, index int) config.Settings { + switch index { + case 0: + target.DefaultVoice.Rate = source.DefaultVoice.Rate + case 1: + target.DefaultVoice.AveragePitch = source.DefaultVoice.AveragePitch + case 2: + target.DefaultVoice.Gain = source.DefaultVoice.Gain + case 3, 4: + // A voice belongs to its synthesizer, and a hardware synthesizer also + // needs its transport. Keep this selection valid across app profiles. + target.SpeechServerFactory = source.SpeechServerFactory + target.SpeechOutputModule = source.SpeechOutputModule + target.HardwareSpeechDevice = source.HardwareSpeechDevice + target.HardwareSpeechBaudRate = source.HardwareSpeechBaudRate + target.DefaultVoice.Family = source.DefaultVoice.Family + } + return target +} + // Keep eSpeak NG's language/variant cross product manageable in quick settings. // Prefer an exact dialect, then a language-only voice if that dialect is absent. // Unknown languages retain the catalog so the user can still select a voice. diff --git a/internal/app/speech_settings_test.go b/internal/app/speech_settings_test.go index aa96a52..972cb12 100644 --- a/internal/app/speech_settings_test.go +++ b/internal/app/speech_settings_test.go @@ -7,11 +7,14 @@ import ( "context" "errors" "log/slog" + "os" "path/filepath" + "reflect" "testing" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/preferences" "git.stormux.org/storm/cthulhu/internal/speech" ) @@ -80,6 +83,7 @@ func TestSpeechShortcutsApplySaveAndFailure(t *testing.T) { t.Fatalf("saved settings did not retain speech choices: %+v", saved.DefaultVoice) } worker.settings.Path = t.TempDir() + worker.globalSettings.Path = worker.settings.Path command(input.SaveSpeechSettings) if message != "Speech settings could not be saved" { t.Fatal(message) @@ -91,6 +95,212 @@ func TestSpeechShortcutsApplySaveAndFailure(t *testing.T) { } } +func TestQuickSpeechFollowsWindowSwitchesAndSavesGlobally(t *testing.T) { + for index, name := range []string{"rate", "pitch", "volume", "synthesizer", "voice"} { + t.Run(name, func(t *testing.T) { + global := config.DefaultSettings() + global.Path = filepath.Join(t.TempDir(), "settings.toml") + rate, pitch, gain := 50.0, 5.0, 5.0 + global.DefaultVoice = config.Voice{Rate: &rate, AveragePitch: &pitch, Gain: &gain, + Family: &config.VoiceFamily{Name: "one", Lang: "en"}} + global.SpeechOutputModule = "alpha" + application := global + appRate, appPitch, appGain := 25.0, 2.0, 2.0 + application.DefaultVoice = config.Voice{Rate: &appRate, AveragePitch: &appPitch, Gain: &appGain, + Family: &config.VoiceFamily{Name: "three", Lang: "en"}} + application.SpeechOutputModule = "beta" + application.TimeFormat = "24-hour" + store, err := config.NewStore(global.Path) + if err != nil { + t.Fatal(err) + } + if err := store.Save(global); err != nil { + t.Fatal(err) + } + for _, app := range []string{"Cached Application", "New Application"} { + if err := store.SaveForApplication(app, application); err != nil { + t.Fatal(err) + } + } + original, err := os.ReadFile(global.Path) + if err != nil { + t.Fatal(err) + } + var applied speech.Settings + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), global, + settingsUpdateFunc(func(_ context.Context, next speech.Settings) error { applied = next; return nil })) + worker.live = &liveSettings{} + worker.live.Store(global) + worker.catalog = speech.Catalog{"alpha": {{Name: "one", Language: "en"}, {Name: "two", Language: "en"}}, + "beta": {{Name: "three", Language: "en"}}} + ctx := t.Context() + worker.switchApplication(ctx, "Cached Application") + worker.switchApplication(ctx, "Presentation") + worker.speechSetting = index + worker.handleSpeechCommand(ctx, input.IncreaseSpeechSetting) + adjusted := worker.settings + check := func(base config.Settings) { + t.Helper() + switch index { + case 0: + value := 51.0 + base.DefaultVoice.Rate = &value + case 1: + value := 5.1 + base.DefaultVoice.AveragePitch = &value + case 2: + value := 5.1 + base.DefaultVoice.Gain = &value + case 3: + base.SpeechOutputModule = "beta" + base.DefaultVoice.Family = &config.VoiceFamily{Name: "three", Lang: "en"} + case 4: + base.SpeechOutputModule = "alpha" + base.DefaultVoice.Family = &config.VoiceFamily{Name: "two", Lang: "en"} + } + if !reflect.DeepEqual(speechSettingsFromConfig(worker.settings), speechSettingsFromConfig(base)) || + !reflect.DeepEqual(speechSettingsFromConfig(worker.live.Load()), speechSettingsFromConfig(base)) || + !reflect.DeepEqual(applied, speechSettingsFromConfig(base)) { + t.Fatalf("speech choice lost in %q: got %+v, want %+v", worker.activeApp, worker.settings.DefaultVoice, base.DefaultVoice) + } + if worker.settings.TimeFormat != base.TimeFormat { + t.Fatal("quick speech changed unrelated application preferences") + } + } + check(global) + for _, app := range []string{"Cached Application", "New Application", "Other Application", "Presentation"} { + worker.switchApplication(ctx, app) + base := global + if app == "Cached Application" || app == "New Application" { + base = application + } + check(base) + } + contents, err := os.ReadFile(global.Path) + if err != nil || string(contents) != string(original) { + t.Fatalf("unsaved shortcuts wrote settings: %v", err) + } + // Saving while an application profile is active must update the global + // speech choice without copying its other preferences into the default. + worker.switchApplication(ctx, "Cached Application") + worker.handleSpeechCommand(ctx, input.SaveSpeechSettings) + loaded, err := store.Load() + if err != nil || !reflect.DeepEqual(speechSettingsFromConfig(loaded), speechSettingsFromConfig(adjusted)) || loaded.TimeFormat != global.TimeFormat { + t.Fatalf("global speech settings were not saved: %+v, %v", loaded, err) + } + appLoaded, mapped, err := store.LoadForApplication("Cached Application") + if err != nil || !mapped || !reflect.DeepEqual(speechSettingsFromConfig(appLoaded), speechSettingsFromConfig(application)) { + t.Fatalf("save shortcut overwrote application profile: %+v, %v", appLoaded, err) + } + if _, mapped, err := store.LoadForApplication("Presentation"); err != nil || mapped { + t.Fatalf("quick speech created an application profile: %t, %v", mapped, err) + } + worker.switchApplication(ctx, "Presentation") + check(global) + worker.switchApplication(ctx, "Cached Application") + check(application) + restarted := newPreferencesWorker(slog.New(slog.DiscardHandler), loaded, + settingsUpdateFunc(func(_ context.Context, next speech.Settings) error { applied = next; return nil })) + restarted.switchApplication(ctx, "Presentation") + if !reflect.DeepEqual(speechSettingsFromConfig(restarted.settings), speechSettingsFromConfig(adjusted)) { + t.Fatal("saved global speech did not survive restart") + } + }) + } +} + +func TestQuickSpeechFailureAndPreferencesReplacement(t *testing.T) { + global := config.DefaultSettings() + global.Path = filepath.Join(t.TempDir(), "settings.toml") + rate := 50.0 + global.DefaultVoice.Rate = &rate + var updateError error + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), global, + settingsUpdateFunc(func(context.Context, speech.Settings) error { return updateError })) + application := global + appRate := 25.0 + application.DefaultVoice.Rate = &appRate + worker.appSettings["Example Editor"] = application + ctx := t.Context() + worker.switchApplication(ctx, "Presentation") + worker.handleSpeechCommand(ctx, input.IncreaseSpeechSetting) + worker.handleSpeechCommand(ctx, input.SelectNextSpeechSetting) + worker.handleSpeechCommand(ctx, input.IncreaseSpeechSetting) + message := "" + worker.announce = func(text string) { message = text } + updateError = errors.New("backend unavailable") + worker.handleSpeechCommand(ctx, input.IncreaseSpeechSetting) + updateError = nil + path := worker.globalSettings.Path + worker.globalSettings.Path = t.TempDir() + worker.handleSpeechCommand(ctx, input.SaveSpeechSettings) + if message != "Speech settings could not be saved" { + t.Fatal(message) + } + worker.globalSettings.Path = path + worker.switchApplication(ctx, "Example Editor") + if *worker.settings.DefaultVoice.Rate != 51 || *worker.settings.DefaultVoice.AveragePitch != 5.1 { + t.Fatal("failed adjustment or save lost prior session choices") + } + if !worker.beginEdit(ctx, "") { + t.Fatal("could not open global preferences") + } + values := preferences.FromSettings(worker.editing) + appliedRate := 60.0 + values.Rate = &appliedRate + updateError = errors.New("backend unavailable") + if response := worker.apply(ctx, preferences.Request{Values: values}); response.Accepted { + t.Fatal("failed preferences apply was accepted") + } + if settings, err := worker.applicationSettings("Example Editor"); err != nil || *settings.DefaultVoice.Rate != 51 { + t.Fatalf("failed preferences apply discarded quick speech: %v", err) + } + updateError = nil + if response := worker.apply(ctx, preferences.Request{Values: values}); !response.Accepted { + t.Fatal(response.Message) + } + if *worker.settings.DefaultVoice.Rate != 25 || *worker.globalSettings.DefaultVoice.Rate != 60 { + t.Fatal("explicit preferences apply did not restore application profile ownership") + } + worker.editingActive = false + worker.switchApplication(ctx, "Presentation") + if *worker.settings.DefaultVoice.Rate != 60 { + t.Fatal("old quick speech choice replaced applied preferences") + } +} + +func TestSpeechShortcutReadsAndSavesRoundedNumbers(t *testing.T) { + settings := config.DefaultSettings() + settings.Path = filepath.Join(t.TempDir(), "settings.toml") + rate, pitch, gain := 89.99999999999999, 4.499999999999998, 4.999999999999999 + settings.DefaultVoice = config.Voice{Rate: &rate, AveragePitch: &pitch, Gain: &gain} + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, nil) + message := "" + worker.announce = func(text string) { message = text } + for index, want := range []string{"Rate 90", "Pitch 4.5", "Volume 5"} { + command := input.SelectNextSpeechSetting + if index == 0 { + command = input.SelectPreviousSpeechSetting + } + worker.handleSpeechCommand(t.Context(), command) + if message != want { + t.Fatalf("got %q, want %q", message, want) + } + } + if _, err := os.Stat(settings.Path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("checking speech settings created a file: %v", err) + } + worker.handleSpeechCommand(t.Context(), input.SaveSpeechSettings) + store, err := config.NewStore(settings.Path) + if err != nil { + t.Fatal(err) + } + saved, err := store.Load() + if err != nil || *saved.DefaultVoice.Rate != 90 || *saved.DefaultVoice.AveragePitch != 4.5 || *saved.DefaultVoice.Gain != 5 { + t.Fatalf("speech numbers were not saved rounded: %+v, %v", saved.DefaultVoice, err) + } +} + func TestSpeechShortcutNumericBounds(t *testing.T) { for index, maximum := range []float64{99, 9, 9} { settings := config.DefaultSettings() diff --git a/internal/config/profile_mapping.go b/internal/config/profile_mapping.go index f78f79d..2c1c488 100644 --- a/internal/config/profile_mapping.go +++ b/internal/config/profile_mapping.go @@ -56,6 +56,7 @@ func RebaseApplicationSettings(oldGlobal, newGlobal, application Settings) Setti } func profileFromSettings(settings Settings) writableProfile { + settings = RoundSpeechValues(settings) return writableProfile{ OCR: writableOCR{Language: stringPointer(settings.OCR.Language), Layout: intPointer(settings.OCR.Layout), Scale: intPointer(settings.OCR.Scale), Grayscale: boolPointer(settings.OCR.Grayscale), Invert: boolPointer(settings.OCR.Invert), BlackWhite: boolPointer(settings.OCR.BlackWhite), Threshold: intPointer(settings.OCR.Threshold), AutoRefresh: boolPointer(settings.OCR.AutoRefresh), RefreshInterval: intPointer(settings.OCR.RefreshInterval), ConfirmClick: boolPointer(settings.OCR.ConfirmClick), CopyFirstResult: boolPointer(settings.OCR.CopyFirstResult), AutoReadAll: boolPointer(settings.OCR.AutoReadAll)}, ProgressBar: writableProgressBar{ diff --git a/internal/config/voices.go b/internal/config/voices.go index 7fa9a33..aa89dd3 100644 --- a/internal/config/voices.go +++ b/internal/config/voices.go @@ -4,6 +4,22 @@ package config +import "math" + +// RoundSpeechValues keeps speech controls and persisted voices at one decimal +// place without changing the caller's values or turning inheritance into values. +func RoundSpeechValues(settings Settings) Settings { + for _, voice := range []*Voice{&settings.DefaultVoice, &settings.UppercaseVoice, &settings.HyperlinkVoice, &settings.SystemVoice, &settings.EchoVoice} { + for _, field := range []**float64{&voice.Rate, &voice.AveragePitch, &voice.Gain} { + if *field != nil { + value := math.Round(**field*10) / 10 + *field = &value + } + } + } + return settings +} + // EffectiveVoice resolves a sparse voice over Default. Uppercase retains the // established automatic pitch unless overridden or capital pitch is disabled. func EffectiveVoice(settings Settings, kind string) Voice { diff --git a/internal/config/voices_test.go b/internal/config/voices_test.go index fc4b3fb..dcef650 100644 --- a/internal/config/voices_test.go +++ b/internal/config/voices_test.go @@ -5,11 +5,76 @@ package config import ( + "os" "path/filepath" "reflect" + "strings" "testing" ) +func TestSpeechNumbersPersistWithOneDecimal(t *testing.T) { + settings := DefaultSettings() + rate, pitch, gain := 61.29999999999999, 4.499999999999998, 4.999999999999999 + settings.DefaultVoice = Voice{Rate: &rate, AveragePitch: &pitch, Gain: &gain} + settings.UppercaseVoice.AveragePitch = &pitch + settings.HyperlinkVoice.Rate = &rate + settings.SystemVoice.Gain = &gain + settings.EchoVoice = settings.DefaultVoice + path := filepath.Join(t.TempDir(), "settings.toml") + store, err := NewStore(path) + if err != nil { + t.Fatal(err) + } + if err := store.Save(settings); err != nil { + t.Fatal(err) + } + loaded, err := store.Load() + if err != nil { + t.Fatal(err) + } + for index, voice := range []Voice{loaded.DefaultVoice, loaded.UppercaseVoice, loaded.HyperlinkVoice, loaded.SystemVoice, loaded.EchoVoice} { + for field, value := range []*float64{voice.Rate, voice.AveragePitch, voice.Gain} { + if value != nil && *value != []float64{61.3, 4.5, 5}[field] { + t.Fatalf("voice %d field %d = %g", index, field, *value) + } + } + } + if loaded.UppercaseVoice.Rate != nil || loaded.HyperlinkVoice.Gain != nil || loaded.SystemVoice.AveragePitch != nil { + t.Fatal("rounding turned inheritance into explicit settings") + } + if *settings.DefaultVoice.AveragePitch != 4.499999999999998 { + t.Fatal("saving mutated caller's voice") + } + contents, err := os.ReadFile(path) + if err != nil || strings.Contains(string(contents), "999999") || !strings.Contains(string(contents), "average-pitch = 4.5") { + t.Fatalf("TOML does not contain rounded decimal values: %v", err) + } + if err := store.SaveForApplication("Example Editor", settings); err != nil { + t.Fatal(err) + } + contents, err = os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + document, _, err := decodeDocument(contents) + if err != nil { + t.Fatal(err) + } + profile := document.Profiles[document.Applications["Example Editor"]] + if profile.Speech.DefaultVoice != nil || profile.KeyEcho.EchoVoice != nil { + t.Fatal("floating-point noise created application voice overrides") + } + appGain := 3.299999999999999 + settings.DefaultVoice.Gain = &appGain + if err := store.SaveForApplication("Example Editor", settings); err != nil { + t.Fatal(err) + } + application, found, err := store.LoadForApplication("Example Editor") + if err != nil || !found || *application.DefaultVoice.Gain != 3.3 { + t.Fatalf("application speech was not rounded: %+v, %v", application.DefaultVoice, err) + } +} + func TestVoiceTypesPersistAndInheritAcrossApplications(t *testing.T) { settings := DefaultSettings() rate, pitch, gain := 62.0, 8.0, 4.0 diff --git a/internal/preferences/echo.go b/internal/preferences/echo.go index 7cca5c5..14017d0 100644 --- a/internal/preferences/echo.go +++ b/internal/preferences/echo.go @@ -11,6 +11,7 @@ package preferences import "C" import ( + "math" "sort" "strings" "unsafe" @@ -142,7 +143,7 @@ func (session *uiSession) getEchoVoice() (config.Voice, string) { {"echoRateScale", &voice.Rate}, {"echoPitchScale", &voice.AveragePitch}, {"echoVolumeScale", &voice.Gain}, } { id := C.CString(item.id) - value := float64(C.prefs_number_value(id)) + value := math.Round(float64(C.prefs_number_value(id))*10) / 10 C.free(unsafe.Pointer(id)) *item.target = &value } diff --git a/internal/preferences/protocol_test.go b/internal/preferences/protocol_test.go index a1b60a8..8b606ca 100644 --- a/internal/preferences/protocol_test.go +++ b/internal/preferences/protocol_test.go @@ -5,12 +5,45 @@ package preferences import ( + "math" "reflect" "testing" "git.stormux.org/storm/cthulhu/internal/config" ) +func TestSpeechValuesMergeRoundsNumbersAndPreservesInheritance(t *testing.T) { + settings := config.DefaultSettings() + values := FromSettings(settings) + rate, pitch, gain := 61.29999999999999, 4.499999999999998, 4.999999999999999 + values.Rate, values.Pitch, values.Volume = &rate, &pitch, &gain + values.UppercaseVoice.AveragePitch = &pitch + values.HyperlinkVoice.Rate = &rate + values.SystemVoice.Gain = &gain + values.EchoVoice = config.Voice{Rate: &rate, AveragePitch: &pitch, Gain: &gain} + merged, err := values.Merge(settings) + if err != nil { + t.Fatal(err) + } + if *merged.DefaultVoice.Rate != 61.3 || *merged.DefaultVoice.AveragePitch != 4.5 || *merged.DefaultVoice.Gain != 5 || + *merged.UppercaseVoice.AveragePitch != 4.5 || *merged.HyperlinkVoice.Rate != 61.3 || *merged.SystemVoice.Gain != 5 || + *merged.EchoVoice.Rate != 61.3 || *merged.EchoVoice.AveragePitch != 4.5 || *merged.EchoVoice.Gain != 5 { + t.Fatal("speech values retained floating-point noise") + } + if merged.UppercaseVoice.Rate != nil || merged.HyperlinkVoice.Gain != nil || merged.SystemVoice.AveragePitch != nil { + t.Fatal("rounding turned inheritance into explicit settings") + } + if pitch != 4.499999999999998 { + t.Fatal("merging mutated caller's values") + } + for _, invalid := range []float64{9.01, -0.01, math.NaN(), math.Inf(1)} { + values.Pitch = &invalid + if _, err := values.Merge(settings); err == nil { + t.Fatalf("rounding hid invalid pitch %g", invalid) + } + } +} + func TestSoundValuesDefaultAndMerge(t *testing.T) { settings := config.DefaultSettings() values := FromSettings(settings) diff --git a/internal/preferences/speech.go b/internal/preferences/speech.go index 184d75f..787f815 100644 --- a/internal/preferences/speech.go +++ b/internal/preferences/speech.go @@ -419,7 +419,7 @@ func (session *uiSession) read_voice_editor() (config.Voice, error) { } { id := C.CString(item.id) edited := C.prefs_number_edited(id) != 0 - value := float64(C.prefs_number_value(id)) + value := math.Round(float64(C.prefs_number_value(id))*10) / 10 C.free(unsafe.Pointer(id)) if !edited { continue diff --git a/internal/preferences/values.go b/internal/preferences/values.go index 3f1f8b8..59951d4 100644 --- a/internal/preferences/values.go +++ b/internal/preferences/values.go @@ -139,5 +139,8 @@ func (values Values) Merge(settings config.Settings) (config.Settings, error) { settings.SayCapForCapitals = values.SayCapForCapitals settings.Reading = values.Reading settings.SpeakDescription = values.SpeakDescription - return settings, config.Validate(settings) + if err := config.Validate(settings); err != nil { + return settings, err + } + return config.RoundSpeechValues(settings), nil } diff --git a/tests/native_preferences.py b/tests/native_preferences.py index a7e850c..9ab90f0 100644 --- a/tests/native_preferences.py +++ b/tests/native_preferences.py @@ -565,6 +565,7 @@ def inside(): values = accept_preview() assert abs(slider.queryValue().currentValue - expected) < 1e-8, (name, arrow, expected) assert abs(values[field] - expected) < 1e-8, (name, arrow, values) + assert values[field] == round(values[field], 1), (name, "precision", values) startValue = expected for bound, arrows in ((0, ("Left", "Down")), (slider.queryValue().maximumValue, ("Right", "Up"))): @@ -597,6 +598,7 @@ def inside(): wait_for(lambda: abs(slider.queryValue().currentValue - expected) < 1e-8, f"{name}: {arrow} did not produce {expected}") assert abs(values["EchoVoice"][field] - expected) < 1e-8, values + assert values["EchoVoice"][field] == round(values["EchoVoice"][field], 1), (name, "precision", values) finalValue = expected for bound, arrows in ((0, ("Left", "Down")), (slider.queryValue().maximumValue, ("Right", "Up"))): @@ -611,8 +613,10 @@ def inside(): assert applied["Save"], applied for field, expected in (("Rate", 49.9), ("Pitch", 4.9), ("Volume", 4.9)): assert abs(applied["Values"][field] - expected) < 1e-8, applied + assert applied["Values"][field] == round(applied["Values"][field], 1), applied for field, expected in (("Rate", 49.9), ("AveragePitch", 4.9), ("Gain", 4.9)): assert abs(applied["Values"]["EchoVoice"][field] - expected) < 1e-8, applied + assert applied["Values"]["EchoVoice"][field] == round(applied["Values"]["EchoVoice"][field], 1), applied send(dict(initial, Values=applied["Values"], Message="Speech settings saved.")) wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "Apply busy") for name, expected in (("Key echo rate", 49.9), ("Key echo pitch", 4.9), ("Key echo volume", 4.9)):