After commit c018f5fd29 Fenrir is being switched over to go. If you do not want to be part of the testing process, please switch to master.

This commit is contained in:
Storm Dragon committed 2026-10-27 20:30:55 -04:00
1 parent c018f5fd29
commit 0e50de4c76
1047 files changed
+58364 -63747

No files matched your search

+227
View File
@@ -0,0 +1,227 @@
// 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()
}