228 lines
6.3 KiB
Go
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()
|
|
}
|