From b6937818f38b8a2c9db214f4b287c444576aac5d Mon Sep 17 00:00:00 2001 From: Carter Campbell Date: Thu, 13 Aug 2026 19:36:28 -0600 Subject: [PATCH 1/2] feat: transcriber DX improvements and RealTimeTranscriber rename Adds a "Choosing a transcriber" decision table to the README, and a class docstring plus failure-contract docs (a failed transcription returns a Transcript with status error, not an exception) on the job-API methods. Guards the config type on all four transcribers, so passing a SyncTranscriptionConfig to Transcriber (or the reverse) raises TypeError naming both directions instead of failing obscurely downstream. Unifies audio input handling on the sync Transcriber upload path (os.PathLike, bytearray, file objects, TypeError for anything else) to match the async twin, and adds an optional poll_timeout to transcribe, transcribe_async, and AsyncTranscriber.transcribe. Parses a bare {"error": ...} body in the sync API, exposes aai.streaming via a lazy module __getattr__ so importing assemblyai no longer depends on websockets, and renames the streaming surface to RealTime* with every former Streaming* name kept as an alias of the same object. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 62 +-- README.md | 84 +-- assemblyai/__init__.py | 21 + assemblyai/__version__.py | 2 +- assemblyai/prerecorded/v2/_base.py | 54 +- assemblyai/prerecorded/v2/async_client.py | 29 +- assemblyai/prerecorded/v2/async_transcript.py | 26 +- assemblyai/prerecorded/v2/client.py | 106 +++- assemblyai/prerecorded/v2/transcript.py | 44 +- assemblyai/streaming/v3/__init__.py | 36 +- assemblyai/streaming/v3/_base.py | 82 +-- assemblyai/streaming/v3/async_client.py | 82 +-- assemblyai/streaming/v3/client.py | 42 +- assemblyai/streaming/v3/extras.py | 38 +- assemblyai/streaming/v3/models.py | 37 +- assemblyai/sync/v1/_base.py | 19 + assemblyai/sync/v1/api.py | 7 +- assemblyai/sync/v1/async_client.py | 13 +- assemblyai/sync/v1/client.py | 20 +- assemblyai/types.py | 7 + tests/unit/test_dx.py | 499 +++++++++++++++++- tox.ini | 6 + 22 files changed, 1076 insertions(+), 240 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d4cc595..515c786 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,8 +48,8 @@ aai.settings.api_key = "your-key" - `aai.AsyncSyncTranscriber` — Asyncio counterpart of `SyncTranscriber`. Same input types, config, result, and errors; `transcribe()` and `warm()` are coroutines. Owns an HTTP pool: use `async with` or `await aclose()`, or pass an `aai.AsyncClient` to share one - `aai.SyncTranscriptionConfig` — Sync options: `model` (default `universal-3-5-pro`), `prompt`, `keyterms_prompt`, `conversation_context`, `language_codes`, `timestamps`, `sample_rate`, `channels` - `aai.SyncTranscriptResponse` — Sync result: `.text`, `.words` (`SyncWord` with `confidence` always, `start`/`end` only when `timestamps=True`), `.confidence`, `.audio_duration_ms`, `.session_id`, `.request_time_ms` -- `assemblyai.streaming.v3.StreamingClient` — Real-time streaming with event-based API (threaded) -- `assemblyai.streaming.v3.AsyncStreamingClient` — Asyncio-native counterpart; same options/events +- `assemblyai.streaming.v3.RealTimeTranscriber` — Real-time streaming with event-based API (threaded) +- `assemblyai.streaming.v3.AsyncRealTimeTranscriber` — Asyncio-native counterpart; same options/events ## Common patterns @@ -280,7 +280,9 @@ open connection. Note `keepalive_expiry` applies to the whole client (sync and a ## Streaming (real-time) -Use `universal-3-5-pro` as the streaming model — it's the flagship and what every example below targets. Two clients with identical option/event/handler surfaces: `StreamingClient` (threaded) and `AsyncStreamingClient` (asyncio). +Use `universal-3-5-pro` as the streaming model — it's the flagship and what every example below targets. Two clients with identical option/event/handler surfaces: `RealTimeTranscriber` (threaded) and `AsyncRealTimeTranscriber` (asyncio). + +The former `Streaming*` names (`StreamingClient`, `AsyncStreamingClient`, `StreamingClientOptions`, `StreamingParameters`, `StreamingSessionParameters`, `StreamingEvents`, `StreamingError`, `StreamingErrorCodes`) are aliases bound to the same objects, so existing code keeps working; prefer the `RealTime*` names in new code. **Handler contract — important**: every handler is called as `handler(client, event)`. Two positional args. Plain functions and `async def` functions both work for the async client; async handlers are awaited inline on the read task, so don't block — use `asyncio.create_task(...)` if you need to fan out work. Exceptions inside handlers are logged and swallowed. @@ -290,15 +292,15 @@ Use `universal-3-5-pro` as the streaming model — it's the flagship and what ev - `Termination` → `TerminationEvent(audio_duration_seconds: int | None, session_duration_seconds: int | None)` - `SpeechStarted` → `SpeechStartedEvent(timestamp: int)` (ms) - `Warning` → `WarningEvent(warning_code: int, warning: str)` -- `Error` → `StreamingError` (an `Exception` subclass) with `.code: int | None`; `str(error)` is the message. Server-side errors come through `on_error` rather than being raised, and the payload is a `StreamingError`, **not** the wire `ErrorEvent` class. +- `Error` → `RealTimeError` (an `Exception` subclass) with `.code: int | None`; `str(error)` is the message. Server-side errors come through `on_error` rather than being raised, and the payload is a `RealTimeError`, **not** the wire `ErrorEvent` class. - `LLMGatewayResponse` → `LLMGatewayResponseEvent(turn_order: int, transcript: str, data: Any)` - `SpeakerRevision` → `SpeakerRevisionEvent(revisions: list[SpeakerRevisionItem])` — diarization-only. Sent once per offline-recluster resolve. Each `SpeakerRevisionItem(turn_order: int, speaker_label: str | None, words: list[Word])` is an earlier Turn whose labels changed (unchanged turns are omitted). For each item, match by `turn_order` against the original Turn and replace its per-word `speaker` (and the turn-level `speaker_label`) with the revision's values. Text and word timestamps are unchanged. **Sync streaming:** ```python from assemblyai.streaming.v3 import ( - StreamingClient, StreamingClientOptions, StreamingEvents, - StreamingParameters, TurnEvent, + RealTimeTranscriber, RealTimeTranscriberOptions, RealTimeEvents, + RealTimeParameters, TurnEvent, ) def on_turn(client, event: TurnEvent): @@ -307,10 +309,10 @@ def on_turn(client, event: TurnEvent): def on_error(client, error): print(f"Error {error.code}: {error}") -client = StreamingClient(StreamingClientOptions(api_key=os.environ["ASSEMBLYAI_API_KEY"])) -client.on(StreamingEvents.Turn, on_turn) -client.on(StreamingEvents.Error, on_error) -client.connect(StreamingParameters( +client = RealTimeTranscriber(RealTimeTranscriberOptions(api_key=os.environ["ASSEMBLYAI_API_KEY"])) +client.on(RealTimeEvents.Turn, on_turn) +client.on(RealTimeEvents.Error, on_error) +client.connect(RealTimeParameters( sample_rate=16000, speech_model="universal-3-5-pro", )) try: @@ -323,19 +325,19 @@ finally: ```python import asyncio from assemblyai.streaming.v3 import ( - AsyncStreamingClient, StreamingClientOptions, StreamingEvents, - StreamingParameters, + AsyncRealTimeTranscriber, RealTimeTranscriberOptions, RealTimeEvents, + RealTimeParameters, ) async def on_turn(client, event): print(event.transcript) async def main(): - async with AsyncStreamingClient( - StreamingClientOptions(api_key=os.environ["ASSEMBLYAI_API_KEY"]) + async with AsyncRealTimeTranscriber( + RealTimeTranscriberOptions(api_key=os.environ["ASSEMBLYAI_API_KEY"]) ) as client: - client.on(StreamingEvents.Turn, on_turn) - await client.connect(StreamingParameters( + client.on(RealTimeEvents.Turn, on_turn) + await client.connect(RealTimeParameters( sample_rate=16000, speech_model="universal-3-5-pro", )) await client.stream(audio_async_generator) # async iterable of bytes @@ -347,7 +349,7 @@ asyncio.run(main()) **Voice-agent tuning** (knobs that matter most when building a voice agent): ```python -StreamingParameters( +RealTimeParameters( sample_rate=8000, speech_model="universal-3-5-pro", encoding="pcm_mulaw", # telephony prompt="Transcribe verbatim with filler words and full punctuation.", agent_context="What is your account number?", # agent's last reply; biases U3Pro context (U3Pro only) @@ -360,19 +362,19 @@ StreamingParameters( Use `pcm_s16le` (default) + `sample_rate=16000` for microphone capture. `agent_context` (like `prompt`) can also be updated mid-stream after each agent -turn: `client.set_params(StreamingSessionParameters(agent_context="..."))`. +turn: `client.set_params(RealTimeSessionParameters(agent_context="..."))`. -**Error codes**: `StreamingErrorCodes` is a `dict[int, str]` mapping wire codes to human messages. Use `.get(...)` for lookup — not enum-style attribute access: +**Error codes**: `RealTimeErrorCodes` is a `dict[int, str]` mapping wire codes to human messages. Use `.get(...)` for lookup — not enum-style attribute access: ```python -from assemblyai.streaming.v3 import StreamingErrorCodes +from assemblyai.streaming.v3 import RealTimeErrorCodes def on_error(client, error): - message = StreamingErrorCodes.get(error.code, str(error)) + message = RealTimeErrorCodes.get(error.code, str(error)) print(f"Streaming error {error.code}: {message}") ``` Common codes worth branching on: `4001` Not Authorized, `4002` Insufficient Funds, `4029` Client sent audio too fast, `4031` Session idle for too long. -**Events enum**: `StreamingEvents.{Begin, Turn, Termination, SpeechStarted, Error, Warning, LLMGatewayResponse, SpeakerRevision}`. Register a handler for each you care about; the call is the same shape: `client.on(StreamingEvents.Begin, on_begin)`, `client.on(StreamingEvents.Error, on_error)`, etc. +**Events enum**: `RealTimeEvents.{Begin, Turn, Termination, SpeechStarted, Error, Warning, LLMGatewayResponse, SpeakerRevision}`. Register a handler for each you care about; the call is the same shape: `client.on(RealTimeEvents.Begin, on_begin)`, `client.on(RealTimeEvents.Error, on_error)`, etc. **Slow async handlers — fan-out pattern**: async handlers are awaited inline on the read task. If `on_turn` calls a slow LLM/TTS, ingestion stalls. Fan out and drain on shutdown: ```python @@ -391,22 +393,22 @@ if pending: **Mint a temporary token without streaming** (typical FastAPI/server use): ```python -async with AsyncStreamingClient(StreamingClientOptions(api_key=MASTER_KEY)) as client: +async with AsyncRealTimeTranscriber(RealTimeTranscriberOptions(api_key=MASTER_KEY)) as client: return await client.create_temporary_token(expires_in_seconds=60) ``` -Use `async with` even when you never call `connect()` — `create_temporary_token` lazily opens an `httpx.AsyncClient` and `__aexit__` closes it. Without the context manager you leak the HTTP pool per request. The sync `StreamingClient.create_temporary_token` doesn't need this (no pool to close). +Use `async with` even when you never call `connect()` — `create_temporary_token` lazily opens an `httpx.AsyncClient` and `__aexit__` closes it. Without the context manager you leak the HTTP pool per request. The sync `RealTimeTranscriber.create_temporary_token` doesn't need this (no pool to close). -**Pass the token to the streaming client** via `StreamingClientOptions(token=...)` — same surface for `StreamingClient` and `AsyncStreamingClient`: +**Pass the token to the streaming client** via `RealTimeTranscriberOptions(token=...)` — same surface for `RealTimeTranscriber` and `AsyncRealTimeTranscriber`: ```python -async with AsyncStreamingClient(StreamingClientOptions(token=token_from_server)) as client: - await client.connect(StreamingParameters(sample_rate=16000, speech_model="universal-3-5-pro")) +async with AsyncRealTimeTranscriber(RealTimeTranscriberOptions(token=token_from_server)) as client: + await client.connect(RealTimeParameters(sample_rate=16000, speech_model="universal-3-5-pro")) await client.stream(audio) ``` **Other gotchas**: -- Don't feed `AsyncStreamingClient.stream()` from a sync generator that blocks (e.g. `time.sleep` pacing) — it starves the read task. Use an `async def` generator with `await asyncio.sleep(...)` instead. -- `format_turns=True` enables punctuation/casing on confirmed end-of-turns. Toggle mid-session via `client.set_params(StreamingSessionParameters(format_turns=True))`. -- `AsyncStreamingClient` used as `async with` calls `disconnect(terminate=True)` on normal block exit and `disconnect(terminate=False)` on exception — no explicit `disconnect()` needed inside the block. +- Don't feed `AsyncRealTimeTranscriber.stream()` from a sync generator that blocks (e.g. `time.sleep` pacing) — it starves the read task. Use an `async def` generator with `await asyncio.sleep(...)` instead. +- `format_turns=True` enables punctuation/casing on confirmed end-of-turns. Toggle mid-session via `client.set_params(RealTimeSessionParameters(format_turns=True))`. +- `AsyncRealTimeTranscriber` used as `async with` calls `disconnect(terminate=True)` on normal block exit and `disconnect(terminate=False)` on exception — no explicit `disconnect()` needed inside the block. ## Important gotchas diff --git a/README.md b/README.md index 30da654..9dbcb1d 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ See [Coding agent prompts](https://www.assemblyai.com/docs/coding-agent-prompts) - [Quick Start](#quick-start) - [Installation](#installation) - [Examples](#examples) + - [Choosing a transcriber](#choosing-a-transcriber) - [**Core Examples**](#core-examples) - [**Asyncio Examples**](#asyncio-examples) - [**Speech Understanding Examples**](#speech-understanding-examples) @@ -92,6 +93,19 @@ aai.settings.api_key = f"{ASSEMBLYAI_API_KEY}" --- +### Choosing a transcriber + +| Class | Use it for | +| --- | --- | +| `aai.Transcriber` | Long-form audio, URLs, and the audio-intelligence features (speaker labels, chapters, sentiment, …), over the polled job API | +| `aai.AsyncTranscriber` | The same, from asyncio code | +| `aai.SyncTranscriber` | Short clips (≤120s, ≤40MB) where you want the transcript back in one request, at the lowest latency | +| `aai.AsyncSyncTranscriber` | The same, from asyncio code | +| `assemblyai.streaming.v3.RealTimeTranscriber` | Live audio (microphone, telephony, voice agents), transcribed as it arrives over a websocket session | +| `assemblyai.streaming.v3.AsyncRealTimeTranscriber` | The same, from asyncio code | + +--- + ### **Core Examples**
@@ -1007,9 +1021,11 @@ for result in transcript.auto_highlights.results: ### **Streaming Examples** -Real-time speech-to-text via WebSocket against the `universal-3-5-pro` model. The SDK ships two clients with identical option/event/handler surfaces — `StreamingClient` (threaded) and `AsyncStreamingClient` (asyncio). Pick whichever fits your codebase. +Real-time speech-to-text via WebSocket against the `universal-3-5-pro` model. The SDK ships two clients with identical option/event/handler surfaces — `RealTimeTranscriber` (threaded) and `AsyncRealTimeTranscriber` (asyncio). Pick whichever fits your codebase. + +> The former `Streaming*` names (`StreamingClient`, `AsyncStreamingClient`, `StreamingClientOptions`, `StreamingParameters`, `StreamingSessionParameters`, `StreamingEvents`, `StreamingError`, `StreamingErrorCodes`) remain available as aliases of the `RealTime*` names — same objects, so existing code keeps working unchanged. -**Handler contract**: every handler is called as `handler(client, event)`. Plain functions and `async def` functions both work; `AsyncStreamingClient` awaits async handlers inline on the read task, so don't block — use `asyncio.create_task(...)` if you need concurrent work. +**Handler contract**: every handler is called as `handler(client, event)`. Plain functions and `async def` functions both work; `AsyncRealTimeTranscriber` awaits async handlers inline on the read task, so don't block — use `asyncio.create_task(...)` if you need concurrent work. [Read more about the streaming service.](https://www.assemblyai.com/docs/streaming/getting-started/transcribe-streaming-audio) @@ -1020,8 +1036,8 @@ Real-time speech-to-text via WebSocket against the `universal-3-5-pro` model. Th import time from assemblyai.streaming.v3 import ( - BeginEvent, StreamingClient, StreamingClientOptions, StreamingError, - StreamingEvents, StreamingParameters, TerminationEvent, TurnEvent, + BeginEvent, RealTimeTranscriber, RealTimeTranscriberOptions, RealTimeError, + RealTimeEvents, RealTimeParameters, TerminationEvent, TurnEvent, ) def stream_file(path: str, sample_rate: int, chunk_duration: float = 0.3): @@ -1040,16 +1056,16 @@ def on_turn(client, event: TurnEvent): def on_terminated(client, event: TerminationEvent): print(f"Done: {event.audio_duration_seconds}s of audio processed") -def on_error(client, error: StreamingError): +def on_error(client, error: RealTimeError): print(f"Error: {error} (code={error.code})") -client = StreamingClient(StreamingClientOptions(api_key="")) -client.on(StreamingEvents.Begin, on_begin) -client.on(StreamingEvents.Turn, on_turn) -client.on(StreamingEvents.Termination, on_terminated) -client.on(StreamingEvents.Error, on_error) +client = RealTimeTranscriber(RealTimeTranscriberOptions(api_key="")) +client.on(RealTimeEvents.Begin, on_begin) +client.on(RealTimeEvents.Turn, on_turn) +client.on(RealTimeEvents.Termination, on_terminated) +client.on(RealTimeEvents.Error, on_error) -client.connect(StreamingParameters( +client.connect(RealTimeParameters( sample_rate=16000, speech_model="universal-3-5-pro", )) try: @@ -1071,8 +1087,8 @@ Unlike a browser sample, the SDK does not capture audio — you supply 16-bit PC ```python from assemblyai.streaming.v3 import ( - ChannelStreamer, StreamingClient, StreamingClientOptions, - StreamingEvents, StreamingParameters, + ChannelStreamer, RealTimeTranscriber, RealTimeTranscriberOptions, + RealTimeEvents, RealTimeParameters, ) def on_turn(client, event): # event is a DualChannelTurnEvent @@ -1080,14 +1096,14 @@ def on_turn(client, event): # event is a DualChannelTurnEvent for w in event.words: print(f" {w.text!r} -> channel={w.channel} speaker={w.speaker}") -client = StreamingClient(StreamingClientOptions(api_key="")) +client = RealTimeTranscriber(RealTimeTranscriberOptions(api_key="")) # Declare the channels and the session sample rate (must be pcm_s16le). mixer = ChannelStreamer(client, channels=["mic", "system"], sample_rate=16000) # Register handlers on the mixer: Turn handlers receive the enriched event, # other events (Begin/Error/…) are forwarded to the client. -mixer.on(StreamingEvents.Turn, on_turn) -client.connect(StreamingParameters( +mixer.on(RealTimeEvents.Turn, on_turn) +client.connect(RealTimeParameters( sample_rate=16000, speech_model="universal-3-5-pro", speaker_labels=True, )) @@ -1127,12 +1143,12 @@ See [`examples/streaming_dual_channel.py`](./examples/streaming_dual_channel.py)
Stream a local file (async) -`AsyncStreamingClient` mirrors `StreamingClient` with async methods. It's safe to use as an async context manager — `disconnect()` runs on block exit even if user code raises. Pace the audio from an async generator so the event loop is never blocked. +`AsyncRealTimeTranscriber` mirrors `RealTimeTranscriber` with async methods. It's safe to use as an async context manager — `disconnect()` runs on block exit even if user code raises. Pace the audio from an async generator so the event loop is never blocked. ```python import asyncio from assemblyai.streaming.v3 import ( - AsyncStreamingClient, StreamingClientOptions, StreamingEvents, StreamingParameters, + AsyncRealTimeTranscriber, RealTimeTranscriberOptions, RealTimeEvents, RealTimeParameters, ) async def stream_file_async(path: str, sample_rate: int, chunk_duration: float = 0.3): @@ -1146,9 +1162,9 @@ async def on_turn(client, event): print(f"{event.transcript} (end_of_turn={event.end_of_turn})") async def main(): - async with AsyncStreamingClient(StreamingClientOptions(api_key="")) as client: - client.on(StreamingEvents.Turn, on_turn) - await client.connect(StreamingParameters( + async with AsyncRealTimeTranscriber(RealTimeTranscriberOptions(api_key="")) as client: + client.on(RealTimeEvents.Turn, on_turn) + await client.connect(RealTimeParameters( sample_rate=16000, speech_model="universal-3-5-pro", )) await client.stream(stream_file_async("audio.wav", 16000)) @@ -1161,15 +1177,15 @@ asyncio.run(main())
Handle errors -Server-side errors arrive on the `Error` event rather than being raised. The handler receives a `StreamingError` (an `Exception` subclass) with `.code: int | None` — **not** the wire `ErrorEvent` class. +Server-side errors arrive on the `Error` event rather than being raised. The handler receives a `RealTimeError` (an `Exception` subclass) with `.code: int | None` — **not** the wire `ErrorEvent` class. -`StreamingErrorCodes` is a `dict[int, str]` mapping wire codes to human-readable messages. Use `.get(...)` for lookup: +`RealTimeErrorCodes` is a `dict[int, str]` mapping wire codes to human-readable messages. Use `.get(...)` for lookup: ```python -from assemblyai.streaming.v3 import StreamingErrorCodes +from assemblyai.streaming.v3 import RealTimeErrorCodes def on_error(client, error): - message = StreamingErrorCodes.get(error.code, str(error)) + message = RealTimeErrorCodes.get(error.code, str(error)) print(f"Streaming error {error.code}: {message}") ``` @@ -1183,11 +1199,11 @@ Common codes: `4001` Not Authorized, `4002` Insufficient Funds, `4029` Client se `set_params` updates an active session. Typical use: enable turn formatting (punctuation, casing) only on confirmed end-of-turn so partial transcripts stay raw: ```python -from assemblyai.streaming.v3 import StreamingSessionParameters +from assemblyai.streaming.v3 import RealTimeSessionParameters def on_turn(client, event): if event.end_of_turn and not event.turn_is_formatted: - client.set_params(StreamingSessionParameters(format_turns=True)) + client.set_params(RealTimeSessionParameters(format_turns=True)) ``` For voice agents, `force_endpoint()` flushes the current turn — useful when an external signal (UI button, barge-in detection) determines the user has stopped speaking before VAD does: @@ -1205,7 +1221,7 @@ Don't ship your API key to browsers. Mint a short-lived token server-side and pa **Sync server (Flask / WSGI / scripts):** ```python -client = StreamingClient(StreamingClientOptions(api_key="")) +client = RealTimeTranscriber(RealTimeTranscriberOptions(api_key="")) token = client.create_temporary_token(expires_in_seconds=60) # Send `token` to the browser, which connects with options(token=token). ``` @@ -1214,22 +1230,22 @@ token = client.create_temporary_token(expires_in_seconds=60) ```python from fastapi import FastAPI -from assemblyai.streaming.v3 import AsyncStreamingClient, StreamingClientOptions +from assemblyai.streaming.v3 import AsyncRealTimeTranscriber, RealTimeTranscriberOptions app = FastAPI() MASTER_KEY = "" @app.get("/streaming-token") async def streaming_token(): - async with AsyncStreamingClient(StreamingClientOptions(api_key=MASTER_KEY)) as client: + async with AsyncRealTimeTranscriber(RealTimeTranscriberOptions(api_key=MASTER_KEY)) as client: return {"token": await client.create_temporary_token(expires_in_seconds=60)} ``` -**Browser / edge client:** pass the token via `StreamingClientOptions(token=...)`: +**Browser / edge client:** pass the token via `RealTimeTranscriberOptions(token=...)`: ```python -client = StreamingClient(StreamingClientOptions(token="")) -client.connect(StreamingParameters(sample_rate=16000, speech_model="universal-3-5-pro")) +client = RealTimeTranscriber(RealTimeTranscriberOptions(token="")) +client.connect(RealTimeParameters(sample_rate=16000, speech_model="universal-3-5-pro")) ```
@@ -1359,7 +1375,7 @@ Notes: - Neither group method drops a failure. Either the first error is raised, or you pass `return_failures=True` and get `(transcripts, errors)`. -For real-time streaming, use `assemblyai.streaming.v3.AsyncStreamingClient`. +For real-time streaming, use `assemblyai.streaming.v3.AsyncRealTimeTranscriber`. ## Getting the HTTP status code diff --git a/assemblyai/__init__.py b/assemblyai/__init__.py index 87d1788..982f0ce 100644 --- a/assemblyai/__init__.py +++ b/assemblyai/__init__.py @@ -73,6 +73,25 @@ """Global settings object that applies to all classes that use the `Client` class.""" +def __getattr__(name: str): + """ + Resolves `assemblyai.streaming` on first access. + + Streaming pulls in `websockets`, so the subpackage is imported when it is + asked for rather than at `import assemblyai` time. The import binds + `streaming` as a real attribute, so this runs only once. + """ + + if name == "streaming": + import importlib + + importlib.import_module(".streaming.v3", __name__) + + return importlib.import_module(".streaming", __name__) + + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + __all__ = [ # types "AssemblyAIError", @@ -146,6 +165,8 @@ "Word", "WordBoost", "WordSearchMatch", + # subpackages + "streaming", # package globals "settings", # version diff --git a/assemblyai/__version__.py b/assemblyai/__version__.py index fa72dac..5becc17 100644 --- a/assemblyai/__version__.py +++ b/assemblyai/__version__.py @@ -1 +1 @@ -__version__ = "0.68.00" +__version__ = "1.0.0" diff --git a/assemblyai/prerecorded/v2/_base.py b/assemblyai/prerecorded/v2/_base.py index b545615..24d8d74 100644 --- a/assemblyai/prerecorded/v2/_base.py +++ b/assemblyai/prerecorded/v2/_base.py @@ -13,7 +13,7 @@ methods are not ``@abstractmethod`` here. """ -from typing import Dict, List, Optional, Type, Union +from typing import ClassVar, Dict, List, Optional, Type, Union from urllib.parse import urlparse import httpx @@ -56,6 +56,45 @@ def is_url(data: str) -> bool: return urlparse(data).scheme in {"http", "https"} +def _poll_timeout_message( + *, + transcript_id: str, + status: types.TranscriptStatus, + poll_timeout: float, +) -> str: + """ + Builds the message for a poll that ran out of time. + + Names the transcript and its last-seen status, so the caller can pick the + transcript back up by id. + """ + + return ( + f"transcript {transcript_id} did not finish within {poll_timeout} seconds; " + f"its last status was {types.TranscriptStatus(status).value}. It keeps " + f'processing, so fetch it later by id, e.g. get_by_id("{transcript_id}").' + ) + + +def check_config(owner: str, config: Optional[types.TranscriptionConfig]) -> None: + """ + Raises unless `config` is a `TranscriptionConfig` or `None`. + + The sync API's `SyncTranscriptionConfig` is a different, non-interchangeable + type, so passing it here is a mistake worth naming. + + Args: + `owner`: the class to name in the message, e.g. `Transcriber`. + `config`: the configuration to check. + """ + + if config is not None and not isinstance(config, types.TranscriptionConfig): + raise TypeError( + f"{owner} expects TranscriptionConfig, got {type(config).__name__}. " + "Use aai.TranscriptionConfig." + ) + + def config_from_response( response: types.TranscriptResponse, ) -> types.TranscriptionConfig: @@ -304,10 +343,21 @@ class _BaseTranscriber: config: types.TranscriptionConfig + # The class named when a config of the wrong type is rejected. The + # implementation classes are internal, so they name their public wrapper. + _config_owner: ClassVar[str] = "Transcriber" + def _resolve_config( self, config: Optional[types.TranscriptionConfig], ) -> types.TranscriptionConfig: - """Returns the per-call config, or the transcriber's default.""" + """ + Returns the per-call config, or the transcriber's default. + + Raises: + TypeError: if `config` is not a `TranscriptionConfig`. + """ + + check_config(self._config_owner, config) return config if config is not None else self.config diff --git a/assemblyai/prerecorded/v2/async_client.py b/assemblyai/prerecorded/v2/async_client.py index 6f34f6a..975468f 100644 --- a/assemblyai/prerecorded/v2/async_client.py +++ b/assemblyai/prerecorded/v2/async_client.py @@ -24,10 +24,10 @@ from ... import async_client as _async_client from ... import types from . import async_api -from ._base import _BaseTranscriber, is_url +from ._base import _BaseTranscriber, check_config, is_url from .async_transcript import AsyncTranscript, _open_binary, _run_in_thread -AudioSource = Union[str, bytes, "os.PathLike[str]", BinaryIO] +AudioSource = Union[str, bytes, bytearray, "os.PathLike[str]", BinaryIO] """An audio URL, a local file path, raw `bytes`, or an opened binary file.""" # Read this much per thread hop when streaming a file off disk into an upload. @@ -165,6 +165,8 @@ async def main(): ``` """ + _config_owner = "AsyncTranscriber" + def __init__( self, *, @@ -188,7 +190,12 @@ def __init__( transcriber builds and owns a client made from a copy of that client's settings with the key replaced, and the given client is left untouched and stays the caller's to close. + + Raises: + TypeError: if `config` is not a `TranscriptionConfig`. """ + check_config(type(self).__name__, config) + self._owns_client = client is None or api_key is not None self._client = _async_client._resolve_client(client, api_key) self.config = config or types.TranscriptionConfig() @@ -262,6 +269,8 @@ async def transcribe( self, data: AudioSource, config: Optional[types.TranscriptionConfig] = None, + *, + poll_timeout: Optional[float] = None, ) -> AsyncTranscript: """ Transcribes an audio file and waits for the result. Accepts a local @@ -271,15 +280,25 @@ async def transcribe( data: An URL, a local file (as path), raw `bytes`, or a binary object. config: Transcription options and features. If `None` is given, the transcriber's default configuration will be used. + poll_timeout: How long to poll for the result, in seconds. If `None` + is given, polling continues until the transcript reaches a + terminal status. Returns: The completed `AsyncTranscript`. Check its `status`. A - server-side failure returns `TranscriptStatus.error` and does not - raise. + transcription the server failed to complete comes back with + `TranscriptStatus.error` and the reason in `error`, without raising; + `text` and `words` are `None` then. + + Raises: + TypeError: if `config` is not a `TranscriptionConfig`. + TranscriptError: if `poll_timeout` elapses before the transcript + reaches a terminal status. The transcript keeps processing + server-side; fetch it later with `get_by_id`. """ transcript = await self.submit(data=data, config=config) - return await transcript.wait_for_completion() + return await transcript.wait_for_completion(poll_timeout=poll_timeout) async def submit_group( self, diff --git a/assemblyai/prerecorded/v2/async_transcript.py b/assemblyai/prerecorded/v2/async_transcript.py index 587dc5a..f9a1401 100644 --- a/assemblyai/prerecorded/v2/async_transcript.py +++ b/assemblyai/prerecorded/v2/async_transcript.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import time from typing import Any, BinaryIO, Callable, List, Optional, TypeVar, cast import httpx @@ -11,7 +12,7 @@ from ... import async_client as _async_client from ... import types from . import async_api -from ._base import TERMINAL_STATUSES, _BaseTranscript +from ._base import TERMINAL_STATUSES, _BaseTranscript, _poll_timeout_message _T = TypeVar("_T") @@ -80,17 +81,29 @@ def id(self) -> Optional[str]: return self._transcript_id - async def wait_for_completion(self) -> Self: + async def wait_for_completion( + self, + *, + poll_timeout: Optional[float] = None, + ) -> Self: """ Polls the transcript until its status is `completed` or `error`. Sleeps `settings.polling_interval` seconds between polls. Other tasks run during the sleep. + Args: + poll_timeout: How long to poll, in seconds. `None` polls until the + transcript reaches a terminal status. + Returns: this `AsyncTranscript`, with the finished response. + + Raises: + TranscriptError: if `poll_timeout` elapses first. """ transcript_id = self._require_id("wait for completion") + start = time.monotonic() while True: # No try-except - if there is an HTTP error then surface it to user @@ -102,6 +115,15 @@ async def wait_for_completion(self) -> Self: if self._transcript.status in TERMINAL_STATUSES: return self + if poll_timeout is not None and time.monotonic() - start >= poll_timeout: + raise types.TranscriptError( + _poll_timeout_message( + transcript_id=transcript_id, + status=self._transcript.status, + poll_timeout=poll_timeout, + ) + ) + await asyncio.sleep(self._client.settings.polling_interval) async def export_subtitles_srt( diff --git a/assemblyai/prerecorded/v2/client.py b/assemblyai/prerecorded/v2/client.py index 071d88f..7a86cfb 100644 --- a/assemblyai/prerecorded/v2/client.py +++ b/assemblyai/prerecorded/v2/client.py @@ -10,10 +10,13 @@ from ... import client as _client from ... import types from . import api -from ._base import _BaseTranscriber, is_url +from ._base import _BaseTranscriber, check_config, is_url from .transcript import Transcript from .transcript_group import TranscriptGroup +AudioSource = Union[str, bytes, bytearray, "os.PathLike[str]", BinaryIO] +"""An audio URL, a local file path, raw `bytes`, or an opened binary file.""" + class _TranscriberImpl(_BaseTranscriber): """ @@ -29,25 +32,35 @@ def __init__( self._client = client self.config = config - def upload_file(self, data: Union[str, bytes, BinaryIO]) -> str: - if isinstance(data, str): - with open(data, "rb") as audio_file: + def upload_file(self, data: AudioSource) -> str: + if isinstance(data, (str, os.PathLike)): + with open(os.fspath(data), "rb") as audio_file: return _root_api.upload_file( client=self._client.http_client, audio_file=audio_file, ) - else: + + if isinstance(data, (bytes, bytearray)): + return _root_api.upload_file( + client=self._client.http_client, + audio_file=bytes(data), + ) + + if hasattr(data, "read"): return _root_api.upload_file( client=self._client.http_client, audio_file=data, ) + raise TypeError(f"unsupported audio input type: {type(data).__name__}") + def transcribe_url( self, *, url: str, config: types.TranscriptionConfig, poll: bool, + poll_timeout: Optional[float] = None, ) -> Transcript: transcript_request = types.TranscriptRequest( audio_url=url, @@ -63,16 +76,17 @@ def transcribe_url( ) if poll: - return transcript.wait_for_completion() + return transcript.wait_for_completion(poll_timeout=poll_timeout) return transcript def transcribe_file( self, *, - data: Union[str, bytes, BinaryIO], + data: AudioSource, config: types.TranscriptionConfig, poll: bool, + poll_timeout: Optional[float] = None, ) -> Transcript: # Note: If uploading fails, it should raise an Exception to the user, hence no try-except here. audio_url = self.upload_file(data) @@ -81,13 +95,15 @@ def transcribe_file( url=audio_url, config=config, poll=poll, + poll_timeout=poll_timeout, ) def transcribe( self, - data: Union[str, bytes, BinaryIO], + data: AudioSource, config: Optional[types.TranscriptionConfig], poll: bool, + poll_timeout: Optional[float] = None, ) -> Transcript: config = self._resolve_config(config) @@ -96,18 +112,20 @@ def transcribe( url=data, config=config, poll=poll, + poll_timeout=poll_timeout, ) return self.transcribe_file( data=data, config=config, poll=poll, + poll_timeout=poll_timeout, ) def transcribe_group( self, *, - data: List[Union[str, bytes, BinaryIO]], + data: List[AudioSource], config: Optional[types.TranscriptionConfig], poll: bool, return_failures: Optional[bool] = False, @@ -197,6 +215,9 @@ def __init__( settings with the key replaced, and the given client is left untouched. + Raises: + TypeError: if `config` is not a `TranscriptionConfig`. + Example: To use the `Transcriber` with the default settings, you can simply do: ``` @@ -210,6 +231,8 @@ def __init__( transcriber = aai.Transcriber(config=config) ``` """ + check_config(type(self).__name__, config) + self._client = _client._resolve_client(client, api_key) self._impl = _TranscriberImpl( @@ -242,28 +265,31 @@ def config(self, config: types.TranscriptionConfig) -> None: Args: `config`: The new default configuration. + + Raises: + TypeError: if `config` is not a `TranscriptionConfig`. """ + check_config(type(self).__name__, config) + self._impl.config = config - def upload_file(self, data: Union[str, bytes, BinaryIO]) -> str: + def upload_file(self, data: AudioSource) -> str: """ Uploads an audio file which can be specified as local path or binary object. Args: - `data`: A local file (as path), or a binary object. + `data`: A local file (as path), raw `bytes`, or a binary object. Returns: The URL of the uploaded audio file. """ return self._impl.upload_file(data=data) - def upload_file_async( - self, data: Union[str, bytes, BinaryIO] - ) -> concurrent.futures.Future[str]: + def upload_file_async(self, data: AudioSource) -> concurrent.futures.Future[str]: """ Uploads an audio file which can be specified as local path or binary object. Args: - `data`: A local file (as path), or a binary object. + `data`: A local file (as path), raw `bytes`, or a binary object. Returns: The URL of the uploaded audio file. """ @@ -274,7 +300,7 @@ def upload_file_async( def submit( self, - data: Union[str, bytes, BinaryIO], + data: AudioSource, config: Optional[types.TranscriptionConfig] = None, ) -> Transcript: """ @@ -293,7 +319,7 @@ def submit( def submit_group( self, - data: List[Union[str, bytes, BinaryIO]], + data: List[AudioSource], config: Optional[types.TranscriptionConfig] = None, return_failures: Optional[bool] = False, ) -> Union[TranscriptGroup, Tuple[TranscriptGroup, List[types.AssemblyAIError]]]: @@ -315,8 +341,10 @@ def submit_group( def transcribe( self, - data: Union[str, bytes, BinaryIO], + data: AudioSource, config: Optional[types.TranscriptionConfig] = None, + *, + poll_timeout: Optional[float] = None, ) -> Transcript: """ Transcribes an audio file which can be specified as local path, URL, raw `bytes`, or binary object. @@ -325,18 +353,34 @@ def transcribe( data: An URL, a local file (as path), raw `bytes`, or a binary object. config: Transcription options and features. If `None` is given, the Transcriber's default configuration will be used. + poll_timeout: How long to poll for the result, in seconds. If `None` is given, + polling continues until the transcript reaches a terminal status. + + Returns: The finished `Transcript`. A transcription the server failed to + complete comes back with `status` `TranscriptStatus.error` and the + reason in `error`; `text` and `words` are `None` then. Check `status` + before reading the result. + + Raises: + TypeError: if `config` is not a `TranscriptionConfig`. + TranscriptError: if `poll_timeout` elapses before the transcript + reaches a terminal status. The transcript keeps processing + server-side; fetch it later with `Transcript.get_by_id`. """ return self._impl.transcribe( data=data, config=config, poll=True, + poll_timeout=poll_timeout, ) def transcribe_async( self, - data: Union[str, bytes, BinaryIO], + data: AudioSource, config: Optional[types.TranscriptionConfig] = None, + *, + poll_timeout: Optional[float] = None, ) -> concurrent.futures.Future[Transcript]: """ Transcribes an audio file which can be specified as local path, URL, raw `bytes`, or binary object. @@ -345,18 +389,34 @@ def transcribe_async( data: An URL, a local file (as path), raw `bytes`, or a binary object. config: Transcription options and features. If `None` is given, the Transcriber's default configuration will be used. + poll_timeout: How long to poll for the result, in seconds. If `None` is given, + polling continues until the transcript reaches a terminal status. + + Returns: A future resolving to the finished `Transcript`. A transcription + the server failed to complete comes back with `status` + `TranscriptStatus.error` and the reason in `error`; `text` and `words` + are `None` then. Check `status` before reading the result. + + Raises: + TypeError: if `config` is not a `TranscriptionConfig`. The future + itself raises `TranscriptError` if `poll_timeout` elapses before + the transcript reaches a terminal status. The transcript keeps + processing server-side; fetch it later with + `Transcript.get_by_id`. """ + check_config(type(self).__name__, config) return self._executor.submit( self._impl.transcribe, data=data, config=config, poll=True, + poll_timeout=poll_timeout, ) def transcribe_group( self, - data: List[Union[str, bytes, BinaryIO]], + data: List[AudioSource], config: Optional[types.TranscriptionConfig] = None, return_failures: Optional[bool] = False, ) -> Union[TranscriptGroup, Tuple[TranscriptGroup, List[types.AssemblyAIError]]]: @@ -379,7 +439,7 @@ def transcribe_group( def transcribe_group_async( self, - data: List[Union[str, bytes, BinaryIO]], + data: List[AudioSource], config: Optional[types.TranscriptionConfig] = None, return_failures: Optional[bool] = False, ) -> concurrent.futures.Future[ @@ -393,7 +453,11 @@ def transcribe_group_async( config: Transcription options and features. If `None` is given, the Transcriber's default configuration will be used. return_failures: Whether to include a list of errors for transcriptions that failed due to HTTP errors + + Raises: + TypeError: if `config` is not a `TranscriptionConfig`. """ + check_config(type(self).__name__, config) return self._executor.submit( self._impl.transcribe_group, diff --git a/assemblyai/prerecorded/v2/transcript.py b/assemblyai/prerecorded/v2/transcript.py index a23b8a2..406942c 100644 --- a/assemblyai/prerecorded/v2/transcript.py +++ b/assemblyai/prerecorded/v2/transcript.py @@ -13,7 +13,12 @@ from ... import client as _client from ... import types from . import api -from ._base import TERMINAL_STATUSES, _BaseTranscript, config_from_response +from ._base import ( + TERMINAL_STATUSES, + _BaseTranscript, + _poll_timeout_message, + config_from_response, +) class _TranscriptImpl: @@ -53,15 +58,21 @@ def from_response( return self - def wait_for_completion(self) -> Self: + def wait_for_completion(self, *, poll_timeout: Optional[float] = None) -> Self: """ polls the given transcript until we have a status other than `processing` or `queued` + + Args: + `poll_timeout`: How long to poll, in seconds. `None` polls until the + transcript reaches a terminal status. """ if not self.transcript_id: raise ValueError( "Cannot wait for completion. The internal transcript ID is None." ) + start = time.monotonic() + while True: # No try-except - if there is an HTTP error then surface it to user self.transcript = api.get_transcript( @@ -72,6 +83,15 @@ def wait_for_completion(self) -> Self: if self.transcript.status in TERMINAL_STATUSES: break + if poll_timeout is not None and time.monotonic() - start >= poll_timeout: + raise types.TranscriptError( + _poll_timeout_message( + transcript_id=self.transcript_id, + status=self.transcript.status, + poll_timeout=poll_timeout, + ) + ) + time.sleep(self._client.settings.polling_interval) return self @@ -224,15 +244,29 @@ def __init__( ) self._executor = concurrent.futures.ThreadPoolExecutor() - def wait_for_completion(self) -> Self: - self._impl.wait_for_completion() + def wait_for_completion(self, *, poll_timeout: Optional[float] = None) -> Self: + """ + Polls the transcript until its status is `completed` or `error`. + + Args: + poll_timeout: How long to poll, in seconds. `None` polls until the + transcript reaches a terminal status. + + Raises: + TranscriptError: if `poll_timeout` elapses first. + """ + self._impl.wait_for_completion(poll_timeout=poll_timeout) return self def wait_for_completion_async( self, + *, + poll_timeout: Optional[float] = None, ) -> concurrent.futures.Future[Self]: - return self._executor.submit(self.wait_for_completion) + return self._executor.submit( + functools.partial(self.wait_for_completion, poll_timeout=poll_timeout) + ) @classmethod def from_response( diff --git a/assemblyai/streaming/v3/__init__.py b/assemblyai/streaming/v3/__init__.py index b2b814f..6e5325e 100644 --- a/assemblyai/streaming/v3/__init__.py +++ b/assemblyai/streaming/v3/__init__.py @@ -1,5 +1,5 @@ -from .async_client import AsyncStreamingClient -from .client import StreamingClient +from .async_client import AsyncRealTimeTranscriber, AsyncStreamingClient +from .client import RealTimeTranscriber, StreamingClient from .extras import ( AsyncChannelStreamer, ChannelAttributionOptions, @@ -19,6 +19,12 @@ HeartbeatEvent, LLMGatewayResponseEvent, NoiseSuppressionModel, + RealTimeError, + RealTimeErrorCodes, + RealTimeEvents, + RealTimeParameters, + RealTimeSessionParameters, + RealTimeTranscriberOptions, SessionConfiguration, SpeakerRevisionEvent, SpeakerRevisionItem, @@ -41,7 +47,7 @@ __all__ = [ "AsyncChannelStreamer", - "AsyncStreamingClient", + "AsyncRealTimeTranscriber", "BeginEvent", "ChannelAttributionOptions", "ChannelStreamer", @@ -53,21 +59,21 @@ "HeartbeatEvent", "LLMGatewayResponseEvent", "NoiseSuppressionModel", + "RealTimeError", + "RealTimeErrorCodes", + "RealTimeEvents", + "RealTimeParameters", + "RealTimeSessionParameters", + "RealTimeTranscriber", + "RealTimeTranscriberOptions", "SpeakerRevisionEvent", "SpeakerRevisionItem", "SpeechModel", "SessionConfiguration", "SpeechStartedEvent", - "StreamingClient", - "StreamingClientOptions", - "StreamingError", - "StreamingErrorCodes", - "StreamingEvents", "StreamingMode", - "StreamingParameters", "StreamingPiiPolicy", "StreamingPiiSubstitution", - "StreamingSessionParameters", "TerminationEvent", "TurnEvent", "VadDetector", @@ -76,4 +82,14 @@ "WarningEvent", "Word", "attribute_turn", + # Aliases: the former names, each bound to the same object as its + # RealTime* counterpart above. + "AsyncStreamingClient", + "StreamingClient", + "StreamingClientOptions", + "StreamingError", + "StreamingErrorCodes", + "StreamingEvents", + "StreamingParameters", + "StreamingSessionParameters", ] diff --git a/assemblyai/streaming/v3/_base.py b/assemblyai/streaming/v3/_base.py index 7fd155d..522ff89 100644 --- a/assemblyai/streaming/v3/_base.py +++ b/assemblyai/streaming/v3/_base.py @@ -1,7 +1,7 @@ """Sync/async-agnostic core for streaming v3 clients. Houses the pieces that are *exactly* the same between the threaded -``StreamingClient`` and the asyncio-based ``AsyncStreamingClient``: +``RealTimeTranscriber`` and the asyncio-based ``AsyncRealTimeTranscriber``: - Wire-format helpers (``_dump_model``, ``_parse_model``, ``_build_uri``, ``_build_headers``, parameter normalization, user-agent construction). @@ -34,13 +34,13 @@ EventMessage, HeartbeatEvent, LLMGatewayResponseEvent, + RealTimeError, + RealTimeErrorCodes, + RealTimeEvents, + RealTimeParameters, + RealTimeTranscriberOptions, SpeakerRevisionEvent, SpeechStartedEvent, - StreamingClientOptions, - StreamingError, - StreamingErrorCodes, - StreamingEvents, - StreamingParameters, TerminationEvent, TurnEvent, WarningEvent, @@ -125,7 +125,7 @@ def _user_agent() -> str: ) -def _emit_param_warnings(params: StreamingParameters) -> None: +def _emit_param_warnings(params: RealTimeParameters) -> None: if params.speech_model == "u3-pro": logger.warning( "[Deprecation Warning] The speech model `u3-pro` is deprecated and will be removed in a future release. " @@ -143,7 +143,7 @@ def _emit_param_warnings(params: StreamingParameters) -> None: ) -def _build_uri(host: str, params: StreamingParameters) -> str: +def _build_uri(host: str, params: RealTimeParameters) -> str: params_dict = _normalize_voice_focus( _normalize_min_turn_silence(_dump_model(params)) ) @@ -162,7 +162,7 @@ def _build_uri(host: str, params: StreamingParameters) -> str: return f"wss://{host}/v3/ws?{params_encoded}" -def _build_headers(options: StreamingClientOptions) -> Dict[str, Optional[str]]: +def _build_headers(options: RealTimeTranscriberOptions) -> Dict[str, Optional[str]]: # Matches the pre-refactor sync behavior: ``Authorization`` is left as the # raw value (may be ``None`` when neither ``token`` nor ``api_key`` is set, # which surfaces the misconfiguration through the websockets/httpx layer). @@ -174,9 +174,9 @@ def _build_headers(options: StreamingClientOptions) -> Dict[str, Optional[str]]: def _resolve_options( - options: Optional[StreamingClientOptions], + options: Optional[RealTimeTranscriberOptions], api_key: Optional[str], -) -> StreamingClientOptions: +) -> RealTimeTranscriberOptions: """Returns the options a streaming client is configured with. ``api_key`` takes precedence: given alongside ``options``, it replaces @@ -184,7 +184,7 @@ def _resolve_options( as the caller set it. The caller's ``options`` object is never mutated. Args: - ``options``: an explicit ``StreamingClientOptions``, or ``None``. + ``options``: an explicit ``RealTimeTranscriberOptions``, or ``None``. Returned as-is when no ``api_key`` accompanies it. ``api_key``: an API key, or ``None``. On its own it builds options with every other field left at its default. @@ -204,12 +204,12 @@ def _resolve_options( return options if api_key is not None: - return StreamingClientOptions(api_key=api_key) + return RealTimeTranscriberOptions(api_key=api_key) raise ValueError( "Please provide credentials: pass api_key= to the client, or pass " - "options=StreamingClientOptions(api_key=...) — or " - "options=StreamingClientOptions(token=...) for a temporary token." + "options=RealTimeTranscriberOptions(api_key=...) — or " + "options=RealTimeTranscriberOptions(token=...) for a temporary token." ) @@ -223,10 +223,10 @@ class _BaseStreamingClient: divergence is why these aren't ``@abstractmethod`` on this base. """ - def __init__(self, options: StreamingClientOptions): + def __init__(self, options: RealTimeTranscriberOptions): self._options = options - self._handlers: Dict[StreamingEvents, List[Callable]] = { - event: [] for event in StreamingEvents.__members__.values() + self._handlers: Dict[RealTimeEvents, List[Callable]] = { + event: [] for event in RealTimeEvents.__members__.values() } # Dedup flags for one-time error dispatch. ``_report_connection_closed`` # and ``_report_server_error`` perform their flag check + set @@ -242,26 +242,26 @@ def __init__(self, options: StreamingClientOptions): self._server_error_reported = False self._websocket: Optional[Any] = None - def on(self, event: StreamingEvents, handler: Callable) -> None: + def on(self, event: RealTimeEvents, handler: Callable) -> None: """Register a handler for a streaming event. - ``event`` is a value from ``StreamingEvents`` (``Begin``, ``Turn``, + ``event`` is a value from ``RealTimeEvents`` (``Begin``, ``Turn``, ``Termination``, ``SpeechStarted``, ``Error``, ``Warning``, ``LLMGatewayResponse``). ``handler`` is invoked as - ``handler(client, event)``. For ``AsyncStreamingClient``, async + ``handler(client, event)``. For ``AsyncRealTimeTranscriber``, async handlers are awaited inline on the read task. Exceptions raised by handlers are logged and swallowed — they do not terminate the session. """ - if event in StreamingEvents.__members__.values() and callable(handler): + if event in RealTimeEvents.__members__.values() and callable(handler): self._handlers[event].append(handler) @staticmethod - def _parse_event_type(message_type: Optional[Any]) -> Optional[StreamingEvents]: + def _parse_event_type(message_type: Optional[Any]) -> Optional[RealTimeEvents]: if not isinstance(message_type, str): return None try: - return StreamingEvents[message_type] + return RealTimeEvents[message_type] except KeyError: return None @@ -270,23 +270,23 @@ def _parse_message(cls, data: Dict[str, Any]) -> Optional[EventMessage]: if "type" in data: event_type = cls._parse_event_type(data.get("type")) - if event_type == StreamingEvents.Begin: + if event_type == RealTimeEvents.Begin: return _parse_model(BeginEvent, data) - elif event_type == StreamingEvents.Termination: + elif event_type == RealTimeEvents.Termination: return _parse_model(TerminationEvent, data) - elif event_type == StreamingEvents.Turn: + elif event_type == RealTimeEvents.Turn: return _parse_model(TurnEvent, data) - elif event_type == StreamingEvents.SpeechStarted: + elif event_type == RealTimeEvents.SpeechStarted: return _parse_model(SpeechStartedEvent, data) - elif event_type == StreamingEvents.LLMGatewayResponse: + elif event_type == RealTimeEvents.LLMGatewayResponse: return _parse_model(LLMGatewayResponseEvent, data) - elif event_type == StreamingEvents.SpeakerRevision: + elif event_type == RealTimeEvents.SpeakerRevision: return _parse_model(SpeakerRevisionEvent, data) - elif event_type == StreamingEvents.Heartbeat: + elif event_type == RealTimeEvents.Heartbeat: return _parse_model(HeartbeatEvent, data) - elif event_type == StreamingEvents.Error: + elif event_type == RealTimeEvents.Error: return _parse_model(ErrorEvent, data) - elif event_type == StreamingEvents.Warning: + elif event_type == RealTimeEvents.Warning: return _parse_model(WarningEvent, data) else: return None @@ -297,22 +297,22 @@ def _parse_message(cls, data: Dict[str, Any]) -> Optional[EventMessage]: @staticmethod def _build_connection_closed_error( error: Union[ - StreamingError, + RealTimeError, ErrorEvent, websockets.exceptions.ConnectionClosed, OSError, ], - ) -> Optional[StreamingError]: - if isinstance(error, StreamingError): + ) -> Optional[RealTimeError]: + if isinstance(error, RealTimeError): return error if isinstance(error, ErrorEvent): - return StreamingError(message=error.error, code=error.error_code) + return RealTimeError(message=error.error, code=error.error_code) if isinstance(error, websockets.exceptions.ConnectionClosed): if error.code == 1000: return None - if error.code is not None and error.code in StreamingErrorCodes: - message = StreamingErrorCodes[error.code] + if error.code is not None and error.code in RealTimeErrorCodes: + message = RealTimeErrorCodes[error.code] else: message = error.reason or f"Connection closed (code={error.code})" - return StreamingError(message=message, code=error.code) - return StreamingError(message=f"Connection failed: {error}") + return RealTimeError(message=message, code=error.code) + return RealTimeError(message=f"Connection failed: {error}") diff --git a/assemblyai/streaming/v3/async_client.py b/assemblyai/streaming/v3/async_client.py index e959cb8..fe34bed 100644 --- a/assemblyai/streaming/v3/async_client.py +++ b/assemblyai/streaming/v3/async_client.py @@ -40,11 +40,11 @@ ForceEndpoint, KeepAlive, OperationMessage, - StreamingClientOptions, - StreamingError, - StreamingEvents, - StreamingParameters, - StreamingSessionParameters, + RealTimeError, + RealTimeEvents, + RealTimeParameters, + RealTimeSessionParameters, + RealTimeTranscriberOptions, TerminateSession, TerminationEvent, UpdateConfiguration, @@ -70,8 +70,8 @@ def websocket_connect_async( return _ws_connect(uri, **{_WS_HEADER_KW: additional_headers}) -class AsyncStreamingClient(_BaseStreamingClient): - """Asyncio-native counterpart to ``StreamingClient``. +class AsyncRealTimeTranscriber(_BaseStreamingClient): + """Asyncio-native counterpart to ``RealTimeTranscriber``. The public API mirrors the thread-based client one-to-one — same options, parameters, events, and event-handler registration. Methods that touch the @@ -80,7 +80,7 @@ class AsyncStreamingClient(_BaseStreamingClient): internal read task. Handlers should therefore avoid indefinite blocking, just as with the sync client. - Behavioral notes vs. the sync ``StreamingClient``: + Behavioral notes vs. the sync ``RealTimeTranscriber``: - ``stream`` / ``set_params`` / ``force_endpoint`` / ``keep_alive`` raise ``RuntimeError`` when called before ``connect()`` — silent drop would @@ -97,17 +97,17 @@ class AsyncStreamingClient(_BaseStreamingClient): def __init__( self, - options: Optional[StreamingClientOptions] = None, + options: Optional[RealTimeTranscriberOptions] = None, *, api_key: Optional[str] = None, ): - """Create an asyncio streaming client. + """Create an asyncio streaming transcriber. Args: ``options``: the full client configuration — credentials, host, timeouts, retries. ``api_key``: the API key to authenticate with. On its own it - builds ``StreamingClientOptions`` with every other option left + builds ``RealTimeTranscriberOptions`` with every other option left at its default. Passed alongside ``options`` it takes precedence, replacing the key while every other field is carried over; the ``options`` object itself is left untouched. @@ -132,7 +132,7 @@ def __init__( self._read_task: Optional[asyncio.Task] = None self._write_task: Optional[asyncio.Task] = None - async def connect(self, params: StreamingParameters) -> None: + async def connect(self, params: RealTimeParameters) -> None: # Single-use: a client whose connection went down (success or # handshake failure) sets ``_connection_closed_reported``; reusing # it would yield a silently dead read/write loop because @@ -144,7 +144,7 @@ async def connect(self, params: StreamingParameters) -> None: ) if already_used: raise RuntimeError( - "AsyncStreamingClient has already been connected; " + "AsyncRealTimeTranscriber has already been connected; " "create a new instance for a new connection." ) @@ -171,7 +171,7 @@ async def connect(self, params: StreamingParameters) -> None: getattr(exc, "response", None), "status_code", None ) await self._report_connection_closed( - StreamingError( + RealTimeError( message=f"WebSocket handshake rejected (HTTP {status_code})", code=status_code, ) @@ -203,10 +203,10 @@ async def connect(self, params: StreamingParameters) -> None: return self._read_task = asyncio.create_task( - self._read_loop(), name="AsyncStreamingClient._read_loop" + self._read_loop(), name="AsyncRealTimeTranscriber._read_loop" ) self._write_task = asyncio.create_task( - self._write_loop(), name="AsyncStreamingClient._write_loop" + self._write_loop(), name="AsyncRealTimeTranscriber._write_loop" ) logger.debug("Connected to WebSocket server") @@ -297,7 +297,7 @@ async def stream( return await write_queue.put(chunk) - async def set_params(self, params: StreamingSessionParameters) -> None: + async def set_params(self, params: RealTimeSessionParameters) -> None: write_queue, stop_event = self._ensure_connected("set_params") if stop_event.is_set(): return @@ -325,7 +325,7 @@ def _ensure_connected( # (mypy can't propagate narrowing through a separate method call). if self._write_queue is None or self._stop_event is None: raise RuntimeError( - f"AsyncStreamingClient is not connected; call connect() before {method}()" + f"AsyncRealTimeTranscriber is not connected; call connect() before {method}()" ) return self._write_queue, self._stop_event @@ -334,7 +334,9 @@ async def _write_loop(self) -> None: # the primitives are initialized. ``if`` (not ``assert``) so it # survives ``python -O`` if the invariant is ever violated. if self._write_queue is None or self._stop_event is None: - raise RuntimeError("AsyncStreamingClient internal state not initialized") + raise RuntimeError( + "AsyncRealTimeTranscriber internal state not initialized" + ) while True: if not self._websocket: raise ValueError("Not connected to the WebSocket server") @@ -380,7 +382,9 @@ async def _read_loop(self) -> None: # ``_stop_event`` is initialized. ``if`` (not ``assert``) so it # survives ``python -O`` if the invariant is ever violated. if self._stop_event is None: - raise RuntimeError("AsyncStreamingClient internal state not initialized") + raise RuntimeError( + "AsyncRealTimeTranscriber internal state not initialized" + ) while True: if not self._websocket: raise ValueError("Not connected to the WebSocket server") @@ -415,11 +419,13 @@ async def _handle_message(self, message: EventMessage) -> None: # ``_handle_message`` is only reached from ``_read_loop``, which only # runs after ``connect()`` has initialized ``_stop_event``. if self._stop_event is None: - raise RuntimeError("AsyncStreamingClient internal state not initialized") + raise RuntimeError( + "AsyncRealTimeTranscriber internal state not initialized" + ) if isinstance(message, TerminationEvent): self._stop_event.set() - event_type = StreamingEvents[message.type] + event_type = RealTimeEvents[message.type] for handler in self._handlers[event_type]: await self._invoke_handler(handler, message, event_type) @@ -428,15 +434,17 @@ async def _handle_warning(self, warning: WarningEvent) -> None: logger.warning( "Streaming warning (code=%s): %s", warning.warning_code, warning.warning ) - for handler in self._handlers[StreamingEvents.Warning]: - await self._invoke_handler(handler, warning, StreamingEvents.Warning) + for handler in self._handlers[RealTimeEvents.Warning]: + await self._invoke_handler(handler, warning, RealTimeEvents.Warning) async def _report_server_error(self, error: ErrorEvent) -> None: # Only reachable from ``_read_loop`` (after primitives are initialized). if self._stop_event is None: - raise RuntimeError("AsyncStreamingClient internal state not initialized") + raise RuntimeError( + "AsyncRealTimeTranscriber internal state not initialized" + ) self._server_error_reported = True - streaming_error = StreamingError(message=error.error, code=error.error_code) + streaming_error = RealTimeError(message=error.error, code=error.error_code) logger.error("Streaming error: %s (code=%s)", error.error, error.error_code) await self._dispatch_error(streaming_error) # Tear down locally so a server that sends Error without a trailing @@ -450,7 +458,7 @@ async def _report_server_error(self, error: ErrorEvent) -> None: async def _report_connection_closed( self, error: Union[ - StreamingError, + RealTimeError, ErrorEvent, websockets.exceptions.ConnectionClosed, OSError, @@ -459,7 +467,9 @@ async def _report_connection_closed( # Callers (``connect()`` failure path, ``_read_loop``, ``_write_loop``) # all run after ``_stop_event`` is initialized. if self._stop_event is None: - raise RuntimeError("AsyncStreamingClient internal state not initialized") + raise RuntimeError( + "AsyncRealTimeTranscriber internal state not initialized" + ) if self._connection_closed_reported: return self._connection_closed_reported = True @@ -489,15 +499,15 @@ async def _report_connection_closed( await self._close_websocket() - async def _dispatch_error(self, error: StreamingError) -> None: - for handler in self._handlers[StreamingEvents.Error]: - await self._invoke_handler(handler, error, StreamingEvents.Error) + async def _dispatch_error(self, error: RealTimeError) -> None: + for handler in self._handlers[RealTimeEvents.Error]: + await self._invoke_handler(handler, error, RealTimeEvents.Error) async def _invoke_handler( self, handler: Callable, payload: Any, - event_type: StreamingEvents, + event_type: RealTimeEvents, ) -> None: try: result = handler(self, payload) @@ -516,17 +526,21 @@ async def create_temporary_token( max_session_duration_seconds=max_session_duration_seconds, ) - async def __aenter__(self) -> "AsyncStreamingClient": + async def __aenter__(self) -> "AsyncRealTimeTranscriber": return self async def __aexit__(self, exc_type, exc, tb) -> None: await self.disconnect(terminate=exc_type is None) +# Alias: the former name for `AsyncRealTimeTranscriber`, bound to the same object. +AsyncStreamingClient = AsyncRealTimeTranscriber + + class _AsyncHTTPClient: def __init__(self, api_host: str, api_key: Optional[str] = None): # Lazy: don't instantiate httpx.AsyncClient here. Bare construction of - # an AsyncStreamingClient that's never connected (or used only for + # an AsyncRealTimeTranscriber that's never connected (or used only for # connect() — which doesn't go through the HTTP client) must not # leak an httpx pool. self._api_host = api_host diff --git a/assemblyai/streaming/v3/client.py b/assemblyai/streaming/v3/client.py index 147c58f..29e4c9d 100644 --- a/assemblyai/streaming/v3/client.py +++ b/assemblyai/streaming/v3/client.py @@ -27,11 +27,11 @@ ForceEndpoint, KeepAlive, OperationMessage, - StreamingClientOptions, - StreamingError, - StreamingEvents, - StreamingParameters, - StreamingSessionParameters, + RealTimeError, + RealTimeEvents, + RealTimeParameters, + RealTimeSessionParameters, + RealTimeTranscriberOptions, TerminateSession, TerminationEvent, UpdateConfiguration, @@ -41,20 +41,20 @@ logger = logging.getLogger(__name__) -class StreamingClient(_BaseStreamingClient): +class RealTimeTranscriber(_BaseStreamingClient): def __init__( self, - options: Optional[StreamingClientOptions] = None, + options: Optional[RealTimeTranscriberOptions] = None, *, api_key: Optional[str] = None, ): - """Create a streaming client. + """Create a streaming transcriber. Args: ``options``: the full client configuration — credentials, host, timeouts, retries. ``api_key``: the API key to authenticate with. On its own it - builds ``StreamingClientOptions`` with every other option left + builds ``RealTimeTranscriberOptions`` with every other option left at its default. Passed alongside ``options`` it takes precedence, replacing the key while every other field is carried over; the ``options`` object itself is left untouched. @@ -78,7 +78,7 @@ def __init__( # (read side), which together give a happens-before within ~1s. self._pending_close_error: Optional[Exception] = None - def connect(self, params: StreamingParameters) -> None: + def connect(self, params: RealTimeParameters) -> None: """Open the WebSocket session and start the read/write threads. Blocks until the handshake completes. A transient handshake failure @@ -86,7 +86,7 @@ def connect(self, params: StreamingParameters) -> None: ``options.max_connection_retries`` times before the failure is reported. If the server rejects the handshake at the HTTP layer (auth error, etc.) ``Error`` is dispatched to any - ``on(StreamingEvents.Error, ...)`` handler rather than raised, so + ``on(RealTimeEvents.Error, ...)`` handler rather than raised, so registration order matters: call ``on()`` before ``connect()``. """ _emit_param_warnings(params) @@ -110,7 +110,7 @@ def connect(self, params: StreamingParameters) -> None: getattr(exc, "response", None), "status_code", None ) self._report_connection_closed( - StreamingError( + RealTimeError( message=f"WebSocket handshake rejected (HTTP {status_code})", code=status_code, ) @@ -208,7 +208,7 @@ def stream( return self._write_queue.put(chunk) - def set_params(self, params: StreamingSessionParameters): + def set_params(self, params: RealTimeSessionParameters): message_dict = _normalize_min_turn_silence(_dump_model(params)) message = UpdateConfiguration(**message_dict) self._write_queue.put(message) @@ -301,7 +301,7 @@ def _handle_message(self, message: EventMessage) -> None: if isinstance(message, TerminationEvent): self._stop_event.set() - event_type = StreamingEvents[message.type] + event_type = RealTimeEvents[message.type] for handler in self._handlers[event_type]: try: @@ -313,7 +313,7 @@ def _handle_warning(self, warning: WarningEvent): logger.warning( "Streaming warning (code=%s): %s", warning.warning_code, warning.warning ) - for handler in self._handlers[StreamingEvents.Warning]: + for handler in self._handlers[RealTimeEvents.Warning]: try: handler(self, warning) except Exception: @@ -321,7 +321,7 @@ def _handle_warning(self, warning: WarningEvent): def _report_server_error(self, error: ErrorEvent) -> None: self._server_error_reported = True - streaming_error = StreamingError( + streaming_error = RealTimeError( message=error.error, code=error.error_code, ) @@ -338,7 +338,7 @@ def _report_server_error(self, error: ErrorEvent) -> None: def _report_connection_closed( self, error: Union[ - StreamingError, + RealTimeError, ErrorEvent, websockets.exceptions.ConnectionClosed, OSError, @@ -376,8 +376,8 @@ def _report_connection_closed( self._close_websocket() - def _dispatch_error(self, error: StreamingError) -> None: - for handler in self._handlers[StreamingEvents.Error]: + def _dispatch_error(self, error: RealTimeError) -> None: + for handler in self._handlers[RealTimeEvents.Error]: try: handler(self, error) except Exception: @@ -394,6 +394,10 @@ def create_temporary_token( ) +# Alias: the former name for `RealTimeTranscriber`, bound to the same object. +StreamingClient = RealTimeTranscriber + + class _HTTPClient: def __init__(self, api_host: str, api_key: Optional[str] = None): headers = {"User-Agent": f"{httpx._client.USER_AGENT} {_user_agent()}"} diff --git a/assemblyai/streaming/v3/extras.py b/assemblyai/streaming/v3/extras.py index a340a59..184cece 100644 --- a/assemblyai/streaming/v3/extras.py +++ b/assemblyai/streaming/v3/extras.py @@ -36,11 +36,11 @@ Union, ) -from .models import StreamingEvents, TurnEvent, Word +from .models import RealTimeEvents, TurnEvent, Word if TYPE_CHECKING: # avoid an import cycle; only used for type hints - from .async_client import AsyncStreamingClient - from .client import StreamingClient + from .async_client import AsyncRealTimeTranscriber + from .client import RealTimeTranscriber logger = logging.getLogger(__name__) @@ -181,7 +181,7 @@ def __init__(self, window_ms: int): self._window_ms = window_ms self._frames: List[VadFrame] = [] self._head = 0 - # The threaded ``StreamingClient`` runs ``frames_in_window`` on the read + # The threaded ``RealTimeTranscriber`` runs ``frames_in_window`` on the read # thread while the user thread runs ``push_frame``; compaction swaps # ``_frames`` / ``_head`` non-atomically. The lock keeps push/read/compact # mutually exclusive. Uncontended on the async / single-threaded paths. @@ -363,10 +363,10 @@ def resolve_unknown_channels_by_speaker_history( for w in turn.words: if w.channel != UNKNOWN_CHANNEL or not w.speaker: continue - entry = speaker_history.get(w.speaker) - if not entry or sum(entry.values()) < min_rms_evidence: + evidence = speaker_history.get(w.speaker) + if not evidence or sum(evidence.values()) < min_rms_evidence: continue - winner = _top_by_ratio(entry, dominance_ratio) + winner = _top_by_ratio(evidence, dominance_ratio) if winner is not None: w.channel = winner w.channel_resolved = True @@ -563,7 +563,7 @@ def _validate_channels(channels: Sequence[str]) -> List[str]: class _BaseChannelStreamer: """Shared dual/multi-channel coordination independent of the wrapped client's sync/async I/O. Channel config lives here, never on - ``StreamingParameters`` (it must not reach the websocket URL); the wrapped + ``RealTimeParameters`` (it must not reach the websocket URL); the wrapped client streams ordinary mono audio and is otherwise untouched. """ @@ -581,13 +581,13 @@ def __init__( self._speaker_history: Dict[str, Dict[str, float]] = {} self._turn_handlers: List[Callable] = [] # Set by each subclass in __init__ (the concrete sync/async client). - self._client: Union["StreamingClient", "AsyncStreamingClient"] + self._client: Union["RealTimeTranscriber", "AsyncRealTimeTranscriber"] - def on(self, event: StreamingEvents, handler: Callable) -> None: + def on(self, event: RealTimeEvents, handler: Callable) -> None: """Register an event handler. ``Turn`` events are delivered as an enriched ``DualChannelTurnEvent``; all other events are forwarded to the underlying client unchanged.""" - if event == StreamingEvents.Turn: + if event == RealTimeEvents.Turn: self._turn_handlers.append(handler) else: self._client.on(event, handler) @@ -634,7 +634,7 @@ def _as_chunks(data: Union[bytes, Iterable[bytes]]) -> Iterable[bytes]: class ChannelStreamer(_BaseChannelStreamer): - """Dual/multi-channel coordinator for the threaded ``StreamingClient``. + """Dual/multi-channel coordinator for the threaded ``RealTimeTranscriber``. Feed each named channel's 16-bit little-endian PCM via ``stream(channel, data)``; the channels are summed into one mono stream over the client's @@ -652,7 +652,7 @@ class ChannelStreamer(_BaseChannelStreamer): def __init__( self, - client: "StreamingClient", + client: "RealTimeTranscriber", channels: Sequence[str], sample_rate: int, attribution: Optional[ChannelAttributionOptions] = None, @@ -660,7 +660,7 @@ def __init__( ): super().__init__(channels, sample_rate, attribution, on_vad) self._client = client - client.on(StreamingEvents.Turn, self._handle_turn) + client.on(RealTimeEvents.Turn, self._handle_turn) def _handle_turn(self, client: object, base_turn: TurnEvent) -> None: enriched = self._enrich(base_turn) @@ -695,14 +695,18 @@ def flush(self) -> None: class AsyncChannelStreamer(_BaseChannelStreamer): """Asyncio-native counterpart to ``ChannelStreamer`` (wraps - ``AsyncStreamingClient``); ``stream`` / ``close_channel`` / ``flush`` are + ``AsyncRealTimeTranscriber``); ``stream`` / ``close_channel`` / ``flush`` are coroutines. ``Turn`` handlers may be sync or ``async`` (awaited inline on the read task). See ``ChannelStreamer`` for requirements. """ + # Narrows the base's sync-or-async union: this streamer only ever wraps the + # async client, so `stream(...)` here is always awaitable. + _client: "AsyncRealTimeTranscriber" + def __init__( self, - client: "AsyncStreamingClient", + client: "AsyncRealTimeTranscriber", channels: Sequence[str], sample_rate: int, attribution: Optional[ChannelAttributionOptions] = None, @@ -710,7 +714,7 @@ def __init__( ): super().__init__(channels, sample_rate, attribution, on_vad) self._client = client - client.on(StreamingEvents.Turn, self._handle_turn) + client.on(RealTimeEvents.Turn, self._handle_turn) async def _handle_turn(self, client: object, base_turn: TurnEvent) -> None: enriched = self._enrich(base_turn) diff --git a/assemblyai/streaming/v3/models.py b/assemblyai/streaming/v3/models.py index 0c1bdb6..c33ff00 100644 --- a/assemblyai/streaming/v3/models.py +++ b/assemblyai/streaming/v3/models.py @@ -152,7 +152,7 @@ class KeepAlive(BaseModel): type: Literal["KeepAlive"] = "KeepAlive" -class StreamingSessionParameters(BaseModel): +class RealTimeSessionParameters(BaseModel): end_of_turn_confidence_threshold: Optional[float] = None min_end_of_turn_silence_when_confident: Optional[int] = ( None # Deprecated: Use min_turn_silence instead @@ -171,6 +171,10 @@ class StreamingSessionParameters(BaseModel): session_heartbeat: Optional[bool] = None +# Alias: the former name for `RealTimeSessionParameters`, bound to the same object. +StreamingSessionParameters = RealTimeSessionParameters + + class Encoding(str, Enum): pcm_s16le = "pcm_s16le" pcm_mulaw = "pcm_mulaw" @@ -287,7 +291,7 @@ def __str__(self): return self.value -class StreamingParameters(StreamingSessionParameters): +class RealTimeParameters(RealTimeSessionParameters): # Required for PCM encodings. May be omitted for Opus encodings # (opus, ogg_opus) — the stream is self-describing and the server # ignores the value. @@ -352,7 +356,11 @@ def _require_sample_rate(cls, values): return values -class UpdateConfiguration(StreamingSessionParameters): +# Alias: the former name for `RealTimeParameters`, bound to the same object. +StreamingParameters = RealTimeParameters + + +class UpdateConfiguration(RealTimeSessionParameters): type: Literal["UpdateConfiguration"] = "UpdateConfiguration" @@ -365,7 +373,7 @@ class UpdateConfiguration(StreamingSessionParameters): ] -class StreamingClientOptions(BaseModel): +class RealTimeTranscriberOptions(BaseModel): api_host: str = "streaming.assemblyai.com" api_key: Optional[str] = None token: Optional[str] = None @@ -383,13 +391,21 @@ class StreamingClientOptions(BaseModel): terminate_timeout: float = 5.0 -class StreamingError(Exception): +# Alias: the former name for `RealTimeTranscriberOptions`, bound to the same object. +StreamingClientOptions = RealTimeTranscriberOptions + + +class RealTimeError(Exception): def __init__(self, message: str, code: Optional[int] = None): super().__init__(message) self.code = code -StreamingErrorCodes = { +# Alias: the former name for `RealTimeError`, bound to the same object. +StreamingError = RealTimeError + + +RealTimeErrorCodes = { 3005: "Server error", 3006: "Input validation error", 3007: "Audio chunk duration violation", @@ -416,8 +432,11 @@ def __init__(self, message: str, code: Optional[int] = None): 1013: "Temporary server condition forced blocking client's request", } +# Alias: the former name for `RealTimeErrorCodes`, bound to the same object. +StreamingErrorCodes = RealTimeErrorCodes + -class StreamingEvents(Enum): +class RealTimeEvents(Enum): Begin = "Begin" Termination = "Termination" Turn = "Turn" @@ -427,3 +446,7 @@ class StreamingEvents(Enum): Heartbeat = "Heartbeat" LLMGatewayResponse = "LLMGatewayResponse" SpeakerRevision = "SpeakerRevision" + + +# Alias: the former name for `RealTimeEvents`, bound to the same object. +StreamingEvents = RealTimeEvents diff --git a/assemblyai/sync/v1/_base.py b/assemblyai/sync/v1/_base.py index 7feb6ad..4e4b26b 100644 --- a/assemblyai/sync/v1/_base.py +++ b/assemblyai/sync/v1/_base.py @@ -14,6 +14,25 @@ _PCM_SUFFIXES = (".pcm", ".raw") +def check_config(owner: str, config: Optional[types.SyncTranscriptionConfig]) -> None: + """ + Raises unless `config` is a `SyncTranscriptionConfig` or `None`. + + The job API's `TranscriptionConfig` is a different, non-interchangeable + type, so passing it here is a mistake worth naming. + + Args: + owner: the class to name in the message, e.g. `SyncTranscriber`. + config: the configuration to check. + """ + + if config is not None and not isinstance(config, types.SyncTranscriptionConfig): + raise TypeError( + f"{owner} expects SyncTranscriptionConfig, got {type(config).__name__}. " + "Use aai.SyncTranscriptionConfig." + ) + + def _resolve_audio( data: AudioInput, config: types.SyncTranscriptionConfig, diff --git a/assemblyai/sync/v1/api.py b/assemblyai/sync/v1/api.py index 2edf6b8..7be3635 100644 --- a/assemblyai/sync/v1/api.py +++ b/assemblyai/sync/v1/api.py @@ -19,7 +19,8 @@ def _error_from_response(response: httpx.Response) -> types.SyncTranscriptError: The service returns an RFC 9457 problem-details envelope (`{"status", "title", "detail"}`); `error_code` is the snake_cased `title` (e.g. `"Audio Too Large"` -> `audio_too_large`). Older envelopes - (`{"error_code", "message"}` and `{"detail"}`) are still accepted. + (`{"error_code", "message"}`, `{"detail"}`, and `{"error"}`) are still + accepted; a bare `error` string carries no `error_code`. """ error_code: Optional[str] = None message: Optional[str] = None @@ -32,6 +33,10 @@ def _error_from_response(response: httpx.Response) -> types.SyncTranscriptError: if error_code is None and isinstance(title, str) and title: error_code = title.lower().replace(" ", "_") message = body.get("detail") or body.get("message") + if not message: + error = body.get("error") + if isinstance(error, str) and error: + message = error except Exception: message = response.text or None diff --git a/assemblyai/sync/v1/async_client.py b/assemblyai/sync/v1/async_client.py index 36b8cc0..6921c4b 100644 --- a/assemblyai/sync/v1/async_client.py +++ b/assemblyai/sync/v1/async_client.py @@ -12,7 +12,7 @@ from ... import async_client as _async_client from ... import types from . import api, async_api -from ._base import AudioInput, _config_to_json, _resolve_audio +from ._base import AudioInput, _config_to_json, _resolve_audio, check_config _T = TypeVar("_T") @@ -88,7 +88,12 @@ def __init__( transcriber builds and owns a client made from a copy of that client's settings with the key replaced, and the given client is left untouched and stays the caller's to close. + + Raises: + TypeError: if `config` is not a `SyncTranscriptionConfig`. """ + check_config(type(self).__name__, config) + self._owns_client = client is None or api_key is not None self._client = _async_client._resolve_client(client, api_key) self.config = config or types.SyncTranscriptionConfig() @@ -115,8 +120,12 @@ async def transcribe( config: Options for this call. If `None`, the transcriber's default configuration is used. - Raises: `SyncTranscriptError` if the request fails. + Raises: + TypeError: if `config` is not a `SyncTranscriptionConfig`. + SyncTranscriptError: if the request fails. """ + check_config(type(self).__name__, config) + config = config or self.config audio, filename, content_type = await _run_in_thread( _resolve_audio, data, config diff --git a/assemblyai/sync/v1/client.py b/assemblyai/sync/v1/client.py index 2d7ca92..a329369 100644 --- a/assemblyai/sync/v1/client.py +++ b/assemblyai/sync/v1/client.py @@ -9,7 +9,7 @@ from ... import client as _client from ... import types from . import api -from ._base import AudioInput, _SyncTranscriberImpl +from ._base import AudioInput, _SyncTranscriberImpl, check_config class SyncTranscriber: @@ -54,7 +54,12 @@ def __init__( transcriber builds its own client from a copy of that client's settings with the key replaced, and the given client is left untouched. + + Raises: + TypeError: if `config` is not a `SyncTranscriptionConfig`. """ + check_config(type(self).__name__, config) + self._client = _client._resolve_client(client, api_key) self._impl = _SyncTranscriberImpl( client=self._client, @@ -76,6 +81,8 @@ def config(self) -> types.SyncTranscriptionConfig: @config.setter def config(self, config: types.SyncTranscriptionConfig) -> None: + check_config(type(self).__name__, config) + self._impl.config = config def transcribe( @@ -92,8 +99,12 @@ def transcribe( config: Options for this call. If `None`, the transcriber's default configuration is used. - Raises: `SyncTranscriptError` if the request fails. + Raises: + TypeError: if `config` is not a `SyncTranscriptionConfig`. + SyncTranscriptError: if the request fails. """ + check_config(type(self).__name__, config) + return self._impl.transcribe(data=data, config=config) def transcribe_async( @@ -107,7 +118,12 @@ def transcribe_async( Returns a `concurrent.futures.Future` (not an asyncio coroutine); call `.result()` to block for the transcript. Useful for fanning out a handful of files concurrently. + + Raises: + TypeError: if `config` is not a `SyncTranscriptionConfig`. """ + check_config(type(self).__name__, config) + return self._executor.submit( self._impl.transcribe, data=data, diff --git a/assemblyai/types.py b/assemblyai/types.py index 01ad4f4..2ad7f5d 100644 --- a/assemblyai/types.py +++ b/assemblyai/types.py @@ -1005,6 +1005,13 @@ class RawTranscriptionConfig(BaseModel): class TranscriptionConfig: + """ + Options for a transcription request against the `/v2/transcript` job API. + + Every option is a constructor parameter — see `__init__` for the full list — + and each is also readable and writable as an attribute afterwards. + """ + def __init__( self, language_code: Optional[Union[str, LanguageCode]] = None, diff --git a/tests/unit/test_dx.py b/tests/unit/test_dx.py index 708f766..3a350ca 100644 --- a/tests/unit/test_dx.py +++ b/tests/unit/test_dx.py @@ -1,22 +1,45 @@ -"""Tests for `api_key=` construction across the client surface. +"""Tests for the client developer-experience surface. -Covers what the four transcribers, the two HTTP clients, and the two streaming -clients share: building one from an explicit key, how that interacts with an -explicit `client=`/`options=`, and the guarantee that neither the global -settings nor a caller's own options object is mutated along the way. +Covers the pieces shared across the four transcribers and the two streaming +clients rather than any single product: `api_key=` construction, the +config-type guard, the audio input types the sync `Transcriber` accepts, +`poll_timeout`, the sync API's error parsing, and the lazily imported +`aai.streaming` attribute. """ +import io +import os +import subprocess +import sys + +import httpx import pytest +from pytest_httpx import HTTPXMock import assemblyai as aai +from assemblyai.api import ENDPOINT_TRANSCRIPT, ENDPOINT_UPLOAD from assemblyai.streaming.v3 import ( AsyncStreamingClient, StreamingClient, StreamingClientOptions, ) +from tests.unit import factories aai.settings.api_key = "test" +TRANSCRIPT_URL = f"{aai.settings.base_url}{ENDPOINT_TRANSCRIPT}" +UPLOAD_URL = f"{aai.settings.base_url}{ENDPOINT_UPLOAD}" +SYNC_TRANSCRIBE_URL = f"{aai.settings.sync_base_url}/v1/transcribe" + +_SYNC_OK_RESPONSE = { + "text": "hello world", + "words": [{"text": "hello", "start": 0, "end": 200, "confidence": 0.9}], + "confidence": 0.92, + "audio_duration_ms": 400, + "session_id": "eb92c4ff-4bbb-429f-9b99-7279d7fe738f", + "request_time_ms": 243.7, +} + @pytest.fixture def no_global_api_key(): @@ -28,7 +51,53 @@ def no_global_api_key(): aai.settings.api_key = original -# == transcribers and clients: api_key= == +@pytest.fixture +def fast_polling(): + """Keeps the polling loop from spending seconds sleeping in tests.""" + + original = aai.settings.polling_interval + aai.settings.polling_interval = 0.001 + yield + aai.settings.polling_interval = original + + +def _completed_response(**overrides) -> dict: + response = factories.generate_dict_factory( + factories.TranscriptCompletedResponseFactory + )() + response.update(overrides) + + return response + + +def _processing_response(transcript_id: str) -> dict: + response = factories.generate_dict_factory( + factories.TranscriptProcessingResponseFactory + )() + response["id"] = transcript_id + + return response + + +def _mock_submit(httpx_mock: HTTPXMock, response: dict) -> None: + httpx_mock.add_response( + url=TRANSCRIPT_URL, + method="POST", + status_code=httpx.codes.OK, + json=response, + ) + + +def _mock_poll(httpx_mock: HTTPXMock, response: dict) -> None: + httpx_mock.add_response( + url=f"{TRANSCRIPT_URL}/{response['id']}", + method="GET", + status_code=httpx.codes.OK, + json=response, + ) + + +# == api_key= == def test_transcriber_accepts_api_key(no_global_api_key): @@ -183,6 +252,313 @@ def test_missing_api_key_names_every_way_to_provide_one(no_global_api_key): assert "api_key=" in message +# == config type guard == + + +def test_sync_transcriber_rejects_a_job_api_config(): + with pytest.raises(TypeError) as exc_info: + aai.SyncTranscriber(config=aai.TranscriptionConfig()) + + assert str(exc_info.value) == ( + "SyncTranscriber expects SyncTranscriptionConfig, got TranscriptionConfig. " + "Use aai.SyncTranscriptionConfig." + ) + + +def test_sync_transcriber_rejects_a_job_api_config_per_call(): + transcriber = aai.SyncTranscriber() + + with pytest.raises(TypeError) as exc_info: + transcriber.transcribe(b"RIFFfake-wav-bytes", config=aai.TranscriptionConfig()) + + assert "SyncTranscriber expects SyncTranscriptionConfig" in str(exc_info.value) + + +def test_transcriber_rejects_a_sync_api_config(): + with pytest.raises(TypeError) as exc_info: + aai.Transcriber(config=aai.SyncTranscriptionConfig()) + + assert str(exc_info.value) == ( + "Transcriber expects TranscriptionConfig, got SyncTranscriptionConfig. " + "Use aai.TranscriptionConfig." + ) + + +@pytest.mark.asyncio +async def test_async_transcriber_rejects_a_sync_api_config(): + with pytest.raises(TypeError) as exc_info: + aai.AsyncTranscriber(config=aai.SyncTranscriptionConfig()) + + assert str(exc_info.value) == ( + "AsyncTranscriber expects TranscriptionConfig, got SyncTranscriptionConfig. " + "Use aai.TranscriptionConfig." + ) + + +@pytest.mark.asyncio +async def test_async_transcriber_rejects_a_sync_api_config_per_call(): + async with aai.AsyncTranscriber() as transcriber: + with pytest.raises(TypeError) as exc_info: + await transcriber.transcribe( + "https://example.org/audio.wav", + config=aai.SyncTranscriptionConfig(), + ) + + assert "AsyncTranscriber expects TranscriptionConfig" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_async_sync_transcriber_rejects_a_job_api_config(): + async with aai.AsyncSyncTranscriber() as transcriber: + with pytest.raises(TypeError) as exc_info: + await transcriber.transcribe( + b"RIFFfake-wav-bytes", + config=aai.TranscriptionConfig(), + ) + + assert "AsyncSyncTranscriber expects SyncTranscriptionConfig" in str(exc_info.value) + + +def test_the_matching_config_class_is_accepted(httpx_mock: HTTPXMock): + # Given a mocked sync endpoint + httpx_mock.add_response( + url=SYNC_TRANSCRIBE_URL, + method="POST", + status_code=httpx.codes.OK, + json=_SYNC_OK_RESPONSE, + ) + + # When the config matches the transcriber + transcriber = aai.SyncTranscriber(config=aai.SyncTranscriptionConfig()) + result = transcriber.transcribe( + b"RIFFfake-wav-bytes", + config=aai.SyncTranscriptionConfig(timestamps=True), + ) + + # Then it transcribes as usual + assert result.text == "hello world" + + +def test_a_config_subclass_is_accepted(): + class CustomConfig(aai.SyncTranscriptionConfig): + pass + + transcriber = aai.SyncTranscriber(config=CustomConfig()) + + assert isinstance(transcriber.config, CustomConfig) + + +# == audio input types == + + +def _expect_upload(httpx_mock: HTTPXMock, audio: bytes) -> None: + """ + Arms the upload endpoint, asserting the body through `match_content`. + + The body is matched while the request is being sent, which is the only + point a streamed upload is readable: a path is streamed from a file object + that closes when `upload_file` returns, and the oldest supported httpx + consumes the request stream lazily. Reading the captured request afterwards + would be reading a closed file. + """ + + httpx_mock.add_response( + url=UPLOAD_URL, + method="POST", + status_code=httpx.codes.OK, + json={"upload_url": "https://example.org/uploaded.wav"}, + match_content=audio, + ) + + +def test_upload_file_accepts_a_pathlike(httpx_mock: HTTPXMock, tmp_path): + # Given a local file addressed by a `pathlib.Path` + audio = os.urandom(64) + audio_path = tmp_path / "audio.wav" + audio_path.write_bytes(audio) + + _expect_upload(httpx_mock, audio) + + # When uploading it + upload_url = aai.Transcriber().upload_file(audio_path) + + # Then the file's bytes are what got sent, and only that request was made + assert upload_url == "https://example.org/uploaded.wav" + assert len(httpx_mock.get_requests()) == 1 + + +def test_upload_file_accepts_a_bytearray(httpx_mock: HTTPXMock): + audio = bytearray(os.urandom(64)) + + _expect_upload(httpx_mock, bytes(audio)) + + aai.Transcriber().upload_file(audio) + + assert len(httpx_mock.get_requests()) == 1 + + +def test_upload_file_accepts_a_file_object(httpx_mock: HTTPXMock): + audio = os.urandom(64) + + _expect_upload(httpx_mock, audio) + + aai.Transcriber().upload_file(io.BytesIO(audio)) + + assert len(httpx_mock.get_requests()) == 1 + + +def test_upload_file_rejects_unsupported_input(): + with pytest.raises(TypeError) as exc_info: + aai.Transcriber().upload_file(42) + + assert str(exc_info.value) == "unsupported audio input type: int" + + +def test_upload_file_still_raises_for_a_missing_path(tmp_path): + with pytest.raises(FileNotFoundError): + aai.Transcriber().upload_file(str(tmp_path / "absent.wav")) + + with pytest.raises(FileNotFoundError): + aai.Transcriber().upload_file(tmp_path / "absent.wav") + + +# == poll_timeout == + + +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) +def test_poll_timeout_raises_with_the_transcript_id( + httpx_mock: HTTPXMock, + fast_polling, +): + # Given a job that never leaves `processing` + _mock_submit(httpx_mock, _processing_response("stuck-id")) + _mock_poll(httpx_mock, _processing_response("stuck-id")) + + # When transcribing with a short poll timeout + with pytest.raises(aai.TranscriptError) as exc_info: + aai.Transcriber().transcribe( + "https://example.org/audio.wav", + poll_timeout=0.05, + ) + + # Then the error names the transcript and its last-seen status + message = str(exc_info.value) + assert "stuck-id" in message + assert "processing" in message + + +def test_poll_timeout_of_none_still_completes(httpx_mock: HTTPXMock, fast_polling): + # Given a job that completes after a couple of polls + completed = _completed_response() + _mock_submit(httpx_mock, _processing_response(completed["id"])) + _mock_poll(httpx_mock, _processing_response(completed["id"])) + _mock_poll(httpx_mock, completed) + + # When transcribing without a poll timeout + transcript = aai.Transcriber().transcribe( + "https://example.org/audio.wav", + poll_timeout=None, + ) + + assert transcript.status == aai.TranscriptStatus.completed + + +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) +def test_transcribe_async_accepts_poll_timeout(httpx_mock: HTTPXMock, fast_polling): + # Given a job that never leaves `processing` + _mock_submit(httpx_mock, _processing_response("stuck-id")) + _mock_poll(httpx_mock, _processing_response("stuck-id")) + + future = aai.Transcriber().transcribe_async( + "https://example.org/audio.wav", + poll_timeout=0.05, + ) + + with pytest.raises(aai.TranscriptError) as exc_info: + future.result() + + assert "stuck-id" in str(exc_info.value) + + +@pytest.mark.asyncio +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) +async def test_async_poll_timeout_raises_with_the_transcript_id( + httpx_mock: HTTPXMock, + fast_polling, +): + # Given a job that never leaves `processing` + _mock_submit(httpx_mock, _processing_response("stuck-id")) + _mock_poll(httpx_mock, _processing_response("stuck-id")) + + # When transcribing with a short poll timeout + async with aai.AsyncTranscriber() as transcriber: + with pytest.raises(aai.TranscriptError) as exc_info: + await transcriber.transcribe( + "https://example.org/audio.wav", + poll_timeout=0.05, + ) + + message = str(exc_info.value) + assert "stuck-id" in message + assert "processing" in message + + +@pytest.mark.asyncio +async def test_async_poll_timeout_of_none_still_completes( + httpx_mock: HTTPXMock, + fast_polling, +): + completed = _completed_response() + _mock_submit(httpx_mock, _processing_response(completed["id"])) + _mock_poll(httpx_mock, _processing_response(completed["id"])) + _mock_poll(httpx_mock, completed) + + async with aai.AsyncTranscriber() as transcriber: + transcript = await transcriber.transcribe( + "https://example.org/audio.wav", + poll_timeout=None, + ) + + assert transcript.status == aai.TranscriptStatus.completed + + +# == sync API error parsing == + + +def test_sync_error_falls_back_to_a_bare_error_key(httpx_mock: HTTPXMock): + # Given a 4xx whose body is a plain `{"error": ...}` + httpx_mock.add_response( + url=SYNC_TRANSCRIBE_URL, + method="POST", + status_code=httpx.codes.BAD_REQUEST, + json={"error": "some message"}, + ) + + # When transcribing + with pytest.raises(aai.SyncTranscriptError) as exc_info: + aai.SyncTranscriber().transcribe(b"RIFFfake-wav-bytes") + + # Then the message comes from `error`, without inventing an error code + assert "some message" in str(exc_info.value) + assert exc_info.value.error_code is None + assert exc_info.value.status_code == httpx.codes.BAD_REQUEST + + +def test_sync_error_prefers_problem_details_over_the_error_key(httpx_mock: HTTPXMock): + httpx_mock.add_response( + url=SYNC_TRANSCRIBE_URL, + method="POST", + status_code=httpx.codes.BAD_REQUEST, + json={"title": "Bad Audio", "detail": "the detail", "error": "some message"}, + ) + + with pytest.raises(aai.SyncTranscriptError) as exc_info: + aai.SyncTranscriber().transcribe(b"RIFFfake-wav-bytes") + + assert "the detail" in str(exc_info.value) + assert exc_info.value.error_code == "bad_audio" + + # == streaming clients: api_key= == @@ -272,5 +648,114 @@ def test_streaming_client_without_credentials_names_both_fixes(client_class): message = str(exc_info.value) assert "api_key=" in message - assert "StreamingClientOptions" in message + assert "RealTimeTranscriberOptions" in message assert "token=" in message + + +# == streaming renames: RealTime* canonical, Streaming* aliases == + +_RENAMED_PAIRS = [ + ("RealTimeTranscriber", "StreamingClient"), + ("AsyncRealTimeTranscriber", "AsyncStreamingClient"), + ("RealTimeTranscriberOptions", "StreamingClientOptions"), + ("RealTimeParameters", "StreamingParameters"), + ("RealTimeSessionParameters", "StreamingSessionParameters"), + ("RealTimeEvents", "StreamingEvents"), + ("RealTimeError", "StreamingError"), + ("RealTimeErrorCodes", "StreamingErrorCodes"), +] + + +@pytest.mark.parametrize(("new_name", "old_name"), _RENAMED_PAIRS) +def test_old_streaming_name_is_an_alias_of_the_new_one(new_name, old_name): + from assemblyai.streaming import v3 + + # Then both names resolve, to the very same object + assert getattr(v3, new_name) is getattr(v3, old_name) + # And both are exported + assert new_name in v3.__all__ + assert old_name in v3.__all__ + + +def test_renamed_streaming_imports_are_warning_free(): + import importlib + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("error") + module = importlib.import_module("assemblyai.streaming.v3") + + for new_name, old_name in _RENAMED_PAIRS: + getattr(module, new_name) + getattr(module, old_name) + + +def test_real_time_transcriber_constructs_with_api_key(): + from assemblyai.streaming.v3 import AsyncRealTimeTranscriber, RealTimeTranscriber + + for client_class in (RealTimeTranscriber, AsyncRealTimeTranscriber): + client = client_class(api_key="explicit-key") + + assert client._options.api_key == "explicit-key" + + +def test_real_time_transcriber_options_is_accepted_by_both_new_constructors(): + from assemblyai.streaming.v3 import ( + AsyncRealTimeTranscriber, + RealTimeTranscriber, + RealTimeTranscriberOptions, + ) + + options = RealTimeTranscriberOptions(api_key="from-options", connect_timeout=2.5) + + for client_class in (RealTimeTranscriber, AsyncRealTimeTranscriber): + client = client_class(options) + + assert client._options is options + assert client._options.connect_timeout == 2.5 + + +def test_isinstance_holds_across_both_names(): + from assemblyai.streaming.v3 import ( + RealTimeTranscriber, + RealTimeTranscriberOptions, + StreamingClient, + StreamingClientOptions, + ) + + client = RealTimeTranscriber(StreamingClientOptions(api_key="explicit-key")) + + assert isinstance(client, StreamingClient) + assert isinstance(client, RealTimeTranscriber) + assert isinstance(client._options, RealTimeTranscriberOptions) + assert isinstance(client._options, StreamingClientOptions) + + +# == lazy streaming attribute == + + +def test_streaming_attribute_imports_lazily(): + # Given a fresh interpreter, so no other test's imports can mask the result + script = ( + "import sys, assemblyai; " + "assert 'websockets' not in sys.modules; " + "import assemblyai as a; " + "a.streaming.v3.StreamingClient; " + "assert 'websockets' in sys.modules" + ) + result = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + + +def test_unknown_attribute_still_raises_attribute_error(): + with pytest.raises(AttributeError) as exc_info: + aai.not_a_real_name + + assert ( + str(exc_info.value) == "module 'assemblyai' has no attribute 'not_a_real_name'" + ) diff --git a/tox.ini b/tox.ini index 0500a7a..a283748 100644 --- a/tox.ini +++ b/tox.ini @@ -33,3 +33,9 @@ commands = pytest -n auto --cov-report term --cov-report xml:coverage.xml --cov= # ``strict`` keeps that opt-in pattern and silences the pytest-asyncio # unset-mode deprecation warning on >=0.21. asyncio_mode = strict +# pytest-httpx registers this mark itself only on newer releases; declaring it +# here keeps the legacy httpx floors free of PytestUnknownMarkWarning. Those +# releases reuse an already-matched response by default, so the options the +# mark carries are a no-op there. +markers = + httpx_mock: per-test configuration for the pytest-httpx fixture. From cc8b3c903cbc9be4f54dfd6b2fff9a69afaf1cfa Mon Sep 17 00:00:00 2001 From: Carter Campbell Date: Fri, 14 Aug 2026 12:09:02 -0600 Subject: [PATCH 2/2] docs: add 1.0 migration guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MIGRATION.md covers what breaks moving from 0.x to 1.0.0 — LeMUR support and the audio-capture extras were removed — and the recommended pattern for everything that still works the old way: the RealTime* streaming names, the canonical top-level import paths, api_key= on constructors, async lifecycle, poll_timeout=, checking transcript.status, warm() plus keepalive_expiry, and pathlib.Path inputs. Linked from the README overview and install section. Co-Authored-By: Claude Fable 5 --- MIGRATION.md | 229 +++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 13 ++- 2 files changed, 238 insertions(+), 4 deletions(-) create mode 100644 MIGRATION.md diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..92f199f --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,229 @@ +# Migrating to 1.0 + +Two things in one guide: what genuinely breaks moving from 0.x to 1.0.0, and the patterns we recommend going forward for everything that still works the old way. Most 0.x code runs on 1.0 unchanged — read [Breaking changes](#breaking-changes) first (it is short), then adopt the rest at your own pace. + +## TL;DR + +| 0.x pattern | 1.0 best practice | +| --- | --- | +| `aai.Lemur(...)` | Removed. Feed `transcript.text` to the LLM of your choice | +| `from assemblyai.extras import MicrophoneStream` | Removed. Capture PCM yourself (`pyaudio`, `sounddevice`) and pass it to `stream(...)` | +| `pip install "assemblyai[extras]"` | `pip install -U assemblyai` | +| `StreamingClient`, `StreamingClientOptions` | `RealTimeTranscriber`, `RealTimeTranscriberOptions` | +| `from assemblyai.transcriber import Transcriber` | `from assemblyai.prerecorded.v2 import Transcriber` | +| `aai.Transcriber` / `aai.SyncTranscriber` (still fine) | `assemblyai.prerecorded.v2` / `assemblyai.sync.v1` imports | +| `aai.settings.api_key = "..."` as the only option | `Transcriber(api_key="...")` per client | +| `AsyncTranscriber()` left open | `async with AsyncTranscriber(...) as transcriber:` | +| `transcriber.transcribe(url)` polling forever | `transcriber.transcribe(url, poll_timeout=300)` | +| Assuming a returned transcript succeeded | Check `transcript.status` before reading `.text` | +| `SyncTranscriber().transcribe(...)` cold | `warm()` first, and raise `settings.keepalive_expiry` | +| `transcriber.transcribe(str(path))` | `transcriber.transcribe(path)` — `pathlib.Path` is accepted | + +## Breaking changes + +### LeMUR support removed + +The LeMUR API and every `aai.Lemur*` name are gone from the SDK. There is no drop-in replacement in this package. + +A transcript is still just text, so the migration is to send it to whichever LLM you already use: + +```python +from assemblyai import TranscriptStatus +from assemblyai.prerecorded.v2 import Transcriber + +transcript = Transcriber(api_key="YOUR_API_KEY").transcribe( + "https://example.org/audio.wav" +) + +if transcript.status == TranscriptStatus.error: + raise RuntimeError(transcript.error) + +prompt = f"Summarize this call transcript:\n\n{transcript.text}" +# ...hand `prompt` to your LLM client of choice. +``` + +### Audio-capture extras removed + +`assemblyai.extras` (including `MicrophoneStream`) and the `[extras]` install option no longer exist. `pip install "assemblyai[extras]"` fails; use `pip install -U assemblyai`. + +The SDK does not capture microphone audio. Bring your own capture — `pyaudio`, `sounddevice`, a loopback device, a file — and pass 16-bit PCM chunks to the streaming client: + +```python +from assemblyai.streaming.v3 import RealTimeParameters, RealTimeTranscriber + +transcriber = RealTimeTranscriber(api_key="YOUR_API_KEY") +transcriber.connect(RealTimeParameters(sample_rate=16_000)) + +# `chunks` is any iterable of 16-bit PCM frames from your capture library. +chunks = [b"\x00\x00" * 160] +transcriber.stream(chunks) +transcriber.disconnect() +``` + +## Still works, but modernize + +### Streaming classes are now `RealTime*` + +The streaming surface was renamed. Every former name remains bound to the same object, so `isinstance` checks and existing imports keep working — but new code should use the `RealTime*` names. + +Before: + +```python +from assemblyai.streaming.v3 import StreamingClient, StreamingClientOptions + +client = StreamingClient(StreamingClientOptions(api_key="YOUR_API_KEY")) +``` + +After: + +```python +from assemblyai.streaming.v3 import RealTimeTranscriber, RealTimeTranscriberOptions + +transcriber = RealTimeTranscriber(RealTimeTranscriberOptions(api_key="YOUR_API_KEY")) +``` + +Full mapping — `StreamingClient`→`RealTimeTranscriber`, `AsyncStreamingClient`→`AsyncRealTimeTranscriber`, `StreamingClientOptions`→`RealTimeTranscriberOptions`, `StreamingParameters`→`RealTimeParameters`, `StreamingSessionParameters`→`RealTimeSessionParameters`, `StreamingEvents`→`RealTimeEvents`, `StreamingError`→`RealTimeError`, `StreamingErrorCodes`→`RealTimeErrorCodes`. + +### Import from the versioned submodules + +Each product lives in its own versioned subpackage — `assemblyai.prerecorded.v2`, `assemblyai.sync.v1`, `assemblyai.streaming.v3` — and importing a transcriber from its versioned path is the preferred style for new code: it says which product and which API version you are pinned to. + +Before: + +```python +import assemblyai as aai + +transcriber = aai.Transcriber(api_key="YOUR_API_KEY") +sync_transcriber = aai.SyncTranscriber(api_key="YOUR_API_KEY") +``` + +After: + +```python +from assemblyai.prerecorded.v2 import Transcriber +from assemblyai.sync.v1 import SyncTranscriber + +transcriber = Transcriber(api_key="YOUR_API_KEY") +sync_transcriber = SyncTranscriber(api_key="YOUR_API_KEY") +``` + +The top-level `aai.Transcriber` / `aai.SyncTranscriber` names continue to work, as do the old flat module paths (`assemblyai.transcriber`, `assemblyai.sync`, `assemblyai.sync_api`) — nothing is deprecated. + +Each subpackage exports only its own surface: `prerecorded.v2` gives you `Transcriber`, `AsyncTranscriber`, `Transcript`, `AsyncTranscript`, `TranscriptGroup`, and `TranscriptionConfig`; `sync.v1` gives you `SyncTranscriber`, `AsyncSyncTranscriber`, `SyncTranscriptionConfig`, `SyncTranscriptError`, and friends; `streaming.v3` gives you the `RealTime*` classes and every event type. Cross-cutting names — `TranscriptStatus`, `TranscriptError`, `Settings`, `Client`, `AsyncClient`, and the global `settings` — are **not** re-exported by the subpackages, so import those from the top-level package, mixing the two styles in one file as needed. + +## New best practices in 1.0 + +### Pass `api_key=` where you build the client + +Every transcriber and both streaming clients accept `api_key=`, so a key no longer has to travel through process-wide state. + +Precedence, when combined with an explicit `client=` (or `options=` on the streaming clients): **`api_key=` wins**. The transcriber derives its own client from a copy of the given client's settings with the key replaced, and your client is left untouched. + +```python +from assemblyai import Client, Settings +from assemblyai.prerecorded.v2 import Transcriber + +transcriber = Transcriber(api_key="YOUR_API_KEY") + +shared = Client(settings=Settings(api_key="TEAM_KEY", http_timeout=60.0)) +reuses_shared = Transcriber(client=shared) # `shared` verbatim +per_tenant = Transcriber(client=shared, api_key="TENANT_KEY") +# `per_tenant` keeps http_timeout=60.0; `shared` still carries TEAM_KEY. +``` + +### Own the async client's lifecycle + +The async transcribers hold an HTTP connection pool. Use them as async context managers so the pool is always released: + +```python +import asyncio + +from assemblyai.prerecorded.v2 import AsyncTranscriber + + +async def main(): + async with AsyncTranscriber(api_key="YOUR_API_KEY") as transcriber: + transcript = await transcriber.transcribe("https://example.org/audio.wav") + print(transcript.text) + + +asyncio.run(main()) +``` + +`aclose()` is the explicit equivalent. A client you pass in with `client=` stays yours to close; anything the transcriber builds — including a client derived because you also passed `api_key=` — it closes itself. + +### Bound your polling with `poll_timeout=` + +`transcribe` polls until the transcript reaches a terminal status. Give it a deadline so a stuck job cannot hang a request handler: + +```python +from assemblyai import TranscriptError +from assemblyai.prerecorded.v2 import Transcriber + +transcriber = Transcriber(api_key="YOUR_API_KEY") + +try: + transcript = transcriber.transcribe( + "https://example.org/audio.wav", + poll_timeout=300, + ) +except TranscriptError as error: + # The message carries the transcript id; the job keeps processing server-side. + print(f"still running: {error}") +``` + +Available on `Transcriber.transcribe`, `Transcriber.transcribe_async`, and `AsyncTranscriber.transcribe`; omitting it keeps the unbounded behaviour. Resume later with `Transcript.get_by_id(transcript_id)` (`assemblyai.prerecorded.v2`). + +### Check `transcript.status` + +A transcription the server fails to complete is **returned, not raised**. `text` and `words` are `None` in that case, so check the status before reading the result: + +```python +from assemblyai import TranscriptStatus +from assemblyai.prerecorded.v2 import Transcriber + +transcriber = Transcriber(api_key="YOUR_API_KEY") +transcript = transcriber.transcribe("https://example.org/audio.wav") + +if transcript.status == TranscriptStatus.error: + raise RuntimeError(f"Transcription failed: {transcript.error}") + +print(transcript.text) +``` + +### Warm the sync connection + +`SyncTranscriber` is one request, so a cold DNS + TCP + TLS handshake lands on the critical path. Call `warm()` as soon as you know audio is coming, and raise `keepalive_expiry` so the connection survives until you send: + +```python +import assemblyai as aai +from assemblyai.sync.v1 import SyncTranscriber + +aai.settings.keepalive_expiry = 120 # seconds; matches the 120s sync audio cap + +transcriber = SyncTranscriber(api_key="YOUR_API_KEY") +transcriber.warm() # while the clip is still being recorded +# ...later: transcriber.transcribe("./call.wav") +``` + +`AsyncSyncTranscriber.warm()` is the awaitable equivalent. + +### Pass `pathlib.Path` directly + +Local files can be a `Path`, a `str` path, raw `bytes`/`bytearray`, or an open binary file — no `str()` wrapping needed: + +```python +import pathlib + +from assemblyai.prerecorded.v2 import Transcriber + +transcriber = Transcriber(api_key="YOUR_API_KEY") +transcriber.transcribe(pathlib.Path("./call.wav")) +``` + +## What did *not* change + +- `aai.settings.api_key = "..."` still works and is still the default for every client that is not given a key. +- Every former `Streaming*` name still imports and still resolves to the same object as its `RealTime*` counterpart. +- The flat import paths (`assemblyai.transcriber`, `assemblyai.sync`, `assemblyai.sync_api`) still work. +- No method signature was narrowed: every argument that worked in 0.x still works, and the new ones are optional keywords. diff --git a/README.md b/README.md index 9dbcb1d..4cda0e6 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ See [Coding agent prompts](https://www.assemblyai.com/docs/coding-agent-prompts) - [AssemblyAI's Python SDK](#assemblyais-python-sdk) - [Overview](#overview) - [Documentation](#documentation) +- [Migrating to 1.0](MIGRATION.md) - [Quick Start](#quick-start) - [Installation](#installation) - [Examples](#examples) @@ -80,6 +81,8 @@ Visit our [AssemblyAI API Documentation](https://www.assemblyai.com/docs) to get pip install -U assemblyai ``` +Upgrading from 0.x? See the [**1.0 migration guide**](MIGRATION.md) for the breaking changes (LeMUR and the audio-capture extras were removed) and the recommended patterns going forward. + ## Examples Before starting, you need to set the API key. If you don't have one yet, [**sign up for one**](https://www.assemblyai.com/dashboard/signup)! @@ -97,13 +100,15 @@ aai.settings.api_key = f"{ASSEMBLYAI_API_KEY}" | Class | Use it for | | --- | --- | -| `aai.Transcriber` | Long-form audio, URLs, and the audio-intelligence features (speaker labels, chapters, sentiment, …), over the polled job API | -| `aai.AsyncTranscriber` | The same, from asyncio code | -| `aai.SyncTranscriber` | Short clips (≤120s, ≤40MB) where you want the transcript back in one request, at the lowest latency | -| `aai.AsyncSyncTranscriber` | The same, from asyncio code | +| `assemblyai.prerecorded.v2.Transcriber` | Long-form audio, URLs, and the audio-intelligence features (speaker labels, chapters, sentiment, …), over the polled job API | +| `assemblyai.prerecorded.v2.AsyncTranscriber` | The same, from asyncio code | +| `assemblyai.sync.v1.SyncTranscriber` | Short clips (≤120s, ≤40MB) where you want the transcript back in one request, at the lowest latency | +| `assemblyai.sync.v1.AsyncSyncTranscriber` | The same, from asyncio code | | `assemblyai.streaming.v3.RealTimeTranscriber` | Live audio (microphone, telephony, voice agents), transcribed as it arrives over a websocket session | | `assemblyai.streaming.v3.AsyncRealTimeTranscriber` | The same, from asyncio code | +The versioned path is the preferred import for new code. The prerecorded and sync classes are also available as top-level shortcuts (`aai.Transcriber`, `aai.SyncTranscriber`, …) — see [Migrating to 1.0](MIGRATION.md) for the details. + --- ### **Core Examples**