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.
- {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/.gitignore +1 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/CHANGELOG.md +47 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/PKG-INFO +98 -11
- {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/README.md +97 -10
- {vocalize_cli-0.3.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.3.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.3.0 → vocalize_cli-0.5.0}/tests/test_config.py +69 -0
- {vocalize_cli-0.3.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.3.0 → vocalize_cli-0.5.0}/tests/test_tts.py +46 -1
- {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/vocalize/__init__.py +1 -1
- vocalize_cli-0.5.0/vocalize/audio.py +177 -0
- vocalize_cli-0.5.0/vocalize/cli.py +312 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/vocalize/config.py +73 -2
- {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/vocalize/preprocess.py +83 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/vocalize/tts.py +19 -0
- vocalize_cli-0.3.0/tests/test_audio.py +0 -101
- vocalize_cli-0.3.0/tests/test_cli.py +0 -272
- vocalize_cli-0.3.0/vocalize/audio.py +0 -71
- vocalize_cli-0.3.0/vocalize/cli.py +0 -178
- {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/.env.example +0 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/.github/workflows/ci.yml +0 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/LICENSE +0 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/hooks/install_hook.py +0 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/pyproject.toml +0 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/tests/conftest.py +0 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/tests/test_auth.py +0 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/tests/test_install_hook.py +0 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/tests/test_wizard.py +0 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/vocalize/__main__.py +0 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/vocalize/auth.py +0 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/vocalize/exceptions.py +0 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.5.0}/vocalize/wizard.py +0 -0
|
@@ -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
|
+
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`
|
|
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
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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.
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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`
|
|
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
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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.
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
#
|
|
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
|