listenflow 0.17.6__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.
listenflow/__init__.py ADDED
@@ -0,0 +1,101 @@
1
+ """listenflow core library: PCM stream input, transcript events output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+
7
+ __path__ = __import__("pkgutil").extend_path(__path__, __name__)
8
+
9
+ from listenflow._version import __version__
10
+ from listenflow.engine import (
11
+ ClosedUtterance,
12
+ DiarizationMode,
13
+ EngineCapabilities,
14
+ EngineInputFormat,
15
+ EngineTransport,
16
+ FinalizationMode,
17
+ FinalObservation,
18
+ OfflineModelDirectorySource,
19
+ PartialMode,
20
+ ProvisionalObservation,
21
+ TimestampDetail,
22
+ UtteranceBoundarySettings,
23
+ UtteranceBoundarySession,
24
+ UtteranceDecodePipeline,
25
+ has_voice,
26
+ )
27
+ from listenflow.base import STTEngineBase
28
+ from listenflow.events import Final, Partial, TranscriptEvent, UtteranceEnd, UtteranceStart
29
+ from listenflow.health import BiasApplicationSnapshot, EngineState, HealthSnapshot
30
+ from listenflow.sync import iterate_async, iterate_sync, run_sync
31
+ from listenflow.errors import (
32
+ ConfigurationError,
33
+ EngineClosedError,
34
+ EngineStateError,
35
+ EnvironmentUnavailableError,
36
+ InferenceError,
37
+ ModuleNotInstalledError,
38
+ STTError,
39
+ )
40
+
41
+ __all__ = [
42
+ "BiasApplicationSnapshot",
43
+ "ClosedUtterance",
44
+ "ConfigurationError",
45
+ "DiarizationMode",
46
+ "EngineCapabilities",
47
+ "EngineClosedError",
48
+ "EngineInputFormat",
49
+ "EngineState",
50
+ "EngineStateError",
51
+ "EngineTransport",
52
+ "EnvironmentUnavailableError",
53
+ "Final",
54
+ "FinalObservation",
55
+ "FinalizationMode",
56
+ "HealthSnapshot",
57
+ "InferenceError",
58
+ "ModuleNotInstalledError",
59
+ "OfflineModelDirectorySource",
60
+ "Partial",
61
+ "PartialMode",
62
+ "ProvisionalObservation",
63
+ "STTEngineBase",
64
+ "STTError",
65
+ "TimestampDetail",
66
+ "TranscriptEvent",
67
+ "UtteranceEnd",
68
+ "UtteranceStart",
69
+ "UtteranceBoundarySettings",
70
+ "UtteranceBoundarySession",
71
+ "UtteranceDecodePipeline",
72
+ "__version__",
73
+ "has_voice",
74
+ "iterate_async",
75
+ "iterate_sync",
76
+ "run_sync",
77
+ ]
78
+
79
+ # Add engine modules here when they are published on PyPI.
80
+ _MODULE_DISTRIBUTIONS: dict[str, str] = {}
81
+
82
+
83
+ def __getattr__(name: str) -> object:
84
+ try:
85
+ distribution = _MODULE_DISTRIBUTIONS[name]
86
+ except KeyError:
87
+ raise AttributeError(name) from None
88
+
89
+ module_name = f"{__name__}.{name}"
90
+ try:
91
+ module = importlib.import_module(module_name)
92
+ except ModuleNotFoundError as exc:
93
+ if exc.name != module_name:
94
+ raise
95
+ raise ModuleNotInstalledError(
96
+ f"why={module_name} is unavailable because {distribution} is not installed; "
97
+ f"what={distribution} provides {module_name}; "
98
+ f"how=pip install {distribution}"
99
+ ) from exc
100
+ globals()[name] = module
101
+ return module
listenflow/_async.py ADDED
@@ -0,0 +1,23 @@
1
+ """Wait for owned asynchronous work without interrupting its cleanup twice."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+
8
+ def cancel_once(task: asyncio.Task[object]) -> None:
9
+ if not task.done() and not task.cancelling():
10
+ task.cancel()
11
+
12
+
13
+ async def finish(task: asyncio.Future[object]) -> None:
14
+ while not task.done():
15
+ try:
16
+ # Wait without adding this frame and its callers to a failed task's traceback.
17
+ await asyncio.wait((task,))
18
+ except asyncio.CancelledError:
19
+ continue
20
+ except BaseException:
21
+ break
22
+ if not task.cancelled():
23
+ task.exception()
listenflow/_version.py ADDED
@@ -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.17.6'
22
+ __version_tuple__ = version_tuple = (0, 17, 6)
23
+
24
+ __commit_id__ = commit_id = None
listenflow/base.py ADDED
@@ -0,0 +1,317 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ from abc import ABC, abstractmethod
6
+ from collections.abc import AsyncIterator, Coroutine, Mapping
7
+ from typing import Self
8
+
9
+ from listenflow._async import cancel_once as _cancel, finish as _finish
10
+ from listenflow.engine import EngineCapabilities, EngineInputFormat, validate_pcm_chunk
11
+ from listenflow.errors import (
12
+ ConfigurationError,
13
+ EngineClosedError,
14
+ EngineStateError,
15
+ InferenceError,
16
+ )
17
+ from listenflow.events import TranscriptEvent
18
+ from listenflow.health import EngineState, HealthSnapshot
19
+
20
+
21
+ class STTEngineBase(ABC):
22
+ """Shared preparation, recognition, closure, and health behavior.
23
+
24
+ Use one engine instance on one event loop. Modules implement the hooks,
25
+ map native errors into STTError subclasses, and finish native work before
26
+ returning from a cancelled hook. Failed loads clean up their own resources.
27
+ """
28
+
29
+ def __init__(self) -> None:
30
+ self._model_lock = asyncio.Lock()
31
+ self._operations: set[asyncio.Task[None]] = set()
32
+ self._stream_started = asyncio.Event()
33
+ self._loaded = False
34
+ self._close_task: asyncio.Task[None] | None = None
35
+ self._last_progress_monotonic_s: float | None = None
36
+
37
+ @property
38
+ @abstractmethod
39
+ def capabilities(self) -> EngineCapabilities:
40
+ """Return the module's immutable, static capability declaration."""
41
+ raise NotImplementedError
42
+
43
+ @abstractmethod
44
+ async def _load(self) -> None:
45
+ """Load resources and run a trial inference; clean up a failed load."""
46
+ raise NotImplementedError
47
+
48
+ @abstractmethod
49
+ async def _release(self) -> None:
50
+ """Return resources created by a successful load."""
51
+ raise NotImplementedError
52
+
53
+ @abstractmethod
54
+ def _recognize(self, pcm_chunks: AsyncIterator[bytes]) -> AsyncIterator[TranscriptEvent]:
55
+ """Recognize PCM; cancellation and iterator closure must finish native work."""
56
+ raise NotImplementedError
57
+
58
+ def _health_details(self) -> Mapping[str, object]:
59
+ """Return recognition fields only, without the three shared health fields."""
60
+ return {}
61
+
62
+ def _mark_progress(self) -> None:
63
+ """Record a processed PCM chunk or completed utterance decode."""
64
+ self._last_progress_monotonic_s = time.monotonic()
65
+
66
+ @property
67
+ def _closing(self) -> bool:
68
+ return self._close_task is not None
69
+
70
+ def _check_open(self) -> None:
71
+ if self._closing:
72
+ raise EngineClosedError("engine is closed")
73
+
74
+ def _start(self, coroutine: Coroutine[object, object, None]) -> asyncio.Task[None]:
75
+ task = asyncio.create_task(coroutine)
76
+ self._operations.add(task)
77
+ task.add_done_callback(self._operations.discard)
78
+ return task
79
+
80
+ async def prepare(self) -> None:
81
+ """Load and trial the model once; retry after an unsuccessful load."""
82
+ self._check_open()
83
+ if self._loaded:
84
+ return
85
+ task = self._start(self._prepare())
86
+ try:
87
+ await asyncio.shield(task)
88
+ except asyncio.CancelledError:
89
+ caller = asyncio.current_task()
90
+ if self._closing and caller is not None and not caller.cancelling():
91
+ raise EngineClosedError("engine closed while preparing") from None
92
+ _cancel(task)
93
+ await _finish(task)
94
+ raise
95
+ except Exception as error:
96
+ if self._closing:
97
+ raise EngineClosedError("engine closed while preparing") from error
98
+ raise
99
+ finally:
100
+ # A failed task retains this frame through the exception traceback.
101
+ del task
102
+
103
+ async def _prepare(self) -> None:
104
+ acquired = False
105
+ try:
106
+ await self._acquire_model_slot()
107
+ acquired = True
108
+ self._check_open()
109
+ if not self._loaded:
110
+ await self._load()
111
+ self._loaded = True
112
+ self._check_open()
113
+ except asyncio.CancelledError:
114
+ if self._closing:
115
+ raise EngineClosedError("engine closed while preparing") from None
116
+ raise
117
+ finally:
118
+ if acquired:
119
+ self._model_lock.release()
120
+
121
+ def stream(self, pcm_chunks: AsyncIterator[bytes]) -> AsyncIterator[TranscriptEvent]:
122
+ """Start recognition on the first read and keep its slot through cleanup.
123
+
124
+ The returned iterator supports aclose(). Close it explicitly when stopping
125
+ before the end, or use contextlib.aclosing around the iterator.
126
+ """
127
+ return _EngineStream(self, pcm_chunks)
128
+
129
+ async def _acquire_model_slot(self, *, recognition: bool = False) -> None:
130
+ self._check_open()
131
+ if recognition and self._stream_started.is_set():
132
+ raise EngineStateError("engine already has an active stream")
133
+ acquire = asyncio.create_task(self._model_lock.acquire())
134
+ overlap = asyncio.create_task(self._stream_started.wait()) if recognition else None
135
+ try:
136
+ if overlap is not None:
137
+ await asyncio.wait((acquire, overlap), return_when=asyncio.FIRST_COMPLETED)
138
+ self._check_open()
139
+ if overlap is not None and overlap.done():
140
+ raise EngineStateError("engine already has an active stream")
141
+ await acquire
142
+ except BaseException:
143
+ _cancel(acquire)
144
+ if overlap is not None:
145
+ _cancel(overlap)
146
+ await _finish(overlap)
147
+ await _finish(acquire)
148
+ if not acquire.cancelled() and acquire.exception() is None:
149
+ self._model_lock.release()
150
+ raise
151
+ finally:
152
+ if overlap is not None:
153
+ _cancel(overlap)
154
+
155
+ async def _validated_pcm(self, pcm_chunks: AsyncIterator[bytes]) -> AsyncIterator[bytes]:
156
+ input_format = self.capabilities.input_format
157
+ async for chunk in pcm_chunks:
158
+ if input_format in (EngineInputFormat.PCM_S16LE, EngineInputFormat.PCM_F32):
159
+ try:
160
+ validate_pcm_chunk(chunk, input_format=input_format)
161
+ except ValueError as error:
162
+ raise ConfigurationError(str(error)) from error
163
+ yield chunk
164
+
165
+ async def _run_stream(self, stream: _EngineStream) -> None:
166
+ iterator: AsyncIterator[TranscriptEvent] | None = None
167
+ acquired = False
168
+ recognizing = False
169
+ try:
170
+ await self._acquire_model_slot(recognition=True)
171
+ acquired = True
172
+ if not self._loaded:
173
+ raise EngineStateError("prepare the engine before recognition")
174
+ self._stream_started.set()
175
+ recognizing = True
176
+ iterator = aiter(self._recognize(self._validated_pcm(stream._pcm_chunks)))
177
+ while True:
178
+ request = await stream._requests.get()
179
+ try:
180
+ event = await anext(iterator)
181
+ except StopAsyncIteration:
182
+ self._check_open()
183
+ break
184
+ self._check_open()
185
+ if stream._closed:
186
+ break
187
+ if not request.done():
188
+ request.set_result(event)
189
+ except asyncio.CancelledError:
190
+ if self._closing:
191
+ raise EngineClosedError("engine closed during recognition") from None
192
+ raise
193
+ finally:
194
+ try:
195
+ if iterator is not None and hasattr(iterator, "aclose"):
196
+ await iterator.aclose()
197
+ finally:
198
+ if recognizing:
199
+ self._stream_started.clear()
200
+ if acquired:
201
+ self._model_lock.release()
202
+ # Do not let an exception traceback lead back to its task or Future.
203
+ request = None
204
+ del stream
205
+
206
+ async def aclose(self) -> None:
207
+ """Cancel operations, wait for their cleanup, and release resources once."""
208
+ first = self._close_task is None
209
+ if first:
210
+ self._close_task = asyncio.create_task(self._close())
211
+ task = self._close_task
212
+ assert task is not None
213
+ try:
214
+ await asyncio.shield(task)
215
+ except asyncio.CancelledError:
216
+ await _finish(task)
217
+ raise
218
+ except BaseException:
219
+ if first:
220
+ raise
221
+
222
+ async def _close(self) -> None:
223
+ operations = tuple(self._operations)
224
+ for task in operations:
225
+ _cancel(task)
226
+ await asyncio.gather(*operations, return_exceptions=True)
227
+ if self._loaded:
228
+ self._loaded = False
229
+ try:
230
+ await self._release()
231
+ except Exception as error:
232
+ raise InferenceError("engine resource release failed") from error
233
+
234
+ async def __aenter__(self) -> Self:
235
+ await self.prepare()
236
+ return self
237
+
238
+ async def __aexit__(self, exc_type, exc_value, traceback) -> None:
239
+ await self.aclose()
240
+
241
+ def health(self) -> HealthSnapshot:
242
+ """Return held values immediately, including after closure."""
243
+ state = (
244
+ EngineState.CLOSED
245
+ if self._closing
246
+ else EngineState.READY
247
+ if self._loaded
248
+ else EngineState.UNPREPARED
249
+ )
250
+ return HealthSnapshot(
251
+ state=state,
252
+ busy=self._model_lock.locked(),
253
+ last_progress_monotonic_s=self._last_progress_monotonic_s,
254
+ **self._health_details(),
255
+ )
256
+
257
+
258
+ class _EngineStream(AsyncIterator[TranscriptEvent]):
259
+ """Keep the model operation alive while the consumer pauses between reads."""
260
+
261
+ def __init__(self, engine: STTEngineBase, pcm_chunks: AsyncIterator[bytes]) -> None:
262
+ self._engine = engine
263
+ self._pcm_chunks = pcm_chunks
264
+ self._requests: asyncio.Queue[asyncio.Future[TranscriptEvent]] = asyncio.Queue()
265
+ self._task: asyncio.Task[None] | None = None
266
+ self._pending: asyncio.Future[TranscriptEvent] | None = None
267
+ self._closed = False
268
+
269
+ async def __anext__(self) -> TranscriptEvent:
270
+ future = None
271
+ try:
272
+ if self._closed:
273
+ raise StopAsyncIteration
274
+ self._engine._check_open()
275
+ if self._pending is not None:
276
+ raise EngineStateError("stream already has a pending read")
277
+ if self._task is not None and self._task.done():
278
+ self._task.result()
279
+ self._closed = True
280
+ raise StopAsyncIteration
281
+ future = asyncio.get_running_loop().create_future()
282
+ self._pending = future
283
+ self._requests.put_nowait(future)
284
+ if self._task is None:
285
+ self._task = self._engine._start(self._engine._run_stream(self))
286
+ self._task.add_done_callback(self._completed)
287
+ try:
288
+ return await future
289
+ except asyncio.CancelledError:
290
+ await self.aclose()
291
+ raise
292
+ finally:
293
+ self._pending = None
294
+ finally:
295
+ # Both the Future and the stream's task may hold the raised exception.
296
+ del self, future
297
+
298
+ def _completed(self, task: asyncio.Task[None]) -> None:
299
+ error = (
300
+ EngineClosedError("engine closed during recognition")
301
+ if task.cancelled() and self._engine._closing
302
+ else asyncio.CancelledError()
303
+ if task.cancelled()
304
+ else task.exception()
305
+ )
306
+ if self._engine._closing and not isinstance(error, EngineClosedError):
307
+ closed = EngineClosedError("engine closed during recognition")
308
+ closed.__cause__ = error
309
+ error = closed
310
+ if self._pending is not None and not self._pending.done():
311
+ self._pending.set_exception(error or StopAsyncIteration())
312
+
313
+ async def aclose(self) -> None:
314
+ self._closed = True
315
+ if self._task is not None:
316
+ _cancel(self._task)
317
+ await _finish(self._task)