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
5 changes: 5 additions & 0 deletions .justfiles/vhs.just
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ booth:
chmod +x {{ root }}/tapes/fake-say
just vhs record tapes/booth.tape

# Real mouth, no audio: a script pipes lines in, wavs land in out/.
pipe:
just build quick
just vhs record tapes/pipe.tape

# Real mouth + VHS terminal, muxed to docs/booth.mp4 (gif stays silent).
# The line is baked with qwen3-tts-cli, the one-shot tool that ships next to
# the worker (cans doctor puts both in ~/.cans/native/bin).
Expand Down
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,55 @@ Keep needs the clip and the words spoken in it. Throat stays put for the session

The CLI is Go. The mouth is a native [Qwen3-TTS](https://github.com/Obedience-Corp/qwen3-tts-native) worker that clones a wav. No Python.

## Scripting

The script owns the document. cans speaks what it is handed.

<p align="center">
<img src="docs/pipe.gif" width="680" alt="A script piping lines into cans" />
</p>

`--stream` reads stdin a line at a time and speaks each line through one worker: one model load for the whole document. Blank lines are skipped and do not take an index.

```bash
# one wav per paragraph, script decides what a paragraph is
awk -v RS='' '{gsub(/\n/," "); print}' chapter.md | cans say --stream -o 'out/%03d.wav'

# a line at a time, with the script's own naming
i=0
while IFS= read -r line; do
i=$((i+1))
cans say "$line" -o "$(printf 'out/%03d.wav' "$i")"
done < lines.txt

# metadata for a build step
cans say --stream --json -o 'out/%03d.wav' < lines.txt | jq -r 'select(.error==null) | .wav' > manifest.txt
```

| Flag | Effect |
|------|--------|
| `-o`, `--out` | Write the wav here. Under `--stream` the path takes one `%d`, as in `out/%03d.wav`. |
| `--stream` | Read stdin line by line, one utterance per line, one worker for all of them. Text on argv is a usage error. |
| `--json` | One JSON record per utterance on stdout; `--stream` adds `"line"`. `wav` is present only with `-o`. |
| `--play` | Play the wav as well as writing it. Needs `-o`. |
| `--nowait` | Do not queue behind another cans: give up at once. |
| `--wait <dur>` | Queue for at most that long. Without either flag, cans waits. |
| `-` | Read one utterance from stdin. Text on argv is a usage error. |

| Exit | Meaning |
|------|---------|
| `0` | Spoke it. |
| `1` | Runtime failure, or a line in a stream failed. |
| `2` | Usage error. |
| `75` | Another cans holds the mouth and the wait was refused or ran out. |
| `130` | Interrupted. |

stdout carries data — a JSON record, a wav path, or `ttfa_ms=N`, the worker's total synthesis time for that line. Prose goes to stderr.

Ctrl-C stops the stream: the line being spoken is dropped, finished wavs stay, exit 130. A second Ctrl-C stops at once.

Without -o the wav is a temp file removed after playback. `--json` without `-o` omits `wav` so a script is not handed a path that will not survive. Pass `-o` if the file is needed.

---

Built with [Festival](https://fest.build)
89 changes: 50 additions & 39 deletions cmd/cans/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,19 @@ import (
"fmt"
"io"
"os"
"os/signal"
"strings"
"syscall"

"github.com/veronica-agent/cans/internal/booth"
"github.com/veronica-agent/cans/internal/doctor"
"github.com/veronica-agent/cans/internal/keep"
"github.com/veronica-agent/cans/internal/play"
"github.com/veronica-agent/cans/internal/say"
"github.com/veronica-agent/cans/internal/ship"
"github.com/veronica-agent/cans/internal/tts"
)

var (
stdin io.Reader = os.Stdin
stdout io.Writer = os.Stdout
stderr io.Writer = os.Stderr
)
Expand All @@ -25,10 +27,15 @@ const usage = `cans — put the cans on.
Apple Silicon. The mouth is a native Qwen3-TTS worker cloning a wav.

cans booth (throat frozen for the session)
cans say <text> speak one line
cans keep <wav> -text WORDS freeze this throat (both orders work)
cans doctor set up the mouth, check the machine
cans version print version

cans say [-o out.wav] [--json] [--play] [--nowait|--wait 30s] <text>
echo text | cans say
cans say --stream -o 'out/%03d.wav' < lines.txt

exit 75 when another cans holds the mouth and --nowait was set or --wait ran out
`

func main() {
Expand All @@ -37,45 +44,11 @@ func main() {

func run(args []string) int {
if len(args) == 0 {
if err := doctor.Prepare(context.Background(), stderr); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
throat, err := keep.Load()
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
if err := booth.Run(context.Background(), keep.Quote(), throat); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
return 0
return runBooth()
}
switch args[0] {
case "say":
text := strings.TrimSpace(strings.Join(args[1:], " "))
if text == "" {
fmt.Fprintln(stderr, "say: missing text")
return 2
}
if err := doctor.Prepare(context.Background(), stderr); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
r, err := tts.Say(context.Background(), text)
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
fmt.Fprintf(stdout, "ttfa_ms=%d\n", r.TTFAMs)
playErr := play.File(r.Wav)
tts.RemoveTemp(r.Wav)
if playErr != nil {
fmt.Fprintln(stderr, playErr)
return 1
}
return 0
return runSay(args[1:])
case "doctor":
if err := doctor.Run(context.Background(), stdout, stderr); err != nil {
return 1
Expand Down Expand Up @@ -106,6 +79,44 @@ func run(args []string) int {
}
}

// runSay speaks one `cans say`, cancellable by SIGINT or SIGTERM.
func runSay(args []string) int {
o, err := parseSay(args)
if err != nil {
fmt.Fprintln(stderr, err)
return 2
}
o.StdinTTY = stdinIsTTY()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// D014: the first signal cancels; stop() then restores the default
// disposition, so a second Ctrl-C ends the process at once. stop() cancels
// ctx as well, so this goroutine always ends with runSay.
go func() {
<-ctx.Done()
stop()
}()
return say.Run(ctx, o, stdin, stdout, stderr)
}

// runBooth prepares the mouth and opens the TUI on the frozen throat.
func runBooth() int {
if err := doctor.Prepare(context.Background(), stderr); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
throat, err := keep.Load()
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
if err := booth.Run(context.Background(), keep.Quote(), throat); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
return 0
}

// parseKeep accepts both `keep take.wav -text words` and `keep -text words take.wav`.
func parseKeep(args []string) (wav, text string, err error) {
var positional []string
Expand Down
84 changes: 84 additions & 0 deletions cmd/cans/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ package main

import (
"bytes"
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/veronica-agent/cans/internal/audio"
"github.com/veronica-agent/cans/internal/mouth"
)

func TestParseKeepBothOrders(t *testing.T) {
Expand Down Expand Up @@ -114,3 +117,84 @@ func TestSayMock(t *testing.T) {
t.Fatalf("code %d", code)
}
}

func TestSayNoTextIsUsage(t *testing.T) {
var buf bytes.Buffer
oldErr, oldIn := stderr, stdin
stderr, stdin = &buf, strings.NewReader("")
defer func() { stderr, stdin = oldErr, oldIn }()
if code := run([]string{"say"}); code != 2 {
t.Fatalf("code %d", code)
}
if !strings.Contains(buf.String(), "say: empty text") {
t.Fatalf("%q", buf.String())
}
}

func TestSayUnknownFlag(t *testing.T) {
var buf bytes.Buffer
old := stderr
stderr = &buf
defer func() { stderr = old }()
if code := run([]string{"say", "--bogus"}); code != 2 {
t.Fatalf("code %d", code)
}
if !strings.Contains(buf.String(), "--bogus") {
t.Fatalf("%q", buf.String())
}
}

func TestSayNowaitBusy(t *testing.T) {
setupFakeMouth(t)
lk, err := mouth.Acquire(context.Background(), mouth.Path(), 0, nil)
if err != nil {
t.Fatal(err)
}
defer lk.Release()
var buf bytes.Buffer
old := stderr
stderr = &buf
defer func() { stderr = old }()
if code := run([]string{"say", "--nowait", "Put the cans on."}); code != 75 {
t.Fatalf("code %d stderr %q", code, buf.String())
}
if !strings.Contains(buf.String(), "mouth busy") {
t.Fatalf("%q", buf.String())
}
}

func setupFakeMouth(t *testing.T) {
t.Helper()
dir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
src := filepath.Join(dir, "..", "..", "internal", "tts", "testdata", "fakeworker")
bin := filepath.Join(t.TempDir(), "fake-worker")
out, err := exec.Command("go", "build", "-o", bin, src).CombinedOutput()
if err != nil {
t.Fatalf("build fake worker: %s\n%s", err, out)
}
home := t.TempDir()
root := t.TempDir()
t.Setenv("CANS_HOME", home)
t.Setenv("CANS_ROOT", root)
t.Setenv("CANS_NOPLAY", "1")
t.Setenv("CANS_SAY_BIN", "")
t.Setenv("CANS_WORKER_BIN", bin)
t.Setenv("CANS_WORKER_MODELS", t.TempDir())
t.Setenv("CANS_NATIVE_URL", "http://127.0.0.1/cans-test")
ref := filepath.Join(root, "voices", "veronica", "ref.wav")
if err := os.MkdirAll(filepath.Dir(ref), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(ref, audio.Minimal(), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "character.toml"), []byte("name = \"veronica\"\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(filepath.Dir(bin), "libqwen3tts.0.dylib"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
Loading
Loading