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/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 30da654..4cda0e6 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,11 @@ 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) + - [Choosing a transcriber](#choosing-a-transcriber) - [**Core Examples**](#core-examples) - [**Asyncio Examples**](#asyncio-examples) - [**Speech Understanding Examples**](#speech-understanding-examples) @@ -79,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)! @@ -92,6 +96,21 @@ aai.settings.api_key = f"{ASSEMBLYAI_API_KEY}" --- +### Choosing a transcriber + +| Class | Use it for | +| --- | --- | +| `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**
@@ -1007,9 +1026,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 +1041,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 +1061,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 +1092,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 +1101,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 +1148,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 +1167,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 +1182,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 +1204,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 +1226,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 +1235,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 +1380,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.