diff --git a/CHANGELOG.md b/CHANGELOG.md index 952fdce..bd1228b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index b299199..508dc8a 100644 --- a/README.md +++ b/README.md @@ -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). | @@ -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: diff --git a/cmd/clawgo/main.go b/cmd/clawgo/main.go index 2b536d9..2bf293f 100644 --- a/cmd/clawgo/main.go +++ b/cmd/clawgo/main.go @@ -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 diff --git a/cmd/clawgo/tts_queue_test.go b/cmd/clawgo/tts_queue_test.go index 71cebaf..928e970 100644 --- a/cmd/clawgo/tts_queue_test.go +++ b/cmd/clawgo/tts_queue_test.go @@ -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) { diff --git a/cmd/clawgo/tts_speak_args_test.go b/cmd/clawgo/tts_speak_args_test.go new file mode 100644 index 0000000..7cbd2f1 --- /dev/null +++ b/cmd/clawgo/tts_speak_args_test.go @@ -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 +}