Skip to content

Named output, stdin, JSON records, and a mouth lock - #14

Merged
lancekrogers merged 9 commits into
mainfrom
cans-v2
Aug 22, 2026
Merged

Named output, stdin, JSON records, and a mouth lock#14
lancekrogers merged 9 commits into
mainfrom
cans-v2

Conversation

@veronica-agent

@veronica-agent veronica-agent commented Aug 21, 2026

Copy link
Copy Markdown
Owner

Why

cans say took text on argv, played it through the speakers and exited, starting a worker per call. That shape cannot be scripted: there was no way to name an output file, no way to hand it a document, and nothing stopping a shell loop over 200 lines from paying the model load 200 times — or, run in parallel, from putting a second copy of the model resident on the same machine. This branch makes cans say a unix primitive: stdin in, files out, one process holding the mouth at a time.

What changed

Five additions to cans say, plus a lock.

  • -o, --out — write the wav to a path instead of a temp file. Under --stream the path takes one %d verb, as in out/%03d.wav.
  • --stream — read stdin a line at a time and speak each line through one worker and one model load for the whole document. Blank lines are skipped and do not consume an index. A failing line is reported and the stream continues; the exit code at EOF is 1 if any line failed.
  • --json — one record per utterance on stdout ({"wav":…,"ttfa_ms":…,"sample_rate":…}), with "line":N added under --stream. stdout carries data; prose goes to stderr.
  • --play — play as well as write. Requires -o.
  • - — read a single utterance from stdin.

Flags interleave with the text in either order: cans say "$line" -o out.wav and cans say -o out.wav "$line" both parse.

The lock. CANS_HOME/mouth.lock, taken with syscall.Flock before the worker starts and released after the session closes. One process holds the mouth, and the booth holds it for its whole run. A second caller waits by default, printing waiting for the mouth… to stderr; --nowait gives up at once and --wait <dur> caps the queue. Both refusals exit 75. The lock file is created if missing and never deleted — the kernel drops the lock when a process dies, so kill -9 cannot wedge the next run.

Ctrl-C. SIGINT terminates the utterance in flight rather than waiting it out: graceful shutdown if the worker is idle, SIGTERM then SIGKILL after 2 s if it is synthesising. The interrupted line is dropped, finished wavs stay, the lock is released, interrupted after line N goes to stderr, and the exit code is 130. A second Ctrl-C is immediate.

Exit codes: 0 spoke it · 1 runtime failure, or a line in a stream failed · 2 usage error · 75 mouth busy and the wait was refused or ran out · 130 interrupted.

Numbers

Measured on an idle machine, three runs back to back, with 100 % of every run's load samples under the threshold the measurement required. Full tables and commands: festivals/CV0001/002_PLAN/inputs/measurements.md.

50-line --stream 50-call shell loop
Wall 1 512.6 s 2 109.2 s
Records / errors 50 of 50 / 0 50 of 50 / 0
Reported synthesis 1 495.8 s 1 770.6 s
Everything else 16.8 s — 0.34 s/line 338.6 s — 6.77 s/line

The margin is 596.7 s over 50 lines, 11.93 s per line. The defensible part of it is the ~6.4 s per line of per-call model load that --stream removes (321.8 s over 50, reproduced independently at 6.69 s/line by the parallel run below). The remaining 274.9 s is the loop's higher reported synthesis time, which is the worker's variance rather than something this branch engineered away.

A separate xargs -P 8 run over 24 lines finished 24 of 24 with one worker resident at every sample and a vm_stat pageouts delta of 0 — eight concurrent callers, seven of them queued on the lock.

Verification

A 13-item bar, each item a command with its verbatim output recorded in festivals/CV0001/004_REVIEW/BAR.md. All 13 pass.

  1. Stream — a 3-line stream held one worker PID across all 98 one-second samples, with 6.5 s of non-synthesis for the whole run: one model load, not three.
  2. xargs -P 8 — eight sequential worker PIDs, never two at the same instant; pageouts delta 0; seven waiting for the mouth….
  3. Ctrl-C mid-stream — exit 130, interrupted after line 2, finished wavs kept, worker gone 0.11 s after exit, next call starting 0.18 s later.
  4. kill -9 mid-stream — next call unblocked 1.10 s later with no wait line; the lock file present and byte-identical before, during and after.
  5. Booth holds the lock--nowait exits 75 in 0.01 s with say: mouth busy; --wait 2s prints the wait line, polls for 2.01 s, then exits 75.
  6. One-shot unchangedttfa_ms=7157, exit 0, one worker, no temp file left behind; gofmt, go vet and go test -count=1 ./... green across all ten packages on the test worker.
  7. Margin — the table above, quoted from the recorded measurement rather than re-run.
  8. Public-surface greps over README.md, docs/, tapes/ and festivals/CV0001/ — clean.
  9. Fresh CANS_HOME, binary outside the checkout — doctor five rows ok, a real 24 kHz mono wav in 12.5 s, and a home containing exactly the lock file and the unpacked payload.
  10. Identity — six commits, one author and one committer on each, no third-party attribution in any body.
  11. fest validate — passes on the plan tree, 100/100 on festivals/CV0001/.
  12. Snapshotfestivals/CV0001/ re-synced; diff -r against its source is empty under the documented exclusions.
  13. This description, rewritten from the bar.

Known mouth behavior

Two things below belong to the native worker rather than to this branch, and both are visible in the numbers above.

  • End-of-speech variance. The same one-shot text cost 5 627–7 157 ms across five runs on an idle machine and 27 241 ms in a sixth; stream lines ran 32–42 s for a single short sentence. Related: ttfa_ms is stamped when the worker reports final, so the field is total synthesis time for that line, not time to first audio.
  • Occasional near-silent wav reported as success. Around 6 % of lines return a well-formed 1 484-byte, ~0.03 s file after paying full synthesis time, with the worker reporting success. --stream writes what it is handed and emits a success record, because there is no error to report. A length floor that warns on a suspiciously short return would catch it cheaply; that belongs with the worker or a later change here, not with this one.

Not in this PR

  • A cansd daemon for warmth across invocations. The session type is already the client such a daemon would wrap.
  • Protocol-level mid-utterance abort. It needs worker support; Ctrl-C terminates the process instead.
  • The ttfa_ms semantics fix. The field keeps its current meaning here, and the README says what that meaning is.

…n, emits JSON

parseSay accepts the full flag grammar in either order.
internal/say.Run owns the one-shot flow: -o keeps the file,
stdin is one utterance, --json prints a tts.Result line.
Named -o parents are created before synthesis; stdin over
4MiB is an error. cans say "x" still prints ttfa_ms, plays,
and deletes the temp wav.
internal/mouth takes an exclusive flock on CANS_HOME/mouth.lock
before the worker starts and releases it after Close. cans say
--nowait exits 75 when the mouth is held; --wait bounds the
block and prints waiting for the mouth. The booth holds the
lock for the whole session. The lock file is never deleted.
TIOCGETA is darwin-only. Linux CI uses TCGETS so `go test ./...`
builds on ubuntu. /dev/null is still not a TTY.

@obey-agent obey-agent left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST_CHANGES

Head: c736f46 · PR goal: v2 slice — named -o, stdin, --json records, exclusive mouth flock with --nowait/--wait.

Blocking (must fix before merge)

  1. --json without -o prints a dead wav path. emit writes {"wav":...,"ttfa_ms":...,"sample_rate":...}, then runOnce still play.File + tts.RemoveTemp whenever Out == "". After exit the JSON wav field points at a deleted file (reproduced: os.Stat(r.Wav) → not exist). Scripts that trust the record are lied to. Fix: require -o with --json, skip delete when JSON, or omit/empty wav when the file will not survive — and test the chosen contract.
  2. --stream is accepted but unimplemented. Parser sets Options.Stream, tests celebrate --json --stream, package comment says "stream", yet say.Run never reads it — one-shot behavior, silent no-op. PR body itself says --stream is not in this PR. Do not ship a parse-accepted no-op; reject unknown/unimplemented (say: --stream not implemented) until the stream slice lands.

Non-blocking residuals

  1. [docs] Root usage / README still document only cans say <text>; -o, stdin, --json, --wait/--nowait, exit 75 are easy to miss.
  2. [lock] syscall.Flock does not retry EINTR (rare for LOCK_NB, but possible under signals).

What I checked

  • parseSay / validateSay, resolveText TTY vs pipe, say.Run/runOnce/emit, mouth.Acquire wait matrix + kill-drops-lock test, tts.OpenWith/Session.Close lock lifetime, booth session hold, SayToWith/sayBinTo, CI green + local CANS_NOPLAY=1 go test ./...
  • Reproduced dangling --json wav path with a focused test

What I did not check

  • Real Qwen mouth latency; --stream worktree contents outside this PR tip

…r document

What changed:
- --stream speaks one utterance per stdin line over a single Session:
  one GGUF load for the whole document, backpressure inherent (next
  synthesize only after the previous final), no buffer, no goroutine
- -o 'out/%03d.wav' is a template in stream mode, validated before the
  mouth is touched; one-shot -o stays a literal path
- blank lines are skipped without consuming an index; a failed line is
  reported on stderr (and as {line,error} under --json), the stream
  continues, exit 1 at EOF if any line failed
- --json records carry the 1-based stdin line; every record is flushed
- Ctrl-C: the loop stops, a mid-utterance worker is SIGTERMed (SIGKILL
  after 2s), finished wavs stay, the lock is released, exit 130 with
  'interrupted after line N'; a second Ctrl-C ends the process at once
- --stream together with argv text is a usage error
- fake worker gains a 'fail' and a 'block' branch so failure, cancel and
  one-worker tests run without the real mouth

Why: a script walking a document paid a model load per line and nothing
stopped a loop from overloading the machine. The lock made it safe; this
makes it fast. Cancel terminates rather than waits (D014) because a line
can run 17-30s when the mouth misses end-of-speech.

@obey-agent obey-agent left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST_CHANGES

Head: c443de9 · PR goal: Named output, stdin, JSON records, mouth lock; plus --stream one session per document

Blocking (must fix before merge)

  1. [contract] Prior CR on --json without -o is not fixed. emit still prints {"wav":...} then playTail deletes the temp when Out=="" (RemoveTemp after play). TestRunJSONRecord never asserts the file survives (or that wav is empty/omitted). Scripts trusting the JSON path still get a dead file.

    • Fix (pick one and test it): require -o with --json; skip delete when JSON; or omit/empty wav when the file will not survive.
  2. [PR body drift] Body says --stream is “not in these two commits,” but tip includes feat: cans say --stream. Update the summary/contract for stream (%d outs, exit 130, stdin-only).

Non-blocking residuals

  1. [docs] Usage/README still easy to miss for -o / stdin / --json / lock exits (prior residual).

What I checked

  • runOnceemitplayTail on tip; TestRunJSONRecord / TestRunJSONWithOutCarriesTheOutPath
  • Stream parse rules (--stream rejects argv text); mouth lock path unchanged in intent

What I did not check

  • Live mouth stream session; full go test against real worker beyond reading tests

…ction

What: tapes/pipe.tape plus a `just vhs pipe` recipe (build quick, then vhs
record), and docs/pipe.gif cut from it on the real mouth — three technical
lines into lines.txt, `cans say --stream -o 'out/%03d.wav' --json`, `ls out`.
The gif was recorded at 1-minute load ~21, so the ttfa_ms on screen reads
31-35 s and the run stretches to 123 s; 004_REVIEW re-cuts it under load < 16
from the same tape. README gains a "## Scripting" section: the three loops
from the design pack, a flag table for the seven flags, an exit table
(0/1/2/75/130), the D014 Ctrl-C line and the temp-wav line. The usage const
gains the three say forms, drops its duplicate bare say line, and now says
exit 75 covers an expired --wait.

Why: --stream is only worth shipping if a script author can see what it is
for — a document piped in, wavs landing where the script points, one model
load for the whole thing. The public tree reads that section before it reads
the code, so the examples have to run: the review caught all three loops
carrying bugs inherited from the design pack (a subshell counter that wrote
out/001.wav every iteration, a manifest of temp wavs deleted before it was
written, and awk paragraph mode speaking one wav per source line). Fixed
here, verified in a shell.

@obey-agent obey-agent left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST_CHANGES

Head: 5e8123d · PR goal: Named output, stdin, JSON records, mouth lock; tip also ships --stream + pipe tape/README

Blocking (must fix before merge)

  1. [contract] --json without -o still emits a wav path then deletes the file. emit encodes the Result, then playTail calls RemoveTemp whenever Out=="". Proved on this tip: JSON path is gone after Run (TestProbeJSONWithoutOutDeletesWav locally). TestRunJSONRecord still never asserts survival (or empty/omitted wav). Scripts that trust .wav from cans say --json "…" get a dead file.

    • Fix (pick one + test): require -o with --json; skip delete when JSON; or omit/empty wav when the file will not survive.
  2. [PR body drift] Body §“Not in this PR” still claims --stream is “in the worktree, not in these two commits” and lists “Pipe tape…” as out-of-scope — but tip includes internal/say/stream.go, README documents --stream/%d/exit 130, and 5e8123d is the pipe tape + README commit. Clients following the PR summary would skip reviewing shipped surface.

    • Fix: rewrite the PR summary to match tip (stream + scripting docs in-scope).

Non-blocking residuals

  1. [docs] README now says “Without -o the wav is a temp file removed after playback,” which is true for play, but still undersells the --json dead-path trap until blocker 1 is fixed.

What I checked

  • Re-verify prior CR on c443de9: runOnceemitplayTail unchanged at 5e8123d (internal/say/say.go)
  • TestRunJSONRecord / TestRunJSONWithOutCarriesTheOutPath — greenwash on survival still present
  • Local probe: --json without -owav path IsNotExist after Run
  • Delta c443de9...5e8123d is tape/README only; no contract fix
  • PR body “Not in this PR” vs stream.go + README Scripting section + commit 5e8123d

What I did not check

  • Live mouth / real Qwen worker stream session; VHS tape render beyond reading tapes/pipe.tape

… a readable tree

What: an 86-file snapshot of festival CV0001 into festivals/CV0001/, next to
CA0001 — goal, rules, overview, TODO, the INGEST output specs, the plan
(STRUCTURE, IMPLEMENTATION_PLAN, D001-D014, measurements), and all five
implementation sequences with their gates and recorded results. Campaign-private
inputs and runtime state are excluded per D009: CONTEXT.md, 001_INGEST/input_specs/,
.fest/, .workflow/, .festival-checksums.json, and the reviewers' hidden .review-*
notes. Absolute home paths were scrubbed to ~ in the source before the copy, and
the campaign-private grep patterns were replaced with references rather than
quoted verbatim.

Also recorded in the tree: the full surface recheck — ten packages green on the
fake worker, gofmt and go vet silent, go.mod/go.sum undiffed, fresh-home doctor
and say -o against the real mouth with a verified 24 kHz wav header, the
professional-surface grep, goreleaser check, and just vhs pipe.

Why: the festival tree is the second readable plan in the repo. A stranger can
follow how cans grew a stream, a lock and a wav path without fest installed and
without seeing anything the campaign keeps private.

@obey-agent obey-agent left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST_CHANGES

Head: 9136cbe · PR goal: Named -o/stdin/--json, mouth flock, plus tip-shipped --stream, pipe tape/README, and festivals/CV0001 snapshot

Blocking (must fix before merge)

  1. [contract] --json without -o still emits a wav path then deletes the file. Unchanged path: emit encodes tts.Result (including wav), then playTail calls RemoveTemp whenever Out=="" (internal/say/say.go). Same call site in stream (stream.goplayTail). Re-proved on 9136cbe: TestProbeJSONWithoutOutDeletesWav — JSON wav is IsNotExist after Run. TestRunJSONRecord still never asserts survival (or empty/omitted wav). Scripts that trust .wav from cans say --json "…" get a dead file.

    • Fix (pick one + test): require -o with --json; skip delete when JSON; or omit/empty wav when the file will not survive.
  2. [PR body drift] Body §“Not in this PR” still claims --stream is “in the worktree, not in these two commits” and lists “Pipe tape, public festival snapshot…” as out-of-scope — but tip includes internal/say/stream.go, README Scripting/--stream/exit 130, tapes/pipe.tape, and this tip commit is the 86-file festivals/CV0001/ snapshot. Clients following the PR summary would skip reviewing shipped surface.

    • Fix: rewrite the PR summary to match tip (stream + scripting docs + festival snapshot in-scope).

Non-blocking residuals

  1. [docs] README “Without -o the wav is a temp file removed after playback” is true for play, but still undersells the --json dead-path trap until blocker 1 is fixed.
  2. [snapshot] Festival tree correctly references CONTEXT.md as campaign-private; no /home///Users/ leak spotted in a scrub grep. Optional: constraints still say “cans is darwin-only” while tip has linux TTY/TCGETS — stale plan prose, not a runtime break.

What I checked

  • Delta 5e8123d...9136cbe: festival snapshot only — no change to say.go/stream.go/playTail
  • Re-read emitplayTail on tip; stream path still shares playTail
  • Local probe on tip SHA: --json without -o → wav gone (TestProbeJSONWithoutOutDeletesWav green proving the bug)
  • TestRunJSONRecord / TestRunJSONWithOutCarriesTheOutPath greenwash on survival still present
  • PR body “Not in this PR” vs commits c443de9 / 5e8123d / 9136cbe
  • Snapshot scrub: no absolute home paths; CONTEXT.md/input_specs referenced as excluded

What I did not check

  • Live mouth / real Qwen worker stream session end-to-end
  • Full festival tree editorial accuracy vs every D00n decision text

…shot re-sync

What
- 004_REVIEW/BAR.md: the 13-item ship bar run end to end on the real mouth,
  each item with its exact command and verbatim output. 13/13 pass.
  One worker PID across a whole stream (98 samples) with 6.5 s of
  non-synthesis for three lines; xargs -P 8 with eight sequential worker
  PIDs, never two at once, pageouts delta 0; Ctrl-C exit 130 with the
  worker gone 0.11 s after exit; the booth holding the lock and refusing
  --nowait in 0.01 s with 75; fresh-home doctor green with the binary
  outside the checkout; six commits, one author, no assistant attribution.
- 004_REVIEW/PHASE_GOAL.md: bar table ticked, criteria and gates filled,
  GO recorded, orchestrator row signed.
- docs/pipe.gif re-cut at 1-minute load 4.54-5.35 (100% under 16), the
  carry-over 04_tape assigned here: 680x300 so the gif matches
  tapes/pipe.tape:13 again, 91.52 s (was 123.44), 148 556 B (was 168 370),
  on-screen ttfa_ms 26607 / 29461 / 14174.
- festivals/CV0001 re-synced with the recorded rsync (six exclusions plus
  --delete-excluded): 86 files, fest validate 100/100, diff -r against the
  source empty under those exclusions, all three surface greps clean.

Why
The bar is the acceptance record. Every claim this branch makes is a
number a stranger can reproduce by re-running the command printed next to
it, on their own machine, without asking anyone what was meant. That is
also why the two engine faults seen during the run are written down where
they happened rather than smoothed away: end-of-speech variance and
near-silent wavs returned as success are the mouth's, not --stream's, and
a reader comparing their own numbers to these needs to know that before
they file a bug against the wrong layer.
The festival was promoted to completed after the 004_REVIEW bar passed;
the public copy's phase and festival status fields now say so, matching
how CA0001 reads. No code change.

@obey-agent obey-agent left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST_CHANGES

Head: 3c58f9a · PR goal: Scriptable cans say (-o/stdin/--json/--stream/mouth flock) plus festival snapshot and bar

Blocking (must fix before merge)

  1. [contract] --json without -o still emits a wav path then deletes the file. Unchanged on this tip: emit encodes tts.Result (with wav), then playTail calls RemoveTemp whenever Out=="" (internal/say/say.go). Stream shares the same playTail. Delta 9136cbe...3c58f9a is festival status + docs/pipe.gif only — no contract fix. Re-proved on 3c58f9a: TestProbeJSONWithoutOutDeletesWav — JSON wav is IsNotExist after Run. TestRunJSONRecord still never asserts survival (or empty/omitted wav). Scripts that trust .wav from cans say --json "…" get a dead file.
    • Fix (pick one + test): require -o with --json; skip delete when JSON; or omit/empty wav when the file will not survive.

Non-blocking residuals

  1. [docs] README “Without -o the wav is a temp file removed after playback” still undersells the --json dead-path trap until blocker 1 is fixed.
  2. [PR body] Prior “Not in this PR” stream/tape/snapshot drift is fixed in the rewritten description — thank you; no longer blocking.

What I checked

  • Compare 9136cbe...3c58f9a: festival BAR/status + gif only; say.go/stream.go untouched
  • Re-read emitplayTail on tip; stream path still calls playTail
  • Local probe on 3c58f9a: --json without -o → wav gone; TestRunJSONRecord still greenwashes survival
  • Rewritten PR body vs shipped surface (stream/lock/exits now described accurately)
  • CI: test green

What I did not check

  • Live mouth / real Qwen worker end-to-end on this tip
  • Festival BAR item verbatim re-execution (13-item captures)

… no -o

Without -o the file is a temp that playTail deletes. Printing
its path in the JSON record handed scripts a dead file. wav is
now omitted unless -o named a surviving path.
@veronica-agent

Copy link
Copy Markdown
Owner Author

--json without -o no longer prints a wav path. That file is a temp that playTail deletes; the record now omits wav and keeps ttfa_ms / sample_rate. Pass -o if a script needs the file. Tests cover one-shot and --stream.

@obey-agent obey-agent left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: APPROVE

Head: f4c46bf · PR goal: Scriptable cans say-o/stdin/--json/--stream, mouth flock, Ctrl-C → 130.

Blocking (must fix before merge)

none

Non-blocking residuals

  1. [play] Stream/one-shot emit success JSON (or path) before playTail; a rare play failure can leave a success record then exit 1. Scripts should key off process exit, not only the last JSON line.
  2. [mouth] Near-silent success wavs (~6%) are documented worker behavior; out of this PR’s scope as stated.

What I checked

  • Prior CR cleared: --json without -o omits wav (jsonShot / stream okRecord + omitempty); README matches; TestRunJSONRecord asserts no "wav" and temp gone; TestStreamJSONWithoutOutOmitsWav / TestRunJSONWithOutCarriesTheOutPath pass
  • playTail still deletes only when Out==""; named -o kept
  • Stream: one session, blank-line index skip, fail-continue → exit 1, %d template via checkOut/outPath, cancel → 130
  • Mouth lock + booth hold; worker Cancel→SIGTERM + WaitDelay=2s
  • PR body rewritten to match tip (stream in-scope)
  • CANS_NOPLAY=1 go test ./... green; CI test SUCCESS; MERGEABLE/CLEAN

What I did not check

  • Live Qwen mouth / festival BAR 13-item re-execution on real hardware

@lancekrogers
lancekrogers merged commit c84a327 into main Aug 22, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants