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:
1 parent
5aef0be169
commit
1d49f5021d
17 files changed
+613
-19
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user