642 lines
23 KiB
Go
642 lines
23 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package app
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
"git.stormux.org/storm/cthulhu/internal/config"
|
|
"git.stormux.org/storm/cthulhu/internal/input"
|
|
"git.stormux.org/storm/cthulhu/internal/preferences"
|
|
"git.stormux.org/storm/cthulhu/internal/remote"
|
|
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
|
|
)
|
|
|
|
func TestEchoSpeakerSwitchesBothVoicesTransactionally(t *testing.T) {
|
|
for _, wrapped := range []bool{false, true} {
|
|
t.Run(fmt.Sprintf("configured=%v", wrapped), func(t *testing.T) { test_echo_speaker(t, wrapped) })
|
|
}
|
|
}
|
|
|
|
func test_echo_speaker(t *testing.T, wrapped bool) {
|
|
path := filepath.Join(t.TempDir(), "speech.sock")
|
|
listener, err := net.Listen("unix", path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
type utterance struct {
|
|
module, text string
|
|
rate, pitch string
|
|
volume, voice string
|
|
}
|
|
spoken := make(chan utterance, 10)
|
|
var handlers sync.WaitGroup
|
|
acceptDone := make(chan struct{})
|
|
go func() {
|
|
defer close(acceptDone)
|
|
for id := 1; ; id++ {
|
|
conn, err := listener.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
handlers.Add(1)
|
|
go func(id int) {
|
|
defer handlers.Done()
|
|
defer conn.Close()
|
|
reader := bufio.NewReader(conn)
|
|
module, rate, pitch := "", "", ""
|
|
volume, voice := "", ""
|
|
notifications := false
|
|
for {
|
|
line, err := reader.ReadString('\n')
|
|
if err != nil {
|
|
return
|
|
}
|
|
command := strings.TrimSpace(line)
|
|
response := "200 OK\r\n"
|
|
if command == "SET SELF NOTIFICATION ALL on" {
|
|
notifications = true
|
|
} else if command == "SET SELF NOTIFICATION ALL off" {
|
|
notifications = false
|
|
}
|
|
if strings.HasPrefix(command, "SET SELF OUTPUT_MODULE ") {
|
|
module = strings.TrimPrefix(command, "SET SELF OUTPUT_MODULE ")
|
|
}
|
|
if strings.HasPrefix(command, "SET SELF RATE ") {
|
|
rate = strings.TrimPrefix(command, "SET SELF RATE ")
|
|
}
|
|
if strings.HasPrefix(command, "SET SELF PITCH ") {
|
|
pitch = strings.TrimPrefix(command, "SET SELF PITCH ")
|
|
}
|
|
if strings.HasPrefix(command, "SET SELF VOLUME ") {
|
|
volume = strings.TrimPrefix(command, "SET SELF VOLUME ")
|
|
}
|
|
if strings.HasPrefix(command, "SET SELF SYNTHESIS_VOICE ") {
|
|
voice = strings.TrimPrefix(command, "SET SELF SYNTHESIS_VOICE ")
|
|
}
|
|
if command == "SET SELF OUTPUT_MODULE rejected" {
|
|
response = "400 Module unavailable\r\n"
|
|
}
|
|
if command == "HISTORY GET CLIENT_ID" {
|
|
response = fmt.Sprintf("245-%d\r\n245 OK\r\n", id)
|
|
}
|
|
if strings.HasPrefix(command, "CHAR ") {
|
|
spoken <- utterance{module, strings.TrimPrefix(command, "CHAR "), rate, pitch, volume, voice}
|
|
}
|
|
if command == "SPEAK" {
|
|
var text strings.Builder
|
|
for {
|
|
part, err := reader.ReadString('\n')
|
|
if err != nil {
|
|
return
|
|
}
|
|
if part == ".\r\n" {
|
|
break
|
|
}
|
|
text.WriteString(strings.TrimSuffix(part, "\r\n"))
|
|
}
|
|
spoken <- utterance{module, text.String(), rate, pitch, volume, voice}
|
|
response = "230 OK\r\n200 QUEUED\r\n"
|
|
if notifications {
|
|
response += fmt.Sprintf("701-1\r\n701-%d\r\n701 BEGIN\r\n702-1\r\n702-%d\r\n702 END\r\n", id, id)
|
|
}
|
|
}
|
|
if _, err := conn.Write([]byte(response)); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}(id)
|
|
}
|
|
}()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
settings := config.DefaultSettings()
|
|
settings.UseCustomEchoVoice = true
|
|
settings.UseCustomEchoSpeechServer = true
|
|
settings.EchoSpeechServer = "echo-module"
|
|
rate := 80.0
|
|
settings.EchoVoice.Rate = &rate
|
|
var speaker interface {
|
|
configuredVoice
|
|
UpdateConfiguration(context.Context, config.Settings) error
|
|
} = newEchoSpeaker(path, settings)
|
|
if wrapped {
|
|
speaker = newConfiguredSpeaker(path, settings)
|
|
}
|
|
defer func() { speaker.Close(ctx); listener.Close(); <-acceptDone; handlers.Wait() }()
|
|
if err := speaker.Connect(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
check := func(custom bool, wantModule, wantRate string) {
|
|
t.Helper()
|
|
var err error
|
|
if custom {
|
|
err = speaker.SpeakEcho(ctx, "test")
|
|
} else {
|
|
err = speaker.Speak(ctx, "test")
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
select {
|
|
case got := <-spoken:
|
|
if got.module != wantModule || got.rate != wantRate {
|
|
t.Fatalf("utterance=%+v, want module %q rate %q", got, wantModule, wantRate)
|
|
}
|
|
case <-ctx.Done():
|
|
t.Fatal("speech missing")
|
|
}
|
|
}
|
|
check(false, "", "")
|
|
check(true, "echo-module", "62")
|
|
if wrapped {
|
|
// Exercise the new request path through the actual configured backend
|
|
// and private SSIP connections, including a failure in the echo client
|
|
// after preparation of the new main client has already succeeded.
|
|
readerWorker, session, _ := reader_extension_fixture(t, "normal")
|
|
base := settings
|
|
base.Path = readerWorker.settings.Path
|
|
readerWorker.settings, readerWorker.globalSettings = base, base
|
|
readerWorker.updater = speaker
|
|
readerWorker.live.Store(base)
|
|
configuration := tentaclewire.SpeechFromSettings(base)
|
|
configuration.Module = "tentacle-module"
|
|
profileRate := 65.0
|
|
configuration.DefaultVoice.Rate = &profileRate
|
|
if _, err := readerWorker.apply_reader_request(ctx, session, tentaclewire.ReaderRequest{Speech: &configuration}, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
check(false, "tentacle-module", "32")
|
|
check(true, "echo-module", "62")
|
|
configuration.Module, configuration.EchoServer = "candidate-module", "rejected"
|
|
if _, err := readerWorker.apply_reader_request(ctx, session, tentaclewire.ReaderRequest{Speech: &configuration}, false); err == nil {
|
|
t.Fatal("failed echo client accepted through reader API")
|
|
}
|
|
check(false, "tentacle-module", "32")
|
|
check(true, "echo-module", "62")
|
|
if _, err := readerWorker.apply_reader_request(ctx, session, tentaclewire.ReaderRequest{RestoreSpeech: true}, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
check(false, "", "")
|
|
check(true, "echo-module", "62")
|
|
}
|
|
// Key names use completion notifications on the selected voice's real
|
|
// SSIP connection, so following navigation cannot cancel an active echo.
|
|
keySpeech := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
|
|
for _, custom := range []bool{false, true} {
|
|
if err := keySpeech.deliver(ctx, speechRequest{Text: "right", Role: "key", Cause: "key-echo", CustomEcho: custom}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := <-spoken
|
|
wantModule, wantRate := "", ""
|
|
if custom {
|
|
wantModule, wantRate = "echo-module", "62"
|
|
}
|
|
if got.text != "right" || got.module != wantModule || got.rate != wantRate {
|
|
t.Fatalf("key echo custom=%v: %+v", custom, got)
|
|
}
|
|
}
|
|
// Drive the insertion presenter through the real SSIP clients for every
|
|
// echo kind. Per-kind voice selection must not affect other echo kinds.
|
|
for _, kind := range []string{"key", "character", "word", "sentence"} {
|
|
for _, selected := range []bool{false, true} {
|
|
current := settings
|
|
current.EnableEchoByCharacter = kind == "character"
|
|
current.EnableEchoByWord = kind == "word"
|
|
current.EnableEchoBySentence = kind == "sentence"
|
|
current.UseCustomEchoForKey = selected
|
|
current.UseCustomEchoForCharacter = selected
|
|
current.UseCustomEchoForWord = selected
|
|
current.UseCustomEchoForSentence = selected
|
|
live := &liveSettings{}
|
|
live.Store(current)
|
|
queue := &remoteSpeechQueue{}
|
|
presenter := eventPresenter{state: &eventLoopState{}, live: live, speech: queue, logger: slog.New(slog.DiscardHandler)}
|
|
payload, contextText, want := "é", "Café", "é"
|
|
if kind == "word" {
|
|
payload, contextText, want = " ", "Café ", "Café"
|
|
} else if kind == "sentence" {
|
|
payload, contextText, want = " ", "First. Second. ", "Second."
|
|
}
|
|
presenter.speak(atspi.Event{Type: atspi.TextInsertedEvent, Payload: payload,
|
|
Source: atspi.Snapshot{Role: "entry", Editable: true, TextBeforeCaret: contextText}})
|
|
if len(queue.requests) != 1 {
|
|
t.Fatalf("%s: requests=%+v", kind, queue.requests)
|
|
}
|
|
if err := keySpeech.deliver(ctx, queue.requests[0]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := <-spoken
|
|
wantModule, wantRate := "", ""
|
|
if selected {
|
|
wantModule, wantRate = "echo-module", "62"
|
|
}
|
|
if got.text != want || got.module != wantModule || got.rate != wantRate {
|
|
t.Fatalf("%s custom=%v: %+v", kind, selected, got)
|
|
}
|
|
}
|
|
}
|
|
// Preview unsaved echo selections through the real SSIP clients. Only the
|
|
// numeric request uses that endpoint; subsequent dialog speech stays main.
|
|
previewWorker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, speaker)
|
|
previewValues := preferences.FromSettings(settings)
|
|
previewRate, previewPitch, previewVolume := 70.0, 6.0, 10.0
|
|
previewValues.EchoSpeechServer = "preview-module"
|
|
previewValues.EchoVoice = config.Voice{Rate: &previewRate, AveragePitch: &previewPitch, Gain: &previewVolume,
|
|
Family: &config.VoiceFamily{Name: "Preview voice", Lang: "en"}}
|
|
previewSpeech := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
|
|
previewWorker.announceEchoPreview = func(label, value string) {
|
|
if err := previewSpeech.deliver(ctx, echo_preview_speech(label, value)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for _, test := range []struct{ slider, text string }{{"rate", "70.0"}, {"pitch", "6.0"}, {"volume", "10.0"}, {"synthesizer", "preview-module"}, {"person", "Preview voice (en)"}} {
|
|
if response := previewWorker.preview(ctx, preferences.Request{Values: previewValues, Preview: true, EchoPreview: test.slider, EchoPreviewFocus: true}); !response.Accepted {
|
|
t.Fatalf("echo preview rejected: %+v", response)
|
|
}
|
|
select {
|
|
case got := <-spoken:
|
|
if !strings.HasPrefix(got.text, "Key echo ") || got.module != "" || got.voice != "" {
|
|
t.Fatalf("echo focus label did not use the normal voice: %+v", got)
|
|
}
|
|
case <-ctx.Done():
|
|
t.Fatal("echo focus label missing")
|
|
}
|
|
select {
|
|
case got := <-spoken:
|
|
if got.text != test.text || got.module != "preview-module" || got.voice != "Preview voice" ||
|
|
got.rate != "42" || got.pitch != "30" || got.volume != "100" {
|
|
t.Fatalf("echo slider SSIP request = %+v", got)
|
|
}
|
|
case <-ctx.Done():
|
|
t.Fatal("echo slider SSIP request missing")
|
|
}
|
|
check(false, "", "")
|
|
}
|
|
if response := previewWorker.restorePreview(ctx); !response.Accepted {
|
|
t.Fatalf("restore echo preview: %+v", response)
|
|
}
|
|
check(true, "echo-module", "62")
|
|
checkCharacters := func(wantNormal string) {
|
|
t.Helper()
|
|
worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
|
|
for _, tc := range []struct {
|
|
text string
|
|
character bool
|
|
pitch string
|
|
}{
|
|
{"A", true, "50"}, {"a", true, wantNormal}, {"É", true, "50"},
|
|
{"o", true, wantNormal}, {"o", true, wantNormal},
|
|
{"猫", true, wantNormal}, {"7", true, wantNormal}, {"space", true, wantNormal},
|
|
{"ABC", false, wantNormal}, {"A", false, wantNormal},
|
|
} {
|
|
if err := worker.deliver(ctx, speechRequest{Text: tc.text, Character: tc.character}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
select {
|
|
case got := <-spoken:
|
|
if got.pitch != tc.pitch || got.text != tc.text || got.module != "" {
|
|
t.Fatalf("character %v: got %+v, want text %q pitch %q", tc.character, got, tc.text, tc.pitch)
|
|
}
|
|
case <-ctx.Done():
|
|
t.Fatal("character speech missing")
|
|
}
|
|
}
|
|
}
|
|
checkCharacters("")
|
|
worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
|
|
// Exercise deletion from event presentation through the real SSIP client,
|
|
// checking both uppercase pitch and restoration on the following letter.
|
|
for _, custom := range []bool{false, true} {
|
|
deletionSettings := settings
|
|
deletionSettings.UseCustomEchoVoice = custom
|
|
live := &liveSettings{}
|
|
live.Store(deletionSettings)
|
|
queue := &fakeSpeechQueue{spoken: make(chan string, 1), requests: make(chan speechRequest, 1)}
|
|
presenter := eventPresenter{state: &eventLoopState{}, logger: slog.New(slog.DiscardHandler), speech: queue, live: live}
|
|
for _, text := range []string{"A", "a", "É", "é"} {
|
|
snapshot := atspi.Snapshot{BusName: ":1.42", Path: "/editor", Role: "entry", Editable: true}
|
|
presenter.speak(atspi.NewEventWithPayload(atspi.TextDeletedEvent, snapshot, text))
|
|
request := <-queue.requests
|
|
<-queue.spoken
|
|
if err := worker.deliver(ctx, request); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := <-spoken
|
|
wantPitch, wantModule, wantRate := "", "", ""
|
|
if text == "A" || text == "É" {
|
|
wantPitch = "50"
|
|
}
|
|
if custom {
|
|
wantModule, wantRate = "echo-module", "62"
|
|
}
|
|
if got.text != text || got.pitch != wantPitch || got.module != wantModule || got.rate != wantRate {
|
|
t.Fatalf("deleted %q, custom=%v: %+v", text, custom, got)
|
|
}
|
|
}
|
|
}
|
|
// Wine insertion must use the same character voice as native typing.
|
|
for _, custom := range []bool{false, true} {
|
|
wineSettings := settings
|
|
wineSettings.UseCustomEchoVoice = custom
|
|
live := &liveSettings{}
|
|
live.Store(wineSettings)
|
|
queue := &remoteSpeechQueue{}
|
|
braille := &remoteBrailleQueue{running: true}
|
|
presenter := remoteSpeech{speech: queue, braille: braille, live: live}
|
|
owner := newWineEventPresenter(func(message string) bool {
|
|
return presenter.presentMessage(message, "wine-accessible-event")
|
|
})
|
|
presenter.wine = owner
|
|
presenter.PresentAccessibleEvent(remote.AccessibleEvent{SourceID: "42:text", EventType: "focus", Role: "edit"})
|
|
owner.flush()
|
|
queue.requests, braille.text = nil, nil
|
|
for _, text := range []string{"A", "a", "É", "é", "é", "𐐀", "𐐨", "😀", "space", "new line"} {
|
|
if !presenter.PresentAccessibleEvent(remote.AccessibleEvent{
|
|
SourceID: "42:text", EventType: "text", Name: text, States: "start=1 end=1 kind=8",
|
|
}) {
|
|
t.Fatalf("Wine insertion %q rejected", text)
|
|
}
|
|
request := queue.requests[len(queue.requests)-1]
|
|
if err := worker.deliver(ctx, request); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := <-spoken
|
|
wantPitch, wantModule, wantRate := "", "", ""
|
|
if text == "A" || text == "É" || text == "𐐀" {
|
|
wantPitch = "50"
|
|
}
|
|
if custom {
|
|
wantModule, wantRate = "echo-module", "62"
|
|
}
|
|
if got.text != text || got.pitch != wantPitch || got.module != wantModule || got.rate != wantRate {
|
|
t.Fatalf("Wine insertion %q, custom=%v: %+v; want pitch %q module %q rate %q", text, custom, got, wantPitch, wantModule, wantRate)
|
|
}
|
|
if braille.text[len(braille.text)-1] != text {
|
|
t.Fatalf("Wine braille changed character case: %v", braille.text)
|
|
}
|
|
}
|
|
owner.Close()
|
|
}
|
|
for _, text := range []string{"A", "a", "É"} {
|
|
if err := worker.deliver(ctx, speechRequest{Text: text, Character: true, CustomEcho: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := <-spoken
|
|
wantPitch := "50"
|
|
if text == "a" {
|
|
wantPitch = ""
|
|
}
|
|
if got.module != "echo-module" || got.rate != "62" || got.pitch != wantPitch {
|
|
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")
|
|
}
|
|
}
|
|
// Delayed arrow hints must not repeat any capital cues. Exercise all
|
|
// capital combinations through SSIP, including a specialized source role.
|
|
capitals.reading = func() config.ReadingSettings {
|
|
return config.ReadingSettings{PhoneticCharacterHints: true}
|
|
}
|
|
capitals.inputActivity = func() input.Activity {
|
|
return input.Activity{PlainCharacterArrow: true, InputSequence: 1}
|
|
}
|
|
before := beeps
|
|
if err := capitals.deliver(ctx, speechRequest{Text: "T", Role: "system", Character: true, PhoneticHint: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantPitch := ""
|
|
if choices.RaisePitchForCapitals {
|
|
wantPitch = "50"
|
|
}
|
|
if choices.SayCapForCapitals {
|
|
if got := <-spoken; got.text != "cap" || got.pitch != wantPitch {
|
|
t.Fatalf("mask %d initial character cap prefix: %+v", mask, got)
|
|
}
|
|
}
|
|
if got := <-spoken; got.text != "T" || got.pitch != wantPitch {
|
|
t.Fatalf("mask %d initial character: %+v", mask, got)
|
|
}
|
|
if got := <-spoken; got.text != "tango" || got.pitch != "" || got.module != "" {
|
|
t.Fatalf("mask %d delayed hint repeated capital or specialized voice: %+v", mask, got)
|
|
}
|
|
wantBeeps := 0
|
|
if choices.BeepForCapitals {
|
|
wantBeeps = 1
|
|
}
|
|
if beeps-before != wantBeeps {
|
|
t.Fatalf("mask %d delayed hint repeated capital beep: %d", mask, beeps-before)
|
|
}
|
|
}
|
|
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
|
|
settings.SystemVoice.AveragePitch = &systemPitch
|
|
if err := speaker.UpdateConfiguration(ctx, settings); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, tc := range []struct {
|
|
request speechRequest
|
|
pitch string
|
|
}{
|
|
{speechRequest{Text: "A", Character: true}, "70"},
|
|
{speechRequest{Text: "A", Character: true, CustomEcho: true}, "70"},
|
|
{speechRequest{Text: "link text", Role: "link"}, "-70"},
|
|
{speechRequest{Text: "status text", Role: "system"}, "-50"},
|
|
{speechRequest{Text: "ordinary text"}, ""},
|
|
{speechRequest{Spelling: []string{"alpha", "bravo"}, SpellingVoices: []string{"uppercase", ""}}, "70"},
|
|
} {
|
|
if err := worker.deliver(ctx, tc.request); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := <-spoken
|
|
if got.pitch != tc.pitch {
|
|
t.Fatalf("voice selection for %+v = %+v", tc.request, got)
|
|
}
|
|
if len(tc.request.Spelling) != 0 {
|
|
if got := <-spoken; got.pitch != "" {
|
|
t.Fatalf("uppercase spelling pitch leaked: %+v", got)
|
|
}
|
|
}
|
|
}
|
|
// Verify real SSIP SPEAK requests from focus presentation, including source
|
|
// punctuation, the selected special voice, and disabling pause chunks.
|
|
for _, enabled := range []bool{true, false} {
|
|
pauseSettings := settings
|
|
pauseSettings.Reading.EnablePauseBreaks = enabled
|
|
pauseSettings.EnableSound = false
|
|
live := &liveSettings{}
|
|
live.Store(pauseSettings)
|
|
queue := &fakeSpeechQueue{spoken: make(chan string, 1), requests: make(chan speechRequest, 1)}
|
|
presenter := eventPresenter{state: &eventLoopState{}, logger: slog.New(slog.DiscardHandler), speech: queue, live: live}
|
|
presenter.speak(atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{Name: "Read, draft. Next!", Role: "link", Description: "Open document"}))
|
|
if err := worker.deliver(ctx, <-queue.requests); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := []string{"Read, draft. Next!", "link", "Open document"}
|
|
if !enabled {
|
|
want = []string{"Read, draft. Next! link Open document"}
|
|
}
|
|
for _, text := range want {
|
|
select {
|
|
case got := <-spoken:
|
|
if got.text != text || got.pitch != "-70" || got.module != "" {
|
|
t.Fatalf("pause chunks enabled=%v: SSIP utterance=%+v, want text %q with hyperlink pitch", enabled, got, text)
|
|
}
|
|
case <-ctx.Done():
|
|
t.Fatal("pause chunk SSIP request missing")
|
|
}
|
|
}
|
|
if len(spoken) != 0 {
|
|
t.Fatal("unexpected extra SSIP request")
|
|
}
|
|
}
|
|
settings.UppercaseVoice, settings.HyperlinkVoice, settings.SystemVoice = config.Voice{}, config.Voice{}, config.Voice{}
|
|
if err := speaker.UpdateConfiguration(ctx, settings); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
settings.EchoSpeechServer = "rejected"
|
|
if err := speaker.UpdateConfiguration(ctx, settings); err == nil {
|
|
t.Fatal("invalid module accepted")
|
|
}
|
|
check(false, "", "")
|
|
check(true, "echo-module", "62")
|
|
settings.UseCustomEchoVoice = false
|
|
if err := speaker.UpdateConfiguration(ctx, settings); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
check(true, "", "")
|
|
pitch := 3.0
|
|
settings.DefaultVoice.AveragePitch = &pitch
|
|
if err := speaker.UpdateConfiguration(ctx, settings); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
checkCharacters("-30")
|
|
if wrapped {
|
|
test_hardware_switch(t, speaker.(*configuredSpeaker), settings)
|
|
check(false, "", "")
|
|
checkCharacters("-30")
|
|
}
|
|
if err := speaker.Cancel(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := speaker.Close(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := speaker.UpdateConfiguration(ctx, settings); err == nil {
|
|
t.Fatal("closed speaker revived")
|
|
}
|
|
}
|