// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package speech import ( "errors" "reflect" "strings" "testing" ) type recordingHardware struct { writes []string cancels []string closed int fail error } func (p *recordingHardware) Write(data []byte, canceled func() bool) error { if canceled() { return errHardwareCanceled } if p.fail != nil { return p.fail } p.writes = append(p.writes, string(data)) return nil } func (p *recordingHardware) Cancel(data []byte) error { p.cancels = append(p.cancels, string(data)) return nil } func (p *recordingHardware) Close() { p.closed++ } func TestHardwareProtocolsMatchPythonFixtures(t *testing.T) { for _, id := range []string{"dectalk", "dectalk-usb", "litetalk", "doubletalk", "tripletalk"} { t.Run(id, func(t *testing.T) { port := &recordingHardware{} client := new_hardware_client(id, Options{}, port) parameters := Parameters{Rate: 100, Pitch: -100, Volume: 0} if err := client.Configure(parameters); err != nil { t.Fatal(err) } if err := client.Configure(parameters); err != nil { t.Fatal(err) } if err := client.Say("Hello\nworld ☃ [:dv g5 10]\x01"); err != nil { t.Fatal(err) } want := []string{"\x019S", "\x010P", "\x014V", "Hello world [:dv g5 10] \r"} cancel := "\x18" if strings.HasPrefix(id, "dectalk") { want = []string{"[:ra 650]", "[:dv ap 50]", "[:dv g5 73]", "Hello world [ :dv g5 10] \x0b"} cancel = "\x03" if id == "dectalk-usb" { cancel = "\x06" } } if !reflect.DeepEqual(port.writes, want) { t.Fatalf("got %q, want %q", port.writes, want) } if err := client.Cancel(); err != nil { t.Fatal(err) } // A flush can discard partially queued settings; the next utterance // must reapply them even when their values have not changed. port.writes = nil if err := client.Say("fresh"); err != nil { t.Fatal(err) } if len(port.writes) != 4 || !reflect.DeepEqual(port.writes[:3], want[:3]) { t.Fatal(port.writes) } client.Close() client.Close() if port.closed != 1 || !reflect.DeepEqual(port.cancels, []string{cancel, cancel}) { t.Fatal(port) } }) } } func TestHardwareRangesAndNativeMidpoints(t *testing.T) { for _, test := range []struct { value int dectalk, lite [3]string }{ {-100, [3]string{"[:ra 75]", "[:dv ap 50]", "[:dv g5 60]"}, [3]string{"\x010S", "\x010P", "\x010V"}}, {0, [3]string{"[:ra 180]", "[:dv ap 122]", "[:dv g5 73]"}, [3]string{"\x014S", "\x0150P", "\x014V"}}, {100, [3]string{"[:ra 650]", "[:dv ap 350]", "[:dv g5 86]"}, [3]string{"\x019S", "\x0199P", "\x019V"}}, } { for _, dectalk := range []bool{false, true} { client := HardwareClient{dectalk: dectalk, parameters: Parameters{Rate: test.value, Pitch: test.value, Volume: test.value}} want := test.lite if dectalk { want = test.dectalk } if got := client.commands(); got != want { t.Fatal(test.value, got, want) } } } } func TestHardwareFailedSettingsAreRetriedAndPitchRestores(t *testing.T) { port := &recordingHardware{fail: errors.New("disconnected")} client := new_hardware_client("dectalk", Options{}, port) if err := client.Configure(Parameters{}); err == nil { t.Fatal("failed write accepted") } port.fail = nil if err := client.Configure(Parameters{}); err != nil { t.Fatal(err) } if err := client.Set_pitch(100); err != nil { t.Fatal(err) } if err := client.Say("A"); err != nil { t.Fatal(err) } if err := client.Set_pitch(0); err != nil { t.Fatal(err) } want := []string{"[:ra 180]", "[:dv ap 122]", "[:dv g5 73]", "[:dv ap 350]", "A\x0b", "[:dv ap 122]"} if !reflect.DeepEqual(port.writes, want) { t.Fatal(port.writes) } if err := client.Configure(Parameters{Rate: 101}); err == nil { t.Fatal("invalid rate accepted") } if err := client.Set_pitch(-101); err == nil { t.Fatal("invalid pitch accepted") } } func TestHardwareValidationDoesNotOpenDevices(t *testing.T) { for _, id := range []string{"dectalk", "litetalk", "doubletalk", "tripletalk"} { options := Options{Hardware: HardwareOptions{Device: "/dev/fixture-synth"}} if err := Validate_driver_options(id, options); err != nil { t.Fatal(id, err) } for _, path := range []string{"", "auto", "ttyUSB0", "/dev/ttyUSB0\n"} { options.Hardware.Device = path if err := Validate_driver_options(id, options); err == nil { t.Fatal(id, path) } } options.Hardware = HardwareOptions{Device: "/dev/fixture-synth", BaudRate: 12345} if err := Validate_driver_options(id, options); err == nil { t.Fatal("unsupported baud accepted") } } if err := Validate_driver_options("dectalk-usb", Options{}); err != nil { t.Fatal(err) } if err := Validate_driver_options("dectalk-usb", Options{Voice: "invented"}); err == nil { t.Fatal("hardware voice accepted") } }