Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@

- Stop speech queues and cancel active system TTS when the bridge disconnects or the node exits. Thanks @SebTardif! (#7)
- Forward only final speech transcripts to quick actions, voice events, and agent requests. Thanks @SebTardif! (#12)
- Treat leading dashes in spoken text as words instead of espeak options. Thanks @SebTardif! (#11)
- Cancel bridge dialing, pairing, and hello waits on SIGINT/SIGTERM. Thanks @SebTardif! (#6)
- Interrupt bridge reconnect backoff promptly on SIGINT/SIGTERM. Thanks @SebTardif! (#5)
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ GOOS=linux GOARCH=arm64 go build -o /tmp/clawgo-linux-arm64 ./cmd/clawgo
| `-chat-subscribe` | Enable chat stream+TTS (default `true`). |
| `-tts-engine` | `system`, `piper`, `elevenlabs`, or `none` (system = `espeak-ng`). |
| `-tts-system-voice` | espeak voice id (default `en-us`). |
| `-tts-system-command` | espeak-compatible executable; receives voice/rate options, `--`, then the utterance. |
| `-tts-system-rate` | Speech rate (wpm). |
| `-mdns-service` | Bonjour service type (default `_clawdbot-node._tcp`). |
| `-stdin` | Read transcripts from stdin (pipe/FIFO). |
Expand Down Expand Up @@ -74,6 +75,8 @@ With `-stt-engine brabble`, only final utterances are routed or sent to the brid
SIGINT and SIGTERM interrupt reconnect backoff immediately, including when the bridge is unavailable.
They also cancel bridge dialing and pairing/hello waits in `run` and `pair`, closing the connection before exit.

Spoken text is passed after `--` so leading dashes are treated as words. Custom `-tts-system-command` wrappers must preserve the option separator when forwarding arguments to espeak-ng.

## systemd example

Minimal steps:
Expand Down
2 changes: 1 addition & 1 deletion cmd/clawgo/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1030,7 +1030,7 @@ func (s *systemTTSEngine) Speak(ctx context.Context, text string) error {
if s.rate > 0 {
args = append(args, "-s", strconv.Itoa(s.rate))
}
args = append(args, trimmed)
args = append(args, "--", trimmed)
cmd := exec.CommandContext(ctx, s.command, args...)
cmd.WaitDelay = 2 * time.Second
cmd.Stdout = io.Discard
Expand Down
2 changes: 1 addition & 1 deletion cmd/clawgo/tts_queue_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ func TestTTSQueueStopCancelsActiveSpeech(t *testing.T) {
q.Speak("first")
deadline := time.Now().Add(5 * time.Second)
for {
if _, err := os.Stat(started); err == nil {
if marker, err := os.ReadFile(started); err == nil && strings.TrimSpace(string(marker)) == "started" {
break
}
if time.Now().After(deadline) {
Expand Down
104 changes: 104 additions & 0 deletions cmd/clawgo/tts_speak_args_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package main

import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"testing"
)

func TestSystemTTSEngineSpeakSeparatesUtteranceFromFlags(t *testing.T) {
rec, out := buildArgRecorder(t)

cases := []struct {
name string
voice string
rate int
text string
}{
{name: "wav-write-flag", voice: "en-us", rate: 180, text: "-w/tmp/x"},
{name: "short-unknown", voice: "en-us", rate: 180, text: "-foo"},
{name: "markdown-list", voice: "en-us", rate: 180, text: "- item"},
{name: "plain", voice: "en-us", rate: 180, text: "hello"},
{name: "no-voice-rate", text: "-fpath"},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := os.Remove(out); err != nil && !os.IsNotExist(err) {
t.Fatalf("remove args file: %v", err)
}
engine := &systemTTSEngine{command: rec, voice: tc.voice, rate: tc.rate}
if err := engine.Speak(context.Background(), tc.text); err != nil {
t.Fatalf("speak: %v", err)
}
raw, err := os.ReadFile(out)
if err != nil {
t.Fatalf("read recorded args: %v", err)
}
var args []string
if err := json.Unmarshal(raw, &args); err != nil {
t.Fatalf("decode recorded args %q: %v", raw, err)
}
t.Logf("argv=%q", args)

want := make([]string, 0, 6)
if tc.voice != "" {
want = append(want, "-v", tc.voice)
}
if tc.rate > 0 {
want = append(want, "-s", strconv.Itoa(tc.rate))
}
want = append(want, "--", tc.text)
if len(args) != len(want) {
t.Fatalf("argv=%q want=%q", args, want)
}
for i := range want {
if args[i] != want[i] {
t.Fatalf("argv=%q want=%q", args, want)
}
}
})
}
}

func buildArgRecorder(t *testing.T) (bin, out string) {
t.Helper()
dir := t.TempDir()
out = filepath.Join(dir, "args.json")
src := filepath.Join(dir, "rec.go")
srcText := `package main

import (
"encoding/json"
"os"
)

func main() {
raw, err := json.Marshal(os.Args[1:])
if err != nil {
os.Exit(2)
}
if err := os.WriteFile(` + strconv.Quote(out) + `, raw, 0644); err != nil {
os.Exit(3)
}
}
`
if err := os.WriteFile(src, []byte(srcText), 0644); err != nil {
t.Fatalf("write recorder: %v", err)
}
bin = filepath.Join(dir, "rec")
if runtime.GOOS == "windows" {
bin += ".exe"
}
cmd := exec.Command("go", "build", "-o", bin, src)
cmd.Dir = dir
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("build recorder: %v\n%s", err, output)
}
return bin, out
}