Speech settings updated, no large decimals for settings. Also changing with quick settings keys now applies across window changes.

This commit is contained in:
Storm Dragon committed 2026-10-25 19:35:55 -04:00
1 parent 7ae2801e53
commit 6ee0454fbe
17 files changed
+406 -15

No files matched your search

+7
View File
@@ -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.
@@ -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"
+6
View File
@@ -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.
+1
View File
@@ -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
+2 -2
View File
@@ -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) {
+2 -2
View File
@@ -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 }))
+4
View File
@@ -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
}
+47 -7
View File
@@ -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.
+210
View File
@@ -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()
+1
View File
@@ -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{
+16
View File
@@ -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 {
+65
View File
@@ -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
+2 -1
View File
@@ -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
}
+33
View File
@@ -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)
+1 -1
View File
@@ -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
+4 -1
View File
@@ -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
}
+4
View File
@@ -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)):