Named output, stdin, JSON records, and a mouth lock - #14
Conversation
…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
left a comment
There was a problem hiding this comment.
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)
--jsonwithout-oprints a deadwavpath.emitwrites{"wav":...,"ttfa_ms":...,"sample_rate":...}, thenrunOncestillplay.File+tts.RemoveTempwheneverOut == "". After exit the JSONwavfield points at a deleted file (reproduced:os.Stat(r.Wav)→ not exist). Scripts that trust the record are lied to. Fix: require-owith--json, skip delete whenJSON, or omit/emptywavwhen the file will not survive — and test the chosen contract.--streamis accepted but unimplemented. Parser setsOptions.Stream, tests celebrate--json --stream, package comment says "stream", yetsay.Runnever reads it — one-shot behavior, silent no-op. PR body itself says--streamis 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
- [docs] Root
usage/ README still document onlycans say <text>;-o, stdin,--json,--wait/--nowait, exit 75 are easy to miss. - [lock]
syscall.Flockdoes not retryEINTR(rare forLOCK_NB, but possible under signals).
What I checked
parseSay/validateSay,resolveTextTTY vs pipe,say.Run/runOnce/emit,mouth.Acquirewait matrix + kill-drops-lock test,tts.OpenWith/Session.Closelock lifetime, booth session hold,SayToWith/sayBinTo, CI green + localCANS_NOPLAY=1 go test ./...- Reproduced dangling
--jsonwav path with a focused test
What I did not check
- Real Qwen mouth latency;
--streamworktree 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
left a comment
There was a problem hiding this comment.
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)
-
[contract] Prior CR on
--jsonwithout-ois not fixed.emitstill prints{"wav":...}thenplayTaildeletes the temp whenOut==""(RemoveTempafter play).TestRunJSONRecordnever asserts the file survives (or thatwavis empty/omitted). Scripts trusting the JSON path still get a dead file.- Fix (pick one and test it): require
-owith--json; skip delete whenJSON; or omit/emptywavwhen the file will not survive.
- Fix (pick one and test it): require
-
[PR body drift] Body says
--streamis “not in these two commits,” but tip includesfeat: cans say --stream. Update the summary/contract for stream (%douts, exit 130, stdin-only).
Non-blocking residuals
- [docs] Usage/README still easy to miss for
-o/ stdin /--json/ lock exits (prior residual).
What I checked
runOnce→emit→playTailon tip;TestRunJSONRecord/TestRunJSONWithOutCarriesTheOutPath- Stream parse rules (
--streamrejects argv text); mouth lock path unchanged in intent
What I did not check
- Live mouth stream session; full
go testagainst 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
left a comment
There was a problem hiding this comment.
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)
-
[contract]
--jsonwithout-ostill emits awavpath then deletes the file.emitencodes the Result, thenplayTailcallsRemoveTempwheneverOut=="". Proved on this tip: JSON path is gone afterRun(TestProbeJSONWithoutOutDeletesWavlocally).TestRunJSONRecordstill never asserts survival (or empty/omittedwav). Scripts that trust.wavfromcans say --json "…"get a dead file.- Fix (pick one + test): require
-owith--json; skip delete whenJSON; or omit/emptywavwhen the file will not survive.
- Fix (pick one + test): require
-
[PR body drift] Body §“Not in this PR” still claims
--streamis “in the worktree, not in these two commits” and lists “Pipe tape…” as out-of-scope — but tip includesinternal/say/stream.go, README documents--stream/%d/exit 130, and5e8123dis 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
- [docs] README now says “Without -o the wav is a temp file removed after playback,” which is true for play, but still undersells the
--jsondead-path trap until blocker 1 is fixed.
What I checked
- Re-verify prior CR on
c443de9:runOnce→emit→playTailunchanged at5e8123d(internal/say/say.go) TestRunJSONRecord/TestRunJSONWithOutCarriesTheOutPath— greenwash on survival still present- Local probe:
--jsonwithout-o→wavpathIsNotExistafter Run - Delta
c443de9...5e8123dis tape/README only; no contract fix - PR body “Not in this PR” vs
stream.go+ README Scripting section + commit5e8123d
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
left a comment
There was a problem hiding this comment.
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)
-
[contract]
--jsonwithout-ostill emits awavpath then deletes the file. Unchanged path:emitencodestts.Result(includingwav), thenplayTailcallsRemoveTempwheneverOut==""(internal/say/say.go). Same call site in stream (stream.go→playTail). Re-proved on9136cbe:TestProbeJSONWithoutOutDeletesWav— JSONwavisIsNotExistafterRun.TestRunJSONRecordstill never asserts survival (or empty/omittedwav). Scripts that trust.wavfromcans say --json "…"get a dead file.- Fix (pick one + test): require
-owith--json; skip delete whenJSON; or omit/emptywavwhen the file will not survive.
- Fix (pick one + test): require
-
[PR body drift] Body §“Not in this PR” still claims
--streamis “in the worktree, not in these two commits” and lists “Pipe tape, public festival snapshot…” as out-of-scope — but tip includesinternal/say/stream.go, README Scripting/--stream/exit 130,tapes/pipe.tape, and this tip commit is the 86-filefestivals/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
- [docs] README “Without -o the wav is a temp file removed after playback” is true for play, but still undersells the
--jsondead-path trap until blocker 1 is fixed. - [snapshot] Festival tree correctly references
CONTEXT.mdas 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 tosay.go/stream.go/playTail - Re-read
emit→playTailon tip; stream path still sharesplayTail - Local probe on tip SHA:
--jsonwithout-o→ wav gone (TestProbeJSONWithoutOutDeletesWavgreen proving the bug) TestRunJSONRecord/TestRunJSONWithOutCarriesTheOutPathgreenwash on survival still present- PR body “Not in this PR” vs commits
c443de9/5e8123d/9136cbe - Snapshot scrub: no absolute home paths;
CONTEXT.md/input_specsreferenced 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
left a comment
There was a problem hiding this comment.
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)
- [contract]
--jsonwithout-ostill emits awavpath then deletes the file. Unchanged on this tip:emitencodestts.Result(withwav), thenplayTailcallsRemoveTempwheneverOut==""(internal/say/say.go). Stream shares the sameplayTail. Delta9136cbe...3c58f9ais festival status +docs/pipe.gifonly — no contract fix. Re-proved on3c58f9a:TestProbeJSONWithoutOutDeletesWav— JSONwavisIsNotExistafterRun.TestRunJSONRecordstill never asserts survival (or empty/omittedwav). Scripts that trust.wavfromcans say --json "…"get a dead file.- Fix (pick one + test): require
-owith--json; skip delete whenJSON; or omit/emptywavwhen the file will not survive.
- Fix (pick one + test): require
Non-blocking residuals
- [docs] README “Without -o the wav is a temp file removed after playback” still undersells the
--jsondead-path trap until blocker 1 is fixed. - [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.gountouched - Re-read
emit→playTailon tip; stream path still callsplayTail - Local probe on
3c58f9a:--jsonwithout-o→ wav gone;TestRunJSONRecordstill 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.
|
|
obey-agent
left a comment
There was a problem hiding this comment.
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
- [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. - [mouth] Near-silent success wavs (~6%) are documented worker behavior; out of this PR’s scope as stated.
What I checked
- Prior CR cleared:
--jsonwithout-oomitswav(jsonShot/ streamokRecord+omitempty); README matches;TestRunJSONRecordasserts no"wav"and temp gone;TestStreamJSONWithoutOutOmitsWav/TestRunJSONWithOutCarriesTheOutPathpass playTailstill deletes only whenOut==""; named-okept- Stream: one session, blank-line index skip, fail-continue → exit 1,
%dtemplate viacheckOut/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
Why
cans saytook 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 makescans saya 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--streamthe path takes one%dverb, as inout/%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":Nadded 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.wavandcans say -o out.wav "$line"both parse.The lock.
CANS_HOME/mouth.lock, taken withsyscall.Flockbefore 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, printingwaiting for the mouth…to stderr;--nowaitgives 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, sokill -9cannot 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 Ngoes to stderr, and the exit code is 130. A second Ctrl-C is immediate.Exit codes:
0spoke it ·1runtime failure, or a line in a stream failed ·2usage error ·75mouth busy and the wait was refused or ran out ·130interrupted.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.--streamThe 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
--streamremoves (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 8run over 24 lines finished 24 of 24 with one worker resident at every sample and avm_statpageouts 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.xargs -P 8— eight sequential worker PIDs, never two at the same instant; pageouts delta 0; sevenwaiting for the mouth….interrupted after line 2, finished wavs kept, worker gone 0.11 s after exit, next call starting 0.18 s later.kill -9mid-stream — next call unblocked 1.10 s later with no wait line; the lock file present and byte-identical before, during and after.--nowaitexits 75 in 0.01 s withsay: mouth busy;--wait 2sprints the wait line, polls for 2.01 s, then exits 75.ttfa_ms=7157, exit 0, one worker, no temp file left behind;gofmt,go vetandgo test -count=1 ./...green across all ten packages on the test worker.README.md,docs/,tapes/andfestivals/CV0001/— clean.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.fest validate— passes on the plan tree, 100/100 onfestivals/CV0001/.festivals/CV0001/re-synced;diff -ragainst its source is empty under the documented exclusions.Known mouth behavior
Two things below belong to the native worker rather than to this branch, and both are visible in the numbers above.
ttfa_msis stamped when the worker reportsfinal, so the field is total synthesis time for that line, not time to first audio.--streamwrites 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
cansddaemon for warmth across invocations. The session type is already the client such a daemon would wrap.ttfa_mssemantics fix. The field keeps its current meaning here, and the README says what that meaning is.