vocalize-cli 0.3.0__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/CHANGELOG.md +6 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/PKG-INFO +34 -1
- {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/README.md +33 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/tests/test_cli.py +45 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/tests/test_tts.py +46 -1
- {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/vocalize/__init__.py +1 -1
- {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/vocalize/cli.py +44 -1
- {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/vocalize/tts.py +19 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/.env.example +0 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/.github/workflows/ci.yml +0 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/.gitignore +0 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/LICENSE +0 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/hooks/claude_stop_hook.py +0 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/hooks/install_hook.py +0 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/pyproject.toml +0 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/tests/conftest.py +0 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/tests/test_audio.py +0 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/tests/test_auth.py +0 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/tests/test_claude_stop_hook.py +0 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/tests/test_config.py +0 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/tests/test_install_hook.py +0 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/tests/test_preprocess.py +0 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/tests/test_wizard.py +0 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/vocalize/__main__.py +0 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/vocalize/audio.py +0 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/vocalize/auth.py +0 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/vocalize/config.py +0 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/vocalize/exceptions.py +0 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/vocalize/preprocess.py +0 -0
- {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/vocalize/wizard.py +0 -0
|
@@ -3,6 +3,12 @@
|
|
|
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.4.0
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- `vocalize usage` — ElevenLabs quota and local cache at a glance.
|
|
11
|
+
|
|
6
12
|
## 0.3.0
|
|
7
13
|
|
|
8
14
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: vocalize-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: A CLI that turns text, markdown, or piped stdin into speech via the ElevenLabs API, with markdown-table-aware preprocessing.
|
|
5
5
|
Project-URL: Homepage, https://github.com/matthager12-collab/vocalize
|
|
6
6
|
Project-URL: Repository, https://github.com/matthager12-collab/vocalize
|
|
@@ -139,6 +139,9 @@ cat notes.md | vocalize speak-file -
|
|
|
139
139
|
# List available voices and grab an ID
|
|
140
140
|
vocalize voices
|
|
141
141
|
|
|
142
|
+
# Check your quota and cache
|
|
143
|
+
vocalize usage
|
|
144
|
+
|
|
142
145
|
# Use a specific voice/model, save without playing
|
|
143
146
|
vocalize speak-file report.md --voice <voice-id> --model eleven_flash_v2_5 \
|
|
144
147
|
--output out.mp3 --no-play
|
|
@@ -258,6 +261,36 @@ run in Claude Code's own environment, not your interactive shell — if
|
|
|
258
261
|
`VOCALIZE_BIN` to the full path (e.g. `/path/to/.venv/bin/vocalize`) to
|
|
259
262
|
point the hook at it directly.
|
|
260
263
|
|
|
264
|
+
### Speaking files, artifacts, and more
|
|
265
|
+
|
|
266
|
+
Two primitives cover almost everything: `vocalize speak-file <path>` speaks
|
|
267
|
+
any local file (markdown flattened first), and the hook's `--latest` mode
|
|
268
|
+
speaks the most recent Claude Code response. Anything Claude itself has to
|
|
269
|
+
fetch — a claude.ai artifact, for instance — has to be fetched *by Claude*
|
|
270
|
+
(the CLI has no session), summarized, and piped in:
|
|
271
|
+
|
|
272
|
+
```bash
|
|
273
|
+
printf '%s' "the summary text" | vocalize speak-file -
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
If you wire this into a slash command of your own, treat it as a security
|
|
277
|
+
surface, because **every character you speak is sent to ElevenLabs**. The
|
|
278
|
+
guard principles that matter, in order:
|
|
279
|
+
|
|
280
|
+
1. Resolve paths (`realpath`, expand `~`, casefold) and check an
|
|
281
|
+
**allow-list** of speakable directories — symlinks and `../` defeat
|
|
282
|
+
string matching on the raw argument.
|
|
283
|
+
2. Hard-refuse secret-shaped files (`.env*`, keys, credentials) and your
|
|
284
|
+
sensitive directories; confirm before speaking anything else unusual.
|
|
285
|
+
3. Summarize long or fetched content in an **isolated subagent** that
|
|
286
|
+
returns only the summary — content you fetched can carry instructions
|
|
287
|
+
aimed at your session.
|
|
288
|
+
4. Pipe summaries over stdin (as above) — no temp files, nothing in argv.
|
|
289
|
+
5. Confirm before any read that will spend real quota; a free tier is
|
|
290
|
+
10,000 characters a month.
|
|
291
|
+
6. Remember the disk cache: everything spoken leaves an mp3 under
|
|
292
|
+
`~/.cache/vocalize/`.
|
|
293
|
+
|
|
261
294
|
## How it's built
|
|
262
295
|
|
|
263
296
|
Four decisions shaped the design:
|
|
@@ -105,6 +105,9 @@ cat notes.md | vocalize speak-file -
|
|
|
105
105
|
# List available voices and grab an ID
|
|
106
106
|
vocalize voices
|
|
107
107
|
|
|
108
|
+
# Check your quota and cache
|
|
109
|
+
vocalize usage
|
|
110
|
+
|
|
108
111
|
# Use a specific voice/model, save without playing
|
|
109
112
|
vocalize speak-file report.md --voice <voice-id> --model eleven_flash_v2_5 \
|
|
110
113
|
--output out.mp3 --no-play
|
|
@@ -224,6 +227,36 @@ run in Claude Code's own environment, not your interactive shell — if
|
|
|
224
227
|
`VOCALIZE_BIN` to the full path (e.g. `/path/to/.venv/bin/vocalize`) to
|
|
225
228
|
point the hook at it directly.
|
|
226
229
|
|
|
230
|
+
### Speaking files, artifacts, and more
|
|
231
|
+
|
|
232
|
+
Two primitives cover almost everything: `vocalize speak-file <path>` speaks
|
|
233
|
+
any local file (markdown flattened first), and the hook's `--latest` mode
|
|
234
|
+
speaks the most recent Claude Code response. Anything Claude itself has to
|
|
235
|
+
fetch — a claude.ai artifact, for instance — has to be fetched *by Claude*
|
|
236
|
+
(the CLI has no session), summarized, and piped in:
|
|
237
|
+
|
|
238
|
+
```bash
|
|
239
|
+
printf '%s' "the summary text" | vocalize speak-file -
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
If you wire this into a slash command of your own, treat it as a security
|
|
243
|
+
surface, because **every character you speak is sent to ElevenLabs**. The
|
|
244
|
+
guard principles that matter, in order:
|
|
245
|
+
|
|
246
|
+
1. Resolve paths (`realpath`, expand `~`, casefold) and check an
|
|
247
|
+
**allow-list** of speakable directories — symlinks and `../` defeat
|
|
248
|
+
string matching on the raw argument.
|
|
249
|
+
2. Hard-refuse secret-shaped files (`.env*`, keys, credentials) and your
|
|
250
|
+
sensitive directories; confirm before speaking anything else unusual.
|
|
251
|
+
3. Summarize long or fetched content in an **isolated subagent** that
|
|
252
|
+
returns only the summary — content you fetched can carry instructions
|
|
253
|
+
aimed at your session.
|
|
254
|
+
4. Pipe summaries over stdin (as above) — no temp files, nothing in argv.
|
|
255
|
+
5. Confirm before any read that will spend real quota; a free tier is
|
|
256
|
+
10,000 characters a month.
|
|
257
|
+
6. Remember the disk cache: everything spoken leaves an mp3 under
|
|
258
|
+
`~/.cache/vocalize/`.
|
|
259
|
+
|
|
227
260
|
## How it's built
|
|
228
261
|
|
|
229
262
|
Four decisions shaped the design:
|
|
@@ -255,6 +255,51 @@ def test_no_speed_flag_leaves_speed_unset(monkeypatch, tmp_path):
|
|
|
255
255
|
assert captured_settings[0].speed is None
|
|
256
256
|
|
|
257
257
|
|
|
258
|
+
def test_usage_command_prints_tier_used_limit_and_percent(monkeypatch, tmp_path):
|
|
259
|
+
monkeypatch.setattr(cli_module, "build_client", lambda key: object())
|
|
260
|
+
monkeypatch.setattr(
|
|
261
|
+
cli_module,
|
|
262
|
+
"get_usage",
|
|
263
|
+
lambda client: {"tier": "creator", "used": 12345, "limit": 100000, "resets_at": None},
|
|
264
|
+
)
|
|
265
|
+
# Empty tmp_path also covers the "cache empty" branch.
|
|
266
|
+
monkeypatch.setattr(cli_module, "DEFAULT_CACHE_DIR", tmp_path)
|
|
267
|
+
runner = CliRunner()
|
|
268
|
+
|
|
269
|
+
result = runner.invoke(main, ["usage", "--api-key", "fake-key"])
|
|
270
|
+
|
|
271
|
+
assert result.exit_code == 0, result.output
|
|
272
|
+
assert "creator" in result.output
|
|
273
|
+
assert "12,345" in result.output
|
|
274
|
+
assert "100,000" in result.output
|
|
275
|
+
assert "12.3%" in result.output
|
|
276
|
+
assert "cache empty" in result.output
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def test_usage_command_reports_local_cache_file_count(monkeypatch, tmp_path):
|
|
280
|
+
from datetime import datetime, timezone
|
|
281
|
+
|
|
282
|
+
reset_unix = 1735689600
|
|
283
|
+
expected_date = datetime.fromtimestamp(reset_unix, tz=timezone.utc).astimezone().strftime("%Y-%m-%d")
|
|
284
|
+
monkeypatch.setattr(cli_module, "build_client", lambda key: object())
|
|
285
|
+
monkeypatch.setattr(
|
|
286
|
+
cli_module,
|
|
287
|
+
"get_usage",
|
|
288
|
+
lambda client: {"tier": "free", "used": 0, "limit": 10000, "resets_at": reset_unix},
|
|
289
|
+
)
|
|
290
|
+
(tmp_path / "a.mp3").write_bytes(b"x" * 1000)
|
|
291
|
+
(tmp_path / "b.mp3").write_bytes(b"y" * 2000)
|
|
292
|
+
(tmp_path / "not-audio.txt").write_bytes(b"ignore me")
|
|
293
|
+
monkeypatch.setattr(cli_module, "DEFAULT_CACHE_DIR", tmp_path)
|
|
294
|
+
runner = CliRunner()
|
|
295
|
+
|
|
296
|
+
result = runner.invoke(main, ["usage", "--api-key", "fake-key"])
|
|
297
|
+
|
|
298
|
+
assert result.exit_code == 0, result.output
|
|
299
|
+
assert "2 files" in result.output
|
|
300
|
+
assert expected_date in result.output
|
|
301
|
+
|
|
302
|
+
|
|
258
303
|
def test_invalid_speed_gives_a_clean_error_not_a_traceback(monkeypatch, tmp_path, capsys):
|
|
259
304
|
_isolate_config(monkeypatch, tmp_path)
|
|
260
305
|
monkeypatch.setattr(
|
|
@@ -6,7 +6,7 @@ import pytest
|
|
|
6
6
|
|
|
7
7
|
from vocalize.config import Settings
|
|
8
8
|
from vocalize.exceptions import TTSRequestError
|
|
9
|
-
from vocalize.tts import _cache_key, list_voices, synthesize
|
|
9
|
+
from vocalize.tts import _cache_key, get_usage, list_voices, synthesize
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
class FakeTTSNamespace:
|
|
@@ -30,6 +30,26 @@ class FakeVoicesNamespace:
|
|
|
30
30
|
return SimpleNamespace(voices=self._voices)
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
class FakeUserNamespace:
|
|
34
|
+
def __init__(self, tier="free", used=1000, limit=10000, resets_at=1700000000, raise_error=None):
|
|
35
|
+
self._tier = tier
|
|
36
|
+
self._used = used
|
|
37
|
+
self._limit = limit
|
|
38
|
+
self._resets_at = resets_at
|
|
39
|
+
self._raise_error = raise_error
|
|
40
|
+
self.subscription = SimpleNamespace(get=self._get)
|
|
41
|
+
|
|
42
|
+
def _get(self):
|
|
43
|
+
if self._raise_error:
|
|
44
|
+
raise self._raise_error
|
|
45
|
+
return SimpleNamespace(
|
|
46
|
+
tier=self._tier,
|
|
47
|
+
character_count=self._used,
|
|
48
|
+
character_limit=self._limit,
|
|
49
|
+
next_character_count_reset_unix=self._resets_at,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
33
53
|
class FakeClient:
|
|
34
54
|
def __init__(self, chunks=(b"fake", b"-audio"), raise_error=None, voices=None):
|
|
35
55
|
self.text_to_speech = FakeTTSNamespace(chunks=chunks, raise_error=raise_error)
|
|
@@ -144,3 +164,28 @@ def test_cache_key_is_unchanged_when_speed_is_unset():
|
|
|
144
164
|
old_payload = f"{settings.voice_id}|{settings.model_id}|{settings.output_format}|hi"
|
|
145
165
|
|
|
146
166
|
assert _cache_key("hi", settings) == hashlib.sha256(old_payload.encode("utf-8")).hexdigest()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_get_usage_returns_tier_used_limit_and_reset():
|
|
170
|
+
client = SimpleNamespace(
|
|
171
|
+
user=FakeUserNamespace(tier="creator", used=4200, limit=100000, resets_at=1735689600)
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
result = get_usage(client)
|
|
175
|
+
|
|
176
|
+
assert result == {"tier": "creator", "used": 4200, "limit": 100000, "resets_at": 1735689600}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_get_usage_passes_through_a_missing_reset_time():
|
|
180
|
+
client = SimpleNamespace(user=FakeUserNamespace(resets_at=None))
|
|
181
|
+
|
|
182
|
+
result = get_usage(client)
|
|
183
|
+
|
|
184
|
+
assert result["resets_at"] is None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_get_usage_wraps_sdk_errors():
|
|
188
|
+
client = SimpleNamespace(user=FakeUserNamespace(raise_error=RuntimeError("unauthorized")))
|
|
189
|
+
|
|
190
|
+
with pytest.raises(TTSRequestError, match="unauthorized"):
|
|
191
|
+
get_usage(client)
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
vocalize speak-file report.md --play
|
|
5
5
|
cat notes.md | vocalize speak-file - --play
|
|
6
6
|
vocalize voices
|
|
7
|
+
vocalize usage
|
|
7
8
|
vocalize config
|
|
8
9
|
vocalize auth login
|
|
9
10
|
"""
|
|
@@ -11,6 +12,7 @@
|
|
|
11
12
|
from __future__ import annotations
|
|
12
13
|
|
|
13
14
|
import sys
|
|
15
|
+
from datetime import datetime, timezone
|
|
14
16
|
from pathlib import Path
|
|
15
17
|
|
|
16
18
|
import click
|
|
@@ -22,7 +24,7 @@ from .auth import delete_key, key_source, login, masked, probe_keychain, prompt_
|
|
|
22
24
|
from .config import DEFAULT_MODEL, DEFAULT_VOICE, resolve_api_key, resolve_settings
|
|
23
25
|
from .exceptions import TTSRequestError, VocalizeError
|
|
24
26
|
from .preprocess import flatten_markdown, truncate_for_budget
|
|
25
|
-
from .tts import DEFAULT_CACHE_DIR, build_client, list_voices, synthesize
|
|
27
|
+
from .tts import DEFAULT_CACHE_DIR, build_client, get_usage, list_voices, synthesize
|
|
26
28
|
from .wizard import run_wizard
|
|
27
29
|
|
|
28
30
|
|
|
@@ -107,6 +109,47 @@ def voices(api_key) -> None:
|
|
|
107
109
|
click.echo(f"{v['id']}\t{v['name']}")
|
|
108
110
|
|
|
109
111
|
|
|
112
|
+
def _human_readable_size(num_bytes: int) -> str:
|
|
113
|
+
size = float(num_bytes)
|
|
114
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
115
|
+
if size < 1024 or unit == "GB":
|
|
116
|
+
return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
|
|
117
|
+
size /= 1024
|
|
118
|
+
return f"{size:.1f} GB"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@main.command()
|
|
122
|
+
@click.option("--api-key", default=None)
|
|
123
|
+
def usage(api_key) -> None:
|
|
124
|
+
"""Show ElevenLabs quota usage and local cache stats."""
|
|
125
|
+
key = resolve_api_key(api_key)
|
|
126
|
+
client = build_client(key)
|
|
127
|
+
stats = get_usage(client)
|
|
128
|
+
|
|
129
|
+
used, limit = stats["used"], stats["limit"]
|
|
130
|
+
percent = (used / limit * 100) if limit else 0.0
|
|
131
|
+
remaining = max(limit - used, 0)
|
|
132
|
+
|
|
133
|
+
click.echo(f"Tier: {stats['tier']}")
|
|
134
|
+
click.echo(f"Used: {used:,} / {limit:,} characters ({percent:.1f}%)")
|
|
135
|
+
click.echo(f"Remaining: {remaining:,} characters")
|
|
136
|
+
if stats["resets_at"] is not None:
|
|
137
|
+
# UTC-aware, then converted to the system's local zone explicitly —
|
|
138
|
+
# a bare fromtimestamp() is implicitly local, which ruff (DTZ006)
|
|
139
|
+
# flags as ambiguous.
|
|
140
|
+
local_reset = datetime.fromtimestamp(stats["resets_at"], tz=timezone.utc).astimezone()
|
|
141
|
+
click.echo(f"Resets: {local_reset.strftime('%Y-%m-%d')}")
|
|
142
|
+
|
|
143
|
+
click.echo("")
|
|
144
|
+
# Local cache stats are pure filesystem lookups — no API call, no quota.
|
|
145
|
+
cache_files = list(DEFAULT_CACHE_DIR.glob("*.mp3")) if DEFAULT_CACHE_DIR.is_dir() else []
|
|
146
|
+
if not cache_files:
|
|
147
|
+
click.echo("Local cache: cache empty")
|
|
148
|
+
else:
|
|
149
|
+
total_bytes = sum(f.stat().st_size for f in cache_files)
|
|
150
|
+
click.echo(f"Local cache: {len(cache_files)} files, {_human_readable_size(total_bytes)}")
|
|
151
|
+
|
|
152
|
+
|
|
110
153
|
@main.command("config")
|
|
111
154
|
def config_cmd() -> None:
|
|
112
155
|
"""Interactive setup: pick voice, model, and speed."""
|
|
@@ -110,6 +110,25 @@ def list_voices(client) -> list[dict]:
|
|
|
110
110
|
]
|
|
111
111
|
|
|
112
112
|
|
|
113
|
+
def get_usage(client) -> dict:
|
|
114
|
+
"""Return ElevenLabs subscription usage: tier, used, limit, resets_at.
|
|
115
|
+
|
|
116
|
+
`resets_at` is the unix timestamp of the next character-count reset,
|
|
117
|
+
or None on tiers that don't report one. This call costs no quota.
|
|
118
|
+
"""
|
|
119
|
+
try:
|
|
120
|
+
subscription = client.user.subscription.get()
|
|
121
|
+
except Exception as exc:
|
|
122
|
+
raise TTSRequestError(f"Could not fetch usage: {exc}") from exc
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
"tier": subscription.tier,
|
|
126
|
+
"used": subscription.character_count,
|
|
127
|
+
"limit": subscription.character_limit,
|
|
128
|
+
"resets_at": subscription.next_character_count_reset_unix,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
113
132
|
def build_client(api_key: str):
|
|
114
133
|
"""Construct the real ElevenLabs SDK client. Imported lazily so the
|
|
115
134
|
rest of the package (and its tests) don't require the `elevenlabs`
|
|
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
|