diff --git a/README.md b/README.md index c213531..3b76166 100644 --- a/README.md +++ b/README.md @@ -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. 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 @@ -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 synchronization remain the responsibility of the device-owning reader. -These additions are available starting with v0.4.0. Both readers pin this -version for standalone builds. Verify pinned dependencies with `GOWORK=off`; +These additions are available starting with v0.4.0. Both readers pin v0.6.0 +for standalone builds. Verify pinned dependencies with `GOWORK=off`; physical keyboard, audible speech, braille, and hardware acceptance remain 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. diff --git a/internalui/session.go b/internalui/session.go index 25d81f7..571a5b6 100644 --- a/internalui/session.go +++ b/internalui/session.go @@ -175,6 +175,27 @@ func (s *Session) Focused_control() (Control, bool) { 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 // discarding its search. Keep the selected word when it still matches. func (s *Session) Set_choices(tabID, controlID string, choices []Choice) { diff --git a/quicksettings/menu.go b/quicksettings/menu.go index 28c0ca6..7811b4a 100644 --- a/quicksettings/menu.go +++ b/quicksettings/menu.go @@ -26,7 +26,10 @@ const ( 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 // 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 { if option.ID == value.Choice { + if option.Literal { + return option.Label, false + } return m.Messages.T(option.Label), false } } diff --git a/quicksettings/menu_test.go b/quicksettings/menu_test.go index 9cf079f..736170a 100644 --- a/quicksettings/menu_test.go +++ b/quicksettings/menu_test.go @@ -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) + } +} diff --git a/voiceprofiles/editor.go b/voiceprofiles/editor.go new file mode 100644 index 0000000..ba19ee3 --- /dev/null +++ b/voiceprofiles/editor.go @@ -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 +} diff --git a/voiceprofiles/profiles.go b/voiceprofiles/profiles.go new file mode 100644 index 0000000..4262979 --- /dev/null +++ b/voiceprofiles/profiles.go @@ -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 +} diff --git a/voiceprofiles/profiles_test.go b/voiceprofiles/profiles_test.go new file mode 100644 index 0000000..4afb525 --- /dev/null +++ b/voiceprofiles/profiles_test.go @@ -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) + } +}