vocalize-cli 0.3.0__tar.gz → 0.5.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 (36) hide show
  1. {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/.gitignore +1 -0
  2. {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/CHANGELOG.md +47 -0
  3. {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/PKG-INFO +98 -11
  4. {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/README.md +97 -10
  5. {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/hooks/claude_stop_hook.py +40 -10
  6. vocalize_cli-0.5.0/hooks/speak_url_gate.py +354 -0
  7. vocalize_cli-0.5.0/tests/test_audio.py +274 -0
  8. {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/tests/test_claude_stop_hook.py +68 -21
  9. vocalize_cli-0.5.0/tests/test_cli.py +609 -0
  10. {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/tests/test_config.py +69 -0
  11. {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/tests/test_preprocess.py +91 -1
  12. vocalize_cli-0.5.0/tests/test_speak_url_gate.py +163 -0
  13. {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/tests/test_tts.py +46 -1
  14. {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/vocalize/__init__.py +1 -1
  15. vocalize_cli-0.5.0/vocalize/audio.py +177 -0
  16. vocalize_cli-0.5.0/vocalize/cli.py +312 -0
  17. {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/vocalize/config.py +73 -2
  18. {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/vocalize/preprocess.py +83 -0
  19. {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/vocalize/tts.py +19 -0
  20. vocalize_cli-0.3.0/tests/test_audio.py +0 -101
  21. vocalize_cli-0.3.0/tests/test_cli.py +0 -272
  22. vocalize_cli-0.3.0/vocalize/audio.py +0 -71
  23. vocalize_cli-0.3.0/vocalize/cli.py +0 -178
  24. {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/.env.example +0 -0
  25. {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/.github/workflows/ci.yml +0 -0
  26. {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/LICENSE +0 -0
  27. {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/hooks/install_hook.py +0 -0
  28. {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/pyproject.toml +0 -0
  29. {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/tests/conftest.py +0 -0
  30. {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/tests/test_auth.py +0 -0
  31. {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/tests/test_install_hook.py +0 -0
  32. {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/tests/test_wizard.py +0 -0
  33. {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/vocalize/__main__.py +0 -0
  34. {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/vocalize/auth.py +0 -0
  35. {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/vocalize/exceptions.py +0 -0
  36. {vocalize_cli-0.3.0 → vocalize_cli-0.5.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,53 @@
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.5.0 - 2026-08-31
7
+
8
+ ### Added
9
+
10
+ - `vocalize stop` — stops in-progress playback from any terminal. play()
11
+ now records the player's PID in `~/.cache/vocalize/play.pid` while audio
12
+ runs; stop kills it only when the PID, its recorded launch timestamp,
13
+ and a known player name all still match — a recycled PID is never
14
+ touched — and a SIGTERM'd playback counts as a clean exit for the
15
+ speak command that started it. Overlapping plays keep the newest
16
+ record: the survivor is what stop stops.
17
+ - Chunked synthesis: input longer than the `eleven_multilingual_v2` model's
18
+ 10,000-character per-request cap is now split into chunks — preferring
19
+ paragraph, then sentence, then word boundaries — synthesized sequentially,
20
+ and concatenated into one audio file, instead of failing outright. Each
21
+ chunk still goes through the existing disk cache individually, so a
22
+ partially-cached long document only pays for the chunks it's missing.
23
+ - `--chunk-chars` flag to control the split size (default: 9,500).
24
+ - Configurable overflow behaviour: a new `overflow` setting (`truncate` |
25
+ `ask` | `never`) decides what happens when input exceeds the character
26
+ cap. `ask` prompts on the controlling terminal and degrades to
27
+ `truncate` with a note when there is none. Resolved like every other
28
+ setting: `--overflow` > `VOCALIZE_OVERFLOW` > config file > `truncate`.
29
+ - `max_chars` can now come from the environment (`VOCALIZE_MAX_CHARS`) and
30
+ the config file, not just the `--max-chars` flag.
31
+ - `--default-max-chars`: a fallback cap that sits below flag, env, and
32
+ config file — for wrapper scripts that want a protective default
33
+ without overriding the user's own settings.
34
+
35
+ ### Changed
36
+
37
+ - The Stop hook no longer reads `VOCALIZE_MAX_CHARS` itself; it passes
38
+ `--default-max-chars 500` and lets the CLI resolve the user's real
39
+ settings. Its subprocess timeout now scales with the text length
40
+ (60s base, ~12 chars/s, 900s ceiling) instead of killing any clip
41
+ longer than a minute; on timeout the whole process group is killed,
42
+ so the `afplay` child can't keep playing as an orphan.
43
+ - The Stop hook launches `vocalize` in its own session (no controlling
44
+ terminal), so an inherited `overflow = "ask"` degrades to truncate
45
+ there instead of blocking on a prompt nobody sees.
46
+
47
+ ## 0.4.0
48
+
49
+ ### Added
50
+
51
+ - `vocalize usage` — ElevenLabs quota and local cache at a glance.
52
+
6
53
  ## 0.3.0
7
54
 
8
55
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: vocalize-cli
3
- Version: 0.3.0
3
+ Version: 0.5.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
@@ -139,6 +139,9 @@ cat notes.md | vocalize speak-file -
139
139
  # List available voices and grab an ID
140
140
  vocalize voices
141
141
 
142
+ # Check your quota and cache
143
+ vocalize usage
144
+
142
145
  # Use a specific voice/model, save without playing
143
146
  vocalize speak-file report.md --voice <voice-id> --model eleven_flash_v2_5 \
144
147
  --output out.mp3 --no-play
@@ -157,6 +160,17 @@ Every synthesis result is cached on disk under `~/.cache/vocalize/`, keyed
157
160
  by a hash of (text, voice, model, format, speed) — re-running the same
158
161
  command twice doesn't burn API quota twice.
159
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
+
160
174
  ## Configuration
161
175
 
162
176
  Each setting is resolved on its own, taking the first source that supplies
@@ -186,7 +200,8 @@ writing anything.
186
200
  | Voice ID | `--voice` | `VOCALIZE_VOICE` | `voice` | `21m00Tcm4TlvDq8ikWAM` ("Rachel") |
187
201
  | Model ID | `--model` | `VOCALIZE_MODEL` | `model` | `eleven_multilingual_v2` |
188
202
  | Speed | `--speed` | `VOCALIZE_SPEED` | `speed` | unset — the API's own 1.0 |
189
- | 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` |
190
205
  | Hook binary | — | `VOCALIZE_BIN` | not read from the config file | `vocalize` as found on `PATH` |
191
206
 
192
207
  The config file is TOML at `$XDG_CONFIG_HOME/vocalize/config.toml`, falling
@@ -196,8 +211,20 @@ back to `~/.config/vocalize/config.toml`. Flat keys, no sections:
196
211
  voice = "21m00Tcm4TlvDq8ikWAM"
197
212
  model = "eleven_flash_v2_5"
198
213
  speed = 0.95
214
+ max_chars = 1000
215
+ overflow = "ask"
199
216
  ```
200
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
+
201
228
  Not having a config file is normal and silent. A file that isn't valid TOML
202
229
  is an error naming the file; a key that isn't recognised is a warning on
203
230
  stderr, so a typo doesn't pass unnoticed but doesn't stop the run either.
@@ -246,11 +273,21 @@ the existing file first) rather than overwriting your other hooks. Every
246
273
  Claude Code response after that gets spoken aloud automatically. Uninstall
247
274
  by removing the `vocalize` entry from the `Stop` array in that file.
248
275
 
249
- By default the hook truncates each response to 500 characters before
250
- speaking it (`DEFAULT_MAX_CHARS` in `claude_stop_hook.py`) a Stop hook
251
- fires after every turn, so a long response would burn through the
252
- ElevenLabs free-tier quota fast. Override with `VOCALIZE_MAX_CHARS` in the
253
- environment.
276
+ By default the hook caps each response at 500 characters before speaking
277
+ it a Stop hook fires after every turn, so a long response would burn
278
+ through the ElevenLabs free-tier quota fast. That 500 is only a fallback
279
+ (`--default-max-chars`, supplied by the hook): a `VOCALIZE_MAX_CHARS` env
280
+ var, a `max_chars` in the config file, or an `overflow` mode of `never`
281
+ all override it, resolved by `vocalize` itself with the usual precedence.
282
+
283
+ The hook launches `vocalize` in its own session, detached from the
284
+ terminal, so an `overflow` of `ask` degrades to truncate there instead of
285
+ writing a Y/n prompt into the middle of a session nobody is watching. Its
286
+ subprocess timeout scales with the length of the text being spoken (about
287
+ twelve characters a second, plus a minute of headroom), capped at a hard
288
+ 15-minute ceiling as a watchdog against hung processes — a read that
289
+ would outlast the ceiling is stopped there, and the whole process group
290
+ is killed so no orphaned audio keeps playing.
254
291
 
255
292
  The hook looks up the `vocalize` binary on `PATH`, but Claude Code hooks
256
293
  run in Claude Code's own environment, not your interactive shell — if
@@ -258,6 +295,55 @@ run in Claude Code's own environment, not your interactive shell — if
258
295
  `VOCALIZE_BIN` to the full path (e.g. `/path/to/.venv/bin/vocalize`) to
259
296
  point the hook at it directly.
260
297
 
298
+ ### Speaking files, artifacts, and more
299
+
300
+ Two primitives cover almost everything: `vocalize speak-file <path>` speaks
301
+ any local file (markdown flattened first), and the hook's `--latest` mode
302
+ speaks the most recent Claude Code response. Anything Claude itself has to
303
+ fetch — a claude.ai artifact, for instance — has to be fetched *by Claude*
304
+ (the CLI has no session), summarized, written to a file, and spoken from
305
+ the file:
306
+
307
+ ```bash
308
+ vocalize speak-file /path/to/summary.txt
309
+ ```
310
+
311
+ Never interpolate the summary into the command line itself — see guard 4.
312
+
313
+ **Web pages.** The CLI has no URL support, by design — it can't fetch
314
+ anything. For a URL, Claude fetches the page itself, in an isolated
315
+ subagent with a locked-down tool set, and produces either a short spoken
316
+ digest or a verbatim extract of the core content. That text comes back to
317
+ the main session the same way any other summary does: written to a file
318
+ and passed to `speak-file`.
319
+
320
+ If you wire this into a slash command of your own, treat it as a security
321
+ surface, because **every character you speak is sent to ElevenLabs**. The
322
+ guard principles that matter, in order:
323
+
324
+ 1. Resolve paths (`realpath`, expand `~`, casefold) and check an
325
+ **allow-list** of speakable directories — symlinks and `../` defeat
326
+ string matching on the raw argument.
327
+ 2. Hard-refuse secret-shaped files (`.env*`, keys, credentials) and your
328
+ sensitive directories; confirm before speaking anything else unusual.
329
+ 3. Summarize long or fetched content in an **isolated subagent** that
330
+ returns only the summary — content you fetched can carry instructions
331
+ aimed at your session.
332
+ 4. Pass summaries as a file path (as above) — never build the shell
333
+ command by interpolating model-written text into a quoted string. A
334
+ summary a model wrote can contain `$(...)`, and the shell will run it;
335
+ `printf '%s' "<summary>"` is exactly that bug.
336
+ 5. Confirm before any read that will spend real quota; a free tier is
337
+ 10,000 characters a month.
338
+ 6. Remember the disk cache: everything spoken leaves an mp3 under
339
+ `~/.cache/vocalize/`.
340
+ 7. Fetching is a second egress. Fetch only the URL the user typed — a page
341
+ can carry text telling its reader to fetch another URL, with data
342
+ smuggled out in the query string.
343
+ 8. Refuse non-public hosts. `localhost` and `169.254.169.254` are
344
+ reachable from your machine and nowhere else; parse the URL with the
345
+ `ipaddress` module rather than pattern-matching the string.
346
+
261
347
  ## How it's built
262
348
 
263
349
  Four decisions shaped the design:
@@ -314,10 +400,11 @@ All tests run offline: the ElevenLabs client is dependency-injected into
314
400
 
315
401
  - **Charts and images aren't described.** Flattening markdown tables is a
316
402
  text problem; a rendered chart is an image, and describing it well needs
317
- a vision model in the loop, not a text transform. Out of scope for this
318
- project, but a natural next step pipe the image through a
319
- vision-capable model first, feed its description into `vocalize` in
320
- place of the chart.
403
+ a vision model in the loop, not a text transform. That's out of scope for
404
+ the CLI itself, but the next step now lives outside it, where it belongs:
405
+ the `/speak` command's web-page mode renders a page in a browser, hands it
406
+ to a vision-capable subagent, and the diagram description comes back as
407
+ plain text — same as any other content this tool speaks.
321
408
  - **Free tier is 10,000 characters/month** — plenty for reading a handful
322
409
  of documents aloud, not for continuous use. `--max-chars` and the disk
323
410
  cache both help stretch it.
@@ -105,6 +105,9 @@ cat notes.md | vocalize speak-file -
105
105
  # List available voices and grab an ID
106
106
  vocalize voices
107
107
 
108
+ # Check your quota and cache
109
+ vocalize usage
110
+
108
111
  # Use a specific voice/model, save without playing
109
112
  vocalize speak-file report.md --voice <voice-id> --model eleven_flash_v2_5 \
110
113
  --output out.mp3 --no-play
@@ -123,6 +126,17 @@ Every synthesis result is cached on disk under `~/.cache/vocalize/`, keyed
123
126
  by a hash of (text, voice, model, format, speed) — re-running the same
124
127
  command twice doesn't burn API quota twice.
125
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
+
126
140
  ## Configuration
127
141
 
128
142
  Each setting is resolved on its own, taking the first source that supplies
@@ -152,7 +166,8 @@ writing anything.
152
166
  | Voice ID | `--voice` | `VOCALIZE_VOICE` | `voice` | `21m00Tcm4TlvDq8ikWAM` ("Rachel") |
153
167
  | Model ID | `--model` | `VOCALIZE_MODEL` | `model` | `eleven_multilingual_v2` |
154
168
  | Speed | `--speed` | `VOCALIZE_SPEED` | `speed` | unset — the API's own 1.0 |
155
- | 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` |
156
171
  | Hook binary | — | `VOCALIZE_BIN` | not read from the config file | `vocalize` as found on `PATH` |
157
172
 
158
173
  The config file is TOML at `$XDG_CONFIG_HOME/vocalize/config.toml`, falling
@@ -162,8 +177,20 @@ back to `~/.config/vocalize/config.toml`. Flat keys, no sections:
162
177
  voice = "21m00Tcm4TlvDq8ikWAM"
163
178
  model = "eleven_flash_v2_5"
164
179
  speed = 0.95
180
+ max_chars = 1000
181
+ overflow = "ask"
165
182
  ```
166
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
+
167
194
  Not having a config file is normal and silent. A file that isn't valid TOML
168
195
  is an error naming the file; a key that isn't recognised is a warning on
169
196
  stderr, so a typo doesn't pass unnoticed but doesn't stop the run either.
@@ -212,11 +239,21 @@ the existing file first) rather than overwriting your other hooks. Every
212
239
  Claude Code response after that gets spoken aloud automatically. Uninstall
213
240
  by removing the `vocalize` entry from the `Stop` array in that file.
214
241
 
215
- By default the hook truncates each response to 500 characters before
216
- speaking it (`DEFAULT_MAX_CHARS` in `claude_stop_hook.py`) a Stop hook
217
- fires after every turn, so a long response would burn through the
218
- ElevenLabs free-tier quota fast. Override with `VOCALIZE_MAX_CHARS` in the
219
- environment.
242
+ By default the hook caps each response at 500 characters before speaking
243
+ it a Stop hook fires after every turn, so a long response would burn
244
+ through the ElevenLabs free-tier quota fast. That 500 is only a fallback
245
+ (`--default-max-chars`, supplied by the hook): a `VOCALIZE_MAX_CHARS` env
246
+ var, a `max_chars` in the config file, or an `overflow` mode of `never`
247
+ all override it, resolved by `vocalize` itself with the usual precedence.
248
+
249
+ The hook launches `vocalize` in its own session, detached from the
250
+ terminal, so an `overflow` of `ask` degrades to truncate there instead of
251
+ writing a Y/n prompt into the middle of a session nobody is watching. Its
252
+ subprocess timeout scales with the length of the text being spoken (about
253
+ twelve characters a second, plus a minute of headroom), capped at a hard
254
+ 15-minute ceiling as a watchdog against hung processes — a read that
255
+ would outlast the ceiling is stopped there, and the whole process group
256
+ is killed so no orphaned audio keeps playing.
220
257
 
221
258
  The hook looks up the `vocalize` binary on `PATH`, but Claude Code hooks
222
259
  run in Claude Code's own environment, not your interactive shell — if
@@ -224,6 +261,55 @@ run in Claude Code's own environment, not your interactive shell — if
224
261
  `VOCALIZE_BIN` to the full path (e.g. `/path/to/.venv/bin/vocalize`) to
225
262
  point the hook at it directly.
226
263
 
264
+ ### Speaking files, artifacts, and more
265
+
266
+ Two primitives cover almost everything: `vocalize speak-file <path>` speaks
267
+ any local file (markdown flattened first), and the hook's `--latest` mode
268
+ speaks the most recent Claude Code response. Anything Claude itself has to
269
+ fetch — a claude.ai artifact, for instance — has to be fetched *by Claude*
270
+ (the CLI has no session), summarized, written to a file, and spoken from
271
+ the file:
272
+
273
+ ```bash
274
+ vocalize speak-file /path/to/summary.txt
275
+ ```
276
+
277
+ Never interpolate the summary into the command line itself — see guard 4.
278
+
279
+ **Web pages.** The CLI has no URL support, by design — it can't fetch
280
+ anything. For a URL, Claude fetches the page itself, in an isolated
281
+ subagent with a locked-down tool set, and produces either a short spoken
282
+ digest or a verbatim extract of the core content. That text comes back to
283
+ the main session the same way any other summary does: written to a file
284
+ and passed to `speak-file`.
285
+
286
+ If you wire this into a slash command of your own, treat it as a security
287
+ surface, because **every character you speak is sent to ElevenLabs**. The
288
+ guard principles that matter, in order:
289
+
290
+ 1. Resolve paths (`realpath`, expand `~`, casefold) and check an
291
+ **allow-list** of speakable directories — symlinks and `../` defeat
292
+ string matching on the raw argument.
293
+ 2. Hard-refuse secret-shaped files (`.env*`, keys, credentials) and your
294
+ sensitive directories; confirm before speaking anything else unusual.
295
+ 3. Summarize long or fetched content in an **isolated subagent** that
296
+ returns only the summary — content you fetched can carry instructions
297
+ aimed at your session.
298
+ 4. Pass summaries as a file path (as above) — never build the shell
299
+ command by interpolating model-written text into a quoted string. A
300
+ summary a model wrote can contain `$(...)`, and the shell will run it;
301
+ `printf '%s' "<summary>"` is exactly that bug.
302
+ 5. Confirm before any read that will spend real quota; a free tier is
303
+ 10,000 characters a month.
304
+ 6. Remember the disk cache: everything spoken leaves an mp3 under
305
+ `~/.cache/vocalize/`.
306
+ 7. Fetching is a second egress. Fetch only the URL the user typed — a page
307
+ can carry text telling its reader to fetch another URL, with data
308
+ smuggled out in the query string.
309
+ 8. Refuse non-public hosts. `localhost` and `169.254.169.254` are
310
+ reachable from your machine and nowhere else; parse the URL with the
311
+ `ipaddress` module rather than pattern-matching the string.
312
+
227
313
  ## How it's built
228
314
 
229
315
  Four decisions shaped the design:
@@ -280,10 +366,11 @@ All tests run offline: the ElevenLabs client is dependency-injected into
280
366
 
281
367
  - **Charts and images aren't described.** Flattening markdown tables is a
282
368
  text problem; a rendered chart is an image, and describing it well needs
283
- a vision model in the loop, not a text transform. Out of scope for this
284
- project, but a natural next step pipe the image through a
285
- vision-capable model first, feed its description into `vocalize` in
286
- place of the chart.
369
+ a vision model in the loop, not a text transform. That's out of scope for
370
+ the CLI itself, but the next step now lives outside it, where it belongs:
371
+ the `/speak` command's web-page mode renders a page in a browser, hands it
372
+ to a vision-capable subagent, and the diagram description comes back as
373
+ plain text — same as any other content this tool speaks.
287
374
  - **Free tier is 10,000 characters/month** — plenty for reading a handful
288
375
  of documents aloud, not for continuous use. `--max-chars` and the disk
289
376
  cache both help stretch it.
@@ -28,15 +28,32 @@ from __future__ import annotations
28
28
  import json
29
29
  import os
30
30
  import shutil
31
+ import signal
31
32
  import subprocess
32
33
  import sys
33
34
  from pathlib import Path
34
35
 
35
36
  # Keep spoken responses short by default — a Stop hook fires after every
36
37
  # turn, and a long response would eat the ElevenLabs free-tier quota fast.
37
- # Override with VOCALIZE_MAX_CHARS in the environment.
38
+ # Passed as vocalize's --default-max-chars, which sits BELOW the user's own
39
+ # settings: a --max-chars flag, VOCALIZE_MAX_CHARS in the environment, or
40
+ # max_chars in the config file all override it (vocalize resolves those
41
+ # itself — this hook deliberately doesn't).
38
42
  DEFAULT_MAX_CHARS = 500
39
43
 
44
+ # Playback runs inside the vocalize subprocess and speech is roughly 12
45
+ # characters a second, so a fixed timeout kills long clips mid-play. Scale
46
+ # with the text instead; the ceiling still stops a hung process from
47
+ # outliving the session. Truncation (if any) happens inside vocalize, so
48
+ # the untruncated length is an upper bound, never too tight.
49
+ TIMEOUT_BASE_SECONDS = 60
50
+ TIMEOUT_CEILING_SECONDS = 900
51
+ CHARS_PER_SECOND = 12
52
+
53
+
54
+ def _speech_timeout(text: str) -> int:
55
+ return min(TIMEOUT_CEILING_SECONDS, TIMEOUT_BASE_SECONDS + len(text) // CHARS_PER_SECOND)
56
+
40
57
 
41
58
  def _extract_last_assistant_text(transcript_path: str) -> str:
42
59
  last_text_parts: list[str] = []
@@ -118,21 +135,34 @@ def main() -> int:
118
135
  # tool isn't installed / not on PATH in this shell.
119
136
  return 0
120
137
 
121
- max_chars = os.environ.get("VOCALIZE_MAX_CHARS", str(DEFAULT_MAX_CHARS))
122
-
123
138
  # Options first, then "--", then the text. Click treats any argv token
124
139
  # starting with "-" as an option, so a reply that opens with a bullet
125
140
  # ("- fixed the parser") or an arrow ("-> next") would otherwise make
126
141
  # vocalize exit 2 with "No such option" and speak nothing. The "--"
127
142
  # end-of-options separator makes the text unambiguously an argument.
143
+ #
144
+ # start_new_session detaches vocalize from the controlling terminal, on
145
+ # purpose, for two reasons: (1) overflow "ask" then finds no /dev/tty
146
+ # and degrades to truncate instead of writing a Y/n prompt into the
147
+ # middle of a Claude Code session that nobody knows to answer; (2) the
148
+ # new process group lets the timeout path kill vocalize AND the afplay
149
+ # child it spawned — subprocess.run's own timeout kills only the direct
150
+ # child and leaves the audio playing.
151
+ argv = [vocalize_bin, "speak", "--default-max-chars", str(DEFAULT_MAX_CHARS),
152
+ "--play", "--", text]
153
+ timeout = _speech_timeout(text)
128
154
  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)
155
+ proc = subprocess.Popen(argv, start_new_session=True)
156
+ try:
157
+ returncode = proc.wait(timeout=timeout)
158
+ except subprocess.TimeoutExpired:
159
+ os.killpg(proc.pid, signal.SIGKILL) # pgid == pid: it leads the new session
160
+ proc.wait()
161
+ print(f"vocalize hook: speech timed out after {timeout}s; "
162
+ "killed the process group", file=sys.stderr)
163
+ return 0
164
+ if returncode != 0:
165
+ print(f"vocalize hook: vocalize exited {returncode}", file=sys.stderr)
136
166
  except Exception as exc: # noqa: BLE001 — must not crash the Stop hook
137
167
  # A speech failure should never break the coding session, so this
138
168
  # still returns 0 — but it's logged to stderr rather than swallowed