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,425 @@
1
+ """Engine-neutral synthesis contracts owned by VoiceRender."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.metadata
6
+ from collections.abc import Mapping, Sequence
7
+ from contextlib import AbstractContextManager
8
+ from dataclasses import dataclass, field
9
+ from typing import TYPE_CHECKING, Any, Literal, Protocol
10
+
11
+ from .api_probe import EngineApiProbe
12
+
13
+ if TYPE_CHECKING:
14
+ from .catalog import CatalogRequest, SynthesisTarget
15
+ from .selection import EngineRequest
16
+
17
+
18
+ def distribution_version(distribution: str) -> str | None:
19
+ """Return the installed distribution version, or None when unavailable."""
20
+ try:
21
+ return importlib.metadata.version(distribution)
22
+ except importlib.metadata.PackageNotFoundError:
23
+ return None
24
+
25
+
26
+ def voice_level_metadata(
27
+ value: Any,
28
+ mode: str,
29
+ *,
30
+ identity_keys: tuple[str, ...],
31
+ revision_keys: tuple[str, ...],
32
+ ) -> dict[str, Any]:
33
+ """Map an engine voice-level calibration record to VoiceRender metadata."""
34
+ metadata = dict(value) if isinstance(value, Mapping) else {}
35
+
36
+ def first_present(keys: tuple[str, ...]) -> Any:
37
+ for key in keys:
38
+ if key in metadata:
39
+ return metadata[key]
40
+ return None
41
+
42
+ return {
43
+ "mode": metadata.get("mode", mode),
44
+ "applied": bool(metadata.get("applied", False)),
45
+ "gain_db": metadata.get("gain_db"),
46
+ "source": metadata.get("source", "none"),
47
+ "reason": metadata.get("reason"),
48
+ "calibration_identity": first_present(identity_keys),
49
+ "calibration_revision": first_present(revision_keys),
50
+ }
51
+
52
+
53
+ @dataclass(frozen=True, slots=True)
54
+ class SpeechToken:
55
+ """One linguistic token with offsets local to a synthesis request."""
56
+
57
+ start: int
58
+ end: int
59
+ text: str
60
+ pos: str | None = None
61
+ tag: str | None = None
62
+ lemma: str | None = None
63
+ morph: str | None = None
64
+ language: str | None = None
65
+
66
+
67
+ @dataclass(frozen=True, slots=True)
68
+ class PronunciationSpan:
69
+ """A request-local pronunciation override."""
70
+
71
+ start: int
72
+ end: int
73
+ phonemes: str | None = None
74
+ language: str | None = None
75
+ alphabet: str | None = None
76
+
77
+
78
+ @dataclass(frozen=True, slots=True)
79
+ class SpeechRequest:
80
+ """Prepared text and optional linguistic context for one engine synthesis."""
81
+
82
+ id: str
83
+ text: str
84
+ language: str
85
+ voice: str | None = None
86
+ speaker: str | int | None = None
87
+ pronunciation_overrides: tuple[PronunciationSpan, ...] = ()
88
+ tokens: tuple[SpeechToken, ...] = ()
89
+ whole_request_phonemes: str | None = None
90
+ options: Mapping[str, Any] = field(default_factory=dict)
91
+
92
+
93
+ @dataclass(frozen=True, slots=True)
94
+ class SpeechWordTiming:
95
+ """A word timing expressed in request text and rendered-audio coordinates."""
96
+
97
+ text: str
98
+ char_start: int
99
+ char_end: int
100
+ start_sample: int
101
+ end_sample: int
102
+
103
+
104
+ @dataclass(slots=True)
105
+ class RenderedSpeech:
106
+ """Mono float32 audio and metadata for one independent speech request."""
107
+
108
+ id: str
109
+ audio: Any
110
+ sample_rate: int
111
+ warnings: tuple[str, ...] = ()
112
+ word_timings: tuple[SpeechWordTiming, ...] = ()
113
+ metadata: Mapping[str, Any] = field(default_factory=dict)
114
+
115
+
116
+ @dataclass(frozen=True, slots=True)
117
+ class RequestMeasure:
118
+ """Capacity measurement for one exact VoiceRender speech request."""
119
+
120
+ fits: bool | None
121
+ amount: int | None
122
+ maximum: int | None
123
+ unit: Literal["model_tokens", "phoneme_ids", "unknown"]
124
+ source: str
125
+ details: Mapping[str, Any] = field(default_factory=dict)
126
+
127
+
128
+ @dataclass(frozen=True, slots=True)
129
+ class EngineCapabilities:
130
+ """Synthesis features an engine adapter can represent natively."""
131
+
132
+ id: str = ""
133
+ voice_binding_namespace: str | None = None
134
+ voice_binding_scope: Literal["request", "target"] = "request"
135
+ option_names: frozenset[str] = frozenset()
136
+ supports_named_voices: bool = False
137
+ supports_reference_voice: bool = False
138
+ supports_speakers: bool = False
139
+ supports_pronunciation_overrides: bool = False
140
+ pronunciation_alphabets: frozenset[str] = frozenset()
141
+ supports_linguistic_tokens: bool = False
142
+ supports_whole_request_phonemes: bool = False
143
+ supports_lexicons: bool = False
144
+ supports_model_sources: bool = False
145
+ supports_qualities: bool = False
146
+ supports_live: bool = False
147
+ supports_timestamps: bool = False
148
+
149
+ supports_voice_level_calibration: bool = False
150
+ supports_request_measurement: bool = False
151
+ # Compatibility aliases retained for the original VoiceRender renderer.
152
+ supports_builtin_voices: bool = True
153
+ supports_reference_audio: bool = False
154
+ supports_speaker_selection: bool = False
155
+ supports_word_timings: bool = False
156
+ options: frozenset[str] = frozenset()
157
+ native_semantics: frozenset[str] = frozenset()
158
+ composition_semantics: frozenset[str] = frozenset()
159
+ supports_synthesis_request: bool = False
160
+
161
+
162
+ @dataclass(frozen=True, slots=True)
163
+ class EngineDiagnostic:
164
+ """Stable, machine-readable adapter selection or capability diagnostic."""
165
+
166
+ code: str
167
+ severity: Literal["error", "warning", "info"]
168
+ message: str
169
+ field: str | None = None
170
+
171
+ def as_dict(self) -> dict[str, str]:
172
+ value = {"code": self.code, "severity": self.severity, "message": self.message}
173
+ if self.field is not None:
174
+ value["field"] = self.field
175
+ return value
176
+
177
+
178
+ @dataclass(frozen=True, slots=True)
179
+ class EngineSelection:
180
+ """Concrete engine selection produced during plan resolution."""
181
+
182
+ engine: str
183
+ target_id: str
184
+ language: str
185
+ voice: str | None = None
186
+ speaker: str | int | None = None
187
+ options: Mapping[str, Any] = field(default_factory=dict)
188
+ metadata: Mapping[str, Any] = field(default_factory=dict)
189
+ offline: bool = False
190
+ refresh: bool = False
191
+
192
+
193
+ class EngineSession(Protocol):
194
+ """Open synthesis session that renders one VoiceRender speech request at a time."""
195
+
196
+ def measure(self, request: SpeechRequest) -> RequestMeasure:
197
+ """Measure request capacity without performing acoustic inference."""
198
+ ...
199
+
200
+ def synthesize(self, request: SpeechRequest) -> RenderedSpeech:
201
+ """Synthesize one independent request without composing other requests."""
202
+ ...
203
+
204
+
205
+ class EngineAdapter(Protocol):
206
+ """Discovery, target resolution, and session opening for one synthesis engine."""
207
+
208
+ id: str
209
+ package_name: str
210
+
211
+ def probe_api(self) -> EngineApiProbe:
212
+ """Return the structured public API compatibility diagnosis."""
213
+ ...
214
+
215
+ def compatible_api(self) -> bool:
216
+ """Return whether the public API is compatible (legacy wrapper)."""
217
+ ...
218
+
219
+ def version(self) -> str | None:
220
+ """Return the installed engine package version, or None."""
221
+ ...
222
+
223
+ def capabilities(self) -> EngineCapabilities:
224
+ """Return the synthesis features supported by this adapter."""
225
+ ...
226
+
227
+ def discover(
228
+ self,
229
+ request: CatalogRequest,
230
+ ) -> tuple[SynthesisTarget, ...]:
231
+ """Discover available targets for this engine."""
232
+ ...
233
+
234
+ def resolve(
235
+ self,
236
+ request: EngineRequest,
237
+ ) -> tuple[EngineSelection, tuple[Any, ...]]:
238
+ """Resolve and validate a concrete engine selection."""
239
+ ...
240
+
241
+ def validate_selection(
242
+ self, selection: EngineSelection
243
+ ) -> tuple[EngineDiagnostic, ...]:
244
+ """Validate resolved engine/model/voice choices without synthesizing."""
245
+ ...
246
+
247
+ def canonical_synthesis_identity(
248
+ self,
249
+ selection: EngineSelection,
250
+ ) -> Mapping[str, Any]:
251
+ """Return identity inputs for canonical speech synthesis."""
252
+ ...
253
+
254
+ def open(
255
+ self,
256
+ selection: EngineSelection,
257
+ ) -> AbstractContextManager[EngineSession]:
258
+ """Open a rendering session for the given concrete target."""
259
+ ...
260
+
261
+
262
+ def validate_word_timings(
263
+ *,
264
+ text: str,
265
+ frame_count: int,
266
+ timings: Sequence[SpeechWordTiming],
267
+ context: Mapping[str, object] | None = None,
268
+ ) -> tuple[SpeechWordTiming, ...]:
269
+ """Validate request-text and rendered-audio coordinates for native or derived words."""
270
+ from numbers import Integral
271
+
272
+ label = ""
273
+ if context:
274
+ label = (
275
+ " ("
276
+ + ", ".join(f"{key}={value}" for key, value in sorted(context.items()))
277
+ + ")"
278
+ )
279
+
280
+ def invalid(reason: str) -> ValueError:
281
+ return ValueError(f"{reason}{label}")
282
+
283
+ if (
284
+ isinstance(frame_count, bool)
285
+ or not isinstance(frame_count, Integral)
286
+ or frame_count < 0
287
+ ):
288
+ raise invalid("frame count must be a non-negative integer")
289
+ previous_char_start = previous_char_end = -1
290
+ previous_sample_start = previous_sample_end = -1
291
+ for timing in timings:
292
+ if not isinstance(timing.text, str):
293
+ raise invalid("word timing text must be a string")
294
+ coordinates = (
295
+ timing.char_start,
296
+ timing.char_end,
297
+ timing.start_sample,
298
+ timing.end_sample,
299
+ )
300
+ if any(
301
+ isinstance(value, bool) or not isinstance(value, Integral)
302
+ for value in coordinates
303
+ ):
304
+ raise invalid("word timing coordinates must be integers")
305
+ char_start, char_end, sample_start, sample_end = (
306
+ int(value) for value in coordinates
307
+ )
308
+ if not (0 <= char_start <= char_end <= len(text)):
309
+ raise invalid("word timing character range is outside the request text")
310
+ if not (0 <= sample_start <= sample_end <= frame_count):
311
+ raise invalid("word timing sample range is outside the rendered audio")
312
+ if char_start < previous_char_start or char_end < previous_char_end:
313
+ raise invalid("word timings are not monotonic in request text")
314
+ if sample_start < previous_sample_start or sample_end < previous_sample_end:
315
+ raise invalid("word timings are not monotonic in rendered audio")
316
+ previous_char_start, previous_char_end = char_start, char_end
317
+ previous_sample_start, previous_sample_end = sample_start, sample_end
318
+ return tuple(timings)
319
+
320
+
321
+ def validate_rendered_speech(
322
+ request: SpeechRequest,
323
+ result: RenderedSpeech,
324
+ *,
325
+ engine: str | None = None,
326
+ engine_version: str | None = None,
327
+ target_id: str | None = None,
328
+ ) -> RenderedSpeech:
329
+ """Validate and normalize an engine result before VoiceRender consumes it."""
330
+ from numbers import Integral
331
+
332
+ import numpy as np
333
+
334
+ from ..errors import EngineBackendError
335
+
336
+ def invalid(reason: str, cause: Exception | None = None) -> EngineBackendError:
337
+ error = EngineBackendError(
338
+ f"Engine returned invalid rendered speech: {reason}",
339
+ engine=engine,
340
+ engine_version=engine_version,
341
+ target_id=target_id,
342
+ language=request.language,
343
+ voice=request.voice,
344
+ speaker=request.speaker,
345
+ request_id=request.id,
346
+ )
347
+ if cause is not None:
348
+ error.__cause__ = cause
349
+ return error
350
+
351
+ if result.id != request.id:
352
+ raise invalid(
353
+ f"result id {result.id!r} does not match request id {request.id!r}"
354
+ )
355
+ if isinstance(result.sample_rate, bool) or not isinstance(
356
+ result.sample_rate, Integral
357
+ ):
358
+ raise invalid("sample rate must be a positive integer")
359
+ sample_rate = int(result.sample_rate)
360
+ if sample_rate <= 0:
361
+ raise invalid("sample rate must be a positive integer")
362
+
363
+ try:
364
+ audio = np.asarray(result.audio, dtype=np.float32)
365
+ except (TypeError, ValueError, OverflowError) as exc:
366
+ raise invalid("audio must convert to float32", exc) from exc
367
+ if audio.ndim == 2 and audio.shape[1] == 1:
368
+ audio = audio[:, 0]
369
+ if audio.ndim != 1:
370
+ raise invalid("audio must be mono")
371
+ if audio.size == 0:
372
+ raise invalid("audio must be non-empty")
373
+ if not np.isfinite(audio).all():
374
+ raise invalid("audio must contain only finite samples")
375
+
376
+ try:
377
+ result.word_timings = validate_word_timings(
378
+ text=request.text,
379
+ frame_count=int(audio.size),
380
+ timings=result.word_timings,
381
+ )
382
+ except ValueError as exc:
383
+ raise invalid(str(exc)) from exc
384
+
385
+ result.audio = audio
386
+ result.sample_rate = sample_rate
387
+ return result
388
+
389
+
390
+ @dataclass(frozen=True, slots=True)
391
+ class SynthesisRequest:
392
+ """Compatibility request for the original VoiceRender v5 renderer."""
393
+
394
+ request_id: str
395
+ text: str
396
+ language: str
397
+ directives: Mapping[str, Any] = field(default_factory=dict)
398
+
399
+
400
+ @dataclass(frozen=True, slots=True)
401
+ class SynthesisOutput:
402
+ """Compatibility result for adapters registered by legacy integrations."""
403
+
404
+ audio: Any
405
+ sample_rate: int
406
+ word_timings: tuple[Mapping[str, Any], ...] = ()
407
+ metadata: Mapping[str, Any] = field(default_factory=dict)
408
+
409
+
410
+ __all__ = [
411
+ "EngineAdapter",
412
+ "EngineCapabilities",
413
+ "EngineDiagnostic",
414
+ "EngineSelection",
415
+ "EngineSession",
416
+ "PronunciationSpan",
417
+ "RenderedSpeech",
418
+ "RequestMeasure",
419
+ "SpeechRequest",
420
+ "SpeechToken",
421
+ "SpeechWordTiming",
422
+ "SynthesisOutput",
423
+ "SynthesisRequest",
424
+ "validate_rendered_speech",
425
+ ]
@@ -0,0 +1,83 @@
1
+ """Neutral catalog types for engine discovery.
2
+
3
+ This module provides engine-neutral types for representing synthesis targets
4
+ (voice bundles, models, etc.) without forcing them into PyKokoro-shaped data.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Mapping
10
+ from dataclasses import dataclass, field
11
+ from typing import Any
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class TargetVoice:
16
+ """Typed descriptive metadata and synthesis-language capabilities for a voice.
17
+
18
+ Adapters populate canonical VoiceRender values; generic layers only project them.
19
+ ``languages`` describes this voice's synthesis-language capabilities,
20
+ separately from its singular descriptive language and locale.
21
+ """
22
+
23
+ id: str
24
+ gender: str = "unknown"
25
+ language: str = "unknown"
26
+ locale: str = "unknown"
27
+ language_label: str = "unknown"
28
+ languages: tuple[str, ...] = ()
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class SynthesisTarget:
33
+ """A neutral representation of a synthesis target.
34
+
35
+ For PyKokoro, this represents one model/distribution with its voices and qualities.
36
+ For PiperSynth, this represents one voice bundle.
37
+ """
38
+
39
+ engine: str
40
+ id: str
41
+ display_name: str
42
+ languages: tuple[str, ...] = ()
43
+ status: str = "ready"
44
+ runtime_available: bool = True
45
+ sample_rate: int | None = None
46
+ voices: tuple[str, ...] = ()
47
+ speakers: tuple[str, ...] = ()
48
+ voice_details: tuple[TargetVoice, ...] = ()
49
+ default_voice: str | None = None
50
+ qualities: tuple[str, ...] = ()
51
+ aliases: tuple[str, ...] = ()
52
+ capabilities: frozenset[str] = frozenset()
53
+ metadata: Mapping[str, Any] = field(default_factory=dict)
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class CatalogRequest:
58
+ """Request for target discovery."""
59
+
60
+ engine: str | None = None
61
+ language: str | None = None
62
+ offline: bool = False
63
+ refresh: bool = False
64
+ preference: str = "auto"
65
+
66
+
67
+ @dataclass(frozen=True, slots=True)
68
+ class CatalogResult:
69
+ """Result of target discovery."""
70
+
71
+ targets: tuple[SynthesisTarget, ...] = ()
72
+ registry_source: str | None = None
73
+ cache_fallback: bool = False
74
+ offline: bool = False
75
+ refreshed: bool = False
76
+
77
+
78
+ __all__ = [
79
+ "CatalogRequest",
80
+ "CatalogResult",
81
+ "SynthesisTarget",
82
+ "TargetVoice",
83
+ ]