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
@@ -0,0 +1,511 @@
1
+ """Request-centric adapter for the public KittenSynth API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Mapping
7
+ from contextlib import AbstractContextManager, contextmanager
8
+ from typing import Any
9
+
10
+ from ..errors import (
11
+ EmptySpeechTextError,
12
+ EngineBackendError,
13
+ EngineSynthesisError,
14
+ InvalidEngineModelError,
15
+ InvalidEngineOptionError,
16
+ InvalidEngineVoiceError,
17
+ )
18
+ from .api_probe import SUPPORTED_REQUEST_API_VERSION, EngineApiProbe, probe_public_api
19
+ from .base import (
20
+ EngineCapabilities,
21
+ EngineSelection,
22
+ RenderedSpeech,
23
+ RequestMeasure,
24
+ SpeechRequest,
25
+ distribution_version,
26
+ )
27
+ from .catalog import CatalogRequest, SynthesisTarget, TargetVoice
28
+ from .metadata import (
29
+ language_base,
30
+ normalize_gender,
31
+ normalize_language_key,
32
+ normalize_locale_tag,
33
+ )
34
+
35
+ DEFAULT_KITTEN_MODEL = "nano-0.8-int8"
36
+ DEFAULT_KITTEN_VOICE = "Jasper"
37
+ KITTEN_OPTION_NAMES = frozenset(
38
+ {
39
+ "speed",
40
+ "quality",
41
+ "cache_dir",
42
+ "force_download",
43
+ "providers",
44
+ "provider_options",
45
+ "session_options",
46
+ }
47
+ )
48
+
49
+
50
+ class KittenSelectionError(ValueError):
51
+ """A Kitten selection error with a stable plan diagnostic."""
52
+
53
+ def __init__(self, code: str, message: str, field: str) -> None:
54
+ super().__init__(message)
55
+ self.diagnostic_code = code
56
+ self.diagnostic_field = field
57
+
58
+
59
+ def _is_english(language: str) -> bool:
60
+ normalized = normalize_language_key(language)
61
+ return normalized == "english" or language_base(normalized) == "en"
62
+
63
+
64
+ def _language_values(value: Any) -> tuple[str, str, str]:
65
+ raw = str(value or "en")
66
+ normalized = normalize_locale_tag(raw)
67
+ base = language_base(normalized) or normalize_language_key(raw)
68
+ if base == "english":
69
+ base = "en"
70
+ normalized = "en"
71
+ return base or "unknown", normalized or base or "unknown", raw
72
+
73
+
74
+ def _target_from_model(model: Any) -> SynthesisTarget:
75
+ language, locale, raw_language = _language_values(model.language)
76
+ quality = model.quality
77
+ voices = tuple(str(voice) for voice in model.voice_ids)
78
+ display_name = str(model.display_name)
79
+ if model.version:
80
+ display_name = f"{display_name} {model.version}"
81
+ normalized_metadata = {
82
+ **dict(model.metadata),
83
+ "language": language,
84
+ "locale": locale,
85
+ "language_label": raw_language
86
+ if raw_language.casefold() != "en"
87
+ else "English",
88
+ "quality": quality,
89
+ "source": "kittensynth",
90
+ }
91
+ return SynthesisTarget(
92
+ engine="kitten",
93
+ id=str(model.id),
94
+ display_name=display_name,
95
+ languages=(locale,) if locale and locale != "unknown" else (),
96
+ status="ready",
97
+ runtime_available=bool(model.runtime_available),
98
+ sample_rate=model.sample_rate,
99
+ voices=voices,
100
+ voice_details=tuple(
101
+ TargetVoice(
102
+ id=str(voice.id),
103
+ gender=normalize_gender(voice.gender),
104
+ language=language_base(voice.language) or language,
105
+ locale=normalize_locale_tag(voice.locale) or locale,
106
+ language_label=str(voice.language_label or raw_language),
107
+ )
108
+ for voice in model.voices
109
+ ),
110
+ default_voice=model.default_voice,
111
+ qualities=(str(quality),) if isinstance(quality, str) and quality else (),
112
+ aliases=tuple(str(alias) for alias in model.aliases),
113
+ capabilities=frozenset({"named_voices"}),
114
+ metadata=normalized_metadata,
115
+ )
116
+
117
+
118
+ def _catalog_targets(request: CatalogRequest) -> tuple[SynthesisTarget, ...]:
119
+ import kittensynth
120
+
121
+ models = kittensynth.discover_models(
122
+ language=request.language,
123
+ offline=request.offline,
124
+ refresh=request.refresh,
125
+ )
126
+ return tuple(_target_from_model(model) for model in models)
127
+
128
+
129
+ def _translate_kitten_error(
130
+ error: Exception,
131
+ selection: EngineSelection,
132
+ request: SpeechRequest | None = None,
133
+ *,
134
+ opening: bool = False,
135
+ ) -> EngineSynthesisError:
136
+ try:
137
+ import kittensynth
138
+ except ImportError:
139
+ kittensynth = None
140
+
141
+ error_map: tuple[tuple[str, type[EngineSynthesisError], str | None], ...] = (
142
+ ("EmptyTextError", EmptySpeechTextError, None),
143
+ ("InvalidSpeedError", InvalidEngineOptionError, "kitten.invalid_speed"),
144
+ ("InvalidVoiceError", InvalidEngineVoiceError, "kitten.voice_unavailable"),
145
+ ("UnsupportedModelError", InvalidEngineModelError, "kitten.model_unsupported"),
146
+ ("OnnxVoiceContractError", EngineBackendError, "kitten.runtime_contract_error"),
147
+ )
148
+ error_type: type[EngineSynthesisError] = EngineBackendError
149
+ code: str | None = "kitten.engine_error"
150
+ if kittensynth is not None:
151
+ for native_name, mapped_type, mapped_code in error_map:
152
+ native_type = getattr(kittensynth, native_name, None)
153
+ if isinstance(native_type, type) and isinstance(error, native_type):
154
+ error_type = mapped_type
155
+ code = mapped_code
156
+ break
157
+ else:
158
+ if opening and isinstance(error, (TypeError, ValueError)):
159
+ error_type = InvalidEngineOptionError
160
+ code = "kitten.invalid_options"
161
+ context: dict[str, Any] = {
162
+ "engine": "kitten",
163
+ "engine_version": distribution_version("kittensynth"),
164
+ "target_id": selection.target_id,
165
+ "language": request.language if request is not None else selection.language,
166
+ "voice": request.voice if request is not None else selection.voice,
167
+ "request_id": request.id if request is not None else None,
168
+ "native_error_type": type(error).__name__,
169
+ }
170
+ return error_type(str(error), **context, code=code)
171
+
172
+
173
+ class KittenSynthEngineSession:
174
+ """Adapt prepared VoiceRender requests to one open ``KittenVoice``."""
175
+
176
+ def __init__(self, voice: Any, selection: EngineSelection) -> None:
177
+ self._voice = voice
178
+ self._selection = selection
179
+
180
+ def measure(self, request: SpeechRequest) -> RequestMeasure:
181
+ return RequestMeasure(
182
+ fits=None,
183
+ amount=None,
184
+ maximum=None,
185
+ unit="unknown",
186
+ source="kittensynth.no_capacity_measurement",
187
+ )
188
+
189
+ def synthesize(self, request: SpeechRequest) -> RenderedSpeech:
190
+ speed = float(self._selection.options.get("speed", 1.0))
191
+ try:
192
+ result = self._voice.synthesize_prepared(
193
+ request.text,
194
+ voice=request.voice or self._selection.voice or DEFAULT_KITTEN_VOICE,
195
+ speed=speed,
196
+ )
197
+ except EngineSynthesisError:
198
+ raise
199
+ except Exception as exc:
200
+ raise _translate_kitten_error(exc, self._selection, request) from exc
201
+ metadata = dict(result.metadata or {})
202
+ metadata.update(
203
+ {
204
+ "voice": result.voice,
205
+ "model_ref": result.model_ref,
206
+ "effective_speed": result.speed,
207
+ }
208
+ )
209
+ return RenderedSpeech(
210
+ id=request.id,
211
+ audio=result.audio,
212
+ sample_rate=result.sample_rate,
213
+ warnings=(),
214
+ word_timings=(),
215
+ metadata=metadata,
216
+ )
217
+
218
+
219
+ class KittenSynthEngineAdapter:
220
+ """Kitten model discovery, selection, runtime and request adaptation."""
221
+
222
+ id = "kitten"
223
+ package_name = "kittensynth"
224
+
225
+ def version(self) -> str | None:
226
+ return distribution_version(self.package_name)
227
+
228
+ def probe_api(self) -> EngineApiProbe:
229
+ return probe_public_api(
230
+ engine=self.id,
231
+ package=self.package_name,
232
+ required_symbols=(
233
+ "KittenVoice",
234
+ "SynthesisConfig",
235
+ "SynthesisResult",
236
+ "discover_models",
237
+ "runtime_identity",
238
+ ),
239
+ required_methods={
240
+ "KittenVoice": (
241
+ "synthesize_prepared",
242
+ "from_pretrained",
243
+ "from_local",
244
+ "close",
245
+ ),
246
+ "__module__": ("discover_models", "runtime_identity"),
247
+ },
248
+ expected_api_version=SUPPORTED_REQUEST_API_VERSION,
249
+ )
250
+
251
+ def compatible_api(self) -> bool:
252
+ return self.probe_api().compatible
253
+
254
+ def capabilities(self) -> EngineCapabilities:
255
+ return EngineCapabilities(
256
+ id=self.id,
257
+ voice_binding_namespace="kitten",
258
+ voice_binding_scope="request",
259
+ option_names=KITTEN_OPTION_NAMES,
260
+ supports_named_voices=True,
261
+ supports_reference_voice=False,
262
+ supports_speakers=False,
263
+ supports_pronunciation_overrides=False,
264
+ supports_linguistic_tokens=False,
265
+ supports_whole_request_phonemes=False,
266
+ supports_lexicons=False,
267
+ supports_model_sources=False,
268
+ supports_qualities=True,
269
+ supports_live=True,
270
+ supports_timestamps=False,
271
+ supports_voice_level_calibration=False,
272
+ supports_request_measurement=False,
273
+ )
274
+
275
+ def discover(self, request: CatalogRequest) -> tuple[SynthesisTarget, ...]:
276
+ try:
277
+ return _catalog_targets(request)
278
+ except ImportError:
279
+ return ()
280
+
281
+ def resolve(self, request: Any) -> tuple[EngineSelection, tuple[Any, ...]]:
282
+ language = normalize_language_key(getattr(request, "language", None) or "en-us")
283
+ if not _is_english(language):
284
+ raise KittenSelectionError(
285
+ "kitten.language_incompatible",
286
+ f"KittenSynth supports English only, not {language!r}.",
287
+ "synthesis.language",
288
+ )
289
+ options = dict(getattr(request, "options", {}) or {})
290
+ options.update(dict(getattr(request, "engine_options", {}) or {}))
291
+ speed = options.get("speed", 1.0)
292
+ try:
293
+ speed = float(speed)
294
+ except (TypeError, ValueError) as exc:
295
+ raise KittenSelectionError(
296
+ "kitten.invalid_speed",
297
+ "Kitten speed must be finite and greater than zero.",
298
+ "synthesis.speed",
299
+ ) from exc
300
+ if not math.isfinite(speed) or speed <= 0:
301
+ raise KittenSelectionError(
302
+ "kitten.invalid_speed",
303
+ "Kitten speed must be finite and greater than zero.",
304
+ "synthesis.speed",
305
+ )
306
+ options["speed"] = speed
307
+ target_id = (
308
+ getattr(request, "target_id", None)
309
+ or options.pop("model", None)
310
+ or DEFAULT_KITTEN_MODEL
311
+ )
312
+ voice = (
313
+ getattr(request, "voice", None)
314
+ or options.pop("voice", None)
315
+ or DEFAULT_KITTEN_VOICE
316
+ )
317
+ return (
318
+ EngineSelection(
319
+ engine=self.id,
320
+ target_id=str(target_id),
321
+ language=language,
322
+ voice=str(voice),
323
+ speaker=getattr(request, "speaker", None),
324
+ options={
325
+ key: value
326
+ for key, value in options.items()
327
+ if key in KITTEN_OPTION_NAMES
328
+ },
329
+ offline=bool(getattr(request, "offline", False)),
330
+ refresh=bool(getattr(request, "refresh", False)),
331
+ ),
332
+ (),
333
+ )
334
+
335
+ def validate_selection(self, selection: EngineSelection) -> tuple[Any, ...]:
336
+ from .base import EngineDiagnostic
337
+
338
+ if not _is_english(selection.language):
339
+ return (
340
+ EngineDiagnostic(
341
+ code="kitten.language_incompatible",
342
+ severity="error",
343
+ message=f"KittenSynth supports English only, not {selection.language!r}.",
344
+ field="synthesis.language",
345
+ ),
346
+ )
347
+ try:
348
+ targets = self.discover(
349
+ CatalogRequest(
350
+ engine=self.id,
351
+ language=selection.language,
352
+ offline=selection.offline,
353
+ refresh=selection.refresh,
354
+ )
355
+ )
356
+ except (ImportError, OSError, RuntimeError, TypeError, ValueError) as exc:
357
+ return (
358
+ EngineDiagnostic(
359
+ code="kitten.engine_unavailable",
360
+ severity="error",
361
+ message=f"Kitten model catalog is unavailable: {exc}",
362
+ field="synthesis.engine",
363
+ ),
364
+ )
365
+ target = next(
366
+ (
367
+ item
368
+ for item in targets
369
+ if item.id == selection.target_id or selection.target_id in item.aliases
370
+ ),
371
+ None,
372
+ )
373
+ if target is None:
374
+ return (
375
+ EngineDiagnostic(
376
+ code="kitten.model_not_found",
377
+ severity="error",
378
+ message=f"Kitten model {selection.target_id!r} was not found in the Kitten catalog.",
379
+ field="render.target.id",
380
+ ),
381
+ )
382
+ if target.engine != "kitten":
383
+ return (
384
+ EngineDiagnostic(
385
+ code="kitten.model_not_found",
386
+ severity="error",
387
+ message=f"Catalog target {target.id!r} does not belong to the Kitten system.",
388
+ field="render.target.id",
389
+ ),
390
+ )
391
+ if not target.sample_rate or target.sample_rate <= 0:
392
+ return (
393
+ EngineDiagnostic(
394
+ code="kitten.model_not_found",
395
+ severity="error",
396
+ message=f"Kitten model {target.id!r} has no valid sample rate.",
397
+ field="render.target.id",
398
+ ),
399
+ )
400
+ if selection.voice not in target.voices:
401
+ return (
402
+ EngineDiagnostic(
403
+ code="kitten.voice_unavailable",
404
+ severity="error",
405
+ message=f"Voice {selection.voice!r} is not available for Kitten model {target.id!r}.",
406
+ field="render.target.voice",
407
+ ),
408
+ )
409
+ quality = selection.options.get("quality")
410
+ if quality is not None and quality not in target.qualities:
411
+ return (
412
+ EngineDiagnostic(
413
+ code="kitten.quality_unavailable",
414
+ severity="error",
415
+ message=f"Quality {quality!r} is not available for Kitten model {target.id!r}.",
416
+ field="render.options.quality",
417
+ ),
418
+ )
419
+ return ()
420
+
421
+ def target_metadata(self, selection: EngineSelection) -> Mapping[str, Any]:
422
+ targets = self.discover(
423
+ CatalogRequest(
424
+ engine=self.id,
425
+ language=selection.language,
426
+ offline=selection.offline,
427
+ refresh=selection.refresh,
428
+ )
429
+ )
430
+ target = next(
431
+ (
432
+ item
433
+ for item in targets
434
+ if item.id == selection.target_id or selection.target_id in item.aliases
435
+ ),
436
+ None,
437
+ )
438
+ if target is None:
439
+ return {}
440
+ return {
441
+ **dict(target.metadata),
442
+ "sample_rate": target.sample_rate,
443
+ "voices": target.voices,
444
+ "qualities": target.qualities,
445
+ "source_revision": target.metadata.get("source_revision"),
446
+ }
447
+
448
+ def canonical_synthesis_identity(
449
+ self, selection: EngineSelection
450
+ ) -> Mapping[str, Any]:
451
+ import kittensynth
452
+
453
+ pcm_options = {
454
+ key: selection.options[key]
455
+ for key in (
456
+ "quality",
457
+ "speed",
458
+ "providers",
459
+ "provider_options",
460
+ "session_options",
461
+ )
462
+ if key in selection.options
463
+ }
464
+ return {
465
+ "engine": self.id,
466
+ "engine_version": self.version(),
467
+ "engine_identity": dict(kittensynth.runtime_identity()),
468
+ "target_id": selection.target_id,
469
+ "voice": selection.voice,
470
+ "language": selection.language,
471
+ "options": pcm_options,
472
+ "model_revision": selection.metadata.get("source_revision"),
473
+ }
474
+
475
+ def open(
476
+ self, selection: EngineSelection
477
+ ) -> AbstractContextManager[KittenSynthEngineSession]:
478
+ import kittensynth
479
+
480
+ options = dict(selection.options)
481
+ try:
482
+ voice = kittensynth.KittenVoice.from_pretrained(
483
+ selection.target_id,
484
+ quality=options.get("quality"),
485
+ cache_dir=options.get("cache_dir"),
486
+ offline=selection.offline,
487
+ refresh_catalog=selection.refresh,
488
+ force_download=bool(options.get("force_download", False)),
489
+ providers=options.get("providers"),
490
+ provider_options=options.get("provider_options"),
491
+ session_options=options.get("session_options"),
492
+ )
493
+ except Exception as exc:
494
+ raise _translate_kitten_error(exc, selection, opening=True) from exc
495
+
496
+ @contextmanager
497
+ def session() -> Any:
498
+ try:
499
+ yield KittenSynthEngineSession(voice, selection)
500
+ finally:
501
+ voice.close()
502
+
503
+ return session()
504
+
505
+
506
+ __all__ = [
507
+ "DEFAULT_KITTEN_MODEL",
508
+ "DEFAULT_KITTEN_VOICE",
509
+ "KittenSynthEngineAdapter",
510
+ "KittenSynthEngineSession",
511
+ ]