Add shared primary voice profiles and editor

This commit is contained in:
Storm Dragon committed 2026-11-15 14:13:17 -05:00
1 parent 5ca137c879
commit 03896de84e
7 files changed
+590 -4

No files matched your search

+22 -3
View File
@@ -44,7 +44,7 @@ before editing. Describe what changed, the tests run, and remaining live reader
checks. Leave submission to the contributor after their review and testing. checks. Leave submission to the contributor after their review and testing.
The shared quick menu and validated discovery API are available starting with The shared quick menu and validated discovery API are available starting with
v0.5.0. Both readers pin this version for standalone builds. v0.5.0. Both readers pin v0.6.0 for standalone builds.
## License ## License
@@ -174,7 +174,26 @@ and 0..9 pitch/volume values at the boundary. It no longer has a separate
DECtalk encoding. Port opening, serial/USB I/O, flush timing and cancellation DECtalk encoding. Port opening, serial/USB I/O, flush timing and cancellation
synchronization remain the responsibility of the device-owning reader. synchronization remain the responsibility of the device-owning reader.
These additions are available starting with v0.4.0. Both readers pin this These additions are available starting with v0.4.0. Both readers pin v0.6.0
version for standalone builds. Verify pinned dependencies with `GOWORK=off`; for standalone builds. Verify pinned dependencies with `GOWORK=off`;
physical keyboard, audible speech, braille, and hardware acceptance remain physical keyboard, audible speech, braille, and hardware acceptance remain
separate live checks. separate live checks.
## Primary voice profiles
`voiceprofiles` supplies the shared primary-voice preset model, collection
validation/copying, unique automatic names, management-tab controls, and editor
forms for software and hardware speech. `Editor.Refresh_session` maintains
module/language/voice dependencies in the shared UI toolkit; native GTK hosts
use the same form and dependent-control updates. Catalog discovery, native
widget rendering, speech preparation/rollback and atomic settings persistence
remain host responsibilities. Existing echo and message voice policies remain
with each reader. A preset stores only primary speech transport/voice/numeric
settings and never modifies unrelated preferences.
`internalui.Session.Focus_control` restores a control when a child editor
returns to its parent form. `quicksettings.Option.Literal` preserves user-chosen
profile names without translation. These APIs are available in v0.6.0, pinned
by both readers for standalone builds. Run `go test -race ./...` here and the
consumer settings/speech regressions with `GOWORK=off` in both readers. The
sibling workspace supports local development without machine-specific replaces.
+21
View File
@@ -175,6 +175,27 @@ func (s *Session) Focused_control() (Control, bool) {
return control, true return control, true
} }
// Focus_control restores a host's focus after returning from a child dialog.
func (s *Session) Focus_control(tabID, controlID string) bool {
if s.closed {
return false
}
for ti, tab := range s.form.Tabs {
if tab.ID != tabID {
continue
}
for ci, control := range tab.Controls {
if control.ID == controlID && !control.Hidden && !control.Disabled {
s.tab, s.focus = ti, ci
s.cursor = len([]rune(control.Text))
s.anchor = s.cursor
return true
}
}
}
return false
}
// Set_choices updates an asynchronous grid without moving keyboard focus or // Set_choices updates an asynchronous grid without moving keyboard focus or
// discarding its search. Keep the selected word when it still matches. // discarding its search. Keep the selected word when it still matches.
func (s *Session) Set_choices(tabID, controlID string, choices []Choice) { func (s *Session) Set_choices(tabID, controlID string, choices []Choice) {
+7 -1
View File
@@ -26,7 +26,10 @@ const (
Range Range
) )
type Option struct{ ID, Label string } type Option struct {
ID, Label string
Literal bool // User-provided labels bypass translation.
}
// Entry describes one setting. Label and option labels are English message // Entry describes one setting. Label and option labels are English message
// IDs passed through the menu's translator. // IDs passed through the menu's translator.
@@ -267,6 +270,9 @@ func (m *Menu) describe(entry Entry, value Value, options []Option) (text string
} }
for _, option := range options { for _, option := range options {
if option.ID == value.Choice { if option.ID == value.Choice {
if option.Literal {
return option.Label, false
}
return m.Messages.T(option.Label), false return m.Messages.T(option.Label), false
} }
} }
+12
View File
@@ -183,3 +183,15 @@ func TestCategoriesCannotMutateMenuOrClone(t *testing.T) {
} }
} }
} }
func TestLiteralProfileNamesBypassTranslation(t *testing.T) {
menu := New(nil, Messages{Translate: func(value string) string { return "translated " + value }})
entry := Entry{ID: "profile", Label: "Voice profile", Kind: Choice}
value := Value{Choice: "Reading"}
if got := menu.Describe(entry, value, []Option{{ID: "Reading", Label: "Reading", Literal: true}}); got != "Reading" {
t.Fatal(got)
}
if got := menu.Describe(entry, value, []Option{{ID: "Reading", Label: "Reading"}}); got != "translated Reading" {
t.Fatal(got)
}
}
+225
View File
@@ -0,0 +1,225 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package voiceprofiles
import (
"fmt"
"math"
"sort"
"strconv"
"strings"
ui "git.stormux.org/storm/screenreader-common/internalui"
sd "git.stormux.org/storm/screenreader-common/speechdispatcher"
)
const PageID = "voice-profile"
// Editor builds the same labeled controls for native GTK and internal forms.
// Catalog queries and committing the draft remain host operations.
type Editor struct {
drivers []Driver
catalog sd.Catalog
previous Profile
}
func New_editor(p Profile, drivers []Driver, catalog sd.Catalog) *Editor {
copyCatalog := sd.Catalog{}
for module, voices := range catalog {
copyCatalog[module] = append([]sd.Voice(nil), voices...)
}
return &Editor{drivers: append([]Driver(nil), drivers...), catalog: copyCatalog, previous: p}
}
func choice(id, label string) ui.Choice { return ui.Choice{ID: id, Label: label, Literal: true} }
func combo(id, label, selected string, choices []ui.Choice) ui.Control {
index := -1
for i, c := range choices {
if c.ID == selected {
index = i
break
}
}
if index < 0 {
index = len(choices)
choices = append(choices, choice(selected, selected+" (unavailable)"))
}
return ui.Control{ID: id, Label: label, Kind: ui.ComboBox, Choices: choices, Selected: index}
}
func (e *Editor) controls(p Profile) []ui.Control {
drivers := []ui.Choice{}
for _, driver := range e.drivers {
drivers = append(drivers, choice(driver.ID, driver.Label))
}
controls := []ui.Control{{ID: "name", Label: "Voice Profile Name", Kind: ui.Entry, Text: p.Name, MaxLength: 128}, combo("driver", "Synthesizer", p.Driver, drivers)}
driver, _ := driver_for(p.Driver)
modules := []ui.Choice{choice("", "Default")}
keys := []string{}
for module := range e.catalog {
if module != "" {
keys = append(keys, module)
}
}
sort.Strings(keys)
for _, module := range keys {
modules = append(modules, choice(module, module))
}
languages := []ui.Choice{choice("", "Default")}
voices := []ui.Choice{choice("", "Default")}
seen := map[string]bool{"": true}
for _, voice := range e.catalog[p.Module] {
if !seen[voice.Language] {
languages = append(languages, choice(voice.Language, voice.Language))
seen[voice.Language] = true
}
}
filtered := e.catalog[p.Module]
if p.Language != "" {
filtered = sd.LanguageVoices(filtered, p.Language)
}
seen = map[string]bool{"": true}
for _, voice := range filtered {
if !seen[voice.Name] {
voices = append(voices, choice(voice.Name, voice.Name+" ("+voice.Language+")"))
seen[voice.Name] = true
}
}
for _, c := range []ui.Control{combo("module", "Module", p.Module, modules), combo("language", "Language", p.Language, languages), combo("voice", "Voice", p.Voice, voices)} {
c.Hidden = driver.Hardware
controls = append(controls, c)
}
baud := []ui.Choice{}
for _, n := range BaudRates {
baud = append(baud, choice(strconv.Itoa(n), strconv.Itoa(n)))
}
device := ui.Control{ID: "device", Label: "Serial device", Kind: ui.Entry, Text: p.Device, Hidden: !driver.Serial, MaxLength: 1024}
baudControl := combo("baud", "Baud rate", strconv.Itoa(p.BaudRate), baud)
baudControl.Hidden = !driver.Serial
controls = append(controls, device, baudControl)
for _, item := range []struct {
id, label string
value int
}{{"rate", "Rate", p.Rate}, {"pitch", "Pitch", p.Pitch}, {"volume", "Volume", p.Volume}} {
controls = append(controls, ui.Control{ID: item.id, Label: item.label, Kind: ui.Number, Number: float64(item.value), Min: -100, Max: 100, Step: 10, UpDownStep: 1})
}
return append(controls, ui.Control{ID: "save", Label: "Save Voice Profile", Kind: ui.Button, Action: "save-profile"}, ui.Control{ID: "cancel", Label: "Cancel", Kind: ui.Button, Action: "cancel", SkipValidation: true})
}
func (e *Editor) Form() ui.Form {
return ui.Form{Type: ui.FlatForm, Title: "Voice Profile", Tabs: []ui.Tab{{ID: PageID, Label: "Voice Profile", Controls: e.controls(e.previous)}}}
}
func selected(c ui.Control) string {
if c.Selected >= 0 && c.Selected < len(c.Choices) {
return c.Choices[c.Selected].ID
}
return ""
}
func (e *Editor) read(values map[string]ui.Control) Profile {
get := func(id string) ui.Control { return values[PageID+"."+id] }
baud, _ := strconv.Atoi(selected(get("baud")))
p := Profile{Name: get("name").Text, Driver: selected(get("driver")), Module: selected(get("module")), Language: selected(get("language")), Voice: selected(get("voice")), Device: get("device").Text, BaudRate: baud, Rate: int(get("rate").Number), Pitch: int(get("pitch").Number), Volume: int(get("volume").Number)}
if p.Module == e.previous.Module && p.Language == e.previous.Language && p.Voice == e.previous.Voice {
p.Variant = e.previous.Variant
}
for _, voice := range e.catalog[p.Module] {
if voice.Name == p.Voice && (p.Language == "" || strings.EqualFold(p.Language, voice.Language)) {
p.Language = voice.Language
p.Variant = voice.Variant
break
}
}
return p
}
// Refresh returns only dependent controls, preserving entry text and numeric drafts.
func (e *Editor) Refresh(values map[string]ui.Control) []ui.Control {
p := e.read(values)
if p.Driver == e.previous.Driver && p.Module == e.previous.Module && selected(values[PageID+".language"]) == e.previous.Language {
return nil
}
if p.Module != e.previous.Module {
p.Language = ""
p.Voice = ""
p.Variant = ""
}
if selected(values[PageID+".language"]) != e.previous.Language {
p.Voice = ""
p.Variant = ""
}
// The language combo is a filter; selecting a voice alone must not reset it.
p.Language = selected(values[PageID+".language"])
if p.Module != e.previous.Module {
p.Language = ""
}
e.previous = p
var changes []ui.Control
for _, c := range e.controls(p) {
if c.ID == "module" || c.ID == "language" || c.ID == "voice" || c.ID == "device" || c.ID == "baud" {
changes = append(changes, c)
}
}
return changes
}
func (e *Editor) Refresh_session(session *ui.Session) {
for _, c := range e.Refresh(session.Values()) {
if c.Kind == ui.ComboBox {
session.Replace_choices(PageID, c.ID, c.Choices, selected(c))
}
session.Set_hidden(PageID, c.ID, c.Hidden)
}
}
func (e *Editor) Read(values map[string]ui.Control) (Profile, error) {
for _, id := range []string{"rate", "pitch", "volume"} {
n := values[PageID+"."+id].Number
if math.IsNaN(n) || math.IsInf(n, 0) || math.Trunc(n) != n || n < -100 || n > 100 {
return Profile{}, fmt.Errorf("Enter a %s between -100 and 100", id)
}
}
p := e.read(values).Normalize()
found := false
for _, driver := range e.drivers {
if driver.ID == p.Driver {
found = true
}
}
if !found {
return p, fmt.Errorf("Choose a supported synthesizer")
}
driver, _ := driver_for(p.Driver)
if driver.Hardware {
p.Module = ""
p.Language = ""
p.Voice = ""
p.Variant = ""
}
if !driver.Serial {
p.Device = ""
p.BaudRate = 0
}
return p, p.Validate()
}
func (c Collection) Tab() ui.Tab {
tab := ui.Tab{ID: "voice-profiles", Label: "Voice Profiles"}
if len(c.Profiles) > 0 {
choices := []ui.Choice{}
for _, p := range c.Profiles {
choices = append(choices, choice(p.Name, p.Name))
}
tab.Controls = append(tab.Controls, ui.Control{ID: "profiles", Label: "Voice Profiles", Kind: ui.ComboBox, Choices: choices})
}
tab.Controls = append(tab.Controls, ui.Control{ID: "add", Label: "Add Voice Profile", Kind: ui.Button, Action: "add-profile"})
if len(c.Profiles) > 0 {
for _, item := range []struct{ id, label string }{{"edit", "Edit Voice Profile"}, {"remove", "Remove Voice Profile"}, {"use", "Use Voice Profile"}} {
tab.Controls = append(tab.Controls, ui.Control{ID: item.id, Label: item.label, Kind: ui.Button, Action: item.id + "-profile"})
}
}
return tab
}
+182
View File
@@ -0,0 +1,182 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
// Package voiceprofiles owns primary-voice presets, independent of reader storage
// and speech transports. Selecting a preset never changes echo or message voices.
package voiceprofiles
import (
"fmt"
"path/filepath"
"slices"
"strings"
"unicode"
"unicode/utf8"
sd "git.stormux.org/storm/screenreader-common/speechdispatcher"
)
type Profile struct {
Name string `toml:"name"`
Driver string `toml:"driver"`
Module string `toml:"module,omitempty"`
Language string `toml:"language,omitempty"`
Voice string `toml:"voice,omitempty"`
Variant string `toml:"variant,omitempty"`
Rate int `toml:"rate"`
Pitch int `toml:"pitch"`
Volume int `toml:"volume"`
Device string `toml:"device,omitempty"`
BaudRate int `toml:"baud-rate,omitempty"`
}
type Collection struct {
Profiles []Profile `toml:"profiles,omitempty"`
}
func (c Collection) Clone() Collection { return Collection{Profiles: slices.Clone(c.Profiles)} }
type Driver struct {
ID, Label string
Hardware, Serial bool
}
func Drivers() []Driver {
return []Driver{{ID: "speech-dispatcher", Label: "Speech Dispatcher"},
{ID: "dectalk", Label: "DECtalk", Hardware: true, Serial: true},
{ID: "dectalk-usb", Label: "DECtalk USB", Hardware: true},
{ID: "litetalk", Label: "LiteTalk", Hardware: true, Serial: true},
{ID: "doubletalk", Label: "DoubleTalk LT", Hardware: true, Serial: true},
{ID: "tripletalk", Label: "TripleTalk", Hardware: true, Serial: true}}
}
func driver_for(id string) (Driver, bool) {
for _, driver := range Drivers() {
if driver.ID == id {
return driver, true
}
}
return Driver{}, false
}
// Normalize supplies the saved name when the editor's name field is blank.
func (p Profile) Normalize() Profile {
p.Name = strings.TrimSpace(p.Name)
if p.Name == "" {
driver, _ := driver_for(p.Driver)
if !driver.Hardware {
p.Name = strings.TrimSpace(strings.Join([]string{p.Module, p.Voice}, " "))
}
if p.Name == "" {
p.Name = driver.Label
}
}
return p
}
func (p Profile) Validate() error {
if p.Name == "" || p.Name != strings.TrimSpace(p.Name) || utf8.RuneCountInString(p.Name) > 128 || !utf8.ValidString(p.Name) {
return fmt.Errorf("Enter a voice profile name of at most 128 characters")
}
for _, char := range p.Name {
if unicode.IsControl(char) {
return fmt.Errorf("Voice profile names must be single-line text")
}
}
driver, ok := driver_for(p.Driver)
if !ok {
return fmt.Errorf("Choose a supported synthesizer")
}
for _, number := range []int{p.Rate, p.Pitch, p.Volume} {
if number < -100 || number > 100 {
return fmt.Errorf("Voice rate, pitch and volume must be between -100 and 100")
}
}
if err := sd.ValidSelection(p.Module, p.Language, p.Voice); err != nil {
return err
}
if len(p.Variant) > 1024 || strings.IndexFunc(p.Variant, unicode.IsControl) >= 0 {
return fmt.Errorf("Invalid voice variant")
}
if driver.Serial {
if !filepath.IsAbs(p.Device) || len(p.Device) > 1024 || strings.IndexFunc(p.Device, unicode.IsControl) >= 0 {
return fmt.Errorf("Choose an absolute serial device path")
}
if !slices.Contains(BaudRates, p.BaudRate) {
return fmt.Errorf("Choose a supported serial baud rate")
}
}
return nil
}
var BaudRates = []int{1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200}
func (c Collection) Validate() error {
if len(c.Profiles) > 128 {
return fmt.Errorf("At most 128 voice profiles can be saved")
}
seen := map[string]bool{}
for _, p := range c.Profiles {
if err := p.Validate(); err != nil {
return err
}
name := strings.ToLower(p.Name)
if seen[name] {
return fmt.Errorf("A voice profile named %q already exists", p.Name)
}
seen[name] = true
}
return nil
}
// Put replaces only the explicitly edited profile; duplicate names never overwrite.
func (c Collection) Put(index int, p Profile) (Collection, error) {
if index < -1 || index >= len(c.Profiles) {
return c, fmt.Errorf("Voice profile no longer exists")
}
next := c.Clone()
p = p.Normalize()
if index == -1 {
next.Profiles = append(next.Profiles, p)
} else {
next.Profiles[index] = p
}
if err := next.Validate(); err != nil {
return c, err
}
return next, nil
}
func (c Collection) Remove(index int) Collection {
next := c.Clone()
if index >= 0 && index < len(next.Profiles) {
next.Profiles = slices.Delete(next.Profiles, index, index+1)
}
return next
}
func Same_voice(a, b Profile) bool {
a.Name = ""
b.Name = ""
a.Variant = ""
b.Variant = ""
return a == b
}
func (c Collection) Matching(p Profile) string {
for _, candidate := range c.Profiles {
if Same_voice(candidate, p) {
return candidate.Name
}
}
return ""
}
func (c Collection) Find(name string) (Profile, bool) {
for _, p := range c.Profiles {
if p.Name == name {
return p, true
}
}
return Profile{}, false
}
+121
View File
@@ -0,0 +1,121 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package voiceprofiles
import (
ui "git.stormux.org/storm/screenreader-common/internalui"
sd "git.stormux.org/storm/screenreader-common/speechdispatcher"
"reflect"
"strings"
"testing"
)
func fixture_profile() Profile {
return Profile{Driver: "speech-dispatcher", Module: "fixture", Voice: "Reading voice", Language: "en", Volume: 100}
}
func TestCollectionNamingDuplicatesAndIsolation(t *testing.T) {
p := fixture_profile()
collection, err := (Collection{}).Put(-1, p)
if err != nil || collection.Profiles[0].Name != "fixture Reading voice" {
t.Fatal(collection, err)
}
before := collection.Clone()
p.Name = "FIXTURE READING VOICE"
if _, err = collection.Put(-1, p); err == nil {
t.Fatal("duplicate silently overwrote a profile")
}
if !reflect.DeepEqual(before, collection) {
t.Fatal("failed add mutated collection")
}
p.Name = "Long reading"
next, err := collection.Put(0, p)
if err != nil {
t.Fatal(err)
}
next.Profiles[0].Name = "Changed"
if collection.Profiles[0].Name != before.Profiles[0].Name {
t.Fatal("aliased profile list")
}
for _, name := range []string{"bad\nname", strings.Repeat("x", 129)} {
p.Name = name
if _, err = collection.Put(-1, p); err == nil {
t.Fatal("accepted invalid name")
}
}
hardware := Profile{Driver: "dectalk", Device: "/dev/fixture", BaudRate: 9600, Volume: 100}.Normalize()
if hardware.Name != "DECtalk" || hardware.Validate() != nil {
t.Fatal(hardware)
}
if len(collection.Remove(0).Profiles) != 0 || len(collection.Profiles) != 1 {
t.Fatal("remove mutated original")
}
}
func TestEditorDependenciesHardwareAndUnavailableVoice(t *testing.T) {
p := fixture_profile()
p.Name = "Saved"
p.Voice = "Offline voice"
p.Variant = "saved"
catalog := sd.Catalog{"fixture": {{Name: "English", Language: "en"}, {Name: "Spanish", Language: "es"}}, "other": {{Name: "Other", Language: "fr"}}}
editor := New_editor(p, Drivers(), catalog)
session, err := ui.New_session(editor.Form())
if err != nil {
t.Fatal(err)
}
if c, _ := session.Focused_control(); c.ID != "name" {
t.Fatal("name is not initially focused")
}
got, err := editor.Read(session.Values())
if err != nil || got.Voice != p.Voice || got.Variant != p.Variant {
t.Fatal("offline voice lost", got, err)
}
session.Focus_control(PageID, "rate")
session.Handle(ui.Key{Name: "left"})
session.Select(PageID, "module", 2)
editor.Refresh_session(session)
got, err = editor.Read(session.Values())
if err != nil || got.Module != "other" || got.Voice != "" || got.Language != "" || got.Rate != -10 {
t.Fatal("dependent reset lost draft", got, err)
}
session.Select(PageID, "language", 1)
editor.Refresh_session(session)
session.Select(PageID, "voice", 1)
got, err = editor.Read(session.Values())
if err != nil || got.Voice != "Other" || got.Language != "fr" {
t.Fatal(got, err)
}
session.Select(PageID, "driver", 1)
editor.Refresh_session(session)
if !session.Values()[PageID+".voice"].Hidden || session.Values()[PageID+".device"].Hidden {
t.Fatal("wrong hardware fields")
}
session.Focus_control(PageID, "device")
session.Handle(ui.Key{Ctrl: true, Text: "a"})
session.Insert("/dev/fixture")
session.Select(PageID, "baud", 3)
got, err = editor.Read(session.Values())
if err != nil || got.Driver != "dectalk" || got.Module != "" || got.Voice != "" || got.BaudRate != 9600 {
t.Fatal(got, err)
}
}
func TestEditorCancelAndGeneratedName(t *testing.T) {
p := fixture_profile()
editor := New_editor(p, Drivers(), nil)
session, err := ui.New_session(editor.Form())
if err != nil {
t.Fatal(err)
}
got, err := editor.Read(session.Values())
if err != nil || got.Name != "fixture Reading voice" {
t.Fatal(got, err)
}
if !session.Handle(ui.Key{Name: "escape"}).Cancelled {
t.Fatal("escape did not cancel")
}
if p.Name != "" {
t.Fatal("editor mutated original")
}
tab := (Collection{}).Tab()
if len(tab.Controls) != 1 || tab.Controls[0].Action != "add-profile" {
t.Fatal(tab)
}
}