evo-cli 0.12.2__tar.gz → 0.13.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 (78) hide show
  1. {evo_cli-0.12.2 → evo_cli-0.13.1}/PKG-INFO +47 -1
  2. {evo_cli-0.12.2 → evo_cli-0.13.1}/README.md +46 -0
  3. evo_cli-0.13.1/evo_cli/VERSION +1 -0
  4. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/cli.py +2 -0
  5. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/opencode.py +10 -5
  6. evo_cli-0.13.1/evo_cli/commands/tts.py +398 -0
  7. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/credentials/registry.py +11 -0
  8. evo_cli-0.13.1/evo_cli/tts/__init__.py +28 -0
  9. evo_cli-0.13.1/evo_cli/tts/chunking.py +61 -0
  10. evo_cli-0.13.1/evo_cli/tts/core.py +203 -0
  11. evo_cli-0.13.1/evo_cli/tts/creds.py +59 -0
  12. evo_cli-0.13.1/evo_cli/tts/errors.py +2 -0
  13. evo_cli-0.13.1/evo_cli/tts/http.py +54 -0
  14. evo_cli-0.13.1/evo_cli/tts/openai.py +58 -0
  15. evo_cli-0.13.1/evo_cli/tts/player.py +83 -0
  16. evo_cli-0.13.1/evo_cli/tts/vbee.py +133 -0
  17. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli.egg-info/PKG-INFO +47 -1
  18. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli.egg-info/SOURCES.txt +11 -0
  19. {evo_cli-0.12.2 → evo_cli-0.13.1}/tests/test_opencode.py +32 -0
  20. evo_cli-0.13.1/tests/test_tts.py +260 -0
  21. evo_cli-0.12.2/evo_cli/VERSION +0 -1
  22. {evo_cli-0.12.2 → evo_cli-0.13.1}/Containerfile +0 -0
  23. {evo_cli-0.12.2 → evo_cli-0.13.1}/HISTORY.md +0 -0
  24. {evo_cli-0.12.2 → evo_cli-0.13.1}/LICENSE +0 -0
  25. {evo_cli-0.12.2 → evo_cli-0.13.1}/MANIFEST.in +0 -0
  26. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/__init__.py +0 -0
  27. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/__main__.py +0 -0
  28. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/base.py +0 -0
  29. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/__init__.py +0 -0
  30. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/claude_code.py +0 -0
  31. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/cloudflare.py +0 -0
  32. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/cred.py +0 -0
  33. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/download.py +0 -0
  34. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/fix_claude.py +0 -0
  35. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/gdrive.py +0 -0
  36. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/gh.py +0 -0
  37. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/harness.py +0 -0
  38. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/hwid.py +0 -0
  39. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/hwid_reset.py +0 -0
  40. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/localproxy.py +0 -0
  41. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/mcp.py +0 -0
  42. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/miniconda.py +0 -0
  43. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/netcheck.py +0 -0
  44. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/plantuml.py +0 -0
  45. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/site2s.py +0 -0
  46. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/ssh.py +0 -0
  47. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/sysmon.py +0 -0
  48. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/update.py +0 -0
  49. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/wifi.py +0 -0
  50. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/console.py +0 -0
  51. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/credentials/__init__.py +0 -0
  52. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/credentials/doctor.py +0 -0
  53. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/credentials/google_oauth.py +0 -0
  54. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/credentials/migrate.py +0 -0
  55. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/credentials/oauth_flow.py +0 -0
  56. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/credentials/store.py +0 -0
  57. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/credentials/sync.py +0 -0
  58. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/mcp_registry.py +0 -0
  59. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli.egg-info/dependency_links.txt +0 -0
  60. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli.egg-info/entry_points.txt +0 -0
  61. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli.egg-info/requires.txt +0 -0
  62. {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli.egg-info/top_level.txt +0 -0
  63. {evo_cli-0.12.2 → evo_cli-0.13.1}/pyproject.toml +0 -0
  64. {evo_cli-0.12.2 → evo_cli-0.13.1}/setup.cfg +0 -0
  65. {evo_cli-0.12.2 → evo_cli-0.13.1}/tests/__init__.py +0 -0
  66. {evo_cli-0.12.2 → evo_cli-0.13.1}/tests/test_claude_code.py +0 -0
  67. {evo_cli-0.12.2 → evo_cli-0.13.1}/tests/test_cli.py +0 -0
  68. {evo_cli-0.12.2 → evo_cli-0.13.1}/tests/test_console.py +0 -0
  69. {evo_cli-0.12.2 → evo_cli-0.13.1}/tests/test_cred.py +0 -0
  70. {evo_cli-0.12.2 → evo_cli-0.13.1}/tests/test_download.py +0 -0
  71. {evo_cli-0.12.2 → evo_cli-0.13.1}/tests/test_fix_claude.py +0 -0
  72. {evo_cli-0.12.2 → evo_cli-0.13.1}/tests/test_gh.py +0 -0
  73. {evo_cli-0.12.2 → evo_cli-0.13.1}/tests/test_harness.py +0 -0
  74. {evo_cli-0.12.2 → evo_cli-0.13.1}/tests/test_mcp.py +0 -0
  75. {evo_cli-0.12.2 → evo_cli-0.13.1}/tests/test_plantuml.py +0 -0
  76. {evo_cli-0.12.2 → evo_cli-0.13.1}/tests/test_sysmon.py +0 -0
  77. {evo_cli-0.12.2 → evo_cli-0.13.1}/tests/test_update.py +0 -0
  78. {evo_cli-0.12.2 → evo_cli-0.13.1}/tests/test_wifi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.12.2
3
+ Version: 0.13.1
4
4
  Summary: Evolution CLI - a developer toolbox for setting up dev machines
5
5
  Author: maycuatroi
6
6
  Project-URL: Homepage, https://github.com/maycuatroi/evo-cli
@@ -177,3 +177,49 @@ evo harness pull --dry-run
177
177
  The command reads `harness.yaml` and its optional `harness.local.yaml` overlay. Repositories marked
178
178
  `present: false` are skipped. Repositories with uncommitted changes are not modified, and every pull
179
179
  uses `git pull --ff-only` so the command never creates merge commits.
180
+
181
+ #### Text to Speech
182
+
183
+ Synthesise speech through Vbee (Vietnamese) or OpenAI `gpt-4o-mini-tts`, and play it right away:
184
+
185
+ ```bash
186
+ evo tts speak "Xin chào, bản build đã xong"
187
+ evo tts speak -f notes.md -o notes.mp3
188
+ evo tts speak "hello there" -p openai -V nova --instructions "calm and encouraging"
189
+ git log -1 --format=%s | evo tts speak
190
+ ```
191
+
192
+ `speak` is the realtime path. Text longer than the provider's per-request limit (Vbee 300
193
+ characters, OpenAI 4000) is split on sentence boundaries and the audio is joined back into one file,
194
+ so the first words start playing while the rest is still being synthesised.
195
+
196
+ For bulk work use the batch path, which goes through Vbee's async API and polls
197
+ `/v1/tts/requests/{id}` until each audio link appears:
198
+
199
+ ```bash
200
+ evo tts batch chapters/ -o audio/ # one mp3 per .txt/.md file
201
+ evo tts batch a.txt b.txt -c 8 # 8 items in flight
202
+ evo tts batch --manifest jobs.jsonl # {"id":.., "text":.., "voice":..} per line
203
+ ```
204
+
205
+ OpenAI has no batch speech endpoint, so with `-p openai` the items are parallelised locally instead.
206
+
207
+ Voice codes come from `evo tts voices` (`-l en-US`, `--gender male`, `-p openai`, `--json`).
208
+
209
+ Credentials live in the omelet store, never in flags or source:
210
+
211
+ ```bash
212
+ evo cred add vbee.app_id --from-stdin # UUID from https://studio.vbee.vn/apps
213
+ evo cred add vbee.token --from-stdin # JWT from the same app page
214
+ evo cred add openai_api_key --from-stdin
215
+ ```
216
+
217
+ `VBEE_APP_ID`, `VBEE_TOKEN`, and `OPENAI_API_KEY` override the store when set. `--provider auto`
218
+ (the default) picks Vbee when its credentials exist, otherwise OpenAI.
219
+
220
+ Playback uses whichever of `ffplay`, `mpv`, `cvlc`, `afplay`, or `paplay`/`aplay` is on PATH, and
221
+ falls back to PowerShell's `MediaPlayer` on Windows. Without any of them the audio is still written
222
+ to disk and the command warns.
223
+
224
+ The same engine backs the `evo-tts` MCP server in
225
+ [agent-skills](https://github.com/maycuatroi1/agent-skills), which gives an agent a `speak` tool.
@@ -149,3 +149,49 @@ evo harness pull --dry-run
149
149
  The command reads `harness.yaml` and its optional `harness.local.yaml` overlay. Repositories marked
150
150
  `present: false` are skipped. Repositories with uncommitted changes are not modified, and every pull
151
151
  uses `git pull --ff-only` so the command never creates merge commits.
152
+
153
+ #### Text to Speech
154
+
155
+ Synthesise speech through Vbee (Vietnamese) or OpenAI `gpt-4o-mini-tts`, and play it right away:
156
+
157
+ ```bash
158
+ evo tts speak "Xin chào, bản build đã xong"
159
+ evo tts speak -f notes.md -o notes.mp3
160
+ evo tts speak "hello there" -p openai -V nova --instructions "calm and encouraging"
161
+ git log -1 --format=%s | evo tts speak
162
+ ```
163
+
164
+ `speak` is the realtime path. Text longer than the provider's per-request limit (Vbee 300
165
+ characters, OpenAI 4000) is split on sentence boundaries and the audio is joined back into one file,
166
+ so the first words start playing while the rest is still being synthesised.
167
+
168
+ For bulk work use the batch path, which goes through Vbee's async API and polls
169
+ `/v1/tts/requests/{id}` until each audio link appears:
170
+
171
+ ```bash
172
+ evo tts batch chapters/ -o audio/ # one mp3 per .txt/.md file
173
+ evo tts batch a.txt b.txt -c 8 # 8 items in flight
174
+ evo tts batch --manifest jobs.jsonl # {"id":.., "text":.., "voice":..} per line
175
+ ```
176
+
177
+ OpenAI has no batch speech endpoint, so with `-p openai` the items are parallelised locally instead.
178
+
179
+ Voice codes come from `evo tts voices` (`-l en-US`, `--gender male`, `-p openai`, `--json`).
180
+
181
+ Credentials live in the omelet store, never in flags or source:
182
+
183
+ ```bash
184
+ evo cred add vbee.app_id --from-stdin # UUID from https://studio.vbee.vn/apps
185
+ evo cred add vbee.token --from-stdin # JWT from the same app page
186
+ evo cred add openai_api_key --from-stdin
187
+ ```
188
+
189
+ `VBEE_APP_ID`, `VBEE_TOKEN`, and `OPENAI_API_KEY` override the store when set. `--provider auto`
190
+ (the default) picks Vbee when its credentials exist, otherwise OpenAI.
191
+
192
+ Playback uses whichever of `ffplay`, `mpv`, `cvlc`, `afplay`, or `paplay`/`aplay` is on PATH, and
193
+ falls back to PowerShell's `MediaPlayer` on Windows. Without any of them the audio is still written
194
+ to disk and the command warns.
195
+
196
+ The same engine backs the `evo-tts` MCP server in
197
+ [agent-skills](https://github.com/maycuatroi1/agent-skills), which gives an agent a `speak` tool.
@@ -0,0 +1 @@
1
+ 0.13.1
@@ -20,6 +20,7 @@ from evo_cli.commands.plantuml import plantuml
20
20
  from evo_cli.commands.site2s import site2s
21
21
  from evo_cli.commands.ssh import setupssh
22
22
  from evo_cli.commands.sysmon import sysmon
23
+ from evo_cli.commands.tts import tts_group
23
24
  from evo_cli.commands.update import update
24
25
  from evo_cli.commands.wifi import wifi
25
26
 
@@ -71,6 +72,7 @@ cli.add_command(mcp_group)
71
72
  cli.add_command(netcheck)
72
73
  cli.add_command(plantuml)
73
74
  cli.add_command(sysmon)
75
+ cli.add_command(tts_group)
74
76
  cli.add_command(update)
75
77
  cli.add_command(wifi)
76
78
 
@@ -42,14 +42,19 @@ def is_windows():
42
42
 
43
43
 
44
44
  def get_global_config_dir():
45
- home = Path.home()
46
- if is_windows():
47
- return home / "AppData" / "Roaming" / "opencode"
48
- return home / ".config" / "opencode"
45
+ # OpenCode reads ~/.config/opencode on every platform, Windows included - it does
46
+ # not follow the %APPDATA% convention. Writing to AppData produced a file that
47
+ # `opencode mcp list` never saw, so every server evo "added" was silently inert.
48
+ return Path.home() / ".config" / "opencode"
49
49
 
50
50
 
51
51
  def get_global_config_path():
52
- return get_global_config_dir() / "opencode.jsonc"
52
+ directory = get_global_config_dir()
53
+ for name in ("opencode.jsonc", "opencode.json"):
54
+ candidate = directory / name
55
+ if candidate.exists():
56
+ return candidate
57
+ return directory / "opencode.jsonc"
53
58
 
54
59
 
55
60
  def ensure_node_installed():
@@ -0,0 +1,398 @@
1
+ import json
2
+ import sys
3
+ import tempfile
4
+ from pathlib import Path
5
+
6
+ import rich_click as click
7
+ from rich.table import Table
8
+ from rich.text import Text
9
+
10
+ from evo_cli.console import console, error, info, step, success, warning
11
+ from evo_cli.tts import core, player
12
+ from evo_cli.tts.errors import TtsError
13
+
14
+ TEXT_SUFFIXES = (".txt", ".md")
15
+
16
+ SPEAK_EPILOG = Text.from_markup(
17
+ "[bold]Examples[/bold]\n\n"
18
+ " [cyan]evo tts speak 'Xin chào, bản build đã xong'[/cyan] speak it out loud now\n"
19
+ " [cyan]evo tts speak -f notes.md -o notes.mp3[/cyan] read a file, keep the audio\n"
20
+ " [cyan]evo tts speak 'hello' -p openai -V nova[/cyan] use gpt-4o-mini-tts instead\n"
21
+ " [cyan]git log -1 --format=%s | evo tts speak[/cyan] read stdin\n"
22
+ " [cyan]evo tts speak 'hi' --stdout > out.mp3[/cyan] pipe raw audio"
23
+ )
24
+
25
+ BATCH_EPILOG = Text.from_markup(
26
+ "[bold]Examples[/bold]\n\n"
27
+ " [cyan]evo tts batch chapters/ -o audio/[/cyan] one mp3 per .txt/.md file\n"
28
+ " [cyan]evo tts batch a.txt b.txt -c 8[/cyan] 8 requests in flight\n"
29
+ " [cyan]evo tts batch -t 'câu một' -t 'câu hai'[/cyan] inline strings\n"
30
+ ' [cyan]evo tts batch --manifest jobs.jsonl[/cyan] {"id":..,"text":..,"voice":..} per line\n'
31
+ " [cyan]evo tts batch big.txt --mode realtime[/cyan] skip the async API"
32
+ )
33
+
34
+ VOICES_EPILOG = Text.from_markup(
35
+ "[bold]Examples[/bold]\n\n"
36
+ " [cyan]evo tts voices[/cyan] Vbee Vietnamese voices\n"
37
+ " [cyan]evo tts voices -l en-US --gender male[/cyan] filter by language and gender\n"
38
+ " [cyan]evo tts voices -p openai[/cyan] gpt-4o-mini-tts voices\n"
39
+ " [cyan]evo tts voices --json[/cyan] machine-readable"
40
+ )
41
+
42
+
43
+ def read_input_text(text, text_file):
44
+ if text and text_file:
45
+ raise click.UsageError("pass either TEXT or --file, not both.")
46
+ if text_file:
47
+ return Path(text_file).read_text(encoding="utf-8")
48
+ if text:
49
+ return text
50
+ if not sys.stdin.isatty():
51
+ return sys.stdin.read()
52
+ raise click.UsageError("no text given: pass TEXT, --file, or pipe it on stdin.")
53
+
54
+
55
+ def collect_items(inputs, texts, manifest):
56
+ items = []
57
+ if manifest:
58
+ for line_number, line in enumerate(Path(manifest).read_text(encoding="utf-8").splitlines(), start=1):
59
+ line = line.strip()
60
+ if not line:
61
+ continue
62
+ try:
63
+ record = json.loads(line)
64
+ except ValueError as exc:
65
+ raise click.UsageError(f"{manifest}:{line_number} is not valid JSON: {exc}") from exc
66
+ if not record.get("text"):
67
+ raise click.UsageError(f"{manifest}:{line_number} has no 'text' field.")
68
+ items.append(
69
+ {
70
+ "name": str(record.get("id") or f"item-{len(items) + 1:03d}"),
71
+ "text": record["text"],
72
+ "voice": record.get("voice"),
73
+ "instructions": record.get("instructions"),
74
+ }
75
+ )
76
+ for raw in inputs:
77
+ path = Path(raw)
78
+ if path.is_dir():
79
+ files = sorted(p for p in path.rglob("*") if p.suffix.lower() in TEXT_SUFFIXES)
80
+ if not files:
81
+ warning(f"no {' or '.join(TEXT_SUFFIXES)} files under [accent]{path}[/accent]")
82
+ for file_path in files:
83
+ items.append({"name": file_path.stem, "text": file_path.read_text(encoding="utf-8")})
84
+ elif path.is_file():
85
+ items.append({"name": path.stem, "text": path.read_text(encoding="utf-8")})
86
+ else:
87
+ raise click.UsageError(f"input not found: {raw}")
88
+ for text in texts:
89
+ items.append({"name": f"item-{len(items) + 1:03d}", "text": text})
90
+ return [item for item in items if item["text"].strip()]
91
+
92
+
93
+ def unique_path(out_dir, name, output_format):
94
+ candidate = out_dir / f"{name}.{output_format}"
95
+ counter = 2
96
+ while candidate.exists():
97
+ candidate = out_dir / f"{name}-{counter}.{output_format}"
98
+ counter += 1
99
+ return candidate
100
+
101
+
102
+ @click.group("tts")
103
+ def tts_group():
104
+ """**Text to speech** via Vbee (Vietnamese) or OpenAI `gpt-4o-mini-tts`.
105
+
106
+ `speak` is the realtime path: it synthesises and plays immediately.
107
+ `batch` is the bulk path: it uses Vbee's async API and writes one file per input.
108
+ Credentials come from the omelet store (`evo cred add vbee.app_id`, `vbee.token`,
109
+ `openai_api_key`); nothing is read from hardcoded values.
110
+ """
111
+
112
+
113
+ @tts_group.command("speak", epilog=SPEAK_EPILOG)
114
+ @click.argument("text", required=False)
115
+ @click.option("-f", "--file", "text_file", help="Read the text from a file instead of the argument.")
116
+ @click.option(
117
+ "-p",
118
+ "--provider",
119
+ type=click.Choice(["auto", "vbee", "openai"]),
120
+ default="auto",
121
+ show_default=True,
122
+ help="auto picks Vbee when its credentials exist, else OpenAI.",
123
+ )
124
+ @click.option("-V", "--voice", help="Voice code (see `evo tts voices`).")
125
+ @click.option("-o", "--output", help="Keep the audio at this path instead of a temp file.")
126
+ @click.option(
127
+ "--format",
128
+ "output_format",
129
+ type=click.Choice(["mp3", "wav"]),
130
+ default="mp3",
131
+ show_default=True,
132
+ help="Audio container.",
133
+ )
134
+ @click.option("--speed", type=float, default=1.0, show_default=True, help="Speaking rate (Vbee: 0.25-1.9).")
135
+ @click.option("--bitrate", type=int, default=128, show_default=True, help="Vbee bitrate in kbps.")
136
+ @click.option("--instructions", help="OpenAI only: how the voice should deliver the text.")
137
+ @click.option("--model", help="OpenAI model override (default gpt-4o-mini-tts).")
138
+ @click.option("--no-play", is_flag=True, help="Synthesise only; do not play through the speakers.")
139
+ @click.option("--stdout", "to_stdout", is_flag=True, help="Write raw audio bytes to stdout (implies --no-play).")
140
+ @click.option("-q", "--quiet", is_flag=True, help="Suppress progress output.")
141
+ def speak(
142
+ text,
143
+ text_file,
144
+ provider,
145
+ voice,
146
+ output,
147
+ output_format,
148
+ speed,
149
+ bitrate,
150
+ instructions,
151
+ model,
152
+ no_play,
153
+ to_stdout,
154
+ quiet,
155
+ ):
156
+ """Synthesise **TEXT** and play it right away.
157
+
158
+ Long text is split on sentence boundaries so each request stays inside the
159
+ provider's realtime limit (Vbee 300 characters, OpenAI 4000), then the
160
+ resulting audio is joined back into one file.
161
+ """
162
+ body = read_input_text(text, text_file)
163
+ if not quiet and not to_stdout:
164
+ step("evo tts speak")
165
+ try:
166
+ resolved = core.resolve_provider(provider)
167
+ chunks = core.chunk_limit(resolved, "realtime")
168
+ if not quiet and not to_stdout:
169
+ info(
170
+ f"Provider [accent]{resolved}[/accent], voice "
171
+ f"[accent]{voice or core.default_voice(resolved)}[/accent], "
172
+ f"{len(body.strip())} characters (limit {chunks}/request)"
173
+ )
174
+
175
+ def on_progress(done, total):
176
+ if total > 1 and not quiet and not to_stdout:
177
+ info(f"Chunk {done}/{total}")
178
+
179
+ audio = core.synthesize(
180
+ body,
181
+ provider=resolved,
182
+ mode="realtime",
183
+ voice=voice,
184
+ output_format=output_format,
185
+ speed=speed,
186
+ bitrate=bitrate,
187
+ instructions=instructions,
188
+ model=model,
189
+ on_progress=on_progress,
190
+ )
191
+ except TtsError as exc:
192
+ error(str(exc))
193
+ sys.exit(1)
194
+
195
+ if to_stdout:
196
+ player.write_stdout(audio)
197
+ return
198
+
199
+ if output:
200
+ target = Path(output)
201
+ target.parent.mkdir(parents=True, exist_ok=True)
202
+ else:
203
+ handle = tempfile.NamedTemporaryFile(suffix=f".{output_format}", delete=False, prefix="evo-tts-")
204
+ handle.close()
205
+ target = Path(handle.name)
206
+ target.write_bytes(audio)
207
+ if not quiet:
208
+ success(f"Wrote [accent]{target}[/accent] ({len(audio)} bytes)")
209
+
210
+ if no_play:
211
+ return
212
+ if not player.play(target):
213
+ warning(f"no audio player found - {player.player_hint()}")
214
+
215
+
216
+ @tts_group.command("batch", epilog=BATCH_EPILOG)
217
+ @click.argument("inputs", nargs=-1)
218
+ @click.option("-t", "--text", "texts", multiple=True, help="Inline string to synthesise; repeatable.")
219
+ @click.option("--manifest", help="JSONL file, one {id, text, voice} object per line.")
220
+ @click.option("-o", "--out-dir", default="tts-out", show_default=True, help="Where the audio files land.")
221
+ @click.option(
222
+ "-p",
223
+ "--provider",
224
+ type=click.Choice(["auto", "vbee", "openai"]),
225
+ default="auto",
226
+ show_default=True,
227
+ help="auto picks Vbee when its credentials exist, else OpenAI.",
228
+ )
229
+ @click.option(
230
+ "--mode",
231
+ type=click.Choice(["batch", "realtime"]),
232
+ default="batch",
233
+ show_default=True,
234
+ help="batch uses Vbee's async API (100k characters per request); realtime chunks at 300.",
235
+ )
236
+ @click.option("-V", "--voice", help="Voice code applied to every item without its own.")
237
+ @click.option(
238
+ "--format",
239
+ "output_format",
240
+ type=click.Choice(["mp3", "wav"]),
241
+ default="mp3",
242
+ show_default=True,
243
+ help="Audio container.",
244
+ )
245
+ @click.option("--speed", type=float, default=1.0, show_default=True, help="Speaking rate.")
246
+ @click.option("--bitrate", type=int, default=128, show_default=True, help="Vbee bitrate in kbps.")
247
+ @click.option("--instructions", help="OpenAI only: how the voice should deliver the text.")
248
+ @click.option("--model", help="OpenAI model override (default gpt-4o-mini-tts).")
249
+ @click.option("-c", "--concurrency", type=int, default=4, show_default=True, help="Items in flight at once.")
250
+ @click.option("--webhook", help="Vbee webhookUrl; the API requires one even though evo polls for the result.")
251
+ @click.option("--timeout", type=int, default=900, show_default=True, help="Seconds to wait per async request.")
252
+ @click.option("--poll-interval", type=float, default=3.0, show_default=True, help="Seconds between polls.")
253
+ def batch(
254
+ inputs,
255
+ texts,
256
+ manifest,
257
+ out_dir,
258
+ provider,
259
+ mode,
260
+ voice,
261
+ output_format,
262
+ speed,
263
+ bitrate,
264
+ instructions,
265
+ model,
266
+ concurrency,
267
+ webhook,
268
+ timeout,
269
+ poll_interval,
270
+ ):
271
+ """Synthesise many inputs at once, one audio file per item.
272
+
273
+ `INPUTS` are text files or directories (`.txt`, `.md` are picked up
274
+ recursively). With `--mode batch` on Vbee each item goes through the async
275
+ API and evo polls `/v1/tts/requests/{id}` until the audio link appears.
276
+ OpenAI has no batch speech endpoint, so there the work is parallelised
277
+ locally with `--concurrency`.
278
+ """
279
+ step("evo tts batch")
280
+ items = collect_items(inputs, texts, manifest)
281
+ if not items:
282
+ raise click.UsageError("nothing to do: pass INPUTS, --text, or --manifest.")
283
+
284
+ try:
285
+ resolved = core.resolve_provider(provider)
286
+ except TtsError as exc:
287
+ error(str(exc))
288
+ sys.exit(1)
289
+
290
+ effective_mode = mode
291
+ if resolved == "openai" and mode == "batch":
292
+ info("OpenAI has no batch speech endpoint - running the items concurrently instead.")
293
+ effective_mode = "realtime"
294
+
295
+ target_dir = Path(out_dir)
296
+ target_dir.mkdir(parents=True, exist_ok=True)
297
+ info(
298
+ f"Provider [accent]{resolved}[/accent], mode [accent]{effective_mode}[/accent], "
299
+ f"{len(items)} items, concurrency {concurrency} -> [accent]{target_dir}[/accent]"
300
+ )
301
+
302
+ written = []
303
+ failed = []
304
+
305
+ def on_item(entry):
306
+ if entry.get("error"):
307
+ failed.append(entry)
308
+ error(f"{entry['name']}: {entry['error']}")
309
+ return
310
+ path = unique_path(target_dir, entry["name"], output_format)
311
+ path.write_bytes(entry["audio"])
312
+ written.append(path)
313
+ success(f"[accent]{path}[/accent] ({len(entry['audio'])} bytes)")
314
+
315
+ core.synthesize_many(
316
+ items,
317
+ concurrency=concurrency,
318
+ on_item=on_item,
319
+ provider=resolved,
320
+ mode=effective_mode,
321
+ voice=voice,
322
+ output_format=output_format,
323
+ speed=speed,
324
+ bitrate=bitrate,
325
+ instructions=instructions,
326
+ model=model,
327
+ webhook_url=webhook,
328
+ timeout=timeout,
329
+ poll_interval=poll_interval,
330
+ )
331
+
332
+ console.print()
333
+ if failed:
334
+ warning(f"{len(written)}/{len(items)} succeeded, {len(failed)} failed")
335
+ sys.exit(1)
336
+ success(f"{len(written)} files in {target_dir}")
337
+
338
+
339
+ @tts_group.command("voices", epilog=VOICES_EPILOG)
340
+ @click.option(
341
+ "-p",
342
+ "--provider",
343
+ type=click.Choice(["auto", "vbee", "openai"]),
344
+ default="auto",
345
+ show_default=True,
346
+ help="Which catalog to list.",
347
+ )
348
+ @click.option("-l", "--language", default="vi-VN", show_default=True, help="Vbee language code; empty for all.")
349
+ @click.option("--gender", type=click.Choice(["male", "female"]), help="Filter by gender (Vbee).")
350
+ @click.option(
351
+ "--ownership",
352
+ type=click.Choice(["VBEE", "COMMUNITY", "PERSONAL"]),
353
+ default="VBEE",
354
+ show_default=True,
355
+ help="Vbee voice ownership.",
356
+ )
357
+ @click.option("--limit", type=int, default=50, show_default=True, help="How many voices to fetch.")
358
+ @click.option("--search", help="Only show voices whose name or code contains this.")
359
+ @click.option("--json", "as_json", is_flag=True, help="Print raw JSON instead of a table.")
360
+ def voices(provider, language, gender, ownership, limit, search, as_json):
361
+ """List the voice codes available for `--voice`."""
362
+ try:
363
+ entries = core.list_voices(
364
+ provider=provider,
365
+ language_code=language or None,
366
+ gender=gender,
367
+ ownership=ownership,
368
+ limit=limit,
369
+ )
370
+ except TtsError as exc:
371
+ error(str(exc))
372
+ sys.exit(1)
373
+
374
+ if search:
375
+ needle = search.lower()
376
+ entries = [
377
+ entry
378
+ for entry in entries
379
+ if needle in str(entry.get("code", "")).lower() or needle in str(entry.get("name", "")).lower()
380
+ ]
381
+
382
+ if as_json:
383
+ console.print_json(json.dumps(entries, ensure_ascii=False))
384
+ return
385
+
386
+ table = Table(title=f"{core.resolve_provider(provider)} voices ({len(entries)})", box=None, pad_edge=False)
387
+ table.add_column("code", style="bold cyan", overflow="fold")
388
+ table.add_column("name")
389
+ table.add_column("lang")
390
+ table.add_column("gender")
391
+ for entry in entries:
392
+ table.add_row(
393
+ str(entry.get("code", "")),
394
+ str(entry.get("name", "")),
395
+ str(entry.get("language_code", "")),
396
+ str(entry.get("gender", "")),
397
+ )
398
+ console.print(table)
@@ -60,6 +60,17 @@ SPECS = [
60
60
  "rotate": "OpenAI dashboard -> API keys -> rotate",
61
61
  "keys": ["openai_api_key"],
62
62
  },
63
+ {
64
+ "path": "ai/vbee.json",
65
+ "id": "vbee",
66
+ "service": "Vbee AIVoice",
67
+ "category": "ai",
68
+ "type": "api_key",
69
+ "lifetime": "token expires per app setting (7/30/60/90 days or never)",
70
+ "description": "Vbee TTS app id + JWT token (evo tts, agent speak MCP)",
71
+ "rotate": "https://studio.vbee.vn/apps -> open the app -> copy App ID + Token",
72
+ "keys": ["vbee"],
73
+ },
63
74
  {
64
75
  "path": "ai/google.json",
65
76
  "id": "google_api",
@@ -0,0 +1,28 @@
1
+ from evo_cli.tts.core import (
2
+ MODES,
3
+ PROVIDERS,
4
+ chunk_limit,
5
+ default_voice,
6
+ list_voices,
7
+ resolve_provider,
8
+ supported_formats,
9
+ synthesize,
10
+ synthesize_many,
11
+ )
12
+ from evo_cli.tts.errors import TtsError
13
+ from evo_cli.tts.player import play, player_hint
14
+
15
+ __all__ = [
16
+ "MODES",
17
+ "PROVIDERS",
18
+ "TtsError",
19
+ "chunk_limit",
20
+ "default_voice",
21
+ "list_voices",
22
+ "play",
23
+ "player_hint",
24
+ "resolve_provider",
25
+ "supported_formats",
26
+ "synthesize",
27
+ "synthesize_many",
28
+ ]
@@ -0,0 +1,61 @@
1
+ import io
2
+ import re
3
+ import wave
4
+
5
+ SEPARATORS = (r"\n{2,}", r"(?<=[.!?…])\s+", r"(?<=[,;:])\s+", r"\s+")
6
+
7
+
8
+ def _split_pieces(text, limit, separators):
9
+ if len(text) <= limit:
10
+ return [text]
11
+ if not separators:
12
+ return [text[index : index + limit] for index in range(0, len(text), limit)]
13
+ parts = [part.strip() for part in re.split(separators[0], text) if part.strip()]
14
+ if len(parts) < 2:
15
+ return _split_pieces(text, limit, separators[1:])
16
+ pieces = []
17
+ for part in parts:
18
+ pieces.extend(_split_pieces(part, limit, separators[1:]))
19
+ return pieces
20
+
21
+
22
+ def split_text(text, limit):
23
+ text = (text or "").strip()
24
+ if not text:
25
+ return []
26
+ if len(text) <= limit:
27
+ return [text]
28
+ chunks = []
29
+ for piece in _split_pieces(text, limit, SEPARATORS):
30
+ if chunks and len(chunks[-1]) + 1 + len(piece) <= limit:
31
+ chunks[-1] = f"{chunks[-1]} {piece}"
32
+ else:
33
+ chunks.append(piece)
34
+ return chunks
35
+
36
+
37
+ def _join_wav(parts):
38
+ buffer = io.BytesIO()
39
+ writer = None
40
+ try:
41
+ for part in parts:
42
+ with wave.open(io.BytesIO(part), "rb") as reader:
43
+ if writer is None:
44
+ writer = wave.open(buffer, "wb")
45
+ writer.setparams(reader.getparams())
46
+ writer.writeframes(reader.readframes(reader.getnframes()))
47
+ finally:
48
+ if writer is not None:
49
+ writer.close()
50
+ return buffer.getvalue()
51
+
52
+
53
+ def join_audio(parts, output_format):
54
+ parts = [part for part in parts if part]
55
+ if not parts:
56
+ return b""
57
+ if len(parts) == 1:
58
+ return parts[0]
59
+ if output_format == "wav":
60
+ return _join_wav(parts)
61
+ return b"".join(parts)