diff --git a/README.md b/README.md index 6048bd24..9cb31ac0 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/go.mod b/go.mod index 2aab3811..384139aa 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 85d4bae3..0843b6e5 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/app/application.go b/internal/app/application.go index 9b449c45..13fc5122 100644 --- a/internal/app/application.go +++ b/internal/app/application.go @@ -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) { diff --git a/internal/app/fenrir_menu.go b/internal/app/fenrir_menu.go index 6f8300d1..bccbb426 100644 --- a/internal/app/fenrir_menu.go +++ b/internal/app/fenrir_menu.go @@ -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 { diff --git a/internal/app/internal_ui.go b/internal/app/internal_ui.go index e89322a1..43dadec1 100644 --- a/internal/app/internal_ui.go +++ b/internal/app/internal_ui.go @@ -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 } diff --git a/internal/app/quick_settings.go b/internal/app/quick_settings.go index 6ca233be..dd0369a3 100644 --- a/internal/app/quick_settings.go +++ b/internal/app/quick_settings.go @@ -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 diff --git a/internal/app/runtime_settings.go b/internal/app/runtime_settings.go index 5e5fc51d..c767b7f4 100644 --- a/internal/app/runtime_settings.go +++ b/internal/app/runtime_settings.go @@ -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 { diff --git a/internal/app/speech_selection.go b/internal/app/speech_selection.go index 61128885..f7ef5799 100644 --- a/internal/app/speech_selection.go +++ b/internal/app/speech_selection.go @@ -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 } diff --git a/internal/app/voice_profiles.go b/internal/app/voice_profiles.go new file mode 100644 index 00000000..48b576de --- /dev/null +++ b/internal/app/voice_profiles.go @@ -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) +} diff --git a/internal/app/voice_profiles_test.go b/internal/app/voice_profiles_test.go new file mode 100644 index 00000000..47d90a9d --- /dev/null +++ b/internal/app/voice_profiles_test.go @@ -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) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 16a49153..e6ef1caf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 } diff --git a/internal/config/document.go b/internal/config/document.go index dd1a736a..80eff648 100644 --- a/internal/config/document.go +++ b/internal/config/document.go @@ -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() { diff --git a/internal/config/voice_profiles.go b/internal/config/voice_profiles.go new file mode 100644 index 00000000..21f1f5c7 --- /dev/null +++ b/internal/config/voice_profiles.go @@ -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() +} diff --git a/internal/config/voice_profiles_test.go b/internal/config/voice_profiles_test.go new file mode 100644 index 00000000..7e0c3777 --- /dev/null +++ b/internal/config/voice_profiles_test.go @@ -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) + } +} diff --git a/internal/preferences/settings.go b/internal/preferences/settings.go index 2e805700..731e79ea 100644 --- a/internal/preferences/settings.go +++ b/internal/preferences/settings.go @@ -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) } diff --git a/internal/preferences/settings_test.go b/internal/preferences/settings_test.go index df458263..3b504999 100644 --- a/internal/preferences/settings_test.go +++ b/internal/preferences/settings_test.go @@ -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)