vocalize-cli 0.4.0__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/.gitignore +1 -0
  2. {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/CHANGELOG.md +52 -0
  3. {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/PKG-INFO +72 -14
  4. {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/README.md +71 -13
  5. {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/hooks/claude_stop_hook.py +53 -10
  6. vocalize_cli-0.6.0/hooks/speak_url_gate.py +354 -0
  7. vocalize_cli-0.6.0/tests/test_audio.py +274 -0
  8. {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/tests/test_claude_stop_hook.py +88 -21
  9. vocalize_cli-0.6.0/tests/test_cli.py +637 -0
  10. {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/tests/test_config.py +69 -0
  11. {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/tests/test_preprocess.py +91 -1
  12. vocalize_cli-0.6.0/tests/test_speak_url_gate.py +163 -0
  13. {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/vocalize/__init__.py +1 -1
  14. vocalize_cli-0.6.0/vocalize/audio.py +177 -0
  15. {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/vocalize/cli.py +122 -12
  16. {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/vocalize/config.py +73 -2
  17. {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/vocalize/preprocess.py +83 -0
  18. vocalize_cli-0.4.0/tests/test_audio.py +0 -101
  19. vocalize_cli-0.4.0/tests/test_cli.py +0 -317
  20. vocalize_cli-0.4.0/vocalize/audio.py +0 -71
  21. {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/.env.example +0 -0
  22. {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/.github/workflows/ci.yml +0 -0
  23. {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/LICENSE +0 -0
  24. {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/hooks/install_hook.py +0 -0
  25. {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/pyproject.toml +0 -0
  26. {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/tests/conftest.py +0 -0
  27. {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/tests/test_auth.py +0 -0
  28. {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/tests/test_install_hook.py +0 -0
  29. {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/tests/test_tts.py +0 -0
  30. {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/tests/test_wizard.py +0 -0
  31. {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/vocalize/__main__.py +0 -0
  32. {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/vocalize/auth.py +0 -0
  33. {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/vocalize/exceptions.py +0 -0
  34. {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/vocalize/tts.py +0 -0
  35. {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/vocalize/wizard.py +0 -0
@@ -11,3 +11,4 @@ dist/
11
11
  venv/
12
12
  *.mp3
13
13
  *.wav
14
+ .claude/
@@ -3,6 +3,58 @@
3
3
  All notable changes to this project are documented here. Format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
+ ## 0.6.0 - 2026-08-31
7
+
8
+ ### Added
9
+
10
+ - `vocalize settings` — prints the resolved settings, one key=value per
11
+ line, so wrapper scripts (like the /speak slash command) can read the
12
+ effective `overflow` and `max_chars` instead of hardcoding them.
13
+ - `--print-length` on the Stop hook: prints the response's character count
14
+ instead of speaking, so a wrapper can decide to ask about truncation
15
+ interactively before any audio is spent.
16
+
17
+ ## 0.5.0 - 2026-08-31
18
+
19
+ ### Added
20
+
21
+ - `vocalize stop` — stops in-progress playback from any terminal. play()
22
+ now records the player's PID in `~/.cache/vocalize/play.pid` while audio
23
+ runs; stop kills it only when the PID, its recorded launch timestamp,
24
+ and a known player name all still match — a recycled PID is never
25
+ touched — and a SIGTERM'd playback counts as a clean exit for the
26
+ speak command that started it. Overlapping plays keep the newest
27
+ record: the survivor is what stop stops.
28
+ - Chunked synthesis: input longer than the `eleven_multilingual_v2` model's
29
+ 10,000-character per-request cap is now split into chunks — preferring
30
+ paragraph, then sentence, then word boundaries — synthesized sequentially,
31
+ and concatenated into one audio file, instead of failing outright. Each
32
+ chunk still goes through the existing disk cache individually, so a
33
+ partially-cached long document only pays for the chunks it's missing.
34
+ - `--chunk-chars` flag to control the split size (default: 9,500).
35
+ - Configurable overflow behaviour: a new `overflow` setting (`truncate` |
36
+ `ask` | `never`) decides what happens when input exceeds the character
37
+ cap. `ask` prompts on the controlling terminal and degrades to
38
+ `truncate` with a note when there is none. Resolved like every other
39
+ setting: `--overflow` > `VOCALIZE_OVERFLOW` > config file > `truncate`.
40
+ - `max_chars` can now come from the environment (`VOCALIZE_MAX_CHARS`) and
41
+ the config file, not just the `--max-chars` flag.
42
+ - `--default-max-chars`: a fallback cap that sits below flag, env, and
43
+ config file — for wrapper scripts that want a protective default
44
+ without overriding the user's own settings.
45
+
46
+ ### Changed
47
+
48
+ - The Stop hook no longer reads `VOCALIZE_MAX_CHARS` itself; it passes
49
+ `--default-max-chars 500` and lets the CLI resolve the user's real
50
+ settings. Its subprocess timeout now scales with the text length
51
+ (60s base, ~12 chars/s, 900s ceiling) instead of killing any clip
52
+ longer than a minute; on timeout the whole process group is killed,
53
+ so the `afplay` child can't keep playing as an orphan.
54
+ - The Stop hook launches `vocalize` in its own session (no controlling
55
+ terminal), so an inherited `overflow = "ask"` degrades to truncate
56
+ there instead of blocking on a prompt nobody sees.
57
+
6
58
  ## 0.4.0
7
59
 
8
60
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: vocalize-cli
3
- Version: 0.4.0
3
+ Version: 0.6.0
4
4
  Summary: A CLI that turns text, markdown, or piped stdin into speech via the ElevenLabs API, with markdown-table-aware preprocessing.
5
5
  Project-URL: Homepage, https://github.com/matthager12-collab/vocalize
6
6
  Project-URL: Repository, https://github.com/matthager12-collab/vocalize
@@ -160,6 +160,17 @@ Every synthesis result is cached on disk under `~/.cache/vocalize/`, keyed
160
160
  by a hash of (text, voice, model, format, speed) — re-running the same
161
161
  command twice doesn't burn API quota twice.
162
162
 
163
+ `vocalize stop` (from any terminal) stops playback immediately — the
164
+ player's identity (process ID plus launch timestamp) is tracked in
165
+ `~/.cache/vocalize/play.pid`, and stop refuses to touch a process that no
166
+ longer matches the full record — a recycled PID is never killed. A
167
+ stopped read exits cleanly; the mp3 stays cached.
168
+
169
+ Long inputs are also split automatically — at paragraph boundaries where
170
+ possible, then sentences, then words — into requests no bigger than
171
+ `--chunk-chars` (default 9500), so a long read no longer fails the API's
172
+ own per-request cap.
173
+
163
174
  ## Configuration
164
175
 
165
176
  Each setting is resolved on its own, taking the first source that supplies
@@ -189,7 +200,8 @@ writing anything.
189
200
  | Voice ID | `--voice` | `VOCALIZE_VOICE` | `voice` | `21m00Tcm4TlvDq8ikWAM` ("Rachel") |
190
201
  | Model ID | `--model` | `VOCALIZE_MODEL` | `model` | `eleven_multilingual_v2` |
191
202
  | Speed | `--speed` | `VOCALIZE_SPEED` | `speed` | unset — the API's own 1.0 |
192
- | Max characters | `--max-chars` | `VOCALIZE_MAX_CHARS` (hook only) | not read from the config file | unset on the CLI; 500 in the hook |
203
+ | Max characters | `--max-chars` | `VOCALIZE_MAX_CHARS` | `max_chars` | unset on the CLI; the hook supplies a 500 fallback |
204
+ | Overflow mode | `--overflow` | `VOCALIZE_OVERFLOW` | `overflow` | `truncate` |
193
205
  | Hook binary | — | `VOCALIZE_BIN` | not read from the config file | `vocalize` as found on `PATH` |
194
206
 
195
207
  The config file is TOML at `$XDG_CONFIG_HOME/vocalize/config.toml`, falling
@@ -199,8 +211,24 @@ back to `~/.config/vocalize/config.toml`. Flat keys, no sections:
199
211
  voice = "21m00Tcm4TlvDq8ikWAM"
200
212
  model = "eleven_flash_v2_5"
201
213
  speed = 0.95
214
+ max_chars = 1000
215
+ overflow = "ask"
202
216
  ```
203
217
 
218
+ `overflow` decides what happens when input is longer than the resolved
219
+ `max_chars` cap: `truncate` (the default) cuts it at the cap, `ask` prompts
220
+ on the controlling terminal first — and degrades to `truncate` with a note
221
+ when there is no terminal to ask on (the Stop hook runs vocalize detached
222
+ from the terminal precisely so this always happens there; pipes and scripts
223
+ usually have no terminal either) — and `never` speaks the whole thing
224
+ regardless. With no cap set anywhere there is no overflow, so the mode
225
+ never fires. Hook-triggered speech still lives under the hook's 15-minute
226
+ watchdog described below, whatever the mode.
227
+
228
+ `vocalize settings` prints the resolved values (one `key=value` per
229
+ line, env and config applied) — handy for wrapper scripts and for checking
230
+ which source won.
231
+
204
232
  Not having a config file is normal and silent. A file that isn't valid TOML
205
233
  is an error naming the file; a key that isn't recognised is a warning on
206
234
  stderr, so a typo doesn't pass unnoticed but doesn't stop the run either.
@@ -249,11 +277,21 @@ the existing file first) rather than overwriting your other hooks. Every
249
277
  Claude Code response after that gets spoken aloud automatically. Uninstall
250
278
  by removing the `vocalize` entry from the `Stop` array in that file.
251
279
 
252
- By default the hook truncates each response to 500 characters before
253
- speaking it (`DEFAULT_MAX_CHARS` in `claude_stop_hook.py`) a Stop hook
254
- fires after every turn, so a long response would burn through the
255
- ElevenLabs free-tier quota fast. Override with `VOCALIZE_MAX_CHARS` in the
256
- environment.
280
+ By default the hook caps each response at 500 characters before speaking
281
+ it a Stop hook fires after every turn, so a long response would burn
282
+ through the ElevenLabs free-tier quota fast. That 500 is only a fallback
283
+ (`--default-max-chars`, supplied by the hook): a `VOCALIZE_MAX_CHARS` env
284
+ var, a `max_chars` in the config file, or an `overflow` mode of `never`
285
+ all override it, resolved by `vocalize` itself with the usual precedence.
286
+
287
+ The hook launches `vocalize` in its own session, detached from the
288
+ terminal, so an `overflow` of `ask` degrades to truncate there instead of
289
+ writing a Y/n prompt into the middle of a session nobody is watching. Its
290
+ subprocess timeout scales with the length of the text being spoken (about
291
+ twelve characters a second, plus a minute of headroom), capped at a hard
292
+ 15-minute ceiling as a watchdog against hung processes — a read that
293
+ would outlast the ceiling is stopped there, and the whole process group
294
+ is killed so no orphaned audio keeps playing.
257
295
 
258
296
  The hook looks up the `vocalize` binary on `PATH`, but Claude Code hooks
259
297
  run in Claude Code's own environment, not your interactive shell — if
@@ -267,12 +305,22 @@ Two primitives cover almost everything: `vocalize speak-file <path>` speaks
267
305
  any local file (markdown flattened first), and the hook's `--latest` mode
268
306
  speaks the most recent Claude Code response. Anything Claude itself has to
269
307
  fetch — a claude.ai artifact, for instance — has to be fetched *by Claude*
270
- (the CLI has no session), summarized, and piped in:
308
+ (the CLI has no session), summarized, written to a file, and spoken from
309
+ the file:
271
310
 
272
311
  ```bash
273
- printf '%s' "the summary text" | vocalize speak-file -
312
+ vocalize speak-file /path/to/summary.txt
274
313
  ```
275
314
 
315
+ Never interpolate the summary into the command line itself — see guard 4.
316
+
317
+ **Web pages.** The CLI has no URL support, by design — it can't fetch
318
+ anything. For a URL, Claude fetches the page itself, in an isolated
319
+ subagent with a locked-down tool set, and produces either a short spoken
320
+ digest or a verbatim extract of the core content. That text comes back to
321
+ the main session the same way any other summary does: written to a file
322
+ and passed to `speak-file`.
323
+
276
324
  If you wire this into a slash command of your own, treat it as a security
277
325
  surface, because **every character you speak is sent to ElevenLabs**. The
278
326
  guard principles that matter, in order:
@@ -285,11 +333,20 @@ guard principles that matter, in order:
285
333
  3. Summarize long or fetched content in an **isolated subagent** that
286
334
  returns only the summary — content you fetched can carry instructions
287
335
  aimed at your session.
288
- 4. Pipe summaries over stdin (as above) — no temp files, nothing in argv.
336
+ 4. Pass summaries as a file path (as above) — never build the shell
337
+ command by interpolating model-written text into a quoted string. A
338
+ summary a model wrote can contain `$(...)`, and the shell will run it;
339
+ `printf '%s' "<summary>"` is exactly that bug.
289
340
  5. Confirm before any read that will spend real quota; a free tier is
290
341
  10,000 characters a month.
291
342
  6. Remember the disk cache: everything spoken leaves an mp3 under
292
343
  `~/.cache/vocalize/`.
344
+ 7. Fetching is a second egress. Fetch only the URL the user typed — a page
345
+ can carry text telling its reader to fetch another URL, with data
346
+ smuggled out in the query string.
347
+ 8. Refuse non-public hosts. `localhost` and `169.254.169.254` are
348
+ reachable from your machine and nowhere else; parse the URL with the
349
+ `ipaddress` module rather than pattern-matching the string.
293
350
 
294
351
  ## How it's built
295
352
 
@@ -347,10 +404,11 @@ All tests run offline: the ElevenLabs client is dependency-injected into
347
404
 
348
405
  - **Charts and images aren't described.** Flattening markdown tables is a
349
406
  text problem; a rendered chart is an image, and describing it well needs
350
- a vision model in the loop, not a text transform. Out of scope for this
351
- project, but a natural next step pipe the image through a
352
- vision-capable model first, feed its description into `vocalize` in
353
- place of the chart.
407
+ a vision model in the loop, not a text transform. That's out of scope for
408
+ the CLI itself, but the next step now lives outside it, where it belongs:
409
+ the `/speak` command's web-page mode renders a page in a browser, hands it
410
+ to a vision-capable subagent, and the diagram description comes back as
411
+ plain text — same as any other content this tool speaks.
354
412
  - **Free tier is 10,000 characters/month** — plenty for reading a handful
355
413
  of documents aloud, not for continuous use. `--max-chars` and the disk
356
414
  cache both help stretch it.
@@ -126,6 +126,17 @@ Every synthesis result is cached on disk under `~/.cache/vocalize/`, keyed
126
126
  by a hash of (text, voice, model, format, speed) — re-running the same
127
127
  command twice doesn't burn API quota twice.
128
128
 
129
+ `vocalize stop` (from any terminal) stops playback immediately — the
130
+ player's identity (process ID plus launch timestamp) is tracked in
131
+ `~/.cache/vocalize/play.pid`, and stop refuses to touch a process that no
132
+ longer matches the full record — a recycled PID is never killed. A
133
+ stopped read exits cleanly; the mp3 stays cached.
134
+
135
+ Long inputs are also split automatically — at paragraph boundaries where
136
+ possible, then sentences, then words — into requests no bigger than
137
+ `--chunk-chars` (default 9500), so a long read no longer fails the API's
138
+ own per-request cap.
139
+
129
140
  ## Configuration
130
141
 
131
142
  Each setting is resolved on its own, taking the first source that supplies
@@ -155,7 +166,8 @@ writing anything.
155
166
  | Voice ID | `--voice` | `VOCALIZE_VOICE` | `voice` | `21m00Tcm4TlvDq8ikWAM` ("Rachel") |
156
167
  | Model ID | `--model` | `VOCALIZE_MODEL` | `model` | `eleven_multilingual_v2` |
157
168
  | Speed | `--speed` | `VOCALIZE_SPEED` | `speed` | unset — the API's own 1.0 |
158
- | Max characters | `--max-chars` | `VOCALIZE_MAX_CHARS` (hook only) | not read from the config file | unset on the CLI; 500 in the hook |
169
+ | Max characters | `--max-chars` | `VOCALIZE_MAX_CHARS` | `max_chars` | unset on the CLI; the hook supplies a 500 fallback |
170
+ | Overflow mode | `--overflow` | `VOCALIZE_OVERFLOW` | `overflow` | `truncate` |
159
171
  | Hook binary | — | `VOCALIZE_BIN` | not read from the config file | `vocalize` as found on `PATH` |
160
172
 
161
173
  The config file is TOML at `$XDG_CONFIG_HOME/vocalize/config.toml`, falling
@@ -165,8 +177,24 @@ back to `~/.config/vocalize/config.toml`. Flat keys, no sections:
165
177
  voice = "21m00Tcm4TlvDq8ikWAM"
166
178
  model = "eleven_flash_v2_5"
167
179
  speed = 0.95
180
+ max_chars = 1000
181
+ overflow = "ask"
168
182
  ```
169
183
 
184
+ `overflow` decides what happens when input is longer than the resolved
185
+ `max_chars` cap: `truncate` (the default) cuts it at the cap, `ask` prompts
186
+ on the controlling terminal first — and degrades to `truncate` with a note
187
+ when there is no terminal to ask on (the Stop hook runs vocalize detached
188
+ from the terminal precisely so this always happens there; pipes and scripts
189
+ usually have no terminal either) — and `never` speaks the whole thing
190
+ regardless. With no cap set anywhere there is no overflow, so the mode
191
+ never fires. Hook-triggered speech still lives under the hook's 15-minute
192
+ watchdog described below, whatever the mode.
193
+
194
+ `vocalize settings` prints the resolved values (one `key=value` per
195
+ line, env and config applied) — handy for wrapper scripts and for checking
196
+ which source won.
197
+
170
198
  Not having a config file is normal and silent. A file that isn't valid TOML
171
199
  is an error naming the file; a key that isn't recognised is a warning on
172
200
  stderr, so a typo doesn't pass unnoticed but doesn't stop the run either.
@@ -215,11 +243,21 @@ the existing file first) rather than overwriting your other hooks. Every
215
243
  Claude Code response after that gets spoken aloud automatically. Uninstall
216
244
  by removing the `vocalize` entry from the `Stop` array in that file.
217
245
 
218
- By default the hook truncates each response to 500 characters before
219
- speaking it (`DEFAULT_MAX_CHARS` in `claude_stop_hook.py`) a Stop hook
220
- fires after every turn, so a long response would burn through the
221
- ElevenLabs free-tier quota fast. Override with `VOCALIZE_MAX_CHARS` in the
222
- environment.
246
+ By default the hook caps each response at 500 characters before speaking
247
+ it a Stop hook fires after every turn, so a long response would burn
248
+ through the ElevenLabs free-tier quota fast. That 500 is only a fallback
249
+ (`--default-max-chars`, supplied by the hook): a `VOCALIZE_MAX_CHARS` env
250
+ var, a `max_chars` in the config file, or an `overflow` mode of `never`
251
+ all override it, resolved by `vocalize` itself with the usual precedence.
252
+
253
+ The hook launches `vocalize` in its own session, detached from the
254
+ terminal, so an `overflow` of `ask` degrades to truncate there instead of
255
+ writing a Y/n prompt into the middle of a session nobody is watching. Its
256
+ subprocess timeout scales with the length of the text being spoken (about
257
+ twelve characters a second, plus a minute of headroom), capped at a hard
258
+ 15-minute ceiling as a watchdog against hung processes — a read that
259
+ would outlast the ceiling is stopped there, and the whole process group
260
+ is killed so no orphaned audio keeps playing.
223
261
 
224
262
  The hook looks up the `vocalize` binary on `PATH`, but Claude Code hooks
225
263
  run in Claude Code's own environment, not your interactive shell — if
@@ -233,12 +271,22 @@ Two primitives cover almost everything: `vocalize speak-file <path>` speaks
233
271
  any local file (markdown flattened first), and the hook's `--latest` mode
234
272
  speaks the most recent Claude Code response. Anything Claude itself has to
235
273
  fetch — a claude.ai artifact, for instance — has to be fetched *by Claude*
236
- (the CLI has no session), summarized, and piped in:
274
+ (the CLI has no session), summarized, written to a file, and spoken from
275
+ the file:
237
276
 
238
277
  ```bash
239
- printf '%s' "the summary text" | vocalize speak-file -
278
+ vocalize speak-file /path/to/summary.txt
240
279
  ```
241
280
 
281
+ Never interpolate the summary into the command line itself — see guard 4.
282
+
283
+ **Web pages.** The CLI has no URL support, by design — it can't fetch
284
+ anything. For a URL, Claude fetches the page itself, in an isolated
285
+ subagent with a locked-down tool set, and produces either a short spoken
286
+ digest or a verbatim extract of the core content. That text comes back to
287
+ the main session the same way any other summary does: written to a file
288
+ and passed to `speak-file`.
289
+
242
290
  If you wire this into a slash command of your own, treat it as a security
243
291
  surface, because **every character you speak is sent to ElevenLabs**. The
244
292
  guard principles that matter, in order:
@@ -251,11 +299,20 @@ guard principles that matter, in order:
251
299
  3. Summarize long or fetched content in an **isolated subagent** that
252
300
  returns only the summary — content you fetched can carry instructions
253
301
  aimed at your session.
254
- 4. Pipe summaries over stdin (as above) — no temp files, nothing in argv.
302
+ 4. Pass summaries as a file path (as above) — never build the shell
303
+ command by interpolating model-written text into a quoted string. A
304
+ summary a model wrote can contain `$(...)`, and the shell will run it;
305
+ `printf '%s' "<summary>"` is exactly that bug.
255
306
  5. Confirm before any read that will spend real quota; a free tier is
256
307
  10,000 characters a month.
257
308
  6. Remember the disk cache: everything spoken leaves an mp3 under
258
309
  `~/.cache/vocalize/`.
310
+ 7. Fetching is a second egress. Fetch only the URL the user typed — a page
311
+ can carry text telling its reader to fetch another URL, with data
312
+ smuggled out in the query string.
313
+ 8. Refuse non-public hosts. `localhost` and `169.254.169.254` are
314
+ reachable from your machine and nowhere else; parse the URL with the
315
+ `ipaddress` module rather than pattern-matching the string.
259
316
 
260
317
  ## How it's built
261
318
 
@@ -313,10 +370,11 @@ All tests run offline: the ElevenLabs client is dependency-injected into
313
370
 
314
371
  - **Charts and images aren't described.** Flattening markdown tables is a
315
372
  text problem; a rendered chart is an image, and describing it well needs
316
- a vision model in the loop, not a text transform. Out of scope for this
317
- project, but a natural next step pipe the image through a
318
- vision-capable model first, feed its description into `vocalize` in
319
- place of the chart.
373
+ a vision model in the loop, not a text transform. That's out of scope for
374
+ the CLI itself, but the next step now lives outside it, where it belongs:
375
+ the `/speak` command's web-page mode renders a page in a browser, hands it
376
+ to a vision-capable subagent, and the diagram description comes back as
377
+ plain text — same as any other content this tool speaks.
320
378
  - **Free tier is 10,000 characters/month** — plenty for reading a handful
321
379
  of documents aloud, not for continuous use. `--max-chars` and the disk
322
380
  cache both help stretch it.
@@ -9,6 +9,10 @@
9
9
  that response. Run it when you want speech instead of installing the
10
10
  hook and getting it after every turn.
11
11
 
12
+ Add `--print-length` to either mode to print the response's character
13
+ count instead of speaking — for wrappers deciding whether to ask about
14
+ truncation before committing to audio.
15
+
12
16
  Either way it pulls the most recent assistant text message out of the
13
17
  transcript and pipes it through the `vocalize` CLI (the same one used
14
18
  directly from the command line), so there's exactly one code path for
@@ -28,15 +32,32 @@ from __future__ import annotations
28
32
  import json
29
33
  import os
30
34
  import shutil
35
+ import signal
31
36
  import subprocess
32
37
  import sys
33
38
  from pathlib import Path
34
39
 
35
40
  # Keep spoken responses short by default — a Stop hook fires after every
36
41
  # turn, and a long response would eat the ElevenLabs free-tier quota fast.
37
- # Override with VOCALIZE_MAX_CHARS in the environment.
42
+ # Passed as vocalize's --default-max-chars, which sits BELOW the user's own
43
+ # settings: a --max-chars flag, VOCALIZE_MAX_CHARS in the environment, or
44
+ # max_chars in the config file all override it (vocalize resolves those
45
+ # itself — this hook deliberately doesn't).
38
46
  DEFAULT_MAX_CHARS = 500
39
47
 
48
+ # Playback runs inside the vocalize subprocess and speech is roughly 12
49
+ # characters a second, so a fixed timeout kills long clips mid-play. Scale
50
+ # with the text instead; the ceiling still stops a hung process from
51
+ # outliving the session. Truncation (if any) happens inside vocalize, so
52
+ # the untruncated length is an upper bound, never too tight.
53
+ TIMEOUT_BASE_SECONDS = 60
54
+ TIMEOUT_CEILING_SECONDS = 900
55
+ CHARS_PER_SECOND = 12
56
+
57
+
58
+ def _speech_timeout(text: str) -> int:
59
+ return min(TIMEOUT_CEILING_SECONDS, TIMEOUT_BASE_SECONDS + len(text) // CHARS_PER_SECOND)
60
+
40
61
 
41
62
  def _extract_last_assistant_text(transcript_path: str) -> str:
42
63
  last_text_parts: list[str] = []
@@ -108,6 +129,15 @@ def main() -> int:
108
129
 
109
130
  text = _extract_last_assistant_text(transcript_path)
110
131
  if not text.strip():
132
+ if "--print-length" in sys.argv[1:]:
133
+ print(0)
134
+ return 0
135
+
136
+ if "--print-length" in sys.argv[1:]:
137
+ # Report the response's size without speaking — the /speak command
138
+ # uses this to decide whether to ask about truncation in-app. Raw
139
+ # (pre-flattening) length: close enough for an ask threshold.
140
+ print(len(text))
111
141
  return 0
112
142
 
113
143
  # VOCALIZE_BIN wins over PATH so a venv install still resolves when the
@@ -118,21 +148,34 @@ def main() -> int:
118
148
  # tool isn't installed / not on PATH in this shell.
119
149
  return 0
120
150
 
121
- max_chars = os.environ.get("VOCALIZE_MAX_CHARS", str(DEFAULT_MAX_CHARS))
122
-
123
151
  # Options first, then "--", then the text. Click treats any argv token
124
152
  # starting with "-" as an option, so a reply that opens with a bullet
125
153
  # ("- fixed the parser") or an arrow ("-> next") would otherwise make
126
154
  # vocalize exit 2 with "No such option" and speak nothing. The "--"
127
155
  # end-of-options separator makes the text unambiguously an argument.
156
+ #
157
+ # start_new_session detaches vocalize from the controlling terminal, on
158
+ # purpose, for two reasons: (1) overflow "ask" then finds no /dev/tty
159
+ # and degrades to truncate instead of writing a Y/n prompt into the
160
+ # middle of a Claude Code session that nobody knows to answer; (2) the
161
+ # new process group lets the timeout path kill vocalize AND the afplay
162
+ # child it spawned — subprocess.run's own timeout kills only the direct
163
+ # child and leaves the audio playing.
164
+ argv = [vocalize_bin, "speak", "--default-max-chars", str(DEFAULT_MAX_CHARS),
165
+ "--play", "--", text]
166
+ timeout = _speech_timeout(text)
128
167
  try:
129
- result = subprocess.run(
130
- [vocalize_bin, "speak", "--max-chars", max_chars, "--play", "--", text],
131
- timeout=60,
132
- check=False,
133
- )
134
- if result.returncode != 0:
135
- print(f"vocalize hook: vocalize exited {result.returncode}", file=sys.stderr)
168
+ proc = subprocess.Popen(argv, start_new_session=True)
169
+ try:
170
+ returncode = proc.wait(timeout=timeout)
171
+ except subprocess.TimeoutExpired:
172
+ os.killpg(proc.pid, signal.SIGKILL) # pgid == pid: it leads the new session
173
+ proc.wait()
174
+ print(f"vocalize hook: speech timed out after {timeout}s; "
175
+ "killed the process group", file=sys.stderr)
176
+ return 0
177
+ if returncode != 0:
178
+ print(f"vocalize hook: vocalize exited {returncode}", file=sys.stderr)
136
179
  except Exception as exc: # noqa: BLE001 — must not crash the Stop hook
137
180
  # A speech failure should never break the coding session, so this
138
181
  # still returns 0 — but it's logged to stderr rather than swallowed