converse-sdk 0.1.0__tar.gz

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.
@@ -0,0 +1,32 @@
1
+ tmp/
2
+ .venv/
3
+ __pycache__/
4
+ *.pyc
5
+ MEMORY.md
6
+
7
+ eval/out/
8
+ eval/results/
9
+
10
+ # Third-party / regenerable eval audio (fetch via eval/runners/*.py); manifests are tracked.
11
+ # noise_beds/local/ is kept: locally-recorded beds (e.g. keyboard) cannot be regenerated.
12
+ eval/datasets/noise_beds/demand_clean/
13
+ eval/datasets/aec_challenge/real/
14
+ eval/datasets/audio_frontend/fixtures/
15
+
16
+ .env
17
+ *.env
18
+ !.env.example
19
+ .runpod_endpoint
20
+ eval/runners/aec_apm/target/
21
+ turncast/checkpoints/
22
+ turncast/data/synth/
23
+ # turncast tooling defaults write repo-root checkpoints/ when run from the root (see vnext/train.py)
24
+ /checkpoints/
25
+
26
+ # pulled session captures (pull-sessions.sh default dest when run from repo root)
27
+ live_sessions/
28
+
29
+ # generated listen-QA page (rebuild: uv run eval/runners/review/span_review_page.py)
30
+ eval/datasets/frontend_pairs/span_review.html
31
+ eval/datasets/frontend_pairs/backchannel_onsets.html
32
+ .claude/
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: converse-sdk
3
+ Version: 0.1.0
4
+ Summary: Headless Python SDK for the Trelis Converse realtime voice API.
5
+ Project-URL: Documentation, https://converse.trelis.com/docs/api/
6
+ Project-URL: Homepage, https://converse.trelis.com/
7
+ Author: Trelis Research
8
+ License-Expression: LicenseRef-Proprietary
9
+ Keywords: converse,realtime,speech,voice,voice-ai
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.11
18
+ Requires-Dist: numpy>=1.26
19
+ Requires-Dist: websockets>=12.0
20
+ Description-Content-Type: text/markdown
21
+
22
+ # converse-sdk
23
+
24
+ The headless Python SDK for the
25
+ [Trelis Converse realtime voice API](https://converse.trelis.com/docs/api/).
26
+ Use it for telephony bridges, services, evaluations and custom devices.
27
+
28
+ ```sh
29
+ uv add converse-sdk
30
+ ```
31
+
32
+ The session-loop fragment below assumes your media layer supplies `mic_frame` and `play_audio`,
33
+ and your application supplies `run_tool`:
34
+
35
+ ```python
36
+ import os
37
+
38
+ from converse_sdk import ConverseMode, ConverseSession
39
+
40
+ session = await ConverseSession.connect(
41
+ "wss://converse.trelis.com/ws",
42
+ api_key=os.environ["CONVERSE_API_KEY"],
43
+ mode=ConverseMode(
44
+ instructions="Help callers with their orders.",
45
+ greeting="Hello, how can I help?",
46
+ ),
47
+ )
48
+
49
+ async with session:
50
+ await session.send_audio(mic_frame) # PCM16 little-endian mono at 16 kHz
51
+
52
+ async for event in session.events():
53
+ if event.type == "audio":
54
+ play_audio(event.audio) # Float32 mono at 16 kHz
55
+ elif event.type == "tool_call":
56
+ result = await run_tool(event.data["name"], event.data["args"])
57
+ await session.send_tool_result(event.data["id"], result)
58
+ ```
59
+
60
+ The SDK deliberately does not own capture, playback, pacing or echo cancellation. Live playback
61
+ integrations must implement the
62
+ [playback contract](https://converse.trelis.com/docs/api/reference/#playback-contract).
63
+
64
+ See the [complete Python reference](https://converse.trelis.com/docs/api/reference/#python-sdk)
65
+ and the [Twilio bridge quickstart](https://converse.trelis.com/docs/api/reference/#twilio).
66
+
67
+ Proprietary software. All rights reserved.
@@ -0,0 +1,46 @@
1
+ # converse-sdk
2
+
3
+ The headless Python SDK for the
4
+ [Trelis Converse realtime voice API](https://converse.trelis.com/docs/api/).
5
+ Use it for telephony bridges, services, evaluations and custom devices.
6
+
7
+ ```sh
8
+ uv add converse-sdk
9
+ ```
10
+
11
+ The session-loop fragment below assumes your media layer supplies `mic_frame` and `play_audio`,
12
+ and your application supplies `run_tool`:
13
+
14
+ ```python
15
+ import os
16
+
17
+ from converse_sdk import ConverseMode, ConverseSession
18
+
19
+ session = await ConverseSession.connect(
20
+ "wss://converse.trelis.com/ws",
21
+ api_key=os.environ["CONVERSE_API_KEY"],
22
+ mode=ConverseMode(
23
+ instructions="Help callers with their orders.",
24
+ greeting="Hello, how can I help?",
25
+ ),
26
+ )
27
+
28
+ async with session:
29
+ await session.send_audio(mic_frame) # PCM16 little-endian mono at 16 kHz
30
+
31
+ async for event in session.events():
32
+ if event.type == "audio":
33
+ play_audio(event.audio) # Float32 mono at 16 kHz
34
+ elif event.type == "tool_call":
35
+ result = await run_tool(event.data["name"], event.data["args"])
36
+ await session.send_tool_result(event.data["id"], result)
37
+ ```
38
+
39
+ The SDK deliberately does not own capture, playback, pacing or echo cancellation. Live playback
40
+ integrations must implement the
41
+ [playback contract](https://converse.trelis.com/docs/api/reference/#playback-contract).
42
+
43
+ See the [complete Python reference](https://converse.trelis.com/docs/api/reference/#python-sdk)
44
+ and the [Twilio bridge quickstart](https://converse.trelis.com/docs/api/reference/#twilio).
45
+
46
+ Proprietary software. All rights reserved.
@@ -0,0 +1,37 @@
1
+ from .audio import (
2
+ chunk_audio,
3
+ f32le_to_float32,
4
+ float32_to_pcm16,
5
+ pcm16_to_float32,
6
+ to_ws_url,
7
+ )
8
+ from .recorder import TurnRecorder
9
+ from .session import (
10
+ DEFAULT_CHUNK_MS,
11
+ DEFAULT_SR,
12
+ OUTPUT_SR,
13
+ ConverseError,
14
+ ConverseMode,
15
+ RelayMode,
16
+ SessionMode,
17
+ ConverseSession,
18
+ SessionEvent,
19
+ )
20
+
21
+ __all__ = [
22
+ "ConverseError",
23
+ "ConverseMode",
24
+ "RelayMode",
25
+ "SessionMode",
26
+ "ConverseSession",
27
+ "SessionEvent",
28
+ "TurnRecorder",
29
+ "DEFAULT_CHUNK_MS",
30
+ "DEFAULT_SR",
31
+ "OUTPUT_SR",
32
+ "chunk_audio",
33
+ "f32le_to_float32",
34
+ "float32_to_pcm16",
35
+ "pcm16_to_float32",
36
+ "to_ws_url",
37
+ ]
@@ -0,0 +1,50 @@
1
+ """Audio codecs for the Converse wire protocol.
2
+
3
+ Uplink is PCM16 little-endian mono; downlink assistant audio is float32 little-endian mono.
4
+ WAV loading/resampling is deliberately out of scope — callers own file I/O.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from urllib.parse import urlparse
9
+
10
+ import numpy as np
11
+
12
+
13
+ def float32_to_pcm16(audio: np.ndarray) -> bytes:
14
+ clipped = np.clip(audio, -1.0, 1.0)
15
+ return (clipped * 32767.0).astype("<i2").tobytes()
16
+
17
+
18
+ def pcm16_to_float32(data: bytes) -> np.ndarray:
19
+ if not data:
20
+ return np.zeros(0, dtype=np.float32)
21
+ return (np.frombuffer(data, dtype="<i2").astype(np.float32)) / 32768.0
22
+
23
+
24
+ def f32le_to_float32(data: bytes) -> np.ndarray:
25
+ if not data:
26
+ return np.zeros(0, dtype=np.float32)
27
+ return np.frombuffer(data, dtype="<f4").astype(np.float32)
28
+
29
+
30
+ def chunk_audio(audio: np.ndarray, sr: int, chunk_ms: int) -> list[np.ndarray]:
31
+ """Slice a float32 waveform into fixed-size frames, zero-padding the tail."""
32
+ n = max(1, int(sr * chunk_ms / 1000))
33
+ chunks = []
34
+ for i in range(0, len(audio), n):
35
+ chunk = audio[i:i + n]
36
+ if len(chunk) < n:
37
+ padded = np.zeros(n, dtype=np.float32)
38
+ padded[: len(chunk)] = chunk
39
+ chunk = padded
40
+ chunks.append(chunk.astype(np.float32, copy=False))
41
+ return chunks
42
+
43
+
44
+ def to_ws_url(value: str) -> str:
45
+ parsed = urlparse(value)
46
+ if parsed.scheme in {"ws", "wss"}:
47
+ return value
48
+ if parsed.scheme in {"http", "https"}:
49
+ return ("wss://" if parsed.scheme == "https" else "ws://") + value.split("://", 1)[1]
50
+ return value
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,89 @@
1
+ """Canonical assistant-audio bookkeeping over a ConverseSession event stream.
2
+
3
+ Encodes the two Converse-specific capture rules the eval runner established:
4
+ - `canceled` is a pre-audible rescind — the rescinded reply was never heard, so its audio must be
5
+ dropped and first-audio must re-latch on the reply the user actually hears.
6
+ - `interrupted` is a barge — the user heard the fragment, so captured audio keeps it.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+
12
+ import numpy as np
13
+
14
+ from .session import OUTPUT_SR, SessionEvent
15
+
16
+
17
+ @dataclass
18
+ class TurnRecorder:
19
+ sr: int = OUTPUT_SR
20
+ audio_parts: list[np.ndarray] = field(default_factory=list)
21
+ # (arrival_ms, cumulative_samples) per audio frame, for mapping samples back to wall clock.
22
+ arrivals: list[tuple[float, int]] = field(default_factory=list)
23
+ first_audio_ms: float | None = None
24
+ turn_started_ms: float | None = None
25
+ done_ms: float | None = None
26
+ canceled_count: int = 0
27
+ interrupted_count: int = 0
28
+ _turn_audio_start: int = 0
29
+ _turn_arrival_start: int = 0
30
+
31
+ def feed(self, ev: SessionEvent) -> None:
32
+ if ev.type == "audio" and ev.audio is not None and len(ev.audio):
33
+ if self.first_audio_ms is None:
34
+ self.first_audio_ms = ev.t_ms
35
+ self.audio_parts.append(ev.audio)
36
+ total = (self.arrivals[-1][1] if self.arrivals else 0) + len(ev.audio)
37
+ self.arrivals.append((ev.t_ms, total))
38
+ elif ev.type == "turn":
39
+ self.turn_started_ms = ev.t_ms
40
+ elif ev.type == "done":
41
+ self.done_ms = ev.t_ms
42
+ elif ev.type == "canceled":
43
+ self.canceled_count += 1
44
+ # Hard clear of the CURRENT turn's audio only: rescinded pre-audible reply.
45
+ del self.audio_parts[self._turn_audio_start:]
46
+ del self.arrivals[self._turn_arrival_start:]
47
+ self.first_audio_ms = None
48
+ self.turn_started_ms = None
49
+ elif ev.type == "interrupted":
50
+ self.interrupted_count += 1 # keep audio: the user heard the fragment
51
+
52
+ def mark_turn_boundary(self) -> None:
53
+ """Call after a completed turn so a later `canceled` only clears the new turn's audio."""
54
+ self._turn_audio_start = len(self.audio_parts)
55
+ self._turn_arrival_start = len(self.arrivals)
56
+ self.first_audio_ms = None
57
+ self.turn_started_ms = None
58
+ self.done_ms = None
59
+
60
+ def waveform(self) -> np.ndarray:
61
+ """Captured assistant audio, gaplessly concatenated."""
62
+ if not self.audio_parts:
63
+ return np.zeros(0, dtype=np.float32)
64
+ return np.concatenate(self.audio_parts)
65
+
66
+ def aligned_waveform(self, *, end_ms: float | None = None) -> np.ndarray:
67
+ """Assistant audio placed on a wall-clock silence timeline.
68
+
69
+ Frames arrive in bursts ahead of playback, so each frame is placed at its arrival time or
70
+ immediately after the previous frame, whichever is later — the earliest moment it could
71
+ have been audible. This is the layout full-duplex benchmarks score against.
72
+
73
+ `end_ms` is a FLOOR, not a ceiling: audio already placed past it is never truncated
74
+ (captured audio the user heard must not be discarded), it only pads shorter timelines.
75
+ """
76
+ if not self.audio_parts:
77
+ n = int((end_ms or 0) / 1000 * self.sr)
78
+ return np.zeros(n, dtype=np.float32)
79
+ placements = []
80
+ cursor = 0
81
+ for (arrival_ms, _cum), part in zip(self.arrivals, self.audio_parts):
82
+ start = max(int(arrival_ms / 1000 * self.sr), cursor)
83
+ placements.append((start, part))
84
+ cursor = start + len(part)
85
+ end = max(cursor, int(end_ms / 1000 * self.sr) if end_ms is not None else 0)
86
+ timeline = np.zeros(end, dtype=np.float32)
87
+ for start, part in placements:
88
+ timeline[start:start + len(part)] = part
89
+ return timeline
@@ -0,0 +1,306 @@
1
+ """Headless duplex client for the Converse broker websocket protocol.
2
+
3
+ Mirrors the browser SDK's event vocabulary (sdk/browser/src/index.js): `ready`, `turn`, `asr`,
4
+ `utterance`, `done`, `interrupted`, `canceled`, `error`, `ack`, `playback_pause`,
5
+ `playback_resume`, `tool_call`, plus a synthetic `audio` event per binary frame. Every event is
6
+ stamped with `t_ms` — milliseconds since `connect()` began — so callers can reconstruct a
7
+ wall-clock-aligned view of the conversation without their own clock plumbing.
8
+
9
+ The client is a thin wire terminal: it does not decide turn boundaries, pacing policy, or what
10
+ `canceled`/`interrupted` mean for captured audio. `TurnRecorder` (recorder.py) implements the
11
+ canonical bookkeeping for callers that assemble waveforms.
12
+
13
+ Callers that perform REAL playback (telephony bridges, apps with a speaker) own the playback
14
+ clock and must report discards themselves: after an `interrupted` event, send
15
+ `send_client_event("playback_stopped", discarded_ms=<unplayed ms>, barge_seq=<from the event>)`
16
+ so the broker re-truncates its committed text to what the user actually heard (see
17
+ https://converse.trelis.com/docs/api/reference/#playback-contract). Without that report the broker assumes every
18
+ delivered fragment was heard — correct for headless recording/eval use, wrong for live playback.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import json
24
+ import time
25
+ from dataclasses import dataclass, field
26
+ from typing import Any, AsyncIterator
27
+
28
+ import numpy as np
29
+
30
+ from .audio import chunk_audio, f32le_to_float32, float32_to_pcm16, to_ws_url
31
+
32
+ DEFAULT_SR = 16_000
33
+ OUTPUT_SR = 16_000
34
+ DEFAULT_CHUNK_MS = 100
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class ConverseMode:
39
+ """Converse-cascade capabilities. Relay-only fields cannot be expressed here."""
40
+
41
+ voice: str | None = None
42
+ instructions: str | None = None
43
+ tools: list[dict[str, Any]] | None = None
44
+ web_search: bool = False
45
+ flow: bool = False
46
+ greeting: str | bool | None = None # None=server default, False=disabled, str=custom opener
47
+
48
+ def __post_init__(self) -> None:
49
+ if self.voice is not None and not isinstance(self.voice, str):
50
+ raise TypeError("ConverseMode voice must be a string or None")
51
+ if self.instructions is not None and not isinstance(self.instructions, str):
52
+ raise TypeError("ConverseMode instructions must be a string or None")
53
+ if self.tools is not None and not isinstance(self.tools, list):
54
+ raise TypeError("ConverseMode tools must be a list or None")
55
+ if type(self.web_search) is not bool:
56
+ raise TypeError("ConverseMode web_search must be a boolean")
57
+ if type(self.flow) is not bool:
58
+ raise TypeError("ConverseMode flow must be a boolean")
59
+ if self.greeting is not None and self.greeting is not False \
60
+ and not isinstance(self.greeting, str):
61
+ raise ValueError("ConverseMode greeting must be a string, False, or None")
62
+
63
+ def to_wire(self) -> dict[str, Any]:
64
+ mode: dict[str, Any] = {"kind": "converse", "web_search": self.web_search}
65
+ for key, value in (("voice", self.voice), ("instructions", self.instructions),
66
+ ("tools", self.tools), ("greeting", self.greeting)):
67
+ if value is not None:
68
+ mode[key] = value
69
+ if self.flow:
70
+ mode["flow"] = True
71
+ return mode
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class RelayMode:
76
+ """External speech-to-speech relay capabilities; no Converse instructions or tools."""
77
+
78
+ provider: str
79
+ model: str | None = None
80
+ voice: str | None = None
81
+ web_search: bool = False
82
+
83
+ def __post_init__(self) -> None:
84
+ if not isinstance(self.provider, str) or not self.provider.strip():
85
+ raise ValueError("RelayMode provider is required")
86
+ if self.model is not None and not isinstance(self.model, str):
87
+ raise TypeError("RelayMode model must be a string or None")
88
+ if self.voice is not None and not isinstance(self.voice, str):
89
+ raise TypeError("RelayMode voice must be a string or None")
90
+ if type(self.web_search) is not bool:
91
+ raise TypeError("RelayMode web_search must be a boolean")
92
+
93
+ def to_wire(self) -> dict[str, Any]:
94
+ mode: dict[str, Any] = {
95
+ "kind": "relay", "provider": self.provider, "web_search": self.web_search,
96
+ }
97
+ if self.model is not None:
98
+ mode["model"] = self.model
99
+ if self.voice is not None:
100
+ mode["voice"] = self.voice
101
+ return mode
102
+
103
+
104
+ SessionMode = ConverseMode | RelayMode
105
+
106
+
107
+ @dataclass
108
+ class SessionEvent:
109
+ type: str
110
+ t_ms: float
111
+ data: dict[str, Any] = field(default_factory=dict)
112
+ audio: np.ndarray | None = None # float32 mono at OUTPUT_SR, only for type == "audio"
113
+
114
+
115
+ class ConverseError(RuntimeError):
116
+ """Raised when the broker reports {"type": "error"} during connect."""
117
+
118
+
119
+ class ConverseSession:
120
+ """One duplex broker session. Use `ConverseSession.connect(...)` to open.
121
+
122
+ Reading and writing are independent: `send_audio`/`stream_audio` feed the mic uplink while
123
+ `events()` yields broker events — full duplex, no phasing. The receive loop runs as a
124
+ background task from connect until the socket closes; events buffer in an unbounded queue.
125
+ """
126
+
127
+ def __init__(self, ws: Any, start_t: float) -> None:
128
+ self._ws = ws
129
+ self._start_t = start_t
130
+ self._queue: asyncio.Queue[SessionEvent | None] = asyncio.Queue()
131
+ self._reader: asyncio.Task | None = None
132
+ self._closed = False
133
+
134
+ # -- lifecycle -----------------------------------------------------------------
135
+
136
+ @classmethod
137
+ async def connect(
138
+ cls,
139
+ url: str,
140
+ *,
141
+ session_id: str | None = None,
142
+ sr: int = DEFAULT_SR,
143
+ api_key: str | None = None,
144
+ mode: SessionMode | None = None,
145
+ user: str | None = None,
146
+ timezone: str | None = None,
147
+ capabilities: list[str] | None = None,
148
+ connect_timeout_s: float = 15.0,
149
+ ) -> "ConverseSession":
150
+ import websockets
151
+
152
+ selected_mode = mode or ConverseMode()
153
+ if not isinstance(selected_mode, (ConverseMode, RelayMode)):
154
+ raise TypeError("mode must be ConverseMode or RelayMode")
155
+ start: dict[str, Any] = {
156
+ "type": "start",
157
+ "session_id": session_id or f"sdk-{int(time.time() * 1000)}",
158
+ "audio": {"sr": sr},
159
+ "mode": selected_mode.to_wire(),
160
+ }
161
+ if api_key is not None:
162
+ start["api_key"] = api_key
163
+ client = {key: value for key, value in (
164
+ ("user", user), ("timezone", timezone), ("capabilities", capabilities),
165
+ ) if value is not None}
166
+ if client:
167
+ start["client"] = client
168
+ start_json = json.dumps(start)
169
+ start_t = time.perf_counter()
170
+ ws = await websockets.connect(to_ws_url(url), max_size=None)
171
+ await ws.send(start_json)
172
+
173
+ session = cls(ws, start_t)
174
+ session._reader = asyncio.create_task(session._read_loop())
175
+ # Surface `ready` (or a pre-ready error) before returning, mirroring the browser client;
176
+ # the event is also re-queued so `events()` consumers see the full stream.
177
+ try:
178
+ async with asyncio.timeout(connect_timeout_s):
179
+ while True:
180
+ ev = await session._queue.get()
181
+ if ev is None:
182
+ raise ConverseError("connection closed before ready")
183
+ session._queue.put_nowait(ev)
184
+ if ev.type == "ready":
185
+ return session
186
+ if ev.type == "error":
187
+ raise ConverseError(ev.data.get("detail") or "broker error before ready")
188
+ except (ConverseError, TimeoutError):
189
+ await session.close()
190
+ raise
191
+
192
+ async def close(self) -> None:
193
+ if self._closed:
194
+ return
195
+ self._closed = True
196
+ try:
197
+ await self._ws.close()
198
+ finally:
199
+ if self._reader is not None:
200
+ self._reader.cancel()
201
+ try:
202
+ await self._reader
203
+ except (asyncio.CancelledError, Exception): # noqa: BLE001
204
+ pass
205
+ self._queue.put_nowait(None)
206
+
207
+ async def __aenter__(self) -> "ConverseSession":
208
+ return self
209
+
210
+ async def __aexit__(self, *exc: Any) -> None:
211
+ await self.close()
212
+
213
+ # -- clock ---------------------------------------------------------------------
214
+
215
+ @property
216
+ def start_t(self) -> float:
217
+ """perf_counter anchor taken as connect() began; all t_ms values are relative to it."""
218
+ return self._start_t
219
+
220
+ def now_ms(self) -> float:
221
+ return round((time.perf_counter() - self._start_t) * 1000, 1)
222
+
223
+ # -- uplink --------------------------------------------------------------------
224
+
225
+ async def send_audio(self, chunk: np.ndarray | bytes) -> None:
226
+ """Send one mic frame. float32 arrays are encoded to PCM16; bytes pass through."""
227
+ data = chunk if isinstance(chunk, (bytes, bytearray)) else float32_to_pcm16(chunk)
228
+ await self._ws.send(data)
229
+
230
+ async def stream_audio(
231
+ self,
232
+ audio: np.ndarray,
233
+ *,
234
+ sr: int = DEFAULT_SR,
235
+ chunk_ms: int = DEFAULT_CHUNK_MS,
236
+ realtime: bool = True,
237
+ on_sent: Any | None = None,
238
+ ) -> list[float]:
239
+ """Stream a waveform as paced mic frames; returns the actual send time (ms) per chunk.
240
+
241
+ asyncio pacing drifts under load, so callers anchoring timing on the input must use the
242
+ returned actual send times, never `i * chunk_ms`.
243
+ """
244
+ send_times: list[float] = []
245
+ for chunk in chunk_audio(audio, sr, chunk_ms):
246
+ now = self.now_ms()
247
+ send_times.append(now)
248
+ await self.send_audio(chunk)
249
+ if on_sent is not None:
250
+ on_sent(now, chunk)
251
+ if realtime:
252
+ await asyncio.sleep(chunk_ms / 1000)
253
+ return send_times
254
+
255
+ # -- control messages ----------------------------------------------------------
256
+
257
+ async def _send_json(self, payload: dict[str, Any]) -> None:
258
+ await self._ws.send(json.dumps(payload))
259
+
260
+ async def reset(self) -> None:
261
+ await self._send_json({"type": "reset"})
262
+
263
+ async def send_tool_result(self, tool_id: str, content: Any) -> None:
264
+ """Resolve a call with JSON content.
265
+
266
+ Keep results compact: the server enforces its configured UTF-8 JSON byte ceiling and
267
+ replaces oversized content with a bounded truncation marker and preview.
268
+ """
269
+ await self._send_json({"type": "tool_result", "id": tool_id, "content": content})
270
+
271
+ async def send_tool_cancel(self, tool_id: str) -> None:
272
+ await self._send_json({"type": "tool_cancel", "id": tool_id})
273
+
274
+ async def send_tool_progress(self, tool_id: str, note: str) -> None:
275
+ """Report progress on an in-flight tool call (docs/client-tool-protocol.md §3): appends to the brain's
276
+ context so the next turn can speak to it; never resolves the call."""
277
+ await self._send_json({"type": "tool_progress", "id": tool_id, "note": note})
278
+
279
+ async def send_client_event(self, event: str, **fields: Any) -> None:
280
+ await self._send_json({"type": "client_event", "event": event, **fields})
281
+
282
+ # -- downlink ------------------------------------------------------------------
283
+
284
+ async def events(self) -> AsyncIterator[SessionEvent]:
285
+ """Yield broker events until the connection closes. Safe to consume from one task only."""
286
+ while True:
287
+ ev = await self._queue.get()
288
+ if ev is None:
289
+ return
290
+ yield ev
291
+
292
+ async def _read_loop(self) -> None:
293
+ try:
294
+ async for raw in self._ws:
295
+ if isinstance(raw, (bytes, bytearray)):
296
+ self._queue.put_nowait(SessionEvent(
297
+ type="audio", t_ms=self.now_ms(), audio=f32le_to_float32(bytes(raw))))
298
+ continue
299
+ msg = json.loads(raw)
300
+ typ = msg.get("type", "")
301
+ data = {k: v for k, v in msg.items() if k != "type"}
302
+ self._queue.put_nowait(SessionEvent(type=typ, t_ms=self.now_ms(), data=data))
303
+ except Exception: # noqa: BLE001 — closure (normal or abnormal) ends the stream
304
+ pass
305
+ finally:
306
+ self._queue.put_nowait(None)
@@ -0,0 +1,35 @@
1
+ [project]
2
+ name = "converse-sdk"
3
+ version = "0.1.0"
4
+ description = "Headless Python SDK for the Trelis Converse realtime voice API."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "LicenseRef-Proprietary"
8
+ authors = [
9
+ { name = "Trelis Research" },
10
+ ]
11
+ keywords = ["voice", "voice-ai", "realtime", "speech", "converse"]
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Intended Audience :: Developers",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Typing :: Typed",
20
+ ]
21
+ dependencies = [
22
+ "numpy>=1.26",
23
+ "websockets>=12.0",
24
+ ]
25
+
26
+ [project.urls]
27
+ Documentation = "https://converse.trelis.com/docs/api/"
28
+ Homepage = "https://converse.trelis.com/"
29
+
30
+ [build-system]
31
+ requires = ["hatchling"]
32
+ build-backend = "hatchling.build"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["converse_sdk"]
@@ -0,0 +1,186 @@
1
+ """Offline ConverseSession tests against a scripted in-process websocket "broker".
2
+
3
+ These pin the SDK's wire behavior (start frame shape, event surfacing, tool messages) and
4
+ TurnRecorder's canceled/interrupted audio semantics without any live services.
5
+ """
6
+ import asyncio
7
+ import json
8
+
9
+ import numpy as np
10
+ import pytest
11
+ import websockets
12
+ from websockets.asyncio.server import serve
13
+
14
+ from converse_sdk import ConverseError, ConverseMode, RelayMode, ConverseSession, TurnRecorder, float32_to_pcm16
15
+
16
+
17
+ def run_with_server(handler, client):
18
+ """Serve `handler` on port 0 and run `client(port)` under one asyncio loop."""
19
+ async def main():
20
+ async with serve(handler, "127.0.0.1", 0, max_size=None) as server:
21
+ port = server.sockets[0].getsockname()[1]
22
+ return await asyncio.wait_for(client(port), timeout=10)
23
+ return asyncio.run(main())
24
+
25
+
26
+ def test_connect_sends_start_and_surfaces_events_and_audio():
27
+ seen = {}
28
+
29
+ async def handler(ws):
30
+ seen["start"] = json.loads(await ws.recv())
31
+ await ws.send(json.dumps({"type": "ready"}))
32
+ pcm = await ws.recv() # one uplink frame
33
+ seen["uplink"] = pcm
34
+ await ws.send(json.dumps({"type": "turn", "ttfa_ms": 123}))
35
+ await ws.send(np.array([0.5, -0.5], dtype="<f4").tobytes())
36
+ await ws.send(json.dumps({"type": "utterance", "text": "hi there"}))
37
+ await ws.send(json.dumps({"type": "done"}))
38
+
39
+ async def client(port):
40
+ session = await ConverseSession.connect(
41
+ f"ws://127.0.0.1:{port}/ws", session_id="t1", mode=ConverseMode(
42
+ greeting=False,
43
+ tools=[{"name": "f", "parameters": {"type": "object"}, "read_only": True}]))
44
+ events = []
45
+ async with session:
46
+ await session.send_audio(np.zeros(160, dtype=np.float32))
47
+ async for ev in session.events():
48
+ events.append(ev)
49
+ if ev.type == "done":
50
+ break
51
+ return events
52
+
53
+ events = run_with_server(handler, client)
54
+ assert seen["start"]["type"] == "start"
55
+ assert seen["start"]["session_id"] == "t1"
56
+ assert seen["start"]["audio"]["sr"] == 16000
57
+ assert seen["start"]["mode"]["greeting"] is False
58
+ assert seen["start"]["mode"]["tools"][0]["name"] == "f"
59
+ assert seen["uplink"] == float32_to_pcm16(np.zeros(160, dtype=np.float32))
60
+ types = [e.type for e in events]
61
+ assert types == ["ready", "turn", "audio", "utterance", "done"]
62
+ turn = events[1]
63
+ assert turn.data["ttfa_ms"] == 123 and turn.t_ms >= 0
64
+ audio = events[2]
65
+ assert np.allclose(audio.audio, [0.5, -0.5])
66
+
67
+
68
+ def test_mode_types_reject_wrong_runtime_values():
69
+ invalid = [
70
+ lambda: ConverseMode(voice=1),
71
+ lambda: ConverseMode(instructions={}),
72
+ lambda: ConverseMode(tools="not-a-list"),
73
+ lambda: ConverseMode(web_search="false"),
74
+ lambda: ConverseMode(flow=1),
75
+ lambda: RelayMode(provider="gemini-live", model=1),
76
+ lambda: RelayMode(provider="gemini-live", voice=[]),
77
+ lambda: RelayMode(provider="gemini-live", web_search="false"),
78
+ ]
79
+ for construct in invalid:
80
+ with pytest.raises(TypeError):
81
+ construct()
82
+
83
+
84
+ def test_unserializable_mode_fails_before_websocket_connect(monkeypatch):
85
+ calls = 0
86
+
87
+ async def fail_if_called(*args, **kwargs):
88
+ nonlocal calls
89
+ calls += 1
90
+ raise AssertionError("websocket opened before start serialization")
91
+
92
+ monkeypatch.setattr(websockets, "connect", fail_if_called)
93
+ with pytest.raises(TypeError, match="JSON serializable"):
94
+ asyncio.run(ConverseSession.connect(
95
+ "ws://example.invalid/ws", mode=ConverseMode(tools=[{"schema": object()}])))
96
+ assert calls == 0
97
+
98
+
99
+
100
+ def test_mode_types_make_invalid_capability_combinations_unserializable():
101
+ combined = ConverseMode(tools=[], web_search=True)
102
+ assert combined.to_wire()["tools"] == [] and combined.to_wire()["web_search"] is True
103
+ relay = RelayMode(provider="gemini-live", model="gemini-live")
104
+ assert relay.to_wire() == {
105
+ "kind": "relay", "provider": "gemini-live", "model": "gemini-live",
106
+ "web_search": False,
107
+ }
108
+
109
+ def test_connect_raises_on_broker_error():
110
+ async def handler(ws):
111
+ await ws.recv()
112
+ await ws.send(json.dumps({"type": "error", "detail": "unauthorized"}))
113
+
114
+ async def client(port):
115
+ with pytest.raises(ConverseError, match="unauthorized"):
116
+ await ConverseSession.connect(f"ws://127.0.0.1:{port}/ws")
117
+ return True
118
+
119
+ assert run_with_server(handler, client)
120
+
121
+
122
+ def test_tool_result_and_cancel_wire_shape():
123
+ frames = []
124
+
125
+ async def handler(ws):
126
+ await ws.recv()
127
+ await ws.send(json.dumps({"type": "ready"}))
128
+ frames.append(json.loads(await ws.recv()))
129
+ frames.append(json.loads(await ws.recv()))
130
+ frames.append(json.loads(await ws.recv()))
131
+
132
+ async def client(port):
133
+ async with await ConverseSession.connect(f"ws://127.0.0.1:{port}/ws") as session:
134
+ await session.send_tool_result("call-1", {"status": "ok", "rows": [1, 2]})
135
+ await session.send_tool_cancel("call-2")
136
+ await session.send_tool_progress("call-3", "running the test suite")
137
+ await asyncio.sleep(0.05)
138
+ return True
139
+
140
+ assert run_with_server(handler, client)
141
+ assert frames[0] == {"type": "tool_result", "id": "call-1",
142
+ "content": {"status": "ok", "rows": [1, 2]}}
143
+ assert frames[1] == {"type": "tool_cancel", "id": "call-2"}
144
+ assert frames[2] == {"type": "tool_progress", "id": "call-3",
145
+ "note": "running the test suite"}
146
+
147
+
148
+ def test_turn_recorder_canceled_clears_only_current_turn_and_interrupted_keeps():
149
+ from converse_sdk.session import SessionEvent
150
+
151
+ rec = TurnRecorder()
152
+ a1 = np.full(160, 0.1, dtype=np.float32)
153
+ rec.feed(SessionEvent("turn", 100.0))
154
+ rec.feed(SessionEvent("audio", 150.0, audio=a1))
155
+ rec.feed(SessionEvent("done", 500.0))
156
+ rec.mark_turn_boundary()
157
+
158
+ # Turn 2: audio arrives, then a pre-audible rescind wipes ONLY turn 2's audio.
159
+ a2 = np.full(80, 0.2, dtype=np.float32)
160
+ rec.feed(SessionEvent("audio", 700.0, audio=a2))
161
+ rec.feed(SessionEvent("canceled", 750.0))
162
+ assert rec.canceled_count == 1
163
+ assert len(rec.waveform()) == 160 # turn 1 audio intact
164
+ assert rec.first_audio_ms is None # re-latches on the reply actually heard
165
+
166
+ # The re-answer arrives, then a barge: interrupted keeps the heard fragment.
167
+ a3 = np.full(320, 0.3, dtype=np.float32)
168
+ rec.feed(SessionEvent("audio", 900.0, audio=a3))
169
+ rec.feed(SessionEvent("interrupted", 1000.0))
170
+ assert rec.interrupted_count == 1
171
+ assert len(rec.waveform()) == 160 + 320
172
+
173
+
174
+ def test_aligned_waveform_places_audio_at_arrival_times():
175
+ from converse_sdk.session import SessionEvent
176
+
177
+ rec = TurnRecorder(sr=16000)
178
+ burst = np.ones(1600, dtype=np.float32) # 100 ms of audio
179
+ rec.feed(SessionEvent("audio", 1000.0, audio=burst)) # arrives at 1.0 s
180
+ rec.feed(SessionEvent("audio", 1010.0, audio=burst)) # burst right behind it
181
+ wave = rec.aligned_waveform(end_ms=2000.0)
182
+ assert len(wave) == 32000 # exactly 2 s timeline
183
+ assert not wave[: int(0.99 * 16000)].any() # silence before first arrival
184
+ start = 16000
185
+ assert wave[start: start + 3200].all() # both frames contiguous from 1.0 s
186
+ assert not wave[start + 3200:].any()