Add shared primary voice profiles and editor
This commit is contained in:
1 parent
5ca137c879
commit
03896de84e
7 files changed
+590
-4
No files matched your search
@@ -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.
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user