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/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)