383 lines
11 KiB
Go
383 lines
11 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
//go:build linux && cgo
|
|
|
|
package speech
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
"unsafe"
|
|
)
|
|
|
|
func TestHardwarePreviewKeepsTemporaryPortUntilCanceledAndReleasesIt(t *testing.T) {
|
|
master, path := hardware_pty(t)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- Test_hardware(ctx, "dectalk", Options{Hardware: HardwareOptions{Device: path}}, "test sample")
|
|
}()
|
|
want := "[:ra 180][:dv ap 122][:dv g5 73]test sample\x0b"
|
|
if got := string(read_hardware(t, master, len(want))); got != want {
|
|
t.Fatal(got)
|
|
}
|
|
if other, err := Open_driver("dectalk", Options{Hardware: HardwareOptions{Device: path}}); err == nil {
|
|
other.Close()
|
|
t.Fatal("preview closed before device could speak")
|
|
}
|
|
cancel()
|
|
select {
|
|
case err := <-done:
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatal(err)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("preview did not cancel")
|
|
}
|
|
client, err := Open_driver("dectalk", Options{Hardware: HardwareOptions{Device: path}})
|
|
if err != nil {
|
|
t.Fatal("preview retained port", err)
|
|
}
|
|
client.Close()
|
|
}
|
|
|
|
func hardware_pty(t *testing.T) (*os.File, string) {
|
|
t.Helper()
|
|
fd, err := syscall.Open("/dev/ptmx", syscall.O_RDWR|syscall.O_NOCTTY|syscall.O_NONBLOCK|syscall.O_CLOEXEC, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
master := os.NewFile(uintptr(fd), "hardware fixture")
|
|
t.Cleanup(func() { master.Close() })
|
|
var unlock int32
|
|
if _, _, err := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), syscall.TIOCSPTLCK, uintptr(unsafe.Pointer(&unlock))); err != 0 {
|
|
t.Fatal(err)
|
|
}
|
|
var number uint32
|
|
if _, _, err := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), syscall.TIOCGPTN, uintptr(unsafe.Pointer(&number))); err != 0 {
|
|
t.Fatal(err)
|
|
}
|
|
return master, fmt.Sprintf("/dev/pts/%d", number)
|
|
}
|
|
|
|
func read_hardware(t *testing.T, master *os.File, count int) []byte {
|
|
t.Helper()
|
|
var result []byte
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for len(result) < count && time.Now().Before(deadline) {
|
|
buffer := make([]byte, count-len(result))
|
|
n, err := syscall.Read(int(master.Fd()), buffer)
|
|
if n > 0 {
|
|
result = append(result, buffer[:n]...)
|
|
}
|
|
if err != nil && err != syscall.EAGAIN && err != syscall.EINTR {
|
|
t.Fatal(err)
|
|
}
|
|
if n <= 0 {
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
}
|
|
if len(result) != count {
|
|
t.Fatalf("read %q (%d of %d)", result, len(result), count)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func assert_hardware_idle(t *testing.T, master *os.File) {
|
|
t.Helper()
|
|
var buffer [128]byte
|
|
if n, err := syscall.Read(int(master.Fd()), buffer[:]); n > 0 || err != syscall.EAGAIN {
|
|
t.Fatalf("unexpected bytes %q, %v", buffer[:max(n, 0)], err)
|
|
}
|
|
}
|
|
|
|
func hardware_termios(t *testing.T, fd int) syscall.Termios {
|
|
t.Helper()
|
|
var value syscall.Termios
|
|
if _, _, err := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), syscall.TCGETS, uintptr(unsafe.Pointer(&value))); err != 0 {
|
|
t.Fatal(err)
|
|
}
|
|
return value
|
|
}
|
|
|
|
func TestNativeSerialHardwareLifecycle(t *testing.T) {
|
|
for _, id := range []string{"dectalk", "litetalk", "doubletalk", "tripletalk"} {
|
|
t.Run(id, func(t *testing.T) {
|
|
master, path := hardware_pty(t)
|
|
slave, err := syscall.Open(path, syscall.O_RDWR|syscall.O_NOCTTY|syscall.O_CLOEXEC, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer syscall.Close(slave)
|
|
original := hardware_termios(t, slave)
|
|
options := Options{Hardware: HardwareOptions{Device: path, BaudRate: 19200}}
|
|
transport, err := Open_driver(id, options)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer transport.Close()
|
|
configured := hardware_termios(t, slave)
|
|
if configured.Cflag&syscall.CS8 == 0 || configured.Lflag&(syscall.ICANON|syscall.ECHO) != 0 || configured.Iflag&(syscall.IXOFF|syscall.IXANY) != 0 || (configured.Iflag&syscall.IXON != 0) != (id == "dectalk") {
|
|
t.Fatal("serial mode", configured)
|
|
}
|
|
assert_hardware_idle(t, master) // Opening a candidate never speaks.
|
|
if other, err := Open_driver(id, options); err == nil {
|
|
other.Close()
|
|
t.Fatal("another reader acquired an owned serial device")
|
|
}
|
|
if err := transport.Configure(Parameters{Volume: 100}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := transport.Say("Hello\nworld ☃"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := "\x014S\x0150P\x019VHello world \r"
|
|
cancel := "\x18"
|
|
if id == "dectalk" {
|
|
want = "[:ra 180][:dv ap 122][:dv g5 86]Hello world \x0b"
|
|
cancel = "\x03"
|
|
}
|
|
if got := string(read_hardware(t, master, len(want))); got != want {
|
|
t.Fatalf("%q != %q", got, want)
|
|
}
|
|
if err := transport.Configure(Parameters{Volume: 100}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assert_hardware_idle(t, master)
|
|
if err := transport.Cancel(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := string(read_hardware(t, master, 1)); got != cancel {
|
|
t.Fatal(got)
|
|
}
|
|
transport.Close()
|
|
if restored := hardware_termios(t, slave); restored != original {
|
|
t.Fatal("termios not restored", restored, original)
|
|
}
|
|
reopened, err := Open_driver(id, options)
|
|
if err != nil {
|
|
t.Fatal("serial lock survived close", err)
|
|
}
|
|
reopened.Close()
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSerialCancellationStopsLargeWriteAndAllowsFreshSpeech(t *testing.T) {
|
|
master, path := hardware_pty(t)
|
|
transport, err := Open_driver("dectalk", Options{Hardware: HardwareOptions{Device: path}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer transport.Close()
|
|
if err := transport.Configure(Parameters{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
read_hardware(t, master, len("[:ra 180][:dv ap 122][:dv g5 73]"))
|
|
done := make(chan error, 1)
|
|
go func() { done <- transport.Say(strings.Repeat("x", 1<<20)) }()
|
|
if string(read_hardware(t, master, 1)) != "x" {
|
|
t.Fatal("text write did not start")
|
|
}
|
|
if err := transport.Cancel(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("cancellation waited behind speech")
|
|
}
|
|
// Discard already transmitted fixture bytes, then ensure nothing from the
|
|
// canceled utterance can appear after a fresh request.
|
|
var buffer [32768]byte
|
|
for {
|
|
if _, err := syscall.Read(int(master.Fd()), buffer[:]); err == syscall.EAGAIN {
|
|
break
|
|
} else if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := transport.Say("fresh"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := "[:ra 180][:dv ap 122][:dv g5 73]fresh\x0b"
|
|
if got := string(read_hardware(t, master, len(want))); got != want {
|
|
t.Fatalf("stale output %q", got)
|
|
}
|
|
assert_hardware_idle(t, master)
|
|
}
|
|
|
|
func TestSerialDisconnectReturnsError(t *testing.T) {
|
|
master, path := hardware_pty(t)
|
|
transport, err := Open_driver("litetalk", Options{Hardware: HardwareOptions{Device: path}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer transport.Close()
|
|
master.Close()
|
|
if err := transport.Say("after disconnect"); err == nil {
|
|
t.Fatal("disconnected serial device accepted speech")
|
|
}
|
|
}
|
|
|
|
func TestSerialXoffPauseRemainsCancelable(t *testing.T) {
|
|
master, path := hardware_pty(t)
|
|
transport, err := Open_driver("dectalk", Options{Hardware: HardwareOptions{Device: path}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer transport.Close()
|
|
if err := transport.Configure(Parameters{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
read_hardware(t, master, len("[:ra 180][:dv ap 122][:dv g5 73]"))
|
|
// Packet mode acknowledges that the terminal processed XOFF before the
|
|
// write starts; this does not rely on a sleep or on filling a PTY buffer.
|
|
packetMode := int32(1)
|
|
if _, _, err := syscall.Syscall(syscall.SYS_IOCTL, master.Fd(), syscall.TIOCPKT, uintptr(unsafe.Pointer(&packetMode))); err != 0 {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := syscall.Write(int(master.Fd()), []byte{0x13}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := read_hardware(t, master, 1)[0]; got&syscall.TIOCPKT_STOP == 0 {
|
|
t.Fatal("XOFF was not processed", got)
|
|
}
|
|
packetMode = 0
|
|
if _, _, err := syscall.Syscall(syscall.SYS_IOCTL, master.Fd(), syscall.TIOCPKT, uintptr(unsafe.Pointer(&packetMode))); err != 0 {
|
|
t.Fatal(err)
|
|
}
|
|
done := make(chan error, 1)
|
|
go func() { done <- transport.Say("paused") }()
|
|
select {
|
|
case err := <-done:
|
|
t.Fatal("normal flow pause terminated speech", err)
|
|
case <-time.After(time.Second):
|
|
}
|
|
if err := transport.Cancel(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
slave, err := syscall.Open(path, syscall.O_RDWR|syscall.O_NOCTTY, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
configured := hardware_termios(t, slave)
|
|
syscall.Close(slave)
|
|
if configured.Iflag&syscall.IXON == 0 {
|
|
t.Fatal("cancellation did not restore XON/XOFF")
|
|
}
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("cancel failed during flow pause")
|
|
}
|
|
if got := string(read_hardware(t, master, 1)); got != "\x03" {
|
|
t.Fatal(got)
|
|
}
|
|
if err := transport.Say("resumed"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := "[:ra 180][:dv ap 122][:dv g5 73]resumed\x0b"
|
|
if got := string(read_hardware(t, master, len(want))); got != want {
|
|
t.Fatal(got)
|
|
}
|
|
}
|
|
|
|
func TestUSBCancelBypassesBlockedPacketAndDiscardsRemainingText(t *testing.T) {
|
|
started, release := make(chan struct{}), make(chan struct{})
|
|
var releaseOnce sync.Once
|
|
unblock := func() { releaseOnce.Do(func() { close(release) }) }
|
|
defer unblock()
|
|
var normal, immediate atomic.Int32
|
|
port := &usbPort{send: func(endpoint byte, data []byte) error {
|
|
if endpoint == 0x04 {
|
|
if !bytes.Equal(data, []byte{0x06}) {
|
|
return fmt.Errorf("bad cancellation %x", data)
|
|
}
|
|
immediate.Add(1)
|
|
} else if bytes.HasPrefix(data, []byte("xxx")) {
|
|
if normal.Add(1) == 1 {
|
|
close(started)
|
|
<-release
|
|
}
|
|
}
|
|
return nil
|
|
}}
|
|
client := new_hardware_client("dectalk-usb", Options{}, port)
|
|
done := make(chan error, 1)
|
|
go func() { done <- client.Say(strings.Repeat("x", 200)) }()
|
|
select {
|
|
case <-started:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("packet not started")
|
|
}
|
|
if err := client.Cancel(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if immediate.Load() != 1 {
|
|
t.Fatal("immediate endpoint was blocked")
|
|
}
|
|
unblock()
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("stale send stuck")
|
|
}
|
|
if normal.Load() != 1 {
|
|
t.Fatal("more stale packets transmitted", normal.Load())
|
|
}
|
|
client.Close()
|
|
}
|
|
|
|
func TestUSBPacketLimitsFailureAndCleanup(t *testing.T) {
|
|
var sizes []int
|
|
closed := 0
|
|
port := &usbPort{send: func(endpoint byte, data []byte) error {
|
|
if endpoint != 2 {
|
|
t.Fatal(endpoint)
|
|
}
|
|
sizes = append(sizes, len(data))
|
|
return nil
|
|
}, close: func() { closed++ }}
|
|
if err := port.Write(make([]byte, 130), func() bool { return false }); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(sizes, []int{64, 64, 2}) {
|
|
t.Fatal(sizes)
|
|
}
|
|
failure := errors.New("short transfer")
|
|
port.send = func(byte, []byte) error { return failure }
|
|
if err := port.Write(make([]byte, 70), func() bool { return false }); !errors.Is(err, failure) {
|
|
t.Fatal(err)
|
|
}
|
|
port.Close()
|
|
port.Close()
|
|
if closed != 1 {
|
|
t.Fatal(closed)
|
|
}
|
|
if err := port.Write([]byte{1}, func() bool { return false }); err == nil {
|
|
t.Fatal("closed USB device accepted write")
|
|
}
|
|
}
|