Add primary voice profiles from shared reader core v0.6.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Storm DragonandClaude Opus 5.5 committed 2026-11-15 14:31:06 -05:00
1 parent 5aef0be169
commit 1d49f5021d
17 files changed
+613 -19

No files matched your search

+30
View File
@@ -455,3 +455,33 @@ appropriate, and sending the final patches to the contribution address.
- IRC: `irc.stormux.org`, channel `#stormux`.
The Go code is licensed under the GNU GPL, version 3 or later. See [LICENSE](LICENSE).
## Primary voice profiles
The global Fenrir Menu includes **Voice Profiles**, initially containing
**Add Voice Profile**. Its editor focuses **Voice Profile Name** first and
shows the selected synthesizer's settings. Leaving the name blank generates a
name from the module and voice, or the hardware synthesizer. Duplicate names
are rejected. Edit/Remove update the menu draft; Escape/Cancel in the child
editor returns to the menu without saving that edit. Confirm the parent menu
with **OK** on a settings tab to persist profile changes.
**Use Voice Profile** loads the profile into the menu's primary speech draft.
Confirming the menu applies it when speech preparation succeeds. Profiles are
also available through the Speech quick-settings category; those switches take
effect immediately for the session and use the ordinary save-settings command
to persist. Individual rate/pitch/volume adjustments leave the preset unchanged.
Removing a preset leaves the configured primary voice in place. Application
settings do not edit the global preset catalog.
Profiles and their editor are shared through `screenreader-common v0.6.0`,
pinned for standalone builds. A sibling Go workspace can use local shared-module
changes during development.
Run `go test ./...` and `go test -race ./internal/config ./internal/preferences
./internal/app`. Before submission, review the diffs and listen while cycling
software profiles in both directions, switching to/from any physical hardware
synthesizer, and trying an unavailable device. Check cancellation, restart
persistence, removal of the last preset, typing echo, system announcements and
application override entry/exit. Automated protocol and menu checks do not
establish audible speech, physical keyboard, Braille or hardware acceptance.
+1 -1
View File
@@ -3,7 +3,7 @@ module git.stormux.org/storm/fenrir
go 1.27.2
require (
git.stormux.org/storm/screenreader-common v0.5.0
git.stormux.org/storm/screenreader-common v0.6.0
github.com/jfreymuth/pulse v0.1.3
github.com/leonelquinteros/gotext v1.8.0
github.com/pelletier/go-toml/v2 v2.4.3
+2
View File
@@ -8,6 +8,8 @@ git.stormux.org/storm/screenreader-common v0.4.0 h1:iq2BuC/d6EBiMh76WZK09De+sQjG
git.stormux.org/storm/screenreader-common v0.4.0/go.mod h1:iLmD0KXRvR4ar3U80JMwbaNUOyR82/gY7xHUY+zeIoY=
git.stormux.org/storm/screenreader-common v0.5.0 h1:OmplWVqpXWwPCQsi9f6tOxRjj/b5XDnSCch9RPzSh4s=
git.stormux.org/storm/screenreader-common v0.5.0/go.mod h1:iLmD0KXRvR4ar3U80JMwbaNUOyR82/gY7xHUY+zeIoY=
git.stormux.org/storm/screenreader-common v0.6.0 h1:iXsUBBjHOQTPu3ROTR9ysrPOi3iIXLMW3DCu6XZRTec=
git.stormux.org/storm/screenreader-common v0.6.0/go.mod h1:iLmD0KXRvR4ar3U80JMwbaNUOyR82/gY7xHUY+zeIoY=
github.com/jfreymuth/pulse v0.1.3 h1:bc5TdxiB8E+2INnFjFWWgyfgXtz2IyNNNCX+Wt/ZD14=
github.com/jfreymuth/pulse v0.1.3/go.mod h1:cpYspI6YljhkUf1WLXLLDmeaaPFc3CnGLjDZf9dZ4no=
github.com/leonelquinteros/gotext v1.8.0 h1:wwpxINWmWq3JN3WFI9BoSupBFrvAqxZTtf3qppijYkU=
+11
View File
@@ -112,9 +112,20 @@ func new_application(ctx context.Context, source Source, keyboards Keyboards, vo
a.speechChoices = make(chan preferences.Discovery, 1)
a.prepareSpeech = prepare_speech
a.quick.selectSpeech = func(target config.Speech, done func(error, bool)) {
driverChanged := target.Driver != a.settings.live.Presentation.Speech.Driver
session, feedback, uiGeneration := a.spellSession, a.spellFeedback, a.ui.generation
a.change_speech(target, func(err error) {
done(err, a.spellSession == session && a.spellFeedback == feedback && a.ui.generation == uiGeneration && !a.quitting)
if err == nil && driverChanged && options.SettingsChoices != nil {
selected := a.settings.live
go func() {
discovery := options.SettingsChoices(selected)
select {
case a.speechChoices <- discovery:
case <-a.ctx.Done():
}
}()
}
})
}
a.ui.catalog = func(discovery preferences.Discovery) {
+44 -3
View File
@@ -24,6 +24,7 @@ import (
"git.stormux.org/storm/fenrir/internal/i18n"
"git.stormux.org/storm/fenrir/internal/interaction"
"git.stormux.org/storm/fenrir/internal/preferences"
"strings"
)
func (ui *internalUI) open_settings(settings config.Settings, path string, liveQuick ...config.Settings) {
@@ -75,7 +76,7 @@ func (ui *internalUI) open_settings_context(settings config.Settings, path strin
if len(liveQuick) != 0 {
copy_quick_settings(&doc.Settings, liveQuick[0])
}
if speechOverride != nil && speechOverride.Driver == doc.Settings.Presentation.Speech.Driver {
if speechOverride != nil {
copy_speech_selection(&doc.Settings.Presentation.Speech, *speechOverride)
}
var discovery preferences.Discovery
@@ -92,9 +93,15 @@ func (ui *internalUI) open_settings_context(settings config.Settings, path strin
ui.post(uiUpdate{generation: generation, close: true, speech: i18n.Format("Could not open Fenrir Menu: {0}", err.Error())})
return
}
readSettings := preferences.Settings
profileDraft := doc.Settings.VoiceProfiles.Clone()
baseReadSettings := preferences.Settings
if applicationBase != nil {
readSettings = preferences.Application_settings
baseReadSettings = preferences.Application_settings
}
readSettings := func(s *interaction.Session, base config.Settings) (config.Settings, error) {
next, err := baseReadSettings(s, base)
next.VoiceProfiles = profileDraft.Clone()
return next, err
}
previous := doc.Settings
catalogs := map[string]preferences.Discovery{previous.Presentation.Speech.Driver: discovery}
@@ -149,6 +156,40 @@ func (ui *internalUI) open_settings_context(settings config.Settings, path strin
}
var action func(string, *interaction.Session)
action = func(name string, session *interaction.Session) {
if strings.HasSuffix(name, "-profile") && applicationBase == nil {
draft, err := readSettings(session, doc.Settings)
if err != nil {
ui.failed("Could not edit voice profiles: {0}", err)
return
}
selectedProfile := session.Values()["voice-profiles.profiles"].Selected
ui.manage_profiles(name, session, draft, discovery, func(next config.Settings) {
profileDraft = next.VoiceProfiles.Clone()
previous = next
restored, err := preferences.New_session(next, doc.Order, discovery.Choices(next))
if err != nil {
ui.failed("Could not open Fenrir Menu: {0}", err)
return
}
restored.Focus_control("voice-profiles", "profiles")
if len(next.VoiceProfiles.Profiles) > len(draft.VoiceProfiles.Profiles) {
selectedProfile = len(next.VoiceProfiles.Profiles) - 1
}
restored.Select("voice-profiles", "profiles", min(selectedProfile, len(next.VoiceProfiles.Profiles)-1))
if len(next.VoiceProfiles.Profiles) == 0 {
restored.Focus_control("voice-profiles", "add")
}
if ui.session != nil {
ui.session.Close()
}
ui.session = restored
ui.action = action
ui.changed = changed
ui.returnToParent = nil
ui.voice.Say(restored.Describe())
})
return
}
if name == "test-synthesizer" {
draft, err := readSettings(session, doc.Settings)
if err != nil {
+12
View File
@@ -34,6 +34,7 @@ import (
// changes run on the app loop; workers return generation-tagged presentation.
// Admitted settings writes complete through the application save coordinator.
type internalUI struct {
profileSequence uint64
readClipboard func() (string, bool)
writeClipboard func(string) bool
catalog func(preferences.Discovery)
@@ -50,6 +51,7 @@ type internalUI struct {
saveSettings func(*config.Document, config.Settings, func(error)) bool
discoverChoices func(config.Settings) preferences.Discovery
changed func(*interaction.Session)
returnToParent func()
onClosed func()
gridSettings func() config.InternalUI
previewSpelling func(config.Reading)
@@ -94,6 +96,7 @@ func (ui *internalUI) begin(name string) (uint64, error) {
ui.session.Close()
}
ui.active, ui.session, ui.action = true, nil, nil
ui.returnToParent = nil
ui.changed, ui.onClosed = nil, nil
ui.name = name
return ui.generation, nil
@@ -121,6 +124,7 @@ func (ui *internalUI) close() {
if ui.session != nil {
ui.session.Close()
}
ui.returnToParent = nil
ui.active, ui.session, ui.action = false, nil, nil
if ui.onClosed != nil {
ui.onClosed()
@@ -175,6 +179,10 @@ func (ui *internalUI) configure_session(session *interaction.Session) {
func (ui *internalUI) handle(key interaction.Key) {
if key.Name == "escape" {
if ui.returnToParent != nil {
ui.returnToParent()
return
}
ui.close()
return
}
@@ -210,6 +218,10 @@ func (ui *internalUI) handle(key interaction.Key) {
ui.changed(ui.session)
}
if result.Cancelled || result.Action == "cancel" {
if ui.returnToParent != nil {
ui.returnToParent()
return
}
ui.close()
return
}
+76
View File
@@ -37,6 +37,7 @@ import (
type quickSettings struct {
selectionChanged bool
menu *quicksettings.Menu
categories []quicksettings.Category
definitions map[string]config.Definition
changed bool
discovery preferences.Discovery
@@ -91,6 +92,7 @@ func new_quick_settings(driverID string) *quickSettings {
}
categories = append(categories, category)
}
menu.categories = categories
menu.menu = quicksettings.New(categories, quicksettings.Messages{Translate: i18n.T})
return menu
}
@@ -109,6 +111,9 @@ type quickHost struct {
}
func (h quickHost) Value(entry quicksettings.Entry) quicksettings.Value {
if entry.ID == "voice-profile" {
return quicksettings.Value{Choice: h.settings.VoiceProfiles.Matching(config.Primary_voice(*h.settings))}
}
switch value := h.quick.definitions[entry.ID].Value(*h.settings).(type) {
case bool:
return quicksettings.Value{Checked: value}
@@ -121,6 +126,16 @@ func (h quickHost) Value(entry quicksettings.Entry) quicksettings.Value {
}
func (h quickHost) Options(entry quicksettings.Entry) []quicksettings.Option {
if entry.ID == "voice-profile" {
options := []quicksettings.Option{}
if h.settings.VoiceProfiles.Matching(config.Primary_voice(*h.settings)) == "" {
options = append(options, quicksettings.Option{ID: "", Label: "Custom"})
}
for _, p := range h.settings.VoiceProfiles.Profiles {
options = append(options, quicksettings.Option{ID: p.Name, Label: p.Name, Literal: true})
}
return options
}
definition := h.quick.definitions[entry.ID]
choices := definition.Choices(*h.settings)
if speech_choice(definition.ChoiceSource) {
@@ -182,12 +197,73 @@ func (q *quickSettings) command(id command.ID, settings *config.Settings, voice
if !ok {
return false
}
fresh := new_quick_settings(settings.Presentation.Speech.Driver)
if len(settings.VoiceProfiles.Profiles) > 0 {
for index := range fresh.categories {
if fresh.categories[index].ID == "presentation.speech" {
fresh.categories[index].Entries = append(fresh.categories[index].Entries, quicksettings.Entry{ID: "voice-profile", Label: "Voice profile", Kind: quicksettings.Choice})
}
}
}
q.definitions = fresh.definitions
if !q.menu.Replace(fresh.categories) && quicksettings.IsValueChange(quickCommand) {
voice.Say(i18n.T("The selected setting is no longer available"))
return true
}
host := quickHost{quick: q, settings: settings}
result := q.menu.Handle(quickCommand, host)
if !result.Changed {
voice.Say(result.Text)
return true
}
if result.Entry.ID == "voice-profile" {
p, ok := settings.VoiceProfiles.Find(result.Value.Choice)
if !ok {
voice.Say(i18n.T("Choose a saved voice profile"))
return true
}
candidate, err := config.Apply_primary_voice(*settings, p)
if err != nil {
voice.Say(err.Error())
return true
}
if q.selectSpeech == nil {
voice.Say(i18n.T("Voice profile was not changed"))
return true
}
selectionRevision := q.revision
q.selectSpeech(candidate.Presentation.Speech, func(err error, present bool) {
if err != nil {
if present {
voice.Say(i18n.T("Voice profile was not changed"))
}
return
}
// A later numeric adjustment belongs to the new primary too.
if q.fields["presentation.speech.rate"] > selectionRevision {
candidate.Presentation.Speech.Rate = settings.Presentation.Speech.Rate
}
if q.fields["presentation.speech.pitch"] > selectionRevision {
candidate.Presentation.Speech.Pitch = settings.Presentation.Speech.Pitch
}
if q.fields["presentation.speech.volume"] > selectionRevision {
candidate.Presentation.Speech.Volume = settings.Presentation.Speech.Volume
}
enabled := settings.Presentation.Speech.Enabled
settings.Presentation.Speech = candidate.Presentation.Speech
settings.Presentation.Speech.Enabled = enabled
voice.Configure(speech_parameters(*settings))
q.changed = true
q.selectionChanged = true
for _, path := range []string{"presentation.speech.driver", "presentation.speech.hardware_device", "presentation.speech.hardware_baud_rate", "presentation.speech.module", "presentation.speech.language", "presentation.speech.voice", "presentation.speech.rate", "presentation.speech.pitch", "presentation.speech.volume"} {
q.mark_changed(path)
}
if present {
voice.Say(result.Text)
}
})
return true
}
definition := q.definitions[result.Entry.ID]
if speech_choice(definition.ChoiceSource) && q.selectSpeech != nil {
candidate := *settings
+5 -1
View File
@@ -88,6 +88,7 @@ func (a *application) apply_saved_settings(saved config.Settings, speechApplied
}
a.settings.saveDraft = saved
a.settings.resetBase = saved
a.settings.live.VoiceProfiles = saved.VoiceProfiles.Clone()
a.settings.live.InternalUI = saved.InternalUI
a.settings.live.Screen = saved.Screen
a.configure_screen()
@@ -208,7 +209,10 @@ func (a *application) save_menu_settings(doc *config.Document, draft config.Sett
return false
}
previousDraft := a.settings.saveDraft
speechApplied := a.settings.speechAvailable && draft.Presentation.Speech.Driver == a.settings.live.Presentation.Speech.Driver && !same_speech_selection(draft.Presentation.Speech, a.settings.saveDraft.Presentation.Speech)
liveSpeech, draftSpeech := a.settings.live.Presentation.Speech, draft.Presentation.Speech
sameDevice := draftSpeech.Driver == liveSpeech.Driver && draftSpeech.HardwareDevice == liveSpeech.HardwareDevice && draftSpeech.HardwareBaudRate == liveSpeech.HardwareBaudRate
profileSelected := draft.VoiceProfiles.Matching(config.Primary_voice(draft)) != ""
speechApplied := a.settings.speechAvailable && (sameDevice || profileSelected) && !same_speech_selection(draftSpeech, liveSpeech)
if !a.saver.start(a.ctx, draft, func() error {
var prepared speech.Transport
if speechApplied {
+4 -1
View File
@@ -20,10 +20,13 @@ type speechChangeResult struct {
}
func same_speech_selection(a, b config.Speech) bool {
return a.Driver == b.Driver && a.Module == b.Module && a.Language == b.Language && a.Voice == b.Voice
return a.Driver == b.Driver && a.HardwareDevice == b.HardwareDevice && a.HardwareBaudRate == b.HardwareBaudRate && a.Module == b.Module && a.Language == b.Language && a.Voice == b.Voice
}
func copy_speech_selection(target *config.Speech, source config.Speech) {
target.Driver = source.Driver
target.HardwareDevice = source.HardwareDevice
target.HardwareBaudRate = source.HardwareBaudRate
target.Module, target.Language, target.Voice = source.Module, source.Language, source.Voice
}
+115
View File
@@ -0,0 +1,115 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"git.stormux.org/storm/fenrir/internal/config"
"git.stormux.org/storm/fenrir/internal/i18n"
"git.stormux.org/storm/fenrir/internal/interaction"
"git.stormux.org/storm/fenrir/internal/preferences"
sd "git.stormux.org/storm/screenreader-common/speechdispatcher"
"git.stormux.org/storm/screenreader-common/voiceprofiles"
)
func (ui *internalUI) manage_profiles(action string, parent *interaction.Session, draft config.Settings, discovery preferences.Discovery, restore func(config.Settings)) {
ui.profileSequence++
sequence := ui.profileSequence
index := -1
if control, ok := parent.Values()["voice-profiles.profiles"]; ok {
index = control.Selected
}
if action == "remove-profile" {
draft.VoiceProfiles = draft.VoiceProfiles.Remove(index)
restore(draft)
return
}
if action == "use-profile" {
if index < 0 || index >= len(draft.VoiceProfiles.Profiles) {
return
}
next, err := config.Apply_primary_voice(draft, draft.VoiceProfiles.Profiles[index])
if err != nil {
ui.failed("Could not select voice profile: {0}", err)
return
}
restore(next)
return
}
p := config.Primary_voice(draft)
p.Name = ""
if p.Device == "" {
p.Device = "/dev/ttyUSB0"
}
if p.BaudRate == 0 {
p.BaudRate = 9600
}
if action == "edit-profile" {
if index < 0 || index >= len(draft.VoiceProfiles.Profiles) {
return
}
p = draft.VoiceProfiles.Profiles[index]
} else {
index = -1
}
catalog := sd.Catalog{}
for module, voices := range discovery.Catalog.Voices {
catalog[module] = voices
}
for _, module := range discovery.Catalog.Modules {
if _, ok := catalog[module]; !ok {
catalog[module] = nil
}
}
open := func(catalog sd.Catalog) {
editor := voiceprofiles.New_editor(p, voiceprofiles.Drivers(), catalog)
child, err := interaction.New_session(editor.Form())
if err != nil {
ui.failed("Could not edit voice profiles: {0}", err)
return
}
parent.Close()
ui.session = child
ui.returnToParent = func() { restore(draft) }
ui.changed = editor.Refresh_session
ui.action = func(name string, session *interaction.Session) {
if name != "save-profile" {
return
}
profile, err := editor.Read(session.Values())
if err != nil {
ui.failed("Could not save voice profile: {0}", err)
return
}
collection, err := draft.VoiceProfiles.Put(index, profile)
if err != nil {
ui.failed("Could not save voice profile: {0}", err)
return
}
draft.VoiceProfiles = collection
restore(draft)
}
ui.voice.Say(child.Open())
}
// Profiles can select Speech Dispatcher even when the current primary is hardware.
if discovery.Driver != "speech-dispatcher" && ui.discoverChoices != nil {
generation := ui.generation
parent.Close()
ui.session = nil
ui.returnToParent = func() { restore(draft) }
ui.voice.Say(i18n.T("Loading voices"))
discover := ui.discoverChoices
go func() {
selected := draft
selected.Presentation.Speech.Driver = "speech-dispatcher"
result := discover(selected)
ui.post(uiUpdate{generation: generation, apply: func() {
if ui.profileSequence == sequence && ui.session == nil && ui.returnToParent != nil {
open(sd.Catalog(result.Catalog.Voices))
}
}})
}()
return
}
open(catalog)
}
+188
View File
@@ -0,0 +1,188 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"errors"
"git.stormux.org/storm/fenrir/internal/command"
"git.stormux.org/storm/fenrir/internal/config"
"git.stormux.org/storm/fenrir/internal/interaction"
"git.stormux.org/storm/fenrir/internal/preferences"
"git.stormux.org/storm/fenrir/internal/speech"
"git.stormux.org/storm/screenreader-common/voiceprofiles"
"os"
"path/filepath"
"reflect"
"testing"
)
func TestVoiceProfileMenuAddCancelPersistAndRemove(t *testing.T) {
path := filepath.Join(t.TempDir(), "settings.toml")
a := settings_ui_application(t, &fakeKeys{}, &fakeVoice{}, path)
a.ui.open_settings(config.Defaults(), path)
receive_ui(t, a.ui)
activate := func(id string) {
t.Helper()
if !a.ui.session.Focus_control("voice-profiles", id) {
t.Fatal("missing profile control", id)
}
a.ui.handle(interaction.Key{Name: "enter"})
}
activate("add")
if c, _ := a.ui.session.Focused_control(); c.ID != "name" {
t.Fatal(c)
}
a.ui.handle(interaction.Key{Text: "Discarded"})
a.ui.handle(interaction.Key{Name: "escape"})
if !a.ui.active || a.ui.session.Values()["voice-profiles.profiles"].ID != "" {
t.Fatal("child cancel changed catalog or closed menu")
}
activate("add")
a.ui.handle(interaction.Key{Text: "Reading"})
a.ui.session.Focus_control(voiceprofiles.PageID, "rate")
a.ui.handle(interaction.Key{Name: "right"})
a.ui.session.Focus_control(voiceprofiles.PageID, "save")
a.ui.handle(interaction.Key{Name: "enter"})
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatal("child dialog saved before parent confirmation", err)
}
activate("edit")
if a.ui.session.Values()[voiceprofiles.PageID+".rate"].Number != 10 {
t.Fatal("rate did not survive add")
}
a.ui.handle(interaction.Key{Name: "escape"})
a.ui.session.Focus_control("presentation", "ok")
a.ui.handle(interaction.Key{Name: "enter"})
finish_persistence(t, a, false)
loaded, _, err := config.Load(path, nil)
if err != nil || len(loaded.VoiceProfiles.Profiles) != 1 || loaded.VoiceProfiles.Profiles[0].Name != "Reading" {
t.Fatal(loaded.VoiceProfiles, err)
}
a.ui.open_settings(loaded, path)
receive_ui(t, a.ui)
activate("remove")
a.ui.session.Focus_control("presentation", "ok")
a.ui.handle(interaction.Key{Name: "enter"})
finish_persistence(t, a, false)
loaded, _, err = config.Load(path, nil)
if err != nil || len(loaded.VoiceProfiles.Profiles) != 0 {
t.Fatal(loaded.VoiceProfiles, err)
}
}
func TestQuickVoiceProfileFailureBackendChangeAndWrap(t *testing.T) {
settings := config.Defaults()
settings.VoiceProfiles.Profiles = []voiceprofiles.Profile{{Name: "Reading", Driver: "speech-dispatcher", Rate: 30, Volume: 80}, {Name: "Hardware", Driver: "dectalk", Device: "/dev/fixture", BaudRate: 9600, Rate: 40, Volume: 90}}
menu, voice := new_quick_settings("speech-dispatcher"), &fakeVoice{}
for range 6 {
menu.command(command.NextQuickEntry, &settings, voice)
}
before := settings
menu.selectSpeech = func(_ config.Speech, done func(error, bool)) { done(errors.New("fixture failed"), true) }
menu.command(command.NextQuickValue, &settings, voice)
if !reflect.DeepEqual(settings, before) || menu.selectionChanged {
t.Fatal("failed selection changed speech settings")
}
menu.selectSpeech = func(_ config.Speech, done func(error, bool)) { done(nil, true) }
for _, name := range []string{"Reading", "Hardware", "Reading", "Hardware"} {
menu.command(command.NextQuickValue, &settings, voice)
if got := settings.VoiceProfiles.Matching(config.Primary_voice(settings)); got != name {
t.Fatal(got, name, voice.messages)
}
}
if !reflect.DeepEqual(settings.Keyboard, before.Keyboard) || !menu.selectionChanged {
t.Fatal("profile changed echo or missed selection")
}
saved := before
copy_speech_selection(&saved.Presentation.Speech, settings.Presentation.Speech)
if saved.Presentation.Speech.Driver != "dectalk" || saved.Presentation.Speech.HardwareDevice != "/dev/fixture" {
t.Fatal("quick save lost backend")
}
}
func TestMenuVoiceProfileRestoresSavedBackendAfterQuickChange(t *testing.T) {
a, _, path := selection_application(t, true)
draft := a.settings.saveDraft
profile := config.Primary_voice(draft)
profile.Name = "Saved voice"
draft.VoiceProfiles.Profiles = []voiceprofiles.Profile{profile}
a.settings.live.Presentation.Speech.Module = "temporary"
prepared := false
a.prepareSpeech = func(selected config.Speech) (speech.Transport, error) { prepared = true; return &preparedVoice{}, nil }
doc, err := config.Open_document(path, draft)
if err != nil {
t.Fatal(err)
}
if !a.save_menu_settings(doc, draft, func(err error) {
if err != nil {
t.Error(err)
}
}) {
t.Fatal("save not admitted")
}
a.settings_save_result(committed_menu_result(t, a))
if !prepared || a.settings.live.Presentation.Speech.Module != "" {
t.Fatal("menu profile left temporary backend active")
}
}
func TestQuickVoiceProfilePreservesLaterNumericAdjustment(t *testing.T) {
settings := config.Defaults()
settings.VoiceProfiles.Profiles = []voiceprofiles.Profile{{Name: "Reading", Driver: "speech-dispatcher", Rate: 30, Volume: 80}}
menu, voice := new_quick_settings("speech-dispatcher"), &fakeVoice{}
var complete func(error, bool)
menu.selectSpeech = func(_ config.Speech, done func(error, bool)) { complete = done }
for range 6 {
menu.command(command.NextQuickEntry, &settings, voice)
}
menu.command(command.NextQuickValue, &settings, voice)
menu.command(command.NextQuickEntry, &settings, voice) // wraps to rate
menu.command(command.NextQuickValue, &settings, voice)
complete(nil, true)
if settings.Presentation.Speech.Rate != 10 || settings.Presentation.Speech.Volume != 80 {
t.Fatal(settings.Presentation.Speech)
}
}
func TestCancelledProfileDiscoveryCannotReplaceNewerEditor(t *testing.T) {
path := filepath.Join(t.TempDir(), "settings.toml")
a := settings_ui_application(t, &fakeKeys{}, &fakeVoice{}, path)
a.ui.open_settings(config.Defaults(), path)
receive_ui(t, a.ui)
first, second := make(chan struct{}), make(chan struct{})
requests := make(chan int, 2)
a.ui.discoverChoices = func(settings config.Settings) preferences.Discovery {
rate := settings.Presentation.Speech.Rate
requests <- rate
if rate == 0 {
<-first
} else {
<-second
}
return preferences.Discovery{Driver: "speech-dispatcher"}
}
draft := config.Defaults()
restore := func(next config.Settings) {
session, err := preferences.New_session(next, nil)
if err != nil {
t.Fatal(err)
}
a.ui.session = session
a.ui.returnToParent = nil
}
a.ui.manage_profiles("add-profile", a.ui.session, draft, preferences.Discovery{Driver: "dectalk"}, restore)
<-requests
a.ui.handle(interaction.Key{Name: "escape"})
draft.Presentation.Speech.Rate = 20
a.ui.manage_profiles("add-profile", a.ui.session, draft, preferences.Discovery{Driver: "dectalk"}, restore)
<-requests
close(first)
receive_ui(t, a.ui)
if a.ui.session != nil {
t.Fatal("cancelled discovery reopened an older editor")
}
close(second)
receive_ui(t, a.ui)
if got := a.ui.session.Values()[voiceprofiles.PageID+".rate"].Number; got != 20 {
t.Fatal("newer draft lost", got)
}
}
+17 -12
View File
@@ -28,6 +28,7 @@ import (
"git.stormux.org/storm/fenrir/internal/punctuation"
"git.stormux.org/storm/fenrir/internal/sound"
"git.stormux.org/storm/fenrir/internal/speech"
"git.stormux.org/storm/screenreader-common/voiceprofiles"
"io"
"os"
"path/filepath"
@@ -53,18 +54,19 @@ type InternalUI struct {
}
type Settings struct {
Version int `toml:"version"`
Presentation Presentation `toml:"presentation" label:"Presentation"`
Keyboard Keyboard `toml:"keyboard" label:"Keyboard"`
Reading Reading `toml:"reading" label:"Reading"`
InternalUI InternalUI `toml:"internal_ui" label:"Internal UI"`
Review Review `toml:"review"`
Screen Screen `toml:"screen"`
Clipboard Clipboard `toml:"clipboard" label:"Clipboard"`
General General `toml:"general"`
Scripts Scripts `toml:"scripts"`
Remote Remote `toml:"remote"`
Prompt Prompt `toml:"prompt"`
VoiceProfiles voiceprofiles.Collection `toml:"voice_profiles"`
Version int `toml:"version"`
Presentation Presentation `toml:"presentation" label:"Presentation"`
Keyboard Keyboard `toml:"keyboard" label:"Keyboard"`
Reading Reading `toml:"reading" label:"Reading"`
InternalUI InternalUI `toml:"internal_ui" label:"Internal UI"`
Review Review `toml:"review"`
Screen Screen `toml:"screen"`
Clipboard Clipboard `toml:"clipboard" label:"Clipboard"`
General General `toml:"general"`
Scripts Scripts `toml:"scripts"`
Remote Remote `toml:"remote"`
Prompt Prompt `toml:"prompt"`
}
// Screen policy is file-only; VT numbers apply only to the console backend.
@@ -174,6 +176,9 @@ func Defaults() Settings {
}
func (s Settings) Validate() error {
if err := s.VoiceProfiles.Validate(); err != nil {
return err
}
if err := sound.Validate_selection(s.Presentation.Sound.Driver, s.Presentation.Sound.Device); err != nil {
return err
}
+13
View File
@@ -168,6 +168,19 @@ func (d *Document) Save(settings Settings) error {
var encode func([]Section) error
encode = func(sections []Section) error {
for _, section := range Ordered_sections(sections, d.Order) {
if section.ID == "voice_profiles" {
if len(settings.VoiceProfiles.Profiles) == 0 {
continue
}
// Qualify array tables; an unqualified [[profiles]] escapes the section.
encoded, err := toml.Marshal(map[string]any{"voice_profiles": settings.VoiceProfiles})
if err != nil {
return err
}
output.Write(encoded)
continue
}
value := setting_value(reflect.ValueOf(settings), section.ID)
fields := make(map[string]any)
for index := range value.NumField() {
+43
View File
@@ -0,0 +1,43 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package config
import "git.stormux.org/storm/screenreader-common/voiceprofiles"
func Primary_voice(settings Settings) voiceprofiles.Profile {
s := settings.Presentation.Speech
p := voiceprofiles.Profile{Driver: s.Driver, Module: s.Module, Language: s.Language, Voice: s.Voice, Rate: s.Rate, Pitch: s.Pitch, Volume: s.Volume}
if s.Driver != "speech-dispatcher" {
p.Module = ""
p.Language = ""
p.Voice = ""
p.Device = s.HardwareDevice
p.BaudRate = s.HardwareBaudRate
if s.Driver == "dectalk-usb" {
p.Device = ""
p.BaudRate = 0
}
}
return p
}
func Apply_primary_voice(settings Settings, p voiceprofiles.Profile) (Settings, error) {
if err := p.Normalize().Validate(); err != nil {
return settings, err
}
s := settings.Presentation.Speech
s.Driver = p.Driver
s.Module = p.Module
s.Language = p.Language
s.Voice = p.Voice
s.Rate = p.Rate
s.Pitch = p.Pitch
s.Volume = p.Volume
if p.Driver != "speech-dispatcher" {
s.HardwareDevice = p.Device
s.HardwareBaudRate = p.BaudRate
}
settings.Presentation.Speech = s
return settings, settings.Validate()
}
+48
View File
@@ -0,0 +1,48 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package config
import (
"git.stormux.org/storm/screenreader-common/voiceprofiles"
"path/filepath"
"reflect"
"testing"
)
func TestVoiceProfilesPersistenceAndPrimaryScope(t *testing.T) {
settings := Defaults()
p := voiceprofiles.Profile{Name: "Reading", Driver: "speech-dispatcher", Module: "fixture", Voice: "Reader", Language: "es", Rate: 30, Pitch: -5, Volume: 80}
settings.VoiceProfiles.Profiles = []voiceprofiles.Profile{p}
path := filepath.Join(t.TempDir(), "settings.toml")
doc, err := Open_document(path, settings)
if err != nil {
t.Fatal(err)
}
if err = doc.Save(settings); err != nil {
t.Fatal(err)
}
loaded, _, err := Load(path, nil)
if err != nil || !reflect.DeepEqual(loaded.VoiceProfiles, settings.VoiceProfiles) {
t.Fatal(loaded.VoiceProfiles, err)
}
original := loaded
loaded, err = Apply_primary_voice(loaded, p)
if err != nil || loaded.Presentation.Speech.Voice != "Reader" || loaded.Presentation.Speech.Rate != 30 {
t.Fatal(loaded, err)
}
if !reflect.DeepEqual(loaded.Keyboard, original.Keyboard) || loaded.Reading != original.Reading || loaded.Presentation.Speech.Enabled != original.Presentation.Speech.Enabled {
t.Fatal("profile changed unrelated settings")
}
loaded.VoiceProfiles = loaded.VoiceProfiles.Remove(0)
doc, err = Open_document(path, loaded)
if err != nil {
t.Fatal(err)
}
if err = doc.Save(loaded); err != nil {
t.Fatal(err)
}
loaded, _, err = Load(path, nil)
if err != nil || len(loaded.VoiceProfiles.Profiles) != 0 {
t.Fatal(loaded.VoiceProfiles, err)
}
}
+3
View File
@@ -96,6 +96,9 @@ func new_session(settings config.Settings, order []string, title string, applica
tab.Controls = append(tab.Controls, interaction.Control{ID: "ok", Label: "OK", Kind: interaction.Button, Action: "save"}, interaction.Control{ID: "cancel", Label: "Cancel", Kind: interaction.Button, Action: "cancel"})
form.Tabs = append(form.Tabs, tab)
}
if !application {
form.Tabs = append(form.Tabs, settings.VoiceProfiles.Tab())
}
return interaction.New_session(form)
}
+1 -1
View File
@@ -38,7 +38,7 @@ func TestSettingsFormCoversSchemaAndReturnsIsolatedDraft(t *testing.T) {
t.Fatal(got)
}
values := session.Values()
if len(values) != 64 {
if len(values) != 65 {
t.Fatal("missing settings or buttons", len(values))
}
unchanged, err := Settings(session, base)