Files

228 lines
6.3 KiB
Go

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