sttx 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.
sttx/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
sttx/asr.py ADDED
@@ -0,0 +1,398 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import wave
5
+ from collections.abc import Callable, Sequence
6
+ from dataclasses import dataclass
7
+ from typing import Final, Protocol, TypeAlias, TypeVar
8
+
9
+ import numpy as np
10
+ from numpy.typing import NDArray
11
+
12
+ from sttx.audio import SAMPLE_RATE, PreparedAudio
13
+ from sttx.asr_events import (
14
+ ActivityCallback,
15
+ AsrActivity,
16
+ DecodeFinished,
17
+ DecodeStarted,
18
+ LanguageReported,
19
+ ScanAdvanced,
20
+ ScanFinished,
21
+ ScanStarted,
22
+ TranscriptionSummary,
23
+ VadSegmentReady,
24
+ WordCountUpdated,
25
+ )
26
+ from sttx.output import Segment, Transcript
27
+
28
+ VAD_WINDOW_SAMPLES: Final = 512
29
+ MAX_CHUNK_SAMPLES: Final = 480_000
30
+ CONTROL_TOKENS: Final = frozenset({"", "<blk>", "<blank>", "<s>", "</s>", "<unk>"})
31
+ SENTENCE_PUNCTUATION: Final = frozenset({".", "!", "?"})
32
+ TIMING_TOLERANCE: Final = 1e-3
33
+
34
+ FloatSamples: TypeAlias = NDArray[np.float32]
35
+ ProgressCallback: TypeAlias = Callable[[int, int], None]
36
+
37
+
38
+ class RecognitionStream(Protocol):
39
+ @property
40
+ def result(self) -> RecognitionResult: ...
41
+
42
+ def accept_waveform(
43
+ self,
44
+ sample_rate: int,
45
+ samples: FloatSamples,
46
+ ) -> None: ...
47
+
48
+
49
+ class RecognitionResult(Protocol):
50
+ @property
51
+ def text(self) -> str: ...
52
+
53
+ @property
54
+ def tokens(self) -> Sequence[str]: ...
55
+
56
+ @property
57
+ def timestamps(self) -> Sequence[float]: ...
58
+
59
+ @property
60
+ def durations(self) -> Sequence[float]: ...
61
+
62
+ @property
63
+ def lang(self) -> str: ...
64
+
65
+
66
+ StreamT = TypeVar("StreamT", bound=RecognitionStream)
67
+ ResultT = TypeVar("ResultT", bound=RecognitionResult)
68
+
69
+
70
+ class Recognizer(Protocol[StreamT, ResultT]):
71
+ def create_stream(self) -> StreamT: ...
72
+
73
+ def decode_stream(self, stream: StreamT) -> None: ...
74
+
75
+
76
+ class VadSegment(Protocol):
77
+ @property
78
+ def start(self) -> int: ...
79
+
80
+ @property
81
+ def samples(self) -> FloatSamples: ...
82
+
83
+
84
+ class VoiceActivityDetector(Protocol):
85
+ def accept_waveform(self, samples: FloatSamples) -> None: ...
86
+
87
+ def flush(self) -> None: ...
88
+
89
+ def empty(self) -> bool: ...
90
+
91
+ @property
92
+ def front(self) -> VadSegment: ...
93
+
94
+ def pop(self) -> None: ...
95
+
96
+
97
+ @dataclass(frozen=True, slots=True)
98
+ class TranscriptionError(Exception):
99
+ reason: str
100
+
101
+ def __str__(self) -> str:
102
+ return f"transcription failed: {self.reason}"
103
+
104
+
105
+ @dataclass(frozen=True, slots=True)
106
+ class WordEvent:
107
+ text: str
108
+ start: float
109
+ end: float
110
+
111
+
112
+ @dataclass(slots=True) # noqa: MUTABLE_OK
113
+ class _PipelineState:
114
+ words: list[WordEvent]
115
+ sample_count: int
116
+ activity: ActivityCallback | None
117
+ language: str = ""
118
+ voiced_samples: int = 0
119
+ vad_segments: int = 0
120
+ decoded_samples: int = 0
121
+ decode_chunks: int = 0
122
+
123
+
124
+ def transcribe(
125
+ audio: PreparedAudio,
126
+ *,
127
+ recognizer: Recognizer[StreamT, ResultT],
128
+ vad: VoiceActivityDetector,
129
+ progress: ProgressCallback | None = None,
130
+ activity: ActivityCallback | None = None,
131
+ ) -> Transcript:
132
+ samples = _read_samples(audio)
133
+ state = _PipelineState(
134
+ words=[],
135
+ sample_count=audio.sample_count,
136
+ activity=activity,
137
+ )
138
+ _emit_activity(activity, ScanStarted(total_samples=audio.sample_count))
139
+ for start in range(0, len(samples), VAD_WINDOW_SAMPLES):
140
+ vad.accept_waveform(samples[start : start + VAD_WINDOW_SAMPLES])
141
+ _drain_vad(vad, recognizer, state)
142
+ processed_samples = min(start + VAD_WINDOW_SAMPLES, audio.sample_count)
143
+ _emit_activity(
144
+ activity,
145
+ ScanAdvanced(
146
+ processed_samples=processed_samples,
147
+ total_samples=audio.sample_count,
148
+ ),
149
+ )
150
+ if progress is not None and processed_samples < audio.sample_count:
151
+ progress(processed_samples, audio.sample_count)
152
+ vad.flush()
153
+ _drain_vad(vad, recognizer, state)
154
+ if progress is not None:
155
+ progress(audio.sample_count, audio.sample_count)
156
+ _emit_activity(activity, ScanFinished(total_samples=audio.sample_count))
157
+ transcript = Transcript(
158
+ language=state.language or "auto",
159
+ duration=audio.duration,
160
+ segments=_sentence_segments(state.words),
161
+ )
162
+ _emit_activity(
163
+ activity,
164
+ TranscriptionSummary(
165
+ total_samples=audio.sample_count,
166
+ voiced_samples=state.voiced_samples,
167
+ vad_segments=state.vad_segments,
168
+ decoded_samples=state.decoded_samples,
169
+ decode_chunks=state.decode_chunks,
170
+ word_count=len(state.words),
171
+ language=transcript.language,
172
+ transcript_segments=len(transcript.segments),
173
+ ),
174
+ )
175
+ return transcript
176
+
177
+
178
+ def _read_samples(audio: PreparedAudio) -> FloatSamples:
179
+ try:
180
+ with wave.open(str(audio.path), "rb") as wav_file:
181
+ raw_samples = wav_file.readframes(wav_file.getnframes())
182
+ except (EOFError, OSError, wave.Error) as error:
183
+ raise TranscriptionError(reason=f"cannot read normalized WAV: {error}") from error
184
+ samples = np.frombuffer(raw_samples, dtype=np.int16)
185
+ if len(samples) != audio.sample_count:
186
+ raise TranscriptionError(
187
+ reason=(
188
+ "normalized WAV sample count changed: "
189
+ f"{len(samples)} != {audio.sample_count}"
190
+ )
191
+ )
192
+ return samples.astype(np.float32) / 32768.0
193
+
194
+
195
+ def _drain_vad(
196
+ vad: VoiceActivityDetector,
197
+ recognizer: Recognizer[StreamT, ResultT],
198
+ state: _PipelineState,
199
+ ) -> None:
200
+ while not vad.empty():
201
+ segment = vad.front
202
+ state.vad_segments += 1
203
+ state.voiced_samples += len(segment.samples)
204
+ _emit_activity(
205
+ state.activity,
206
+ VadSegmentReady(
207
+ index=state.vad_segments,
208
+ start_sample=segment.start,
209
+ sample_count=len(segment.samples),
210
+ ),
211
+ )
212
+ _decode_segment(segment, recognizer, state)
213
+ vad.pop()
214
+
215
+
216
+ def _decode_segment(
217
+ segment: VadSegment,
218
+ recognizer: Recognizer[StreamT, ResultT],
219
+ state: _PipelineState,
220
+ ) -> None:
221
+ if segment.start < 0 or segment.start + len(segment.samples) > state.sample_count:
222
+ raise TranscriptionError(reason="VAD segment lies outside WAV duration")
223
+ for split_start in range(0, len(segment.samples), MAX_CHUNK_SAMPLES):
224
+ chunk = segment.samples[split_start : split_start + MAX_CHUNK_SAMPLES]
225
+ chunk_start = segment.start + split_start
226
+ state.decode_chunks += 1
227
+ _emit_activity(
228
+ state.activity,
229
+ DecodeStarted(
230
+ index=state.decode_chunks,
231
+ start_sample=chunk_start,
232
+ sample_count=len(chunk),
233
+ ),
234
+ )
235
+ stream = recognizer.create_stream()
236
+ stream.accept_waveform(SAMPLE_RATE, chunk)
237
+ recognizer.decode_stream(stream)
238
+ state.decoded_samples += len(chunk)
239
+ _emit_activity(
240
+ state.activity,
241
+ DecodeFinished(
242
+ index=state.decode_chunks,
243
+ start_sample=chunk_start,
244
+ sample_count=len(chunk),
245
+ ),
246
+ )
247
+ result = stream.result
248
+ if not state.language and result.lang.strip():
249
+ state.language = result.lang.strip()
250
+ _emit_activity(
251
+ state.activity,
252
+ LanguageReported(language=state.language),
253
+ )
254
+ offset = chunk_start / SAMPLE_RATE
255
+ words = _word_events(result, offset=offset, chunk_duration=len(chunk) / SAMPLE_RATE)
256
+ previous_word_count = len(state.words)
257
+ _append_monotonic(state.words, words, state.sample_count / SAMPLE_RATE)
258
+ if len(state.words) > previous_word_count:
259
+ _emit_activity(
260
+ state.activity,
261
+ WordCountUpdated(word_count=len(state.words)),
262
+ )
263
+
264
+
265
+ def _emit_activity(activity: ActivityCallback | None, event: AsrActivity) -> None:
266
+ if activity is not None:
267
+ activity(event)
268
+
269
+
270
+ def _word_events(
271
+ result: RecognitionResult,
272
+ *,
273
+ offset: float,
274
+ chunk_duration: float,
275
+ ) -> tuple[WordEvent, ...]:
276
+ tokens = tuple(result.tokens)
277
+ timestamps = tuple(float(timestamp) for timestamp in result.timestamps)
278
+ durations = tuple(float(duration) for duration in result.durations)
279
+ if len(tokens) != len(timestamps):
280
+ raise TranscriptionError(reason="token/timestamp cardinality mismatch")
281
+ if durations and len(durations) != len(tokens):
282
+ raise TranscriptionError(reason="token/duration cardinality mismatch")
283
+ if not tokens:
284
+ return ()
285
+ if not _valid_starts(timestamps, chunk_duration):
286
+ raise TranscriptionError(reason="invalid token timestamps")
287
+ if durations:
288
+ if not all(math.isfinite(value) and value >= 0 for value in durations):
289
+ raise TranscriptionError(reason="invalid token durations")
290
+ ends = tuple(
291
+ start + duration
292
+ for start, duration in zip(timestamps, durations, strict=True)
293
+ )
294
+ else:
295
+ ends = (*timestamps[1:], chunk_duration)
296
+ if any(
297
+ end < start
298
+ or end > chunk_duration + TIMING_TOLERANCE
299
+ or not math.isfinite(end)
300
+ for start, end in zip(timestamps, ends, strict=True)
301
+ ):
302
+ raise TranscriptionError(reason="token end lies outside its chunk")
303
+
304
+ words: list[WordEvent] = []
305
+ current_text = ""
306
+ current_start = 0.0
307
+ current_end = 0.0
308
+ for token, start, end in zip(tokens, timestamps, ends, strict=True):
309
+ if token in CONTROL_TOKENS:
310
+ continue
311
+ boundary = token.startswith("▁") or token.startswith(" ")
312
+ if boundary:
313
+ piece = token[1:] if token.startswith("▁") else token.lstrip(" ")
314
+ if current_text:
315
+ words.append(
316
+ WordEvent(current_text, offset + current_start, offset + current_end)
317
+ )
318
+ if not piece:
319
+ current_text = ""
320
+ continue
321
+ current_text = piece
322
+ current_start = start
323
+ current_end = end
324
+ continue
325
+ if not current_text:
326
+ if token in SENTENCE_PUNCTUATION:
327
+ words.append(WordEvent(token, offset + start, offset + end))
328
+ else:
329
+ current_text, current_start, current_end = token, start, end
330
+ continue
331
+ current_text += token
332
+ current_end = end
333
+ if token in SENTENCE_PUNCTUATION:
334
+ words.append(
335
+ WordEvent(current_text, offset + current_start, offset + current_end)
336
+ )
337
+ current_text = ""
338
+ if current_text:
339
+ words.append(WordEvent(current_text, offset + current_start, offset + current_end))
340
+ return tuple(words)
341
+
342
+
343
+ def _valid_starts(timestamps: tuple[float, ...], chunk_duration: float) -> bool:
344
+ return (
345
+ all(
346
+ math.isfinite(value) and 0 <= value <= chunk_duration + TIMING_TOLERANCE
347
+ for value in timestamps
348
+ )
349
+ and all(left <= right for left, right in zip(timestamps, timestamps[1:]))
350
+ )
351
+
352
+
353
+ def _append_monotonic(
354
+ accumulated: list[WordEvent],
355
+ incoming: tuple[WordEvent, ...],
356
+ wav_duration: float,
357
+ ) -> None:
358
+ previous_end = accumulated[-1].end if accumulated else 0.0
359
+ for word in incoming:
360
+ if (
361
+ word.start + TIMING_TOLERANCE < previous_end
362
+ or word.end < word.start
363
+ or word.end > wav_duration + TIMING_TOLERANCE
364
+ ):
365
+ raise TranscriptionError(reason="global word timing is not monotonic")
366
+ if word.text in SENTENCE_PUNCTUATION and accumulated:
367
+ previous = accumulated[-1]
368
+ accumulated[-1] = WordEvent(
369
+ text=f"{previous.text}{word.text}",
370
+ start=previous.start,
371
+ end=word.end,
372
+ )
373
+ previous_end = word.end
374
+ continue
375
+ accumulated.append(word)
376
+ previous_end = word.end
377
+
378
+
379
+ def _sentence_segments(words: list[WordEvent]) -> tuple[Segment, ...]:
380
+ segments: list[Segment] = []
381
+ sentence: list[WordEvent] = []
382
+ for word in words:
383
+ sentence.append(word)
384
+ if word.text[-1:] in SENTENCE_PUNCTUATION:
385
+ segments.append(_segment(len(segments), sentence))
386
+ sentence = []
387
+ if sentence:
388
+ segments.append(_segment(len(segments), sentence))
389
+ return tuple(segments)
390
+
391
+
392
+ def _segment(segment_id: int, words: list[WordEvent]) -> Segment:
393
+ return Segment(
394
+ id=segment_id,
395
+ start=words[0].start,
396
+ end=words[-1].end,
397
+ text=" ".join(word.text for word in words),
398
+ )
sttx/asr_events.py ADDED
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+ from typing import TypeAlias
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class ScanStarted:
10
+ total_samples: int
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class ScanAdvanced:
15
+ processed_samples: int
16
+ total_samples: int
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class VadSegmentReady:
21
+ index: int
22
+ start_sample: int
23
+ sample_count: int
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class DecodeStarted:
28
+ index: int
29
+ start_sample: int
30
+ sample_count: int
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class DecodeFinished:
35
+ index: int
36
+ start_sample: int
37
+ sample_count: int
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class LanguageReported:
42
+ language: str
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class WordCountUpdated:
47
+ word_count: int
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class ScanFinished:
52
+ total_samples: int
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class TranscriptionSummary:
57
+ total_samples: int
58
+ voiced_samples: int
59
+ vad_segments: int
60
+ decoded_samples: int
61
+ decode_chunks: int
62
+ word_count: int
63
+ language: str
64
+ transcript_segments: int
65
+
66
+
67
+ AsrActivity: TypeAlias = (
68
+ ScanStarted
69
+ | ScanAdvanced
70
+ | VadSegmentReady
71
+ | DecodeStarted
72
+ | DecodeFinished
73
+ | LanguageReported
74
+ | WordCountUpdated
75
+ | ScanFinished
76
+ | TranscriptionSummary
77
+ )
78
+ ActivityCallback: TypeAlias = Callable[[AsrActivity], None]
sttx/audio.py ADDED
@@ -0,0 +1,207 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ import signal
6
+ import subprocess
7
+ import tempfile
8
+ import wave
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from types import TracebackType
12
+ from typing import Final
13
+
14
+ SAMPLE_RATE: Final = 16_000
15
+ SAMPLE_WIDTH: Final = 2
16
+ CHANNEL_COUNT: Final = 1
17
+ PROCESS_STOP_TIMEOUT: Final = 1.0
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class AudioEnvironmentError(Exception):
22
+ path: Path
23
+ reason: str
24
+
25
+ def __str__(self) -> str:
26
+ return f"cannot prepare audio from {self.path}: {self.reason}"
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class AudioCancellationError(Exception):
31
+ path: Path
32
+
33
+ def __str__(self) -> str:
34
+ return f"audio normalization cancelled for {self.path}"
35
+
36
+
37
+ @dataclass(slots=True) # noqa: MUTABLE_OK
38
+ class PreparedAudio:
39
+ """Own a normalized temporary WAV until its context or cleanup ends."""
40
+
41
+ path: Path
42
+ sample_count: int
43
+ _cleaned: bool = field(default=False, init=False, repr=False)
44
+
45
+ @property
46
+ def duration(self) -> float:
47
+ return self.sample_count / SAMPLE_RATE
48
+
49
+ def cleanup(self) -> None:
50
+ if self._cleaned:
51
+ return
52
+ _remove_temp(self.path)
53
+ self._cleaned = True
54
+
55
+ def __enter__(self) -> PreparedAudio:
56
+ return self
57
+
58
+ def __exit__(
59
+ self,
60
+ exc_type: type[BaseException] | None,
61
+ exc_value: BaseException | None,
62
+ traceback: TracebackType | None,
63
+ ) -> bool:
64
+ del exc_type, exc_value, traceback
65
+ self.cleanup()
66
+ return False
67
+
68
+
69
+ def normalize_media(input_path: Path) -> PreparedAudio:
70
+ if not input_path.is_file():
71
+ raise AudioEnvironmentError(path=input_path, reason="input is not a file")
72
+ if not os.access(input_path, os.R_OK):
73
+ raise AudioEnvironmentError(path=input_path, reason="input is not readable")
74
+ if shutil.which("ffmpeg") is None:
75
+ raise AudioEnvironmentError(path=input_path, reason="ffmpeg is not available")
76
+
77
+ try:
78
+ descriptor, raw_temp_path = tempfile.mkstemp(suffix=".wav")
79
+ os.close(descriptor)
80
+ except OSError as error:
81
+ raise AudioEnvironmentError(
82
+ path=input_path,
83
+ reason=f"cannot allocate temporary WAV: {error}",
84
+ ) from error
85
+
86
+ temp_path = Path(raw_temp_path)
87
+ completed = False
88
+ try:
89
+ process = _start_ffmpeg(input_path, temp_path)
90
+ try:
91
+ _, stderr = process.communicate()
92
+ except KeyboardInterrupt as error:
93
+ _stop_process_group(process)
94
+ raise AudioCancellationError(path=input_path) from error
95
+ except BaseException: # noqa: BROAD_EXCEPT_OK
96
+ _stop_process_group(process)
97
+ raise
98
+
99
+ if process.returncode != 0:
100
+ detail = stderr.decode(errors="replace").strip()
101
+ reason = "ffmpeg did not produce audio"
102
+ if detail:
103
+ reason = f"{reason}: {detail}"
104
+ raise AudioEnvironmentError(path=input_path, reason=reason)
105
+
106
+ sample_count = _validated_sample_count(temp_path, input_path)
107
+ completed = True
108
+ return PreparedAudio(path=temp_path, sample_count=sample_count)
109
+ finally:
110
+ if not completed:
111
+ _remove_temp(temp_path)
112
+
113
+
114
+ def _start_ffmpeg(
115
+ input_path: Path,
116
+ temp_path: Path,
117
+ ) -> subprocess.Popen[bytes]:
118
+ try:
119
+ return subprocess.Popen(
120
+ ffmpeg_argv(input_path, temp_path),
121
+ start_new_session=True,
122
+ stdout=subprocess.DEVNULL,
123
+ stderr=subprocess.PIPE,
124
+ )
125
+ except OSError as error:
126
+ raise AudioEnvironmentError(
127
+ path=input_path,
128
+ reason=f"cannot start ffmpeg: {error}",
129
+ ) from error
130
+
131
+
132
+ def ffmpeg_argv(input_path: Path, temp_path: Path) -> tuple[str, ...]:
133
+ return (
134
+ "ffmpeg",
135
+ "-nostdin",
136
+ "-hide_banner",
137
+ "-loglevel",
138
+ "error",
139
+ "-i",
140
+ str(input_path),
141
+ "-vn",
142
+ "-ac",
143
+ "1",
144
+ "-ar",
145
+ "16000",
146
+ "-c:a",
147
+ "pcm_s16le",
148
+ "-y",
149
+ str(temp_path),
150
+ )
151
+
152
+
153
+ def _stop_process_group(process: subprocess.Popen[bytes]) -> None:
154
+ try:
155
+ process_group = os.getpgid(process.pid)
156
+ except ProcessLookupError:
157
+ process.wait()
158
+ return
159
+ try:
160
+ os.killpg(process_group, signal.SIGTERM)
161
+ process.wait(timeout=PROCESS_STOP_TIMEOUT)
162
+ except subprocess.TimeoutExpired:
163
+ try:
164
+ os.killpg(process_group, signal.SIGKILL)
165
+ except ProcessLookupError:
166
+ pass
167
+ process.wait(timeout=PROCESS_STOP_TIMEOUT)
168
+ except ProcessLookupError:
169
+ process.wait()
170
+
171
+
172
+ def _validated_sample_count(temp_path: Path, input_path: Path) -> int:
173
+ try:
174
+ with wave.open(str(temp_path), "rb") as wav_file:
175
+ sample_count = wav_file.getnframes()
176
+ valid_header = (
177
+ wav_file.getframerate() == SAMPLE_RATE
178
+ and wav_file.getnchannels() == CHANNEL_COUNT
179
+ and wav_file.getsampwidth() == SAMPLE_WIDTH
180
+ and wav_file.getcomptype() == "NONE"
181
+ )
182
+ except (EOFError, OSError, wave.Error) as error:
183
+ raise AudioEnvironmentError(
184
+ path=input_path,
185
+ reason=f"invalid normalized WAV: {error}",
186
+ ) from error
187
+ if not valid_header:
188
+ raise AudioEnvironmentError(
189
+ path=input_path,
190
+ reason="normalized WAV must be mono 16 kHz signed 16-bit PCM",
191
+ )
192
+ if sample_count <= 0:
193
+ raise AudioEnvironmentError(
194
+ path=input_path,
195
+ reason="normalized WAV contains no audio samples",
196
+ )
197
+ return sample_count
198
+
199
+
200
+ def _remove_temp(path: Path) -> None:
201
+ try:
202
+ path.unlink(missing_ok=True)
203
+ except OSError as error:
204
+ raise AudioEnvironmentError(
205
+ path=path,
206
+ reason=f"cannot remove temporary WAV: {error}",
207
+ ) from error