voicerender 0.1.1__py3-none-any.whl
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.
- voicerender/__init__.py +127 -0
- voicerender/__main__.py +3 -0
- voicerender/_version.py +24 -0
- voicerender/capacity.py +678 -0
- voicerender/cli.py +345 -0
- voicerender/engines/__init__.py +68 -0
- voicerender/engines/api_probe.py +495 -0
- voicerender/engines/base.py +425 -0
- voicerender/engines/catalog.py +83 -0
- voicerender/engines/inflectsynth.py +920 -0
- voicerender/engines/kittensynth.py +511 -0
- voicerender/engines/kokoro.py +725 -0
- voicerender/engines/metadata.py +58 -0
- voicerender/engines/piper.py +173 -0
- voicerender/engines/pipersynth.py +570 -0
- voicerender/engines/pocket.py +169 -0
- voicerender/engines/pocketsynth.py +1234 -0
- voicerender/engines/registry.py +320 -0
- voicerender/engines/selection.py +61 -0
- voicerender/engines/supertonicsynth.py +707 -0
- voicerender/errors.py +161 -0
- voicerender/events.py +43 -0
- voicerender/handoff.py +62 -0
- voicerender/inputs/__init__.py +21 -0
- voicerender/inputs/utterplan_v5.py +897 -0
- voicerender/lexicons.py +42 -0
- voicerender/manifest.py +750 -0
- voicerender/models.py +499 -0
- voicerender/py.typed +0 -0
- voicerender/render.py +365 -0
- voicerender/render_v5.py +1193 -0
- voicerender/schemas/render-manifest-v2.schema.json +415 -0
- voicerender/storage.py +113 -0
- voicerender-0.1.1.dist-info/METADATA +254 -0
- voicerender-0.1.1.dist-info/RECORD +39 -0
- voicerender-0.1.1.dist-info/WHEEL +5 -0
- voicerender-0.1.1.dist-info/entry_points.txt +2 -0
- voicerender-0.1.1.dist-info/licenses/LICENSE +201 -0
- voicerender-0.1.1.dist-info/top_level.txt +1 -0
voicerender/cli.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Command-line interface for independent VoiceRender usage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .engines import (
|
|
13
|
+
EngineRequest,
|
|
14
|
+
discover_targets,
|
|
15
|
+
engine_status,
|
|
16
|
+
list_engines,
|
|
17
|
+
resolve_voice,
|
|
18
|
+
)
|
|
19
|
+
from .errors import VoiceRenderError
|
|
20
|
+
from .manifest import MANIFEST_SCHEMA as MANIFEST_SCHEMA_V2
|
|
21
|
+
from .manifest import read_manifest
|
|
22
|
+
from .models import SCHEMA_MANIFEST, VoiceProfile
|
|
23
|
+
from .render import render_plan
|
|
24
|
+
from .storage import verified_wav
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _parser() -> argparse.ArgumentParser:
|
|
28
|
+
parser = argparse.ArgumentParser(
|
|
29
|
+
prog="voicerender",
|
|
30
|
+
description="Render VoicePlan or UtterPlan v5 to resumable WAV bundles",
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"--version", action="version", version=f"voicerender {__version__}"
|
|
34
|
+
)
|
|
35
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
36
|
+
render = commands.add_parser(
|
|
37
|
+
"render", help="Synthesize selected logical speech segments"
|
|
38
|
+
)
|
|
39
|
+
render.add_argument("plan", type=Path)
|
|
40
|
+
voice_input = render.add_mutually_exclusive_group(required=True)
|
|
41
|
+
voice_input.add_argument("--profile", "--voices", dest="profile", type=Path)
|
|
42
|
+
voice_input.add_argument(
|
|
43
|
+
"--voice", type=Path, help="voice.toml for direct UtterPlan v5 rendering"
|
|
44
|
+
)
|
|
45
|
+
render.add_argument("--out-dir", required=True, type=Path)
|
|
46
|
+
render.add_argument("--cache-dir", type=Path)
|
|
47
|
+
select = render.add_mutually_exclusive_group()
|
|
48
|
+
select.add_argument("--take", type=int)
|
|
49
|
+
select.add_argument(
|
|
50
|
+
"--select", metavar="ID[,ID...]", help="Legacy IDs or v5 source locators"
|
|
51
|
+
)
|
|
52
|
+
render.add_argument(
|
|
53
|
+
"--force", action="store_true", help="Regenerate selected audio, bypass cache"
|
|
54
|
+
)
|
|
55
|
+
render.add_argument("--continue-on-error", action="store_true")
|
|
56
|
+
render.add_argument("--json", action="store_true")
|
|
57
|
+
engines = commands.add_parser("engines", help="Inspect installed synthesis engines")
|
|
58
|
+
engines.add_argument("action", choices=["list", "inspect"])
|
|
59
|
+
engines.add_argument("engine", nargs="?", help="Engine ID (required for inspect)")
|
|
60
|
+
engines.add_argument("--json", action="store_true")
|
|
61
|
+
voices = commands.add_parser(
|
|
62
|
+
"voices", help="Discover backend voice and model choices"
|
|
63
|
+
)
|
|
64
|
+
voices.add_argument("action", choices=["list"])
|
|
65
|
+
voices.add_argument("--engine", required=True)
|
|
66
|
+
voices.add_argument("--lang")
|
|
67
|
+
voices.add_argument("--offline", action="store_true")
|
|
68
|
+
voices.add_argument("--refresh", action="store_true")
|
|
69
|
+
voices.add_argument("--json", action="store_true")
|
|
70
|
+
profiles = commands.add_parser("profiles", help="Validate a voice profile")
|
|
71
|
+
profiles.add_argument("action", choices=["validate"])
|
|
72
|
+
profiles.add_argument("path", type=Path)
|
|
73
|
+
profiles.add_argument("--json", action="store_true")
|
|
74
|
+
inspect = commands.add_parser(
|
|
75
|
+
"inspect", help="Inspect a persisted manifest and verify WAVs"
|
|
76
|
+
)
|
|
77
|
+
inspect.add_argument("manifest", type=Path)
|
|
78
|
+
inspect.add_argument(
|
|
79
|
+
"--verify",
|
|
80
|
+
action="store_true",
|
|
81
|
+
help="Verify WAV bytes (verification is also the default)",
|
|
82
|
+
)
|
|
83
|
+
inspect.add_argument("--json", action="store_true")
|
|
84
|
+
validate = commands.add_parser(
|
|
85
|
+
"validate", help="Run no-inference UtterPlan v5 and profile preflight"
|
|
86
|
+
)
|
|
87
|
+
validate.add_argument("plan", type=Path)
|
|
88
|
+
validate.add_argument(
|
|
89
|
+
"--voice", "--profile", dest="voice", required=True, type=Path
|
|
90
|
+
)
|
|
91
|
+
validate.add_argument("--json", action="store_true")
|
|
92
|
+
return parser
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def main(argv: list[str] | None = None) -> int:
|
|
96
|
+
args = _parser().parse_args(argv)
|
|
97
|
+
try:
|
|
98
|
+
if args.command == "validate":
|
|
99
|
+
from .render_v5 import validate_utterplan
|
|
100
|
+
|
|
101
|
+
payload = validate_utterplan(args.plan, profile=args.voice)
|
|
102
|
+
if args.json:
|
|
103
|
+
print(json.dumps(payload, indent=2))
|
|
104
|
+
else:
|
|
105
|
+
engine_names = ", ".join(item["id"] for item in payload["engines"])
|
|
106
|
+
print(
|
|
107
|
+
f"Valid UtterPlan v5 {payload['plan_id']}: "
|
|
108
|
+
f"{payload['selection']['selected_speech_count']} speech operations; "
|
|
109
|
+
f"engines: {engine_names or 'none'}"
|
|
110
|
+
)
|
|
111
|
+
return 0
|
|
112
|
+
if args.command == "render":
|
|
113
|
+
selected = args.select.split(",") if args.select is not None else None
|
|
114
|
+
if args.voice is not None:
|
|
115
|
+
from .render_v5 import render_utterplan_v5
|
|
116
|
+
|
|
117
|
+
result = render_utterplan_v5(
|
|
118
|
+
args.plan,
|
|
119
|
+
profile=args.voice,
|
|
120
|
+
output_dir=args.out_dir,
|
|
121
|
+
cache_dir=args.cache_dir,
|
|
122
|
+
take=args.take,
|
|
123
|
+
select=selected,
|
|
124
|
+
force=args.force,
|
|
125
|
+
fail_fast=not args.continue_on_error,
|
|
126
|
+
)
|
|
127
|
+
else:
|
|
128
|
+
result = render_plan(
|
|
129
|
+
args.plan,
|
|
130
|
+
profile=args.profile,
|
|
131
|
+
output_dir=args.out_dir,
|
|
132
|
+
cache_dir=args.cache_dir,
|
|
133
|
+
take=args.take,
|
|
134
|
+
select=selected,
|
|
135
|
+
force=args.force,
|
|
136
|
+
fail_fast=not args.continue_on_error,
|
|
137
|
+
)
|
|
138
|
+
payload = result.as_dict()
|
|
139
|
+
if args.json:
|
|
140
|
+
print(json.dumps(payload, indent=2))
|
|
141
|
+
else:
|
|
142
|
+
print(
|
|
143
|
+
f"Rendered {payload['rendered']}, reused {payload['reused']}, "
|
|
144
|
+
f"failed {payload['failed']} of {payload['selected']} → {result.manifest_path}"
|
|
145
|
+
)
|
|
146
|
+
return 1 if result.failed_count else 0
|
|
147
|
+
if args.command == "engines":
|
|
148
|
+
if args.action == "list":
|
|
149
|
+
if args.engine is not None:
|
|
150
|
+
raise VoiceRenderError("engines list does not accept an engine ID")
|
|
151
|
+
engines = list_engines()
|
|
152
|
+
if args.json:
|
|
153
|
+
print(json.dumps(engines, indent=2))
|
|
154
|
+
else:
|
|
155
|
+
for engine in engines:
|
|
156
|
+
print(
|
|
157
|
+
f"{engine['id']}: {engine['installed_version'] or 'not installed'} "
|
|
158
|
+
f"(package: {engine['package']})"
|
|
159
|
+
)
|
|
160
|
+
return 0
|
|
161
|
+
if args.engine is None:
|
|
162
|
+
raise VoiceRenderError("engines inspect requires an engine ID")
|
|
163
|
+
status = engine_status(args.engine)
|
|
164
|
+
if args.json:
|
|
165
|
+
print(json.dumps(status, indent=2, default=str))
|
|
166
|
+
else:
|
|
167
|
+
label = "compatible" if status["compatible"] else status["status"]
|
|
168
|
+
print(f"{status['engine']}: {label}")
|
|
169
|
+
version = status["distribution_version"] or "(not installed)"
|
|
170
|
+
print(f" package: {status['package']} {version}")
|
|
171
|
+
if status["missing_symbols"]:
|
|
172
|
+
print(f" missing symbols: {', '.join(status['missing_symbols'])}")
|
|
173
|
+
if status["missing_methods"]:
|
|
174
|
+
print(f" missing methods: {', '.join(status['missing_methods'])}")
|
|
175
|
+
return 0 if status["compatible"] else 1
|
|
176
|
+
if args.command == "voices":
|
|
177
|
+
found = discover_targets(
|
|
178
|
+
args.engine,
|
|
179
|
+
language=args.lang,
|
|
180
|
+
offline=args.offline,
|
|
181
|
+
refresh=args.refresh,
|
|
182
|
+
)
|
|
183
|
+
targets_payload = [
|
|
184
|
+
{
|
|
185
|
+
"engine": target.engine,
|
|
186
|
+
"id": target.id,
|
|
187
|
+
"display_name": target.display_name,
|
|
188
|
+
"languages": target.languages,
|
|
189
|
+
"status": target.status,
|
|
190
|
+
"runtime_available": target.runtime_available,
|
|
191
|
+
"sample_rate": target.sample_rate,
|
|
192
|
+
"voices": target.voices,
|
|
193
|
+
"speakers": target.speakers,
|
|
194
|
+
"voice_details": [
|
|
195
|
+
{
|
|
196
|
+
"id": voice.id,
|
|
197
|
+
"gender": voice.gender,
|
|
198
|
+
"language": voice.language,
|
|
199
|
+
"locale": voice.locale,
|
|
200
|
+
"language_label": voice.language_label,
|
|
201
|
+
"languages": voice.languages,
|
|
202
|
+
}
|
|
203
|
+
for voice in target.voice_details
|
|
204
|
+
],
|
|
205
|
+
"default_voice": target.default_voice,
|
|
206
|
+
"qualities": target.qualities,
|
|
207
|
+
"aliases": target.aliases,
|
|
208
|
+
"capabilities": sorted(target.capabilities),
|
|
209
|
+
"metadata": dict(target.metadata),
|
|
210
|
+
}
|
|
211
|
+
for target in found
|
|
212
|
+
]
|
|
213
|
+
if args.json:
|
|
214
|
+
print(json.dumps(targets_payload, indent=2, default=str))
|
|
215
|
+
else:
|
|
216
|
+
for target in found:
|
|
217
|
+
names = ", ".join(target.voices)
|
|
218
|
+
print(
|
|
219
|
+
f"{target.engine}:{target.id}"
|
|
220
|
+
+ (f" ({names})" if names else "")
|
|
221
|
+
+ f" [{target.status}]"
|
|
222
|
+
)
|
|
223
|
+
return 0
|
|
224
|
+
if args.command == "profiles":
|
|
225
|
+
profile = VoiceProfile.load(args.path)
|
|
226
|
+
for role, binding in profile.roles.items():
|
|
227
|
+
options = dict(binding.options)
|
|
228
|
+
if binding.reference_audio is not None:
|
|
229
|
+
options["voice_source"] = {
|
|
230
|
+
"kind": "reference",
|
|
231
|
+
"value": str(binding.reference_audio),
|
|
232
|
+
"sha256": hashlib.sha256(
|
|
233
|
+
binding.reference_audio.read_bytes()
|
|
234
|
+
).hexdigest(),
|
|
235
|
+
}
|
|
236
|
+
_, diagnostics = resolve_voice(
|
|
237
|
+
EngineRequest(
|
|
238
|
+
engine=binding.engine,
|
|
239
|
+
target_id=binding.target_id,
|
|
240
|
+
language=profile.default_language or "en-us",
|
|
241
|
+
voice=binding.voice,
|
|
242
|
+
speaker=binding.speaker,
|
|
243
|
+
options=options,
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
probe = engine_status(binding.engine)
|
|
247
|
+
if not probe["compatible"]:
|
|
248
|
+
raise VoiceRenderError(
|
|
249
|
+
f"Role {role!r} has an incompatible engine API: {probe['status']}"
|
|
250
|
+
)
|
|
251
|
+
errors = [item for item in diagnostics if item.severity == "error"]
|
|
252
|
+
if errors:
|
|
253
|
+
details = "; ".join(
|
|
254
|
+
f"{item.code}: {item.message}" for item in errors
|
|
255
|
+
)
|
|
256
|
+
raise VoiceRenderError(f"Role {role!r} is invalid: {details}")
|
|
257
|
+
payload = {
|
|
258
|
+
"valid": True,
|
|
259
|
+
"name": profile.name,
|
|
260
|
+
"roles": list(profile.roles),
|
|
261
|
+
}
|
|
262
|
+
print(
|
|
263
|
+
json.dumps(payload, indent=2)
|
|
264
|
+
if args.json
|
|
265
|
+
else f"Valid profile {profile.name}: {len(profile.roles)} roles"
|
|
266
|
+
)
|
|
267
|
+
return 0
|
|
268
|
+
if args.command == "inspect":
|
|
269
|
+
input_path = args.manifest.expanduser().absolute()
|
|
270
|
+
raw_document = json.loads(input_path.read_text(encoding="utf-8"))
|
|
271
|
+
if not isinstance(raw_document, dict):
|
|
272
|
+
raise VoiceRenderError("Manifest root must be a JSON object")
|
|
273
|
+
if raw_document.get("schema") == MANIFEST_SCHEMA_V2:
|
|
274
|
+
# Manifest v2 verification is mandatory for inspection; --verify
|
|
275
|
+
# makes that behavior explicit in scripts and documentation.
|
|
276
|
+
document = read_manifest(input_path, verify_audio=True)
|
|
277
|
+
findings = [
|
|
278
|
+
{
|
|
279
|
+
"locator": entry["source"]["locator"],
|
|
280
|
+
"status": entry["status"],
|
|
281
|
+
"wav_valid": entry["status"] in {"rendered", "reused"},
|
|
282
|
+
}
|
|
283
|
+
for entry in document["segments"]
|
|
284
|
+
]
|
|
285
|
+
payload = {
|
|
286
|
+
"schema": MANIFEST_SCHEMA_V2,
|
|
287
|
+
"manifest_id": document["manifest_id"],
|
|
288
|
+
"complete": document["complete"],
|
|
289
|
+
"valid_wavs": sum(item["wav_valid"] for item in findings),
|
|
290
|
+
"selected": len(findings),
|
|
291
|
+
"segments": findings,
|
|
292
|
+
}
|
|
293
|
+
else:
|
|
294
|
+
manifest_path = input_path.resolve()
|
|
295
|
+
document = raw_document
|
|
296
|
+
if document.get("schema") != SCHEMA_MANIFEST:
|
|
297
|
+
raise VoiceRenderError(
|
|
298
|
+
f"Unsupported manifest schema: {document.get('schema')}"
|
|
299
|
+
)
|
|
300
|
+
findings = []
|
|
301
|
+
for entry in document.get("segments", []):
|
|
302
|
+
wav = entry.get("wav")
|
|
303
|
+
stored = Path(entry["path"])
|
|
304
|
+
if stored.is_absolute() or ".." in stored.parts:
|
|
305
|
+
valid = False
|
|
306
|
+
else:
|
|
307
|
+
valid = bool(
|
|
308
|
+
isinstance(wav, dict)
|
|
309
|
+
and verified_wav(manifest_path.parent / stored, wav)
|
|
310
|
+
)
|
|
311
|
+
findings.append(
|
|
312
|
+
{
|
|
313
|
+
"scope_id": entry["scope_id"],
|
|
314
|
+
"segment_id": entry["segment_id"],
|
|
315
|
+
"status": entry["status"],
|
|
316
|
+
"wav_valid": valid,
|
|
317
|
+
}
|
|
318
|
+
)
|
|
319
|
+
payload = {
|
|
320
|
+
"schema": SCHEMA_MANIFEST,
|
|
321
|
+
"complete": document.get("complete"),
|
|
322
|
+
"valid_wavs": sum(item["wav_valid"] for item in findings),
|
|
323
|
+
"selected": len(findings),
|
|
324
|
+
"segments": findings,
|
|
325
|
+
}
|
|
326
|
+
if args.json:
|
|
327
|
+
print(json.dumps(payload, indent=2))
|
|
328
|
+
else:
|
|
329
|
+
print(
|
|
330
|
+
f"{payload['valid_wavs']}/{payload['selected']} WAVs verified; "
|
|
331
|
+
f"complete: {payload['complete']}"
|
|
332
|
+
)
|
|
333
|
+
return (
|
|
334
|
+
0
|
|
335
|
+
if payload["complete"] and payload["valid_wavs"] == payload["selected"]
|
|
336
|
+
else 1
|
|
337
|
+
)
|
|
338
|
+
except (VoiceRenderError, OSError, ValueError, KeyError) as exc:
|
|
339
|
+
print(f"voicerender: {exc}", file=sys.stderr)
|
|
340
|
+
return 2
|
|
341
|
+
return 2
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
if __name__ == "__main__":
|
|
345
|
+
sys.exit(main())
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""VoiceRender engine APIs and the legacy batch-render adapter registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..errors import ProfileError
|
|
9
|
+
from .registry import (
|
|
10
|
+
discover_targets,
|
|
11
|
+
engine_status,
|
|
12
|
+
get_adapter,
|
|
13
|
+
list_engines,
|
|
14
|
+
open_session,
|
|
15
|
+
register_adapter,
|
|
16
|
+
resolve_voice,
|
|
17
|
+
synthesize,
|
|
18
|
+
)
|
|
19
|
+
from .selection import EngineRequest
|
|
20
|
+
|
|
21
|
+
# The original VoicePlan renderer remains temporarily available, but its two
|
|
22
|
+
# adapters are separate from the request-centric registry used by public APIs.
|
|
23
|
+
_LEGACY_ENGINES = {
|
|
24
|
+
"piper": ("voicerender.engines.piper", "PiperAdapter"),
|
|
25
|
+
"pocket": ("voicerender.engines.pocket", "PocketAdapter"),
|
|
26
|
+
}
|
|
27
|
+
_legacy_adapters: dict[str, Any] = {}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def register_engine(adapter: Any) -> None:
|
|
31
|
+
"""Register an adapter for the transitional VoicePlan renderer."""
|
|
32
|
+
if (
|
|
33
|
+
not adapter.id
|
|
34
|
+
or adapter.id in _legacy_adapters
|
|
35
|
+
or adapter.id in _LEGACY_ENGINES
|
|
36
|
+
):
|
|
37
|
+
raise ProfileError(f"Invalid or already registered engine: {adapter.id!r}")
|
|
38
|
+
_legacy_adapters[adapter.id] = adapter
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_engine(name: str) -> Any:
|
|
42
|
+
"""Return a legacy renderer adapter, importing its implementation lazily."""
|
|
43
|
+
if name in _legacy_adapters:
|
|
44
|
+
return _legacy_adapters[name]
|
|
45
|
+
try:
|
|
46
|
+
module_name, class_name = _LEGACY_ENGINES[name]
|
|
47
|
+
except KeyError as exc:
|
|
48
|
+
raise ProfileError(
|
|
49
|
+
f"Unknown legacy engine {name!r}; available: {', '.join(sorted(_LEGACY_ENGINES))}"
|
|
50
|
+
) from exc
|
|
51
|
+
adapter = getattr(importlib.import_module(module_name), class_name)()
|
|
52
|
+
_legacy_adapters[name] = adapter
|
|
53
|
+
return adapter
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"EngineRequest",
|
|
58
|
+
"discover_targets",
|
|
59
|
+
"engine_status",
|
|
60
|
+
"get_adapter",
|
|
61
|
+
"get_engine",
|
|
62
|
+
"list_engines",
|
|
63
|
+
"open_session",
|
|
64
|
+
"register_adapter",
|
|
65
|
+
"register_engine",
|
|
66
|
+
"resolve_voice",
|
|
67
|
+
"synthesize",
|
|
68
|
+
]
|