164 lines
4.1 KiB
Go
164 lines
4.1 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package speech
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// Hardware ports Python Cthulhu's serial protocols. One owner serializes text
|
|
// and settings; cancellation retires a blocked write before sending Ctrl+X.
|
|
type Hardware struct {
|
|
mu sync.Mutex
|
|
ioMu sync.Mutex
|
|
fd int
|
|
synth string
|
|
generation uint64
|
|
}
|
|
|
|
type HardwareVoice struct{ Rate, Pitch, Volume *float64 }
|
|
|
|
func NewHardware(device, synth string, baud int) (*Hardware, error) {
|
|
if !filepath.IsAbs(device) || strings.ContainsAny(device, "\x00\r\n") {
|
|
return nil, errors.New("hardware speech requires an explicit serial device path")
|
|
}
|
|
switch synth {
|
|
case "litetalk", "doubletalk", "tripletalk", "dectalk":
|
|
default:
|
|
return nil, errors.New("unsupported hardware speech synthesizer")
|
|
}
|
|
fd, err := open_serial(device, baud)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Hardware{fd: fd, synth: synth}, nil
|
|
}
|
|
|
|
func hardware_bytes(synth, text string, voice HardwareVoice) []byte {
|
|
data := []byte{}
|
|
for index, value := range []*float64{voice.Rate, voice.Pitch, voice.Volume} {
|
|
if value == nil {
|
|
continue
|
|
}
|
|
limit := 9.0
|
|
if index == 0 {
|
|
limit = 99
|
|
}
|
|
normalized := math.Max(0, math.Min(limit, *value)) / limit
|
|
if synth == "dectalk" {
|
|
minimum := []int{75, 50, 0}[index]
|
|
maximum := []int{650, 180, 100}[index]
|
|
command := []string{"ra", "dv ap", "vo"}[index]
|
|
data = fmt.Appendf(data, "[:%s %d]", command, int(math.RoundToEven(float64(minimum)+normalized*float64(maximum-minimum))))
|
|
} else {
|
|
maximum := []int{9, 99, 9}[index]
|
|
command := []byte{'S', 'P', 'V'}[index]
|
|
data = fmt.Appendf(data, "\x01%d%c", int(math.RoundToEven(normalized*float64(maximum))), command)
|
|
}
|
|
}
|
|
// Devices accept ASCII. Strip control bytes and retain spacing just as
|
|
// Python's driver does.
|
|
for _, value := range text {
|
|
if value < 0x20 || value > 0x7e {
|
|
value = ' '
|
|
}
|
|
data = append(data, byte(value))
|
|
}
|
|
if synth == "dectalk" {
|
|
return append(data, 1)
|
|
}
|
|
return append(data, '\r')
|
|
}
|
|
|
|
func (client *Hardware) Speak(ctx context.Context, text string, voice HardwareVoice) error {
|
|
if text == "" {
|
|
return errors.New("speech text is empty")
|
|
}
|
|
client.mu.Lock()
|
|
generation := client.generation
|
|
client.mu.Unlock()
|
|
client.ioMu.Lock()
|
|
defer client.ioMu.Unlock()
|
|
return client.write_serial(ctx, hardware_bytes(client.synth, text, voice), generation)
|
|
}
|
|
|
|
func (client *Hardware) write_serial(ctx context.Context, data []byte, generation uint64) error {
|
|
ctx, cancel := context.WithTimeout(ctx, ioTimeout)
|
|
defer cancel()
|
|
for len(data) > 0 {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
client.mu.Lock()
|
|
current := client.generation
|
|
client.mu.Unlock()
|
|
if generation != current {
|
|
return context.Canceled
|
|
}
|
|
if client.fd < 0 {
|
|
return errors.New("hardware speech is closed")
|
|
}
|
|
written, err := syscall.Write(client.fd, data[:min(256, len(data))])
|
|
if written > 0 {
|
|
data = data[written:]
|
|
}
|
|
if err == syscall.EINTR {
|
|
continue
|
|
}
|
|
if err == syscall.EAGAIN || err == syscall.EWOULDBLOCK {
|
|
timer := time.NewTimer(5 * time.Millisecond)
|
|
select {
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
return ctx.Err()
|
|
case <-timer.C:
|
|
}
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("write hardware speech: %w", err)
|
|
}
|
|
if written == 0 {
|
|
return errors.New("serial write returned zero bytes")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (client *Hardware) Cancel(ctx context.Context) error {
|
|
client.mu.Lock()
|
|
client.generation++
|
|
generation := client.generation
|
|
client.mu.Unlock()
|
|
client.ioMu.Lock()
|
|
defer client.ioMu.Unlock()
|
|
if client.fd < 0 {
|
|
return nil
|
|
}
|
|
if err := flush_serial(client.fd); err != nil {
|
|
return err
|
|
}
|
|
return client.write_serial(ctx, []byte{0x18}, generation)
|
|
}
|
|
|
|
func (client *Hardware) Close(ctx context.Context) error {
|
|
result := client.Cancel(ctx)
|
|
client.ioMu.Lock()
|
|
defer client.ioMu.Unlock()
|
|
if client.fd >= 0 {
|
|
result = errors.Join(result, syscall.Close(client.fd))
|
|
client.fd = -1
|
|
}
|
|
return result
|
|
}
|