Use shared Speech Dispatcher client v0.3.0

Adapt console speech and discovery to the shared SSIP transport while retaining worker recovery, queue admission, and hardware drivers.

Validated race-enabled speech, application, and preferences tests and the standalone build with GOWORK=off. Private native startup checks passed; physical keyboard, audible speech, and braille acceptance remain separate live checks.
This commit is contained in:
ak47 committed 2026-11-12 22:26:30 -05:00
1 parent a941e39f60
commit 0dd8e2f9c7
12 files changed
+99 -645

No files matched your search

+1 -1
View File
@@ -3,7 +3,7 @@ module git.stormux.org/storm/fenrir
go 1.27.2
require (
git.stormux.org/storm/screenreader-common v0.2.0
git.stormux.org/storm/screenreader-common v0.3.0
github.com/jfreymuth/pulse v0.1.3
github.com/leonelquinteros/gotext v1.8.0
github.com/pelletier/go-toml/v2 v2.4.3
+2
View File
@@ -2,6 +2,8 @@ git.stormux.org/storm/screenreader-common v0.1.1 h1:r/qcWUv4ODZ+XzXw1J+qe4hVnyr7
git.stormux.org/storm/screenreader-common v0.1.1/go.mod h1:iLmD0KXRvR4ar3U80JMwbaNUOyR82/gY7xHUY+zeIoY=
git.stormux.org/storm/screenreader-common v0.2.0 h1:PbGwyIi/nT0qwvscje4pvIIsAxDLBYwLV1eTXT36hxk=
git.stormux.org/storm/screenreader-common v0.2.0/go.mod h1:iLmD0KXRvR4ar3U80JMwbaNUOyR82/gY7xHUY+zeIoY=
git.stormux.org/storm/screenreader-common v0.3.0 h1:eWuSMf3lM8fB5RyegnsyHmdz1uP0CzPqMfFP8wonV3k=
git.stormux.org/storm/screenreader-common v0.3.0/go.mod h1:iLmD0KXRvR4ar3U80JMwbaNUOyR82/gY7xHUY+zeIoY=
github.com/jfreymuth/pulse v0.1.3 h1:bc5TdxiB8E+2INnFjFWWgyfgXtz2IyNNNCX+Wt/ZD14=
github.com/jfreymuth/pulse v0.1.3/go.mod h1:cpYspI6YljhkUf1WLXLLDmeaaPFc3CnGLjDZf9dZ4no=
github.com/leonelquinteros/gotext v1.8.0 h1:wwpxINWmWq3JN3WFI9BoSupBFrvAqxZTtf3qppijYkU=
-105
View File
@@ -1,105 +0,0 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package speech
import (
"fmt"
"net"
"os"
"os/user"
"path/filepath"
"strconv"
"strings"
)
type serverAddress struct {
network string
address string
}
// default_socket_path follows Python speechd, retaining Fenrir's sudo-user
// runtime directory selection for console sessions.
func default_socket_path() string {
if uid := os.Getenv("SUDO_UID"); uid != "" {
if parsed, err := strconv.ParseUint(uid, 10, 32); err == nil {
runtimeDirectory := os.Getenv("XDG_RUNTIME_DIR")
if !owned_directory(runtimeDirectory, uint32(parsed)) {
runtimeDirectory = filepath.Join("/run/user", uid)
}
if !owned_directory(runtimeDirectory, uint32(parsed)) {
if account, err := user.LookupId(uid); err == nil {
runtimeDirectory = filepath.Join(account.HomeDir, ".cache")
}
}
return filepath.Join(runtimeDirectory, "speech-dispatcher/speechd.sock")
}
}
runtimeDirectory := os.Getenv("XDG_RUNTIME_DIR")
if runtimeDirectory == "" {
runtimeDirectory = os.Getenv("XDG_CACHE_HOME")
}
if runtimeDirectory == "" {
cacheDirectory, err := os.UserCacheDir()
if err == nil {
runtimeDirectory = cacheDirectory
}
}
return filepath.Join(runtimeDirectory, "speech-dispatcher", "speechd.sock")
}
// resolve_server_address gives an internal socket override priority over speechd's
// standard address variables. SPEECHD_SOCKET is the older Unix-only setting.
func resolve_server_address(socket string) (serverAddress, error) {
if socket != "" {
return serverAddress{network: "unix", address: socket}, nil
}
if address := os.Getenv("SPEECHD_ADDRESS"); address != "" {
return parse_server_address(address)
}
if socket := os.Getenv("SPEECHD_SOCKET"); socket != "" {
return serverAddress{network: "unix", address: socket}, nil
}
return serverAddress{network: "unix", address: default_socket_path()}, nil
}
func parse_server_address(address string) (serverAddress, error) {
method, value, _ := strings.Cut(address, ":")
switch method {
case "unix_socket":
if value == "" {
value = default_socket_path()
}
if !filepath.IsAbs(value) {
return serverAddress{}, fmt.Errorf("Speech Dispatcher Unix socket must be an absolute path: %q", value)
}
return serverAddress{network: "unix", address: value}, nil
case "inet_socket":
host, port := "127.0.0.1", 6560
if value != "" {
parts := strings.Split(value, ":")
if len(parts) > 2 {
return serverAddress{}, fmt.Errorf("invalid Speech Dispatcher address %q", address)
}
if parts[0] != "" {
host = parts[0]
}
if len(parts) == 2 {
parsed, err := strconv.Atoi(parts[1])
if err != nil || parsed < 1 || parsed > 65535 {
return serverAddress{}, fmt.Errorf("invalid Speech Dispatcher port in %q", address)
}
port = parsed
}
}
return serverAddress{network: "tcp4", address: net.JoinHostPort(host, strconv.Itoa(port))}, nil
default:
return serverAddress{}, fmt.Errorf("unsupported Speech Dispatcher address %q", address)
}
}
// Explicit settings select exactly one server; only defaults use fallback.
func automatic_server_address(socket string) bool {
return socket == "" && os.Getenv("SPEECHD_ADDRESS") == "" && os.Getenv("SPEECHD_SOCKET") == ""
}
+12 -10
View File
@@ -10,6 +10,8 @@ import (
"path/filepath"
"strings"
"testing"
sd "git.stormux.org/storm/screenreader-common/speechdispatcher"
)
func TestResolveServerAddressPrecedence(t *testing.T) {
@@ -34,8 +36,8 @@ func TestResolveServerAddressPrecedence(t *testing.T) {
t.Setenv("SPEECHD_ADDRESS", test.speechAddress)
t.Setenv("SPEECHD_SOCKET", test.legacySocket)
t.Setenv("XDG_RUNTIME_DIR", test.runtimeDir)
got, err := resolve_server_address(test.override)
if err != nil || got.network != "unix" || got.address != test.want {
got, err := sd.ResolveAddress(test.override)
if err != nil || got.Network != "unix" || got.Address != test.want {
t.Fatalf("address = %+v, error = %v; want %q", got, err, test.want)
}
})
@@ -51,13 +53,13 @@ func TestParseServerAddress(t *testing.T) {
{"inet_socket:localhost:6561", "tcp4", "localhost:6561"},
{"inet_socket::6561", "tcp4", "127.0.0.1:6561"},
} {
got, err := parse_server_address(test.input)
if err != nil || got.network != test.network || got.address != test.address {
got, err := sd.ParseAddress(test.input)
if err != nil || got.Network != test.network || got.Address != test.address {
t.Fatalf("parse %q = %+v, %v", test.input, got, err)
}
}
for _, address := range []string{"invalid:/tmp/speech.sock", "unix_socket:relative.sock", "inet_socket:localhost:0", "inet_socket:localhost:bad"} {
if _, err := parse_server_address(address); err == nil {
if _, err := sd.ParseAddress(address); err == nil {
t.Fatalf("accepted invalid address %q", address)
}
}
@@ -118,7 +120,7 @@ func TestDefaultSocketWithoutRuntimeOrCacheEnvironment(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", "")
t.Setenv("HOME", t.TempDir())
want := filepath.Join(os.Getenv("HOME"), ".cache", "speech-dispatcher", "speechd.sock")
if got := default_socket_path(); got != want {
if got := sd.DefaultSocketAddress(); got != want {
t.Fatalf("socket %q, want %q", got, want)
}
}
@@ -126,19 +128,19 @@ func TestDefaultSocketWithoutRuntimeOrCacheEnvironment(t *testing.T) {
func TestAutomaticServerAddressHonorsOverrides(t *testing.T) {
t.Setenv("SPEECHD_ADDRESS", "")
t.Setenv("SPEECHD_SOCKET", "")
if !automatic_server_address("") {
if !sd.AutomaticAddress("") {
t.Fatal("default address disables system fallback")
}
if automatic_server_address("/custom.sock") {
if sd.AutomaticAddress("/custom.sock") {
t.Fatal("explicit socket allows system fallback")
}
t.Setenv("SPEECHD_SOCKET", "/legacy.sock")
if automatic_server_address("") {
if sd.AutomaticAddress("") {
t.Fatal("legacy socket allows system fallback")
}
t.Setenv("SPEECHD_ADDRESS", "unix_socket:/address.sock")
t.Setenv("SPEECHD_SOCKET", "")
if automatic_server_address("") {
if sd.AutomaticAddress("") {
t.Fatal("explicit address allows system fallback")
}
}
-187
View File
@@ -1,187 +0,0 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package speech
import (
"context"
"errors"
"fmt"
"net"
"os"
"os/exec"
"os/user"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
)
type serverDialer func(context.Context, string, string) (net.Conn, error)
// Automatic connections prefer the user's server, then the system service.
// Preserve the user's endpoint error if neither server is available so that
// autospawn still targets the user's socket, never the system socket.
func dial_with_system_fallback(dial serverDialer) serverDialer {
return func(ctx context.Context, network, address string) (net.Conn, error) {
conn, err := dial(ctx, network, address)
if ctx.Err() != nil || network != "unix" || (!errors.Is(err, syscall.ENOENT) && !errors.Is(err, syscall.ECONNREFUSED)) {
return conn, err
}
systemConn, systemErr := dial(ctx, "unix", "/run/speech-dispatcher/speechd.sock")
if systemErr == nil {
return systemConn, nil
}
if ctx.Err() != nil {
return nil, ctx.Err()
}
return nil, err
}
}
// Like Python speechd, try the existing server first, then ask the daemon to
// spawn according to its own configuration. Never start a local daemon for a
// remote TCP endpoint, and never stop a daemon owned by the session.
func connect_with_autospawn(ctx context.Context, endpoint serverAddress, dial serverDialer, spawn func(context.Context, serverAddress) error) (net.Conn, error) {
conn, err := dial(ctx, endpoint.network, endpoint.address)
if ctx.Err() != nil {
if conn != nil {
_ = conn.Close()
}
return nil, ctx.Err()
}
if err == nil {
return conn, err
}
// Only an absent or stale endpoint warrants starting a daemon.
if !errors.Is(err, syscall.ENOENT) && !errors.Is(err, syscall.ECONNREFUSED) {
return nil, err
}
if spawnErr := spawn(ctx, endpoint); spawnErr != nil {
return nil, fmt.Errorf("%w; autostart Speech Dispatcher: %v", err, spawnErr)
}
retryContext, cancel := context.WithTimeout(ctx, commandTimeout)
defer cancel()
for {
conn, err = dial(retryContext, endpoint.network, endpoint.address)
if err == nil {
return conn, nil
}
timer := time.NewTimer(50 * time.Millisecond)
select {
case <-retryContext.Done():
timer.Stop()
return nil, fmt.Errorf("after Speech Dispatcher autostart: %w", err)
case <-timer.C:
}
}
}
func spawn_arguments(ctx context.Context, endpoint serverAddress) ([]string, error) {
args := []string{"--spawn"}
if endpoint.network == "unix" {
return append(args, "--communication-method", "unix_socket", "--socket-path", endpoint.address), nil
}
host, port, err := net.SplitHostPort(endpoint.address)
if err != nil {
return nil, err
}
addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
if len(addresses) == 0 {
return nil, fmt.Errorf("no addresses for %s", host)
}
for _, address := range addresses {
if !address.IP.IsLoopback() {
return nil, fmt.Errorf("cannot autostart a remote Speech Dispatcher at %s", host)
}
}
return append(args, "--communication-method", "inet_socket", "--port", port), nil
}
func spawn_server(ctx context.Context, endpoint serverAddress) error {
spawnContext, cancel := context.WithTimeout(ctx, commandTimeout)
defer cancel()
args, err := spawn_arguments(spawnContext, endpoint)
if err != nil {
return err
}
command := os.Getenv("SPEECHD_CMD")
if command == "" {
command = "speech-dispatcher"
}
process := exec.CommandContext(spawnContext, command, args...)
if uid := os.Getenv("SUDO_UID"); uid != "" && uid != strconv.Itoa(os.Geteuid()) {
// Drop credentials only in the child. Never run a root-owned daemon in
// another user's session or change the reader's device privileges.
if os.Geteuid() != 0 {
return fmt.Errorf("cannot start Speech Dispatcher as the invoking user without root privileges")
}
account, err := user.LookupId(uid)
if err != nil {
return fmt.Errorf("look up invoking user for Speech Dispatcher: %w", err)
}
groups, err := account.GroupIds()
if err != nil {
return fmt.Errorf("look up invoking user's groups: %w", err)
}
if err := configure_sudo_spawn(process, account, groups); err != nil {
return err
}
}
process.WaitDelay = 100 * time.Millisecond
output, err := process.CombinedOutput()
if err != nil {
return fmt.Errorf("%s --spawn: %w: %s", command, err, strings.TrimSpace(string(output)))
}
return nil
}
func configure_sudo_spawn(process *exec.Cmd, account *user.User, groups []string) error {
uid, uidErr := strconv.ParseUint(account.Uid, 10, 32)
gid, gidErr := strconv.ParseUint(account.Gid, 10, 32)
if uidErr != nil || gidErr != nil || uid == 0 || !filepath.IsAbs(account.HomeDir) {
return fmt.Errorf("invalid invoking user for Speech Dispatcher")
}
credentials := &syscall.Credential{Uid: uint32(uid), Gid: uint32(gid)}
for _, group := range groups {
id, err := strconv.ParseUint(group, 10, 32)
if err != nil {
return fmt.Errorf("invalid invoking user's group: %w", err)
}
credentials.Groups = append(credentials.Groups, uint32(id))
}
process.SysProcAttr = &syscall.SysProcAttr{Credential: credentials}
process.Dir = account.HomeDir
// Do not pass root's HOME, XDG paths, D-Bus address, or unrelated secrets
// into a user-owned daemon. Preserve explicit audio choices and locale.
process.Env = []string{"HOME=" + account.HomeDir, "USER=" + account.Username,
"LOGNAME=" + account.Username, "PATH=/usr/local/bin:/usr/bin:/bin"}
for _, entry := range os.Environ() {
key, _, _ := strings.Cut(entry, "=")
if key == "LANG" || strings.HasPrefix(key, "LC_") || key == "PULSE_SERVER" || key == "PULSE_COOKIE" {
process.Env = append(process.Env, entry)
}
}
runtimeDir := filepath.Join("/run/user", account.Uid)
if candidate := os.Getenv("XDG_RUNTIME_DIR"); owned_directory(candidate, uint32(uid)) {
runtimeDir = candidate
}
if owned_directory(runtimeDir, uint32(uid)) {
process.Env = append(process.Env, "XDG_RUNTIME_DIR="+runtimeDir)
}
return nil
}
func owned_directory(path string, uid uint32) bool {
info, err := os.Stat(path)
if err != nil || !info.IsDir() {
return false
}
stat, ok := info.Sys().(*syscall.Stat_t)
return ok && stat.Uid == uid
}
+23 -21
View File
@@ -17,18 +17,20 @@ import (
"strings"
"syscall"
"testing"
sd "git.stormux.org/storm/screenreader-common/speechdispatcher"
)
func TestColdSpeechConnectionAutostartsAndRetriesSelectedEndpoint(t *testing.T) {
for _, connectError := range []error{syscall.ENOENT, syscall.ECONNREFUSED} {
t.Run(connectError.Error(), func(t *testing.T) {
endpoint := serverAddress{network: "unix", address: "/tmp/selected-speech.sock"}
endpoint := sd.Endpoint{Network: "unix", Address: "/tmp/selected-speech.sock"}
client, server := net.Pipe()
defer client.Close()
defer server.Close()
attempts, starts := 0, 0
got, err := connect_with_autospawn(context.Background(), endpoint, func(_ context.Context, network, address string) (net.Conn, error) {
if network != endpoint.network || address != endpoint.address {
got, err := sd.ConnectWithAutospawn(context.Background(), endpoint, func(_ context.Context, network, address string) (net.Conn, error) {
if network != endpoint.Network || address != endpoint.Address {
t.Fatal("connection address changed during autostart")
}
attempts++
@@ -36,7 +38,7 @@ func TestColdSpeechConnectionAutostartsAndRetriesSelectedEndpoint(t *testing.T)
return nil, connectError
}
return client, nil
}, func(_ context.Context, selected serverAddress) error {
}, func(_ context.Context, selected sd.Endpoint) error {
if selected != endpoint {
t.Fatal("spawn address differs from selected server")
}
@@ -53,24 +55,24 @@ func TestColdSpeechConnectionAutostartsAndRetriesSelectedEndpoint(t *testing.T)
func TestAutospawnKeepsRefusalAndCancellationErrors(t *testing.T) {
dial := func(context.Context, string, string) (net.Conn, error) { return nil, syscall.ECONNREFUSED }
starts := 0
spawn := func(context.Context, serverAddress) error { starts++; return errors.New("autospawn disabled") }
_, err := connect_with_autospawn(context.Background(), serverAddress{}, dial, spawn)
spawn := func(context.Context, sd.Endpoint) error { starts++; return errors.New("autospawn disabled") }
_, err := sd.ConnectWithAutospawn(context.Background(), sd.Endpoint{}, dial, spawn)
if !errors.Is(err, syscall.ECONNREFUSED) || !strings.Contains(err.Error(), "autospawn disabled") || starts != 1 {
t.Fatalf("error = %v, starts = %d", err, starts)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, _ = connect_with_autospawn(ctx, serverAddress{}, dial, spawn)
_, _ = sd.ConnectWithAutospawn(ctx, sd.Endpoint{}, dial, spawn)
if starts != 1 {
t.Fatal("canceled connection started a daemon")
}
ctx, cancel = context.WithCancel(context.Background())
_, err = connect_with_autospawn(ctx, serverAddress{}, func(ctx context.Context, _, _ string) (net.Conn, error) {
_, err = sd.ConnectWithAutospawn(ctx, sd.Endpoint{}, func(ctx context.Context, _, _ string) (net.Conn, error) {
if ctx.Err() != nil {
return nil, ctx.Err()
}
return nil, syscall.ECONNREFUSED
}, func(context.Context, serverAddress) error { cancel(); return nil })
}, func(context.Context, sd.Endpoint) error { cancel(); return nil })
if !errors.Is(err, context.Canceled) {
t.Fatalf("retry error = %v", err)
}
@@ -78,18 +80,18 @@ func TestAutospawnKeepsRefusalAndCancellationErrors(t *testing.T) {
func TestSpeechSpawnArgumentsPreserveEndpointAndRejectRemote(t *testing.T) {
for _, test := range []struct {
endpoint serverAddress
endpoint sd.Endpoint
want []string
}{
{serverAddress{"unix", "/tmp/custom.sock"}, []string{"--spawn", "--communication-method", "unix_socket", "--socket-path", "/tmp/custom.sock"}},
{serverAddress{"tcp4", "127.0.0.1:6561"}, []string{"--spawn", "--communication-method", "inet_socket", "--port", "6561"}},
{sd.Endpoint{"unix", "/tmp/custom.sock"}, []string{"--spawn", "--communication-method", "unix_socket", "--socket-path", "/tmp/custom.sock"}},
{sd.Endpoint{"tcp4", "127.0.0.1:6561"}, []string{"--spawn", "--communication-method", "inet_socket", "--port", "6561"}},
} {
args, err := spawn_arguments(context.Background(), test.endpoint)
args, err := sd.SpawnArguments(context.Background(), test.endpoint)
if err != nil || !reflect.DeepEqual(args, test.want) {
t.Fatalf("arguments = %v, %v", args, err)
}
}
if _, err := spawn_arguments(context.Background(), serverAddress{"tcp4", "192.0.2.1:6560"}); err == nil {
if _, err := sd.SpawnArguments(context.Background(), sd.Endpoint{"tcp4", "192.0.2.1:6560"}); err == nil {
t.Fatal("remote server allowed local autospawn")
}
}
@@ -101,7 +103,7 @@ func TestSpeechSpawnUsesConfiguredExecutableAndReportsRefusal(t *testing.T) {
t.Fatal(err)
}
t.Setenv("SPEECHD_CMD", path)
err := spawn_server(context.Background(), serverAddress{"unix", "/tmp/custom.sock"})
err := sd.SpawnServer(context.Background(), sd.Endpoint{"unix", "/tmp/custom.sock"})
if err == nil || !strings.Contains(err.Error(), "autospawn disabled by daemon") {
t.Fatalf("error = %v", err)
}
@@ -119,9 +121,9 @@ func TestExistingSpeechServerDoesNotInvokeSpawn(t *testing.T) {
func TestPermissionErrorDoesNotSpawn(t *testing.T) {
starts := 0
_, err := connect_with_autospawn(context.Background(), serverAddress{},
_, err := sd.ConnectWithAutospawn(context.Background(), sd.Endpoint{},
func(context.Context, string, string) (net.Conn, error) { return nil, syscall.EACCES },
func(context.Context, serverAddress) error { starts++; return nil })
func(context.Context, sd.Endpoint) error { starts++; return nil })
if !errors.Is(err, syscall.EACCES) || starts != 0 {
t.Fatalf("error %v, starts %d", err, starts)
}
@@ -154,7 +156,7 @@ func TestUnprivilegedProcessDoesNotImpersonateSudoUser(t *testing.T) {
t.Skip("requires an unprivileged caller")
}
t.Setenv("SUDO_UID", strconv.Itoa(os.Geteuid()+1))
err := spawn_server(context.Background(), serverAddress{"unix", "/tmp/custom.sock"})
err := sd.SpawnServer(context.Background(), sd.Endpoint{"unix", "/tmp/custom.sock"})
if err == nil || !strings.Contains(err.Error(), "invoking user") {
t.Fatalf("error = %v", err)
}
@@ -200,7 +202,7 @@ func TestNativeSpeechDispatcherColdStart(t *testing.T) {
}
}
}()
socket := default_socket_path()
socket := sd.DefaultSocketAddress()
if staleSocket {
if err := os.MkdirAll(filepath.Dir(socket), 0700); err != nil {
t.Fatal(err)
@@ -230,7 +232,7 @@ func TestNativeSpeechDispatcherColdStart(t *testing.T) {
t.Fatal(err)
}
defer client.Close()
if client.id == "" || client.control == nil {
if client.client == nil {
t.Fatal("cold-start client did not initialize both channels")
}
})
@@ -269,7 +271,7 @@ func TestSystemSocketFallback(t *testing.T) {
}
return client, nil
}
got, err := dial_with_system_fallback(dial)(context.Background(), "unix", "/user/speechd.sock")
got, err := sd.WithSystemFallback(dial)(context.Background(), "unix", "/user/speechd.sock")
want := []string{"/user/speechd.sock"}
if test.wantSystem {
want = append(want, "/run/speech-dispatcher/speechd.sock")
+11 -25
View File
@@ -12,9 +12,11 @@ import (
"time"
"unicode"
"unicode/utf8"
sd "git.stormux.org/storm/screenreader-common/speechdispatcher"
)
type Voice struct{ Name, Language, Variant string }
type Voice = sd.Voice
type Catalog struct {
Modules []string
@@ -46,20 +48,11 @@ func Discover(options Options) (Catalog, error) {
if err != nil {
return result, err
}
defer w.close()
deadline := time.Now().Add(4 * time.Second)
query := func(command string) ([]string, error) {
if time.Now().After(deadline) {
return nil, fmt.Errorf("speech discovery timed out")
}
if err := w.conn.SetDeadline(min_time(deadline, time.Now().Add(commandTimeout))); err != nil {
return nil, err
}
if _, err := fmt.Fprintf(w.conn, "%s\r\n", command); err != nil {
return nil, err
}
return w.reply()
}
defer w.Close()
ctx, cancel := context.WithTimeout(option_context(options), 4*time.Second)
defer cancel()
query := func(command string) ([]string, error) { return w.Command(ctx, command) }
modules, err := query("LIST OUTPUT_MODULES")
if err != nil {
return result, err
@@ -97,13 +90,6 @@ func Discover(options Options) (Catalog, error) {
return result, nil
}
func min_time(a, b time.Time) time.Time {
if a.Before(b) {
return a
}
return b
}
func parse_voices(rows []string) ([]Voice, error) {
voices := make([]Voice, 0, len(rows))
seen := map[string]bool{}
@@ -239,13 +225,13 @@ func discover_voices(options Options) ([]Voice, error) {
if err != nil {
return nil, err
}
defer w.close()
defer w.Close()
if options.Module != "" {
if _, err = w.command("SET SELF OUTPUT_MODULE " + options.Module); err != nil {
if _, err = w.Command(option_context(options), "SET SELF OUTPUT_MODULE "+options.Module); err != nil {
return nil, err
}
}
rows, err := w.command("LIST SYNTHESIS_VOICES")
rows, err := w.Command(option_context(options), "LIST SYNTHESIS_VOICES")
if err != nil {
return nil, err
}
-11
View File
@@ -4,7 +4,6 @@
package speech
import (
"bufio"
"fmt"
"reflect"
"strings"
@@ -52,16 +51,6 @@ func TestLargeVoiceCatalogAndSilentPreparation(t *testing.T) {
}
func TestBoundedRepliesAndInjection(t *testing.T) {
for _, reply := range []string{
"249-" + strings.Repeat("a", 5000) + "\n249 OK\n",
strings.Repeat("249-"+strings.Repeat("a", 3000)+"\n", 3000) + "249 OK\n",
"249-one\n250 OK\n",
} {
w := wire{reader: bufio.NewReaderSize(strings.NewReader(reply), 4096)}
if _, err := w.reply(); err == nil {
t.Fatal("accepted unbounded or malformed reply")
}
}
for _, row := range []string{"Missing tabs", "name\ten\tvariant\textra", "name\ten\rCANCEL ALL", "bad\x00name\ten"} {
if _, err := parse_voices([]string{row}); err == nil {
t.Fatal("accepted malformed catalog row", row)
+40 -278
View File
@@ -15,140 +15,24 @@
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
// Package speech implements the Speech Dispatcher SSIP boundary and admission.
// Package speech adapts shared Speech Dispatcher transport to console output.
package speech
import (
"bufio"
"context"
"fmt"
"net"
"strconv"
"strings"
"sync"
"time"
"unicode"
"unicode/utf8"
"git.stormux.org/storm/fenrir/internal/diagnostic"
sd "git.stormux.org/storm/screenreader-common/speechdispatcher"
)
// Speech Dispatcher serializes some module queries with speech commands.
// Large voice catalogs can delay acknowledgements beyond a fraction of a second.
// Keep the wait bounded while allowing those queries to finish.
const commandTimeout = 5 * time.Second
type Rejection struct{ Code string }
func (r *Rejection) Error() string { return "Speech Dispatcher rejected a command: " + r.Code }
type wire struct {
conn net.Conn
reader *bufio.Reader
stopContext func() bool
}
func (w *wire) detach_context() {
if w.stopContext != nil {
w.stopContext()
}
}
func (w *wire) close() {
w.detach_context()
_ = w.conn.Close()
}
func (w *wire) reply() ([]string, error) {
var data []string
expected := ""
total := 0
for count := 0; count < 65536; count++ {
part, err := w.reader.ReadSlice('\n')
line := string(part)
total += len(line)
if total > 8<<20 {
return nil, fmt.Errorf("SSIP reply exceeds byte limit")
}
if err != nil {
return nil, err
}
line = strings.TrimRight(line, "\r\n")
if len(line) < 4 || line[3] != '-' && line[3] != ' ' {
return nil, fmt.Errorf("malformed SSIP reply")
}
if _, err := strconv.Atoi(line[:3]); err != nil {
return nil, fmt.Errorf("invalid SSIP reply code")
}
if expected == "" {
expected = line[:3]
}
if expected != line[:3] {
return nil, fmt.Errorf("inconsistent SSIP reply codes")
}
if line[3] == ' ' {
if line[0] != '2' {
return nil, &Rejection{Code: line[:3]}
}
return data, nil
}
data = append(data, line[4:])
}
return nil, fmt.Errorf("SSIP reply exceeds line limit")
}
func (w *wire) command(command string) ([]string, error) {
if err := w.conn.SetDeadline(time.Now().Add(commandTimeout)); err != nil {
return nil, err
}
if _, err := fmt.Fprintf(w.conn, "%s\r\n", command); err != nil {
return nil, err
}
return w.reply()
}
// Queries share the address resolver. Only Prepare opts into daemon startup;
// ordinary settings discovery never starts a service.
func connect_options(options Options) (*wire, error) {
endpoint, err := resolve_server_address(options.Socket)
if err != nil {
return nil, err
}
return connect_endpoint(options.context, endpoint, options.autospawn, automatic_server_address(options.Socket))
}
func connect_endpoint(parent context.Context, endpoint serverAddress, autospawn, systemFallback bool) (*wire, error) {
if parent == nil {
parent = context.Background()
}
ctx, cancel := context.WithTimeout(parent, 3*commandTimeout)
defer cancel()
dialer := &net.Dialer{Timeout: commandTimeout}
dial := serverDialer(dialer.DialContext)
if systemFallback {
dial = dial_with_system_fallback(dial)
}
var conn net.Conn
var err error
if autospawn {
conn, err = connect_with_autospawn(ctx, endpoint, dial, spawn_server)
} else {
conn, err = dial(ctx, endpoint.network, endpoint.address)
}
if err != nil {
return nil, err
}
return &wire{conn: conn, reader: bufio.NewReaderSize(conn, 4096), stopContext: context.AfterFunc(parent, func() { _ = conn.Close() })}, nil
}
type Client struct {
data, control *wire
id string
sendMu sync.Mutex
controlMu sync.Mutex
closeOnce sync.Once
options Options
}
type Rejection = sd.Rejection
type Options struct {
// Only Prepare enables startup during its prerequisite selection queries.
@@ -175,6 +59,18 @@ func (p Parameters) Validate() error {
return nil
}
type Client struct {
client *sd.Client
options Options
closeOnce sync.Once
}
func option_context(options Options) context.Context {
if options.context != nil {
return options.context
}
return context.Background()
}
func Open(options Options) (*Client, error) {
if options.Rate < -100 || options.Rate > 100 {
return nil, fmt.Errorf("speech rate must be between -100 and 100")
@@ -187,179 +83,45 @@ func Open(options Options) (*Client, error) {
if err := Validate_selection(options.Module, options.Language, options.Voice); err != nil {
return nil, err
}
endpoint, err := resolve_server_address(options.Socket)
if err != nil {
backend := sd.NewWithSettings(options.Socket, sd.Settings{
ClientName: "fenrir", SystemFallback: true,
Rate: &options.Rate, Pitch: options.Pitch, Volume: options.Volume,
OutputModule: options.Module, Language: options.Language, SynthesisVoice: options.Voice,
Punctuation: "NONE", Capitalization: "NONE",
})
if err := backend.Connect(option_context(options)); err != nil {
backend.Close(context.Background())
return nil, err
}
data, err := connect_endpoint(options.context, endpoint, true, automatic_server_address(options.Socket))
if err != nil {
return nil, fmt.Errorf("connect Speech Dispatcher at %s: %w", endpoint.address, err)
}
client := &Client{data: data, options: options}
fail := func(err error) (*Client, error) {
data.close()
if client.control != nil {
client.control.close()
}
return nil, err
}
commands := []string{
`SET SELF CLIENT_NAME "fenrir:fenrir:main"`,
"SET SELF PRIORITY MESSAGE",
"SET SELF PUNCTUATION NONE",
"SET SELF CAP_LET_RECOGN NONE",
fmt.Sprintf("SET SELF RATE %d", options.Rate),
}
if options.Module != "" {
commands = append(commands, "SET SELF OUTPUT_MODULE "+options.Module)
}
if options.Pitch != nil {
commands = append(commands, fmt.Sprintf("SET SELF PITCH %d", *options.Pitch))
}
if options.Volume != nil {
commands = append(commands, fmt.Sprintf("SET SELF VOLUME %d", *options.Volume))
}
if options.Language != "" {
commands = append(commands, "SET SELF LANGUAGE "+options.Language)
}
if options.Voice != "" {
commands = append(commands, "SET SELF SYNTHESIS_VOICE "+options.Voice)
}
for _, command := range commands {
if _, err := data.command(command); err != nil {
return fail(err)
}
}
identifiers, err := data.command("HISTORY GET CLIENT_ID")
if err != nil {
return fail(err)
}
if len(identifiers) != 1 {
return fail(fmt.Errorf("missing Speech Dispatcher client ID"))
}
id, err := strconv.ParseUint(identifiers[0], 10, 64)
if err != nil {
return fail(fmt.Errorf("invalid Speech Dispatcher client ID"))
}
client.id = strconv.FormatUint(id, 10)
// Bind control to the same server selected for data, including fallback.
if endpoint.network == "unix" {
endpoint.address = data.conn.RemoteAddr().String()
}
client.control, err = connect_endpoint(options.context, endpoint, false, false)
if err != nil {
return fail(err)
}
if _, err := client.control.command(`SET SELF CLIENT_NAME "fenrir:fenrir:control"`); err != nil {
return fail(err)
}
data.detach_context()
client.control.detach_context()
if options.context != nil && options.context.Err() != nil {
client.abort()
return nil, options.context.Err()
}
client.options.context = nil
return client, nil
options.context = nil
return &Client{client: backend, options: options}, nil
}
// Reopen reconnects only the selected server and synthesis options.
func (c *Client) Reopen(ctx context.Context) (Transport, error) {
options := c.options
options.context = ctx
client, err := Open(options)
if err != nil {
return nil, err
}
return client, nil
return Open(options)
}
// Configure shares the speaking connection, so subsequent utterances use the
// new values. The service worker calls this away from the input loop.
func (c *Client) Configure(parameters Parameters) error {
if err := parameters.Validate(); err != nil {
return err
}
c.sendMu.Lock()
defer c.sendMu.Unlock()
for _, setting := range []struct {
name string
value int
}{{"RATE", parameters.Rate}, {"PITCH", parameters.Pitch}, {"VOLUME", parameters.Volume}} {
if _, err := c.data.command(fmt.Sprintf("SET SELF %s %d", setting.name, setting.value)); err != nil {
return err
}
}
return nil
return c.client.Configure(context.Background(), &parameters.Rate, &parameters.Pitch, &parameters.Volume)
}
func (c *Client) Say(text string) error {
c.sendMu.Lock()
defer c.sendMu.Unlock()
started := time.Now()
if char, size := utf8.DecodeRuneInString(text); size == len(text) && size > 0 && utf8.ValidString(text) && !unicode.IsControl(char) && !unicode.IsSpace(char) {
// Single-letter feedback needs one command/acknowledgement, rather
// than a SPEAK handshake followed by a separate text transaction.
_, err := c.data.command("CHAR " + text)
diagnostic.Timing("speech CHAR acknowledgement", started)
return err
}
if _, err := c.data.command("SPEAK"); err != nil {
return err
}
diagnostic.Timing("speech SPEAK acknowledgement", started)
text = strings.ReplaceAll(text, "\r\n", "\n")
text = strings.ReplaceAll(text, "\r", "\n")
text = strings.ReplaceAll(text, "\x00", "")
lines := strings.Split(text, "\n")
for index, line := range lines {
if strings.HasPrefix(line, ".") {
lines[index] = "." + line
}
}
if err := c.data.conn.SetDeadline(time.Now().Add(commandTimeout)); err != nil {
return err
}
if _, err := fmt.Fprintf(c.data.conn, "%s\r\n.\r\n", strings.Join(lines, "\r\n")); err != nil {
return err
}
_, err := c.data.reply()
return err
}
// Set_pitch is called by the serialized speech worker, never the input loop.
func (c *Client) Set_pitch(pitch int) error {
if pitch < -100 || pitch > 100 {
return fmt.Errorf("speech pitch must be between -100 and 100")
return c.client.Configure(context.Background(), nil, &pitch, nil)
}
func (c *Client) Say(text string) error {
started := time.Now()
defer diagnostic.Timing("speech acknowledgement", started)
if char, size := utf8.DecodeRuneInString(text); size == len(text) && size > 0 && utf8.ValidString(text) && !unicode.IsControl(char) && !unicode.IsSpace(char) {
return c.client.SpeakCharacter(context.Background(), text)
}
c.sendMu.Lock()
defer c.sendMu.Unlock()
_, err := c.data.command(fmt.Sprintf("SET SELF PITCH %d", pitch))
return err
return c.client.SpeakText(context.Background(), text)
}
func (c *Client) Cancel() error { return c.client.Cancel(context.Background()) }
func (c *Client) Close() { c.closeOnce.Do(func() { c.client.Shutdown(context.Background()) }) }
func (c *Client) abort() { c.client.Abort() }
// Cancel uses the independent control socket and targets only this client.
// Service holds the dispatch barrier for the final cancellation.
func (c *Client) Cancel() error {
c.controlMu.Lock()
defer c.controlMu.Unlock()
_, err := c.control.command("CANCEL " + c.id)
return err
}
func (c *Client) Close() {
c.closeOnce.Do(func() {
// Closing the data socket releases a blocked sender before cancellation.
c.data.close()
c.Cancel()
c.control.close()
})
}
// abort releases blocked initialization commands without another SSIP request.
func (c *Client) abort() {
_ = c.data.conn.Close()
if c.control != nil {
_ = c.control.conn.Close()
}
func connect_options(options Options) (*sd.Query, error) {
return sd.OpenQuery(option_context(options), options.Socket, options.autospawn, true)
}
+5 -3
View File
@@ -28,6 +28,8 @@ import (
"sync"
"testing"
"time"
sd "git.stormux.org/storm/screenreader-common/speechdispatcher"
)
func TestSpeechAcknowledgementWaitsForBusyDispatcher(t *testing.T) {
@@ -146,7 +148,7 @@ func (s *fakeServer) serve(conn net.Conn) {
s.nextID++
id := 40 + s.nextID
s.mu.Unlock()
fmt.Fprintf(conn, "200-%d\r\n200 OK CLIENT ID SENT\r\n", id)
fmt.Fprintf(conn, "245-%d\r\n245 OK CLIENT ID SENT\r\n", id)
case strings.HasPrefix(line, "CHAR "):
s.events <- "char:" + strings.TrimPrefix(line, "CHAR ")
fmt.Fprint(conn, "200 OK\r\n")
@@ -304,11 +306,11 @@ func TestDefaultSocketForSudo(t *testing.T) {
runtimeDirectory := t.TempDir()
t.Setenv("SUDO_UID", strconv.Itoa(os.Getuid()))
t.Setenv("XDG_RUNTIME_DIR", runtimeDirectory)
if got, err := resolve_server_address(""); err != nil || got.address != filepath.Join(runtimeDirectory, "speech-dispatcher/speechd.sock") {
if got, err := sd.ResolveAddress(""); err != nil || got.Address != filepath.Join(runtimeDirectory, "speech-dispatcher/speechd.sock") {
t.Fatal(got, err)
}
t.Setenv("SPEECHD_ADDRESS", "unix_socket:/tmp/example-speechd.sock")
if got, err := resolve_server_address(""); err != nil || got.address != "/tmp/example-speechd.sock" {
if got, err := sd.ResolveAddress(""); err != nil || got.Address != "/tmp/example-speechd.sock" {
t.Fatal(got, err)
}
}
+2 -2
View File
@@ -116,8 +116,8 @@ func discover_modules(options Options) ([]string, error) {
if err != nil {
return nil, err
}
defer wire.close()
modules, err := wire.command("LIST OUTPUT_MODULES")
defer wire.Close()
modules, err := wire.Command(option_context(options), "LIST OUTPUT_MODULES")
if err != nil {
return nil, err
}
+3 -2
View File
@@ -21,6 +21,7 @@ import (
"time"
"git.stormux.org/storm/fenrir/internal/audio"
sd "git.stormux.org/storm/screenreader-common/speechdispatcher"
)
type disconnectedTransport struct {
@@ -180,7 +181,7 @@ func TestSudoSpawnDropsChildCredentialsAndFiltersRootEnvironment(t *testing.T) {
t.Setenv("PULSE_SERVER", "unix:/tmp/selected-audio.sock")
command := exec.Command("speech-dispatcher", "--spawn")
account := &user.User{Uid: "2000", Gid: "2001", Username: "Username", HomeDir: "/home/Username"}
if err := configure_sudo_spawn(command, account, []string{"2001", "2002"}); err != nil {
if err := sd.ConfigureSudoSpawn(command, account, []string{"2001", "2002"}); err != nil {
t.Fatal(err)
}
want := &syscall.Credential{Uid: 2000, Gid: 2001, Groups: []uint32{2001, 2002}}
@@ -192,7 +193,7 @@ func TestSudoSpawnDropsChildCredentialsAndFiltersRootEnvironment(t *testing.T) {
t.Fatal(environment)
}
account.Uid = "0"
if err := configure_sudo_spawn(command, account, nil); err == nil {
if err := sd.ConfigureSudoSpawn(command, account, nil); err == nil {
t.Fatal("invalid invoking user selected root")
}
}