Update caps notification preferences.
This commit is contained in:
1 parent
3df96145f2
commit
1c4e0a99bc
34 files changed
+577
-56
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,7 @@ func DefaultSettings() Settings {
|
||||
},
|
||||
VerbalizePunctuationStyle: 1,
|
||||
CapitalizationStyle: "none",
|
||||
RaisePitchForCapitals: true,
|
||||
KeyboardLayout: 1,
|
||||
CthulhuModifierKeys: []string{"Insert", "KP_Insert"},
|
||||
UseCustomEchoForKey: true,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user