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/capacity.py
ADDED
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
"""VoiceRender-owned request fitting, subdivision, and result merging."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Iterable, Mapping
|
|
7
|
+
from dataclasses import asdict, dataclass, field, is_dataclass
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Literal, cast
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from .engines.base import (
|
|
15
|
+
PronunciationSpan,
|
|
16
|
+
RenderedSpeech,
|
|
17
|
+
RequestMeasure,
|
|
18
|
+
SpeechRequest,
|
|
19
|
+
SpeechToken,
|
|
20
|
+
SpeechWordTiming,
|
|
21
|
+
validate_rendered_speech,
|
|
22
|
+
)
|
|
23
|
+
from .errors import EngineBackendError, SpeechRequestTooLongError
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class SemanticBoundary:
|
|
28
|
+
"""A planner-authorized request split position and its provenance."""
|
|
29
|
+
|
|
30
|
+
position: int
|
|
31
|
+
kind: str
|
|
32
|
+
semantic_boundary_id: str | None = None
|
|
33
|
+
origin: str | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
LOWERING_SCHEMA = "voicerender.atomic-lowering.v1"
|
|
37
|
+
CAPACITY_SCHEMA = "voicerender.capacity-fitting.v1"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _json_value(value: Any) -> Any:
|
|
41
|
+
if is_dataclass(value) and not isinstance(value, type):
|
|
42
|
+
return _json_value(asdict(cast(Any, value)))
|
|
43
|
+
if isinstance(value, Enum):
|
|
44
|
+
return _json_value(value.value)
|
|
45
|
+
if isinstance(value, Path):
|
|
46
|
+
return str(value)
|
|
47
|
+
if isinstance(value, Mapping):
|
|
48
|
+
return {str(key): _json_value(item) for key, item in value.items()}
|
|
49
|
+
if isinstance(value, (tuple, list)):
|
|
50
|
+
return [_json_value(item) for item in value]
|
|
51
|
+
if isinstance(value, bool | int | str) or value is None:
|
|
52
|
+
return value
|
|
53
|
+
if isinstance(value, float):
|
|
54
|
+
if math.isfinite(value):
|
|
55
|
+
return value
|
|
56
|
+
raise ValueError("capacity metadata must contain finite numbers")
|
|
57
|
+
if hasattr(value, "tolist"):
|
|
58
|
+
return _json_value(value.tolist())
|
|
59
|
+
raise TypeError(f"capacity metadata is not JSON-compatible: {type(value).__name__}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class CapacityContext:
|
|
64
|
+
"""VoiceRender-owned planning hints for request-capacity subdivision."""
|
|
65
|
+
|
|
66
|
+
semantic_boundaries: tuple[SemanticBoundary, ...] = ()
|
|
67
|
+
linguistic_token_ranges: tuple[tuple[int, int], ...] = ()
|
|
68
|
+
protected_ranges: tuple[tuple[int, int], ...] = ()
|
|
69
|
+
planned_semantics: bool = False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True, slots=True)
|
|
73
|
+
class CapacityBoundary:
|
|
74
|
+
"""One legal split candidate with priority and provenance."""
|
|
75
|
+
|
|
76
|
+
position: int
|
|
77
|
+
priority: int
|
|
78
|
+
kind: str
|
|
79
|
+
source: str
|
|
80
|
+
semantic_boundary_id: str | None = None
|
|
81
|
+
origin: str | None = None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True, slots=True)
|
|
85
|
+
class AtomicRequest:
|
|
86
|
+
"""One exact child request rendered for a semantic parent request."""
|
|
87
|
+
|
|
88
|
+
request: SpeechRequest
|
|
89
|
+
parent_id: str
|
|
90
|
+
index: int
|
|
91
|
+
char_start: int
|
|
92
|
+
char_end: int
|
|
93
|
+
measure: RequestMeasure | None = None
|
|
94
|
+
render: Mapping[str, Any] = field(default_factory=dict)
|
|
95
|
+
split: Mapping[str, Any] = field(default_factory=dict)
|
|
96
|
+
|
|
97
|
+
def to_dict(self) -> dict[str, Any]:
|
|
98
|
+
measure = self.measure
|
|
99
|
+
request = self.request
|
|
100
|
+
return {
|
|
101
|
+
"id": request.id,
|
|
102
|
+
"parent_id": self.parent_id,
|
|
103
|
+
"index": self.index,
|
|
104
|
+
"char_start": self.char_start,
|
|
105
|
+
"char_end": self.char_end,
|
|
106
|
+
"text": request.text,
|
|
107
|
+
"request": _json_value(
|
|
108
|
+
{
|
|
109
|
+
"id": request.id,
|
|
110
|
+
"text": request.text,
|
|
111
|
+
"language": request.language,
|
|
112
|
+
"voice": request.voice,
|
|
113
|
+
"speaker": request.speaker,
|
|
114
|
+
"pronunciation_overrides": request.pronunciation_overrides,
|
|
115
|
+
"tokens": request.tokens,
|
|
116
|
+
"whole_request_phonemes": request.whole_request_phonemes,
|
|
117
|
+
"options": request.options,
|
|
118
|
+
}
|
|
119
|
+
),
|
|
120
|
+
"measure": (
|
|
121
|
+
{
|
|
122
|
+
"fits": measure.fits,
|
|
123
|
+
"amount": measure.amount,
|
|
124
|
+
"maximum": measure.maximum,
|
|
125
|
+
"unit": measure.unit,
|
|
126
|
+
"source": measure.source,
|
|
127
|
+
"details": _json_value(measure.details),
|
|
128
|
+
}
|
|
129
|
+
if measure is not None
|
|
130
|
+
else None
|
|
131
|
+
),
|
|
132
|
+
"render": _json_value(self.render),
|
|
133
|
+
"split": _json_value(self.split),
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True, slots=True)
|
|
138
|
+
class AtomicRender:
|
|
139
|
+
"""Merged result plus the exact child requests used to produce it."""
|
|
140
|
+
|
|
141
|
+
result: RenderedSpeech
|
|
142
|
+
requests: tuple[AtomicRequest, ...]
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def manifest(self) -> dict[str, Any]:
|
|
146
|
+
return {
|
|
147
|
+
"schema": LOWERING_SCHEMA,
|
|
148
|
+
"capacity_schema": CAPACITY_SCHEMA,
|
|
149
|
+
"requests": [item.to_dict() for item in self.requests],
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass(frozen=True, slots=True)
|
|
154
|
+
class _RenderedChild:
|
|
155
|
+
request: SpeechRequest
|
|
156
|
+
start: int
|
|
157
|
+
end: int
|
|
158
|
+
measure: RequestMeasure
|
|
159
|
+
result: RenderedSpeech
|
|
160
|
+
split: Mapping[str, Any] = field(default_factory=dict)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def render_atomic_request(
|
|
164
|
+
session: Any,
|
|
165
|
+
request: SpeechRequest,
|
|
166
|
+
*,
|
|
167
|
+
capacity: CapacityContext | None = None,
|
|
168
|
+
protected_ranges: Iterable[tuple[int, int]] = (),
|
|
169
|
+
) -> AtomicRender:
|
|
170
|
+
"""Measure, fit, render, and merge one exact VoiceRender speech request."""
|
|
171
|
+
capacity_context = capacity if capacity is not None else CapacityContext()
|
|
172
|
+
protected = _collect_protected_ranges(
|
|
173
|
+
request,
|
|
174
|
+
(
|
|
175
|
+
*capacity_context.linguistic_token_ranges,
|
|
176
|
+
*capacity_context.protected_ranges,
|
|
177
|
+
*protected_ranges,
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
measured: dict[tuple[int, int], RequestMeasure] = {}
|
|
181
|
+
|
|
182
|
+
def child_request(start: int, end: int) -> SpeechRequest:
|
|
183
|
+
return _slice_request(request, start, end)
|
|
184
|
+
|
|
185
|
+
def measure_range(start: int, end: int) -> RequestMeasure:
|
|
186
|
+
key = (start, end)
|
|
187
|
+
if key in measured:
|
|
188
|
+
return measured[key]
|
|
189
|
+
candidate = child_request(start, end)
|
|
190
|
+
measure_method = getattr(session, "measure", None)
|
|
191
|
+
if not callable(measure_method):
|
|
192
|
+
result = RequestMeasure(
|
|
193
|
+
fits=None,
|
|
194
|
+
amount=None,
|
|
195
|
+
maximum=None,
|
|
196
|
+
unit="unknown",
|
|
197
|
+
source="measurement.unavailable",
|
|
198
|
+
)
|
|
199
|
+
else:
|
|
200
|
+
try:
|
|
201
|
+
result = measure_method(candidate)
|
|
202
|
+
except SpeechRequestTooLongError as exc:
|
|
203
|
+
result = _measure_from_error(exc, candidate, "measurement.too_long")
|
|
204
|
+
if not isinstance(result, RequestMeasure):
|
|
205
|
+
result = RequestMeasure(
|
|
206
|
+
fits=None,
|
|
207
|
+
amount=None,
|
|
208
|
+
maximum=None,
|
|
209
|
+
unit="unknown",
|
|
210
|
+
source="measurement.indeterminate",
|
|
211
|
+
)
|
|
212
|
+
measured[key] = result
|
|
213
|
+
return result
|
|
214
|
+
|
|
215
|
+
def render_range(
|
|
216
|
+
start: int,
|
|
217
|
+
end: int,
|
|
218
|
+
split_context: Mapping[str, Any] | None = None,
|
|
219
|
+
) -> list[_RenderedChild]:
|
|
220
|
+
candidate = child_request(start, end)
|
|
221
|
+
capacity_measure = measure_range(start, end)
|
|
222
|
+
if capacity_measure.fits is not False:
|
|
223
|
+
try:
|
|
224
|
+
result = session.synthesize(candidate)
|
|
225
|
+
except SpeechRequestTooLongError as exc:
|
|
226
|
+
capacity_measure = _measure_from_error(
|
|
227
|
+
exc, candidate, "synthesis.too_long"
|
|
228
|
+
)
|
|
229
|
+
measured[(start, end)] = capacity_measure
|
|
230
|
+
else:
|
|
231
|
+
validate_rendered_speech(candidate, result)
|
|
232
|
+
return [
|
|
233
|
+
_RenderedChild(
|
|
234
|
+
candidate,
|
|
235
|
+
start,
|
|
236
|
+
end,
|
|
237
|
+
capacity_measure,
|
|
238
|
+
result,
|
|
239
|
+
dict(split_context or {}),
|
|
240
|
+
)
|
|
241
|
+
]
|
|
242
|
+
if request.whole_request_phonemes is not None:
|
|
243
|
+
raise _capacity_error(
|
|
244
|
+
candidate,
|
|
245
|
+
capacity_measure,
|
|
246
|
+
"whole-request phonemes cannot be subdivided safely",
|
|
247
|
+
)
|
|
248
|
+
boundaries = _candidate_boundaries(
|
|
249
|
+
request,
|
|
250
|
+
start,
|
|
251
|
+
end,
|
|
252
|
+
protected,
|
|
253
|
+
capacity_context,
|
|
254
|
+
)
|
|
255
|
+
if not boundaries:
|
|
256
|
+
raise _capacity_error(
|
|
257
|
+
candidate, capacity_measure, "no legal source-text split boundary"
|
|
258
|
+
)
|
|
259
|
+
split_boundary = _choose_boundary(
|
|
260
|
+
request,
|
|
261
|
+
start,
|
|
262
|
+
end,
|
|
263
|
+
boundaries,
|
|
264
|
+
capacity_measure,
|
|
265
|
+
measure_range,
|
|
266
|
+
)
|
|
267
|
+
split_position = split_boundary.position
|
|
268
|
+
if split_position <= start or split_position >= end:
|
|
269
|
+
raise _capacity_error(
|
|
270
|
+
candidate, capacity_measure, "no legal source-text split boundary"
|
|
271
|
+
)
|
|
272
|
+
split_provenance = _split_provenance(split_boundary)
|
|
273
|
+
return render_range(start, split_position, split_provenance) + render_range(
|
|
274
|
+
split_position,
|
|
275
|
+
end,
|
|
276
|
+
split_provenance,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
children = render_range(0, len(request.text))
|
|
280
|
+
if "".join(child.request.text for child in children) != request.text:
|
|
281
|
+
raise AssertionError("capacity fitting did not preserve exact request text")
|
|
282
|
+
|
|
283
|
+
atomic_requests = tuple(
|
|
284
|
+
AtomicRequest(
|
|
285
|
+
request=child.request,
|
|
286
|
+
parent_id=request.id,
|
|
287
|
+
index=index,
|
|
288
|
+
char_start=child.start,
|
|
289
|
+
char_end=child.end,
|
|
290
|
+
measure=child.measure,
|
|
291
|
+
render={
|
|
292
|
+
"sample_rate": child.result.sample_rate,
|
|
293
|
+
"frames": int(np.asarray(child.result.audio).size),
|
|
294
|
+
"warnings": list(child.result.warnings),
|
|
295
|
+
"metadata": _json_value(child.result.metadata),
|
|
296
|
+
},
|
|
297
|
+
split=child.split,
|
|
298
|
+
)
|
|
299
|
+
for index, child in enumerate(children)
|
|
300
|
+
)
|
|
301
|
+
result = _merge_results(request, children, atomic_requests)
|
|
302
|
+
validate_rendered_speech(request, result)
|
|
303
|
+
return AtomicRender(result=result, requests=atomic_requests)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _collect_protected_ranges(
|
|
307
|
+
request: SpeechRequest,
|
|
308
|
+
extra: Iterable[tuple[int, int]],
|
|
309
|
+
) -> tuple[tuple[int, int], ...]:
|
|
310
|
+
ranges = [(token.start, token.end) for token in request.tokens]
|
|
311
|
+
ranges.extend((span.start, span.end) for span in request.pronunciation_overrides)
|
|
312
|
+
ranges.extend(extra)
|
|
313
|
+
for start, end in ranges:
|
|
314
|
+
if start < 0 or start >= end or end > len(request.text):
|
|
315
|
+
raise ValueError(
|
|
316
|
+
"protected request range is outside the exact request text"
|
|
317
|
+
)
|
|
318
|
+
return tuple(sorted(set(ranges)))
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _slice_request(request: SpeechRequest, start: int, end: int) -> SpeechRequest:
|
|
322
|
+
text = request.text[start:end]
|
|
323
|
+
if start == 0 and end == len(request.text):
|
|
324
|
+
child_id = request.id
|
|
325
|
+
else:
|
|
326
|
+
child_id = f"{request.id}.part-{start:06d}-{end:06d}"
|
|
327
|
+
tokens = tuple(
|
|
328
|
+
SpeechToken(
|
|
329
|
+
start=token.start - start,
|
|
330
|
+
end=token.end - start,
|
|
331
|
+
text=token.text,
|
|
332
|
+
pos=token.pos,
|
|
333
|
+
tag=token.tag,
|
|
334
|
+
lemma=token.lemma,
|
|
335
|
+
morph=token.morph,
|
|
336
|
+
language=token.language,
|
|
337
|
+
)
|
|
338
|
+
for token in request.tokens
|
|
339
|
+
if token.start >= start and token.end <= end
|
|
340
|
+
)
|
|
341
|
+
overrides = tuple(
|
|
342
|
+
PronunciationSpan(
|
|
343
|
+
start=span.start - start,
|
|
344
|
+
end=span.end - start,
|
|
345
|
+
phonemes=span.phonemes,
|
|
346
|
+
language=span.language,
|
|
347
|
+
alphabet=span.alphabet,
|
|
348
|
+
)
|
|
349
|
+
for span in request.pronunciation_overrides
|
|
350
|
+
if span.start >= start and span.end <= end
|
|
351
|
+
)
|
|
352
|
+
return SpeechRequest(
|
|
353
|
+
id=child_id,
|
|
354
|
+
text=text,
|
|
355
|
+
language=request.language,
|
|
356
|
+
voice=request.voice,
|
|
357
|
+
speaker=request.speaker,
|
|
358
|
+
pronunciation_overrides=overrides,
|
|
359
|
+
tokens=tokens,
|
|
360
|
+
whole_request_phonemes=request.whole_request_phonemes,
|
|
361
|
+
options=dict(request.options),
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _candidate_boundaries(
|
|
366
|
+
request: SpeechRequest,
|
|
367
|
+
start: int,
|
|
368
|
+
end: int,
|
|
369
|
+
protected: tuple[tuple[int, int], ...],
|
|
370
|
+
capacity: CapacityContext,
|
|
371
|
+
) -> tuple[CapacityBoundary, ...]:
|
|
372
|
+
text = request.text
|
|
373
|
+
candidates: dict[int, CapacityBoundary] = {}
|
|
374
|
+
planned = capacity.planned_semantics
|
|
375
|
+
|
|
376
|
+
def add(
|
|
377
|
+
position: int,
|
|
378
|
+
priority: int,
|
|
379
|
+
kind: str,
|
|
380
|
+
source: str = "voicerender.heuristic",
|
|
381
|
+
*,
|
|
382
|
+
semantic_boundary_id: str | None = None,
|
|
383
|
+
origin: str | None = None,
|
|
384
|
+
) -> None:
|
|
385
|
+
if not start < position < end:
|
|
386
|
+
return
|
|
387
|
+
if any(left < position < right for left, right in protected):
|
|
388
|
+
return
|
|
389
|
+
if not text[start:position].strip() or not text[position:end].strip():
|
|
390
|
+
return
|
|
391
|
+
candidate = CapacityBoundary(
|
|
392
|
+
position=position,
|
|
393
|
+
priority=priority,
|
|
394
|
+
kind=kind,
|
|
395
|
+
source=source,
|
|
396
|
+
semantic_boundary_id=semantic_boundary_id,
|
|
397
|
+
origin=origin,
|
|
398
|
+
)
|
|
399
|
+
existing = candidates.get(position)
|
|
400
|
+
if existing is None or _prefer_candidate(candidate, existing):
|
|
401
|
+
candidates[position] = candidate
|
|
402
|
+
|
|
403
|
+
if planned:
|
|
404
|
+
semantic_priorities = {"clause": 0, "parenthetical": 1}
|
|
405
|
+
for boundary in capacity.semantic_boundaries:
|
|
406
|
+
priority = semantic_priorities.get(boundary.kind)
|
|
407
|
+
if priority is None:
|
|
408
|
+
continue
|
|
409
|
+
add(
|
|
410
|
+
boundary.position,
|
|
411
|
+
priority,
|
|
412
|
+
f"semantic_{boundary.kind}",
|
|
413
|
+
"utterplan",
|
|
414
|
+
semantic_boundary_id=boundary.semantic_boundary_id,
|
|
415
|
+
origin=boundary.origin,
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
index = start
|
|
419
|
+
while index < end:
|
|
420
|
+
if text[index] in "\r\n":
|
|
421
|
+
position = index + 1
|
|
422
|
+
while position < end and text[position] in "\r\n":
|
|
423
|
+
position += 1
|
|
424
|
+
add(position, 2 if planned else 0, "newline")
|
|
425
|
+
index = position
|
|
426
|
+
continue
|
|
427
|
+
index += 1
|
|
428
|
+
|
|
429
|
+
if not planned:
|
|
430
|
+
sentence_marks = ".!?。!?"
|
|
431
|
+
closing = "\"'’”»)]}"
|
|
432
|
+
index = start
|
|
433
|
+
while index < end:
|
|
434
|
+
if text[index] in sentence_marks:
|
|
435
|
+
position = index + 1
|
|
436
|
+
while position < end and text[position] in sentence_marks + closing:
|
|
437
|
+
position += 1
|
|
438
|
+
while position < end and text[position] in " \t":
|
|
439
|
+
position += 1
|
|
440
|
+
add(position, 1, "sentence_punctuation")
|
|
441
|
+
index += 1
|
|
442
|
+
|
|
443
|
+
clause_marks = ";:,,;:、"
|
|
444
|
+
index = start
|
|
445
|
+
while index < end:
|
|
446
|
+
if text[index] in clause_marks:
|
|
447
|
+
position = index + 1
|
|
448
|
+
while position < end and text[position] in " \t":
|
|
449
|
+
position += 1
|
|
450
|
+
add(
|
|
451
|
+
position,
|
|
452
|
+
3 if planned else 2,
|
|
453
|
+
"punctuation_clause",
|
|
454
|
+
)
|
|
455
|
+
index += 1
|
|
456
|
+
|
|
457
|
+
token_priority = 4 if planned else 3
|
|
458
|
+
for token_start, token_end in capacity.linguistic_token_ranges:
|
|
459
|
+
add(token_start, token_priority, "token_edge", "utterplan")
|
|
460
|
+
add(token_end, token_priority, "token_edge", "utterplan")
|
|
461
|
+
for token in request.tokens:
|
|
462
|
+
add(token.start, token_priority, "token_edge", "request.tokens")
|
|
463
|
+
add(token.end, token_priority, "token_edge", "request.tokens")
|
|
464
|
+
|
|
465
|
+
index = start
|
|
466
|
+
while index < end:
|
|
467
|
+
if text[index].isspace():
|
|
468
|
+
position = index + 1
|
|
469
|
+
while position < end and text[position].isspace():
|
|
470
|
+
position += 1
|
|
471
|
+
add(position, 5 if planned else 4, "whitespace")
|
|
472
|
+
index = position
|
|
473
|
+
continue
|
|
474
|
+
index += 1
|
|
475
|
+
|
|
476
|
+
if not planned:
|
|
477
|
+
for position in range(start + 1, end):
|
|
478
|
+
add(position, 5, "character")
|
|
479
|
+
return tuple(
|
|
480
|
+
sorted(
|
|
481
|
+
candidates.values(),
|
|
482
|
+
key=lambda candidate: (
|
|
483
|
+
candidate.priority,
|
|
484
|
+
candidate.position,
|
|
485
|
+
candidate.kind,
|
|
486
|
+
),
|
|
487
|
+
)
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _prefer_candidate(candidate: CapacityBoundary, current: CapacityBoundary) -> bool:
|
|
492
|
+
if candidate.priority != current.priority:
|
|
493
|
+
return candidate.priority < current.priority
|
|
494
|
+
if candidate.source == "utterplan" and current.source != "utterplan":
|
|
495
|
+
return True
|
|
496
|
+
if current.source == "utterplan" and candidate.source != "utterplan":
|
|
497
|
+
return False
|
|
498
|
+
return (candidate.kind, candidate.semantic_boundary_id or "") < (
|
|
499
|
+
current.kind,
|
|
500
|
+
current.semantic_boundary_id or "",
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _choose_boundary(
|
|
505
|
+
request: SpeechRequest,
|
|
506
|
+
start: int,
|
|
507
|
+
end: int,
|
|
508
|
+
boundaries: tuple[CapacityBoundary, ...],
|
|
509
|
+
parent_measure: RequestMeasure,
|
|
510
|
+
measure_range: Any,
|
|
511
|
+
) -> CapacityBoundary:
|
|
512
|
+
groups: dict[int, list[CapacityBoundary]] = {}
|
|
513
|
+
for boundary in boundaries:
|
|
514
|
+
groups.setdefault(boundary.priority, []).append(boundary)
|
|
515
|
+
ordered_groups = [
|
|
516
|
+
sorted(groups[priority], key=lambda boundary: boundary.position)
|
|
517
|
+
for priority in sorted(groups)
|
|
518
|
+
]
|
|
519
|
+
fallback = min(
|
|
520
|
+
ordered_groups[0],
|
|
521
|
+
key=lambda boundary: (
|
|
522
|
+
abs(boundary.position - (start + end) / 2),
|
|
523
|
+
boundary.position,
|
|
524
|
+
),
|
|
525
|
+
)
|
|
526
|
+
if parent_measure.fits is None:
|
|
527
|
+
return fallback
|
|
528
|
+
|
|
529
|
+
for boundaries_at_priority in ordered_groups:
|
|
530
|
+
low = 0
|
|
531
|
+
high = len(boundaries_at_priority) - 1
|
|
532
|
+
while low <= high:
|
|
533
|
+
middle = (low + high) // 2
|
|
534
|
+
boundary = boundaries_at_priority[middle]
|
|
535
|
+
left = measure_range(start, boundary.position)
|
|
536
|
+
right = measure_range(boundary.position, end)
|
|
537
|
+
if left.fits is True and right.fits is True:
|
|
538
|
+
return boundary
|
|
539
|
+
if left.fits is None or right.fits is None:
|
|
540
|
+
break
|
|
541
|
+
if left.fits is False and right.fits is True:
|
|
542
|
+
high = middle - 1
|
|
543
|
+
elif left.fits is True and right.fits is False:
|
|
544
|
+
low = middle + 1
|
|
545
|
+
else:
|
|
546
|
+
break
|
|
547
|
+
return fallback
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _split_provenance(boundary: CapacityBoundary) -> dict[str, Any]:
|
|
551
|
+
provenance: dict[str, Any] = {
|
|
552
|
+
"reason": "model_capacity",
|
|
553
|
+
"boundary_kind": boundary.kind,
|
|
554
|
+
"source": boundary.source,
|
|
555
|
+
}
|
|
556
|
+
if boundary.semantic_boundary_id is not None:
|
|
557
|
+
provenance["semantic_boundary_id"] = boundary.semantic_boundary_id
|
|
558
|
+
if boundary.origin is not None:
|
|
559
|
+
provenance["origin"] = boundary.origin
|
|
560
|
+
return provenance
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _measure_from_error(
|
|
564
|
+
error: SpeechRequestTooLongError,
|
|
565
|
+
request: SpeechRequest,
|
|
566
|
+
source: str,
|
|
567
|
+
) -> RequestMeasure:
|
|
568
|
+
if error.unit == "model_tokens":
|
|
569
|
+
unit: Literal["model_tokens", "phoneme_ids", "unknown"] = "model_tokens"
|
|
570
|
+
elif error.unit == "phoneme_ids":
|
|
571
|
+
unit = "phoneme_ids"
|
|
572
|
+
else:
|
|
573
|
+
unit = "unknown"
|
|
574
|
+
return RequestMeasure(
|
|
575
|
+
fits=False,
|
|
576
|
+
amount=error.amount,
|
|
577
|
+
maximum=error.maximum,
|
|
578
|
+
unit=unit,
|
|
579
|
+
source=source,
|
|
580
|
+
details={
|
|
581
|
+
"native_error_type": getattr(error, "native_error_type", None),
|
|
582
|
+
"request_id": request.id,
|
|
583
|
+
"text_length": len(request.text),
|
|
584
|
+
},
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _capacity_error(
|
|
589
|
+
request: SpeechRequest,
|
|
590
|
+
measure: RequestMeasure,
|
|
591
|
+
reason: str,
|
|
592
|
+
) -> SpeechRequestTooLongError:
|
|
593
|
+
return SpeechRequestTooLongError(
|
|
594
|
+
f"VoiceRender cannot fit request {request.id!r}: {reason}.",
|
|
595
|
+
language=request.language,
|
|
596
|
+
voice=request.voice,
|
|
597
|
+
speaker=request.speaker,
|
|
598
|
+
request_id=request.id,
|
|
599
|
+
amount=measure.amount,
|
|
600
|
+
maximum=measure.maximum,
|
|
601
|
+
unit=measure.unit,
|
|
602
|
+
text_length=len(request.text),
|
|
603
|
+
source="voicerender.capacity",
|
|
604
|
+
details={
|
|
605
|
+
"capacity_source": measure.source,
|
|
606
|
+
"capacity_reason": reason,
|
|
607
|
+
},
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _merge_results(
|
|
612
|
+
request: SpeechRequest,
|
|
613
|
+
children: list[_RenderedChild],
|
|
614
|
+
atomic_requests: tuple[AtomicRequest, ...],
|
|
615
|
+
) -> RenderedSpeech:
|
|
616
|
+
sample_rate = children[0].result.sample_rate
|
|
617
|
+
arrays: list[np.ndarray] = []
|
|
618
|
+
timings: list[SpeechWordTiming] = []
|
|
619
|
+
warnings: list[str] = []
|
|
620
|
+
sample_offset = 0
|
|
621
|
+
for child in children:
|
|
622
|
+
result = child.result
|
|
623
|
+
if result.sample_rate != sample_rate:
|
|
624
|
+
raise EngineBackendError(
|
|
625
|
+
"Atomic request children returned different sample rates.",
|
|
626
|
+
language=request.language,
|
|
627
|
+
voice=request.voice,
|
|
628
|
+
speaker=request.speaker,
|
|
629
|
+
request_id=request.id,
|
|
630
|
+
details={
|
|
631
|
+
"expected_sample_rate": sample_rate,
|
|
632
|
+
"actual_sample_rate": result.sample_rate,
|
|
633
|
+
"child_request_id": child.request.id,
|
|
634
|
+
},
|
|
635
|
+
)
|
|
636
|
+
audio = np.asarray(result.audio, dtype=np.float32)
|
|
637
|
+
arrays.append(audio)
|
|
638
|
+
timings.extend(
|
|
639
|
+
SpeechWordTiming(
|
|
640
|
+
text=timing.text,
|
|
641
|
+
char_start=child.start + timing.char_start,
|
|
642
|
+
char_end=child.start + timing.char_end,
|
|
643
|
+
start_sample=sample_offset + timing.start_sample,
|
|
644
|
+
end_sample=sample_offset + timing.end_sample,
|
|
645
|
+
)
|
|
646
|
+
for timing in result.word_timings
|
|
647
|
+
)
|
|
648
|
+
for warning in result.warnings:
|
|
649
|
+
if warning not in warnings:
|
|
650
|
+
warnings.append(warning)
|
|
651
|
+
sample_offset += int(audio.size)
|
|
652
|
+
|
|
653
|
+
metadata = dict(children[0].result.metadata)
|
|
654
|
+
metadata["atomic_lowering"] = {
|
|
655
|
+
"schema": LOWERING_SCHEMA,
|
|
656
|
+
"capacity_schema": CAPACITY_SCHEMA,
|
|
657
|
+
"requests": [item.to_dict() for item in atomic_requests],
|
|
658
|
+
}
|
|
659
|
+
return RenderedSpeech(
|
|
660
|
+
id=request.id,
|
|
661
|
+
audio=np.concatenate(arrays).astype(np.float32, copy=False),
|
|
662
|
+
sample_rate=sample_rate,
|
|
663
|
+
warnings=tuple(warnings),
|
|
664
|
+
word_timings=tuple(timings),
|
|
665
|
+
metadata=metadata,
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
__all__ = [
|
|
670
|
+
"CAPACITY_SCHEMA",
|
|
671
|
+
"LOWERING_SCHEMA",
|
|
672
|
+
"AtomicRender",
|
|
673
|
+
"AtomicRequest",
|
|
674
|
+
"CapacityBoundary",
|
|
675
|
+
"CapacityContext",
|
|
676
|
+
"SemanticBoundary",
|
|
677
|
+
"render_atomic_request",
|
|
678
|
+
]
|