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.
Files changed (39) hide show
  1. voicerender/__init__.py +127 -0
  2. voicerender/__main__.py +3 -0
  3. voicerender/_version.py +24 -0
  4. voicerender/capacity.py +678 -0
  5. voicerender/cli.py +345 -0
  6. voicerender/engines/__init__.py +68 -0
  7. voicerender/engines/api_probe.py +495 -0
  8. voicerender/engines/base.py +425 -0
  9. voicerender/engines/catalog.py +83 -0
  10. voicerender/engines/inflectsynth.py +920 -0
  11. voicerender/engines/kittensynth.py +511 -0
  12. voicerender/engines/kokoro.py +725 -0
  13. voicerender/engines/metadata.py +58 -0
  14. voicerender/engines/piper.py +173 -0
  15. voicerender/engines/pipersynth.py +570 -0
  16. voicerender/engines/pocket.py +169 -0
  17. voicerender/engines/pocketsynth.py +1234 -0
  18. voicerender/engines/registry.py +320 -0
  19. voicerender/engines/selection.py +61 -0
  20. voicerender/engines/supertonicsynth.py +707 -0
  21. voicerender/errors.py +161 -0
  22. voicerender/events.py +43 -0
  23. voicerender/handoff.py +62 -0
  24. voicerender/inputs/__init__.py +21 -0
  25. voicerender/inputs/utterplan_v5.py +897 -0
  26. voicerender/lexicons.py +42 -0
  27. voicerender/manifest.py +750 -0
  28. voicerender/models.py +499 -0
  29. voicerender/py.typed +0 -0
  30. voicerender/render.py +365 -0
  31. voicerender/render_v5.py +1193 -0
  32. voicerender/schemas/render-manifest-v2.schema.json +415 -0
  33. voicerender/storage.py +113 -0
  34. voicerender-0.1.1.dist-info/METADATA +254 -0
  35. voicerender-0.1.1.dist-info/RECORD +39 -0
  36. voicerender-0.1.1.dist-info/WHEEL +5 -0
  37. voicerender-0.1.1.dist-info/entry_points.txt +2 -0
  38. voicerender-0.1.1.dist-info/licenses/LICENSE +201 -0
  39. 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
+ ]