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:
1 parent
c018f5fd29
commit
0e50de4c76
1047 files changed
+58364
-63747
No files matched your search
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user