vocalstack 1.0.0a0__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.
- vocalstack/SKILL.md +235 -0
- vocalstack/__init__.py +63 -0
- vocalstack/_http.py +157 -0
- vocalstack/_models.py +1745 -0
- vocalstack/client.py +107 -0
- vocalstack/errors.py +83 -0
- vocalstack/realtime.py +411 -0
- vocalstack/resources.py +835 -0
- vocalstack-1.0.0a0.dist-info/METADATA +117 -0
- vocalstack-1.0.0a0.dist-info/RECORD +11 -0
- vocalstack-1.0.0a0.dist-info/WHEEL +4 -0
vocalstack/client.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""The VocalStack client: one class, grouped by resource.
|
|
2
|
+
|
|
3
|
+
import asyncio, os
|
|
4
|
+
from vocalstack import VocalStack
|
|
5
|
+
|
|
6
|
+
async def main():
|
|
7
|
+
async with VocalStack(api_key=os.environ["VOCALSTACK_API_KEY"]) as vs:
|
|
8
|
+
tx = await vs.transcriptions.create(url="https://example.com/talk.mp3")
|
|
9
|
+
job = await vs.jobs.wait(tx.job.recording_id)
|
|
10
|
+
snap = await vs.recordings.transcript(job.recording_id)
|
|
11
|
+
print(" ".join(s.text for s in snap.sentences))
|
|
12
|
+
|
|
13
|
+
asyncio.run(main())
|
|
14
|
+
|
|
15
|
+
What is what: a realtime session is a signed token plus one socket,
|
|
16
|
+
nothing stored (``vs.realtime``); a broadcast is the link listeners open;
|
|
17
|
+
a transcription is a file, link, microphone or stream transcribed and
|
|
18
|
+
stored; a recording is one take inside either; a job is a file or link
|
|
19
|
+
recording's transcription work.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import Callable, Optional, Union
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
|
|
28
|
+
from ._http import DEFAULT_BASE_URL, Transport
|
|
29
|
+
from .realtime import Realtime
|
|
30
|
+
from .resources import (
|
|
31
|
+
Billing,
|
|
32
|
+
Broadcasts,
|
|
33
|
+
Corrections,
|
|
34
|
+
Events,
|
|
35
|
+
Jobs,
|
|
36
|
+
Languages,
|
|
37
|
+
Me,
|
|
38
|
+
Meta,
|
|
39
|
+
Recordings,
|
|
40
|
+
Rtmp,
|
|
41
|
+
Search,
|
|
42
|
+
ShareLinks,
|
|
43
|
+
Transcriptions,
|
|
44
|
+
Voices,
|
|
45
|
+
)
|
|
46
|
+
from .realtime import DEFAULT_READY_DEADLINE_S
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class VocalStack:
|
|
50
|
+
"""``api_key``: an API key (``vs_key_``), for a server. ``token``
|
|
51
|
+
instead: a signed-in person's access token or a browser token, or a
|
|
52
|
+
callable returning one (read per request); not with ``api_key``. ``base_url`` defaults to
|
|
53
|
+
``https://api.vocalstack.com``. A ``429`` is retried after its
|
|
54
|
+
``Retry-After`` (at most 60 s) up to ``max_retries`` times."""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
base_url: Optional[str] = None,
|
|
59
|
+
*,
|
|
60
|
+
api_key: Optional[str] = None,
|
|
61
|
+
token: Union[None, str, Callable[[], Optional[str]]] = None,
|
|
62
|
+
acting_principal: Optional[str] = None,
|
|
63
|
+
device_id: Optional[str] = None,
|
|
64
|
+
http: Optional[httpx.AsyncClient] = None,
|
|
65
|
+
max_retries: int = 2,
|
|
66
|
+
timeout: float = 300.0,
|
|
67
|
+
ready_deadline_s: float = DEFAULT_READY_DEADLINE_S,
|
|
68
|
+
) -> None:
|
|
69
|
+
t = Transport(
|
|
70
|
+
base_url,
|
|
71
|
+
api_key,
|
|
72
|
+
token,
|
|
73
|
+
http=http,
|
|
74
|
+
acting_principal=acting_principal,
|
|
75
|
+
device_id=device_id,
|
|
76
|
+
max_retries=max_retries,
|
|
77
|
+
timeout=timeout,
|
|
78
|
+
)
|
|
79
|
+
self._t = t
|
|
80
|
+
self.base_url = t.base_url
|
|
81
|
+
self.realtime = Realtime(t, ready_deadline_s)
|
|
82
|
+
self.broadcasts = Broadcasts(t)
|
|
83
|
+
self.transcriptions = Transcriptions(t)
|
|
84
|
+
self.recordings = Recordings(t, ready_deadline_s)
|
|
85
|
+
self.jobs = Jobs(t)
|
|
86
|
+
self.corrections = Corrections(t)
|
|
87
|
+
self.share_links = ShareLinks(t)
|
|
88
|
+
self.voices = Voices(t)
|
|
89
|
+
self.search = Search(t)
|
|
90
|
+
self.me = Me(t)
|
|
91
|
+
self.billing = Billing(t)
|
|
92
|
+
self.languages = Languages(t)
|
|
93
|
+
self.rtmp = Rtmp(t)
|
|
94
|
+
self.events = Events(t)
|
|
95
|
+
self.meta = Meta(t)
|
|
96
|
+
|
|
97
|
+
async def aclose(self) -> None:
|
|
98
|
+
await self._t.aclose()
|
|
99
|
+
|
|
100
|
+
async def __aenter__(self) -> "VocalStack":
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
104
|
+
await self.aclose()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
__all__ = ["DEFAULT_BASE_URL", "VocalStack"]
|
vocalstack/errors.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Typed exceptions keyed by the contract's closed error-code enum."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import email.utils
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Mapping, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class VocalStackError(Exception):
|
|
11
|
+
"""Any failure surfaced by this SDK: an HTTP error envelope, a socket
|
|
12
|
+
``error`` frame, or one the transport synthesizes (``capacity_exceeded``).
|
|
13
|
+
|
|
14
|
+
``details`` is the envelope's ``data.details`` (for example ``metric``,
|
|
15
|
+
``balance``, ``required``, ``plan``, ``topUpUrl`` on ``insufficient_credit``);
|
|
16
|
+
``headers`` are the response headers (``Retry-After``, ``RateLimit-*``,
|
|
17
|
+
``WWW-Authenticate``) when the error came from an HTTP response.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
code: str,
|
|
23
|
+
message: str,
|
|
24
|
+
*,
|
|
25
|
+
retryable: bool = False,
|
|
26
|
+
status: Optional[int] = None,
|
|
27
|
+
details: Any = None,
|
|
28
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
super().__init__(message)
|
|
31
|
+
self.code = code
|
|
32
|
+
self.message = message
|
|
33
|
+
self.retryable = retryable
|
|
34
|
+
self.status = status
|
|
35
|
+
self.details = details
|
|
36
|
+
self.headers = headers
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def retry_after(self) -> Optional[float]:
|
|
40
|
+
"""Seconds the server asked the caller to wait (``Retry-After``), if it said."""
|
|
41
|
+
v = self.headers.get("retry-after") if self.headers is not None else None
|
|
42
|
+
if not v:
|
|
43
|
+
return None
|
|
44
|
+
try:
|
|
45
|
+
return float(v)
|
|
46
|
+
except ValueError:
|
|
47
|
+
pass
|
|
48
|
+
try:
|
|
49
|
+
at = email.utils.parsedate_to_datetime(v).timestamp()
|
|
50
|
+
except (TypeError, ValueError):
|
|
51
|
+
return None
|
|
52
|
+
return max(0.0, at - time.time())
|
|
53
|
+
|
|
54
|
+
def __repr__(self) -> str:
|
|
55
|
+
return (
|
|
56
|
+
f"VocalStackError(code={self.code!r}, message={self.message!r}, "
|
|
57
|
+
f"retryable={self.retryable}, status={self.status}, details={self.details!r})"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def capacity_exceeded(message: str) -> VocalStackError:
|
|
62
|
+
"""Synthesized by the transport: a saturated fleet queues the socket
|
|
63
|
+
upgrade at the proxy (it hangs) or answers a bare 503, and the wire
|
|
64
|
+
never carries the envelope."""
|
|
65
|
+
return VocalStackError("capacity_exceeded", message, retryable=True)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def from_envelope(
|
|
69
|
+
body: Any, status: Optional[int] = None, headers: Optional[Mapping[str, str]] = None
|
|
70
|
+
) -> VocalStackError:
|
|
71
|
+
if isinstance(body, dict) and isinstance(body.get("code"), str):
|
|
72
|
+
data = body.get("data") or {}
|
|
73
|
+
return VocalStackError(
|
|
74
|
+
body["code"],
|
|
75
|
+
body.get("message", body["code"]),
|
|
76
|
+
retryable=bool(data.get("retryable", False)),
|
|
77
|
+
status=status,
|
|
78
|
+
details=data.get("details"),
|
|
79
|
+
headers=headers,
|
|
80
|
+
)
|
|
81
|
+
return VocalStackError(
|
|
82
|
+
"internal", f"unexpected error (status {status})", status=status, headers=headers
|
|
83
|
+
)
|
vocalstack/realtime.py
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"""``vs.realtime``: a realtime session is a signed token plus one socket
|
|
2
|
+
(``RealtimeSession``, over the public WS frame protocol; a recording's
|
|
3
|
+
publisher stream is one too). Nothing is stored: the transcript is what
|
|
4
|
+
the socket sends."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any, AsyncIterator, Callable, Dict, List, Optional, Union
|
|
13
|
+
|
|
14
|
+
import websockets
|
|
15
|
+
from websockets.asyncio.client import ClientConnection
|
|
16
|
+
|
|
17
|
+
from ._http import Transport
|
|
18
|
+
from ._models import (
|
|
19
|
+
Capabilities,
|
|
20
|
+
CreateSessionResponse,
|
|
21
|
+
ErrorFrame,
|
|
22
|
+
SessionDone,
|
|
23
|
+
SessionOptions,
|
|
24
|
+
SessionStats,
|
|
25
|
+
TranscriptEvent,
|
|
26
|
+
parse_server_frame,
|
|
27
|
+
)
|
|
28
|
+
from .errors import VocalStackError, capacity_exceeded, from_envelope
|
|
29
|
+
|
|
30
|
+
DEFAULT_READY_DEADLINE_S = 30.0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def upgrade_refused(status: int, body: Optional[bytes]) -> VocalStackError:
|
|
34
|
+
"""A refused socket upgrade as an error. A 503 carrying the error body
|
|
35
|
+
with ``unavailable`` (the database is waking; retry shortly) says so;
|
|
36
|
+
any other 503 (a bare one from a saturated proxy, or no body at all) is
|
|
37
|
+
``capacity_exceeded``. So is a 502 or 504 without the API's error body:
|
|
38
|
+
the proxy in front of the engines found none free in time. Every other
|
|
39
|
+
status is its own envelope."""
|
|
40
|
+
parsed = None
|
|
41
|
+
try:
|
|
42
|
+
parsed = json.loads(body) if body else None
|
|
43
|
+
except Exception:
|
|
44
|
+
pass
|
|
45
|
+
if status == 503:
|
|
46
|
+
if isinstance(parsed, dict) and parsed.get("code") == "unavailable":
|
|
47
|
+
return from_envelope(parsed, status)
|
|
48
|
+
return capacity_exceeded("stream host answered 503 (fleet saturated)")
|
|
49
|
+
ours = isinstance(parsed, dict) and isinstance(parsed.get("code"), str)
|
|
50
|
+
if status in (502, 504) and not ours:
|
|
51
|
+
return capacity_exceeded(f"stream host answered {status} (no engine answered in time)")
|
|
52
|
+
return from_envelope(parsed, status)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RealtimeSession:
|
|
56
|
+
"""Async-iterable stream of transcript events.
|
|
57
|
+
|
|
58
|
+
Yields ``transcript.committed`` / ``transcript.draft`` /
|
|
59
|
+
``transcript.words`` model objects; lifecycle frames are handled
|
|
60
|
+
internally (``session.loading``/``ready`` before iteration begins,
|
|
61
|
+
``session.done``/``error`` ending it). Unknown frame types are
|
|
62
|
+
ignored.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self, session_id: str, ws: ClientConnection, on_frame: Optional[Callable[[Any], None]] = None
|
|
67
|
+
) -> None:
|
|
68
|
+
self.session_id = session_id
|
|
69
|
+
self._ws = ws
|
|
70
|
+
self._on_frame = on_frame
|
|
71
|
+
self.done_reason: Optional[str] = None
|
|
72
|
+
self.stats: Optional[SessionStats] = None
|
|
73
|
+
self._error: Optional[VocalStackError] = None
|
|
74
|
+
self._closed = False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
async def connect(
|
|
79
|
+
cls,
|
|
80
|
+
session_id: str,
|
|
81
|
+
stream_url: str,
|
|
82
|
+
*,
|
|
83
|
+
ready_deadline_s: float = DEFAULT_READY_DEADLINE_S,
|
|
84
|
+
on_frame: Optional[Callable[[Any], None]] = None,
|
|
85
|
+
) -> "RealtimeSession":
|
|
86
|
+
"""Open the WS and wait for ``session.ready``.
|
|
87
|
+
|
|
88
|
+
``on_frame`` sees every server text frame as it arrives, parsed:
|
|
89
|
+
the lifecycle frames the iterator keeps to itself
|
|
90
|
+
(``session.loading``, ``session.ready``, ``session.done``,
|
|
91
|
+
``error``) and the transcript frames alike. For logs and protocol
|
|
92
|
+
views; iterate the session to read the transcript.
|
|
93
|
+
|
|
94
|
+
The deadline covers the gap since the LAST server frame: expiry
|
|
95
|
+
with no frames seen synthesizes ``capacity_exceeded`` (the
|
|
96
|
+
upgrade hung in the proxy's queue); once
|
|
97
|
+
``session.loading`` heartbeats flow, expiry maps to
|
|
98
|
+
``engine_unavailable`` instead.
|
|
99
|
+
"""
|
|
100
|
+
try:
|
|
101
|
+
# The upgrade waits in the proxy's queue while every engine is
|
|
102
|
+
# busy: the ready deadline covers it, not websockets' own 10 s.
|
|
103
|
+
ws = await websockets.connect(stream_url, max_size=None, open_timeout=ready_deadline_s)
|
|
104
|
+
except websockets.InvalidStatus as e:
|
|
105
|
+
raise upgrade_refused(e.response.status_code, e.response.body) from e
|
|
106
|
+
except TimeoutError as e:
|
|
107
|
+
raise VocalStackError(
|
|
108
|
+
"capacity_exceeded", f"the upgrade was not answered within {ready_deadline_s}s", retryable=True
|
|
109
|
+
) from e
|
|
110
|
+
except OSError as e:
|
|
111
|
+
raise VocalStackError("engine_unavailable", f"connect failed: {e}", retryable=True) from e
|
|
112
|
+
|
|
113
|
+
saw_frame = False
|
|
114
|
+
while True:
|
|
115
|
+
try:
|
|
116
|
+
raw = await asyncio.wait_for(ws.recv(), timeout=ready_deadline_s)
|
|
117
|
+
except asyncio.TimeoutError:
|
|
118
|
+
await ws.close()
|
|
119
|
+
if saw_frame:
|
|
120
|
+
raise VocalStackError(
|
|
121
|
+
"engine_unavailable",
|
|
122
|
+
f"engine did not become ready within {ready_deadline_s}s",
|
|
123
|
+
retryable=True,
|
|
124
|
+
) from None
|
|
125
|
+
raise capacity_exceeded(
|
|
126
|
+
f"no response from stream host within {ready_deadline_s}s "
|
|
127
|
+
"(fleet may be saturated)"
|
|
128
|
+
) from None
|
|
129
|
+
except websockets.ConnectionClosed as e:
|
|
130
|
+
raise VocalStackError(
|
|
131
|
+
"engine_unavailable", "connection closed before ready", retryable=True
|
|
132
|
+
) from e
|
|
133
|
+
if isinstance(raw, bytes):
|
|
134
|
+
continue
|
|
135
|
+
saw_frame = True
|
|
136
|
+
frame = parse_server_frame(raw)
|
|
137
|
+
if frame is None:
|
|
138
|
+
continue
|
|
139
|
+
_hand(on_frame, frame)
|
|
140
|
+
if frame.type == "session.ready":
|
|
141
|
+
return cls(session_id, ws, on_frame)
|
|
142
|
+
if frame.type == "error":
|
|
143
|
+
await ws.close()
|
|
144
|
+
raise VocalStackError(frame.code, frame.message, retryable=frame.retryable)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def send_audio(self, pcm: Union[bytes, bytearray, memoryview]) -> None:
|
|
148
|
+
"""Raw PCM per the session's negotiated format (f32le 16 kHz mono).
|
|
149
|
+
|
|
150
|
+
Sends racing the end of the session are silently dropped — the
|
|
151
|
+
event iterator is the authority on how the session ended.
|
|
152
|
+
"""
|
|
153
|
+
try:
|
|
154
|
+
await self._ws.send(pcm)
|
|
155
|
+
except websockets.ConnectionClosed:
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
async def send_file(
|
|
159
|
+
self,
|
|
160
|
+
source: Union[str, bytes, bytearray, "os.PathLike[str]"],
|
|
161
|
+
*,
|
|
162
|
+
chunk_bytes: int = 256 * 1024,
|
|
163
|
+
) -> None:
|
|
164
|
+
"""Upload an encoded audio file (file sessions only), then ``eof()``.
|
|
165
|
+
|
|
166
|
+
A path is streamed from disk rather than read whole — uploads run
|
|
167
|
+
to hundreds of megabytes.
|
|
168
|
+
"""
|
|
169
|
+
if isinstance(source, (bytes, bytearray)):
|
|
170
|
+
for off in range(0, len(source), chunk_bytes):
|
|
171
|
+
await self.send_audio(bytes(source[off : off + chunk_bytes]))
|
|
172
|
+
else:
|
|
173
|
+
with open(source, "rb") as fh:
|
|
174
|
+
while True:
|
|
175
|
+
chunk = await asyncio.to_thread(fh.read, chunk_bytes)
|
|
176
|
+
if not chunk:
|
|
177
|
+
break
|
|
178
|
+
await self.send_audio(chunk)
|
|
179
|
+
await self.eof()
|
|
180
|
+
|
|
181
|
+
async def eof(self) -> None:
|
|
182
|
+
"""End of source: server flushes, emits final commits, then
|
|
183
|
+
``session.done`` — after which iteration completes."""
|
|
184
|
+
try:
|
|
185
|
+
await self._ws.send(json.dumps({"type": "eof"}))
|
|
186
|
+
except websockets.ConnectionClosed:
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
async def update(
|
|
190
|
+
self,
|
|
191
|
+
*,
|
|
192
|
+
latency_mode: Optional[str] = None,
|
|
193
|
+
spoken_language: Optional[str] = None,
|
|
194
|
+
speaker_labels: Optional[str] = None,
|
|
195
|
+
translate: Optional[List[str]] = None,
|
|
196
|
+
) -> None:
|
|
197
|
+
"""Options update during the realtime session; omitted fields are unchanged.
|
|
198
|
+
|
|
199
|
+
``latency_mode`` (``"auto"`` | ``"fast"`` | ``"accuracy"``)
|
|
200
|
+
switches the decode model policy from the next engine step;
|
|
201
|
+
``spoken_language`` changes the transcription language from the next
|
|
202
|
+
engine step (``""`` or ``"auto"`` = auto-detect); ``speaker_labels``
|
|
203
|
+
turns speaker labelling on (``"on"`` or ``"auto"``) or off (``"off"``) from
|
|
204
|
+
the next engine step; ``translate`` replaces the whole
|
|
205
|
+
target-language list (``[]`` turns translation off). Applies to
|
|
206
|
+
subsequent transcript — already-delivered segments are not
|
|
207
|
+
re-transcribed, re-attributed or re-translated.
|
|
208
|
+
"""
|
|
209
|
+
frame: Dict[str, Any] = {"type": "session.update"}
|
|
210
|
+
if latency_mode is not None:
|
|
211
|
+
frame["latencyMode"] = latency_mode
|
|
212
|
+
if spoken_language is not None:
|
|
213
|
+
frame["spokenLanguage"] = spoken_language
|
|
214
|
+
if speaker_labels is not None:
|
|
215
|
+
frame["speakerLabels"] = speaker_labels
|
|
216
|
+
if translate is not None:
|
|
217
|
+
frame["translate"] = translate
|
|
218
|
+
try:
|
|
219
|
+
await self._ws.send(json.dumps(frame))
|
|
220
|
+
except websockets.ConnectionClosed:
|
|
221
|
+
pass
|
|
222
|
+
|
|
223
|
+
async def cancel(self) -> None:
|
|
224
|
+
"""Stop now, abandoning the rest of the audio.
|
|
225
|
+
|
|
226
|
+
The server ends the session with ``session.done`` /
|
|
227
|
+
``cancelled`` and frees the machine immediately; everything
|
|
228
|
+
committed before the stop stays valid. Prefer this to
|
|
229
|
+
``close()`` for a deliberate stop — it is a clean end that still
|
|
230
|
+
reports stats.
|
|
231
|
+
"""
|
|
232
|
+
try:
|
|
233
|
+
await self._ws.send(json.dumps({"type": "cancel"}))
|
|
234
|
+
except websockets.ConnectionClosed:
|
|
235
|
+
pass
|
|
236
|
+
|
|
237
|
+
async def close(self) -> None:
|
|
238
|
+
"""Hard-close (aborts the run server-side; no resume, rule 7).
|
|
239
|
+
Prefer ``eof()`` to get final commits."""
|
|
240
|
+
self._closed = True
|
|
241
|
+
await self._ws.close()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def __aiter__(self) -> AsyncIterator[TranscriptEvent]:
|
|
245
|
+
return self._events()
|
|
246
|
+
|
|
247
|
+
async def _events(self) -> AsyncIterator[TranscriptEvent]:
|
|
248
|
+
while True:
|
|
249
|
+
if self._error:
|
|
250
|
+
raise self._error
|
|
251
|
+
if self.done_reason is not None or self._closed:
|
|
252
|
+
return
|
|
253
|
+
try:
|
|
254
|
+
raw = await self._ws.recv()
|
|
255
|
+
except websockets.ConnectionClosed:
|
|
256
|
+
return
|
|
257
|
+
if isinstance(raw, bytes):
|
|
258
|
+
continue
|
|
259
|
+
frame = parse_server_frame(raw)
|
|
260
|
+
if frame is None:
|
|
261
|
+
continue
|
|
262
|
+
_hand(self._on_frame, frame)
|
|
263
|
+
if isinstance(frame, SessionDone):
|
|
264
|
+
self.done_reason = frame.reason
|
|
265
|
+
self.stats = frame.stats
|
|
266
|
+
return
|
|
267
|
+
if isinstance(frame, ErrorFrame):
|
|
268
|
+
self._error = VocalStackError(
|
|
269
|
+
frame.code, frame.message, retryable=frame.retryable
|
|
270
|
+
)
|
|
271
|
+
raise self._error
|
|
272
|
+
if frame.type.startswith("transcript."):
|
|
273
|
+
yield frame
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
async def __aenter__(self) -> "RealtimeSession":
|
|
277
|
+
return self
|
|
278
|
+
|
|
279
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
280
|
+
await self.close()
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _hand(on_frame: Optional[Callable[[Any], None]], frame: Any) -> None:
|
|
284
|
+
"""Hands a frame to ``on_frame``; a raising callback never breaks the socket."""
|
|
285
|
+
if on_frame is None:
|
|
286
|
+
return
|
|
287
|
+
try:
|
|
288
|
+
on_frame(frame)
|
|
289
|
+
except Exception:
|
|
290
|
+
pass
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
async def pace_realtime(
|
|
294
|
+
session: RealtimeSession,
|
|
295
|
+
pcm: bytes,
|
|
296
|
+
*,
|
|
297
|
+
chunk_seconds: float = 0.25,
|
|
298
|
+
speed: float = 1.0,
|
|
299
|
+
) -> None:
|
|
300
|
+
"""Feed a whole PCM buffer at ``speed``× realtime, then ``eof()``.
|
|
301
|
+
Convenience for file/batch sources against the realtime API."""
|
|
302
|
+
bytes_per_second = 16000 * 4
|
|
303
|
+
chunk = max(4, int(bytes_per_second * chunk_seconds) & ~3)
|
|
304
|
+
start = time.monotonic()
|
|
305
|
+
sent_s = 0.0
|
|
306
|
+
for off in range(0, len(pcm), chunk):
|
|
307
|
+
await session.send_audio(pcm[off : off + chunk])
|
|
308
|
+
sent_s += len(pcm[off : off + chunk]) / bytes_per_second
|
|
309
|
+
target = start + sent_s / speed
|
|
310
|
+
delay = target - time.monotonic()
|
|
311
|
+
if delay > 0:
|
|
312
|
+
await asyncio.sleep(delay)
|
|
313
|
+
await session.eof()
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class RealtimeTranscript:
|
|
317
|
+
"""Result of :meth:`Realtime.transcribe_file` / :meth:`Realtime.transcribe_url`."""
|
|
318
|
+
|
|
319
|
+
__slots__ = ("segments", "done_reason", "stats")
|
|
320
|
+
|
|
321
|
+
def __init__(self, segments: list, done_reason: Optional[str], stats: Any = None) -> None:
|
|
322
|
+
self.segments = segments
|
|
323
|
+
self.done_reason = done_reason
|
|
324
|
+
self.stats = stats
|
|
325
|
+
|
|
326
|
+
@property
|
|
327
|
+
def text(self) -> str:
|
|
328
|
+
"""All committed text, space-joined."""
|
|
329
|
+
return " ".join(s.text for s in self.segments)
|
|
330
|
+
|
|
331
|
+
def __repr__(self) -> str:
|
|
332
|
+
rt = f", realtime={self.stats.realtime_factor}x" if self.stats else ""
|
|
333
|
+
return f"RealtimeTranscript(segments={len(self.segments)}, done_reason={self.done_reason!r}{rt})"
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
class Realtime:
|
|
337
|
+
def __init__(self, t: Transport, ready_deadline_s: float) -> None:
|
|
338
|
+
self._t = t
|
|
339
|
+
self._ready_deadline_s = ready_deadline_s
|
|
340
|
+
|
|
341
|
+
async def create_session(self, **options: Any) -> CreateSessionResponse:
|
|
342
|
+
"""``POST /v1/realtime/sessions``: validate the options, get a signed
|
|
343
|
+
``stream_url``. Keyword args mirror SessionOptions (``spoken_language``,
|
|
344
|
+
``speaker_labels``, ``latency_mode``, ``events``, ``input``, ...)."""
|
|
345
|
+
body = SessionOptions.model_validate(options).model_dump(by_alias=True, exclude_none=True, mode="json")
|
|
346
|
+
data = await self._t.json("POST", "/v1/realtime/sessions", json_body=body)
|
|
347
|
+
return CreateSessionResponse.model_validate(data)
|
|
348
|
+
|
|
349
|
+
async def connect(
|
|
350
|
+
self,
|
|
351
|
+
*,
|
|
352
|
+
ready_deadline_s: Optional[float] = None,
|
|
353
|
+
on_frame: Optional[Callable[[Any], None]] = None,
|
|
354
|
+
**options: Any,
|
|
355
|
+
) -> RealtimeSession:
|
|
356
|
+
"""``create_session``, then open the socket and wait for ``session.ready``.
|
|
357
|
+
``on_frame`` sees every server frame, lifecycle included (see
|
|
358
|
+
:meth:`RealtimeSession.connect`)."""
|
|
359
|
+
created = await self.create_session(**options)
|
|
360
|
+
return await RealtimeSession.connect(
|
|
361
|
+
created.session_id,
|
|
362
|
+
created.stream_url,
|
|
363
|
+
ready_deadline_s=ready_deadline_s or self._ready_deadline_s,
|
|
364
|
+
on_frame=on_frame,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
async def capabilities(self) -> Capabilities:
|
|
368
|
+
"""Languages, audio formats and limits."""
|
|
369
|
+
return Capabilities.model_validate(await self._t.json("GET", "/v1/realtime/capabilities"))
|
|
370
|
+
|
|
371
|
+
async def transcribe_file(
|
|
372
|
+
self,
|
|
373
|
+
source: Any,
|
|
374
|
+
*,
|
|
375
|
+
on_progress: Optional[Callable[[Any], None]] = None,
|
|
376
|
+
on_commit: Optional[Callable[[Any], None]] = None,
|
|
377
|
+
**options: Any,
|
|
378
|
+
) -> RealtimeTranscript:
|
|
379
|
+
"""Stream an encoded file (mp3, wav, flac, ogg, m4a...; a path or
|
|
380
|
+
bytes) through a realtime session and collect the transcript.
|
|
381
|
+
Nothing is stored; for a stored, shareable transcription use
|
|
382
|
+
``vs.transcriptions.create(file=...)``."""
|
|
383
|
+
session = await self.connect(input={"kind": "file"}, **options)
|
|
384
|
+
async with session:
|
|
385
|
+
await session.send_file(source)
|
|
386
|
+
return await _collect(session, on_progress, on_commit)
|
|
387
|
+
|
|
388
|
+
async def transcribe_url(
|
|
389
|
+
self,
|
|
390
|
+
url: str,
|
|
391
|
+
*,
|
|
392
|
+
on_progress: Optional[Callable[[Any], None]] = None,
|
|
393
|
+
on_commit: Optional[Callable[[Any], None]] = None,
|
|
394
|
+
**options: Any,
|
|
395
|
+
) -> RealtimeTranscript:
|
|
396
|
+
"""The same for a link the engine fetches itself."""
|
|
397
|
+
session = await self.connect(input={"kind": "url", "url": url}, **options)
|
|
398
|
+
async with session:
|
|
399
|
+
return await _collect(session, on_progress, on_commit)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
async def _collect(session: RealtimeSession, on_progress: Any, on_commit: Any) -> RealtimeTranscript:
|
|
403
|
+
segments: List[Any] = []
|
|
404
|
+
async for event in session:
|
|
405
|
+
if event.type == "transcript.committed":
|
|
406
|
+
segments.append(event.segment)
|
|
407
|
+
if on_commit is not None:
|
|
408
|
+
on_commit(event.segment)
|
|
409
|
+
elif event.type == "transcript.progress" and on_progress is not None:
|
|
410
|
+
on_progress(event)
|
|
411
|
+
return RealtimeTranscript(segments, session.done_reason, session.stats)
|