converse-sdk 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.
- converse_sdk/__init__.py +37 -0
- converse_sdk/audio.py +50 -0
- converse_sdk/py.typed +1 -0
- converse_sdk/recorder.py +89 -0
- converse_sdk/session.py +306 -0
- converse_sdk-0.1.0.dist-info/METADATA +67 -0
- converse_sdk-0.1.0.dist-info/RECORD +8 -0
- converse_sdk-0.1.0.dist-info/WHEEL +4 -0
converse_sdk/__init__.py
ADDED
|
@@ -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
|
+
]
|
converse_sdk/audio.py
ADDED
|
@@ -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
|
converse_sdk/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
converse_sdk/recorder.py
ADDED
|
@@ -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
|
converse_sdk/session.py
ADDED
|
@@ -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,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,8 @@
|
|
|
1
|
+
converse_sdk/__init__.py,sha256=dAfTd2nlRmBS6Zu1JOFM6a65XF06MIM3Rp7n73Vm5TU,664
|
|
2
|
+
converse_sdk/audio.py,sha256=HJUr8VetVoSx5UNnmdOGY2KrbG8OzMq9NMh8V-_A_7E,1618
|
|
3
|
+
converse_sdk/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
4
|
+
converse_sdk/recorder.py,sha256=342EVHZ7kC5Ag7iqSYsx4pmnxMzzuD6OmgRufsjJgXU,3968
|
|
5
|
+
converse_sdk/session.py,sha256=Q6TRiTI37R-lVlmMQ0XNdCAeoFoBiF17ebFcWua_lps,12572
|
|
6
|
+
converse_sdk-0.1.0.dist-info/METADATA,sha256=nK6j2P3DkpFFJLMjL-rKdTIBAHNZwHbEz4qa6U44uBo,2378
|
|
7
|
+
converse_sdk-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
8
|
+
converse_sdk-0.1.0.dist-info/RECORD,,
|