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.
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/CHANGELOG.md +20 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/PKG-INFO +1 -1
- vocalize_cli-0.9.1/docs/installation.md +399 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/conftest.py +11 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_audio.py +59 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/__init__.py +1 -1
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/audio.py +70 -16
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/.env.example +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/.github/workflows/ci.yml +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/.gitignore +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/LICENSE +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/README.md +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/docs/provider-credentials.md +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/hooks/claude_stop_hook.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/hooks/install_hook.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/hooks/install_quick_action.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/hooks/quick_actions/Speak Latest Plan.workflow/Contents/Info.plist +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/hooks/quick_actions/Speak Latest Plan.workflow/Contents/Resources/document.wflow +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/hooks/quick_actions/Speak with Vocalize.workflow/Contents/Info.plist +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/hooks/quick_actions/Speak with Vocalize.workflow/Contents/Resources/document.wflow +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/hooks/quick_actions/Stop Vocalize.workflow/Contents/Info.plist +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/hooks/quick_actions/Stop Vocalize.workflow/Contents/Resources/document.wflow +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/hooks/speak_options.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/hooks/speak_url_gate.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/pyproject.toml +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_auth.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_cache.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_chain.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_claude_stop_hook.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_cli.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_clipboard.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_config.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_elevenlabs_provider.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_exceptions.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_google_provider.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_http.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_install_hook.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_install_quick_action.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_kokoro_manifest.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_kokoro_provider.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_kokoro_worker.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_ledger.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_local_install.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_openai_provider.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_polly_provider.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_preprocess.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_providers_registry.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_say_provider.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_speak_options.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_speak_url_gate.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_tts.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/tests/test_wizard.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/__main__.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/auth.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/cache.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/chain.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/cli.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/clipboard.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/config.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/exceptions.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/ledger.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/local/__init__.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/local/install.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/local/kokoro_manifest.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/local/kokoro_worker.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/preprocess.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/providers/__init__.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/providers/_http.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/providers/elevenlabs.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/providers/google.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/providers/kokoro.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/providers/openai.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/providers/polly.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/providers/say.py +0 -0
- {vocalize_cli-0.9.0 → vocalize_cli-0.9.1}/vocalize/tts.py +0 -0
- {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.
|
|
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()
|
|
@@ -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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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.
|
|
105
|
-
|
|
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
|
-
|
|
147
|
-
the
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|