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.
- {evo_cli-0.12.2 → evo_cli-0.13.1}/PKG-INFO +47 -1
- {evo_cli-0.12.2 → evo_cli-0.13.1}/README.md +46 -0
- evo_cli-0.13.1/evo_cli/VERSION +1 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/cli.py +2 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/opencode.py +10 -5
- evo_cli-0.13.1/evo_cli/commands/tts.py +398 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/credentials/registry.py +11 -0
- evo_cli-0.13.1/evo_cli/tts/__init__.py +28 -0
- evo_cli-0.13.1/evo_cli/tts/chunking.py +61 -0
- evo_cli-0.13.1/evo_cli/tts/core.py +203 -0
- evo_cli-0.13.1/evo_cli/tts/creds.py +59 -0
- evo_cli-0.13.1/evo_cli/tts/errors.py +2 -0
- evo_cli-0.13.1/evo_cli/tts/http.py +54 -0
- evo_cli-0.13.1/evo_cli/tts/openai.py +58 -0
- evo_cli-0.13.1/evo_cli/tts/player.py +83 -0
- evo_cli-0.13.1/evo_cli/tts/vbee.py +133 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli.egg-info/PKG-INFO +47 -1
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli.egg-info/SOURCES.txt +11 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/tests/test_opencode.py +32 -0
- evo_cli-0.13.1/tests/test_tts.py +260 -0
- evo_cli-0.12.2/evo_cli/VERSION +0 -1
- {evo_cli-0.12.2 → evo_cli-0.13.1}/Containerfile +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/HISTORY.md +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/LICENSE +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/MANIFEST.in +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/__init__.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/__main__.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/base.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/__init__.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/claude_code.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/cloudflare.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/cred.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/download.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/fix_claude.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/gdrive.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/gh.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/harness.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/hwid.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/hwid_reset.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/localproxy.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/mcp.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/miniconda.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/netcheck.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/plantuml.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/site2s.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/ssh.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/sysmon.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/update.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/commands/wifi.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/console.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/credentials/__init__.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/credentials/doctor.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/credentials/google_oauth.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/credentials/migrate.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/credentials/oauth_flow.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/credentials/store.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/credentials/sync.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli/mcp_registry.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli.egg-info/dependency_links.txt +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli.egg-info/entry_points.txt +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli.egg-info/requires.txt +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/evo_cli.egg-info/top_level.txt +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/pyproject.toml +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/setup.cfg +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/tests/__init__.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/tests/test_claude_code.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/tests/test_cli.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/tests/test_console.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/tests/test_cred.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/tests/test_download.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/tests/test_fix_claude.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/tests/test_gh.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/tests/test_harness.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/tests/test_mcp.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/tests/test_plantuml.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/tests/test_sysmon.py +0 -0
- {evo_cli-0.12.2 → evo_cli-0.13.1}/tests/test_update.py +0 -0
- {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.
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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)
|