utterplan 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.
- utterplan/__init__.py +81 -0
- utterplan/__main__.py +4 -0
- utterplan/_version.py +24 -0
- utterplan/cli.py +271 -0
- utterplan/config.py +161 -0
- utterplan/diagnostics.py +3 -0
- utterplan/directives.py +82 -0
- utterplan/exceptions.py +47 -0
- utterplan/format.py +34 -0
- utterplan/hashing.py +43 -0
- utterplan/language.py +93 -0
- utterplan/linguistics.py +179 -0
- utterplan/model.py +931 -0
- utterplan/parsers.py +272 -0
- utterplan/pauses.py +66 -0
- utterplan/planner.py +474 -0
- utterplan/preparation.py +224 -0
- utterplan/py.typed +0 -0
- utterplan/serialization.py +22 -0
- utterplan/spacy_models.py +21 -0
- utterplan/spans.py +3 -0
- utterplan/stages/__init__.py +0 -0
- utterplan/stages/document/__init__.py +0 -0
- utterplan/stages/document/plain.py +4 -0
- utterplan/stages/document/ssmd.py +3 -0
- utterplan/stages/preparation/__init__.py +0 -0
- utterplan/stages/preparation/identity.py +3 -0
- utterplan/stages/preparation/spokenform.py +3 -0
- utterplan/stages/protocols.py +25 -0
- utterplan/stages/segmentation/__init__.py +0 -0
- utterplan/stages/segmentation/phrasplit.py +35 -0
- utterplan/units.py +81 -0
- utterplan/utterplan.schema.json +260 -0
- utterplan-0.1.1.dist-info/METADATA +348 -0
- utterplan-0.1.1.dist-info/RECORD +40 -0
- utterplan-0.1.1.dist-info/WHEEL +5 -0
- utterplan-0.1.1.dist-info/entry_points.txt +2 -0
- utterplan-0.1.1.dist-info/licenses/LICENSE +201 -0
- utterplan-0.1.1.dist-info/licenses/NOTICE +1 -0
- utterplan-0.1.1.dist-info/top_level.txt +1 -0
utterplan/planner.py
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, replace
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .config import PauseConfig, PlannerConfig, parse_duration
|
|
7
|
+
from .directives import resolve_directives
|
|
8
|
+
from .exceptions import ConfigurationError, PlanningError
|
|
9
|
+
from .language import build_language_runs, spans_from_annotations
|
|
10
|
+
from .linguistics import LinguisticResourcePool, analyze_run_analyses
|
|
11
|
+
from .model import (
|
|
12
|
+
AnnotationSpan,
|
|
13
|
+
BoundaryEvent,
|
|
14
|
+
Diagnostic,
|
|
15
|
+
Marker,
|
|
16
|
+
PlanSegment,
|
|
17
|
+
PlanSource,
|
|
18
|
+
PlanTexts,
|
|
19
|
+
UtterancePlan,
|
|
20
|
+
)
|
|
21
|
+
from .parsers import PlainDocumentParser, SSMDDocumentParser
|
|
22
|
+
from .pauses import boundary_is_active, resolve_pauses
|
|
23
|
+
from .preparation import IdentityTextPreparer, SourceToSpokenMap, SpokenformTextPreparer
|
|
24
|
+
from .units import make_units
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class UtterancePlanner:
|
|
28
|
+
"""Reusable planner for sequential requests.
|
|
29
|
+
|
|
30
|
+
Linguistic resource caches are shared, but request configuration and all
|
|
31
|
+
intermediate state are local to :meth:`plan`. Concurrent use is not
|
|
32
|
+
promised; callers should use one planner per thread or synchronize access.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, config: PlannerConfig) -> None:
|
|
36
|
+
self.config = config
|
|
37
|
+
self._resources = LinguisticResourcePool()
|
|
38
|
+
|
|
39
|
+
def plan(
|
|
40
|
+
self, text: str, *, config: PlannerConfig | None = None, unit: str | None = None
|
|
41
|
+
) -> UtterancePlan:
|
|
42
|
+
if not isinstance(text, str):
|
|
43
|
+
raise TypeError("text must be a string")
|
|
44
|
+
effective_config = config if config is not None else self.config
|
|
45
|
+
if unit is not None:
|
|
46
|
+
if unit not in {"paragraph", "sentence"}:
|
|
47
|
+
raise ConfigurationError("unit must be 'paragraph' or 'sentence'")
|
|
48
|
+
selected_unit = unit
|
|
49
|
+
else:
|
|
50
|
+
selected_unit = effective_config.unit
|
|
51
|
+
config = effective_config
|
|
52
|
+
parsed = self._parse(text, config)
|
|
53
|
+
pause_config = _effective_pause_config(config, parsed.header)
|
|
54
|
+
source_runs = build_language_runs(
|
|
55
|
+
parsed.structural_text,
|
|
56
|
+
spans_from_annotations(parsed.annotations),
|
|
57
|
+
config.language,
|
|
58
|
+
dict(config.language_aliases),
|
|
59
|
+
)
|
|
60
|
+
pass_a = analyze_run_analyses(
|
|
61
|
+
parsed.structural_text, source_runs, config.linguistics, self._resources
|
|
62
|
+
)
|
|
63
|
+
preparer = (
|
|
64
|
+
IdentityTextPreparer()
|
|
65
|
+
if config.text_preparation == "identity"
|
|
66
|
+
else SpokenformTextPreparer()
|
|
67
|
+
)
|
|
68
|
+
prepared = preparer.prepare(
|
|
69
|
+
parsed.structural_text,
|
|
70
|
+
config.language,
|
|
71
|
+
source_runs,
|
|
72
|
+
parsed.annotations,
|
|
73
|
+
parsed.boundaries,
|
|
74
|
+
analyses=pass_a,
|
|
75
|
+
)
|
|
76
|
+
spoken = prepared.spoken_text
|
|
77
|
+
runs = build_language_runs(
|
|
78
|
+
spoken,
|
|
79
|
+
spans_from_annotations(prepared.annotations),
|
|
80
|
+
config.language,
|
|
81
|
+
dict(config.language_aliases),
|
|
82
|
+
)
|
|
83
|
+
pass_b = analyze_run_analyses(spoken, runs, config.linguistics, self._resources)
|
|
84
|
+
tokens = tuple(token for analysis in pass_b for token in analysis.tokens)
|
|
85
|
+
boundaries = list(prepared.boundaries)
|
|
86
|
+
boundaries.extend(_linguistic_boundaries(spoken, runs, config, start_id=len(boundaries)))
|
|
87
|
+
segments = _segment(
|
|
88
|
+
spoken, runs, prepared.annotations, boundaries, config, pause_config, pass_b
|
|
89
|
+
)
|
|
90
|
+
segments = _attach_membership(segments, tokens, prepared.annotations)
|
|
91
|
+
segments = [resolve_directives(segment, prepared.annotations) for segment in segments]
|
|
92
|
+
boundaries.extend(_derived_boundaries(segments, boundaries, pause_config))
|
|
93
|
+
segments = resolve_pauses(segments, boundaries, pause_config)
|
|
94
|
+
markers = tuple(_map_marker(marker, prepared.source_map) for marker in parsed.markers)
|
|
95
|
+
segment_tuple = tuple(segments)
|
|
96
|
+
units = make_units(segment_tuple, markers, selected_unit)
|
|
97
|
+
metadata = dict(parsed.metadata)
|
|
98
|
+
metadata["planning"] = {
|
|
99
|
+
"linguistic_passes": 2,
|
|
100
|
+
"engine_independent": True,
|
|
101
|
+
"pass_a_tokens": sum(len(run.tokens) for run in pass_a),
|
|
102
|
+
}
|
|
103
|
+
diagnostics: tuple[Diagnostic, ...] = ()
|
|
104
|
+
if config.diagnostics:
|
|
105
|
+
diagnostics = (
|
|
106
|
+
Diagnostic(
|
|
107
|
+
"planning.complete", "Plan compiled without renderer or audio processing"
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
plan_config = _config_dict(config)
|
|
111
|
+
plan_config["unit"] = selected_unit
|
|
112
|
+
plan = UtterancePlan(
|
|
113
|
+
source=PlanSource(parsed.source_text and config.document_format or "plain", text),
|
|
114
|
+
config=plan_config,
|
|
115
|
+
texts=PlanTexts(parsed.structural_text, spoken),
|
|
116
|
+
preparation=prepared.info,
|
|
117
|
+
languages=runs,
|
|
118
|
+
annotations=prepared.annotations,
|
|
119
|
+
boundaries=tuple(boundaries),
|
|
120
|
+
tokens=tokens,
|
|
121
|
+
segments=segment_tuple,
|
|
122
|
+
units=units,
|
|
123
|
+
markers=markers,
|
|
124
|
+
document_metadata=metadata,
|
|
125
|
+
warnings=tuple(parsed.warnings) + prepared.info.warnings,
|
|
126
|
+
diagnostics=diagnostics,
|
|
127
|
+
).with_identity()
|
|
128
|
+
plan.validate()
|
|
129
|
+
return plan
|
|
130
|
+
|
|
131
|
+
def _parse(self, text: str, config: PlannerConfig) -> Any:
|
|
132
|
+
if config.document_format == "plain":
|
|
133
|
+
return PlainDocumentParser().parse(text, config)
|
|
134
|
+
if config.document_format == "ssmd":
|
|
135
|
+
return SSMDDocumentParser().parse(text, config)
|
|
136
|
+
raise PlanningError(f"unsupported document format {config.document_format!r}")
|
|
137
|
+
|
|
138
|
+
def close(self) -> None:
|
|
139
|
+
self._resources.clear()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _effective_pause_config(config: PlannerConfig, header: dict[str, Any]) -> PauseConfig:
|
|
143
|
+
values: dict[str, Any] = {"mode": config.pauses.mode}
|
|
144
|
+
values.update(
|
|
145
|
+
{
|
|
146
|
+
name: getattr(config.pauses, name)
|
|
147
|
+
for name in (
|
|
148
|
+
"weak",
|
|
149
|
+
"clause",
|
|
150
|
+
"sentence",
|
|
151
|
+
"paragraph",
|
|
152
|
+
"parenthetical",
|
|
153
|
+
"voice_change",
|
|
154
|
+
"enabled",
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
)
|
|
158
|
+
header_defaults = header.get("pause_defaults") if isinstance(header, dict) else None
|
|
159
|
+
if isinstance(header_defaults, dict):
|
|
160
|
+
for name, value in header_defaults.items():
|
|
161
|
+
if name == "enabled" and isinstance(value, bool):
|
|
162
|
+
values[name] = value
|
|
163
|
+
elif name != "enabled":
|
|
164
|
+
try:
|
|
165
|
+
values[name] = parse_duration(value, field_name=f"pause_defaults.{name}")
|
|
166
|
+
except ConfigurationError:
|
|
167
|
+
continue
|
|
168
|
+
if config.ssmd.pause_defaults:
|
|
169
|
+
for name, value in config.ssmd.pause_defaults.items():
|
|
170
|
+
values[name] = (
|
|
171
|
+
bool(value)
|
|
172
|
+
if name == "enabled"
|
|
173
|
+
else parse_duration(value, field_name=f"ssmd.pause_defaults.{name}")
|
|
174
|
+
)
|
|
175
|
+
return PauseConfig(**values)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _config_dict(config: PlannerConfig) -> dict[str, Any]:
|
|
179
|
+
result = asdict(config)
|
|
180
|
+
result["language_aliases"] = dict(config.language_aliases)
|
|
181
|
+
if result.get("ssmd", {}).get("pause_defaults") is not None:
|
|
182
|
+
result["ssmd"]["pause_defaults"] = dict(config.ssmd.pause_defaults or {})
|
|
183
|
+
return result
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _segment(
|
|
187
|
+
text: str,
|
|
188
|
+
runs: tuple[Any, ...],
|
|
189
|
+
annotations: tuple[AnnotationSpan, ...],
|
|
190
|
+
boundaries: list[BoundaryEvent],
|
|
191
|
+
config: PlannerConfig,
|
|
192
|
+
pause_config: PauseConfig,
|
|
193
|
+
analyses: tuple[Any, ...] = (),
|
|
194
|
+
) -> list[PlanSegment]:
|
|
195
|
+
if not text:
|
|
196
|
+
return []
|
|
197
|
+
result: list[PlanSegment] = []
|
|
198
|
+
for run_index, run in enumerate(runs):
|
|
199
|
+
local_text = text[run.spoken_start : run.spoken_end]
|
|
200
|
+
analysis = analyses[run_index] if run_index < len(analyses) else None
|
|
201
|
+
for item in _split_run(local_text, run.language, config, analysis):
|
|
202
|
+
local_start = int(getattr(item, "char_start", 0))
|
|
203
|
+
local_end = int(getattr(item, "char_end", len(local_text)))
|
|
204
|
+
start = run.spoken_start + local_start
|
|
205
|
+
end = run.spoken_start + local_end
|
|
206
|
+
if end <= start or not text[start:end].strip():
|
|
207
|
+
continue
|
|
208
|
+
paragraph = int(getattr(item, "paragraph_idx", 0) or 0)
|
|
209
|
+
sentence = int(getattr(item, "sentence_idx", 0) or 0)
|
|
210
|
+
clause = int(getattr(item, "clause_idx", 0) or 0)
|
|
211
|
+
cuts = {start, end}
|
|
212
|
+
cuts.update(
|
|
213
|
+
boundary.position
|
|
214
|
+
for boundary in boundaries
|
|
215
|
+
if start < boundary.position < end and boundary_is_active(boundary, pause_config)
|
|
216
|
+
)
|
|
217
|
+
cuts.update(
|
|
218
|
+
point
|
|
219
|
+
for annotation in annotations
|
|
220
|
+
for point in (annotation.spoken_start, annotation.spoken_end)
|
|
221
|
+
if point is not None and start < point < end and _semantic_annotation(annotation)
|
|
222
|
+
)
|
|
223
|
+
ordered = sorted(cuts)
|
|
224
|
+
for part_start, part_end in zip(ordered, ordered[1:], strict=False):
|
|
225
|
+
if part_end <= part_start or not text[part_start:part_end].strip():
|
|
226
|
+
continue
|
|
227
|
+
result.append(
|
|
228
|
+
PlanSegment(
|
|
229
|
+
id=f"seg-{len(result):06d}",
|
|
230
|
+
text=text[part_start:part_end],
|
|
231
|
+
spoken_start=part_start,
|
|
232
|
+
spoken_end=part_end,
|
|
233
|
+
language=run.language,
|
|
234
|
+
paragraph=paragraph,
|
|
235
|
+
sentence=sentence,
|
|
236
|
+
clause=clause,
|
|
237
|
+
structural_start=None,
|
|
238
|
+
structural_end=None,
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
return result
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _split_run(
|
|
245
|
+
text: str, language: str, config: PlannerConfig, analysis: Any | None = None
|
|
246
|
+
) -> list[Any]:
|
|
247
|
+
try:
|
|
248
|
+
import phrasplit
|
|
249
|
+
|
|
250
|
+
kwargs: dict[str, Any] = {"mode": "sentence", "use_spacy": False, "language": language}
|
|
251
|
+
if analysis is not None and analysis.provider_doc is not None:
|
|
252
|
+
kwargs["nlp"] = analysis.provider_doc
|
|
253
|
+
items = phrasplit.split_with_offsets(text, **kwargs)
|
|
254
|
+
except (ImportError, OSError, TypeError, ValueError):
|
|
255
|
+
return [_FallbackSplit(0, len(text), 0, 0)] if text else []
|
|
256
|
+
valid: list[Any] = []
|
|
257
|
+
previous = 0
|
|
258
|
+
for item in items:
|
|
259
|
+
start = int(getattr(item, "char_start", -1))
|
|
260
|
+
end = int(getattr(item, "char_end", -1))
|
|
261
|
+
if (
|
|
262
|
+
0 <= start < end <= len(text)
|
|
263
|
+
and text[start:end] == str(getattr(item, "text", text[start:end]))
|
|
264
|
+
and start >= previous
|
|
265
|
+
):
|
|
266
|
+
valid.append(item)
|
|
267
|
+
previous = end
|
|
268
|
+
if not valid and text:
|
|
269
|
+
return [_FallbackSplit(0, len(text), 0, 0, text)]
|
|
270
|
+
if any(
|
|
271
|
+
text[left.char_end : right.char_start].strip()
|
|
272
|
+
for left, right in zip(valid, valid[1:], strict=False)
|
|
273
|
+
):
|
|
274
|
+
return [_FallbackSplit(0, len(text), 0, 0, text)]
|
|
275
|
+
return _repair_quote_boundaries(valid, text)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _repair_quote_boundaries(items: list[Any], text: str) -> list[Any]:
|
|
279
|
+
repaired: list[Any] = []
|
|
280
|
+
closing = "\"'”’)]}"
|
|
281
|
+
for item in items:
|
|
282
|
+
current = _FallbackSplit(
|
|
283
|
+
int(item.char_start),
|
|
284
|
+
int(item.char_end),
|
|
285
|
+
int(getattr(item, "paragraph_idx", 0) or 0),
|
|
286
|
+
int(getattr(item, "sentence_idx", 0) or 0),
|
|
287
|
+
text[int(item.char_start) : int(item.char_end)],
|
|
288
|
+
)
|
|
289
|
+
if (
|
|
290
|
+
repaired
|
|
291
|
+
and repaired[-1].text.rstrip().endswith((".", "!", "?"))
|
|
292
|
+
and current.text.lstrip().startswith(tuple(closing))
|
|
293
|
+
):
|
|
294
|
+
previous = repaired[-1]
|
|
295
|
+
previous.char_end = current.char_end
|
|
296
|
+
previous.text = text[previous.char_start : previous.char_end]
|
|
297
|
+
else:
|
|
298
|
+
repaired.append(current)
|
|
299
|
+
return repaired
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class _FallbackSplit:
|
|
303
|
+
def __init__(
|
|
304
|
+
self, char_start: int, char_end: int, paragraph_idx: int, sentence_idx: int, text: str = ""
|
|
305
|
+
) -> None:
|
|
306
|
+
self.char_start = char_start
|
|
307
|
+
self.char_end = char_end
|
|
308
|
+
self.paragraph_idx = paragraph_idx
|
|
309
|
+
self.sentence_idx = sentence_idx
|
|
310
|
+
self.clause_idx = 0
|
|
311
|
+
self.text = text
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _linguistic_boundaries(
|
|
315
|
+
text: str, runs: tuple[Any, ...], config: PlannerConfig, *, start_id: int = 0
|
|
316
|
+
) -> list[BoundaryEvent]:
|
|
317
|
+
try:
|
|
318
|
+
import phrasplit
|
|
319
|
+
except ImportError:
|
|
320
|
+
return []
|
|
321
|
+
result: list[BoundaryEvent] = []
|
|
322
|
+
for run in runs:
|
|
323
|
+
local = text[run.spoken_start : run.spoken_end]
|
|
324
|
+
try:
|
|
325
|
+
clause_items = phrasplit.detect_clause_boundaries(
|
|
326
|
+
local, language=run.language, use_spacy=False
|
|
327
|
+
)
|
|
328
|
+
except (OSError, TypeError, ValueError):
|
|
329
|
+
clause_items = []
|
|
330
|
+
for item in clause_items:
|
|
331
|
+
kind = str(getattr(item, "kind", ""))
|
|
332
|
+
event_kind = (
|
|
333
|
+
"parenthetical"
|
|
334
|
+
if "parenthetical" in kind
|
|
335
|
+
else "clausal_comma"
|
|
336
|
+
if ("clause" in kind or "comma" in kind)
|
|
337
|
+
else None
|
|
338
|
+
)
|
|
339
|
+
if event_kind:
|
|
340
|
+
result.append(
|
|
341
|
+
BoundaryEvent(
|
|
342
|
+
f"boundary-{start_id + len(result):06d}",
|
|
343
|
+
run.spoken_start + int(getattr(item, "char_start", 0)),
|
|
344
|
+
event_kind,
|
|
345
|
+
origin="phrasplit",
|
|
346
|
+
strength="weak",
|
|
347
|
+
attrs={"detected_kind": kind, "automatic": True},
|
|
348
|
+
)
|
|
349
|
+
)
|
|
350
|
+
try:
|
|
351
|
+
parenthetical_items = phrasplit.detect_parenthetical_boundaries(
|
|
352
|
+
local, language=run.language
|
|
353
|
+
)
|
|
354
|
+
except (AttributeError, OSError, TypeError, ValueError):
|
|
355
|
+
parenthetical_items = []
|
|
356
|
+
for item in parenthetical_items:
|
|
357
|
+
kind = str(getattr(item, "kind", "parenthetical"))
|
|
358
|
+
char_start = int(getattr(item, "char_start", 0))
|
|
359
|
+
char_end = int(getattr(item, "char_end", char_start))
|
|
360
|
+
if kind == "parenthetical_open":
|
|
361
|
+
local_position = char_start
|
|
362
|
+
elif kind == "parenthetical_close":
|
|
363
|
+
local_position = char_end
|
|
364
|
+
else:
|
|
365
|
+
continue
|
|
366
|
+
result.append(
|
|
367
|
+
BoundaryEvent(
|
|
368
|
+
f"boundary-{start_id + len(result):06d}",
|
|
369
|
+
run.spoken_start + local_position,
|
|
370
|
+
"parenthetical",
|
|
371
|
+
origin="phrasplit",
|
|
372
|
+
strength="weak",
|
|
373
|
+
attrs={
|
|
374
|
+
"detected_kind": kind,
|
|
375
|
+
"automatic": True,
|
|
376
|
+
"anchor": "before",
|
|
377
|
+
},
|
|
378
|
+
)
|
|
379
|
+
)
|
|
380
|
+
return result
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _derived_boundaries(
|
|
384
|
+
segments: list[PlanSegment], existing: list[BoundaryEvent], config: PauseConfig
|
|
385
|
+
) -> list[BoundaryEvent]:
|
|
386
|
+
result: list[BoundaryEvent] = []
|
|
387
|
+
existing_keys = {
|
|
388
|
+
(event.position, event.kind)
|
|
389
|
+
for event in existing
|
|
390
|
+
if event.kind in {"paragraph", "sentence"}
|
|
391
|
+
}
|
|
392
|
+
next_id = len(existing)
|
|
393
|
+
for previous, current in zip(segments, segments[1:], strict=False):
|
|
394
|
+
kind: str | None = None
|
|
395
|
+
strength: str | None = None
|
|
396
|
+
if previous.paragraph != current.paragraph:
|
|
397
|
+
kind, strength = "paragraph", "paragraph"
|
|
398
|
+
elif previous.sentence != current.sentence:
|
|
399
|
+
kind, strength = "sentence", "sentence"
|
|
400
|
+
elif config.mode == "auto" and _voice_changed(previous, current):
|
|
401
|
+
kind, strength = "voice_change", "weak"
|
|
402
|
+
if kind is not None:
|
|
403
|
+
position = previous.spoken_end
|
|
404
|
+
if (position, kind) in existing_keys:
|
|
405
|
+
continue
|
|
406
|
+
result.append(
|
|
407
|
+
BoundaryEvent(
|
|
408
|
+
f"boundary-{next_id:06d}",
|
|
409
|
+
position,
|
|
410
|
+
kind,
|
|
411
|
+
origin="planner",
|
|
412
|
+
strength=strength,
|
|
413
|
+
attrs={"automatic": True},
|
|
414
|
+
)
|
|
415
|
+
)
|
|
416
|
+
existing_keys.add((position, kind))
|
|
417
|
+
next_id += 1
|
|
418
|
+
return result
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _voice_changed(previous: PlanSegment, current: PlanSegment) -> bool:
|
|
422
|
+
left = previous.directives.voice.reference if previous.directives.voice else None
|
|
423
|
+
right = current.directives.voice.reference if current.directives.voice else None
|
|
424
|
+
return left != right
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _attach_membership(
|
|
428
|
+
segments: list[PlanSegment], tokens: tuple[Any, ...], annotations: tuple[AnnotationSpan, ...]
|
|
429
|
+
) -> list[PlanSegment]:
|
|
430
|
+
output: list[PlanSegment] = []
|
|
431
|
+
for segment in segments:
|
|
432
|
+
token_indices = tuple(
|
|
433
|
+
index
|
|
434
|
+
for index, token in enumerate(tokens)
|
|
435
|
+
if token.spoken_start < segment.spoken_end and token.spoken_end > segment.spoken_start
|
|
436
|
+
)
|
|
437
|
+
annotation_ids = tuple(
|
|
438
|
+
annotation.id
|
|
439
|
+
for annotation in annotations
|
|
440
|
+
if annotation.spoken_start is not None
|
|
441
|
+
and annotation.spoken_end is not None
|
|
442
|
+
and annotation.spoken_start < segment.spoken_end
|
|
443
|
+
and annotation.spoken_end > segment.spoken_start
|
|
444
|
+
)
|
|
445
|
+
output.append(replace(segment, token_indices=token_indices, annotation_ids=annotation_ids))
|
|
446
|
+
return output
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _semantic_annotation(annotation: AnnotationSpan) -> bool:
|
|
450
|
+
return any(
|
|
451
|
+
key in annotation.attrs
|
|
452
|
+
for key in (
|
|
453
|
+
"lang",
|
|
454
|
+
"language",
|
|
455
|
+
"voice",
|
|
456
|
+
"voice_name",
|
|
457
|
+
"ph",
|
|
458
|
+
"phonemes",
|
|
459
|
+
"rate",
|
|
460
|
+
"pitch",
|
|
461
|
+
"volume",
|
|
462
|
+
"emphasis",
|
|
463
|
+
"level",
|
|
464
|
+
"audio",
|
|
465
|
+
"audio_src",
|
|
466
|
+
"src",
|
|
467
|
+
"speed",
|
|
468
|
+
)
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _map_marker(marker: Marker, source_map: SourceToSpokenMap) -> Marker:
|
|
473
|
+
position, _ = source_map.map_source_span(marker.spoken_position, marker.spoken_position)
|
|
474
|
+
return replace(marker, spoken_position=position)
|
utterplan/preparation.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
5
|
+
from typing import Any, Protocol
|
|
6
|
+
|
|
7
|
+
from .exceptions import TextPreparationError
|
|
8
|
+
from .language import LanguageRun
|
|
9
|
+
from .model import AnnotationSpan, BoundaryEvent, TextPreparationInfo
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SourceToSpokenMap(Protocol):
|
|
13
|
+
source_length: int
|
|
14
|
+
output_length: int
|
|
15
|
+
|
|
16
|
+
def map_source_span(self, start: int, end: int) -> tuple[int, int]: ...
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class PreparedText:
|
|
21
|
+
spoken_text: str
|
|
22
|
+
info: TextPreparationInfo
|
|
23
|
+
annotations: tuple[AnnotationSpan, ...]
|
|
24
|
+
boundaries: tuple[BoundaryEvent, ...]
|
|
25
|
+
source_map: SourceToSpokenMap
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SpokenformTextPreparer:
|
|
29
|
+
def prepare(
|
|
30
|
+
self,
|
|
31
|
+
text: str,
|
|
32
|
+
language: str,
|
|
33
|
+
runs: tuple[LanguageRun, ...],
|
|
34
|
+
annotations: tuple[AnnotationSpan, ...] = (),
|
|
35
|
+
boundaries: tuple[BoundaryEvent, ...] = (),
|
|
36
|
+
analyses: tuple[Any, ...] = (),
|
|
37
|
+
) -> PreparedText:
|
|
38
|
+
try:
|
|
39
|
+
import spokenform
|
|
40
|
+
|
|
41
|
+
prepared_runs: list[Any] = []
|
|
42
|
+
active_runs = runs or (LanguageRun("lang-0", 0, len(text), language),)
|
|
43
|
+
for run_index, run in enumerate(active_runs):
|
|
44
|
+
local_text = text[run.spoken_start : run.spoken_end]
|
|
45
|
+
protected = [
|
|
46
|
+
(
|
|
47
|
+
annotation.structural_start - run.spoken_start,
|
|
48
|
+
annotation.structural_end - run.spoken_start,
|
|
49
|
+
)
|
|
50
|
+
for annotation in annotations
|
|
51
|
+
if annotation.structural_start >= run.spoken_start
|
|
52
|
+
and annotation.structural_end <= run.spoken_end
|
|
53
|
+
and (annotation.attrs.get("ph") or annotation.attrs.get("phonemes"))
|
|
54
|
+
]
|
|
55
|
+
kwargs: dict[str, Any] = {
|
|
56
|
+
"language": run.language,
|
|
57
|
+
"use_spacy": False,
|
|
58
|
+
"strip_outer_whitespace": False,
|
|
59
|
+
"preserve_run_boundaries": True,
|
|
60
|
+
"protected_spans": protected,
|
|
61
|
+
}
|
|
62
|
+
if run_index < len(analyses) and analyses[run_index].provider_doc is not None:
|
|
63
|
+
kwargs["nlp"] = analyses[run_index].provider_doc
|
|
64
|
+
prepared_runs.append(spokenform.prepare(local_text, **kwargs))
|
|
65
|
+
spoken = "".join(str(item.spoken_text) for item in prepared_runs)
|
|
66
|
+
source_map = _compose_offsets(text, prepared_runs)
|
|
67
|
+
replacements: list[dict[str, Any]] = []
|
|
68
|
+
warnings: list[str] = []
|
|
69
|
+
output_offset = 0
|
|
70
|
+
for run, item in zip(
|
|
71
|
+
runs or (LanguageRun("lang-0", 0, len(text), language),),
|
|
72
|
+
prepared_runs,
|
|
73
|
+
strict=False,
|
|
74
|
+
):
|
|
75
|
+
for replacement in getattr(item, "source_replacements", ()):
|
|
76
|
+
value = _replacement_dict(replacement)
|
|
77
|
+
value["source_start"] = int(value.get("source_start", 0)) + run.spoken_start
|
|
78
|
+
value["source_end"] = int(value.get("source_end", 0)) + run.spoken_start
|
|
79
|
+
value["output_start"] = int(value.get("output_start", 0)) + output_offset
|
|
80
|
+
value["output_end"] = int(value.get("output_end", 0)) + output_offset
|
|
81
|
+
replacements.append(value)
|
|
82
|
+
warnings.extend(str(value) for value in getattr(item, "warnings", ()))
|
|
83
|
+
output_offset += len(str(item.spoken_text))
|
|
84
|
+
try:
|
|
85
|
+
ver = version("spokenform")
|
|
86
|
+
except PackageNotFoundError:
|
|
87
|
+
ver = None
|
|
88
|
+
except (ImportError, ValueError, TypeError) as exc:
|
|
89
|
+
raise TextPreparationError(str(exc)) from exc
|
|
90
|
+
mapped_annotations = tuple(
|
|
91
|
+
_map_annotation(annotation, source_map) for annotation in annotations
|
|
92
|
+
)
|
|
93
|
+
mapped_boundaries = tuple(_map_boundary(boundary, source_map) for boundary in boundaries)
|
|
94
|
+
info = TextPreparationInfo(
|
|
95
|
+
backend="spokenform",
|
|
96
|
+
version=ver,
|
|
97
|
+
languages=tuple(run.language for run in runs),
|
|
98
|
+
replacements=tuple(replacements),
|
|
99
|
+
warnings=tuple(warnings),
|
|
100
|
+
)
|
|
101
|
+
return PreparedText(spoken, info, mapped_annotations, mapped_boundaries, source_map)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class IdentityTextPreparer:
|
|
105
|
+
def prepare(
|
|
106
|
+
self,
|
|
107
|
+
text: str,
|
|
108
|
+
language: str,
|
|
109
|
+
runs: tuple[LanguageRun, ...],
|
|
110
|
+
annotations: tuple[AnnotationSpan, ...] = (),
|
|
111
|
+
boundaries: tuple[BoundaryEvent, ...] = (),
|
|
112
|
+
analyses: tuple[Any, ...] = (),
|
|
113
|
+
) -> PreparedText:
|
|
114
|
+
source_map = _IdentitySourceMap(len(text))
|
|
115
|
+
info = TextPreparationInfo(
|
|
116
|
+
backend="identity",
|
|
117
|
+
version=None,
|
|
118
|
+
languages=tuple(run.language for run in runs),
|
|
119
|
+
)
|
|
120
|
+
return PreparedText(
|
|
121
|
+
text,
|
|
122
|
+
info,
|
|
123
|
+
tuple(_map_annotation(annotation, source_map) for annotation in annotations),
|
|
124
|
+
tuple(_map_boundary(boundary, source_map) for boundary in boundaries),
|
|
125
|
+
source_map,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class _IdentitySourceMap:
|
|
130
|
+
def __init__(self, length: int) -> None:
|
|
131
|
+
self.source_length = length
|
|
132
|
+
self.output_length = length
|
|
133
|
+
|
|
134
|
+
def map_source_span(self, start: int, end: int) -> tuple[int, int]:
|
|
135
|
+
return start, end
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class _CompositeSourceMap:
|
|
139
|
+
def __init__(
|
|
140
|
+
self,
|
|
141
|
+
source_length: int,
|
|
142
|
+
output_length: int,
|
|
143
|
+
source_left: list[int],
|
|
144
|
+
source_right: list[int],
|
|
145
|
+
) -> None:
|
|
146
|
+
self.source_length = source_length
|
|
147
|
+
self.output_length = output_length
|
|
148
|
+
self.source_left = source_left
|
|
149
|
+
self.source_right = source_right
|
|
150
|
+
|
|
151
|
+
def map_source_span(self, start: int, end: int) -> tuple[int, int]:
|
|
152
|
+
start = max(0, min(self.source_length, start))
|
|
153
|
+
end = max(start, min(self.source_length, end))
|
|
154
|
+
return self.source_left[start], self.source_right[end]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _compose_offsets(text: str, prepared_runs: list[Any]) -> SourceToSpokenMap:
|
|
158
|
+
if not prepared_runs:
|
|
159
|
+
return _IdentitySourceMap(len(text))
|
|
160
|
+
|
|
161
|
+
source_left: list[int] = []
|
|
162
|
+
source_right: list[int] = []
|
|
163
|
+
source_length = output_length = 0
|
|
164
|
+
for item in prepared_runs:
|
|
165
|
+
offset = getattr(item, "offset_map", None)
|
|
166
|
+
if offset is None:
|
|
167
|
+
raise TextPreparationError("spokenform did not provide an offset map")
|
|
168
|
+
local_left = list(offset.source_left)
|
|
169
|
+
local_right = list(offset.source_right)
|
|
170
|
+
if not source_left:
|
|
171
|
+
source_left.extend(value + output_length for value in local_left)
|
|
172
|
+
source_right.extend(value + output_length for value in local_right)
|
|
173
|
+
else:
|
|
174
|
+
source_left.extend(value + output_length for value in local_left[1:])
|
|
175
|
+
source_right.extend(value + output_length for value in local_right[1:])
|
|
176
|
+
source_length += int(offset.source_length)
|
|
177
|
+
output_length += int(offset.output_length)
|
|
178
|
+
return _CompositeSourceMap(source_length, output_length, source_left, source_right)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _replacement_dict(item: Any) -> dict[str, Any]:
|
|
182
|
+
return {
|
|
183
|
+
key: getattr(item, key)
|
|
184
|
+
for key in (
|
|
185
|
+
"source_start",
|
|
186
|
+
"source_end",
|
|
187
|
+
"output_start",
|
|
188
|
+
"output_end",
|
|
189
|
+
"source",
|
|
190
|
+
"replacement",
|
|
191
|
+
"kind",
|
|
192
|
+
"rule",
|
|
193
|
+
"language",
|
|
194
|
+
)
|
|
195
|
+
if hasattr(item, key)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _map_annotation(annotation: AnnotationSpan, source_map: SourceToSpokenMap) -> AnnotationSpan:
|
|
200
|
+
spoken_start, spoken_end = source_map.map_source_span(
|
|
201
|
+
annotation.structural_start, annotation.structural_end
|
|
202
|
+
)
|
|
203
|
+
return AnnotationSpan(
|
|
204
|
+
annotation.id,
|
|
205
|
+
annotation.kind,
|
|
206
|
+
annotation.attrs,
|
|
207
|
+
annotation.structural_start,
|
|
208
|
+
annotation.structural_end,
|
|
209
|
+
spoken_start,
|
|
210
|
+
max(spoken_start, spoken_end),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _map_boundary(boundary: BoundaryEvent, source_map: SourceToSpokenMap) -> BoundaryEvent:
|
|
215
|
+
position, _ = source_map.map_source_span(boundary.position, boundary.position)
|
|
216
|
+
return BoundaryEvent(
|
|
217
|
+
boundary.id,
|
|
218
|
+
position,
|
|
219
|
+
boundary.kind,
|
|
220
|
+
boundary.seconds,
|
|
221
|
+
boundary.origin,
|
|
222
|
+
boundary.strength,
|
|
223
|
+
boundary.attrs,
|
|
224
|
+
)
|
utterplan/py.typed
ADDED
|
File without changes
|