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.
- {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/.gitignore +1 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/CHANGELOG.md +52 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/PKG-INFO +72 -14
- {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/README.md +71 -13
- {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/hooks/claude_stop_hook.py +53 -10
- vocalize_cli-0.6.0/hooks/speak_url_gate.py +354 -0
- vocalize_cli-0.6.0/tests/test_audio.py +274 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/tests/test_claude_stop_hook.py +88 -21
- vocalize_cli-0.6.0/tests/test_cli.py +637 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/tests/test_config.py +69 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/tests/test_preprocess.py +91 -1
- vocalize_cli-0.6.0/tests/test_speak_url_gate.py +163 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/vocalize/__init__.py +1 -1
- vocalize_cli-0.6.0/vocalize/audio.py +177 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/vocalize/cli.py +122 -12
- {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/vocalize/config.py +73 -2
- {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/vocalize/preprocess.py +83 -0
- vocalize_cli-0.4.0/tests/test_audio.py +0 -101
- vocalize_cli-0.4.0/tests/test_cli.py +0 -317
- vocalize_cli-0.4.0/vocalize/audio.py +0 -71
- {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/.env.example +0 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/.github/workflows/ci.yml +0 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/LICENSE +0 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/hooks/install_hook.py +0 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/pyproject.toml +0 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/tests/conftest.py +0 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/tests/test_auth.py +0 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/tests/test_install_hook.py +0 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/tests/test_tts.py +0 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/tests/test_wizard.py +0 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/vocalize/__main__.py +0 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/vocalize/auth.py +0 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/vocalize/exceptions.py +0 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/vocalize/tts.py +0 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.6.0}/vocalize/wizard.py +0 -0
|
@@ -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.
|
|
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`
|
|
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
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
|
308
|
+
(the CLI has no session), summarized, written to a file, and spoken from
|
|
309
|
+
the file:
|
|
271
310
|
|
|
272
311
|
```bash
|
|
273
|
-
|
|
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.
|
|
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.
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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`
|
|
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
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
274
|
+
(the CLI has no session), summarized, written to a file, and spoken from
|
|
275
|
+
the file:
|
|
237
276
|
|
|
238
277
|
```bash
|
|
239
|
-
|
|
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.
|
|
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.
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
timeout=
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
print(f"vocalize hook:
|
|
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
|