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.
@@ -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,6 @@
1
+ """Backend implementations."""
2
+
3
+ from .cli import CliBackend
4
+ from .native import NativeBackend
5
+
6
+ __all__ = ["CliBackend", "NativeBackend"]
@@ -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