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.
- {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/.gitignore +1 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/CHANGELOG.md +41 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/PKG-INFO +68 -14
- {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/README.md +67 -13
- {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/hooks/claude_stop_hook.py +40 -10
- vocalize_cli-0.5.0/hooks/speak_url_gate.py +354 -0
- vocalize_cli-0.5.0/tests/test_audio.py +274 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/tests/test_claude_stop_hook.py +68 -21
- vocalize_cli-0.5.0/tests/test_cli.py +609 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/tests/test_config.py +69 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/tests/test_preprocess.py +91 -1
- vocalize_cli-0.5.0/tests/test_speak_url_gate.py +163 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/vocalize/__init__.py +1 -1
- vocalize_cli-0.5.0/vocalize/audio.py +177 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/vocalize/cli.py +103 -12
- {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/vocalize/config.py +73 -2
- {vocalize_cli-0.4.0 → vocalize_cli-0.5.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.5.0}/.env.example +0 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/.github/workflows/ci.yml +0 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/LICENSE +0 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/hooks/install_hook.py +0 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/pyproject.toml +0 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/tests/conftest.py +0 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/tests/test_auth.py +0 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/tests/test_install_hook.py +0 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/tests/test_tts.py +0 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/tests/test_wizard.py +0 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/vocalize/__main__.py +0 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/vocalize/auth.py +0 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/vocalize/exceptions.py +0 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/vocalize/tts.py +0 -0
- {vocalize_cli-0.4.0 → vocalize_cli-0.5.0}/vocalize/wizard.py +0 -0
|
@@ -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.
|
|
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`
|
|
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
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
|
304
|
+
(the CLI has no session), summarized, written to a file, and spoken from
|
|
305
|
+
the file:
|
|
271
306
|
|
|
272
307
|
```bash
|
|
273
|
-
|
|
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.
|
|
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.
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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`
|
|
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
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
270
|
+
(the CLI has no session), summarized, written to a file, and spoken from
|
|
271
|
+
the file:
|
|
237
272
|
|
|
238
273
|
```bash
|
|
239
|
-
|
|
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.
|
|
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.
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
timeout=
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
print(f"vocalize hook:
|
|
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
|