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.
Potentially problematic release.
This version of utterplan might be problematic. Click here for more details.
- 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/__init__.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from ._version import __version__
|
|
2
|
+
from .config import (
|
|
3
|
+
LinguisticsConfig,
|
|
4
|
+
PauseConfig,
|
|
5
|
+
PlannerConfig,
|
|
6
|
+
SSMDConfig,
|
|
7
|
+
parse_duration,
|
|
8
|
+
)
|
|
9
|
+
from .exceptions import (
|
|
10
|
+
ConfigurationError,
|
|
11
|
+
LanguagePlanError,
|
|
12
|
+
PlanFormatError,
|
|
13
|
+
PlanningError,
|
|
14
|
+
PlanValidationError,
|
|
15
|
+
SegmentationError,
|
|
16
|
+
TextPreparationError,
|
|
17
|
+
UnsupportedSchemaError,
|
|
18
|
+
UtterPlanError,
|
|
19
|
+
)
|
|
20
|
+
from .language import LanguageRun, build_language_runs, normalize_language
|
|
21
|
+
from .model import (
|
|
22
|
+
AnnotationSpan,
|
|
23
|
+
AudioDirective,
|
|
24
|
+
BoundaryEvent,
|
|
25
|
+
Diagnostic,
|
|
26
|
+
EmphasisDirective,
|
|
27
|
+
Marker,
|
|
28
|
+
PlanSegment,
|
|
29
|
+
PlanSource,
|
|
30
|
+
PlanTexts,
|
|
31
|
+
PlanUnit,
|
|
32
|
+
PronunciationDirective,
|
|
33
|
+
ProsodyDirective,
|
|
34
|
+
ResolvedPause,
|
|
35
|
+
SegmentDirectives,
|
|
36
|
+
TextPreparationInfo,
|
|
37
|
+
TokenAnnotation,
|
|
38
|
+
UtterancePlan,
|
|
39
|
+
VoiceDirective,
|
|
40
|
+
)
|
|
41
|
+
from .planner import UtterancePlanner
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"__version__",
|
|
45
|
+
"PlannerConfig",
|
|
46
|
+
"PauseConfig",
|
|
47
|
+
"LinguisticsConfig",
|
|
48
|
+
"SSMDConfig",
|
|
49
|
+
"parse_duration",
|
|
50
|
+
"UtterancePlanner",
|
|
51
|
+
"UtterancePlan",
|
|
52
|
+
"PlanSource",
|
|
53
|
+
"PlanTexts",
|
|
54
|
+
"PlanSegment",
|
|
55
|
+
"PlanUnit",
|
|
56
|
+
"LanguageRun",
|
|
57
|
+
"TokenAnnotation",
|
|
58
|
+
"AnnotationSpan",
|
|
59
|
+
"BoundaryEvent",
|
|
60
|
+
"ResolvedPause",
|
|
61
|
+
"SegmentDirectives",
|
|
62
|
+
"VoiceDirective",
|
|
63
|
+
"PronunciationDirective",
|
|
64
|
+
"ProsodyDirective",
|
|
65
|
+
"EmphasisDirective",
|
|
66
|
+
"AudioDirective",
|
|
67
|
+
"Marker",
|
|
68
|
+
"TextPreparationInfo",
|
|
69
|
+
"Diagnostic",
|
|
70
|
+
"normalize_language",
|
|
71
|
+
"build_language_runs",
|
|
72
|
+
"UtterPlanError",
|
|
73
|
+
"ConfigurationError",
|
|
74
|
+
"PlanFormatError",
|
|
75
|
+
"PlanValidationError",
|
|
76
|
+
"UnsupportedSchemaError",
|
|
77
|
+
"PlanningError",
|
|
78
|
+
"LanguagePlanError",
|
|
79
|
+
"TextPreparationError",
|
|
80
|
+
"SegmentationError",
|
|
81
|
+
]
|
utterplan/__main__.py
ADDED
utterplan/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 1)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = 'ga56a3513d'
|
utterplan/cli.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Literal, cast
|
|
7
|
+
|
|
8
|
+
from . import PlannerConfig, UtterancePlan, UtterancePlanner, __version__
|
|
9
|
+
from .config import LinguisticsConfig, PauseConfig
|
|
10
|
+
from .exceptions import UtterPlanError
|
|
11
|
+
|
|
12
|
+
InputFormat = Literal["plain", "ssmd"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_EXAMPLES = """examples:
|
|
16
|
+
utterplan compile "Hello world." --lang en-us
|
|
17
|
+
echo "Hello world." | utterplan compile --lang en-us | jq .
|
|
18
|
+
utterplan compile chapter.ssmd --lang en-us -o chapter.utterplan.json
|
|
19
|
+
utterplan validate chapter.utterplan.json
|
|
20
|
+
utterplan inspect chapter.utterplan.json --segment 0
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
25
|
+
parser = argparse.ArgumentParser(
|
|
26
|
+
prog="utterplan",
|
|
27
|
+
description="Compile text or SSMD into deterministic, engine-independent TTS plans.",
|
|
28
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
29
|
+
epilog=_EXAMPLES,
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument("--version", action="version", version=f"utterplan {__version__}")
|
|
32
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
33
|
+
|
|
34
|
+
compile_parser = commands.add_parser(
|
|
35
|
+
"compile",
|
|
36
|
+
help="compile literal text, stdin, or a file into a TTS plan",
|
|
37
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
38
|
+
epilog=_EXAMPLES,
|
|
39
|
+
)
|
|
40
|
+
compile_parser.add_argument(
|
|
41
|
+
"text",
|
|
42
|
+
nargs="*",
|
|
43
|
+
help="literal text, or one existing file path; omit to read stdin",
|
|
44
|
+
)
|
|
45
|
+
compile_parser.add_argument(
|
|
46
|
+
"--file",
|
|
47
|
+
type=Path,
|
|
48
|
+
help="read UTF-8 text from this file (cannot be combined with positional text)",
|
|
49
|
+
)
|
|
50
|
+
compile_parser.add_argument("--language", "--lang", required=True, dest="language")
|
|
51
|
+
compile_parser.add_argument(
|
|
52
|
+
"--input-format",
|
|
53
|
+
"--format",
|
|
54
|
+
dest="input_format",
|
|
55
|
+
choices=("auto", "text", "ssmd"),
|
|
56
|
+
default="auto",
|
|
57
|
+
help="input interpretation; auto infers SSMD from a .ssmd file suffix",
|
|
58
|
+
)
|
|
59
|
+
compile_parser.add_argument("--unit", choices=("paragraph", "sentence"), default="paragraph")
|
|
60
|
+
compile_parser.add_argument(
|
|
61
|
+
"--text-preparation",
|
|
62
|
+
choices=("spokenform", "identity"),
|
|
63
|
+
default="spokenform",
|
|
64
|
+
)
|
|
65
|
+
compile_parser.add_argument("--pause-mode", choices=("tts", "manual", "auto"), default="tts")
|
|
66
|
+
compile_parser.add_argument(
|
|
67
|
+
"--spacy",
|
|
68
|
+
choices=("auto", "off", "sm", "md", "lg", "trf"),
|
|
69
|
+
default="off",
|
|
70
|
+
help="linguistic-resource policy; default is the deterministic fallback",
|
|
71
|
+
)
|
|
72
|
+
compile_parser.add_argument("-o", "--output", type=Path, help="write the plan to this file")
|
|
73
|
+
compile_parser.add_argument(
|
|
74
|
+
"--force", action="store_true", help="replace an existing output file"
|
|
75
|
+
)
|
|
76
|
+
compile_parser.add_argument(
|
|
77
|
+
"--json",
|
|
78
|
+
action="store_true",
|
|
79
|
+
help="also print the complete plan JSON when --output is used",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
validate_parser = commands.add_parser("validate", help="validate a saved TTS plan")
|
|
83
|
+
validate_parser.add_argument("input", type=Path)
|
|
84
|
+
|
|
85
|
+
inspect_parser = commands.add_parser("inspect", help="inspect a saved TTS plan")
|
|
86
|
+
inspect_parser.add_argument("input", type=Path)
|
|
87
|
+
inspect_parser.add_argument("--unit", type=int)
|
|
88
|
+
inspect_parser.add_argument("--segment", type=int)
|
|
89
|
+
inspect_parser.add_argument("--warnings", action="store_true")
|
|
90
|
+
inspect_parser.add_argument("--boundaries", action="store_true")
|
|
91
|
+
inspect_parser.add_argument("--tokens", action="store_true")
|
|
92
|
+
inspect_parser.add_argument("--preparation", action="store_true")
|
|
93
|
+
return parser
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _format_for_path(path: Path) -> InputFormat:
|
|
97
|
+
return "ssmd" if path.suffix.lower() == ".ssmd" else "plain"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _read_compile_input(args: argparse.Namespace) -> tuple[str, InputFormat]:
|
|
101
|
+
if args.file is not None:
|
|
102
|
+
if args.text:
|
|
103
|
+
raise ValueError("--file cannot be combined with positional text")
|
|
104
|
+
source = args.file.read_text(encoding="utf-8")
|
|
105
|
+
input_format = (
|
|
106
|
+
_format_for_path(args.file)
|
|
107
|
+
if args.input_format == "auto"
|
|
108
|
+
else _map_input_format(args.input_format)
|
|
109
|
+
)
|
|
110
|
+
return source, input_format
|
|
111
|
+
|
|
112
|
+
if not args.text:
|
|
113
|
+
if sys.stdin.isatty():
|
|
114
|
+
raise ValueError("no input text supplied; provide text or pipe data on stdin")
|
|
115
|
+
source = sys.stdin.read()
|
|
116
|
+
if not source.strip():
|
|
117
|
+
raise ValueError("stdin is empty; provide meaningful input text")
|
|
118
|
+
return source, _map_input_format(args.input_format, default="plain")
|
|
119
|
+
|
|
120
|
+
if args.input_format == "text":
|
|
121
|
+
return " ".join(args.text), "plain"
|
|
122
|
+
|
|
123
|
+
if len(args.text) == 1:
|
|
124
|
+
candidate = Path(args.text[0])
|
|
125
|
+
if candidate.is_file():
|
|
126
|
+
source = candidate.read_text(encoding="utf-8")
|
|
127
|
+
input_format = (
|
|
128
|
+
_format_for_path(candidate)
|
|
129
|
+
if args.input_format == "auto"
|
|
130
|
+
else _map_input_format(args.input_format)
|
|
131
|
+
)
|
|
132
|
+
return source, input_format
|
|
133
|
+
|
|
134
|
+
return " ".join(args.text), _map_input_format(args.input_format, default="plain")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _map_input_format(value: str, *, default: InputFormat = "plain") -> InputFormat:
|
|
138
|
+
if value == "text":
|
|
139
|
+
return "plain"
|
|
140
|
+
if value == "ssmd":
|
|
141
|
+
return "ssmd"
|
|
142
|
+
return default
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _linguistics_config(policy: str) -> LinguisticsConfig:
|
|
146
|
+
if policy == "off":
|
|
147
|
+
return LinguisticsConfig(use_spacy=False)
|
|
148
|
+
if policy == "auto":
|
|
149
|
+
return LinguisticsConfig(use_spacy=True)
|
|
150
|
+
return LinguisticsConfig(
|
|
151
|
+
use_spacy=True,
|
|
152
|
+
spacy_model_size=cast(Literal["sm", "md", "lg", "trf"], policy),
|
|
153
|
+
require_spacy=True,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _compile(args: argparse.Namespace) -> int:
|
|
158
|
+
source, input_format = _read_compile_input(args)
|
|
159
|
+
if args.output is not None and args.output.exists() and not args.force:
|
|
160
|
+
raise ValueError(f"output exists: {args.output}; use --force to replace it")
|
|
161
|
+
|
|
162
|
+
config = PlannerConfig(
|
|
163
|
+
language=args.language,
|
|
164
|
+
document_format=input_format,
|
|
165
|
+
text_preparation=args.text_preparation,
|
|
166
|
+
unit=args.unit,
|
|
167
|
+
pauses=PauseConfig(mode=args.pause_mode),
|
|
168
|
+
linguistics=_linguistics_config(args.spacy),
|
|
169
|
+
)
|
|
170
|
+
plan = UtterancePlanner(config).plan(source)
|
|
171
|
+
payload = plan.to_json()
|
|
172
|
+
if args.output is None:
|
|
173
|
+
print(payload, end="")
|
|
174
|
+
else:
|
|
175
|
+
args.output.write_text(payload, encoding="utf-8")
|
|
176
|
+
if args.json:
|
|
177
|
+
print(payload, end="")
|
|
178
|
+
else:
|
|
179
|
+
print(
|
|
180
|
+
f"wrote {args.output} ({len(plan.segments)} segments, {len(plan.units)} units)",
|
|
181
|
+
file=sys.stderr,
|
|
182
|
+
)
|
|
183
|
+
return 0
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def main(argv: list[str] | None = None) -> int:
|
|
187
|
+
args = build_parser().parse_args(argv)
|
|
188
|
+
try:
|
|
189
|
+
if args.command == "compile":
|
|
190
|
+
return _compile(args)
|
|
191
|
+
plan = UtterancePlan.load(args.input)
|
|
192
|
+
if args.command == "validate":
|
|
193
|
+
print("valid")
|
|
194
|
+
print(f"schema version: {plan.schema_version}")
|
|
195
|
+
print(f"plan ID: {plan.plan_id}")
|
|
196
|
+
print(f"segments: {len(plan.segments)}")
|
|
197
|
+
print(f"units: {len(plan.units)}")
|
|
198
|
+
print(f"warnings: {len(plan.warnings)}")
|
|
199
|
+
return 0
|
|
200
|
+
_inspect(plan, args)
|
|
201
|
+
return 0
|
|
202
|
+
except (OSError, UtterPlanError, ValueError, TypeError) as exc:
|
|
203
|
+
print(str(exc), file=sys.stderr)
|
|
204
|
+
return 1
|
|
205
|
+
|
|
206
|
+
print(f"UtterPlan schema {plan.schema_version}")
|
|
207
|
+
print(f"Plan: {plan.plan_id}")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _inspect(plan: UtterancePlan, args: argparse.Namespace) -> None:
|
|
211
|
+
print(f"Default language: {plan.config.get('language', '')}")
|
|
212
|
+
print("\nTexts")
|
|
213
|
+
print(f" source: {len(plan.source.text)} characters")
|
|
214
|
+
print(f" structural: {len(plan.texts.structural)} characters")
|
|
215
|
+
print(f" spoken: {len(plan.texts.spoken)} characters")
|
|
216
|
+
print(f"\nUnits: {len(plan.units)}")
|
|
217
|
+
print(f"Segments: {len(plan.segments)}")
|
|
218
|
+
print(f"Languages: {len(plan.languages)}")
|
|
219
|
+
print(f"Warnings: {len(plan.warnings)}")
|
|
220
|
+
if args.warnings:
|
|
221
|
+
for warning in plan.warnings:
|
|
222
|
+
print(f"warning: {warning}")
|
|
223
|
+
if args.preparation:
|
|
224
|
+
preparation = plan.preparation
|
|
225
|
+
version = f" {preparation.version}" if preparation.version else ""
|
|
226
|
+
print("\nPreparation")
|
|
227
|
+
print(f" backend: {preparation.backend}{version}")
|
|
228
|
+
print(f" source: {len(plan.texts.structural)} characters")
|
|
229
|
+
print(f" spoken: {len(plan.texts.spoken)} characters")
|
|
230
|
+
print(f" replacements: {len(preparation.replacements)}")
|
|
231
|
+
for replacement in preparation.replacements:
|
|
232
|
+
source_range = (
|
|
233
|
+
f"{replacement.get('source_start', 0)}:{replacement.get('source_end', 0)}"
|
|
234
|
+
)
|
|
235
|
+
output_range = (
|
|
236
|
+
f"{replacement.get('output_start', 0)}:{replacement.get('output_end', 0)}"
|
|
237
|
+
)
|
|
238
|
+
label = replacement.get("rule") or replacement.get("kind", "")
|
|
239
|
+
print(f" {source_range} -> {output_range} {label}")
|
|
240
|
+
print(
|
|
241
|
+
f" {replacement.get('source', '')!r} -> {replacement.get('replacement', '')!r}"
|
|
242
|
+
)
|
|
243
|
+
if args.boundaries:
|
|
244
|
+
print("\nBoundaries")
|
|
245
|
+
for boundary in plan.boundaries:
|
|
246
|
+
print(
|
|
247
|
+
f" {boundary.id}: {boundary.kind} at {boundary.position}, "
|
|
248
|
+
f"{boundary.seconds}s, {boundary.origin}"
|
|
249
|
+
)
|
|
250
|
+
if args.tokens:
|
|
251
|
+
print("\nTokens")
|
|
252
|
+
for token in plan.tokens:
|
|
253
|
+
print(
|
|
254
|
+
f" {token.spoken_start}:{token.spoken_end} {token.text!r} {token.language or ''}"
|
|
255
|
+
)
|
|
256
|
+
if args.unit is not None:
|
|
257
|
+
unit = plan.units[args.unit]
|
|
258
|
+
print(f"\nUnit {unit.index}: {unit.kind} {unit.spoken_start}:{unit.spoken_end}")
|
|
259
|
+
for segment_id in unit.segment_ids:
|
|
260
|
+
print(f" {segment_id}")
|
|
261
|
+
if args.segment is not None:
|
|
262
|
+
segment = plan.segments[args.segment]
|
|
263
|
+
print(f"\nSegment {args.segment}")
|
|
264
|
+
print(f" text: {segment.text!r}")
|
|
265
|
+
print(f" language: {segment.language}")
|
|
266
|
+
print(f" paragraph: {segment.paragraph}")
|
|
267
|
+
print(f" sentence: {segment.sentence}")
|
|
268
|
+
print(f" clause: {segment.clause}")
|
|
269
|
+
print(f" pause_before: {segment.pause_before.seconds}s {segment.pause_before.events}")
|
|
270
|
+
print(f" pause_after: {segment.pause_after.seconds}s {segment.pause_after.events}")
|
|
271
|
+
print(f" directives: {segment.directives.to_dict()}")
|
utterplan/config.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import asdict, dataclass, field
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from .exceptions import ConfigurationError
|
|
10
|
+
|
|
11
|
+
_DURATION_RE = re.compile(r"^\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*(ms|s)?\s*$", re.I)
|
|
12
|
+
_PAUSE_KEYS = frozenset(
|
|
13
|
+
{"weak", "clause", "sentence", "paragraph", "parenthetical", "voice_change"}
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_duration(value: object, *, field_name: str = "duration") -> float:
|
|
18
|
+
"""Parse a portable duration and return finite, non-negative seconds.
|
|
19
|
+
|
|
20
|
+
Numbers and unitless strings are seconds. Strings with ``ms`` or ``s`` are
|
|
21
|
+
normalized to seconds so equivalent spellings have identical semantics.
|
|
22
|
+
"""
|
|
23
|
+
if isinstance(value, bool):
|
|
24
|
+
raise ConfigurationError(f"{field_name} must be a duration, not a boolean")
|
|
25
|
+
if isinstance(value, (int, float)):
|
|
26
|
+
seconds = float(value)
|
|
27
|
+
elif isinstance(value, str):
|
|
28
|
+
match = _DURATION_RE.fullmatch(value)
|
|
29
|
+
if match is None:
|
|
30
|
+
raise ConfigurationError(
|
|
31
|
+
f"{field_name} must be a finite non-negative number of seconds or use ms/s syntax"
|
|
32
|
+
)
|
|
33
|
+
seconds = float(match.group(1))
|
|
34
|
+
if match.group(2) and match.group(2).lower() == "ms":
|
|
35
|
+
seconds /= 1000.0
|
|
36
|
+
else:
|
|
37
|
+
raise ConfigurationError(f"{field_name} must be a number or duration string")
|
|
38
|
+
if not math.isfinite(seconds) or seconds < 0:
|
|
39
|
+
raise ConfigurationError(f"{field_name} must be finite and non-negative")
|
|
40
|
+
return seconds
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _validate_bool(value: object, field_name: str) -> None:
|
|
44
|
+
if not isinstance(value, bool):
|
|
45
|
+
raise ConfigurationError(f"{field_name} must be a boolean")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class PauseConfig:
|
|
50
|
+
mode: Literal["tts", "manual", "auto"] = "tts"
|
|
51
|
+
weak: float = 0.15
|
|
52
|
+
clause: float = 0.30
|
|
53
|
+
sentence: float = 0.60
|
|
54
|
+
paragraph: float = 1.00
|
|
55
|
+
parenthetical: float = 0.15
|
|
56
|
+
voice_change: float = 0.15
|
|
57
|
+
enabled: bool = True
|
|
58
|
+
|
|
59
|
+
def __post_init__(self) -> None:
|
|
60
|
+
if self.mode not in {"tts", "manual", "auto"}:
|
|
61
|
+
raise ConfigurationError("pauses.mode must be one of 'tts', 'manual', or 'auto'")
|
|
62
|
+
_validate_bool(self.enabled, "pauses.enabled")
|
|
63
|
+
for name in ("weak", "clause", "sentence", "paragraph", "parenthetical", "voice_change"):
|
|
64
|
+
value = parse_duration(getattr(self, name), field_name=f"pauses.{name}")
|
|
65
|
+
object.__setattr__(self, name, value)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class LinguisticsConfig:
|
|
70
|
+
use_spacy: bool | None = None
|
|
71
|
+
spacy_model: str | None = None
|
|
72
|
+
spacy_model_size: Literal["sm", "md", "lg", "trf"] | None = None
|
|
73
|
+
require_spacy: bool = False
|
|
74
|
+
|
|
75
|
+
def __post_init__(self) -> None:
|
|
76
|
+
for name in ("use_spacy", "require_spacy"):
|
|
77
|
+
value = getattr(self, name)
|
|
78
|
+
if value is not None:
|
|
79
|
+
_validate_bool(value, f"linguistics.{name}")
|
|
80
|
+
if self.spacy_model_size is not None and self.spacy_model_size not in {
|
|
81
|
+
"sm",
|
|
82
|
+
"md",
|
|
83
|
+
"lg",
|
|
84
|
+
"trf",
|
|
85
|
+
}:
|
|
86
|
+
raise ConfigurationError("linguistics.spacy_model_size is unsupported")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True, slots=True)
|
|
90
|
+
class SSMDConfig:
|
|
91
|
+
parse_header: bool = True
|
|
92
|
+
strict_header: bool = True
|
|
93
|
+
unknown_header: Literal["warn", "error", "ignore"] = "warn"
|
|
94
|
+
pause_defaults: Mapping[str, object] | None = None
|
|
95
|
+
|
|
96
|
+
def __post_init__(self) -> None:
|
|
97
|
+
_validate_bool(self.parse_header, "ssmd.parse_header")
|
|
98
|
+
_validate_bool(self.strict_header, "ssmd.strict_header")
|
|
99
|
+
if self.unknown_header not in {"warn", "error", "ignore"}:
|
|
100
|
+
raise ConfigurationError("ssmd.unknown_header must be 'warn', 'error', or 'ignore'")
|
|
101
|
+
if self.pause_defaults is not None:
|
|
102
|
+
if not isinstance(self.pause_defaults, Mapping):
|
|
103
|
+
raise ConfigurationError("ssmd.pause_defaults must be a mapping")
|
|
104
|
+
for key, value in self.pause_defaults.items():
|
|
105
|
+
if key == "enabled":
|
|
106
|
+
_validate_bool(value, "ssmd.pause_defaults.enabled")
|
|
107
|
+
elif key in _PAUSE_KEYS:
|
|
108
|
+
parse_duration(value, field_name=f"ssmd.pause_defaults.{key}")
|
|
109
|
+
else:
|
|
110
|
+
raise ConfigurationError(f"ssmd.pause_defaults.{key} is unsupported")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True, slots=True)
|
|
114
|
+
class PlannerConfig:
|
|
115
|
+
language: str
|
|
116
|
+
document_format: Literal["plain", "ssmd"] = "ssmd"
|
|
117
|
+
text_preparation: Literal["spokenform", "identity"] = "spokenform"
|
|
118
|
+
unit: Literal["paragraph", "sentence"] = "paragraph"
|
|
119
|
+
pauses: PauseConfig = field(default_factory=PauseConfig)
|
|
120
|
+
linguistics: LinguisticsConfig = field(default_factory=LinguisticsConfig)
|
|
121
|
+
ssmd: SSMDConfig = field(default_factory=SSMDConfig)
|
|
122
|
+
overlap_mode: Literal["snap", "strict"] = "snap"
|
|
123
|
+
language_aliases: Mapping[str, str] = field(default_factory=dict)
|
|
124
|
+
diagnostics: bool = True
|
|
125
|
+
|
|
126
|
+
def __post_init__(self) -> None:
|
|
127
|
+
if not isinstance(self.language, str) or not self.language.strip():
|
|
128
|
+
raise ConfigurationError("language must be a non-empty string")
|
|
129
|
+
if self.document_format not in {"plain", "ssmd"}:
|
|
130
|
+
raise ConfigurationError("document_format must be 'plain' or 'ssmd'")
|
|
131
|
+
if self.text_preparation not in {"spokenform", "identity"}:
|
|
132
|
+
raise ConfigurationError("text_preparation must be 'spokenform' or 'identity'")
|
|
133
|
+
if self.unit not in {"paragraph", "sentence"}:
|
|
134
|
+
raise ConfigurationError("unit must be 'paragraph' or 'sentence'")
|
|
135
|
+
if self.overlap_mode not in {"snap", "strict"}:
|
|
136
|
+
raise ConfigurationError("overlap_mode must be 'snap' or 'strict'")
|
|
137
|
+
if not isinstance(self.language_aliases, Mapping):
|
|
138
|
+
raise ConfigurationError("language_aliases must be a string-to-string mapping")
|
|
139
|
+
if any(
|
|
140
|
+
not isinstance(key, str) or not isinstance(value, str)
|
|
141
|
+
for key, value in self.language_aliases.items()
|
|
142
|
+
):
|
|
143
|
+
raise ConfigurationError("language_aliases must contain only string keys and values")
|
|
144
|
+
_validate_bool(self.diagnostics, "diagnostics")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def semantic_config(config: PlannerConfig | Mapping[str, object]) -> dict[str, object]:
|
|
148
|
+
"""Return only configuration fields that can change the planning result."""
|
|
149
|
+
value = asdict(config) if isinstance(config, PlannerConfig) else dict(config)
|
|
150
|
+
value.pop("diagnostics", None)
|
|
151
|
+
return value
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
__all__ = [
|
|
155
|
+
"LinguisticsConfig",
|
|
156
|
+
"PauseConfig",
|
|
157
|
+
"PlannerConfig",
|
|
158
|
+
"SSMDConfig",
|
|
159
|
+
"parse_duration",
|
|
160
|
+
"semantic_config",
|
|
161
|
+
]
|
utterplan/diagnostics.py
ADDED
utterplan/directives.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .model import (
|
|
7
|
+
AnnotationSpan,
|
|
8
|
+
AudioDirective,
|
|
9
|
+
EmphasisDirective,
|
|
10
|
+
PlanSegment,
|
|
11
|
+
PronunciationDirective,
|
|
12
|
+
ProsodyDirective,
|
|
13
|
+
SegmentDirectives,
|
|
14
|
+
VoiceDirective,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def resolve_directives(
|
|
19
|
+
segment: PlanSegment, annotations: tuple[AnnotationSpan, ...]
|
|
20
|
+
) -> PlanSegment:
|
|
21
|
+
selected = sorted(
|
|
22
|
+
(
|
|
23
|
+
annotation
|
|
24
|
+
for annotation in annotations
|
|
25
|
+
if annotation.spoken_start is not None
|
|
26
|
+
and annotation.spoken_end is not None
|
|
27
|
+
and annotation.spoken_start <= segment.spoken_start
|
|
28
|
+
and segment.spoken_end <= annotation.spoken_end
|
|
29
|
+
),
|
|
30
|
+
key=lambda annotation: (
|
|
31
|
+
(annotation.spoken_end or 0) - (annotation.spoken_start or 0),
|
|
32
|
+
annotation.spoken_start or 0,
|
|
33
|
+
annotation.id,
|
|
34
|
+
),
|
|
35
|
+
)
|
|
36
|
+
voice = pronunciation = prosody = emphasis = audio = None
|
|
37
|
+
for annotation in selected:
|
|
38
|
+
attrs = annotation.attrs
|
|
39
|
+
if attrs.get("voice") or attrs.get("voice_name"):
|
|
40
|
+
voice = VoiceDirective(str(attrs.get("voice") or attrs.get("voice_name")))
|
|
41
|
+
phonemes = attrs.get("phonemes") or attrs.get("ph")
|
|
42
|
+
if phonemes:
|
|
43
|
+
pronunciation = PronunciationDirective(str(phonemes), str(attrs.get("alphabet", "ipa")))
|
|
44
|
+
if any(key in attrs for key in ("rate", "pitch", "volume", "speed")):
|
|
45
|
+
prosody = ProsodyDirective(
|
|
46
|
+
_first(attrs, "rate", "speed"),
|
|
47
|
+
_first(attrs, "pitch"),
|
|
48
|
+
_first(attrs, "volume", "loudness"),
|
|
49
|
+
)
|
|
50
|
+
if attrs.get("emphasis") or attrs.get("level"):
|
|
51
|
+
emphasis = EmphasisDirective(str(attrs.get("emphasis") or attrs.get("level")))
|
|
52
|
+
src = attrs.get("audio_src") or attrs.get("src")
|
|
53
|
+
if src:
|
|
54
|
+
audio = AudioDirective(
|
|
55
|
+
str(src),
|
|
56
|
+
_first(attrs, "alt_text", "audio_alt_text"),
|
|
57
|
+
_first(attrs, "clip_begin"),
|
|
58
|
+
_first(attrs, "clip_end"),
|
|
59
|
+
_first(attrs, "speed"),
|
|
60
|
+
_first(attrs, "repeat_duration"),
|
|
61
|
+
_int(attrs, "repeat_count"),
|
|
62
|
+
_first(attrs, "sound_level"),
|
|
63
|
+
)
|
|
64
|
+
return PlanSegment(
|
|
65
|
+
**{
|
|
66
|
+
**{name: getattr(segment, name) for name in segment.__dataclass_fields__},
|
|
67
|
+
"directives": SegmentDirectives(voice, pronunciation, prosody, emphasis, audio),
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _first(attrs: Mapping[str, Any], *names: str) -> str | None:
|
|
73
|
+
for name in names:
|
|
74
|
+
value = attrs.get(name)
|
|
75
|
+
if value is not None:
|
|
76
|
+
return str(value)
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _int(attrs: Mapping[str, Any], name: str) -> int | None:
|
|
81
|
+
value = attrs.get(name)
|
|
82
|
+
return int(value) if value is not None else None
|
utterplan/exceptions.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class UtterPlanError(Exception):
|
|
5
|
+
"""Base exception for UtterPlan."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigurationError(UtterPlanError):
|
|
9
|
+
"""A public planning configuration value is invalid."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PlanFormatError(UtterPlanError):
|
|
13
|
+
"""The serialized plan is not structurally valid."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, message: str, *, code: str = "plan.invalid", path: str = "$") -> None:
|
|
16
|
+
self.code = code
|
|
17
|
+
self.path = path
|
|
18
|
+
super().__init__(f"{code} at {path}: {message}")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class UnsupportedSchemaError(PlanFormatError):
|
|
22
|
+
def __init__(self, version: object) -> None:
|
|
23
|
+
super().__init__(
|
|
24
|
+
f"Unsupported UtterPlan schema version {version}. This version of utterplan supports schema version 1.",
|
|
25
|
+
code="schema.unsupported_version",
|
|
26
|
+
path="$.schema_version",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PlanValidationError(PlanFormatError):
|
|
31
|
+
"""The plan has valid JSON shape but invalid planning semantics."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PlanningError(UtterPlanError):
|
|
35
|
+
"""Planning could not produce a plan."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class LanguagePlanError(PlanningError):
|
|
39
|
+
"""Language spans cannot be resolved."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TextPreparationError(PlanningError):
|
|
43
|
+
"""Written-to-spoken preparation failed."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SegmentationError(PlanningError):
|
|
47
|
+
"""Segmentation failed."""
|