Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,163 @@
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user