// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package speech import ( "errors" "fmt" "math" "path/filepath" "slices" "strings" "sync" "sync/atomic" ) var errHardwareCanceled = errors.New("hardware speech write canceled") // Hardware device settings take effect at launch. Serial paths are explicit; // DECtalk USB finds a single native device, without probing serial ports. type HardwareOptions struct { Device string BaudRate int } // Baud_rates supplies validation and settings choices from the same list. // Zero retains the saved default, which selects 9600 on open. func Baud_rates() []int { return []int{0, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200} } func hardware_driver(id string) bool { switch id { case "dectalk", "dectalk-usb", "litetalk", "doubletalk", "tripletalk": return true } return false } func Validate_driver_options(id string, options Options) error { if err := Validate_selection(options.Module, options.Language, options.Voice); err != nil { return err } if !hardware_driver(id) { return nil } if options.Module != "" || options.Language != "" || options.Voice != "" { return fmt.Errorf("%s supports default module, language and voice only", id) } if id == "dectalk-usb" { return nil } device := options.Hardware.Device if device == "" || !filepath.IsAbs(device) || strings.ContainsAny(device, "\x00\r\n") { return fmt.Errorf("%s requires an explicit absolute hardware_device path", id) } if slices.Contains(Baud_rates(), options.Hardware.BaudRate) { return nil } return fmt.Errorf("unsupported hardware_baud_rate %d", options.Hardware.BaudRate) } // Write checks cancellation between packets. Cancel must bypass a blocked // normal write; Close releases resources after stopping in-flight operations. type hardwarePort interface { Write([]byte, func() bool) error Cancel([]byte) error Close() } type HardwareClient struct { port hardwarePort dectalk bool usb bool mu sync.Mutex // Serialized settings/text; cancellation never takes this lock. parameters Parameters cache [3]string cacheGeneration uint64 generation atomic.Uint64 closed atomic.Bool } func new_hardware_client(id string, options Options, port hardwarePort) *HardwareClient { parameters := Parameters{Rate: options.Rate} if options.Pitch != nil { parameters.Pitch = *options.Pitch } if options.Volume != nil { parameters.Volume = *options.Volume } return &HardwareClient{port: port, dectalk: id == "dectalk" || id == "dectalk-usb", usb: id == "dectalk-usb", parameters: parameters} } func hardware_parameters(options Options) error { parameters := Parameters{Rate: options.Rate} if options.Pitch != nil { parameters.Pitch = *options.Pitch } if options.Volume != nil { parameters.Volume = *options.Volume } return parameters.Validate() } // Python's normalized 0..1 values correspond to the shared -100..100 scale. // RoundToEven matches Python round, including LiteTalk's midpoint volume 4. func hardware_scale(value, minimum, maximum int) int { return int(math.RoundToEven(float64(minimum) + float64(value+100)*float64(maximum-minimum)/200)) } func dectalk_scale(value, minimum, normal, maximum int) int { if value <= 0 { return hardware_scale(value*2+100, minimum, normal) } return hardware_scale(value*2-100, normal, maximum) } func (c *HardwareClient) commands() [3]string { p := c.parameters if c.dectalk { return [3]string{fmt.Sprintf("[:ra %d]", dectalk_scale(p.Rate, 75, 180, 650)), fmt.Sprintf("[:dv ap %d]", dectalk_scale(p.Pitch, 50, 122, 350)), fmt.Sprintf("[:dv g5 %d]", hardware_scale(p.Volume, 60, 86))} } return [3]string{fmt.Sprintf("\x01%dS", hardware_scale(p.Rate, 0, 9)), fmt.Sprintf("\x01%dP", hardware_scale(p.Pitch, 0, 99)), fmt.Sprintf("\x01%dV", hardware_scale(p.Volume, 0, 9))} } func (c *HardwareClient) write(data []byte, generation uint64) error { if c.closed.Load() { return errors.New("hardware speech closed") } return c.port.Write(data, func() bool { return c.closed.Load() || c.generation.Load() != generation }) } func (c *HardwareClient) configure(generation uint64) error { if c.cacheGeneration != generation { c.cache = [3]string{} c.cacheGeneration = generation } for index, command := range c.commands() { if command == c.cache[index] { continue } if err := c.write([]byte(command), generation); err != nil { return err } c.cache[index] = command } return nil } func ignore_hardware_cancel(err error) error { if errors.Is(err, errHardwareCanceled) { return nil } return err } func (c *HardwareClient) Configure(parameters Parameters) error { if err := parameters.Validate(); err != nil { return err } c.mu.Lock() defer c.mu.Unlock() c.parameters = parameters return ignore_hardware_cancel(c.configure(c.generation.Load())) } func (c *HardwareClient) Set_pitch(pitch int) error { if pitch < -100 || pitch > 100 { return fmt.Errorf("speech pitch must be between -100 and 100") } c.mu.Lock() defer c.mu.Unlock() c.parameters.Pitch = pitch return ignore_hardware_cancel(c.configure(c.generation.Load())) } func (c *HardwareClient) Say(text string) error { c.mu.Lock() defer c.mu.Unlock() generation := c.generation.Load() if err := c.configure(generation); err != nil { return ignore_hardware_cancel(err) } var clean strings.Builder for _, char := range text { if char < 0x20 || char > 0x7e { char = ' ' } clean.WriteRune(char) } prepared := clean.String() if c.dectalk { prepared = strings.ReplaceAll(prepared, "[:", "[ :") + "\x0b" } else { prepared += "\r" } return ignore_hardware_cancel(c.write([]byte(prepared), generation)) } func (c *HardwareClient) Cancel() error { c.generation.Add(1) if c.closed.Load() { return nil } command := []byte{0x18} if c.usb { command = []byte{0x06} } else if c.dectalk { command = []byte{0x03} } return c.port.Cancel(command) } func (c *HardwareClient) Close() { if c.closed.Swap(true) { return } c.generation.Add(1) // Cancel audible device buffers before releasing ownership. Port writes // are bounded, including when the device is disconnected or flow-stopped. command := []byte{0x18} if c.usb { command = []byte{0x06} } else if c.dectalk { command = []byte{0x03} } _ = c.port.Cancel(command) c.port.Close() }