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 +1 -0
- sttx/asr.py +398 -0
- sttx/asr_events.py +78 -0
- sttx/audio.py +207 -0
- sttx/cli.py +1188 -0
- sttx/model.py +325 -0
- sttx/output.py +197 -0
- sttx-0.1.0.dist-info/METADATA +224 -0
- sttx-0.1.0.dist-info/RECORD +13 -0
- sttx-0.1.0.dist-info/WHEEL +4 -0
- sttx-0.1.0.dist-info/entry_points.txt +3 -0
- sttx-0.1.0.dist-info/licenses/LICENSE +674 -0
- sttx-0.1.0.dist-info/licenses/NOTICE.md +49 -0
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
|