// 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") } }