vocalize-cli 0.9.0__tar.gz → 0.9.1__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 (76) hide show
  1. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/CHANGELOG.md +20 -0
  2. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/PKG-INFO +1 -1
  3. vocalize_cli-0.9.1/docs/installation.md +399 -0
  4. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/conftest.py +11 -0
  5. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_audio.py +59 -0
  6. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/__init__.py +1 -1
  7. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/audio.py +70 -16
  8. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/.env.example +0 -0
  9. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/.github/workflows/ci.yml +0 -0
  10. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/.gitignore +0 -0
  11. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/LICENSE +0 -0
  12. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/README.md +0 -0
  13. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/docs/provider-credentials.md +0 -0
  14. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/hooks/claude_stop_hook.py +0 -0
  15. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/hooks/install_hook.py +0 -0
  16. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/hooks/install_quick_action.py +0 -0
  17. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/hooks/quick_actions/Speak Latest Plan.workflow/Contents/Info.plist +0 -0
  18. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/hooks/quick_actions/Speak Latest Plan.workflow/Contents/Resources/document.wflow +0 -0
  19. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/hooks/quick_actions/Speak with Vocalize.workflow/Contents/Info.plist +0 -0
  20. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/hooks/quick_actions/Speak with Vocalize.workflow/Contents/Resources/document.wflow +0 -0
  21. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/hooks/quick_actions/Stop Vocalize.workflow/Contents/Info.plist +0 -0
  22. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/hooks/quick_actions/Stop Vocalize.workflow/Contents/Resources/document.wflow +0 -0
  23. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/hooks/speak_options.py +0 -0
  24. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/hooks/speak_url_gate.py +0 -0
  25. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/pyproject.toml +0 -0
  26. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_auth.py +0 -0
  27. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_cache.py +0 -0
  28. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_chain.py +0 -0
  29. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_claude_stop_hook.py +0 -0
  30. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_cli.py +0 -0
  31. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_clipboard.py +0 -0
  32. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_config.py +0 -0
  33. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_elevenlabs_provider.py +0 -0
  34. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_exceptions.py +0 -0
  35. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_google_provider.py +0 -0
  36. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_http.py +0 -0
  37. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_install_hook.py +0 -0
  38. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_install_quick_action.py +0 -0
  39. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_kokoro_manifest.py +0 -0
  40. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_kokoro_provider.py +0 -0
  41. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_kokoro_worker.py +0 -0
  42. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_ledger.py +0 -0
  43. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_local_install.py +0 -0
  44. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_openai_provider.py +0 -0
  45. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_polly_provider.py +0 -0
  46. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_preprocess.py +0 -0
  47. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_providers_registry.py +0 -0
  48. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_say_provider.py +0 -0
  49. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_speak_options.py +0 -0
  50. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_speak_url_gate.py +0 -0
  51. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_tts.py +0 -0
  52. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_wizard.py +0 -0
  53. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/__main__.py +0 -0
  54. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/auth.py +0 -0
  55. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/cache.py +0 -0
  56. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/chain.py +0 -0
  57. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/cli.py +0 -0
  58. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/clipboard.py +0 -0
  59. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/config.py +0 -0
  60. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/exceptions.py +0 -0
  61. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/ledger.py +0 -0
  62. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/local/__init__.py +0 -0
  63. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/local/install.py +0 -0
  64. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/local/kokoro_manifest.py +0 -0
  65. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/local/kokoro_worker.py +0 -0
  66. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/preprocess.py +0 -0
  67. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/providers/__init__.py +0 -0
  68. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/providers/_http.py +0 -0
  69. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/providers/elevenlabs.py +0 -0
  70. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/providers/google.py +0 -0
  71. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/providers/kokoro.py +0 -0
  72. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/providers/openai.py +0 -0
  73. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/providers/polly.py +0 -0
  74. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/providers/say.py +0 -0
  75. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/tts.py +0 -0
  76. {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/wizard.py +0 -0
@@ -3,6 +3,26 @@
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.1 - 2026-09-01
7
+
8
+ ### Fixed
9
+
10
+ - Concurrent invocations no longer talk over each other. Playback is now
11
+ serialized machine-wide on an exclusive file lock
12
+ (`~/.cache/vocalize/play.lock`): a read that arrives while another is
13
+ playing queues and starts the moment the first one ends. Only the audible
14
+ part is serialized — synthesis still runs concurrently — and the lock
15
+ dies with its process, so a killed or timed-out waiter can never leave a
16
+ stale lock behind. Chunked reads hold the slot for the whole sequence, so
17
+ pieces of two reads never interleave. On platforms without `fcntl`
18
+ (Windows), the lock is skipped and the old overlapping behavior remains.
19
+
20
+ ### Changed
21
+
22
+ - `vocalize stop` semantics with a queue: stopping kills the *current*
23
+ player; the next queued read (if any) then begins. Run `stop` again to
24
+ silence that one too.
25
+
6
26
  ## 0.9.0 - 2026-09-01
7
27
 
8
28
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: vocalize-cli
3
- Version: 0.9.0
3
+ Version: 0.9.1
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
@@ -0,0 +1,399 @@
1
+ # Installing vocalize — CLI, providers, Claude Code, and macOS integration
2
+
3
+ This is the full installation guide, written after a complete from-scratch
4
+ install on a real machine (macOS, 2026-09-01). Every command in the main flow
5
+ was actually executed during that install; anything not exercised is labeled
6
+ **untested**. It supersedes the scattered install notes in the README by
7
+ treating the system as what it actually is: **five layers that install
8
+ separately**.
9
+
10
+ | Layer | What you get | Installed by | Scope |
11
+ |---|---|---|---|
12
+ | 0. CLI | `vocalize` binary | `pipx install vocalize-cli` | user-global |
13
+ | 1. Providers | Kokoro (local), ElevenLabs, `say` | `vocalize local install`, `vocalize auth login`, `vocalize chain` | user-global |
14
+ | 2. `/speak` | slash command / skill inside Claude Code | **not shipped yet** — create by hand (below) | user-global |
15
+ | 3. Stop hook | every Claude Code response auto-spoken | `hooks/install_hook.py` (repo) | user-global, new sessions |
16
+ | 4. Quick Actions | right-click → Services, hotkey-able | `hooks/install_quick_action.py` (repo) | user-global + 2 GUI steps |
17
+
18
+ The single most common confusion: **the PyPI package ships layer 0 only.**
19
+ Layers 3–4 live in the git repo, and layer 2 currently ships nowhere — the
20
+ README references `/speak`, but no artifact creates it. If you installed via
21
+ pipx and wonder where `/speak` is: that's why.
22
+
23
+ ---
24
+
25
+ ## Layer 0 — the CLI
26
+
27
+ ```bash
28
+ pipx install vocalize-cli
29
+ ```
30
+
31
+ - pipx installs are **user-global**: one install serves your terminal, Claude
32
+ Code desktop, IDE terminals — everything. Never "reinstall per app."
33
+ - Binary lands at `~/.local/bin/vocalize` (a symlink into
34
+ `~/.local/pipx/venvs/vocalize-cli/`). Verify from the environment that will
35
+ actually use it — shells inside Electron apps can differ from your terminal:
36
+
37
+ ```bash
38
+ command -v vocalize && vocalize --version
39
+ ```
40
+
41
+ ## Layer 1 — providers and the chain
42
+
43
+ The chain is ordered fallback: first provider that works, speaks.
44
+
45
+ ```bash
46
+ vocalize chain kokoro elevenlabs say
47
+ ```
48
+
49
+ writes `~/.config/vocalize/config.toml` — **one config, every consumer** (CLI,
50
+ `/speak`, Stop hook, Quick Actions all shell out to the same binary). Env vars
51
+ override config per-run; `vocalize settings` always shows what resolved and
52
+ from where.
53
+
54
+ - **Kokoro (local, recommended primary):** `vocalize local status` to check;
55
+ `vocalize local install` to fetch models (~340 MB, needs `uv`) — *untested
56
+ in this install; models were already present.* Expect a short cold-start on
57
+ the first utterance (~5 s total for a short line).
58
+ - **ElevenLabs:** `vocalize auth login` stores the key in the OS keychain
59
+ (*untested in this install*). Known gotcha: a key stored from your terminal
60
+ may not be visible to keyring lookups from an Electron app's shell — if
61
+ ElevenLabs mysteriously reports "no key" only inside Claude Code, use
62
+ `ELEVENLABS_API_KEY` in `~/.zshenv` instead.
63
+ - **`say`:** zero-config macOS last resort. Keep it in the chain; it's what
64
+ makes a keyless fresh machine still speak.
65
+
66
+ Smoke test (also proves stdin + preprocessing):
67
+
68
+ ```bash
69
+ echo "vocalize is alive" | vocalize speak-file -
70
+ ```
71
+
72
+ ## Layer 2 — the `/speak` command in Claude Code
73
+
74
+ Until the repo ships this (tracked as an open gap), create it by hand at
75
+ `~/.claude/skills/speak/SKILL.md`. A skill (not a `~/.claude/commands/` file)
76
+ is deliberate: skills demonstrably surface in Claude Code **desktop**, get
77
+ natural-language triggering ("read that aloud"), and — observed live — the
78
+ desktop harness **hot-loads a new skill into running sessions**, so it works
79
+ without a restart (the typed `/speak` autocomplete may still need a new
80
+ conversation).
81
+
82
+ Required semantics (mirror the README's promises):
83
+
84
+ - `/speak` (no args) → speak Claude's previous response
85
+ - `/speak clip` → `vocalize clip` (the clipboard — see Electron caveat below)
86
+ - `/speak stop` → `vocalize stop`
87
+ - `/speak <path>` → `vocalize speak-file <path>`
88
+ - `/speak <text>` → speak the text
89
+
90
+ Two implementation rules that matter:
91
+
92
+ 1. **Never interpolate spoken text into a shell command.** Write the raw
93
+ markdown to a unique temp file and run `vocalize speak-file <tmp>` — the
94
+ preprocessor wants raw markdown, and quoting bugs (backticks, `$`, quotes)
95
+ disappear entirely.
96
+ 2. **Run playback in the background.** A long read on a foreground shell call
97
+ gets killed at the tool timeout, mid-sentence.
98
+
99
+ ## Layer 3 — the Stop hook (auto-speak every response)
100
+
101
+ ```bash
102
+ git clone https://github.com/matthager12-collab/vocalize ~/code/vocalize
103
+ python3 ~/code/vocalize/hooks/install_hook.py
104
+ ```
105
+
106
+ > **The clone location becomes load-bearing.** The hook entry written into
107
+ > `~/.claude/settings.json` is
108
+ > `python3 <clone>/hooks/claude_stop_hook.py` — delete or move the clone and
109
+ > the hook dies silently. Treat `~/code/vocalize` as installed software, not
110
+ > a scratch checkout.
111
+
112
+ The installer is safe: idempotent, merges rather than overwrites, and writes
113
+ a timestamped backup (`settings.json.bak.<epoch>`) first.
114
+
115
+ Behavior and controls:
116
+
117
+ - Fires in **new sessions only** — hooks are snapshotted at session start.
118
+ The session you install from stays silent.
119
+ - Applies to terminal **and** desktop — they share `~/.claude/settings.json`
120
+ and the same transcript layout (verified: the hook script finds
121
+ desktop-session transcripts).
122
+ - Default cap ~500 chars per response (`VOCALIZE_MAX_CHARS` to change) — a
123
+ Stop hook fires every turn, so an uncapped hook burns quota/time fast.
124
+ - **Uninstall:** delete the vocalize entry from the `Stop` array in
125
+ `~/.claude/settings.json`.
126
+
127
+ Verify **before** any real Stop event, using the script's on-demand mode:
128
+
129
+ ```bash
130
+ VOCALIZE_MAX_CHARS=150 python3 ~/code/vocalize/hooks/claude_stop_hook.py --latest
131
+ ```
132
+
133
+ If that speaks your latest Claude response, the whole pipeline (transcript
134
+ discovery → preprocessing → provider chain) is proven.
135
+
136
+ > **Reference-install postscript:** the hook was installed, lived for about
137
+ > an hour, and was removed the same day. With several Claude Code sessions
138
+ > running, every one of them narrating every response was noise, not
139
+ > signal — on-demand speech (`/speak`, Quick Actions, hotkeys) proved to be
140
+ > the sustainable default. Treat this layer as opt-in for single-session
141
+ > workflows, not part of "full" setup. Removal really is just deleting the
142
+ > vocalize entry from the `Stop` array — and note that sessions opened
143
+ > while the hook was live keep speaking until they end (hooks snapshot at
144
+ > session start, in both directions).
145
+
146
+ ## Layer 4 — macOS Quick Actions (right-click + hotkeys)
147
+
148
+ **Pre-check first — this is the one silent-failure mode.** The installer
149
+ bakes `shutil.which()`-resolved absolute paths into the workflows
150
+ permanently. Run:
151
+
152
+ ```bash
153
+ command -v vocalize claude node python3
154
+ ```
155
+
156
+ Every path must be a **stable** location (`~/.local/bin`,
157
+ `/opt/homebrew/bin`). If anything resolves into a session-scoped or
158
+ version-pinned directory, run the installer with a sanitized PATH
159
+ (*fallback, untested in this install — stable paths made it unnecessary*):
160
+
161
+ ```bash
162
+ PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/bin:/bin" python3 ~/code/vocalize/hooks/install_quick_action.py
163
+ ```
164
+
165
+ Otherwise, plainly:
166
+
167
+ ```bash
168
+ python3 ~/code/vocalize/hooks/install_quick_action.py
169
+ ```
170
+
171
+ Installs three workflows into `~/Library/Services/` and refreshes the
172
+ registry: **Speak with Vocalize** (selected text), **Stop Vocalize**,
173
+ **Speak Latest Plan** (newest `~/.claude/plans/` file — made for the
174
+ plan-approval moment).
175
+
176
+ Known rot: even with the pre-check, `.resolve()` follows symlinks, so
177
+ `claude` bakes to a **version-pinned Caskroom path** (e.g.
178
+ `/opt/homebrew/Caskroom/claude-code/2.1.223/claude`). After
179
+ `brew upgrade claude-code`, the summary depths in the picker die silently
180
+ (speak-all/truncate keep working) until you re-run the installer. One
181
+ command, idempotent — re-run it after upgrades.
182
+
183
+ ### The two steps no script can do (GUI-only, by design)
184
+
185
+ 1. **Hotkeys:** System Settings → Keyboard → Keyboard Shortcuts →
186
+ **Services** → assign shortcuts to the three actions. Give **Stop
187
+ Vocalize** its own shortcut — it's your mute button from anywhere.
188
+ Scripting this via `defaults write pbs` is off-limits territory (system
189
+ settings) and fragile besides — don't automate it. You *can* verify it:
190
+ `defaults read pbs NSServicesStatus` shows a `key_equivalent` per
191
+ assigned service; zero entries means nobody assigned anything, whatever
192
+ they remember doing.
193
+ 2. **First-use permission prompts:** macOS asks once per app the first time
194
+ a Quick Action runs. Approve them as they appear.
195
+
196
+ **Electron caveat** (Claude Code desktop included): these apps don't expose
197
+ the Services menu for text selected in their own windows. There, copy the
198
+ text and use `/speak clip`.
199
+
200
+ ---
201
+
202
+ ## Post-install verification checklist
203
+
204
+ All verifiable from a shell (run each; every one was exercised on the
205
+ reference install):
206
+
207
+ ```bash
208
+ vocalize settings # chain + resolved config
209
+ vocalize local status # "Kokoro is ready."
210
+ echo test | vocalize speak-file - # audible, names the provider used
211
+ python3 -c "import json;print(json.load(open('$HOME/.claude/settings.json'))['hooks']['Stop'])"
212
+ ls ~/Library/Services/ # three .workflow bundles
213
+ ls ~/.claude/skills/speak/SKILL.md # /speak exists
214
+ VOCALIZE_MAX_CHARS=150 python3 ~/code/vocalize/hooks/claude_stop_hook.py --latest
215
+ defaults read pbs NSServicesStatus # hotkeys assigned? (GUI step)
216
+ ```
217
+
218
+ Not verifiable from a shell: first-use TCC prompts, and whether audio is
219
+ actually audible (headphones, volume). Test with ears once.
220
+
221
+ ---
222
+
223
+ ## The automation prompt
224
+
225
+ Paste this into Claude Code on any fresh Mac to run the whole install
226
+ agentically. It encodes every safeguard the reference install needed.
227
+
228
+ ```text
229
+ Set up vocalize (github.com/matthager12-collab/vocalize, PyPI: vocalize-cli)
230
+ with FULL Claude Code + macOS integration on this machine. Work in layers,
231
+ verify each before the next, and never skip a pre-check because a step
232
+ "looks obvious."
233
+
234
+ 0. ORIENT. Check what already exists before installing anything:
235
+ `pipx list`, `command -v vocalize`, `ls ~/.claude/skills/speak/`,
236
+ `python3 -c ...` to read hooks from ~/.claude/settings.json,
237
+ `ls ~/Library/Services/`, `vocalize local status`, `vocalize settings`.
238
+ pipx installs are user-global — never reinstall per app. Report what's
239
+ already done and only do the gaps.
240
+
241
+ 1. CLI: `pipx install vocalize-cli` if absent. Verify with
242
+ `command -v vocalize && vocalize --version`.
243
+
244
+ 2. PROVIDERS: If `vocalize local status` says Kokoro isn't ready, run
245
+ `vocalize local install` (~340 MB download — tell me before starting it).
246
+ Set local-first fallback: `vocalize chain kokoro elevenlabs say`.
247
+ Do NOT handle any ElevenLabs API key yourself — if I want ElevenLabs,
248
+ tell me to run `vocalize auth login` myself. Smoke test:
249
+ `echo "setup test" | vocalize speak-file -` and report which provider
250
+ actually spoke.
251
+
252
+ 3. /speak SKILL: If the repo ships a Claude Code skill for /speak, install
253
+ that. Otherwise create ~/.claude/skills/speak/SKILL.md with these
254
+ semantics: no args = speak my previous response; "clip" = vocalize clip;
255
+ "stop" = vocalize stop; an existing file path = vocalize speak-file on
256
+ it; any other text = speak it. Implementation rules: write spoken text
257
+ to a unique temp file and speak-file it (never interpolate text into a
258
+ shell line); run playback in the background; feed raw markdown (the
259
+ preprocessor handles structure).
260
+
261
+ 4. STOP HOOK (ask me first — it makes EVERY new Claude Code session speak
262
+ every response, terminal and desktop): clone the repo to ~/code/vocalize
263
+ and run `python3 ~/code/vocalize/hooks/install_hook.py`. Warn me that
264
+ the clone is now load-bearing (the hook points into it — moving or
265
+ deleting it breaks the hook). Verify by re-reading ~/.claude/settings.json
266
+ and confirming the Stop entry merged (and that the backup file the
267
+ installer prints actually exists). Then prove the pipeline with:
268
+ `VOCALIZE_MAX_CHARS=150 python3 ~/code/vocalize/hooks/claude_stop_hook.py --latest`
269
+ Note: the hook only fires in NEW sessions — say so in the report.
270
+
271
+ 5. QUICK ACTIONS: BEFORE running the installer, check
272
+ `command -v vocalize claude node python3` — every result must live in a
273
+ stable directory (~/.local/bin, /opt/homebrew/bin). If anything resolves
274
+ into a session-scoped or temp path, run the installer with
275
+ PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/bin:/bin" prefixed.
276
+ Then: `python3 ~/code/vocalize/hooks/install_quick_action.py`.
277
+ Confirm the three .workflow bundles landed in ~/Library/Services/.
278
+ Warn me: the baked claude path is version-pinned, so after
279
+ `brew upgrade claude-code` I must re-run this installer (summary modes
280
+ break silently otherwise).
281
+
282
+ 6. HAND OFF the two GUI-only steps to me explicitly — do NOT attempt them:
283
+ (a) assign hotkeys in System Settings → Keyboard → Keyboard Shortcuts →
284
+ Services (Stop Vocalize deserves its own), (b) approve macOS's first-use
285
+ permission prompts. Never write to the `pbs` defaults domain. You MAY
286
+ verify my claim later by READING `defaults read pbs NSServicesStatus`
287
+ and checking for key_equivalent entries.
288
+
289
+ 7. FINAL REPORT in three states: verified-done (with the command output
290
+ that proves each), done-but-unverifiable-from-shell (audio audibility,
291
+ TCC prompts), and not-done (anything I still owe, like hotkeys).
292
+ Remind me: /speak may need a new conversation to appear in autocomplete,
293
+ and the Stop hook speaks starting with my next new session.
294
+ ```
295
+
296
+ ---
297
+
298
+ ## What still can't be automated
299
+
300
+ Being honest about the ceiling — an installer that claims 100% is lying:
301
+
302
+ - **Hotkey assignment** — GUI-only. Scriptable in theory via `defaults write
303
+ pbs`, but that's system-settings territory: fragile across macOS versions
304
+ and not something an agent should touch. Readable, though — verification
305
+ is automatable even where the action isn't.
306
+ - **First-use permission prompts (TCC)** — macOS asks the human, once per
307
+ app, on purpose.
308
+ - **Audible confirmation** — a shell can prove the provider rendered audio;
309
+ only ears prove the speaker played it.
310
+
311
+ ## Lessons learned from the reference install (2026-09-01)
312
+
313
+ ### What went well
314
+
315
+ - **pipx's user-global model** collapsed the "install it on this instance
316
+ too" request to zero work — the terminal install was already serving the
317
+ desktop app. The real task was discovering *that*, not reinstalling.
318
+ - **The provider chain design paid off immediately**: the first smoke test
319
+ had no ElevenLabs key reachable and degraded gracefully to `say` instead
320
+ of failing; switching to local-first later was one command
321
+ (`vocalize chain kokoro elevenlabs say`) that instantly covered every
322
+ consumer, because the chain lives in one config file.
323
+ - **Skill over command file** for `/speak`: skills provably render in the
324
+ desktop app, and the harness hot-loaded the new skill into the *live*
325
+ session — zero-restart install, which no one expected.
326
+ - **The repo's installers held up**: idempotent, merge-don't-overwrite,
327
+ timestamped backup, non-interactive, accurate printed instructions. Small
328
+ scripts, no surprises.
329
+ - **Verify-before-the-real-event**: `claude_stop_hook.py --latest` proved
330
+ the entire hook pipeline (including that desktop-session transcripts are
331
+ found — previously only assumed for terminal/IDE) without waiting for a
332
+ Stop event that the installing session can never fire.
333
+ - **PATH-stability pre-check before baking** caught the one silent-failure
334
+ mode *before* it happened — the installing shell carried a dozen
335
+ session-scoped plugin dirs that would have died with the session.
336
+ - **Remote-first repo inspection** (`gh api .../git/trees`) answered "does
337
+ the repo ship /speak?" without cloning; the clone happened only when it
338
+ was about to become load-bearing.
339
+
340
+ ### What didn't go well / gotchas found
341
+
342
+ - **Docs promised what no artifact ships**: the README references `/speak`
343
+ (twice), but neither the PyPI package nor the repo creates it. Cost: a
344
+ full diagnostic dig on a machine owned by the tool's own author. Fix is
345
+ tracked: ship the skill + an installer step in the repo.
346
+ - **The PyPI/repo split is invisible at install time**: `pipx install`
347
+ succeeds, the CLI works, and nothing tells you layers 2–4 exist elsewhere.
348
+ - **`.resolve()` bakes rot**: the Quick Actions installer resolved
349
+ `/opt/homebrew/bin/claude` through its symlink into a version-pinned
350
+ Caskroom path (`.../claude-code/2.1.223/...`). Every `brew upgrade
351
+ claude-code` will silently break summary modes until re-run. Repo fix:
352
+ bake the stable symlink (or resolve at runtime), not the fully-resolved
353
+ target.
354
+ - **Keychain visibility differs by shell**: an ElevenLabs voice/model was
355
+ configured, but no key was reachable from the desktop app's shell — a
356
+ terminal-stored keychain item isn't guaranteed visible there. Test from
357
+ the environment that will actually run, not the one you installed from.
358
+ - **Session-start snapshotting confuses everyone once**: the installing
359
+ session never auto-speaks (hooks) and may not show `/speak` in
360
+ autocomplete (commands) — "it doesn't work" is often "open a new session."
361
+ - **"I think that's done" is checkable — check it**: the human believed the
362
+ hotkey step was complete; `defaults read pbs NSServicesStatus` showed
363
+ zero `key_equivalent` entries machine-wide. Where a GUI step has a
364
+ readable side effect, read it instead of trusting the recollection.
365
+ - **GitHub Releases drifted five versions behind main** (v0.4.0 vs 0.9.0,
366
+ five release commits in one day) — versioning lived in commit messages
367
+ and PyPI only. A `gh release create` step in CI would keep them honest.
368
+ - **Small shell traps**: zsh globbed the unquoted `?` in a
369
+ `gh api "...?recursive=1"` URL ("no matches found"); and macOS's default
370
+ bash 3.2 has no associative arrays. Quote API URLs; don't assume bash 4.
371
+ - **Auto-speak-everything didn't survive contact with real usage**: with
372
+ multiple Claude Code sessions open, every session narrating every
373
+ response produced overlapping voices within the hour, and the hook came
374
+ back out. Two lessons in one: (a) the sustainable default is on-demand
375
+ speech, hook opt-in for single-session workflows; (b) the overlap exposed
376
+ that vocalize had **no playback mutex** — `play()` docstrings openly
377
+ said concurrent reads talk over each other. Fixed in 0.9.1: playback now
378
+ queues machine-wide on an exclusive flock (`play.lock`), whole sequences
379
+ hold one slot so chunks never interleave, and a killed waiter can't
380
+ leave a stale lock. Verified by timing: two concurrent reads = solo time
381
+ + one extra playback, not parallel.
382
+ - **`uv run --extra dev pytest` shows 2 phantom failures** — the dotenv
383
+ tests need the optional `dotenv` extra too. Run with
384
+ `--extra dev --extra dotenv` for a truthful suite (806 pass).
385
+
386
+ ## Making the repo do this in one command (future work)
387
+
388
+ 1. **Ship `/speak`** as `claude/skills/speak/SKILL.md` + installer step
389
+ (open task).
390
+ 2. **One entry point** — `python3 hooks/install_all.py` or a
391
+ `vocalize integrate claude` subcommand: runs the PATH pre-check, installs
392
+ skill + hook + Quick Actions, runs the capped `--latest` smoke test, then
393
+ prints exactly the two GUI steps and the three-state report.
394
+ 3. **Fix path baking** — prefer stable symlinks over `.resolve()` targets.
395
+ 4. **`vocalize doctor`** — detect rot: baked paths that no longer exist,
396
+ hook entry pointing at a missing clone, chain providers that can't
397
+ initialize, releases drift.
398
+ 5. **CI release sync** — tag → `gh release create`, so GitHub Releases stop
399
+ lagging PyPI.
@@ -42,6 +42,17 @@ class _FakeKeyring:
42
42
  raise PasswordDeleteError("no such password")
43
43
 
44
44
 
45
+ @pytest.fixture(autouse=True)
46
+ def _no_real_playback_lock(monkeypatch, tmp_path):
47
+ """Keep every test off the real playback lock at ~/.cache/vocalize.
48
+
49
+ Autouse for the same reason as the ledger fixture: a real lock held by
50
+ an actual read on the developer's machine would otherwise make any
51
+ play() test block until the audio finished — a hang with no error.
52
+ """
53
+ monkeypatch.setattr("vocalize.audio._LOCK_FILE", tmp_path / "play.lock")
54
+
55
+
45
56
  @pytest.fixture(autouse=True)
46
57
  def _no_real_ledger(monkeypatch, tmp_path):
47
58
  """Keep every test off the real usage ledger at ~/.cache/vocalize.
@@ -12,6 +12,8 @@ import platform
12
12
  import shutil
13
13
  import signal
14
14
  import subprocess
15
+ import threading
16
+ from contextlib import contextmanager
15
17
  from pathlib import Path
16
18
 
17
19
  import pytest
@@ -373,3 +375,60 @@ def test_join_audio_refuses_to_stitch_two_m4a_pieces():
373
375
 
374
376
  def test_join_audio_passes_a_single_m4a_piece_straight_through():
375
377
  assert audio_module.join_audio([b"the only piece"], "m4a") == b"the only piece"
378
+
379
+
380
+ def test_play_waits_for_the_playback_slot(monkeypatch, tmp_path):
381
+ """A play() started while another read holds the slot must not launch
382
+ its player until the slot frees — the bug this guards against was two
383
+ Claude Code sessions speaking over each other.
384
+ """
385
+ monkeypatch.setattr(platform, "system", lambda: "Darwin")
386
+ monkeypatch.setattr(shutil, "which", lambda exe: "/usr/bin/afplay" if exe == "afplay" else None)
387
+ calls = _patch_player(monkeypatch, tmp_path)
388
+ done = threading.Event()
389
+
390
+ def queued_play():
391
+ play(tmp_path / "queued.mp3")
392
+ done.set()
393
+
394
+ with audio_module._playback_slot(): # someone else is mid-read
395
+ thread = threading.Thread(target=queued_play)
396
+ thread.start()
397
+ assert not done.wait(0.3) # still queued behind the held slot
398
+ assert calls == []
399
+ thread.join(timeout=5)
400
+ assert done.is_set()
401
+ assert len(calls) == 1
402
+
403
+
404
+ def test_play_sequence_holds_one_slot_for_the_whole_read(monkeypatch, tmp_path):
405
+ """The slot is acquired once for a whole sequence, not per piece —
406
+ otherwise a queued read could interleave between two chunks.
407
+ """
408
+ acquisitions = []
409
+
410
+ @contextmanager
411
+ def counting_slot():
412
+ acquisitions.append(1)
413
+ yield
414
+
415
+ monkeypatch.setattr(audio_module, "_playback_slot", counting_slot)
416
+ monkeypatch.setattr(platform, "system", lambda: "Darwin")
417
+ monkeypatch.setattr(shutil, "which", lambda exe: "/usr/bin/afplay" if exe == "afplay" else None)
418
+ calls = _patch_player(monkeypatch, tmp_path)
419
+
420
+ paths = [tmp_path / f"{i}.mp3" for i in range(3)]
421
+ assert audio_module.play_sequence(paths) is True
422
+
423
+ assert acquisitions == [1]
424
+ assert len(calls) == 3
425
+
426
+
427
+ def test_playback_slot_is_a_noop_without_fcntl(monkeypatch):
428
+ """Windows has no fcntl; the slot degrades to a pass-through rather
429
+ than crashing, and leaves no lock file behind.
430
+ """
431
+ monkeypatch.setattr(audio_module, "fcntl", None)
432
+ with audio_module._playback_slot():
433
+ pass
434
+ assert not audio_module._LOCK_FILE.exists()
@@ -7,4 +7,4 @@ formatting into something that actually sounds good spoken aloud
7
7
  which is close to useless).
8
8
  """
9
9
 
10
- __version__ = "0.9.0"
10
+ __version__ = "0.9.1"
@@ -4,6 +4,11 @@ Deliberately avoids pulling in a heavy playback dependency (pydub /
4
4
  simpleaudio / ffmpeg-python) — this just shells out to a system
5
5
  player that's virtually always already installed, and fails with a
6
6
  clear message if none is found.
7
+
8
+ Playback is serialized machine-wide: concurrent vocalize invocations (a
9
+ /speak issued while another read is going, two Claude Code sessions
10
+ finishing at once) queue on an exclusive file lock and come out one after
11
+ the other, never over each other.
7
12
  """
8
13
 
9
14
  from __future__ import annotations
@@ -15,8 +20,14 @@ import shutil
15
20
  import signal
16
21
  import subprocess
17
22
  import wave
23
+ from contextlib import contextmanager
18
24
  from pathlib import Path
19
25
 
26
+ try: # POSIX-only. Windows playback is already an untested known limitation.
27
+ import fcntl
28
+ except ImportError: # pragma: no cover — no Windows CI
29
+ fcntl = None
30
+
20
31
  from .exceptions import AudioPlaybackError, NoAudioPlayerError
21
32
 
22
33
  _CANDIDATES = {
@@ -33,6 +44,36 @@ _PID_FILE = Path.home() / ".cache" / "vocalize" / "play.pid"
33
44
  # by something else after the player exited uncleanly.
34
45
  _PLAYER_NAMES = {"afplay", "mpg123", "ffplay", "cvlc", "powershell"}
35
46
 
47
+ # The machine-wide playback queue. Held (flock LOCK_EX) for the duration of
48
+ # one audible read; everyone else blocks here until it frees.
49
+ _LOCK_FILE = Path.home() / ".cache" / "vocalize" / "play.lock"
50
+
51
+
52
+ @contextmanager
53
+ def _playback_slot():
54
+ """Hold the machine-wide right to be the one audible playback.
55
+
56
+ Concurrent invocations queue on an exclusive file lock instead of
57
+ talking over each other; waiters proceed in the order the OS grants
58
+ the lock. Only playback is serialized — a waiter's synthesis has
59
+ already happened by the time it gets here. The lock dies with its
60
+ process (flock semantics), so a killed or timed-out waiter can never
61
+ leave a stale lock behind.
62
+
63
+ Platforms without fcntl (Windows) skip the lock: playback there is
64
+ already documented as untested, and overlapping beats crashing.
65
+ """
66
+ if fcntl is None:
67
+ yield
68
+ return
69
+ _LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
70
+ fd = os.open(_LOCK_FILE, os.O_CREAT | os.O_RDWR, 0o644)
71
+ try:
72
+ fcntl.flock(fd, fcntl.LOCK_EX) # blocks until the current read ends
73
+ yield
74
+ finally:
75
+ os.close(fd) # closing the descriptor releases the lock
76
+
36
77
 
37
78
  def _proc_start_time(pid: int) -> str:
38
79
  """The process's launch timestamp per ps, or "" if it can't be read.
@@ -52,9 +93,10 @@ def _proc_start_time(pid: int) -> str:
52
93
  def _clear_own_record(pid: int) -> None:
53
94
  """Remove the PID file only if it still holds this play's own record.
54
95
 
55
- When plays overlap, the later one overwrote the file; the earlier
56
- play's exit must not destroy the record of a player that is still
57
- running.
96
+ Defensive: with the playback slot serializing reads, records should
97
+ never overlap — but on no-fcntl platforms (and against any future
98
+ bypass) a later play may have overwritten the file, and the earlier
99
+ play's exit must not destroy the record of a player still running.
58
100
  """
59
101
  try:
60
102
  if _PID_FILE.read_text().splitlines()[:1] == [str(pid)]:
@@ -101,8 +143,9 @@ def stop_playback() -> bool:
101
143
  """Kill the player a previous play() recorded. True if one was stopped.
102
144
 
103
145
  Works across processes: the /speak hook's playback can be stopped from
104
- any terminal. When two plays overlap, the last one to start wins the
105
- PID file — stopping ends that one.
146
+ any terminal. Playback is serialized machine-wide, so there is at most
147
+ one player at a time; stopping it lets the next queued read (if any)
148
+ begin — run stop again to silence that one too.
106
149
 
107
150
  Kills only a process that still matches the full recorded identity:
108
151
  same PID, same ps launch timestamp, and a known player name. A stale
@@ -143,9 +186,17 @@ def save(audio: bytes, path: Path) -> Path:
143
186
  def play(path: Path) -> int:
144
187
  """Play one file, blocking until it ends. Returns the player's exit code.
145
188
 
146
- `-signal.SIGTERM` means `vocalize stop` ended it; 0 means it played to
147
- the end.
189
+ Queues behind any playback already running anywhere on the machine —
190
+ two concurrent plays come out one after the other, never over each
191
+ other. `-signal.SIGTERM` means `vocalize stop` ended it; 0 means it
192
+ played to the end.
148
193
  """
194
+ with _playback_slot():
195
+ return _play_now(path)
196
+
197
+
198
+ def _play_now(path: Path) -> int:
199
+ """The actual player launch — the caller must hold the playback slot."""
149
200
  system = platform.system()
150
201
 
151
202
  if system == "Windows":
@@ -187,16 +238,19 @@ def play(path: Path) -> int:
187
238
  def play_sequence(paths, *, stop_check=None) -> bool:
188
239
  """Play each path in order. False as soon as the user stopped one.
189
240
 
190
- Each piece goes through play(), so the PID file always names the
191
- player that is running right now and `vocalize stop` keeps working
192
- unchanged. A piece killed by SIGTERM is the user stopping the read:
193
- the rest of the sequence is abandoned rather than played on.
241
+ The whole sequence holds a single playback slot, so a read queued
242
+ behind it starts only after the last piece — chunks of two reads never
243
+ interleave. Each piece still goes through _play_now(), so the PID file
244
+ always names the player that is running right now and `vocalize stop`
245
+ keeps working unchanged. A piece killed by SIGTERM is the user stopping
246
+ the read: the rest of the sequence is abandoned rather than played on.
194
247
  """
195
- for path in paths:
196
- if stop_check is not None and stop_check():
197
- return False
198
- if play(path) == -signal.SIGTERM:
199
- return False
248
+ with _playback_slot():
249
+ for path in paths:
250
+ if stop_check is not None and stop_check():
251
+ return False
252
+ if _play_now(path) == -signal.SIGTERM:
253
+ return False
200
254
  return True
201
255
 
202
256
 
File without changes
File without changes
File without changes
File without changes