vocalize-cli 0.4.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 (35) hide show
  1. {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/.gitignore +1 -0
  2. {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/CHANGELOG.md +41 -0
  3. {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/PKG-INFO +68 -14
  4. {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/README.md +67 -13
  5. {vocalize_cli-0.4.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.4.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.4.0 → vocalize_cli-0.5.0}/tests/test_config.py +69 -0
  11. {vocalize_cli-0.4.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.4.0 → vocalize_cli-0.5.0}/vocalize/__init__.py +1 -1
  14. vocalize_cli-0.5.0/vocalize/audio.py +177 -0
  15. {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/vocalize/cli.py +103 -12
  16. {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/vocalize/config.py +73 -2
  17. {vocalize_cli-0.4.0 → vocalize_cli-0.5.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.5.0}/.env.example +0 -0
  22. {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/.github/workflows/ci.yml +0 -0
  23. {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/LICENSE +0 -0
  24. {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/hooks/install_hook.py +0 -0
  25. {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/pyproject.toml +0 -0
  26. {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/tests/conftest.py +0 -0
  27. {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/tests/test_auth.py +0 -0
  28. {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/tests/test_install_hook.py +0 -0
  29. {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/tests/test_tts.py +0 -0
  30. {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/tests/test_wizard.py +0 -0
  31. {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/vocalize/__main__.py +0 -0
  32. {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/vocalize/auth.py +0 -0
  33. {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/vocalize/exceptions.py +0 -0
  34. {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/vocalize/tts.py +0 -0
  35. {vocalize_cli-0.4.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,47 @@
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
+
6
47
  ## 0.4.0
7
48
 
8
49
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: vocalize-cli
3
- Version: 0.4.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
@@ -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,20 @@ 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
+
204
228
  Not having a config file is normal and silent. A file that isn't valid TOML
205
229
  is an error naming the file; a key that isn't recognised is a warning on
206
230
  stderr, so a typo doesn't pass unnoticed but doesn't stop the run either.
@@ -249,11 +273,21 @@ the existing file first) rather than overwriting your other hooks. Every
249
273
  Claude Code response after that gets spoken aloud automatically. Uninstall
250
274
  by removing the `vocalize` entry from the `Stop` array in that file.
251
275
 
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.
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.
257
291
 
258
292
  The hook looks up the `vocalize` binary on `PATH`, but Claude Code hooks
259
293
  run in Claude Code's own environment, not your interactive shell — if
@@ -267,12 +301,22 @@ Two primitives cover almost everything: `vocalize speak-file <path>` speaks
267
301
  any local file (markdown flattened first), and the hook's `--latest` mode
268
302
  speaks the most recent Claude Code response. Anything Claude itself has to
269
303
  fetch — a claude.ai artifact, for instance — has to be fetched *by Claude*
270
- (the CLI has no session), summarized, and piped in:
304
+ (the CLI has no session), summarized, written to a file, and spoken from
305
+ the file:
271
306
 
272
307
  ```bash
273
- printf '%s' "the summary text" | vocalize speak-file -
308
+ vocalize speak-file /path/to/summary.txt
274
309
  ```
275
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
+
276
320
  If you wire this into a slash command of your own, treat it as a security
277
321
  surface, because **every character you speak is sent to ElevenLabs**. The
278
322
  guard principles that matter, in order:
@@ -285,11 +329,20 @@ guard principles that matter, in order:
285
329
  3. Summarize long or fetched content in an **isolated subagent** that
286
330
  returns only the summary — content you fetched can carry instructions
287
331
  aimed at your session.
288
- 4. Pipe summaries over stdin (as above) — no temp files, nothing in argv.
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.
289
336
  5. Confirm before any read that will spend real quota; a free tier is
290
337
  10,000 characters a month.
291
338
  6. Remember the disk cache: everything spoken leaves an mp3 under
292
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.
293
346
 
294
347
  ## How it's built
295
348
 
@@ -347,10 +400,11 @@ All tests run offline: the ElevenLabs client is dependency-injected into
347
400
 
348
401
  - **Charts and images aren't described.** Flattening markdown tables is a
349
402
  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.
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.
354
408
  - **Free tier is 10,000 characters/month** — plenty for reading a handful
355
409
  of documents aloud, not for continuous use. `--max-chars` and the disk
356
410
  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,20 @@ 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
+
170
194
  Not having a config file is normal and silent. A file that isn't valid TOML
171
195
  is an error naming the file; a key that isn't recognised is a warning on
172
196
  stderr, so a typo doesn't pass unnoticed but doesn't stop the run either.
@@ -215,11 +239,21 @@ the existing file first) rather than overwriting your other hooks. Every
215
239
  Claude Code response after that gets spoken aloud automatically. Uninstall
216
240
  by removing the `vocalize` entry from the `Stop` array in that file.
217
241
 
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.
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.
223
257
 
224
258
  The hook looks up the `vocalize` binary on `PATH`, but Claude Code hooks
225
259
  run in Claude Code's own environment, not your interactive shell — if
@@ -233,12 +267,22 @@ Two primitives cover almost everything: `vocalize speak-file <path>` speaks
233
267
  any local file (markdown flattened first), and the hook's `--latest` mode
234
268
  speaks the most recent Claude Code response. Anything Claude itself has to
235
269
  fetch — a claude.ai artifact, for instance — has to be fetched *by Claude*
236
- (the CLI has no session), summarized, and piped in:
270
+ (the CLI has no session), summarized, written to a file, and spoken from
271
+ the file:
237
272
 
238
273
  ```bash
239
- printf '%s' "the summary text" | vocalize speak-file -
274
+ vocalize speak-file /path/to/summary.txt
240
275
  ```
241
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
+
242
286
  If you wire this into a slash command of your own, treat it as a security
243
287
  surface, because **every character you speak is sent to ElevenLabs**. The
244
288
  guard principles that matter, in order:
@@ -251,11 +295,20 @@ guard principles that matter, in order:
251
295
  3. Summarize long or fetched content in an **isolated subagent** that
252
296
  returns only the summary — content you fetched can carry instructions
253
297
  aimed at your session.
254
- 4. Pipe summaries over stdin (as above) — no temp files, nothing in argv.
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.
255
302
  5. Confirm before any read that will spend real quota; a free tier is
256
303
  10,000 characters a month.
257
304
  6. Remember the disk cache: everything spoken leaves an mp3 under
258
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.
259
312
 
260
313
  ## How it's built
261
314
 
@@ -313,10 +366,11 @@ All tests run offline: the ElevenLabs client is dependency-injected into
313
366
 
314
367
  - **Charts and images aren't described.** Flattening markdown tables is a
315
368
  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.
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.
320
374
  - **Free tier is 10,000 characters/month** — plenty for reading a handful
321
375
  of documents aloud, not for continuous use. `--max-chars` and the disk
322
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