vocalize-cli 0.8.0__tar.gz → 0.9.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 (79) hide show
  1. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/.gitignore +1 -0
  2. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/CHANGELOG.md +69 -0
  3. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/PKG-INFO +203 -5
  4. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/README.md +200 -4
  5. vocalize_cli-0.9.0/docs/provider-credentials.md +143 -0
  6. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/pyproject.toml +1 -0
  7. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/tests/conftest.py +11 -0
  8. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/tests/test_audio.py +101 -0
  9. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/tests/test_auth.py +81 -0
  10. vocalize_cli-0.9.0/tests/test_cache.py +105 -0
  11. vocalize_cli-0.9.0/tests/test_chain.py +682 -0
  12. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/tests/test_claude_stop_hook.py +7 -3
  13. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/tests/test_cli.py +672 -47
  14. vocalize_cli-0.9.0/tests/test_config.py +559 -0
  15. vocalize_cli-0.9.0/tests/test_elevenlabs_provider.py +228 -0
  16. vocalize_cli-0.9.0/tests/test_exceptions.py +77 -0
  17. vocalize_cli-0.9.0/tests/test_google_provider.py +337 -0
  18. vocalize_cli-0.9.0/tests/test_http.py +217 -0
  19. vocalize_cli-0.9.0/tests/test_kokoro_manifest.py +62 -0
  20. vocalize_cli-0.9.0/tests/test_kokoro_provider.py +485 -0
  21. vocalize_cli-0.9.0/tests/test_kokoro_worker.py +284 -0
  22. vocalize_cli-0.9.0/tests/test_ledger.py +193 -0
  23. vocalize_cli-0.9.0/tests/test_local_install.py +548 -0
  24. vocalize_cli-0.9.0/tests/test_openai_provider.py +259 -0
  25. vocalize_cli-0.9.0/tests/test_polly_provider.py +340 -0
  26. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/tests/test_preprocess.py +8 -0
  27. vocalize_cli-0.9.0/tests/test_providers_registry.py +78 -0
  28. vocalize_cli-0.9.0/tests/test_say_provider.py +142 -0
  29. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/tests/test_speak_options.py +9 -0
  30. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/tests/test_tts.py +11 -1
  31. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/tests/test_wizard.py +147 -1
  32. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/vocalize/__init__.py +1 -1
  33. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/vocalize/audio.py +76 -7
  34. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/vocalize/auth.py +113 -29
  35. vocalize_cli-0.9.0/vocalize/cache.py +70 -0
  36. vocalize_cli-0.9.0/vocalize/chain.py +274 -0
  37. vocalize_cli-0.9.0/vocalize/cli.py +859 -0
  38. vocalize_cli-0.9.0/vocalize/config.py +458 -0
  39. vocalize_cli-0.9.0/vocalize/exceptions.py +105 -0
  40. vocalize_cli-0.9.0/vocalize/ledger.py +185 -0
  41. vocalize_cli-0.9.0/vocalize/local/__init__.py +7 -0
  42. vocalize_cli-0.9.0/vocalize/local/install.py +217 -0
  43. vocalize_cli-0.9.0/vocalize/local/kokoro_manifest.py +90 -0
  44. vocalize_cli-0.9.0/vocalize/local/kokoro_worker.py +159 -0
  45. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/vocalize/preprocess.py +3 -2
  46. vocalize_cli-0.9.0/vocalize/providers/__init__.py +87 -0
  47. vocalize_cli-0.9.0/vocalize/providers/_http.py +111 -0
  48. vocalize_cli-0.9.0/vocalize/providers/elevenlabs.py +126 -0
  49. vocalize_cli-0.9.0/vocalize/providers/google.py +183 -0
  50. vocalize_cli-0.9.0/vocalize/providers/kokoro.py +299 -0
  51. vocalize_cli-0.9.0/vocalize/providers/openai.py +144 -0
  52. vocalize_cli-0.9.0/vocalize/providers/polly.py +221 -0
  53. vocalize_cli-0.9.0/vocalize/providers/say.py +123 -0
  54. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/vocalize/tts.py +20 -30
  55. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/vocalize/wizard.py +117 -13
  56. vocalize_cli-0.8.0/tests/test_config.py +0 -249
  57. vocalize_cli-0.8.0/vocalize/cli.py +0 -456
  58. vocalize_cli-0.8.0/vocalize/config.py +0 -237
  59. vocalize_cli-0.8.0/vocalize/exceptions.py +0 -42
  60. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/.env.example +0 -0
  61. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/.github/workflows/ci.yml +0 -0
  62. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/LICENSE +0 -0
  63. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/hooks/claude_stop_hook.py +0 -0
  64. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/hooks/install_hook.py +0 -0
  65. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/hooks/install_quick_action.py +0 -0
  66. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/hooks/quick_actions/Speak Latest Plan.workflow/Contents/Info.plist +0 -0
  67. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/hooks/quick_actions/Speak Latest Plan.workflow/Contents/Resources/document.wflow +0 -0
  68. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/hooks/quick_actions/Speak with Vocalize.workflow/Contents/Info.plist +0 -0
  69. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/hooks/quick_actions/Speak with Vocalize.workflow/Contents/Resources/document.wflow +0 -0
  70. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/hooks/quick_actions/Stop Vocalize.workflow/Contents/Info.plist +0 -0
  71. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/hooks/quick_actions/Stop Vocalize.workflow/Contents/Resources/document.wflow +0 -0
  72. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/hooks/speak_options.py +0 -0
  73. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/hooks/speak_url_gate.py +0 -0
  74. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/tests/test_clipboard.py +0 -0
  75. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/tests/test_install_hook.py +0 -0
  76. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/tests/test_install_quick_action.py +0 -0
  77. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/tests/test_speak_url_gate.py +0 -0
  78. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/vocalize/__main__.py +0 -0
  79. {vocalize_cli-0.8.0 → vocalize_cli-0.9.0}/vocalize/clipboard.py +0 -0
@@ -12,3 +12,4 @@ venv/
12
12
  *.mp3
13
13
  *.wav
14
14
  .claude/
15
+ uv.lock
@@ -3,6 +3,75 @@
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.9.0 - 2026-09-01
7
+
8
+ ### Added
9
+
10
+ - Multi-provider text-to-speech with a fallback chain. Alongside ElevenLabs,
11
+ vocalize can now speak through OpenAI, Google Cloud Text-to-Speech, Amazon
12
+ Polly, macOS `say`, and a new local Kokoro provider — tried in order until
13
+ one succeeds. Default chain when nothing is configured: `elevenlabs, say`.
14
+ - `--provider` on `speak`/`speak-file`/`clip` forces a single provider and
15
+ turns fallback off. `vocalize chain` shows the resolved order and its
16
+ source (flag/env/config/default), or writes a new one to `config.toml`
17
+ (`vocalize chain google polly say`) with every other key and table
18
+ preserved.
19
+ - A local monthly character budget per cloud provider (`monthly_chars` under
20
+ `[providers.<name>]`), tracked in `~/.cache/vocalize/usage.json`. A
21
+ provider that returns a real quota error from the vendor is remembered as
22
+ exhausted for the rest of the calendar month. `vocalize usage` now reports
23
+ every provider's tally against its budget alongside the existing
24
+ ElevenLabs remote quota.
25
+ - Per-provider `vocalize auth login|status|logout --provider <name>` and
26
+ `vocalize voices --provider <name>`.
27
+ - `vocalize local install` and `vocalize local status` — opt-in setup for
28
+ Kokoro, an offline local voice. Nothing is downloaded until you run
29
+ `install`: it prints exactly what it will fetch (sizes, source URLs,
30
+ destination), verifies every file against a pinned sha256, and runs the
31
+ model under its own `uv`-managed Python 3.12 so vocalize's own environment
32
+ never changes. `pip install vocalize-cli` pulls in none of it.
33
+ - Streaming playback for Kokoro: long text renders in ~400-character pieces
34
+ and starts playing after the first one instead of waiting for the whole
35
+ read to finish. `vocalize stop` works mid-read same as any other provider.
36
+ - New optional extra `pip install "vocalize-cli[polly]"` for Amazon Polly
37
+ (boto3, lazy-imported — nothing else pays for it).
38
+ - New `docs/provider-credentials.md`: click-by-click setup for OpenAI,
39
+ Google, Polly, and Kokoro.
40
+
41
+ ### Changed
42
+
43
+ - The default output file is now `~/.cache/vocalize/last.<ext>` (`.mp3`,
44
+ `.m4a`, or `.wav` depending on which provider spoke), not always `.mp3`.
45
+ - `vocalize usage` no longer fails outright when no ElevenLabs key is
46
+ configured — it prints "no key configured, skipped" for that section and
47
+ still shows every provider's local budget line and the cache stats.
48
+ - Request progress on stderr now names the provider that's speaking
49
+ (`Requesting 340 characters from google...`) instead of always saying
50
+ ElevenLabs, and a fallback that succeeds says so (`Spoke via say
51
+ (fallback).`).
52
+ - `vocalize config`'s wizard step labels are now suffixed `(ElevenLabs)` —
53
+ the wizard still only sets up ElevenLabs; use `vocalize chain` or hand-edit
54
+ `config.toml` for the rest of the chain.
55
+ - `vocalize settings` gains one additive line: `chain=elevenlabs,say`.
56
+
57
+ ### Security
58
+
59
+ - Every provider's API key stays out of URLs, argv, logs, and error
60
+ messages — headers, the OS keychain, or environment variables only.
61
+ - Kokoro's model downloads are pinned by URL, size, and sha256; a mismatch
62
+ deletes the file and refuses rather than installing anything unverified.
63
+ - Text reaches every local worker (`say`, Kokoro) through a file or stdin,
64
+ never as a command-line argument or environment variable.
65
+
66
+ ## 0.8.1 - 2026-09-01
67
+
68
+ ### Fixed
69
+
70
+ - Truncated speech no longer reads the words "dot dot dot truncated" aloud.
71
+ The `... (truncated)` marker was being appended to the text sent to
72
+ ElevenLabs and spoken; it's gone from the audio now. The CLI still prints
73
+ a "Note: input truncated to N characters." line to stderr.
74
+
6
75
  ## 0.8.0 - 2026-08-31
7
76
 
8
77
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: vocalize-cli
3
- Version: 0.8.0
3
+ Version: 0.9.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
@@ -30,6 +30,8 @@ Requires-Dist: ruff; extra == 'dev'
30
30
  Requires-Dist: twine; extra == 'dev'
31
31
  Provides-Extra: dotenv
32
32
  Requires-Dist: python-dotenv>=1.0; extra == 'dotenv'
33
+ Provides-Extra: polly
34
+ Requires-Dist: boto3>=1.34; extra == 'polly'
33
35
  Description-Content-Type: text/markdown
34
36
 
35
37
  # vocalize
@@ -157,6 +159,15 @@ vocalize speak-file report.md --speed 0.9
157
159
 
158
160
  # Skip the markdown flattening entirely
159
161
  vocalize speak "raw **markdown** stays raw" --raw
162
+
163
+ # Speak through one specific provider, no fallback
164
+ vocalize speak "Test" --provider google
165
+
166
+ # See (or set) the order providers are tried in
167
+ vocalize chain
168
+
169
+ # Set up the offline, opt-in local voice (downloads ~354 MB once)
170
+ vocalize local install
160
171
  ```
161
172
 
162
173
  Every synthesis result is cached on disk under `~/.cache/vocalize/`, keyed
@@ -213,19 +224,45 @@ writing anything.
213
224
  | Speed | `--speed` | `VOCALIZE_SPEED` | `speed` | unset — the API's own 1.0 |
214
225
  | Max characters | `--max-chars` | `VOCALIZE_MAX_CHARS` | `max_chars` | unset on the CLI; the hook supplies a 500 fallback |
215
226
  | Overflow mode | `--overflow` | `VOCALIZE_OVERFLOW` | `overflow` | `truncate` |
227
+ | Provider chain | `--provider` (forces one, no fallback) | `VOCALIZE_CHAIN` (comma-separated) | `chain` (array) | `["elevenlabs", "say"]` |
216
228
  | Hook binary | — | `VOCALIZE_BIN` | not read from the config file | `vocalize` as found on `PATH` |
217
229
 
230
+ `--voice`/`--model`/`--speed` and their `VOCALIZE_*` env vars only ever apply
231
+ to the **primary** provider — the first in the chain, or the one `--provider`
232
+ forces. Every other link reads only its own `[providers.<name>]` table and
233
+ its own built-in defaults; see
234
+ [Providers and fallback](#providers-and-fallback) below.
235
+
218
236
  The config file is TOML at `$XDG_CONFIG_HOME/vocalize/config.toml`, falling
219
237
  back to `~/.config/vocalize/config.toml`. Flat keys, no sections:
220
238
 
221
239
  ```toml
240
+ chain = ["elevenlabs", "google", "say"]
241
+
242
+ # Flat keys = ElevenLabs, unchanged since before there was a chain.
222
243
  voice = "21m00Tcm4TlvDq8ikWAM"
223
244
  model = "eleven_flash_v2_5"
224
245
  speed = 0.95
225
246
  max_chars = 1000
226
247
  overflow = "ask"
248
+
249
+ [providers.google]
250
+ voice = "en-US-Neural2-F"
251
+ language = "en-US"
252
+ monthly_chars = 1000000
253
+
254
+ [providers.say]
255
+ voice = "Samantha"
256
+
257
+ [providers.kokoro]
258
+ voice = "af_heart"
227
259
  ```
228
260
 
261
+ Every other provider gets its own `[providers.<name>]` table. The keys it
262
+ can hold: `voice`, `model`, `engine` (an alias for `model` — Polly's field is
263
+ called that), `speed`, `language`, `region`, `profile`, and `monthly_chars`.
264
+ A key outside that set warns on stderr rather than failing the run.
265
+
229
266
  `overflow` decides what happens when input is longer than the resolved
230
267
  `max_chars` cap: `truncate` (the default) cuts it at the cap, `ask` prompts
231
268
  on the controlling terminal first — and degrades to `truncate` with a note
@@ -252,6 +289,134 @@ in the current directory, then the OS keychain. `vocalize auth login` sets
252
289
  up the keychain entry; `vocalize auth status` shows which of those sources
253
290
  is currently supplying the key.
254
291
 
292
+ ## Providers and fallback
293
+
294
+ vocalize tries providers in order — a **chain** — until one speaks. The
295
+ default is `elevenlabs, say`: ElevenLabs behaves exactly as before, and a
296
+ failure now degrades to the always-free `say` instead of erroring out.
297
+
298
+ | Provider | Credentials | Config table | Per-request cap | Free tier | What `check` needs |
299
+ |---|---|---|---|---|---|
300
+ | `elevenlabs` | keychain / env / `.env` / `--api-key` | `[providers.elevenlabs]` or the flat legacy keys | 9,500 chars | 10,000 chars/month | an API key |
301
+ | `openai` | keychain / env / `.env` | `[providers.openai]` | 4,000 chars | none — prepaid credit only, ~$15/million chars | an API key |
302
+ | `google` | keychain / env / `.env` | `[providers.google]` | 4,500 chars (also a 4,900-byte hard cap) | ~4M Standard or ~1M Neural2/WaveNet chars/month, then bills | an API key |
303
+ | `polly` | your normal AWS credentials (env, `~/.aws/credentials`, a profile, or a role) — vocalize stores none of it | `[providers.polly]` | 2,900 chars | Standard 5M/month ongoing; Neural 1M/month for 12 months, then $4–$16/million | `boto3` installed + AWS credentials discoverable |
304
+ | `say` | none | `[providers.say]` | none (one call, any length) | free, offline | macOS with the `say` binary |
305
+ | `kokoro` | none | `[providers.kokoro]` | 400 chars per streamed piece | free, offline, one-time ~354 MB download | `uv` + `vocalize local install` done |
306
+
307
+ **Fallback rules**, decided by typed errors, not string-matching:
308
+
309
+ - **Unavailable / auth / transient** errors (missing key, bad credentials, a
310
+ 5xx or rate limit) skip straight to the next provider in the chain.
311
+ - **Quota** errors do the same, but also mark that provider exhausted in the
312
+ local ledger for the rest of the calendar month — see
313
+ [Budgets and the usage ledger](#budgets-and-the-usage-ledger).
314
+ - **Content** errors — a bad voice name, text longer than the API actually
315
+ accepts — **stop the chain immediately**, loudly, naming the bad config
316
+ key. A silent misconfiguration would be the worse bug to ship.
317
+ - Anything else (a bug in vocalize itself) is never treated as "try the next
318
+ one" — it propagates as a real error.
319
+ - Once Kokoro's streaming playback has started, a later failure can't fall
320
+ through to another provider either — you can't un-hear the first half of
321
+ a read.
322
+
323
+ You'll see the handoff on stderr as it happens:
324
+
325
+ ```
326
+ openai: out of credit — trying google
327
+ google: local budget reached (1,004,233/1,000,000 chars this month) — trying polly
328
+ Spoke via say (fallback).
329
+ ```
330
+
331
+ If every provider fails, the error lists each one's reason, plus a hint to
332
+ add `say` to the chain if it's missing.
333
+
334
+ `--provider` is the opt-out: it forces exactly one provider with no
335
+ fallback at all, same as vocalize behaved before it had a chain.
336
+
337
+ **A chain is multi-vendor egress.** `elevenlabs, google, say` can, on a bad
338
+ day, send the same text to both ElevenLabs and Google before `say` finally
339
+ speaks it — every attempt that reaches a provider's `synthesize` call is a
340
+ real request to that vendor. `say` and `kokoro` are the exception: they
341
+ never send text off the machine.
342
+
343
+ Amazon Polly needs the optional extra:
344
+
345
+ ```bash
346
+ pip install "vocalize-cli[polly]"
347
+ ```
348
+
349
+ For the click-by-click setup of each provider — where to go, what to click,
350
+ the one command that stores the credential, the one command that proves it
351
+ works — see [docs/provider-credentials.md](docs/provider-credentials.md).
352
+
353
+ ## Budgets and the usage ledger
354
+
355
+ Cloud providers don't stop at their free tier — they bill past it. vocalize
356
+ can't see your vendor invoice, so it keeps its own local estimate instead
357
+ and stops using a provider once you say where the line is.
358
+
359
+ Set `monthly_chars` under that provider's `[providers.<name>]` table:
360
+
361
+ ```toml
362
+ [providers.google]
363
+ monthly_chars = 1000000
364
+ ```
365
+
366
+ Usage is tracked in `~/.cache/vocalize/usage.json`, one entry per provider
367
+ per calendar month, decided by your machine's local time. A provider that
368
+ comes back with a real quota error from the vendor is remembered as
369
+ exhausted for the rest of that month — no further requests to it, even if
370
+ you raise `monthly_chars` in between; only the new month clears it.
371
+
372
+ `vocalize usage` prints every provider's tally against its budget (or
373
+ "unlimited" with no `monthly_chars` set), flags any that are exhausted, then
374
+ ElevenLabs's own remote quota (skipped gracefully, not a failure, when no
375
+ key is configured), then local disk-cache stats.
376
+
377
+ The ledger is per-machine and an estimate, not a bill: a cached (repeat)
378
+ request costs nothing and isn't counted, and usage from a different machine
379
+ never shows up here. Google's own limits and billing are byte-based, not
380
+ character-based, so vocalize counts Google's usage in UTF-8 bytes too — the
381
+ same text can cost a different amount against Google's cap than everyone
382
+ else's.
383
+
384
+ ## Local providers
385
+
386
+ Two providers never leave the machine.
387
+
388
+ **`say`** is built in — macOS only, no setup, no network, no quota. Output
389
+ is `.m4a`, not `.mp3`. It uses whichever voices `say -v ?` lists on your
390
+ Mac; set one with `[providers.say] voice = "Samantha"`.
391
+
392
+ **Kokoro** is opt-in. `pip install vocalize-cli` brings none of it — no
393
+ model weights, no extra runtime — until you ask for it:
394
+
395
+ ```bash
396
+ vocalize local install
397
+ ```
398
+
399
+ This prints exactly what it's about to download before asking to confirm:
400
+ `kokoro-v1.0.onnx` (326 MB) and `voices-v1.0.bin` (28 MB) from a pinned
401
+ GitHub release, into `~/.cache/vocalize/models/kokoro/`, plus about 230 MB
402
+ more that `uv` fetches into its own cache (Python 3.12 and the `kokoro-onnx`
403
+ runtime). Every file is checked against a pinned sha256 before it's kept —
404
+ a mismatch deletes it and refuses rather than installing anything
405
+ unverified. The runtime runs under `uv run --python 3.12`, entirely apart
406
+ from vocalize's own environment, so installing Kokoro never touches or
407
+ upgrades the Python vocalize itself runs in. `vocalize local status`
408
+ reports what's present, missing, or unverified.
409
+
410
+ Use it for one read with `--provider kokoro`, or add it to your chain in
411
+ `config.toml`.
412
+
413
+ Long text streams: it's broken into ~400-character pieces, and playback
414
+ starts after the first one is ready — roughly 20–25 seconds of speech —
415
+ instead of waiting for the whole thing to render. Measured on this Mac
416
+ (M3): about 5x faster than real time, peaking around 870 MB of RAM while
417
+ rendering. `vocalize stop`, run from any terminal, halts a Kokoro read
418
+ mid-sentence the same as any other provider.
419
+
255
420
  ## macOS Quick Actions (highlight → speak)
256
421
 
257
422
  Two Services let you use vocalize from any app without a terminal:
@@ -305,6 +470,10 @@ CLI — so it works identically whether Claude Code is running in a bare
305
470
  terminal or inside an IDE's integrated terminal (VS Code, Cursor, etc.),
306
471
  since both use the same `~/.claude/settings.json` hook config.
307
472
 
473
+ Whatever speaks a response is whichever provider your chain resolves to —
474
+ `vocalize settings` now prints a `chain=` line alongside `overflow=` and
475
+ `max_chars=`, which is how a wrapper script like `/speak` can check it.
476
+
308
477
  **On-demand mode.** If you'd rather trigger speech yourself than have every
309
478
  response spoken, skip the install and run the script with `--latest`. It
310
479
  finds your most recent Claude Code response — in any session — and speaks
@@ -372,8 +541,9 @@ the main session the same way any other summary does: written to a file
372
541
  and passed to `speak-file`.
373
542
 
374
543
  If you wire this into a slash command of your own, treat it as a security
375
- surface, because **every character you speak is sent to ElevenLabs**. The
376
- guard principles that matter, in order:
544
+ surface, because **every character you speak is sent to whichever provider
545
+ in your chain ends up speaking it** — unless that provider is `say` or
546
+ `kokoro`. The guard principles that matter, in order:
377
547
 
378
548
  1. Resolve paths (`realpath`, expand `~`, casefold) and check an
379
549
  **allow-list** of speakable directories — symlinks and `../` defeat
@@ -428,9 +598,14 @@ vocalize/
428
598
  __main__.py # python -m vocalize entry point
429
599
  preprocess.py # markdown -> speakable text (pure function, fully unit tested)
430
600
  config.py # API key resolution + settings: flag > env > config.toml > default
431
- exceptions.py # VocalizeError / TTSRequestError
432
- tts.py # ElevenLabs API wrapper + disk cache (client is injected, so
601
+ exceptions.py # VocalizeError / TTSRequestError / typed ProviderError family
602
+ tts.py # ElevenLabs API wrapper (client is injected, so
433
603
  # it's mockable in tests without hitting the network)
604
+ cache.py # disk cache: cache_key/get/put, shared by every provider
605
+ chain.py # tries each provider in the chain in turn until one speaks
606
+ ledger.py # ~/.cache/vocalize/usage.json — local monthly budget tracking
607
+ providers/ # elevenlabs.py, openai.py, google.py, polly.py, say.py, kokoro.py
608
+ local/ # Kokoro's opt-in download/verify + the uv-run worker script
434
609
  audio.py # save to disk + play via the OS's native player
435
610
  # (afplay / mpg123 / ffplay / PowerShell, whichever exists)
436
611
  cli.py # click-based CLI wiring the above together
@@ -478,6 +653,29 @@ All tests run offline: the ElevenLabs client is dependency-injected into
478
653
  `ELEVENLABS_API_KEY` environment variable, or a `.env` file instead.
479
654
  - `vocalize voices` lists only the first page of results from the
480
655
  ElevenLabs API.
656
+ - **A chain is multi-vendor egress.** A fallback chain can send the same
657
+ text to more than one cloud vendor before one of them succeeds — see
658
+ [Providers and fallback](#providers-and-fallback).
659
+ - **The usage ledger is local and an estimate, not a bill.** It doesn't see
660
+ what your vendor actually charges, doesn't know about usage from another
661
+ machine, and a cached (repeat) request isn't counted at all.
662
+ - **Polly ignores `--speed`.** Its rate control needs SSML, which this
663
+ release doesn't wrap plain text into; `[providers.polly]` has no speed
664
+ knob yet.
665
+ - **Joined MP3 chunks are a byte concatenation, not a re-encode.** Most
666
+ players handle it fine, but the frame boundary between chunks can
667
+ occasionally produce an audible click.
668
+ - **`vocalize config`'s wizard only sets up ElevenLabs.** Configure the rest
669
+ of the chain with `vocalize chain` or by hand-editing `config.toml`.
670
+ - **Kokoro needs `uv`**, and its shipped pack carries 54 voices across nine
671
+ languages but is phonemized for `en-us` unless you set `language` under
672
+ `[providers.kokoro]` to match a non-English voice; it's opt-in for a
673
+ reason — see [Local providers](#local-providers).
674
+ - **A locked or first-use macOS keychain blocks silently.** Reading a stored
675
+ key can raise a macOS permission dialog ("python wants to use your
676
+ confidential information"); until you click Always Allow, every command
677
+ that needs that key waits. Click it once per Python binary, or supply the
678
+ key through its environment variable instead.
481
679
 
482
680
  ## License
483
681
 
@@ -123,6 +123,15 @@ vocalize speak-file report.md --speed 0.9
123
123
 
124
124
  # Skip the markdown flattening entirely
125
125
  vocalize speak "raw **markdown** stays raw" --raw
126
+
127
+ # Speak through one specific provider, no fallback
128
+ vocalize speak "Test" --provider google
129
+
130
+ # See (or set) the order providers are tried in
131
+ vocalize chain
132
+
133
+ # Set up the offline, opt-in local voice (downloads ~354 MB once)
134
+ vocalize local install
126
135
  ```
127
136
 
128
137
  Every synthesis result is cached on disk under `~/.cache/vocalize/`, keyed
@@ -179,19 +188,45 @@ writing anything.
179
188
  | Speed | `--speed` | `VOCALIZE_SPEED` | `speed` | unset — the API's own 1.0 |
180
189
  | Max characters | `--max-chars` | `VOCALIZE_MAX_CHARS` | `max_chars` | unset on the CLI; the hook supplies a 500 fallback |
181
190
  | Overflow mode | `--overflow` | `VOCALIZE_OVERFLOW` | `overflow` | `truncate` |
191
+ | Provider chain | `--provider` (forces one, no fallback) | `VOCALIZE_CHAIN` (comma-separated) | `chain` (array) | `["elevenlabs", "say"]` |
182
192
  | Hook binary | — | `VOCALIZE_BIN` | not read from the config file | `vocalize` as found on `PATH` |
183
193
 
194
+ `--voice`/`--model`/`--speed` and their `VOCALIZE_*` env vars only ever apply
195
+ to the **primary** provider — the first in the chain, or the one `--provider`
196
+ forces. Every other link reads only its own `[providers.<name>]` table and
197
+ its own built-in defaults; see
198
+ [Providers and fallback](#providers-and-fallback) below.
199
+
184
200
  The config file is TOML at `$XDG_CONFIG_HOME/vocalize/config.toml`, falling
185
201
  back to `~/.config/vocalize/config.toml`. Flat keys, no sections:
186
202
 
187
203
  ```toml
204
+ chain = ["elevenlabs", "google", "say"]
205
+
206
+ # Flat keys = ElevenLabs, unchanged since before there was a chain.
188
207
  voice = "21m00Tcm4TlvDq8ikWAM"
189
208
  model = "eleven_flash_v2_5"
190
209
  speed = 0.95
191
210
  max_chars = 1000
192
211
  overflow = "ask"
212
+
213
+ [providers.google]
214
+ voice = "en-US-Neural2-F"
215
+ language = "en-US"
216
+ monthly_chars = 1000000
217
+
218
+ [providers.say]
219
+ voice = "Samantha"
220
+
221
+ [providers.kokoro]
222
+ voice = "af_heart"
193
223
  ```
194
224
 
225
+ Every other provider gets its own `[providers.<name>]` table. The keys it
226
+ can hold: `voice`, `model`, `engine` (an alias for `model` — Polly's field is
227
+ called that), `speed`, `language`, `region`, `profile`, and `monthly_chars`.
228
+ A key outside that set warns on stderr rather than failing the run.
229
+
195
230
  `overflow` decides what happens when input is longer than the resolved
196
231
  `max_chars` cap: `truncate` (the default) cuts it at the cap, `ask` prompts
197
232
  on the controlling terminal first — and degrades to `truncate` with a note
@@ -218,6 +253,134 @@ in the current directory, then the OS keychain. `vocalize auth login` sets
218
253
  up the keychain entry; `vocalize auth status` shows which of those sources
219
254
  is currently supplying the key.
220
255
 
256
+ ## Providers and fallback
257
+
258
+ vocalize tries providers in order — a **chain** — until one speaks. The
259
+ default is `elevenlabs, say`: ElevenLabs behaves exactly as before, and a
260
+ failure now degrades to the always-free `say` instead of erroring out.
261
+
262
+ | Provider | Credentials | Config table | Per-request cap | Free tier | What `check` needs |
263
+ |---|---|---|---|---|---|
264
+ | `elevenlabs` | keychain / env / `.env` / `--api-key` | `[providers.elevenlabs]` or the flat legacy keys | 9,500 chars | 10,000 chars/month | an API key |
265
+ | `openai` | keychain / env / `.env` | `[providers.openai]` | 4,000 chars | none — prepaid credit only, ~$15/million chars | an API key |
266
+ | `google` | keychain / env / `.env` | `[providers.google]` | 4,500 chars (also a 4,900-byte hard cap) | ~4M Standard or ~1M Neural2/WaveNet chars/month, then bills | an API key |
267
+ | `polly` | your normal AWS credentials (env, `~/.aws/credentials`, a profile, or a role) — vocalize stores none of it | `[providers.polly]` | 2,900 chars | Standard 5M/month ongoing; Neural 1M/month for 12 months, then $4–$16/million | `boto3` installed + AWS credentials discoverable |
268
+ | `say` | none | `[providers.say]` | none (one call, any length) | free, offline | macOS with the `say` binary |
269
+ | `kokoro` | none | `[providers.kokoro]` | 400 chars per streamed piece | free, offline, one-time ~354 MB download | `uv` + `vocalize local install` done |
270
+
271
+ **Fallback rules**, decided by typed errors, not string-matching:
272
+
273
+ - **Unavailable / auth / transient** errors (missing key, bad credentials, a
274
+ 5xx or rate limit) skip straight to the next provider in the chain.
275
+ - **Quota** errors do the same, but also mark that provider exhausted in the
276
+ local ledger for the rest of the calendar month — see
277
+ [Budgets and the usage ledger](#budgets-and-the-usage-ledger).
278
+ - **Content** errors — a bad voice name, text longer than the API actually
279
+ accepts — **stop the chain immediately**, loudly, naming the bad config
280
+ key. A silent misconfiguration would be the worse bug to ship.
281
+ - Anything else (a bug in vocalize itself) is never treated as "try the next
282
+ one" — it propagates as a real error.
283
+ - Once Kokoro's streaming playback has started, a later failure can't fall
284
+ through to another provider either — you can't un-hear the first half of
285
+ a read.
286
+
287
+ You'll see the handoff on stderr as it happens:
288
+
289
+ ```
290
+ openai: out of credit — trying google
291
+ google: local budget reached (1,004,233/1,000,000 chars this month) — trying polly
292
+ Spoke via say (fallback).
293
+ ```
294
+
295
+ If every provider fails, the error lists each one's reason, plus a hint to
296
+ add `say` to the chain if it's missing.
297
+
298
+ `--provider` is the opt-out: it forces exactly one provider with no
299
+ fallback at all, same as vocalize behaved before it had a chain.
300
+
301
+ **A chain is multi-vendor egress.** `elevenlabs, google, say` can, on a bad
302
+ day, send the same text to both ElevenLabs and Google before `say` finally
303
+ speaks it — every attempt that reaches a provider's `synthesize` call is a
304
+ real request to that vendor. `say` and `kokoro` are the exception: they
305
+ never send text off the machine.
306
+
307
+ Amazon Polly needs the optional extra:
308
+
309
+ ```bash
310
+ pip install "vocalize-cli[polly]"
311
+ ```
312
+
313
+ For the click-by-click setup of each provider — where to go, what to click,
314
+ the one command that stores the credential, the one command that proves it
315
+ works — see [docs/provider-credentials.md](docs/provider-credentials.md).
316
+
317
+ ## Budgets and the usage ledger
318
+
319
+ Cloud providers don't stop at their free tier — they bill past it. vocalize
320
+ can't see your vendor invoice, so it keeps its own local estimate instead
321
+ and stops using a provider once you say where the line is.
322
+
323
+ Set `monthly_chars` under that provider's `[providers.<name>]` table:
324
+
325
+ ```toml
326
+ [providers.google]
327
+ monthly_chars = 1000000
328
+ ```
329
+
330
+ Usage is tracked in `~/.cache/vocalize/usage.json`, one entry per provider
331
+ per calendar month, decided by your machine's local time. A provider that
332
+ comes back with a real quota error from the vendor is remembered as
333
+ exhausted for the rest of that month — no further requests to it, even if
334
+ you raise `monthly_chars` in between; only the new month clears it.
335
+
336
+ `vocalize usage` prints every provider's tally against its budget (or
337
+ "unlimited" with no `monthly_chars` set), flags any that are exhausted, then
338
+ ElevenLabs's own remote quota (skipped gracefully, not a failure, when no
339
+ key is configured), then local disk-cache stats.
340
+
341
+ The ledger is per-machine and an estimate, not a bill: a cached (repeat)
342
+ request costs nothing and isn't counted, and usage from a different machine
343
+ never shows up here. Google's own limits and billing are byte-based, not
344
+ character-based, so vocalize counts Google's usage in UTF-8 bytes too — the
345
+ same text can cost a different amount against Google's cap than everyone
346
+ else's.
347
+
348
+ ## Local providers
349
+
350
+ Two providers never leave the machine.
351
+
352
+ **`say`** is built in — macOS only, no setup, no network, no quota. Output
353
+ is `.m4a`, not `.mp3`. It uses whichever voices `say -v ?` lists on your
354
+ Mac; set one with `[providers.say] voice = "Samantha"`.
355
+
356
+ **Kokoro** is opt-in. `pip install vocalize-cli` brings none of it — no
357
+ model weights, no extra runtime — until you ask for it:
358
+
359
+ ```bash
360
+ vocalize local install
361
+ ```
362
+
363
+ This prints exactly what it's about to download before asking to confirm:
364
+ `kokoro-v1.0.onnx` (326 MB) and `voices-v1.0.bin` (28 MB) from a pinned
365
+ GitHub release, into `~/.cache/vocalize/models/kokoro/`, plus about 230 MB
366
+ more that `uv` fetches into its own cache (Python 3.12 and the `kokoro-onnx`
367
+ runtime). Every file is checked against a pinned sha256 before it's kept —
368
+ a mismatch deletes it and refuses rather than installing anything
369
+ unverified. The runtime runs under `uv run --python 3.12`, entirely apart
370
+ from vocalize's own environment, so installing Kokoro never touches or
371
+ upgrades the Python vocalize itself runs in. `vocalize local status`
372
+ reports what's present, missing, or unverified.
373
+
374
+ Use it for one read with `--provider kokoro`, or add it to your chain in
375
+ `config.toml`.
376
+
377
+ Long text streams: it's broken into ~400-character pieces, and playback
378
+ starts after the first one is ready — roughly 20–25 seconds of speech —
379
+ instead of waiting for the whole thing to render. Measured on this Mac
380
+ (M3): about 5x faster than real time, peaking around 870 MB of RAM while
381
+ rendering. `vocalize stop`, run from any terminal, halts a Kokoro read
382
+ mid-sentence the same as any other provider.
383
+
221
384
  ## macOS Quick Actions (highlight → speak)
222
385
 
223
386
  Two Services let you use vocalize from any app without a terminal:
@@ -271,6 +434,10 @@ CLI — so it works identically whether Claude Code is running in a bare
271
434
  terminal or inside an IDE's integrated terminal (VS Code, Cursor, etc.),
272
435
  since both use the same `~/.claude/settings.json` hook config.
273
436
 
437
+ Whatever speaks a response is whichever provider your chain resolves to —
438
+ `vocalize settings` now prints a `chain=` line alongside `overflow=` and
439
+ `max_chars=`, which is how a wrapper script like `/speak` can check it.
440
+
274
441
  **On-demand mode.** If you'd rather trigger speech yourself than have every
275
442
  response spoken, skip the install and run the script with `--latest`. It
276
443
  finds your most recent Claude Code response — in any session — and speaks
@@ -338,8 +505,9 @@ the main session the same way any other summary does: written to a file
338
505
  and passed to `speak-file`.
339
506
 
340
507
  If you wire this into a slash command of your own, treat it as a security
341
- surface, because **every character you speak is sent to ElevenLabs**. The
342
- guard principles that matter, in order:
508
+ surface, because **every character you speak is sent to whichever provider
509
+ in your chain ends up speaking it** — unless that provider is `say` or
510
+ `kokoro`. The guard principles that matter, in order:
343
511
 
344
512
  1. Resolve paths (`realpath`, expand `~`, casefold) and check an
345
513
  **allow-list** of speakable directories — symlinks and `../` defeat
@@ -394,9 +562,14 @@ vocalize/
394
562
  __main__.py # python -m vocalize entry point
395
563
  preprocess.py # markdown -> speakable text (pure function, fully unit tested)
396
564
  config.py # API key resolution + settings: flag > env > config.toml > default
397
- exceptions.py # VocalizeError / TTSRequestError
398
- tts.py # ElevenLabs API wrapper + disk cache (client is injected, so
565
+ exceptions.py # VocalizeError / TTSRequestError / typed ProviderError family
566
+ tts.py # ElevenLabs API wrapper (client is injected, so
399
567
  # it's mockable in tests without hitting the network)
568
+ cache.py # disk cache: cache_key/get/put, shared by every provider
569
+ chain.py # tries each provider in the chain in turn until one speaks
570
+ ledger.py # ~/.cache/vocalize/usage.json — local monthly budget tracking
571
+ providers/ # elevenlabs.py, openai.py, google.py, polly.py, say.py, kokoro.py
572
+ local/ # Kokoro's opt-in download/verify + the uv-run worker script
400
573
  audio.py # save to disk + play via the OS's native player
401
574
  # (afplay / mpg123 / ffplay / PowerShell, whichever exists)
402
575
  cli.py # click-based CLI wiring the above together
@@ -444,6 +617,29 @@ All tests run offline: the ElevenLabs client is dependency-injected into
444
617
  `ELEVENLABS_API_KEY` environment variable, or a `.env` file instead.
445
618
  - `vocalize voices` lists only the first page of results from the
446
619
  ElevenLabs API.
620
+ - **A chain is multi-vendor egress.** A fallback chain can send the same
621
+ text to more than one cloud vendor before one of them succeeds — see
622
+ [Providers and fallback](#providers-and-fallback).
623
+ - **The usage ledger is local and an estimate, not a bill.** It doesn't see
624
+ what your vendor actually charges, doesn't know about usage from another
625
+ machine, and a cached (repeat) request isn't counted at all.
626
+ - **Polly ignores `--speed`.** Its rate control needs SSML, which this
627
+ release doesn't wrap plain text into; `[providers.polly]` has no speed
628
+ knob yet.
629
+ - **Joined MP3 chunks are a byte concatenation, not a re-encode.** Most
630
+ players handle it fine, but the frame boundary between chunks can
631
+ occasionally produce an audible click.
632
+ - **`vocalize config`'s wizard only sets up ElevenLabs.** Configure the rest
633
+ of the chain with `vocalize chain` or by hand-editing `config.toml`.
634
+ - **Kokoro needs `uv`**, and its shipped pack carries 54 voices across nine
635
+ languages but is phonemized for `en-us` unless you set `language` under
636
+ `[providers.kokoro]` to match a non-English voice; it's opt-in for a
637
+ reason — see [Local providers](#local-providers).
638
+ - **A locked or first-use macOS keychain blocks silently.** Reading a stored
639
+ key can raise a macOS permission dialog ("python wants to use your
640
+ confidential information"); until you click Always Allow, every command
641
+ that needs that key waits. Click it once per Python binary, or supply the
642
+ key through its environment variable instead.
447
643
 
448
644
  ## License
449
645