flexaligner 0.1.0a1__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.
- flexaligner/__init__.py +101 -0
- flexaligner/__main__.py +7 -0
- flexaligner/adapters/__init__.py +1 -0
- flexaligner/adapters/hf_local.py +548 -0
- flexaligner/adapters/lexicon_file.py +399 -0
- flexaligner/adapters/wav_pcm16.py +196 -0
- flexaligner/api.py +149 -0
- flexaligner/capabilities.py +171 -0
- flexaligner/cli.py +164 -0
- flexaligner/contracts.py +267 -0
- flexaligner/core/__init__.py +89 -0
- flexaligner/core/stage1.py +1015 -0
- flexaligner/core/stage2.py +1438 -0
- flexaligner/errors.py +135 -0
- flexaligner/pipeline.py +889 -0
- flexaligner/ports.py +120 -0
- flexaligner/py.typed +1 -0
- flexaligner/textgrid.py +820 -0
- flexaligner-0.1.0a1.dist-info/METADATA +284 -0
- flexaligner-0.1.0a1.dist-info/RECORD +23 -0
- flexaligner-0.1.0a1.dist-info/WHEEL +4 -0
- flexaligner-0.1.0a1.dist-info/entry_points.txt +2 -0
- flexaligner-0.1.0a1.dist-info/licenses/LICENSE +22 -0
flexaligner/__init__.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Public package surface for the clean FlexAligner rebuild."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
__version__ = version("flexaligner")
|
|
9
|
+
except PackageNotFoundError:
|
|
10
|
+
__version__ = "0+unknown"
|
|
11
|
+
|
|
12
|
+
from .api import FlexAligner
|
|
13
|
+
from .capabilities import (
|
|
14
|
+
Capability,
|
|
15
|
+
CapabilityId,
|
|
16
|
+
CapabilityReport,
|
|
17
|
+
CapabilityStatus,
|
|
18
|
+
get_capabilities,
|
|
19
|
+
)
|
|
20
|
+
from .contracts import (
|
|
21
|
+
AlignmentOptions,
|
|
22
|
+
AlignmentRequest,
|
|
23
|
+
AlignmentResult,
|
|
24
|
+
AudioPolicy,
|
|
25
|
+
CalibrationMode,
|
|
26
|
+
ChunkResult,
|
|
27
|
+
Device,
|
|
28
|
+
Language,
|
|
29
|
+
LocalModelBundle,
|
|
30
|
+
ModelResolution,
|
|
31
|
+
PhoneInterval,
|
|
32
|
+
PronunciationMode,
|
|
33
|
+
ResourceLimits,
|
|
34
|
+
RunProvenance,
|
|
35
|
+
Score,
|
|
36
|
+
ScoreKind,
|
|
37
|
+
TextGridOutput,
|
|
38
|
+
WordInterval,
|
|
39
|
+
)
|
|
40
|
+
from .errors import (
|
|
41
|
+
AlignmentError,
|
|
42
|
+
ArtifactExistsError,
|
|
43
|
+
AudioFormatError,
|
|
44
|
+
ConfigurationError,
|
|
45
|
+
EngineClosedError,
|
|
46
|
+
ErrorCode,
|
|
47
|
+
FeatureNotAvailableError,
|
|
48
|
+
FlexAlignerError,
|
|
49
|
+
InputValidationError,
|
|
50
|
+
InternalError,
|
|
51
|
+
ModelCompatibilityError,
|
|
52
|
+
ModelValidationError,
|
|
53
|
+
OutputError,
|
|
54
|
+
OutputValidationError,
|
|
55
|
+
ResourceLimitError,
|
|
56
|
+
UnreachableAlignmentError,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
"AlignmentError",
|
|
61
|
+
"AlignmentOptions",
|
|
62
|
+
"AlignmentRequest",
|
|
63
|
+
"AlignmentResult",
|
|
64
|
+
"ArtifactExistsError",
|
|
65
|
+
"AudioFormatError",
|
|
66
|
+
"AudioPolicy",
|
|
67
|
+
"CalibrationMode",
|
|
68
|
+
"Capability",
|
|
69
|
+
"CapabilityId",
|
|
70
|
+
"CapabilityReport",
|
|
71
|
+
"CapabilityStatus",
|
|
72
|
+
"ChunkResult",
|
|
73
|
+
"ConfigurationError",
|
|
74
|
+
"Device",
|
|
75
|
+
"EngineClosedError",
|
|
76
|
+
"ErrorCode",
|
|
77
|
+
"FeatureNotAvailableError",
|
|
78
|
+
"FlexAligner",
|
|
79
|
+
"FlexAlignerError",
|
|
80
|
+
"InputValidationError",
|
|
81
|
+
"InternalError",
|
|
82
|
+
"Language",
|
|
83
|
+
"LocalModelBundle",
|
|
84
|
+
"ModelCompatibilityError",
|
|
85
|
+
"ModelResolution",
|
|
86
|
+
"ModelValidationError",
|
|
87
|
+
"OutputError",
|
|
88
|
+
"OutputValidationError",
|
|
89
|
+
"PhoneInterval",
|
|
90
|
+
"PronunciationMode",
|
|
91
|
+
"ResourceLimitError",
|
|
92
|
+
"ResourceLimits",
|
|
93
|
+
"RunProvenance",
|
|
94
|
+
"Score",
|
|
95
|
+
"ScoreKind",
|
|
96
|
+
"TextGridOutput",
|
|
97
|
+
"UnreachableAlignmentError",
|
|
98
|
+
"WordInterval",
|
|
99
|
+
"__version__",
|
|
100
|
+
"get_capabilities",
|
|
101
|
+
]
|
flexaligner/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Lazy local adapters for strict input and inference boundaries."""
|
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
"""Offline, CPU-only Hugging Face CTC inference adapter.
|
|
2
|
+
|
|
3
|
+
The optional inference dependencies are deliberately imported only while a
|
|
4
|
+
session context is being entered. Importing :mod:`flexaligner` therefore does
|
|
5
|
+
not import PyTorch or Transformers and cannot trigger model discovery.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import gc
|
|
11
|
+
import importlib
|
|
12
|
+
import math
|
|
13
|
+
import threading
|
|
14
|
+
import traceback
|
|
15
|
+
from collections.abc import Iterator, Mapping
|
|
16
|
+
from contextlib import AbstractContextManager, contextmanager
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from types import MappingProxyType, ModuleType
|
|
19
|
+
from typing import Any, cast
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
from flexaligner.errors import AlignmentError, ModelCompatibilityError, ModelValidationError
|
|
24
|
+
from flexaligner.ports import CtcPosterior, CtcSessionPort, Float32Array
|
|
25
|
+
|
|
26
|
+
_TARGET_SAMPLE_RATE = 16_000
|
|
27
|
+
_ALIGNER_NOMINAL_STRIDE_SAMPLES = 160
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _LocalCtcSession:
|
|
31
|
+
"""One validated, explicitly disposable CTC model session."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
*,
|
|
36
|
+
kind: str,
|
|
37
|
+
torch_module: ModuleType,
|
|
38
|
+
processor: Any,
|
|
39
|
+
tokenizer: Any,
|
|
40
|
+
model: Any,
|
|
41
|
+
vocabulary: Mapping[str, int],
|
|
42
|
+
model_vocab_size: int,
|
|
43
|
+
sample_rate: int,
|
|
44
|
+
pad_token: str | None,
|
|
45
|
+
) -> None:
|
|
46
|
+
self._kind = kind
|
|
47
|
+
self._torch: ModuleType | None = torch_module
|
|
48
|
+
self._processor: Any | None = processor
|
|
49
|
+
self._tokenizer: Any | None = tokenizer
|
|
50
|
+
self._model: Any | None = model
|
|
51
|
+
self._vocabulary: Mapping[str, int] | None = MappingProxyType(dict(vocabulary))
|
|
52
|
+
self._model_vocab_size: int | None = model_vocab_size
|
|
53
|
+
self._sample_rate: int | None = sample_rate
|
|
54
|
+
self._pad_token = pad_token
|
|
55
|
+
|
|
56
|
+
def _require_open(self) -> None:
|
|
57
|
+
if self._model is None or self._processor is None or self._torch is None:
|
|
58
|
+
raise AlignmentError(
|
|
59
|
+
f"The {self._kind} inference session is closed.",
|
|
60
|
+
context={"session": self._kind},
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def vocabulary(self) -> Mapping[str, int]:
|
|
65
|
+
self._require_open()
|
|
66
|
+
assert self._vocabulary is not None
|
|
67
|
+
return self._vocabulary
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def model_vocab_size(self) -> int:
|
|
71
|
+
self._require_open()
|
|
72
|
+
assert self._model_vocab_size is not None
|
|
73
|
+
return self._model_vocab_size
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def sample_rate(self) -> int:
|
|
77
|
+
self._require_open()
|
|
78
|
+
assert self._sample_rate is not None
|
|
79
|
+
return self._sample_rate
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def pad_token(self) -> str | None:
|
|
83
|
+
self._require_open()
|
|
84
|
+
return self._pad_token
|
|
85
|
+
|
|
86
|
+
def infer(self, audio: Float32Array, sample_rate: int) -> CtcPosterior:
|
|
87
|
+
"""Return finite contiguous float32 log probabilities for one waveform."""
|
|
88
|
+
|
|
89
|
+
self._require_open()
|
|
90
|
+
if not isinstance(audio, np.ndarray):
|
|
91
|
+
raise AlignmentError(
|
|
92
|
+
"Audio must be a NumPy ndarray.",
|
|
93
|
+
context={"session": self._kind},
|
|
94
|
+
)
|
|
95
|
+
if audio.dtype != np.dtype(np.float32):
|
|
96
|
+
raise AlignmentError(
|
|
97
|
+
"Audio must have dtype float32.",
|
|
98
|
+
context={"session": self._kind, "dtype": str(audio.dtype)},
|
|
99
|
+
)
|
|
100
|
+
if audio.ndim != 1:
|
|
101
|
+
raise AlignmentError(
|
|
102
|
+
"Audio must be one-dimensional.",
|
|
103
|
+
context={"session": self._kind, "ndim": int(audio.ndim)},
|
|
104
|
+
)
|
|
105
|
+
if audio.size <= 0:
|
|
106
|
+
raise AlignmentError(
|
|
107
|
+
"Audio must not be empty.",
|
|
108
|
+
context={"session": self._kind},
|
|
109
|
+
)
|
|
110
|
+
if not bool(np.isfinite(audio).all()):
|
|
111
|
+
raise AlignmentError(
|
|
112
|
+
"Audio contains NaN or infinity.",
|
|
113
|
+
context={"session": self._kind},
|
|
114
|
+
)
|
|
115
|
+
if type(sample_rate) is not int or sample_rate != _TARGET_SAMPLE_RATE:
|
|
116
|
+
raise AlignmentError(
|
|
117
|
+
f"Inference requires {_TARGET_SAMPLE_RATE} Hz audio.",
|
|
118
|
+
context={"session": self._kind, "sample_rate": sample_rate},
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
torch_module = cast(Any, self._torch)
|
|
122
|
+
processor = cast(Any, self._processor)
|
|
123
|
+
model = cast(Any, self._model)
|
|
124
|
+
cpu = torch_module.device("cpu")
|
|
125
|
+
waveform = np.ascontiguousarray(audio, dtype=np.float32)
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
with torch_module.inference_mode():
|
|
129
|
+
encoded = processor(
|
|
130
|
+
waveform,
|
|
131
|
+
sampling_rate=sample_rate,
|
|
132
|
+
return_tensors="pt",
|
|
133
|
+
)
|
|
134
|
+
if not isinstance(encoded, Mapping) or not encoded:
|
|
135
|
+
raise AlignmentError(
|
|
136
|
+
"Processor returned an empty or invalid input mapping.",
|
|
137
|
+
context={"session": self._kind},
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
inputs: dict[str, Any] = {}
|
|
141
|
+
for name, value in encoded.items():
|
|
142
|
+
if not isinstance(name, str):
|
|
143
|
+
raise AlignmentError(
|
|
144
|
+
"Processor input names must be strings.",
|
|
145
|
+
context={"session": self._kind},
|
|
146
|
+
)
|
|
147
|
+
move_to_cpu = getattr(value, "to", None)
|
|
148
|
+
if not callable(move_to_cpu):
|
|
149
|
+
raise AlignmentError(
|
|
150
|
+
"Processor returned a value that cannot be moved to CPU.",
|
|
151
|
+
context={"session": self._kind, "input": name},
|
|
152
|
+
)
|
|
153
|
+
inputs[name] = move_to_cpu(cpu)
|
|
154
|
+
|
|
155
|
+
output = model(**inputs)
|
|
156
|
+
logits = getattr(output, "logits", None)
|
|
157
|
+
logits_array = _tensor_to_numpy(logits, session_kind=self._kind)
|
|
158
|
+
if logits_array.ndim != 3 or logits_array.shape[0] != 1:
|
|
159
|
+
raise AlignmentError(
|
|
160
|
+
"Model logits must have shape [1, T, V].",
|
|
161
|
+
context={"session": self._kind, "shape": str(logits_array.shape)},
|
|
162
|
+
)
|
|
163
|
+
if logits_array.shape[1] <= 0 or logits_array.shape[2] <= 0:
|
|
164
|
+
raise AlignmentError(
|
|
165
|
+
"Model logits must have positive time and vocabulary dimensions.",
|
|
166
|
+
context={"session": self._kind, "shape": str(logits_array.shape)},
|
|
167
|
+
)
|
|
168
|
+
if logits_array.shape[2] != self.model_vocab_size:
|
|
169
|
+
raise ModelCompatibilityError(
|
|
170
|
+
"Model output vocabulary does not match config.vocab_size.",
|
|
171
|
+
context={
|
|
172
|
+
"session": self._kind,
|
|
173
|
+
"logits_vocab_size": int(logits_array.shape[2]),
|
|
174
|
+
"model_vocab_size": self.model_vocab_size,
|
|
175
|
+
},
|
|
176
|
+
)
|
|
177
|
+
if not bool(np.isfinite(logits_array).all()):
|
|
178
|
+
raise AlignmentError(
|
|
179
|
+
"Model logits contain NaN or infinity.",
|
|
180
|
+
context={"session": self._kind},
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
log_probs_tensor = torch_module.log_softmax(logits, dim=-1)
|
|
184
|
+
log_probs_array = _tensor_to_numpy(
|
|
185
|
+
log_probs_tensor,
|
|
186
|
+
session_kind=self._kind,
|
|
187
|
+
)[0]
|
|
188
|
+
except (AlignmentError, ModelCompatibilityError):
|
|
189
|
+
raise
|
|
190
|
+
except Exception as exc:
|
|
191
|
+
raise AlignmentError(
|
|
192
|
+
f"{self._kind.capitalize()} inference failed.",
|
|
193
|
+
context={"session": self._kind},
|
|
194
|
+
) from exc
|
|
195
|
+
|
|
196
|
+
log_probs = np.ascontiguousarray(log_probs_array, dtype=np.float32)
|
|
197
|
+
if log_probs.ndim != 2 or log_probs.shape[0] <= 0:
|
|
198
|
+
raise AlignmentError(
|
|
199
|
+
"Log probabilities must have shape [T, V] with T > 0.",
|
|
200
|
+
context={"session": self._kind, "shape": str(log_probs.shape)},
|
|
201
|
+
)
|
|
202
|
+
if log_probs.shape[1] != self.model_vocab_size:
|
|
203
|
+
raise ModelCompatibilityError(
|
|
204
|
+
"Log-probability vocabulary does not match config.vocab_size.",
|
|
205
|
+
context={
|
|
206
|
+
"session": self._kind,
|
|
207
|
+
"log_probs_vocab_size": int(log_probs.shape[1]),
|
|
208
|
+
"model_vocab_size": self.model_vocab_size,
|
|
209
|
+
},
|
|
210
|
+
)
|
|
211
|
+
if not bool(np.isfinite(log_probs).all()):
|
|
212
|
+
raise AlignmentError(
|
|
213
|
+
"Model produced non-finite log probabilities.",
|
|
214
|
+
context={"session": self._kind},
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
seconds_per_frame = (float(audio.size) / float(sample_rate)) / float(log_probs.shape[0])
|
|
218
|
+
if seconds_per_frame <= 0.0 or not math.isfinite(seconds_per_frame):
|
|
219
|
+
raise AlignmentError(
|
|
220
|
+
"Could not derive a finite positive frame duration.",
|
|
221
|
+
context={"session": self._kind},
|
|
222
|
+
)
|
|
223
|
+
return CtcPosterior(log_probs=log_probs, seconds_per_frame=seconds_per_frame)
|
|
224
|
+
|
|
225
|
+
def close(self) -> None:
|
|
226
|
+
"""Sever every heavyweight reference, including on exceptional exits."""
|
|
227
|
+
|
|
228
|
+
self._model = None
|
|
229
|
+
self._processor = None
|
|
230
|
+
self._tokenizer = None
|
|
231
|
+
self._torch = None
|
|
232
|
+
self._vocabulary = None
|
|
233
|
+
self._model_vocab_size = None
|
|
234
|
+
self._sample_rate = None
|
|
235
|
+
self._pad_token = None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _tensor_to_numpy(value: Any, *, session_kind: str) -> np.ndarray[Any, Any]:
|
|
239
|
+
if value is None:
|
|
240
|
+
raise AlignmentError(
|
|
241
|
+
"Model output has no logits tensor.",
|
|
242
|
+
context={"session": session_kind},
|
|
243
|
+
)
|
|
244
|
+
try:
|
|
245
|
+
detached = value.detach()
|
|
246
|
+
on_cpu = detached.cpu()
|
|
247
|
+
return np.asarray(on_cpu.numpy())
|
|
248
|
+
except Exception as exc:
|
|
249
|
+
raise AlignmentError(
|
|
250
|
+
"Model output could not be converted to a CPU NumPy array.",
|
|
251
|
+
context={"session": session_kind},
|
|
252
|
+
) from exc
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class LocalHuggingFaceInferenceFactory:
|
|
256
|
+
"""Create mutually exclusive, local-only Chunker and Aligner sessions."""
|
|
257
|
+
|
|
258
|
+
def __init__(self) -> None:
|
|
259
|
+
self._state_lock = threading.Lock()
|
|
260
|
+
self._active_kind: str | None = None
|
|
261
|
+
self._active_session: _LocalCtcSession | None = None
|
|
262
|
+
|
|
263
|
+
def chunker_session(
|
|
264
|
+
self,
|
|
265
|
+
model_dir: Path,
|
|
266
|
+
*,
|
|
267
|
+
num_threads: int,
|
|
268
|
+
) -> AbstractContextManager[CtcSessionPort]:
|
|
269
|
+
return self._open_session("chunker", model_dir, num_threads=num_threads)
|
|
270
|
+
|
|
271
|
+
def aligner_session(
|
|
272
|
+
self,
|
|
273
|
+
model_dir: Path,
|
|
274
|
+
*,
|
|
275
|
+
num_threads: int,
|
|
276
|
+
) -> AbstractContextManager[CtcSessionPort]:
|
|
277
|
+
return self._open_session("aligner", model_dir, num_threads=num_threads)
|
|
278
|
+
|
|
279
|
+
@contextmanager
|
|
280
|
+
def _open_session(
|
|
281
|
+
self,
|
|
282
|
+
kind: str,
|
|
283
|
+
model_dir: Path,
|
|
284
|
+
*,
|
|
285
|
+
num_threads: int,
|
|
286
|
+
) -> Iterator[CtcSessionPort]:
|
|
287
|
+
session: _LocalCtcSession | None = None
|
|
288
|
+
claimed = False
|
|
289
|
+
try:
|
|
290
|
+
if type(num_threads) is not int or num_threads <= 0:
|
|
291
|
+
raise ModelValidationError(
|
|
292
|
+
"num_threads must be a positive integer.",
|
|
293
|
+
context={"session": kind, "num_threads": num_threads},
|
|
294
|
+
)
|
|
295
|
+
try:
|
|
296
|
+
path = Path(model_dir)
|
|
297
|
+
except Exception as exc:
|
|
298
|
+
raise ModelValidationError(
|
|
299
|
+
"Model path must be path-like.",
|
|
300
|
+
context={"session": kind},
|
|
301
|
+
) from exc
|
|
302
|
+
if not path.is_dir():
|
|
303
|
+
raise ModelValidationError(
|
|
304
|
+
"Model path must be an existing local directory.",
|
|
305
|
+
context={"session": kind, "model_dir": str(path)},
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
with self._state_lock:
|
|
309
|
+
if self._active_kind is not None:
|
|
310
|
+
raise ModelValidationError(
|
|
311
|
+
"Chunker and Aligner sessions may not overlap.",
|
|
312
|
+
context={"active_session": self._active_kind, "requested_session": kind},
|
|
313
|
+
)
|
|
314
|
+
self._active_kind = kind
|
|
315
|
+
claimed = True
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
torch_module, transformers_module = _import_inference_dependencies(kind)
|
|
319
|
+
try:
|
|
320
|
+
torch_api = cast(Any, torch_module)
|
|
321
|
+
torch_api.set_num_threads(num_threads)
|
|
322
|
+
session = _load_local_session(
|
|
323
|
+
kind=kind,
|
|
324
|
+
model_dir=path,
|
|
325
|
+
torch_module=torch_module,
|
|
326
|
+
transformers_module=transformers_module,
|
|
327
|
+
)
|
|
328
|
+
finally:
|
|
329
|
+
del torch_api, torch_module, transformers_module
|
|
330
|
+
except (ModelValidationError, ModelCompatibilityError, AlignmentError):
|
|
331
|
+
raise
|
|
332
|
+
except Exception as exc:
|
|
333
|
+
raise ModelValidationError(
|
|
334
|
+
f"Could not initialize the local {kind} model.",
|
|
335
|
+
context={"session": kind, "model_dir": str(model_dir)},
|
|
336
|
+
) from exc
|
|
337
|
+
with self._state_lock:
|
|
338
|
+
self._active_session = session
|
|
339
|
+
yield session
|
|
340
|
+
finally:
|
|
341
|
+
if session is not None:
|
|
342
|
+
session.close()
|
|
343
|
+
session = None
|
|
344
|
+
if claimed:
|
|
345
|
+
with self._state_lock:
|
|
346
|
+
self._active_session = None
|
|
347
|
+
self._active_kind = None
|
|
348
|
+
gc.collect()
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _import_inference_dependencies(kind: str) -> tuple[ModuleType, ModuleType]:
|
|
352
|
+
try:
|
|
353
|
+
torch_module = importlib.import_module("torch")
|
|
354
|
+
transformers_module = importlib.import_module("transformers")
|
|
355
|
+
except Exception as exc:
|
|
356
|
+
raise ModelValidationError(
|
|
357
|
+
"Local inference requires the 'inference' optional dependencies.",
|
|
358
|
+
context={"session": kind, "extra": "inference"},
|
|
359
|
+
) from exc
|
|
360
|
+
return torch_module, transformers_module
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _load_local_session(
|
|
364
|
+
*,
|
|
365
|
+
kind: str,
|
|
366
|
+
model_dir: Path,
|
|
367
|
+
torch_module: ModuleType,
|
|
368
|
+
transformers_module: ModuleType,
|
|
369
|
+
) -> _LocalCtcSession:
|
|
370
|
+
"""Load a session without retaining heavyweight locals on failure tracebacks."""
|
|
371
|
+
|
|
372
|
+
try:
|
|
373
|
+
return _load_local_session_impl(
|
|
374
|
+
kind=kind,
|
|
375
|
+
model_dir=model_dir,
|
|
376
|
+
torch_module=torch_module,
|
|
377
|
+
transformers_module=transformers_module,
|
|
378
|
+
)
|
|
379
|
+
except BaseException as exc:
|
|
380
|
+
_clear_exception_traceback_frames(exc)
|
|
381
|
+
raise
|
|
382
|
+
finally:
|
|
383
|
+
del torch_module, transformers_module
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _clear_exception_traceback_frames(exc: BaseException) -> None:
|
|
387
|
+
"""Clear completed traceback frames while preserving the typed cause chain."""
|
|
388
|
+
|
|
389
|
+
seen: set[int] = set()
|
|
390
|
+
current: BaseException | None = exc
|
|
391
|
+
while current is not None and id(current) not in seen:
|
|
392
|
+
seen.add(id(current))
|
|
393
|
+
traceback.clear_frames(current.__traceback__)
|
|
394
|
+
current = current.__cause__ if current.__cause__ is not None else current.__context__
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _load_local_session_impl(
|
|
398
|
+
*,
|
|
399
|
+
kind: str,
|
|
400
|
+
model_dir: Path,
|
|
401
|
+
torch_module: ModuleType,
|
|
402
|
+
transformers_module: ModuleType,
|
|
403
|
+
) -> _LocalCtcSession:
|
|
404
|
+
transformers_api = cast(Any, transformers_module)
|
|
405
|
+
torch_api = cast(Any, torch_module)
|
|
406
|
+
load_options = {"local_files_only": True, "trust_remote_code": False}
|
|
407
|
+
try:
|
|
408
|
+
processor = transformers_api.AutoProcessor.from_pretrained(
|
|
409
|
+
str(model_dir),
|
|
410
|
+
**load_options,
|
|
411
|
+
)
|
|
412
|
+
model = transformers_api.AutoModelForCTC.from_pretrained(
|
|
413
|
+
str(model_dir),
|
|
414
|
+
**load_options,
|
|
415
|
+
)
|
|
416
|
+
cpu = torch_api.device("cpu")
|
|
417
|
+
moved_model = model.to(cpu)
|
|
418
|
+
if moved_model is not None:
|
|
419
|
+
model = moved_model
|
|
420
|
+
evaluated_model = model.eval()
|
|
421
|
+
if evaluated_model is not None:
|
|
422
|
+
model = evaluated_model
|
|
423
|
+
except Exception as exc:
|
|
424
|
+
raise ModelValidationError(
|
|
425
|
+
f"Could not load the local {kind} Hugging Face bundle.",
|
|
426
|
+
context={"session": kind, "model_dir": str(model_dir)},
|
|
427
|
+
) from exc
|
|
428
|
+
|
|
429
|
+
feature_extractor = getattr(processor, "feature_extractor", None)
|
|
430
|
+
sample_rate = getattr(feature_extractor, "sampling_rate", None)
|
|
431
|
+
if type(sample_rate) is not int:
|
|
432
|
+
raise ModelValidationError(
|
|
433
|
+
"Processor feature_extractor.sampling_rate must be an integer.",
|
|
434
|
+
context={"session": kind, "sample_rate": str(sample_rate)},
|
|
435
|
+
)
|
|
436
|
+
if sample_rate != _TARGET_SAMPLE_RATE:
|
|
437
|
+
raise ModelCompatibilityError(
|
|
438
|
+
f"Processor must require {_TARGET_SAMPLE_RATE} Hz audio.",
|
|
439
|
+
context={"session": kind, "sample_rate": sample_rate},
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
if kind == "aligner":
|
|
443
|
+
_validate_aligner_nominal_stride(model)
|
|
444
|
+
|
|
445
|
+
tokenizer = getattr(processor, "tokenizer", None)
|
|
446
|
+
get_vocab = getattr(tokenizer, "get_vocab", None)
|
|
447
|
+
if not callable(get_vocab):
|
|
448
|
+
raise ModelValidationError(
|
|
449
|
+
"Processor tokenizer must provide get_vocab().",
|
|
450
|
+
context={"session": kind},
|
|
451
|
+
)
|
|
452
|
+
try:
|
|
453
|
+
raw_vocabulary = get_vocab()
|
|
454
|
+
except Exception as exc:
|
|
455
|
+
raise ModelValidationError(
|
|
456
|
+
"Tokenizer get_vocab() failed.",
|
|
457
|
+
context={"session": kind},
|
|
458
|
+
) from exc
|
|
459
|
+
vocabulary = _validate_vocabulary(raw_vocabulary, kind=kind)
|
|
460
|
+
|
|
461
|
+
model_vocab_size = getattr(getattr(model, "config", None), "vocab_size", None)
|
|
462
|
+
if type(model_vocab_size) is not int or model_vocab_size <= 0:
|
|
463
|
+
raise ModelValidationError(
|
|
464
|
+
"Model config.vocab_size must be a positive integer.",
|
|
465
|
+
context={"session": kind, "model_vocab_size": str(model_vocab_size)},
|
|
466
|
+
)
|
|
467
|
+
if any(token_id >= model_vocab_size for token_id in vocabulary.values()):
|
|
468
|
+
raise ModelCompatibilityError(
|
|
469
|
+
"Tokenizer vocabulary contains an id outside model config.vocab_size.",
|
|
470
|
+
context={"session": kind, "model_vocab_size": model_vocab_size},
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
pad_token = getattr(tokenizer, "pad_token", None)
|
|
474
|
+
if pad_token is not None and not isinstance(pad_token, str):
|
|
475
|
+
raise ModelValidationError(
|
|
476
|
+
"Tokenizer pad_token must be a string or None.",
|
|
477
|
+
context={"session": kind},
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
return _LocalCtcSession(
|
|
481
|
+
kind=kind,
|
|
482
|
+
torch_module=torch_module,
|
|
483
|
+
processor=processor,
|
|
484
|
+
tokenizer=tokenizer,
|
|
485
|
+
model=model,
|
|
486
|
+
vocabulary=vocabulary,
|
|
487
|
+
model_vocab_size=model_vocab_size,
|
|
488
|
+
sample_rate=sample_rate,
|
|
489
|
+
pad_token=pad_token,
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _validate_aligner_nominal_stride(model: Any) -> None:
|
|
494
|
+
"""Require the reviewed 10 ms Aligner convolution stride at 16 kHz."""
|
|
495
|
+
|
|
496
|
+
raw_stride = getattr(getattr(model, "config", None), "conv_stride", None)
|
|
497
|
+
if not isinstance(raw_stride, (list, tuple)) or not raw_stride:
|
|
498
|
+
raise ModelValidationError(
|
|
499
|
+
"Aligner model config.conv_stride must be a non-empty list or tuple.",
|
|
500
|
+
context={"session": "aligner", "conv_stride": str(raw_stride)},
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
stride: list[int] = []
|
|
504
|
+
for value in raw_stride:
|
|
505
|
+
if type(value) is not int or value <= 0:
|
|
506
|
+
raise ModelValidationError(
|
|
507
|
+
"Aligner model config.conv_stride must contain positive integers.",
|
|
508
|
+
context={"session": "aligner", "conv_stride": str(raw_stride)},
|
|
509
|
+
)
|
|
510
|
+
stride.append(value)
|
|
511
|
+
|
|
512
|
+
nominal_stride_samples = math.prod(stride)
|
|
513
|
+
if nominal_stride_samples != _ALIGNER_NOMINAL_STRIDE_SAMPLES:
|
|
514
|
+
raise ModelCompatibilityError(
|
|
515
|
+
"Aligner model nominal convolution stride must be 160 samples (10 ms at 16 kHz).",
|
|
516
|
+
context={
|
|
517
|
+
"session": "aligner",
|
|
518
|
+
"conv_stride": str(stride),
|
|
519
|
+
"nominal_stride_samples": nominal_stride_samples,
|
|
520
|
+
"required_stride_samples": _ALIGNER_NOMINAL_STRIDE_SAMPLES,
|
|
521
|
+
"sample_rate": _TARGET_SAMPLE_RATE,
|
|
522
|
+
},
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _validate_vocabulary(value: Any, *, kind: str) -> dict[str, int]:
|
|
527
|
+
if not isinstance(value, Mapping) or not value:
|
|
528
|
+
raise ModelValidationError(
|
|
529
|
+
"Tokenizer get_vocab() must return a non-empty mapping.",
|
|
530
|
+
context={"session": kind},
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
vocabulary: dict[str, int] = {}
|
|
534
|
+
ids: set[int] = set()
|
|
535
|
+
for token, token_id in value.items():
|
|
536
|
+
if not isinstance(token, str) or type(token_id) is not int or token_id < 0:
|
|
537
|
+
raise ModelValidationError(
|
|
538
|
+
"Tokenizer vocabulary must map strings to non-negative integer ids.",
|
|
539
|
+
context={"session": kind},
|
|
540
|
+
)
|
|
541
|
+
if token_id in ids:
|
|
542
|
+
raise ModelValidationError(
|
|
543
|
+
"Tokenizer vocabulary ids must be unique.",
|
|
544
|
+
context={"session": kind, "token_id": token_id},
|
|
545
|
+
)
|
|
546
|
+
vocabulary[token] = token_id
|
|
547
|
+
ids.add(token_id)
|
|
548
|
+
return vocabulary
|