espeakng-runtime 0.1.0__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.
- espeakng_runtime/__init__.py +39 -0
- espeakng_runtime/_text.py +29 -0
- espeakng_runtime/_version.py +24 -0
- espeakng_runtime/_voices.py +89 -0
- espeakng_runtime/backends/__init__.py +6 -0
- espeakng_runtime/backends/base.py +39 -0
- espeakng_runtime/backends/cli.py +199 -0
- espeakng_runtime/backends/native.py +406 -0
- espeakng_runtime/discovery.py +376 -0
- espeakng_runtime/errors.py +25 -0
- espeakng_runtime/py.typed +0 -0
- espeakng_runtime/runtime.py +252 -0
- espeakng_runtime/types.py +60 -0
- espeakng_runtime-0.1.0.dist-info/METADATA +159 -0
- espeakng_runtime-0.1.0.dist-info/RECORD +18 -0
- espeakng_runtime-0.1.0.dist-info/WHEEL +5 -0
- espeakng_runtime-0.1.0.dist-info/licenses/LICENSE +201 -0
- espeakng_runtime-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Reliable native/CLI access to eSpeak NG phonemization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .discovery import EspeakInspection, LibraryCandidate, LibraryProbe, inspect_espeak
|
|
6
|
+
from .errors import (
|
|
7
|
+
CapabilityError,
|
|
8
|
+
EspeakConflictError,
|
|
9
|
+
EspeakError,
|
|
10
|
+
EspeakUnavailableError,
|
|
11
|
+
PhonemizationError,
|
|
12
|
+
VoiceNotFoundError,
|
|
13
|
+
)
|
|
14
|
+
from .runtime import EspeakRuntime
|
|
15
|
+
from .types import Clause, FallbackCode, RuntimeInfo, Voice
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
from ._version import __version__
|
|
19
|
+
except ImportError: # pragma: no cover - editable tree before build hook runs
|
|
20
|
+
__version__ = "0.1.dev0"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"CapabilityError",
|
|
24
|
+
"Clause",
|
|
25
|
+
"FallbackCode",
|
|
26
|
+
"EspeakConflictError",
|
|
27
|
+
"EspeakInspection",
|
|
28
|
+
"LibraryCandidate",
|
|
29
|
+
"LibraryProbe",
|
|
30
|
+
"inspect_espeak",
|
|
31
|
+
"EspeakError",
|
|
32
|
+
"EspeakRuntime",
|
|
33
|
+
"EspeakUnavailableError",
|
|
34
|
+
"PhonemizationError",
|
|
35
|
+
"RuntimeInfo",
|
|
36
|
+
"Voice",
|
|
37
|
+
"VoiceNotFoundError",
|
|
38
|
+
"__version__",
|
|
39
|
+
]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Internal text helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
_CLAUSE_RE = re.compile(r"(.*?)([.!?]+|[,;:]+|$)", re.DOTALL)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def split_best_effort_clauses(text: str) -> list[tuple[str, str | None, bool]]:
|
|
11
|
+
"""Split punctuation for CLI/non-exact fallback.
|
|
12
|
+
|
|
13
|
+
This is intentionally labeled best-effort: it is not a replacement for
|
|
14
|
+
eSpeak NG's native terminator API.
|
|
15
|
+
"""
|
|
16
|
+
result: list[tuple[str, str | None, bool]] = []
|
|
17
|
+
position = 0
|
|
18
|
+
for match in _CLAUSE_RE.finditer(text):
|
|
19
|
+
if match.start() != position:
|
|
20
|
+
continue
|
|
21
|
+
body, terminator = match.groups()
|
|
22
|
+
if not body and not terminator:
|
|
23
|
+
break
|
|
24
|
+
token = terminator or None
|
|
25
|
+
result.append((body, token, bool(token and set(token) & {".", "!", "?"})))
|
|
26
|
+
position = match.end()
|
|
27
|
+
if position >= len(text):
|
|
28
|
+
break
|
|
29
|
+
return result
|
|
@@ -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.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = 'ga7b5c0781'
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Voice request normalization and deterministic inventory selection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
from .errors import VoiceNotFoundError
|
|
8
|
+
from .types import Voice
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def normalize_voice_code(value: str) -> str:
|
|
12
|
+
"""Normalize an eSpeak voice code for comparison."""
|
|
13
|
+
return value.strip().lower().replace("\\", "/").replace("_", "-")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def is_mbrola_identifier(value: str) -> bool:
|
|
17
|
+
"""Return whether an inventory identifier denotes an MBROLA voice."""
|
|
18
|
+
return normalize_voice_code(value).startswith("mb/")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_explicit_mbrola_request(value: str) -> bool:
|
|
22
|
+
"""Return whether a request explicitly asks for an MBROLA voice."""
|
|
23
|
+
code = normalize_voice_code(value)
|
|
24
|
+
return code.startswith(("mb/", "mb-", "mbrola"))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _standard_rank(requested: str, voice: Voice) -> int | None:
|
|
28
|
+
identifier = normalize_voice_code(voice.identifier)
|
|
29
|
+
language = normalize_voice_code(voice.language)
|
|
30
|
+
identifier_name = identifier.rsplit("/", 1)[-1]
|
|
31
|
+
requested_base = requested.split("-", 1)[0]
|
|
32
|
+
|
|
33
|
+
if language == requested:
|
|
34
|
+
return 0
|
|
35
|
+
if identifier == requested:
|
|
36
|
+
return 1
|
|
37
|
+
if identifier_name == requested:
|
|
38
|
+
return 2
|
|
39
|
+
if language.split("-", 1)[0] == requested_base:
|
|
40
|
+
return 3
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def choose_voice(
|
|
45
|
+
voices: Sequence[Voice],
|
|
46
|
+
request: str,
|
|
47
|
+
*,
|
|
48
|
+
allow_mbrola: bool = False,
|
|
49
|
+
) -> Voice:
|
|
50
|
+
"""Select the best compatible voice from an eSpeak inventory."""
|
|
51
|
+
requested = normalize_voice_code(request)
|
|
52
|
+
if not requested:
|
|
53
|
+
raise VoiceNotFoundError("eSpeak voice request must not be empty")
|
|
54
|
+
|
|
55
|
+
explicit_mbrola = is_explicit_mbrola_request(requested)
|
|
56
|
+
ranked: list[tuple[int, int, Voice]] = []
|
|
57
|
+
for index, voice in enumerate(voices):
|
|
58
|
+
identifier = normalize_voice_code(voice.identifier)
|
|
59
|
+
mbrola = is_mbrola_identifier(identifier)
|
|
60
|
+
rank: int | None
|
|
61
|
+
|
|
62
|
+
if explicit_mbrola:
|
|
63
|
+
if not mbrola:
|
|
64
|
+
continue
|
|
65
|
+
if identifier == requested:
|
|
66
|
+
rank = 0
|
|
67
|
+
elif identifier.rsplit("/", 1)[-1] == requested:
|
|
68
|
+
rank = 1
|
|
69
|
+
else:
|
|
70
|
+
continue
|
|
71
|
+
else:
|
|
72
|
+
if mbrola:
|
|
73
|
+
if not allow_mbrola:
|
|
74
|
+
continue
|
|
75
|
+
rank = _standard_rank(requested, voice)
|
|
76
|
+
if rank is None:
|
|
77
|
+
continue
|
|
78
|
+
rank += 10
|
|
79
|
+
else:
|
|
80
|
+
rank = _standard_rank(requested, voice)
|
|
81
|
+
if rank is None:
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
if rank is not None:
|
|
85
|
+
ranked.append((rank, index, voice))
|
|
86
|
+
|
|
87
|
+
if not ranked:
|
|
88
|
+
raise VoiceNotFoundError(f"eSpeak voice {request!r} was not found")
|
|
89
|
+
return min(ranked, key=lambda item: (item[0], item[1]))[2]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Backend protocol."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
from ..types import Clause, RuntimeInfo, Voice
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EspeakBackend(Protocol):
|
|
12
|
+
@property
|
|
13
|
+
def info(self) -> RuntimeInfo: ...
|
|
14
|
+
|
|
15
|
+
def phonemize(
|
|
16
|
+
self,
|
|
17
|
+
text: str,
|
|
18
|
+
*,
|
|
19
|
+
voice: str,
|
|
20
|
+
separator: str | None = None,
|
|
21
|
+
use_tie: bool = False,
|
|
22
|
+
tie_char: str = "͡",
|
|
23
|
+
) -> str: ...
|
|
24
|
+
|
|
25
|
+
def phonemize_many(
|
|
26
|
+
self,
|
|
27
|
+
texts: Sequence[str],
|
|
28
|
+
*,
|
|
29
|
+
voice: str,
|
|
30
|
+
separator: str | None = None,
|
|
31
|
+
use_tie: bool = False,
|
|
32
|
+
tie_char: str = "͡",
|
|
33
|
+
) -> list[str]: ...
|
|
34
|
+
|
|
35
|
+
def clauses(self, text: str, *, voice: str, exact: bool = False) -> list[Clause]: ...
|
|
36
|
+
|
|
37
|
+
def list_voices(self, filter_name: str | None = None) -> list[Voice]: ...
|
|
38
|
+
|
|
39
|
+
def close(self) -> None: ...
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Command-line eSpeak backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
|
|
9
|
+
from .._text import split_best_effort_clauses
|
|
10
|
+
from ..discovery import data_parent_for_espeak
|
|
11
|
+
from ..errors import CapabilityError, PhonemizationError
|
|
12
|
+
from ..types import Clause, FallbackCode, RuntimeInfo, Voice
|
|
13
|
+
|
|
14
|
+
_VERSION_RE = re.compile(r"(?:eSpeak NG|eSpeak)[^0-9]*([0-9]+(?:\.[0-9]+)+)", re.I)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CliBackend:
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
*,
|
|
21
|
+
executable: str,
|
|
22
|
+
data: str | None,
|
|
23
|
+
timeout: float | None,
|
|
24
|
+
requested_mode: str,
|
|
25
|
+
fallback_reason: str | None = None,
|
|
26
|
+
fallback_code: FallbackCode | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
self.executable = executable
|
|
29
|
+
self.data = data
|
|
30
|
+
self.timeout = timeout
|
|
31
|
+
self.requested_mode = requested_mode
|
|
32
|
+
self.fallback_reason = fallback_reason
|
|
33
|
+
self.fallback_code = fallback_code
|
|
34
|
+
self._version: str | None = None
|
|
35
|
+
|
|
36
|
+
def _run(
|
|
37
|
+
self,
|
|
38
|
+
args: list[str],
|
|
39
|
+
*,
|
|
40
|
+
input_text: str | None = None,
|
|
41
|
+
) -> subprocess.CompletedProcess[str]:
|
|
42
|
+
command = [self.executable, *args]
|
|
43
|
+
if self.data:
|
|
44
|
+
command.append(f"--path={data_parent_for_espeak(self.data)}")
|
|
45
|
+
try:
|
|
46
|
+
process = subprocess.run(
|
|
47
|
+
command,
|
|
48
|
+
input=input_text,
|
|
49
|
+
text=True,
|
|
50
|
+
encoding="utf-8",
|
|
51
|
+
errors="strict",
|
|
52
|
+
capture_output=True,
|
|
53
|
+
check=False,
|
|
54
|
+
timeout=self.timeout,
|
|
55
|
+
)
|
|
56
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
57
|
+
raise PhonemizationError(f"eSpeak CLI invocation failed: {exc}") from exc
|
|
58
|
+
if process.returncode:
|
|
59
|
+
raise PhonemizationError(
|
|
60
|
+
f"eSpeak failed ({process.returncode}): {process.stderr.strip()}"
|
|
61
|
+
)
|
|
62
|
+
return process
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def version(self) -> str | None:
|
|
66
|
+
if self._version is None:
|
|
67
|
+
process = self._run(["--version"])
|
|
68
|
+
output = f"{process.stdout}\n{process.stderr}"
|
|
69
|
+
match = _VERSION_RE.search(output)
|
|
70
|
+
self._version = match.group(1) if match else output.strip().splitlines()[0]
|
|
71
|
+
return self._version
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def info(self) -> RuntimeInfo:
|
|
75
|
+
return RuntimeInfo(
|
|
76
|
+
requested_mode=self.requested_mode, # type: ignore[arg-type]
|
|
77
|
+
implementation="cli",
|
|
78
|
+
executable=self.executable,
|
|
79
|
+
data=self.data,
|
|
80
|
+
source="cli",
|
|
81
|
+
version=self.version,
|
|
82
|
+
exact_clause_api=False,
|
|
83
|
+
parity="best-effort",
|
|
84
|
+
fallback_reason=self.fallback_reason,
|
|
85
|
+
fallback_code=self.fallback_code,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def _phoneme_args(
|
|
90
|
+
*,
|
|
91
|
+
voice: str,
|
|
92
|
+
separator: str | None,
|
|
93
|
+
use_tie: bool,
|
|
94
|
+
tie_char: str,
|
|
95
|
+
) -> list[str]:
|
|
96
|
+
args = ["-q", "-x", "--ipa", f"-v{voice}"]
|
|
97
|
+
if use_tie:
|
|
98
|
+
if not tie_char:
|
|
99
|
+
raise ValueError("tie_char must not be empty")
|
|
100
|
+
args.append(f"--tie={tie_char[0]}")
|
|
101
|
+
elif separator:
|
|
102
|
+
args.append(f"--sep={separator[0]}")
|
|
103
|
+
return args
|
|
104
|
+
|
|
105
|
+
def phonemize(
|
|
106
|
+
self,
|
|
107
|
+
text: str,
|
|
108
|
+
*,
|
|
109
|
+
voice: str,
|
|
110
|
+
separator: str | None = None,
|
|
111
|
+
use_tie: bool = False,
|
|
112
|
+
tie_char: str = "͡",
|
|
113
|
+
) -> str:
|
|
114
|
+
if not text or not text.strip():
|
|
115
|
+
return ""
|
|
116
|
+
process = self._run(
|
|
117
|
+
self._phoneme_args(
|
|
118
|
+
voice=voice,
|
|
119
|
+
separator=separator,
|
|
120
|
+
use_tie=use_tie,
|
|
121
|
+
tie_char=tie_char,
|
|
122
|
+
),
|
|
123
|
+
input_text=text if text.endswith("\n") else f"{text}\n",
|
|
124
|
+
)
|
|
125
|
+
return re.sub(r"\s+", " ", process.stdout.strip())
|
|
126
|
+
|
|
127
|
+
def phonemize_many(
|
|
128
|
+
self,
|
|
129
|
+
texts: Sequence[str],
|
|
130
|
+
*,
|
|
131
|
+
voice: str,
|
|
132
|
+
separator: str | None = None,
|
|
133
|
+
use_tie: bool = False,
|
|
134
|
+
tie_char: str = "͡",
|
|
135
|
+
) -> list[str]:
|
|
136
|
+
values = list(texts)
|
|
137
|
+
nonempty = [text for text in values if text and text.strip()]
|
|
138
|
+
if not nonempty:
|
|
139
|
+
return [""] * len(values)
|
|
140
|
+
if any("\n" in text or "\r" in text for text in nonempty):
|
|
141
|
+
raise ValueError("phonemize_many inputs must not contain line breaks")
|
|
142
|
+
process = self._run(
|
|
143
|
+
self._phoneme_args(
|
|
144
|
+
voice=voice,
|
|
145
|
+
separator=separator,
|
|
146
|
+
use_tie=use_tie,
|
|
147
|
+
tie_char=tie_char,
|
|
148
|
+
),
|
|
149
|
+
input_text="\n".join(nonempty) + "\n",
|
|
150
|
+
)
|
|
151
|
+
lines = process.stdout.splitlines()
|
|
152
|
+
if len(lines) != len(nonempty):
|
|
153
|
+
raise PhonemizationError(
|
|
154
|
+
f"eSpeak returned {len(lines)} lines for {len(nonempty)} inputs"
|
|
155
|
+
)
|
|
156
|
+
iterator = iter(lines)
|
|
157
|
+
result: list[str] = []
|
|
158
|
+
for text in values:
|
|
159
|
+
if not text or not text.strip():
|
|
160
|
+
result.append("")
|
|
161
|
+
else:
|
|
162
|
+
result.append(re.sub(r"\s+", " ", next(iterator).strip()))
|
|
163
|
+
return result
|
|
164
|
+
|
|
165
|
+
def clauses(self, text: str, *, voice: str, exact: bool = False) -> list[Clause]:
|
|
166
|
+
if exact:
|
|
167
|
+
raise CapabilityError("CLI eSpeak does not expose exact clause terminator codes")
|
|
168
|
+
return [
|
|
169
|
+
Clause(
|
|
170
|
+
phonemes=self.phonemize(body, voice=voice),
|
|
171
|
+
terminator=terminator,
|
|
172
|
+
terminator_code=None,
|
|
173
|
+
sentence_end=sentence_end,
|
|
174
|
+
)
|
|
175
|
+
for body, terminator, sentence_end in split_best_effort_clauses(text)
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
def list_voices(self, filter_name: str | None = None) -> list[Voice]:
|
|
179
|
+
args = [f"--voices={filter_name}"] if filter_name else ["--voices"]
|
|
180
|
+
process = self._run(args)
|
|
181
|
+
voices: list[Voice] = []
|
|
182
|
+
for line in process.stdout.splitlines():
|
|
183
|
+
line = line.strip()
|
|
184
|
+
if not line or line.lower().startswith("pty"):
|
|
185
|
+
continue
|
|
186
|
+
parts = line.split()
|
|
187
|
+
if len(parts) < 5:
|
|
188
|
+
continue
|
|
189
|
+
voices.append(
|
|
190
|
+
Voice(
|
|
191
|
+
name=parts[3].replace("_", " "),
|
|
192
|
+
language=parts[1],
|
|
193
|
+
identifier=parts[4].replace("\\", "/"),
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
return voices
|
|
197
|
+
|
|
198
|
+
def close(self) -> None:
|
|
199
|
+
return None
|