Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ee0454fbe |
No files matched your search
@@ -53,6 +53,13 @@ Native X11 grab-window regression:
|
|||||||
|
|
||||||
## Speech preferences
|
## 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
|
Speech preferences let you adjust rate, pitch and volume directly, without
|
||||||
first disabling a default checkbox. Unconfigured values display Speech
|
first disabling a default checkbox. Unconfigured values display Speech
|
||||||
Dispatcher's defaults when available, or rate 50, pitch 5 and volume 5.
|
Dispatcher's defaults when available, or rate 50, pitch 5 and volume 5.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
pkgbase=cthulhu-git
|
pkgbase=cthulhu-git
|
||||||
pkgname=cthulhu-git
|
pkgname=cthulhu-git
|
||||||
_pkgname=cthulhu
|
_pkgname=cthulhu
|
||||||
pkgver=2026.10.25.r562.gf035287
|
pkgver=2026.10.25.r566.g7ae2801
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Desktop-agnostic screen reader with speech, Braille, and Tentacle support"
|
pkgdesc="Desktop-agnostic screen reader with speech, Braille, and Tentacle support"
|
||||||
url="https://git.stormux.org/storm/cthulhu"
|
url="https://git.stormux.org/storm/cthulhu"
|
||||||
|
|||||||
@@ -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
|
labels and values. Check Braille if used. These checks also cover existing
|
||||||
profiles with explicit values; the automated harness does not establish audible
|
profiles with explicit values; the automated harness does not establish audible
|
||||||
speech, physical input or physical Braille acceptance.
|
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.
|
||||||
@@ -46,6 +46,7 @@ type preferencesWorker struct {
|
|||||||
settings config.Settings
|
settings config.Settings
|
||||||
globalSettings config.Settings
|
globalSettings config.Settings
|
||||||
appSettings map[string]config.Settings
|
appSettings map[string]config.Settings
|
||||||
|
quickSpeech quickSpeechSettings
|
||||||
activeApp string
|
activeApp string
|
||||||
editApp string
|
editApp string
|
||||||
editing config.Settings
|
editing config.Settings
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
|
|
||||||
func (worker *preferencesWorker) applicationSettings(application string) (config.Settings, error) {
|
func (worker *preferencesWorker) applicationSettings(application string) (config.Settings, error) {
|
||||||
if settings, ok := worker.appSettings[application]; ok {
|
if settings, ok := worker.appSettings[application]; ok {
|
||||||
return settings, nil
|
return worker.quickSpeech.apply(settings), nil
|
||||||
}
|
}
|
||||||
store, err := config.NewStore(worker.globalSettings.Path)
|
store, err := config.NewStore(worker.globalSettings.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -32,7 +32,7 @@ func (worker *preferencesWorker) applicationSettings(application string) (config
|
|||||||
}
|
}
|
||||||
settings = config.RebaseApplicationSettings(persistedGlobal, worker.globalSettings, settings)
|
settings = config.RebaseApplicationSettings(persistedGlobal, worker.globalSettings, settings)
|
||||||
worker.appSettings[application] = settings
|
worker.appSettings[application] = settings
|
||||||
return settings, nil
|
return worker.quickSpeech.apply(settings), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (worker *preferencesWorker) switchApplication(ctx context.Context, application string) {
|
func (worker *preferencesWorker) switchApplication(ctx context.Context, application string) {
|
||||||
|
|||||||
@@ -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 {
|
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)
|
t.Fatalf("application profile not saved: %+v, %t, %v", appLoaded, mapped, err)
|
||||||
}
|
}
|
||||||
if loaded.DefaultVoice.Rate != nil || loaded.Reading.AutomaticallyReadClipboardChanges {
|
if loaded.DefaultVoice.Rate == nil || *loaded.DefaultVoice.Rate != 51 || loaded.Reading.AutomaticallyReadClipboardChanges {
|
||||||
t.Fatal("application speech shortcut overwrote global voice")
|
t.Fatal("speech shortcut did not save globally or leaked unrelated application settings")
|
||||||
}
|
}
|
||||||
restarted := newPreferencesWorker(slog.New(slog.DiscardHandler), loaded,
|
restarted := newPreferencesWorker(slog.New(slog.DiscardHandler), loaded,
|
||||||
settingsUpdateFunc(func(context.Context, speech.Settings) error { return nil }))
|
settingsUpdateFunc(func(context.Context, speech.Settings) error { return nil }))
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ func (worker *preferencesWorker) applyNext(ctx context.Context, next config.Sett
|
|||||||
if err := worker.applyRuntime(ctx, next); err != nil {
|
if err := worker.applyRuntime(ctx, next); err != nil {
|
||||||
return worker.response(err.Error(), false)
|
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 {
|
if worker.editingActive {
|
||||||
worker.editing = next
|
worker.editing = next
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,13 +47,9 @@ func (worker *preferencesWorker) handleSpeechCommand(ctx context.Context, comman
|
|||||||
ctx, cancel := context.WithTimeout(ctx, 6*time.Second)
|
ctx, cancel := context.WithTimeout(ctx, 6*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if command == input.SaveSpeechSettings {
|
if command == input.SaveSpeechSettings {
|
||||||
store, err := config.NewStore(worker.settings.Path)
|
store, err := config.NewStore(worker.globalSettings.Path)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if worker.activeApp != "" {
|
err = store.Save(worker.globalSettings)
|
||||||
err = store.SaveForApplication(worker.activeApp, worker.settings)
|
|
||||||
} else {
|
|
||||||
err = store.Save(worker.settings)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
worker.logger.Warn("save speech settings", "error", err)
|
worker.logger.Warn("save speech settings", "error", err)
|
||||||
@@ -115,7 +111,7 @@ func (worker *preferencesWorker) handleSpeechCommand(ctx context.Context, comman
|
|||||||
field := fields[worker.speechSetting]
|
field := fields[worker.speechSetting]
|
||||||
value := defaults[worker.speechSetting]
|
value := defaults[worker.speechSetting]
|
||||||
if *field != nil {
|
if *field != nil {
|
||||||
value = **field
|
value = math.Round(**field*10) / 10
|
||||||
}
|
}
|
||||||
if adjust {
|
if adjust {
|
||||||
value = math.Round((value+float64(delta)*steps[worker.speechSetting])*10) / 10
|
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)
|
value := tentaclewire.SpeechFromSettings(next)
|
||||||
worker.speechOverride = &value
|
worker.speechOverride = &value
|
||||||
} else {
|
} 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
|
worker.settings = next
|
||||||
}
|
}
|
||||||
if worker.live != nil {
|
if worker.live != nil {
|
||||||
@@ -236,6 +239,43 @@ func (worker *preferencesWorker) handleSpeechCommand(ctx context.Context, comman
|
|||||||
worker.announceSpeechSetting(message)
|
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.
|
// 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.
|
// 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.
|
// Unknown languages retain the catalog so the user can still select a voice.
|
||||||
|
|||||||
@@ -7,11 +7,14 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.stormux.org/storm/cthulhu/internal/config"
|
"git.stormux.org/storm/cthulhu/internal/config"
|
||||||
"git.stormux.org/storm/cthulhu/internal/input"
|
"git.stormux.org/storm/cthulhu/internal/input"
|
||||||
|
"git.stormux.org/storm/cthulhu/internal/preferences"
|
||||||
"git.stormux.org/storm/cthulhu/internal/speech"
|
"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)
|
t.Fatalf("saved settings did not retain speech choices: %+v", saved.DefaultVoice)
|
||||||
}
|
}
|
||||||
worker.settings.Path = t.TempDir()
|
worker.settings.Path = t.TempDir()
|
||||||
|
worker.globalSettings.Path = worker.settings.Path
|
||||||
command(input.SaveSpeechSettings)
|
command(input.SaveSpeechSettings)
|
||||||
if message != "Speech settings could not be saved" {
|
if message != "Speech settings could not be saved" {
|
||||||
t.Fatal(message)
|
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) {
|
func TestSpeechShortcutNumericBounds(t *testing.T) {
|
||||||
for index, maximum := range []float64{99, 9, 9} {
|
for index, maximum := range []float64{99, 9, 9} {
|
||||||
settings := config.DefaultSettings()
|
settings := config.DefaultSettings()
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ func RebaseApplicationSettings(oldGlobal, newGlobal, application Settings) Setti
|
|||||||
}
|
}
|
||||||
|
|
||||||
func profileFromSettings(settings Settings) writableProfile {
|
func profileFromSettings(settings Settings) writableProfile {
|
||||||
|
settings = RoundSpeechValues(settings)
|
||||||
return writableProfile{
|
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)},
|
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{
|
ProgressBar: writableProgressBar{
|
||||||
|
|||||||
@@ -4,6 +4,22 @@
|
|||||||
|
|
||||||
package config
|
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
|
// EffectiveVoice resolves a sparse voice over Default. Uppercase retains the
|
||||||
// established automatic pitch unless overridden or capital pitch is disabled.
|
// established automatic pitch unless overridden or capital pitch is disabled.
|
||||||
func EffectiveVoice(settings Settings, kind string) Voice {
|
func EffectiveVoice(settings Settings, kind string) Voice {
|
||||||
|
|||||||
@@ -5,11 +5,76 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"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) {
|
func TestVoiceTypesPersistAndInheritAcrossApplications(t *testing.T) {
|
||||||
settings := DefaultSettings()
|
settings := DefaultSettings()
|
||||||
rate, pitch, gain := 62.0, 8.0, 4.0
|
rate, pitch, gain := 62.0, 8.0, 4.0
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ package preferences
|
|||||||
import "C"
|
import "C"
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
@@ -142,7 +143,7 @@ func (session *uiSession) getEchoVoice() (config.Voice, string) {
|
|||||||
{"echoRateScale", &voice.Rate}, {"echoPitchScale", &voice.AveragePitch}, {"echoVolumeScale", &voice.Gain},
|
{"echoRateScale", &voice.Rate}, {"echoPitchScale", &voice.AveragePitch}, {"echoVolumeScale", &voice.Gain},
|
||||||
} {
|
} {
|
||||||
id := C.CString(item.id)
|
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))
|
C.free(unsafe.Pointer(id))
|
||||||
*item.target = &value
|
*item.target = &value
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,45 @@
|
|||||||
package preferences
|
package preferences
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math"
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.stormux.org/storm/cthulhu/internal/config"
|
"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) {
|
func TestSoundValuesDefaultAndMerge(t *testing.T) {
|
||||||
settings := config.DefaultSettings()
|
settings := config.DefaultSettings()
|
||||||
values := FromSettings(settings)
|
values := FromSettings(settings)
|
||||||
|
|||||||
@@ -419,7 +419,7 @@ func (session *uiSession) read_voice_editor() (config.Voice, error) {
|
|||||||
} {
|
} {
|
||||||
id := C.CString(item.id)
|
id := C.CString(item.id)
|
||||||
edited := C.prefs_number_edited(id) != 0
|
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))
|
C.free(unsafe.Pointer(id))
|
||||||
if !edited {
|
if !edited {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -139,5 +139,8 @@ func (values Values) Merge(settings config.Settings) (config.Settings, error) {
|
|||||||
settings.SayCapForCapitals = values.SayCapForCapitals
|
settings.SayCapForCapitals = values.SayCapForCapitals
|
||||||
settings.Reading = values.Reading
|
settings.Reading = values.Reading
|
||||||
settings.SpeakDescription = values.SpeakDescription
|
settings.SpeakDescription = values.SpeakDescription
|
||||||
return settings, config.Validate(settings)
|
if err := config.Validate(settings); err != nil {
|
||||||
|
return settings, err
|
||||||
|
}
|
||||||
|
return config.RoundSpeechValues(settings), nil
|
||||||
}
|
}
|
||||||
@@ -565,6 +565,7 @@ def inside():
|
|||||||
values = accept_preview()
|
values = accept_preview()
|
||||||
assert abs(slider.queryValue().currentValue - expected) < 1e-8, (name, arrow, expected)
|
assert abs(slider.queryValue().currentValue - expected) < 1e-8, (name, arrow, expected)
|
||||||
assert abs(values[field] - expected) < 1e-8, (name, arrow, values)
|
assert abs(values[field] - expected) < 1e-8, (name, arrow, values)
|
||||||
|
assert values[field] == round(values[field], 1), (name, "precision", values)
|
||||||
startValue = expected
|
startValue = expected
|
||||||
for bound, arrows in ((0, ("Left", "Down")),
|
for bound, arrows in ((0, ("Left", "Down")),
|
||||||
(slider.queryValue().maximumValue, ("Right", "Up"))):
|
(slider.queryValue().maximumValue, ("Right", "Up"))):
|
||||||
@@ -597,6 +598,7 @@ def inside():
|
|||||||
wait_for(lambda: abs(slider.queryValue().currentValue - expected) < 1e-8,
|
wait_for(lambda: abs(slider.queryValue().currentValue - expected) < 1e-8,
|
||||||
f"{name}: {arrow} did not produce {expected}")
|
f"{name}: {arrow} did not produce {expected}")
|
||||||
assert abs(values["EchoVoice"][field] - expected) < 1e-8, values
|
assert abs(values["EchoVoice"][field] - expected) < 1e-8, values
|
||||||
|
assert values["EchoVoice"][field] == round(values["EchoVoice"][field], 1), (name, "precision", values)
|
||||||
finalValue = expected
|
finalValue = expected
|
||||||
for bound, arrows in ((0, ("Left", "Down")),
|
for bound, arrows in ((0, ("Left", "Down")),
|
||||||
(slider.queryValue().maximumValue, ("Right", "Up"))):
|
(slider.queryValue().maximumValue, ("Right", "Up"))):
|
||||||
@@ -611,8 +613,10 @@ def inside():
|
|||||||
assert applied["Save"], applied
|
assert applied["Save"], applied
|
||||||
for field, expected in (("Rate", 49.9), ("Pitch", 4.9), ("Volume", 4.9)):
|
for field, expected in (("Rate", 49.9), ("Pitch", 4.9), ("Volume", 4.9)):
|
||||||
assert abs(applied["Values"][field] - expected) < 1e-8, applied
|
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)):
|
for field, expected in (("Rate", 49.9), ("AveragePitch", 4.9), ("Gain", 4.9)):
|
||||||
assert abs(applied["Values"]["EchoVoice"][field] - expected) < 1e-8, applied
|
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."))
|
send(dict(initial, Values=applied["Values"], Message="Speech settings saved."))
|
||||||
wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "Apply busy")
|
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)):
|
for name, expected in (("Key echo rate", 49.9), ("Key echo pitch", 4.9), ("Key echo volume", 4.9)):
|
||||||
|
|||||||
Reference in new issue
Block a user