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.
- converse_sdk-0.1.0/.gitignore +32 -0
- converse_sdk-0.1.0/PKG-INFO +67 -0
- converse_sdk-0.1.0/README.md +46 -0
- converse_sdk-0.1.0/converse_sdk/__init__.py +37 -0
- converse_sdk-0.1.0/converse_sdk/audio.py +50 -0
- converse_sdk-0.1.0/converse_sdk/py.typed +1 -0
- converse_sdk-0.1.0/converse_sdk/recorder.py +89 -0
- converse_sdk-0.1.0/converse_sdk/session.py +306 -0
- converse_sdk-0.1.0/pyproject.toml +35 -0
- converse_sdk-0.1.0/tests/test_session.py +186 -0
|
@@ -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()
|