// 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 }