Files

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
}