Update caps notification preferences.

This commit is contained in:
ak47 committed 2026-10-17 20:16:43 -04:00
1 parent 3df96145f2
commit 1c4e0a99bc
34 files changed
+577 -56

No files matched your search

+4
View File
@@ -243,6 +243,10 @@ func Run(ctx context.Context, options Options) error {
gatedSpeech{speech: speechOutput, gate: focusGate}, brailleOutput, live, focusGate))
}
if speechMessages != nil {
speechMessages.capitalization = func() (bool, bool) {
settings := live.Load()
return settings.BeepForCapitals, settings.SayCapForCapitals
}
speechMessages.reading = func() config.ReadingSettings { return live.Load().Reading }
speechMessages.verbosity = func() int { return live.Load().SpeechVerbosityLevel }
speechMessages.indentationCue = func(cue sound.Cue) {
+52
View File
@@ -0,0 +1,52 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"unicode"
"unicode/utf8"
)
func capitalCharacter(text string) bool {
letter, size := utf8.DecodeRuneInString(text)
return size == len(text) && unicode.IsUpper(letter)
}
func capitalVoice(customEcho bool) string {
if customEcho {
return "uppercase-echo"
}
return "uppercase"
}
// Called only by serialized speech delivery, after mode and context handling.
// No cue outlives its owning request's cancellation context.
func (worker *speechWorker) present_capital(ctx context.Context, request speechRequest) error {
if worker.capitalization == nil {
return nil
}
beep, sayCap := worker.capitalization()
if beep && worker.capitalBeep != nil {
if err := worker.capitalBeep(ctx); err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
worker.logger.Warn("capital letter beep", "error", err)
}
}
if err := ctx.Err(); err != nil {
return err
}
if sayCap {
if voice, ok := worker.speaker.(interface {
SpeakVoiceAndWait(context.Context, string, string, func()) error
}); ok {
return voice.SpeakVoiceAndWait(ctx, "cap", capitalVoice(request.CustomEcho), nil)
}
return worker.speaker.Speak(ctx, "cap")
}
return nil
}
+37
View File
@@ -0,0 +1,37 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"errors"
"log/slog"
"testing"
)
func TestCapitalBeepCancellationAndFailure(t *testing.T) {
for _, interrupt := range []bool{false, true} {
speaker := &spellingTestSpeaker{}
worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
worker.capitalization = func() (bool, bool) { return true, true }
ctx, cancel := context.WithCancel(context.Background())
worker.capitalBeep = func(beepContext context.Context) error {
if interrupt {
cancel()
return beepContext.Err()
}
return errors.New("audio unavailable")
}
err := worker.deliver(ctx, speechRequest{Text: "A", Character: true})
cancel()
if interrupt {
if !errors.Is(err, context.Canceled) || len(speaker.parts) != 0 {
t.Fatalf("canceled capital: %v %v", err, speaker.parts)
}
} else if err != nil || len(speaker.parts) != 2 || speaker.parts[0] != "cap" || speaker.parts[1] != "A" {
t.Fatalf("failed beep suppressed speech: %v %v", err, speaker.parts)
}
}
}
+3
View File
@@ -244,6 +244,9 @@ func (speaker *hardwareSpeaker) SpeakVoice(ctx context.Context, text, kind strin
if kind == "echo" {
return speaker.SpeakEcho(ctx, text)
}
if kind == "uppercase-echo" {
return speaker.speak(ctx, text, true, "uppercase")
}
return speaker.speak(ctx, text, false, kind)
}
func (speaker *hardwareSpeaker) speak(ctx context.Context, text string, echo bool, kind string) error {
+103
View File
@@ -330,6 +330,109 @@ func test_echo_speaker(t *testing.T, wrapped bool) {
t.Fatalf("custom character echo = %+v; want pitch %s", got, wantPitch)
}
}
capitalPreferences := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, speaker)
capitalLive := &liveSettings{}
capitalLive.Store(settings)
capitalPreferences.live = capitalLive
for mask := 0; mask < 8; mask++ {
choices := settings
echoPitch := 6.0
choices.EchoVoice.AveragePitch = &echoPitch
choices.RaisePitchForCapitals = mask&1 != 0
choices.BeepForCapitals = mask&2 != 0
choices.SayCapForCapitals = mask&4 != 0
if response := capitalPreferences.apply(ctx, preferences.Request{Values: preferences.FromSettings(choices)}); !response.Accepted {
t.Fatalf("capital choices Apply failed: %+v", response)
}
capitals := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
capitals.capitalization = func() (bool, bool) {
current := capitalLive.Load()
return current.BeepForCapitals, current.SayCapForCapitals
}
beeps := 0
capitals.capitalBeep = func(context.Context) error { beeps++; return nil }
for _, custom := range []bool{false, true} {
for _, tc := range []struct {
text, cause, voice string
character, capital bool
}{
{"A", "character-echo", "", true, true},
{"É", "document-caret-character", "", true, true},
{"𐐀", "flat-review", "", true, true},
{"A", "key-echo", "", true, true},
{"a", "character-echo", "", true, false},
{"ABC", "word-echo", "", false, false},
{"A", "focus", "", false, false},
} {
before := beeps
request := speechRequest{Text: tc.text, Cause: tc.cause, VoiceType: tc.voice, Character: tc.character, CustomEcho: custom}
if err := capitals.deliver(ctx, request); err != nil {
t.Fatal(err)
}
wantPitch, wantModule := "", ""
if custom {
wantPitch, wantModule = "30", "echo-module"
}
if tc.capital && choices.RaisePitchForCapitals {
wantPitch = "50"
if custom {
wantPitch = "70"
}
}
if tc.capital && choices.SayCapForCapitals {
got := <-spoken
if got.text != "cap" || got.pitch != wantPitch || got.module != wantModule {
t.Fatalf("mask %d cap prefix: %+v", mask, got)
}
}
got := <-spoken
if got.text != tc.text || got.pitch != wantPitch || got.module != wantModule {
t.Fatalf("mask %d custom=%v request=%+v: %+v", mask, custom, request, got)
}
wantBeeps := 0
if tc.capital && choices.BeepForCapitals {
wantBeeps = 1
}
if beeps-before != wantBeeps {
t.Fatalf("mask %d beeps=%d", mask, beeps-before)
}
}
}
// Phonetic spelling carries uppercase metadata despite lowercase words.
for _, character := range []bool{false, true} {
text := "alpha"
if character {
text = "A"
}
request := speechRequest{Spelling: []string{text, "b"}, SpellingVoices: []string{"uppercase", ""}, Character: character}
before := beeps
if err := capitals.deliver(ctx, request); err != nil {
t.Fatal(err)
}
if choices.SayCapForCapitals {
if got := <-spoken; got.text != "cap" {
t.Fatalf("spelled prefix: %+v", got)
}
}
if got := <-spoken; got.text != text {
t.Fatalf("spelled capital: %+v", got)
}
if got := <-spoken; got.text != "b" || got.pitch != "" {
t.Fatalf("spelled lowercase: %+v", got)
}
wantBeeps := 0
if choices.BeepForCapitals {
wantBeeps = 1
}
if beeps-before != wantBeeps {
t.Fatal("spelling beep count")
}
}
}
if err := speaker.UpdateConfiguration(ctx, settings); err != nil {
t.Fatal(err)
}
upperPitch, linkPitch, systemPitch := 8.0, 1.0, 2.0
settings.UppercaseVoice.AveragePitch = &upperPitch
settings.HyperlinkVoice.AveragePitch = &linkPitch
+2 -1
View File
@@ -115,7 +115,8 @@ func (worker *preferencesWorker) applyRuntime(ctx context.Context, next config.S
}
}()
}
speechChanged := next.SpeechServerFactory != worker.settings.SpeechServerFactory ||
speechChanged := next.RaisePitchForCapitals != worker.settings.RaisePitchForCapitals ||
next.SpeechServerFactory != worker.settings.SpeechServerFactory ||
!reflect.DeepEqual(next.UppercaseVoice, worker.settings.UppercaseVoice) ||
!reflect.DeepEqual(next.HyperlinkVoice, worker.settings.HyperlinkVoice) ||
!reflect.DeepEqual(next.SystemVoice, worker.settings.SystemVoice) ||
+17
View File
@@ -85,6 +85,16 @@ func remoteValue(settings config.Settings, module, property string) (dbus.Varian
return dbus.MakeVariant(float64(10)), true
case "SpeechIsEnabled":
return dbus.MakeVariant(settings.EnableSpeech), true
case "CapitalizationStyle":
// The old API cannot represent both cues; prefer the spoken cue.
style := "none"
if settings.BeepForCapitals {
style = "icon"
}
if settings.SayCapForCapitals {
style = "spell"
}
return dbus.MakeVariant(style), true
case "PunctuationLevel":
return dbus.MakeVariant([]string{"all", "most", "some", "none"}[settings.VerbalizePunctuationStyle]), true
}
@@ -240,6 +250,13 @@ func setRemoteValue(settings *config.Settings, module, property string, value db
}
settings.DefaultVoice.Family.Name = v
return true
case "CapitalizationStyle":
v, ok := value.Value().(string)
if !ok || (v != "none" && v != "spell" && v != "icon") {
return false
}
settings.SetCapitalizationStyle(v)
return true
case "PunctuationLevel":
v, ok := value.Value().(string)
if !ok {
+24
View File
@@ -68,3 +68,27 @@ func TestRemoteSettingsUseWorkerWithoutSaving(t *testing.T) {
t.Fatalf("remote setter wrote settings file: %v", err)
}
}
func TestLegacyRemoteCapitalStyleControlsIndependentCues(t *testing.T) {
settings := config.DefaultSettings()
for _, style := range []string{"icon", "spell", "none"} {
if !setRemoteValue(&settings, "SpeechManager", "CapitalizationStyle", dbus.MakeVariant(style)) {
t.Fatal("style rejected")
}
if !settings.RaisePitchForCapitals || settings.BeepForCapitals != (style == "icon") || settings.SayCapForCapitals != (style == "spell") {
t.Fatalf("style %s cue mapping", style)
}
value, ok := remoteValue(settings, "SpeechManager", "CapitalizationStyle")
if !ok || value.Value() != style {
t.Fatalf("style %s getter: %v", style, value)
}
}
settings.BeepForCapitals, settings.SayCapForCapitals = true, true
value, ok := remoteValue(settings, "SpeechManager", "CapitalizationStyle")
if !ok || value.Value() != "spell" {
t.Fatal("combined style compatibility")
}
if setRemoteValue(&settings, "SpeechManager", "CapitalizationStyle", dbus.MakeVariant("invalid")) {
t.Fatal("invalid style accepted")
}
}
+3
View File
@@ -89,6 +89,9 @@ func newRuntimeBackends(options *Options, settings config.Settings, logger *slog
if beeper, ok := backends.sounds.(interface{ SpeechBeep(context.Context) error }); ok {
backends.speechMessages.beep = beeper.SpeechBeep
}
if beeper, ok := backends.sounds.(interface{ CapitalBeep(context.Context) error }); ok {
backends.speechMessages.capitalBeep = beeper.CapitalBeep
}
backends.history = newSpeechHistory(settings.SpeechHistoryLimit)
backends.speechMessages.history = backends.history
backends.speechOutput = backends.speechMessages
+1 -1
View File
@@ -29,7 +29,7 @@ func speechSettingsFromConfig(settings config.Settings) speech.Settings {
speechSettings := speech.Settings{
OutputModule: settings.SpeechOutputModule,
Punctuation: map[int]string{0: "all", 1: "most", 2: "some", 3: "none"}[settings.VerbalizePunctuationStyle],
Capitalization: settings.CapitalizationStyle,
Capitalization: "none",
}
if settings.DefaultVoice.Rate != nil {
value := int(2*(*settings.DefaultVoice.Rate) - 98)
+1 -1
View File
@@ -40,7 +40,7 @@ func TestSpeechSettingsFromConfig(t *testing.T) {
got.Volume == nil || *got.Volume != 40 {
t.Fatalf("converted voice values = rate %v pitch %v volume %v", got.Rate, got.Pitch, got.Volume)
}
if got.Punctuation != "some" || got.Capitalization != "icon" ||
if got.Punctuation != "some" || got.Capitalization != "none" ||
got.Language != "en-US" || got.SynthesisVoice != "Generic Voice" {
t.Fatalf("converted speech settings = %#v", got)
}
+8
View File
@@ -185,6 +185,9 @@ func (worker *speechWorker) deliver(ctx context.Context, request speechRequest)
if request.CustomEcho {
kind = "echo"
}
if request.Character && capitalCharacter(request.Text) {
kind = capitalVoice(request.CustomEcho)
}
speak = func(ctx context.Context, text string) error {
return voiced.SpeakVoiceAndWait(ctx, text, kind, nil)
}
@@ -215,6 +218,11 @@ func (worker *speechWorker) deliver(ctx context.Context, request speechRequest)
if !historyRow && !(request.Character && utf8.RuneCountInString(chunk) == 1) {
text = worker.transformText(chunk)
}
if (request.Character && capitalCharacter(chunk)) || request.VoiceType == "uppercase" {
if err := worker.present_capital(ctx, request); err != nil {
return err
}
}
part := request
part.Append = request.Append || index > 0
worker.record_delivery(ctx, part, text)
+2
View File
@@ -87,6 +87,8 @@ type speechWorker struct {
speaker Speaker
history *speechHistory
beep func(context.Context) error
capitalBeep func(context.Context) error
capitalization func() (beep, sayCap bool)
textTransform func(string) string
reading func() config.ReadingSettings
verbosity func() int
+13
View File
@@ -0,0 +1,13 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package config
// SetCapitalizationStyle adapts the old exclusive choice to independent cues.
// Pitch has always been configured separately and is left untouched.
func (settings *Settings) SetCapitalizationStyle(style string) {
settings.CapitalizationStyle = style
settings.BeepForCapitals = style == "icon"
settings.SayCapForCapitals = style == "spell"
}
+104
View File
@@ -0,0 +1,104 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package config
import (
"fmt"
"path/filepath"
"testing"
)
func TestCapitalChoicesPersistIndependently(t *testing.T) {
defaults := DefaultSettings()
if !defaults.RaisePitchForCapitals || defaults.BeepForCapitals || defaults.SayCapForCapitals {
t.Fatal("only capital pitch should default on")
}
store, err := NewStore(filepath.Join(t.TempDir(), "settings.toml"))
if err != nil {
t.Fatal(err)
}
for mask := 0; mask < 8; mask++ {
settings := defaults
settings.RaisePitchForCapitals = mask&1 != 0
settings.BeepForCapitals = mask&2 != 0
settings.SayCapForCapitals = mask&4 != 0
if err := store.Save(settings); err != nil {
t.Fatal(err)
}
loaded, err := Load(store.path)
if err != nil {
t.Fatal(err)
}
checkCapitalChoices(t, loaded, settings)
app := defaults
if err := store.SaveForApplication("editor", app); err != nil {
t.Fatal(err)
}
loaded, found, err := store.LoadForApplication("editor")
if err != nil || !found {
t.Fatalf("application load: %v, %v", found, err)
}
checkCapitalChoices(t, loaded, app)
loaded, err = store.Load()
if err != nil {
t.Fatal(err)
}
checkCapitalChoices(t, loaded, settings)
}
}
func checkCapitalChoices(t *testing.T, got, want Settings) {
t.Helper()
if got.RaisePitchForCapitals != want.RaisePitchForCapitals ||
got.BeepForCapitals != want.BeepForCapitals || got.SayCapForCapitals != want.SayCapForCapitals {
t.Fatalf("capital choices: pitch=%v beep=%v cap=%v; want %v %v %v",
got.RaisePitchForCapitals, got.BeepForCapitals, got.SayCapForCapitals,
want.RaisePitchForCapitals, want.BeepForCapitals, want.SayCapForCapitals)
}
}
func TestOldCapitalStylesMigrate(t *testing.T) {
for _, style := range []string{"none", "spell", "icon"} {
for _, versioned := range []bool{false, true} {
contents := fmt.Sprintf("[general]\ncapitalizationStyle = %q\n", style)
if versioned {
contents = fmt.Sprintf("schema-version = 2\nactive-profile = 'default'\n[profiles.default.speech]\ncapitalization-style = %q\n", style)
}
settings, err := Load(writeConfig(t, contents))
if err != nil {
t.Fatal(err)
}
if !settings.RaisePitchForCapitals || settings.BeepForCapitals != (style == "icon") || settings.SayCapForCapitals != (style == "spell") {
t.Fatalf("old %s, versioned=%v: %+v", style, versioned, settings)
}
}
}
settings, err := Load(writeConfig(t, "schema-version = 2\nactive-profile = 'default'\n[profiles.default.speech]\ncapitalization-style = 'spell'\nsay-cap-for-capitals = false\nbeep-for-capitals = true\nraise-pitch-for-capitals = false\n"))
if err != nil {
t.Fatal(err)
}
if settings.RaisePitchForCapitals || settings.SayCapForCapitals || !settings.BeepForCapitals {
t.Fatal("explicit independent choices did not override old style")
}
}
func TestCapitalPitchCanBeDisabledWithExplicitUppercaseVoice(t *testing.T) {
settings := DefaultSettings()
normal, upper := 4.0, 8.0
settings.DefaultVoice.AveragePitch = &normal
settings.UppercaseVoice.AveragePitch = &upper
settings.RaisePitchForCapitals = false
if got := *EffectiveVoice(settings, "uppercase").AveragePitch; got != normal {
t.Fatalf("pitch=%v", got)
}
settings.DefaultVoice.AveragePitch = nil
if EffectiveVoice(settings, "uppercase").AveragePitch != nil {
t.Fatal("server default pitch was lost")
}
settings.RaisePitchForCapitals = true
if got := *EffectiveVoice(settings, "uppercase").AveragePitch; got != upper {
t.Fatalf("pitch=%v", got)
}
}
+1
View File
@@ -68,6 +68,7 @@ func DefaultSettings() Settings {
},
VerbalizePunctuationStyle: 1,
CapitalizationStyle: "none",
RaisePitchForCapitals: true,
KeyboardLayout: 1,
CthulhuModifierKeys: []string{"Insert", "KP_Insert"},
UseCustomEchoForKey: true,
+1 -1
View File
@@ -227,7 +227,7 @@ func apply_legacy_speech_formats(settings *Settings, values rawSettings) {
settings.VerbalizePunctuationStyle = *values.VerbalizePunctuationStyle
}
if values.CapitalizationStyle != nil {
settings.CapitalizationStyle = *values.CapitalizationStyle
settings.SetCapitalizationStyle(*values.CapitalizationStyle)
}
if values.TimeFormat != nil {
settings.TimeFormat = *values.TimeFormat
+18 -6
View File
@@ -112,11 +112,14 @@ func profileFromSettings(settings Settings) writableProfile {
Enabled: boolPointer(settings.EnableSpeech), ServerFactory: stringPointer(settings.SpeechServerFactory),
HistoryLimit: intPointer(settings.SpeechHistoryLimit),
Punctuation: intPointer(settings.VerbalizePunctuationStyle), Capitalization: stringPointer(settings.CapitalizationStyle),
DefaultVoice: voicePointer(settings.DefaultVoice),
UppercaseVoice: voicePointer(settings.UppercaseVoice),
HyperlinkVoice: voicePointer(settings.HyperlinkVoice),
SystemVoice: voicePointer(settings.SystemVoice),
OutputModule: stringPointer(settings.SpeechOutputModule),
RaisePitchForCapitals: boolPointer(settings.RaisePitchForCapitals),
BeepForCapitals: boolPointer(settings.BeepForCapitals),
SayCapForCapitals: boolPointer(settings.SayCapForCapitals),
DefaultVoice: voicePointer(settings.DefaultVoice),
UppercaseVoice: voicePointer(settings.UppercaseVoice),
HyperlinkVoice: voicePointer(settings.HyperlinkVoice),
SystemVoice: voicePointer(settings.SystemVoice),
OutputModule: stringPointer(settings.SpeechOutputModule),
},
Braille: writableBraille{Enabled: boolPointer(settings.EnableBraille),
Contracted: boolPointer(settings.EnableContractedBraille),
@@ -440,7 +443,16 @@ func apply_profile_speech(settings *Settings, profile writableProfile) {
settings.VerbalizePunctuationStyle = *value
}
if value := profile.Speech.Capitalization; value != nil {
settings.CapitalizationStyle = *value
settings.SetCapitalizationStyle(*value)
}
if value := profile.Speech.RaisePitchForCapitals; value != nil {
settings.RaisePitchForCapitals = *value
}
if value := profile.Speech.BeepForCapitals; value != nil {
settings.BeepForCapitals = *value
}
if value := profile.Speech.SayCapForCapitals; value != nil {
settings.SayCapForCapitals = *value
}
if value := profile.Speech.OutputModule; value != nil {
settings.SpeechOutputModule = *value
+15 -12
View File
@@ -117,18 +117,21 @@ type writableReading struct {
}
type writableSpeech struct {
HardwareDevice *string `toml:"hardware-device,omitempty"`
HardwareBaudRate *int `toml:"hardware-baud-rate,omitempty"`
OutputModule *string `toml:"output-module,omitempty"`
Enabled *bool `toml:"enabled,omitempty"`
HistoryLimit *int `toml:"history-limit,omitempty"`
ServerFactory *string `toml:"server-factory,omitempty"`
Punctuation *int `toml:"punctuation-style,omitempty"`
Capitalization *string `toml:"capitalization-style,omitempty"`
DefaultVoice *Voice `toml:"default-voice,omitempty"`
UppercaseVoice *Voice `toml:"uppercase-voice,omitempty"`
HyperlinkVoice *Voice `toml:"hyperlink-voice,omitempty"`
SystemVoice *Voice `toml:"system-voice,omitempty"`
HardwareDevice *string `toml:"hardware-device,omitempty"`
HardwareBaudRate *int `toml:"hardware-baud-rate,omitempty"`
OutputModule *string `toml:"output-module,omitempty"`
Enabled *bool `toml:"enabled,omitempty"`
HistoryLimit *int `toml:"history-limit,omitempty"`
ServerFactory *string `toml:"server-factory,omitempty"`
Punctuation *int `toml:"punctuation-style,omitempty"`
Capitalization *string `toml:"capitalization-style,omitempty"`
RaisePitchForCapitals *bool `toml:"raise-pitch-for-capitals,omitempty"`
BeepForCapitals *bool `toml:"beep-for-capitals,omitempty"`
SayCapForCapitals *bool `toml:"say-cap-for-capitals,omitempty"`
DefaultVoice *Voice `toml:"default-voice,omitempty"`
UppercaseVoice *Voice `toml:"uppercase-voice,omitempty"`
HyperlinkVoice *Voice `toml:"hyperlink-voice,omitempty"`
SystemVoice *Voice `toml:"system-voice,omitempty"`
}
type writableBraille struct {
+4 -1
View File
@@ -145,7 +145,10 @@ type Settings struct {
Pronunciations []PronunciationEntry
TextAttributes TextAttributeSettings
VerbalizePunctuationStyle int
CapitalizationStyle string
CapitalizationStyle string // Compatibility with older settings and the remote API.
RaisePitchForCapitals bool
BeepForCapitals bool
SayCapForCapitals bool
KeyBindings []input.BindingOverride
KeyboardLayout int
CthulhuModifierKeys []string
+7 -2
View File
@@ -5,7 +5,7 @@
package config
// EffectiveVoice resolves a sparse voice over Default. Uppercase retains the
// established pitch of 7 unless the user explicitly chooses another pitch.
// established automatic pitch unless overridden or capital pitch is disabled.
func EffectiveVoice(settings Settings, kind string) Voice {
voice := cloneVoice(settings.DefaultVoice)
var override Voice
@@ -15,8 +15,13 @@ func EffectiveVoice(settings Settings, kind string) Voice {
if voice.AveragePitch != nil {
pitch = min(9, max(pitch, *voice.AveragePitch+2))
}
voice.AveragePitch = &pitch
if settings.RaisePitchForCapitals {
voice.AveragePitch = &pitch
}
override = settings.UppercaseVoice
if !settings.RaisePitchForCapitals {
override.AveragePitch = nil
}
case "hyperlink":
override = settings.HyperlinkVoice
case "system":
+2 -3
View File
@@ -205,8 +205,8 @@ int prefs_init(const char *xml) {
gtk_entry_set_completion(GTK_ENTRY(prefs_widget("hardwareDeviceEntry")), completion);
g_object_unref(completion);
g_object_unref(devices);
const char *labelled[] = {"rate", "pitch", "volume", "capitalization"};
for (int i = 0; i < 4; i++) {
const char *labelled[] = {"rate", "pitch", "volume"};
for (unsigned i = 0; i < G_N_ELEMENTS(labelled); i++) {
gchar *id = g_strconcat(labelled[i], "_label", NULL);
name_widget(labelled[i], gtk_label_get_text(GTK_LABEL(prefs_widget(id))));
g_free(id);
@@ -226,7 +226,6 @@ int prefs_init(const char *xml) {
{"speechFamilies", "Person"},
{"speechHistoryLimitSpinButton", "Speech history entries"},
{"voiceTypesCombo", "Voice type"},
{"capitalization", "Capitalization style"},
{"hardwareDeviceEntry", "Serial device"},
{"hardwareBaudCombo", "Serial baud rate"},
{"contractionTableCombo", "Contraction table"},
+5 -2
View File
@@ -123,8 +123,11 @@
<child><object class="GtkCheckButton" id="rate_default"><property name="label">Use server default rate</property><property name="can-focus">True</property></object><packing><property name="left-attach">0</property><property name="top-attach">9</property><property name="width">2</property></packing></child>
<child><object class="GtkLabel" id="rate_label"><property name="label">_Rate:</property><property name="use-underline">True</property><property name="mnemonic-widget">rate</property><property name="xalign">1</property></object><packing><property name="left-attach">0</property><property name="top-attach">10</property></packing></child>
<child><object class="GtkScale" id="rate"><property name="adjustment">rate_adjustment</property><property name="can-focus">True</property><property name="digits">1</property><property name="hexpand">True</property><property name="value-pos">right</property></object><packing><property name="left-attach">1</property><property name="top-attach">10</property></packing></child>
<child><object class="GtkLabel" id="capitalization_label"><property name="label">_Capitalization style:</property><property name="use-underline">True</property><property name="mnemonic-widget">capitalization</property><property name="xalign">1</property></object><packing><property name="left-attach">0</property><property name="top-attach">11</property></packing></child>
<child><object class="GtkComboBoxText" id="capitalization"><property name="can-focus">False</property><items><item>None</item><item>Spell</item><item>Sound</item></items></object><packing><property name="left-attach">1</property><property name="top-attach">11</property></packing></child>
<child><object class="GtkFrame" id="capitalLettersFrame"><property name="label">Capital letters</property><child><object class="GtkBox"><property name="orientation">vertical</property><property name="spacing">6</property><property name="border-width">8</property>
<child><object class="GtkCheckButton" id="raisePitchForCapitals"><property name="label">Raise pitch for capital letters</property><property name="can-focus">True</property></object></child>
<child><object class="GtkCheckButton" id="beepForCapitals"><property name="label">Beep for capital letters</property><property name="can-focus">True</property></object></child>
<child><object class="GtkCheckButton" id="sayCapForCapitals"><property name="label">Say “cap” before capital letters</property><property name="can-focus">True</property></object></child>
</object></child></object><packing><property name="left-attach">0</property><property name="top-attach">11</property><property name="width">2</property></packing></child>
<child><object class="GtkEntry" id="hardwareDeviceEntry"><property name="can-focus">True</property><property name="tooltip-text">Serial device path; completion offers detected devices</property></object><packing><property name="left-attach">1</property><property name="top-attach">15</property></packing></child>
<child><object class="GtkLabel"><property name="label">Serial _device:</property><property name="use-underline">True</property><property name="mnemonic-widget">hardwareDeviceEntry</property><property name="xalign">1</property></object><packing><property name="left-attach">0</property><property name="top-attach">15</property></packing></child>
<child><object class="GtkLabel"><property name="label">Serial baud rate:</property><property name="mnemonic-widget">hardwareBaudCombo</property><property name="xalign">1</property></object><packing><property name="left-attach">0</property><property name="top-attach">17</property></packing></child>
+3 -1
View File
@@ -76,7 +76,9 @@ type Values struct {
SystemVoice config.Voice
VoiceType string // Preview selection; never persisted as reader state.
Punctuation int
Capitalization string
RaisePitchForCapitals bool
BeepForCapitals bool
SayCapForCapitals bool
Reading config.ReadingSettings
SpeakDescription bool
}
+17
View File
@@ -140,3 +140,20 @@ func TestKeyboardValuesMergeAndOwnership(t *testing.T) {
t.Fatalf("cannot disable all modifiers: %v", err)
}
}
func TestCapitalCheckboxesRoundTrip(t *testing.T) {
for mask := 0; mask < 8; mask++ {
settings := config.DefaultSettings()
values := FromSettings(settings)
values.RaisePitchForCapitals = mask&1 != 0
values.BeepForCapitals = mask&2 != 0
values.SayCapForCapitals = mask&4 != 0
merged, err := values.Merge(settings)
if err != nil {
t.Fatal(err)
}
if got := FromSettings(merged); !reflect.DeepEqual(got, values) {
t.Fatalf("mask %d did not round trip", mask)
}
}
}
+7 -7
View File
@@ -306,7 +306,9 @@ func set_speech_numbers(values Values, inherited config.Voice) {
func set_speech_presentation(values Values) {
setChoice("punctuation", values.Punctuation)
setCombo("capitalization", map[string]int{"none": 0, "spell": 1, "icon": 2}[values.Capitalization])
setCheck("raisePitchForCapitals", values.RaisePitchForCapitals)
setCheck("beepForCapitals", values.BeepForCapitals)
setCheck("sayCapForCapitals", values.SayCapForCapitals)
setReading(values)
}
@@ -357,17 +359,15 @@ func (session *uiSession) get_speech_values(values *Values) error {
values.HardwareSpeechBaudRate = hardwareBaudRates[indexBaud]
values.SpeechOutputModule = session.selectedSpeechModule()
values.Punctuation = getChoice("punctuation")
index := getCombo("capitalization")
if index < 0 || index > 2 {
return errors.New("Choose a capitalization style.")
}
values.Capitalization = []string{"none", "spell", "icon"}[index]
values.RaisePitchForCapitals = getCheck("raisePitchForCapitals")
values.BeepForCapitals = getCheck("beepForCapitals")
values.SayCapForCapitals = getCheck("sayCapForCapitals")
return nil
}
func (session *uiSession) load_voice_editor() {
voice := session.voiceDrafts[session.activeVoiceType]
settings := config.Settings{DefaultVoice: session.voiceDrafts[0], UppercaseVoice: session.voiceDrafts[1], HyperlinkVoice: session.voiceDrafts[2], SystemVoice: session.voiceDrafts[3]}
settings := config.Settings{RaisePitchForCapitals: true, DefaultVoice: session.voiceDrafts[0], UppercaseVoice: session.voiceDrafts[1], HyperlinkVoice: session.voiceDrafts[2], SystemVoice: session.voiceDrafts[3]}
kind := []string{"default", "uppercase", "hyperlink", "system"}[session.activeVoiceType]
set_speech_numbers(Values{Rate: voice.Rate, Pitch: voice.AveragePitch, Volume: voice.Gain}, config.EffectiveVoice(settings, kind))
C.prefs_voice_defaults(C.int(session.activeVoiceType))
+8 -2
View File
@@ -30,7 +30,10 @@ func FromSettings(settings config.Settings) Values {
ProgressBar: settings.ProgressBar,
RoleSoundPresentation: settings.RoleSoundPresentation,
Rate: settings.DefaultVoice.Rate, Pitch: settings.DefaultVoice.AveragePitch,
Volume: settings.DefaultVoice.Gain, Punctuation: settings.VerbalizePunctuationStyle, Capitalization: settings.CapitalizationStyle,
Volume: settings.DefaultVoice.Gain, Punctuation: settings.VerbalizePunctuationStyle,
RaisePitchForCapitals: settings.RaisePitchForCapitals,
BeepForCapitals: settings.BeepForCapitals,
SayCapForCapitals: settings.SayCapForCapitals,
SpeechOutputModule: settings.SpeechOutputModule,
SpeechServerFactory: settings.SpeechServerFactory,
HardwareSpeechDevice: settings.HardwareSpeechDevice,
@@ -126,7 +129,10 @@ func (values Values) Merge(settings config.Settings) (config.Settings, error) {
settings.HyperlinkVoice = values.HyperlinkVoice
settings.SystemVoice = values.SystemVoice
settings.VerbalizePunctuationStyle = values.Punctuation
settings.CapitalizationStyle = values.Capitalization
settings.CapitalizationStyle = "none"
settings.RaisePitchForCapitals = values.RaisePitchForCapitals
settings.BeepForCapitals = values.BeepForCapitals
settings.SayCapForCapitals = values.SayCapForCapitals
settings.Reading = values.Reading
settings.SpeakDescription = values.SpeakDescription
return settings, config.Validate(settings)
+7 -2
View File
@@ -33,12 +33,17 @@ func progress_percentage(cue string) (float64, bool) {
// Reference: nvaccess/nvda a4a89720ccbf3973a0e1e50a76dd8bff38db3d15,
// source/NVDAObjects/behaviors.py and nvdaHelper/local/beeps.cpp.
func progress_tone_wav(percentage float64) []byte {
return nvda_tone_wav(110*math.Pow(2, percentage/25), 40)
}
// nvda_tone_wav also supplies the capital-letter beep (2000 Hz, 50 ms).
func nvda_tone_wav(hz float64, milliseconds int) []byte {
const sampleRate = 44100
frequency := float64(float32(110 * math.Pow(2, percentage/25)))
frequency := float64(float32(hz))
// sampleRate/hz is evaluated as a float in NVDA's C++ generator.
cycleLength := float64(float32(sampleRate) / float32(frequency))
samplesPerCycle := int(cycleLength)
sampleCount := int(0.040 / (1.0 / sampleRate))
sampleCount := int((float64(milliseconds) / 1000) / (1.0 / sampleRate))
sampleCount += samplesPerCycle - sampleCount%samplesPerCycle
dataSize := sampleCount * 4
buffer := bytes.NewBuffer(make([]byte, 0, 44+dataSize))
+12 -1
View File
@@ -10,15 +10,26 @@ import (
)
var speechBeepWAV = tone_wav(440, 40*time.Millisecond, 0.35, 5*time.Millisecond)
var capitalBeepWAV = nvda_tone_wav(2000, 50)
// SpeechBeep replaces an utterance, independently of role-sound enablement or
// theme availability. The speech worker owns its ordering and cancellation;
// sound preferences still supply the output sink and volume.
func (service *Service) SpeechBeep(ctx context.Context) error {
return service.play_speech_beep(ctx, speechBeepWAV)
}
// CapitalBeep is ordered and canceled by the speech worker, just like speech.
// It uses sound volume and sink, independently of the role sound theme.
func (service *Service) CapitalBeep(ctx context.Context) error {
return service.play_speech_beep(ctx, capitalBeepWAV)
}
func (service *Service) play_speech_beep(ctx context.Context, data []byte) error {
service.mutex.Lock()
player, volume := service.player, service.volume
service.mutex.Unlock()
beepContext, cancelBeep := context.WithTimeout(ctx, 3*time.Second)
defer cancelBeep()
return player.Play(beepContext, speechBeepWAV, volume, 0)
return player.Play(beepContext, data, volume, 0)
}
+23
View File
@@ -6,7 +6,10 @@ package sound
import (
"context"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"log/slog"
"testing"
"time"
@@ -26,3 +29,23 @@ func TestSpeechBeepDecodesWithRoleSoundsDisabled(t *testing.T) {
t.Fatalf("canceled beep = %v", err)
}
}
func TestCapitalBeepMatchesNVDAAndDecodes(t *testing.T) {
// PCM from NVDA's C++ generateBeep(2000, 50, 50, 50), compiled separately.
if got := fmt.Sprintf("%x", sha256.Sum256(capitalBeepWAV[44:])); got != "3385e0c7e50f2b4abd8da47606999f27b651e999bcc04ba853e3a7338f469367" {
t.Fatalf("capital beep differs from NVDA: %s", got)
}
if binary.LittleEndian.Uint32(capitalBeepWAV[24:]) != 44100 || binary.LittleEndian.Uint16(capitalBeepWAV[22:]) != 2 {
t.Fatal("capital beep must be 44100 Hz stereo")
}
service := New(slog.New(slog.DiscardHandler), NativePlayer{Sink: "test"}, "none", 0.5)
service.Configure(Configuration{Enabled: false, Theme: "none", Sink: "test", Volume: 0.4})
if err := service.CapitalBeep(context.Background()); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := service.CapitalBeep(ctx); !errors.Is(err, context.Canceled) {
t.Fatalf("canceled capital beep: %v", err)
}
}