converse-framework 0.2.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.
Files changed (33) hide show
  1. converse_framework/__init__.py +108 -0
  2. converse_framework/audio_utils.py +412 -0
  3. converse_framework/cuda_utils.py +176 -0
  4. converse_framework/events.py +94 -0
  5. converse_framework/examples/__init__.py +20 -0
  6. converse_framework/examples/subprocess_provider.py +439 -0
  7. converse_framework/examples/text_chat.py +308 -0
  8. converse_framework/examples/voice_chat.py +223 -0
  9. converse_framework/examples/websocket_voice_chat.py +174 -0
  10. converse_framework/js/browser-voice-client.js +248 -0
  11. converse_framework/js/mic-frame-sender.js +445 -0
  12. converse_framework/js/speaker-echo-guard.js +308 -0
  13. converse_framework/js/tts-audio-player.js +237 -0
  14. converse_framework/pipeline.py +620 -0
  15. converse_framework/protocols.py +382 -0
  16. converse_framework/provider_events.py +159 -0
  17. converse_framework/providers/__init__.py +28 -0
  18. converse_framework/providers/faster_whisper.py +290 -0
  19. converse_framework/providers/kokoro_onnx.py +391 -0
  20. converse_framework/providers/llamacpp.py +264 -0
  21. converse_framework/providers/mock.py +171 -0
  22. converse_framework/providers/pocket_tts.py +409 -0
  23. converse_framework/providers/silero.py +161 -0
  24. converse_framework/providers/unavailable.py +137 -0
  25. converse_framework/providers/whisper_cpp.py +322 -0
  26. converse_framework/registry.py +397 -0
  27. converse_framework/session.py +315 -0
  28. converse_framework/transport.py +54 -0
  29. converse_framework/utterance_collector.py +336 -0
  30. converse_framework-0.2.0.dist-info/METADATA +992 -0
  31. converse_framework-0.2.0.dist-info/RECORD +33 -0
  32. converse_framework-0.2.0.dist-info/WHEEL +4 -0
  33. converse_framework-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,137 @@
1
+ """Unavailable provider sentinel returned when a real provider cannot load."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncIterator
6
+
7
+ from converse_framework.protocols import (
8
+ ASRProvider,
9
+ AudioChunk,
10
+ LLMProvider,
11
+ ProviderCapabilities,
12
+ ProviderStatus,
13
+ TTSProvider,
14
+ TranscriptEvent,
15
+ VADProvider,
16
+ ) # fmt: skip
17
+
18
+
19
+ # Map of provider name -> optional-dependency extra. Used to construct a
20
+ # helpful install hint when a provider's underlying dependency is missing.
21
+ EXTRAS: dict[tuple[str, str], str] = {
22
+ ("vad", "silero"): "silero",
23
+ ("asr", "faster-whisper"): "faster-whisper",
24
+ ("asr", "whisper-cpp"): "whisper-cpp",
25
+ ("llm", "llamacpp"): "llamacpp",
26
+ ("tts", "kokoro"): "kokoro",
27
+ ("tts", "kokoro-onnx"): "kokoro",
28
+ ("tts", "pocket-tts"): "pocket-tts",
29
+ }
30
+
31
+ # Backward-compatible table exported for callers that already use it.
32
+ EXTRA_HINTS: dict[tuple[str, str], str] = {
33
+ key: f"converse-framework[{extra}]" for key, extra in EXTRAS.items()
34
+ }
35
+
36
+
37
+ def extra_hint_for(kind: str, name: str) -> str | None:
38
+ """Return the ``pip install`` extra hint for a missing provider, if known.
39
+
40
+ Apps and the :class:`UnavailableProvider` sentinel use this to
41
+ build a friendly installation message when a registered
42
+ provider's heavy backend is not installed in the current
43
+ environment.
44
+
45
+ Args:
46
+ kind: Provider category (``"vad"``, ``"asr"``, ``"llm"``,
47
+ ``"tts"``).
48
+ name: Registered provider name (e.g. ``"silero"``,
49
+ ``"faster-whisper"``).
50
+
51
+ Returns:
52
+ The matching ``pip install`` extra string (e.g.
53
+ ``"converse-framework[silero]"``) when one is registered,
54
+ otherwise ``None``.
55
+ """
56
+ return EXTRA_HINTS.get((kind, name))
57
+
58
+
59
+ def missing_extra_for(kind: str, name: str) -> str | None:
60
+ """Return the optional-dependency extra for a missing provider, if known."""
61
+ return EXTRAS.get((kind, name))
62
+
63
+
64
+ class UnavailableProvider(VADProvider, ASRProvider, LLMProvider, TTSProvider):
65
+ """Sentinel provider that reports not-ready and raises on use."""
66
+
67
+ def __init__(
68
+ self,
69
+ kind: str,
70
+ name: str,
71
+ message: str | None = None,
72
+ requires_gpu: bool = False,
73
+ ):
74
+ if message is None:
75
+ extra = extra_hint_for(kind, name)
76
+ message = (
77
+ f"Provider '{name}' ({kind}) is not available. "
78
+ f"Install the required extra with "
79
+ f"`pip install {extra}`."
80
+ if extra
81
+ else f"Provider '{name}' ({kind}) is not available."
82
+ )
83
+ install_hint = extra_hint_for(kind, name)
84
+ self._status = ProviderStatus(
85
+ name=name,
86
+ kind=kind,
87
+ ready=False,
88
+ message=message,
89
+ capabilities=ProviderCapabilities(requires_gpu=requires_gpu),
90
+ install_hint=install_hint,
91
+ missing_extra=missing_extra_for(kind, name),
92
+ provider_id=name,
93
+ loaded=False,
94
+ status_level="unavailable",
95
+ )
96
+
97
+ @property
98
+ def status(self) -> ProviderStatus:
99
+ return self._status
100
+
101
+ async def check_status(self) -> ProviderStatus:
102
+ return self.status
103
+
104
+ async def process_frame(self, frame) -> list:
105
+ return []
106
+
107
+ async def transcribe_text_input(self, text: str) -> AsyncIterator[TranscriptEvent]:
108
+ raise RuntimeError(self._status.message)
109
+ yield # pragma: no cover
110
+
111
+ async def transcribe_audio(
112
+ self, pcm_s16le: bytes, sample_rate: int, progress=None
113
+ ) -> AsyncIterator[TranscriptEvent]:
114
+ raise RuntimeError(self._status.message)
115
+ yield # pragma: no cover
116
+
117
+ async def stream_response(
118
+ self, messages: list[dict[str, str]]
119
+ ) -> AsyncIterator[str]:
120
+ raise RuntimeError(self._status.message)
121
+ yield # pragma: no cover
122
+
123
+ async def stream_audio(self, text: str) -> AsyncIterator[AudioChunk]:
124
+ raise RuntimeError(self._status.message)
125
+ yield # pragma: no cover
126
+
127
+ async def stream_audio_with_progress(
128
+ self, text: str, progress=None
129
+ ) -> AsyncIterator[AudioChunk]:
130
+ raise RuntimeError(self._status.message)
131
+ yield # pragma: no cover
132
+
133
+ async def load(self) -> ProviderStatus:
134
+ return self.status
135
+
136
+ async def unload(self) -> ProviderStatus:
137
+ return self.status
@@ -0,0 +1,322 @@
1
+ """whisper.cpp HTTP ASR provider.
2
+
3
+ Talks to a running ``whisper-server`` (whisper.cpp's official HTTP server
4
+ binary) over HTTP. The provider does NOT manage the server subprocess --
5
+ the user is expected to start ``whisper-server`` themselves and configure
6
+ it with the desired model. The framework only needs ``httpx`` to talk
7
+ to it; install with::
8
+
9
+ pip install 'converse-framework[whisper-cpp]'
10
+
11
+ The ``httpx`` package is imported lazily inside async methods so the
12
+ base :mod:`converse_framework` package stays light. The provider probes
13
+ ``{base_url}/health`` on :meth:`check_status` to decide whether to use
14
+ the legacy ``/inference`` endpoint (older whisper-server releases) or
15
+ the OpenAI-compatible ``/v1/audio/transcriptions`` endpoint (newer
16
+ releases). Both endpoints return a JSON object with a ``text`` field.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import logging
23
+ from collections.abc import AsyncIterator
24
+
25
+ from converse_framework.audio_utils import (
26
+ float_audio_to_wav_bytes,
27
+ pcm_s16le_to_float32,
28
+ )
29
+ from converse_framework.protocols import (
30
+ ASRProvider,
31
+ ProgressCallback,
32
+ ProviderCapabilities,
33
+ ProviderStatus,
34
+ TranscriptEvent,
35
+ )
36
+ from converse_framework.providers.unavailable import extra_hint_for
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+ _DEFAULT_EXTRA_HINT = "converse-framework[whisper-cpp]"
41
+
42
+
43
+ class WhisperCppASRProvider(ASRProvider):
44
+ """ASR provider that proxies audio to a ``whisper-server`` HTTP endpoint.
45
+
46
+ Config keys (all optional):
47
+
48
+ * ``base_url`` -- whisper-server address. Default
49
+ ``"http://127.0.0.1:8082"``.
50
+ * ``model`` -- model filename hint stored alongside the server
51
+ config (e.g. ``"ggml-small.en.bin"``). Default
52
+ ``"ggml-small.en.bin"``. whisper-server itself picks the
53
+ model on its own command line, so this key is for diagnostics
54
+ and status messages only.
55
+ * ``language`` -- ISO language code sent with each request.
56
+ Default ``"en"``.
57
+ * ``temperature`` -- sampling temperature forwarded to
58
+ whisper-server. Default ``0`` (greedy).
59
+ * ``timeout_s`` -- request timeout in seconds. Default ``120``.
60
+ """
61
+
62
+ def __init__(self, config: dict):
63
+ self.base_url = str(config.get("base_url", "http://127.0.0.1:8082")).rstrip("/")
64
+ self.model = str(config.get("model", "ggml-small.en.bin"))
65
+ self.language = config.get("language", "en")
66
+ self.temperature = config.get("temperature", 0)
67
+ self.timeout_s = float(config.get("timeout_s", 120))
68
+ # The provider never starts or stops the server, so the bundle
69
+ # has no subprocess lifecycle to track.
70
+ self._endpoint: str | None = None
71
+ self._last_error: str | None = None
72
+
73
+ @property
74
+ def status(self) -> ProviderStatus:
75
+ if self._last_error:
76
+ message = self._last_error
77
+ status_level = "error"
78
+ else:
79
+ message = (
80
+ f"Configured for whisper-server at {self.base_url} "
81
+ f"(model hint: {self.model}, language: {self.language}). "
82
+ "The server is managed externally; the framework will not "
83
+ "start it for you."
84
+ )
85
+ status_level = "configured"
86
+ return ProviderStatus(
87
+ name="whisper-cpp",
88
+ kind="asr",
89
+ ready=self._last_error is None,
90
+ message=message,
91
+ capabilities=ProviderCapabilities(
92
+ languages=(str(self.language),) if self.language else ("en",)
93
+ ),
94
+ provider_id="whisper-cpp",
95
+ # The provider does not own the heavy backend; the user does.
96
+ loaded=False,
97
+ managed_externally=True,
98
+ supports_model_management=False,
99
+ supports_voice_selection=False,
100
+ active_model=self.model,
101
+ models=({"id": self.model, "label": self.model},),
102
+ status_level=status_level,
103
+ )
104
+
105
+ async def check_status(self) -> ProviderStatus:
106
+ return await self._http_check_status()
107
+
108
+ async def probe_status(self) -> ProviderStatus:
109
+ """Cheap probe: check httpx import; no HTTP call."""
110
+ try:
111
+ pass # type: ignore[import-not-found]
112
+ except Exception as exc: # pragma: no cover - import path
113
+ self._last_error = self._missing_dep_message(exc)
114
+ return self.status
115
+ # httpx available; return cached status.
116
+ return self.status
117
+
118
+ async def load_status(self) -> ProviderStatus:
119
+ """Alias for probe_status - server lifecycle is user-owned."""
120
+ return await self.probe_status()
121
+
122
+ async def _http_check_status(self) -> ProviderStatus:
123
+ """Probe the server by GETting ``{base_url}/health``.
124
+
125
+ Returns a :class:`ProviderStatus` whose ``ready`` reflects
126
+ whether the server is reachable. As a side effect it caches
127
+ the preferred transcription endpoint so the next
128
+ :meth:`transcribe_audio` call skips the probe.
129
+ """
130
+ try:
131
+ import httpx # type: ignore[import-not-found]
132
+ except Exception as exc: # pragma: no cover - import path
133
+ self._last_error = self._missing_dep_message(exc)
134
+ return self.status
135
+
136
+ timeout = httpx.Timeout(connect=2.0, read=5.0, write=5.0, pool=2.0)
137
+ try:
138
+ async with httpx.AsyncClient(timeout=timeout) as client:
139
+ response = await client.get(f"{self.base_url}/health")
140
+ response.raise_for_status()
141
+ except Exception as exc:
142
+ self._last_error = f"Cannot reach whisper-server at {self.base_url}: {exc}"
143
+ self._endpoint = None
144
+ return self.status
145
+
146
+ # Server is up -- pick the endpoint once. Older whisper-server
147
+ # builds only expose /inference; newer builds also expose the
148
+ # OpenAI-compatible /v1/audio/transcriptions. We always try
149
+ # /inference first when /health answers, mirroring the
150
+ # upstream docs.
151
+ self._endpoint = "/inference"
152
+ self._last_error = None
153
+ return ProviderStatus(
154
+ name="whisper-cpp",
155
+ kind="asr",
156
+ ready=True,
157
+ message=(
158
+ f"whisper-server reachable at {self.base_url} "
159
+ f"(/health 200, will use {self._endpoint}). "
160
+ f"Model hint: {self.model}."
161
+ ),
162
+ capabilities=ProviderCapabilities(
163
+ languages=(str(self.language),) if self.language else ("en",)
164
+ ),
165
+ provider_id="whisper-cpp",
166
+ loaded=False,
167
+ managed_externally=True,
168
+ supports_model_management=False,
169
+ supports_voice_selection=False,
170
+ )
171
+
172
+ async def load(self) -> ProviderStatus:
173
+ """No-op: the server lifecycle is owned by the user.
174
+
175
+ We still call :meth:`check_status` so callers see the
176
+ current reachability state in the returned snapshot.
177
+ """
178
+ return await self.check_status()
179
+
180
+ async def transcribe_text_input(self, text: str) -> AsyncIterator[TranscriptEvent]:
181
+ stripped = text.strip()
182
+ if stripped:
183
+ yield TranscriptEvent(text=stripped, final=True)
184
+
185
+ async def transcribe_audio(
186
+ self,
187
+ pcm_s16le: bytes,
188
+ sample_rate: int,
189
+ progress: ProgressCallback | None = None,
190
+ ) -> AsyncIterator[TranscriptEvent]:
191
+ try:
192
+ import httpx # type: ignore[import-not-found]
193
+ except Exception as exc: # pragma: no cover - import path
194
+ raise RuntimeError(self._missing_dep_message(exc)) from exc
195
+
196
+ if sample_rate <= 0:
197
+ raise ValueError(
198
+ f"whisper-cpp needs a positive sample_rate, got {sample_rate}"
199
+ )
200
+ if not pcm_s16le:
201
+ return
202
+
203
+ audio = pcm_s16le_to_float32(pcm_s16le)
204
+ wav_bytes = float_audio_to_wav_bytes(audio, sample_rate)
205
+ if not wav_bytes:
206
+ return
207
+
208
+ endpoint = await self._resolve_endpoint()
209
+ url = f"{self.base_url}{endpoint}"
210
+
211
+ if progress:
212
+ await progress(
213
+ "asr.progress",
214
+ {
215
+ "stage": "queued",
216
+ "message": (
217
+ f"Queued {round(audio.size / sample_rate, 2)}s utterance "
218
+ "for whisper.cpp."
219
+ ),
220
+ },
221
+ )
222
+
223
+ params: dict[str, str] = {}
224
+ if self.language:
225
+ params["language"] = str(self.language)
226
+ if self.temperature is not None:
227
+ params["temperature"] = str(self.temperature)
228
+ # whisper-server's /inference endpoint reads the language and
229
+ # temperature from query string; /v1/audio/transcriptions reads
230
+ # them from the multipart form. Use the right shape for each.
231
+ if endpoint == "/inference":
232
+ request_kwargs: dict = {"params": params} if params else {}
233
+ else:
234
+ form: dict[str, str] = {}
235
+ if self.language:
236
+ form["language"] = str(self.language)
237
+ if self.temperature is not None:
238
+ form["temperature"] = str(self.temperature)
239
+ form["response_format"] = "json"
240
+ request_kwargs = {"data": form, "files": {"file": wav_bytes}}
241
+
242
+ timeout = httpx.Timeout(connect=5.0, read=self.timeout_s, write=10.0, pool=5.0)
243
+ try:
244
+ async with httpx.AsyncClient(timeout=timeout) as client:
245
+ response = await asyncio.wait_for(
246
+ client.post(url, content=wav_bytes, **request_kwargs),
247
+ timeout=self.timeout_s,
248
+ )
249
+ response.raise_for_status()
250
+ payload = response.json()
251
+ except asyncio.TimeoutError as exc:
252
+ self._last_error = (
253
+ f"whisper-server request timed out after {self.timeout_s}s"
254
+ )
255
+ raise RuntimeError(self._last_error) from exc
256
+ except Exception as exc:
257
+ self._last_error = f"whisper-server request to {url} failed: {exc}"
258
+ raise RuntimeError(self._last_error) from exc
259
+
260
+ text = self._extract_text(payload)
261
+ if progress:
262
+ await progress(
263
+ "asr.progress",
264
+ {"stage": "complete", "message": "ASR transcription complete."},
265
+ )
266
+ if text:
267
+ yield TranscriptEvent(text=text, final=True)
268
+
269
+ async def unload(self) -> ProviderStatus:
270
+ # No state to release -- the server is owned by the user.
271
+ self._endpoint = None
272
+ return await self.check_status()
273
+
274
+ async def _resolve_endpoint(self) -> str:
275
+ """Pick the transcription endpoint the running server actually exposes.
276
+
277
+ First call probes the legacy ``/inference`` endpoint with a tiny
278
+ HEAD-ish request; if it 404s or returns an error we fall back to
279
+ the OpenAI-compatible ``/v1/audio/transcriptions``. The result
280
+ is cached on the instance so subsequent calls are cheap.
281
+ """
282
+ if self._endpoint is not None:
283
+ return self._endpoint
284
+
285
+ import httpx # type: ignore[import-not-found]
286
+
287
+ timeout = httpx.Timeout(connect=2.0, read=3.0, write=3.0, pool=2.0)
288
+ async with httpx.AsyncClient(timeout=timeout) as client:
289
+ for candidate in ("/inference", "/v1/audio/transcriptions"):
290
+ try:
291
+ response = await client.get(f"{self.base_url}{candidate}")
292
+ except Exception:
293
+ continue
294
+ # 405 (method not allowed) means the endpoint exists
295
+ # and only accepts POST; that is the exact response we
296
+ # expect from /inference. 404 means it is not there.
297
+ if response.status_code in (200, 405):
298
+ self._endpoint = candidate
299
+ return candidate
300
+
301
+ # Nothing matched -- default to the legacy endpoint and let the
302
+ # POST surface a clear error.
303
+ self._endpoint = "/inference"
304
+ return self._endpoint
305
+
306
+ @staticmethod
307
+ def _extract_text(payload) -> str:
308
+ """Pull the transcript out of either endpoint's JSON shape."""
309
+ if not isinstance(payload, dict):
310
+ return ""
311
+ text = payload.get("text")
312
+ if isinstance(text, str):
313
+ return text.strip()
314
+ return ""
315
+
316
+ @staticmethod
317
+ def _missing_dep_message(exc: Exception) -> str:
318
+ hint = extra_hint_for("asr", "whisper-cpp") or _DEFAULT_EXTRA_HINT
319
+ return (
320
+ f"Provider 'whisper-cpp' (asr) is not available. "
321
+ f"Install the required extra with `pip install {hint}`. ({exc})"
322
+ )