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/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)
@@ -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