diff --git a/CLAUDE.md b/CLAUDE.md index 03414ae..48148e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,8 +41,8 @@ aai.settings.api_key = "your-key" - `aai.TranscriptionConfig` — All transcription options: `speech_models`, `speaker_labels`, `sentiment_analysis`, `entity_detection`, `auto_chapters`, `content_safety`, `language_detection`, `summarization`, `word_boost`, `disfluencies` - `aai.Transcript` — Result object with `.text`, `.status`, `.utterances`, `.words`, `.chapters`, `.entities`, `.sentiment_analysis`. Methods: `get_sentences()`, `get_paragraphs()`, `export_subtitles_srt()`, `export_subtitles_vtt()` - `aai.SyncTranscriber` — Synchronous pre-recorded transcription: audio in, transcript out, one request (no polling). Methods: `transcribe()`, `transcribe_async()` -- `aai.SyncTranscriptionConfig` — Sync options: `model` (default `u3-sync-pro`), `prompt`, `word_boost`, `conversation_context`, `language_code`, `sample_rate`, `channels` -- `aai.SyncTranscriptResponse` — Sync result: `.text`, `.words` (`Word` type with `start`/`end`/`confidence`), `.confidence`, `.audio_duration_ms`, `.session_id`, `.request_time_ms` +- `aai.SyncTranscriptionConfig` — Sync options: `model` (default `u3-sync-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 @@ -97,7 +97,7 @@ aai.settings.api_key = os.environ["ASSEMBLYAI_API_KEY"] result = aai.SyncTranscriber().transcribe("./call.wav") print(result.text, result.session_id) for w in result.words: - print(w.text, w.start, w.end, w.confidence) + print(w.text, w.confidence) # w.start/w.end need timestamps=True (see below) ``` **Input**: a local file path, raw `bytes`, or a binary file object. **Not** a URL — @@ -107,8 +107,8 @@ pass a path/bytes or use `Transcriber` for URL ingestion. ```python config = aai.SyncTranscriptionConfig( prompt="Transcribe verbatim. Preserve disfluencies.", # max 4096 chars - word_boost=["AssemblyAI", "Lemur", "U3-Pro"], # max 2048 chars total - language_code="es", # or ["en", "es"]; defaults to English + keyterms_prompt=["AssemblyAI", "Lemur", "U3-Pro"], # max 2048 chars total + language_codes=["es"], # or e.g. ["en", "es"] for multilingual; defaults to English ) result = aai.SyncTranscriber().transcribe("./call.wav", config=config) ``` @@ -129,9 +129,20 @@ config = aai.SyncTranscriptionConfig( result = aai.SyncTranscriber().transcribe("./reply.wav", config=config) ``` -**Language**: `language_code` takes an ISO 639-1 code (or list of codes for multilingual -audio) and steers the default prompt toward that language — ignored when you pass a custom -`prompt`. Supported: en, es, de, fr, it, pt, tr, nl, sv, no, da, fi, hi, vi, ar, he, ja, ur, zh. +**Language**: `language_codes` takes a list of ISO 639-1 codes — a single-element list +for monolingual audio or several codes for multilingual audio — and steers the default +prompt toward those languages; ignored when you pass a custom `prompt`. +Supported: en, es, de, fr, it, pt, tr, nl, sv, no, da, fi, hi, vi, ar, he, ja, ur, zh. + +**Word timestamps** are opt-in. By default words carry `text` and `confidence` only — +`start`/`end` are `None`. `timestamps=True` computes accurate per-word timings for a +small latency cost: +```python +config = aai.SyncTranscriptionConfig(timestamps=True) +result = aai.SyncTranscriber().transcribe("./call.wav", config=config) +for w in result.words: + print(w.text, w.start, w.end) # milliseconds +``` **Raw PCM** (S16LE) needs `sample_rate` + `channels`; WAV reads them from its header. Setting either field routes the audio as `audio/pcm`, and both must be present: diff --git a/assemblyai/__init__.py b/assemblyai/__init__.py index 6e66b5b..7d92a77 100644 --- a/assemblyai/__init__.py +++ b/assemblyai/__init__.py @@ -68,6 +68,7 @@ SyncTranscriptError, SyncTranscriptionConfig, SyncTranscriptResponse, + SyncWord, Timestamp, TranscriptError, TranscriptionConfig, @@ -148,6 +149,7 @@ "SyncTranscriptError", "SyncTranscriptionConfig", "SyncTranscriptResponse", + "SyncWord", "Timestamp", "Transcriber", "TranscriptionConfig", diff --git a/assemblyai/__version__.py b/assemblyai/__version__.py index 0348df7..6618e4e 100644 --- a/assemblyai/__version__.py +++ b/assemblyai/__version__.py @@ -1 +1 @@ -__version__ = "0.64.26" +__version__ = "0.64.29" diff --git a/assemblyai/sync/__init__.py b/assemblyai/sync/__init__.py new file mode 100644 index 0000000..2bf1a28 --- /dev/null +++ b/assemblyai/sync/__init__.py @@ -0,0 +1,22 @@ +"""Backwards-compatibility package for the old flat ``sync.py`` module. + +The sync (single-request) transcription product now lives in ``sync/v1/``, +mirroring the ``streaming/v3/`` layout. This ``__init__`` re-exports the full +surface the old ``assemblyai.sync`` module exposed so every existing import — +``from assemblyai.sync import SyncTranscriber``, the ``AudioInput`` alias, and +the private helpers — keeps working silently. +""" + +from .v1._base import ( # noqa: F401 + _PCM_SUFFIXES, + AudioInput, + _config_to_json, + _resolve_audio, + _SyncTranscriberImpl, +) +from .v1.client import SyncTranscriber + +__all__ = [ + "AudioInput", + "SyncTranscriber", +] diff --git a/assemblyai/sync/v1/__init__.py b/assemblyai/sync/v1/__init__.py new file mode 100644 index 0000000..c6417ff --- /dev/null +++ b/assemblyai/sync/v1/__init__.py @@ -0,0 +1,19 @@ +from ...types import ( + SyncSpeechModel, + SyncTranscriptError, + SyncTranscriptionConfig, + SyncTranscriptResponse, + SyncWord, +) +from ._base import AudioInput +from .client import SyncTranscriber + +__all__ = [ + "AudioInput", + "SyncSpeechModel", + "SyncTranscriber", + "SyncTranscriptError", + "SyncTranscriptionConfig", + "SyncTranscriptResponse", + "SyncWord", +] diff --git a/assemblyai/sync/v1/_base.py b/assemblyai/sync/v1/_base.py new file mode 100644 index 0000000..7feb6ad --- /dev/null +++ b/assemblyai/sync/v1/_base.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import os +from typing import BinaryIO, Optional, Tuple, Union +from urllib.parse import urlparse + +from ... import client as _client +from ... import types +from . import api + +AudioInput = Union[str, bytes, bytearray, "os.PathLike[str]", BinaryIO] + +# Extensions that signal raw S16LE PCM rather than a WAV container. +_PCM_SUFFIXES = (".pcm", ".raw") + + +def _resolve_audio( + data: AudioInput, + config: types.SyncTranscriptionConfig, +) -> Tuple[bytes, str, str]: + """ + Reads the audio input into bytes and decides its multipart Content-Type. + + PCM is selected when the source has a `.pcm`/`.raw` extension or when + `sample_rate`/`channels` are set on the config (the fields the sync API + requires only for raw PCM) — and both must then be present. Everything + else is treated as a WAV container. URLs are rejected — the sync API has + no URL ingestion. + + Returns: `(audio_bytes, filename, content_type)`. + """ + suffix = "" + filename: Optional[str] = None + + if isinstance(data, (bytes, bytearray)): + audio = bytes(data) + elif isinstance(data, (str, os.PathLike)): + path = os.fspath(data) + if urlparse(path).scheme in ("http", "https"): + raise ValueError( + "SyncTranscriber does not accept URLs. Pass a local file path or " + "audio bytes, or use aai.Transcriber for URL/async transcription." + ) + with open(path, "rb") as f: + audio = f.read() + filename = os.path.basename(path) + suffix = os.path.splitext(path)[1].lower() + elif hasattr(data, "read"): + audio = data.read() + name = getattr(data, "name", None) + if name: + filename = os.path.basename(name) + suffix = os.path.splitext(name)[1].lower() + else: + raise TypeError(f"unsupported audio input type: {type(data).__name__}") + + wants_pcm = config.sample_rate is not None or config.channels is not None + is_pcm = suffix in _PCM_SUFFIXES or wants_pcm + if is_pcm and (config.sample_rate is None or config.channels is None): + raise ValueError( + "raw PCM audio requires both sample_rate and channels in " + "SyncTranscriptionConfig" + ) + + content_type = "audio/pcm" if is_pcm else "audio/wav" + if not filename: + filename = "audio.pcm" if is_pcm else "audio.wav" + + return audio, filename, content_type + + +def _config_to_json(config: types.SyncTranscriptionConfig) -> Optional[dict]: + """Serializes the config to the JSON `config` part, dropping the routing model.""" + data = config.dict(exclude_none=True) + data.pop("model", None) + return data or None + + +class _SyncTranscriberImpl: + def __init__( + self, + *, + client: _client.Client, + config: types.SyncTranscriptionConfig, + ) -> None: + self._client = client + self.config = config + + def transcribe( + self, + *, + data: AudioInput, + config: Optional[types.SyncTranscriptionConfig], + ) -> types.SyncTranscriptResponse: + config = config or self.config + audio, filename, content_type = _resolve_audio(data, config) + return api.transcribe( + self._client.http_client, + base_url=self._client.settings.sync_base_url, + audio=audio, + filename=filename, + audio_content_type=content_type, + model=config.model, + config=_config_to_json(config), + timeout=self._client.settings.sync_http_timeout, + ) diff --git a/assemblyai/sync/v1/api.py b/assemblyai/sync/v1/api.py new file mode 100644 index 0000000..2edf6b8 --- /dev/null +++ b/assemblyai/sync/v1/api.py @@ -0,0 +1,106 @@ +import json +from typing import Dict, Optional, Tuple + +import httpx + +from ... import types + +# Canonical paths since the sync API gained a /v1 prefix (#18103); the +# unprefixed routes remain served for SDK versions that predate it. +ENDPOINT_TRANSCRIBE = "/v1/transcribe" +ENDPOINT_WARM = "/v1/warm" +MODEL_HEADER = "X-AAI-Model" + + +def _error_from_response(response: httpx.Response) -> types.SyncTranscriptError: + """ + Builds a `SyncTranscriptError` from a non-200 response. + + 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: Optional[str] = None + message: Optional[str] = None + + try: + body = response.json() + if isinstance(body, dict): + error_code = body.get("error_code") + title = body.get("title") + if error_code is None and isinstance(title, str) and title: + error_code = title.lower().replace(" ", "_") + message = body.get("detail") or body.get("message") + except Exception: + message = response.text or None + + if not message: + message = f"sync transcription failed with status {response.status_code}" + + retry_after_header = response.headers.get("retry-after") + retry_after = ( + int(retry_after_header) + if retry_after_header and retry_after_header.isdigit() + else None + ) + + return types.SyncTranscriptError( + message, + status_code=response.status_code, + error_code=error_code, + retry_after=retry_after, + ) + + +def transcribe( + client: httpx.Client, + *, + base_url: str, + audio: bytes, + filename: str, + audio_content_type: str, + model: str, + config: Optional[dict], + timeout: float, +) -> types.SyncTranscriptResponse: + """ + Posts a single synchronous transcription request. + + Args: + client: the HTTP client (carries the `Authorization` header). + base_url: the sync API base URL, e.g. `https://sync.assemblyai.com`. + audio: raw audio bytes (WAV container or S16LE PCM). + filename: name for the audio multipart part. + audio_content_type: `audio/wav` or `audio/pcm`; selects the decoder. + model: sent as the `X-AAI-Model` routing header. + config: the JSON `config` part, or None to omit it. + timeout: per-request timeout in seconds. + + Returns: the parsed transcript response. + + Raises: `SyncTranscriptError` on any non-200 response. + """ + files: Dict[str, Tuple[Optional[str], bytes, str]] = { + "audio": (filename, audio, audio_content_type) + } + if config: + # httpx <0.23 rejects a `str` multipart part; encode to bytes so the + # config part works across the full supported httpx range (>=0.19). + files["config"] = ( + None, + json.dumps(config).encode("utf-8"), + "application/json", + ) + + response = client.post( + base_url.rstrip("/") + ENDPOINT_TRANSCRIBE, + files=files, + headers={MODEL_HEADER: model}, + timeout=timeout, + ) + + if response.status_code != httpx.codes.OK: + raise _error_from_response(response) + + return types.SyncTranscriptResponse.parse_obj(response.json()) diff --git a/assemblyai/sync.py b/assemblyai/sync/v1/client.py similarity index 57% rename from assemblyai/sync.py rename to assemblyai/sync/v1/client.py index 2c9ed3d..a855651 100644 --- a/assemblyai/sync.py +++ b/assemblyai/sync/v1/client.py @@ -2,110 +2,14 @@ import concurrent.futures import os -from typing import Any, BinaryIO, Optional, Tuple, Union -from urllib.parse import urlparse +from typing import Any, Optional import httpx -from . import client as _client -from . import sync_api, types - -AudioInput = Union[str, bytes, bytearray, "os.PathLike[str]", BinaryIO] - -# Extensions that signal raw S16LE PCM rather than a WAV container. -_PCM_SUFFIXES = (".pcm", ".raw") - - -def _resolve_audio( - data: AudioInput, - config: types.SyncTranscriptionConfig, -) -> Tuple[bytes, str, str]: - """ - Reads the audio input into bytes and decides its multipart Content-Type. - - PCM is selected when the source has a `.pcm`/`.raw` extension or when - `sample_rate`/`channels` are set on the config (the fields the sync API - requires only for raw PCM) — and both must then be present. Everything - else is treated as a WAV container. URLs are rejected — the sync API has - no URL ingestion. - - Returns: `(audio_bytes, filename, content_type)`. - """ - suffix = "" - filename: Optional[str] = None - - if isinstance(data, (bytes, bytearray)): - audio = bytes(data) - elif isinstance(data, (str, os.PathLike)): - path = os.fspath(data) - if urlparse(path).scheme in ("http", "https"): - raise ValueError( - "SyncTranscriber does not accept URLs. Pass a local file path or " - "audio bytes, or use aai.Transcriber for URL/async transcription." - ) - with open(path, "rb") as f: - audio = f.read() - filename = os.path.basename(path) - suffix = os.path.splitext(path)[1].lower() - elif hasattr(data, "read"): - audio = data.read() - name = getattr(data, "name", None) - if name: - filename = os.path.basename(name) - suffix = os.path.splitext(name)[1].lower() - else: - raise TypeError(f"unsupported audio input type: {type(data).__name__}") - - wants_pcm = config.sample_rate is not None or config.channels is not None - is_pcm = suffix in _PCM_SUFFIXES or wants_pcm - if is_pcm and (config.sample_rate is None or config.channels is None): - raise ValueError( - "raw PCM audio requires both sample_rate and channels in " - "SyncTranscriptionConfig" - ) - - content_type = "audio/pcm" if is_pcm else "audio/wav" - if not filename: - filename = "audio.pcm" if is_pcm else "audio.wav" - - return audio, filename, content_type - - -def _config_to_json(config: types.SyncTranscriptionConfig) -> Optional[dict]: - """Serializes the config to the JSON `config` part, dropping the routing model.""" - data = config.dict(exclude_none=True) - data.pop("model", None) - return data or None - - -class _SyncTranscriberImpl: - def __init__( - self, - *, - client: _client.Client, - config: types.SyncTranscriptionConfig, - ) -> None: - self._client = client - self.config = config - - def transcribe( - self, - *, - data: AudioInput, - config: Optional[types.SyncTranscriptionConfig], - ) -> types.SyncTranscriptResponse: - config = config or self.config - audio, filename, content_type = _resolve_audio(data, config) - return sync_api.transcribe( - self._client.http_client, - base_url=self._client.settings.sync_base_url, - audio=audio, - filename=filename, - audio_content_type=content_type, - model=config.model, - config=_config_to_json(config), - timeout=self._client.settings.sync_http_timeout, - ) +from ... import client as _client +from ... import types +from . import api +from ._base import AudioInput, _SyncTranscriberImpl class SyncTranscriber: @@ -228,15 +132,15 @@ def warm(self) -> bool: Returns: True once the connection is open (any HTTP response — even a - non-200 health probe — means the socket is established); False if - the connection could not be opened (transport error). + non-200 — means the socket is established); False if the + connection could not be opened (transport error). """ settings = self._client.settings - url = settings.sync_base_url.rstrip("/") + sync_api.ENDPOINT_HEALTH + url = settings.sync_base_url.rstrip("/") + api.ENDPOINT_WARM try: self._client.http_client.get( url, - headers={sync_api.MODEL_HEADER: self.config.model}, + headers={api.MODEL_HEADER: self.config.model}, timeout=min(settings.sync_http_timeout, 10.0), ) except httpx.HTTPError: diff --git a/assemblyai/sync_api.py b/assemblyai/sync_api.py index 1e978f3..6cf1d9e 100644 --- a/assemblyai/sync_api.py +++ b/assemblyai/sync_api.py @@ -1,104 +1,21 @@ -import json -from typing import Dict, Optional, Tuple - -import httpx - -from . import types - -ENDPOINT_TRANSCRIBE = "/transcribe" -ENDPOINT_HEALTH = "/healthz" -MODEL_HEADER = "X-AAI-Model" - - -def _error_from_response(response: httpx.Response) -> types.SyncTranscriptError: - """ - Builds a `SyncTranscriptError` from a non-200 response. - - 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: Optional[str] = None - message: Optional[str] = None - - try: - body = response.json() - if isinstance(body, dict): - error_code = body.get("error_code") - title = body.get("title") - if error_code is None and isinstance(title, str) and title: - error_code = title.lower().replace(" ", "_") - message = body.get("detail") or body.get("message") - except Exception: - message = response.text or None - - if not message: - message = f"sync transcription failed with status {response.status_code}" - - retry_after_header = response.headers.get("retry-after") - retry_after = ( - int(retry_after_header) - if retry_after_header and retry_after_header.isdigit() - else None - ) - - return types.SyncTranscriptError( - message, - status_code=response.status_code, - error_code=error_code, - retry_after=retry_after, - ) - - -def transcribe( - client: httpx.Client, - *, - base_url: str, - audio: bytes, - filename: str, - audio_content_type: str, - model: str, - config: Optional[dict], - timeout: float, -) -> types.SyncTranscriptResponse: - """ - Posts a single synchronous transcription request. - - Args: - client: the HTTP client (carries the `Authorization` header). - base_url: the sync API base URL, e.g. `https://sync.assemblyai.com`. - audio: raw audio bytes (WAV container or S16LE PCM). - filename: name for the audio multipart part. - audio_content_type: `audio/wav` or `audio/pcm`; selects the decoder. - model: sent as the `X-AAI-Model` routing header. - config: the JSON `config` part, or None to omit it. - timeout: per-request timeout in seconds. - - Returns: the parsed transcript response. - - Raises: `SyncTranscriptError` on any non-200 response. - """ - files: Dict[str, Tuple[Optional[str], bytes, str]] = { - "audio": (filename, audio, audio_content_type) - } - if config: - # httpx <0.23 rejects a `str` multipart part; encode to bytes so the - # config part works across the full supported httpx range (>=0.19). - files["config"] = ( - None, - json.dumps(config).encode("utf-8"), - "application/json", - ) - - response = client.post( - base_url.rstrip("/") + ENDPOINT_TRANSCRIBE, - files=files, - headers={MODEL_HEADER: model}, - timeout=timeout, - ) - - if response.status_code != httpx.codes.OK: - raise _error_from_response(response) - - return types.SyncTranscriptResponse.parse_obj(response.json()) +"""Backwards-compatibility shim for the old flat ``sync_api.py`` module. + +The sync API transport layer now lives in ``sync/v1/api.py`` alongside the +rest of the sync product. This module re-exports its full surface so every +existing ``assemblyai.sync_api`` import keeps working silently. +""" + +from .sync.v1.api import ( # noqa: F401 + ENDPOINT_TRANSCRIBE, + ENDPOINT_WARM, + MODEL_HEADER, + _error_from_response, + transcribe, +) + +__all__ = [ + "ENDPOINT_TRANSCRIBE", + "ENDPOINT_WARM", + "MODEL_HEADER", + "transcribe", +] diff --git a/assemblyai/types.py b/assemblyai/types.py index cff8a3f..097facf 100644 --- a/assemblyai/types.py +++ b/assemblyai/types.py @@ -2985,11 +2985,11 @@ class LemurPurgeResponse(BaseModel): "The result of the LeMUR purge request" -# Caps mirror the sync service's `config` part. `prompt` and `word_boost` +# Caps mirror the sync service's `config` part. `prompt` and `keyterms_prompt` # over their caps are rejected; `conversation_context` over its caps is # trimmed (oldest turns first), matching the server. _SYNC_MAX_PROMPT_LEN = 4096 -_SYNC_MAX_WORD_BOOST_LEN = 2048 +_SYNC_MAX_KEYTERMS_PROMPT_LEN = 2048 _SYNC_MAX_CONVERSATION_CONTEXT_TURNS = 100 _SYNC_MAX_CONVERSATION_CONTEXT_LEN = 4096 @@ -3028,11 +3028,11 @@ class SyncTranscriptionConfig(BaseModel): """ Options for a synchronous transcription request. - `prompt`, `word_boost`, `conversation_context`, and `language_code` shape - the transcript; `sample_rate` and `channels` are required only for raw PCM - audio (WAV carries them in its header). `model` selects the sync speech - model and is sent as the `X-AAI-Model` routing header, not in the request - body. + `prompt`, `keyterms_prompt`, `conversation_context`, and `language_codes` shape + the transcript; `timestamps` opts into per-word `start`/`end` timings; + `sample_rate` and `channels` are required only for raw PCM audio (WAV + carries them in its header). `model` selects the sync speech model and is + sent as the `X-AAI-Model` routing header, not in the request body. """ model: str = SyncSpeechModel.u3_sync_pro.value @@ -3041,7 +3041,7 @@ class SyncTranscriptionConfig(BaseModel): prompt: Optional[str] = Field(default=None, max_length=_SYNC_MAX_PROMPT_LEN) "Custom transcription instruction prepended to the model's system prompt. Max 4096 characters." - word_boost: Optional[List[str]] = None + keyterms_prompt: Optional[List[str]] = None "Keyterms biasing the decoder. Whitespace is stripped and empty terms dropped. Max 2048 characters total." conversation_context: Optional[Union[str, List[str]]] = None @@ -3056,12 +3056,13 @@ class SyncTranscriptionConfig(BaseModel): the prompt exceeds the model token budget, so put the most recent turn last.""" - language_code: Optional[Union[str, List[str]]] = None - """ISO 639-1 language code, or a list of codes for multilingual audio (e.g. - `"es"` or `["en", "es"]`). Steers the default transcription prompt toward - the named language(s); ignored when `prompt` is set. Defaults to English. - Supported: en, es, de, fr, it, pt, tr, nl, sv, no, da, fi, hi, vi, ar, he, - ja, ur, zh.""" + language_codes: Optional[List[str]] = None + """ISO 639-1 codes for the language(s) of the audio — a single-element + list (e.g. `["es"]`) for monolingual audio, or several codes (e.g. + `["en", "es"]`) for multilingual audio. Steers the default transcription + prompt toward the named language(s); ignored when `prompt` is set. + Defaults to English. Supported: en, es, de, fr, it, pt, tr, nl, sv, no, + da, fi, hi, vi, ar, he, ja, ur, zh.""" sample_rate: Optional[int] = None "Source sample rate in Hz. Required for raw PCM audio; ignored for WAV." @@ -3069,18 +3070,23 @@ class SyncTranscriptionConfig(BaseModel): channels: Optional[int] = None "Channel count (1 mono, 2 stereo). Required for raw PCM audio; ignored for WAV." + timestamps: Optional[bool] = None + """Whether to compute per-word `start`/`end` timestamps. When `True`, + words carry accurate timestamps at a small latency cost. Defaults to + `False`: no timestamps are returned.""" + if pydantic_v2: - @field_validator("word_boost") + @field_validator("keyterms_prompt") @classmethod - def _normalize_word_boost(cls, v): + def _normalize_keyterms_prompt(cls, v): if not v: return None terms = [t.strip() for t in v if t and t.strip()] total = sum(len(t) for t in terms) - if total > _SYNC_MAX_WORD_BOOST_LEN: + if total > _SYNC_MAX_KEYTERMS_PROMPT_LEN: raise ValueError( - f"word_boost exceeds {_SYNC_MAX_WORD_BOOST_LEN} characters (got {total})" + f"keyterms_prompt exceeds {_SYNC_MAX_KEYTERMS_PROMPT_LEN} characters (got {total})" ) return terms or None @@ -3091,15 +3097,15 @@ def _normalize_conversation_context(cls, v): else: - @validator("word_boost") - def _normalize_word_boost(cls, v): + @validator("keyterms_prompt") + def _normalize_keyterms_prompt(cls, v): if not v: return None terms = [t.strip() for t in v if t and t.strip()] total = sum(len(t) for t in terms) - if total > _SYNC_MAX_WORD_BOOST_LEN: + if total > _SYNC_MAX_KEYTERMS_PROMPT_LEN: raise ValueError( - f"word_boost exceeds {_SYNC_MAX_WORD_BOOST_LEN} characters (got {total})" + f"keyterms_prompt exceeds {_SYNC_MAX_KEYTERMS_PROMPT_LEN} characters (got {total})" ) return terms or None @@ -3108,14 +3114,35 @@ def _normalize_conversation_context(cls, v): return _normalize_conversation_context(v) +class SyncWord(BaseModel): + """A single word in a sync transcript. + + `start`/`end` are in milliseconds and present only when the request set + `timestamps=True`; otherwise they are `None`. + """ + + text: str + "The text of the word." + + start: Optional[int] = None + "Word start in milliseconds. `None` unless `timestamps` was requested." + + end: Optional[int] = None + "Word end in milliseconds. `None` unless `timestamps` was requested." + + confidence: float + "Word confidence in the range 0-1." + + class SyncTranscriptResponse(BaseModel): """The result of a synchronous transcription request.""" text: str "The full transcript text." - words: List[Word] = Field(default_factory=list) - "Per-word timing and confidence." + words: List[SyncWord] = Field(default_factory=list) + """Per-word confidence, plus `start`/`end` timings when the request set + `timestamps=True`.""" confidence: float "Overall transcript confidence in the range 0-1." diff --git a/tests/unit/test_sync.py b/tests/unit/test_sync.py deleted file mode 100644 index 78013eb..0000000 --- a/tests/unit/test_sync.py +++ /dev/null @@ -1,432 +0,0 @@ -import httpx -import pytest -from pytest_httpx import HTTPXMock - -import assemblyai as aai - -aai.settings.api_key = "test" - -TRANSCRIBE_URL = f"{aai.settings.sync_base_url}/transcribe" -HEALTH_URL = f"{aai.settings.sync_base_url}/healthz" - -_OK_RESPONSE = { - "text": "hello world", - "words": [ - {"text": "hello", "start": 0, "end": 200, "confidence": 0.9}, - {"text": "world", "start": 220, "end": 400, "confidence": 0.95}, - ], - "confidence": 0.92, - "audio_duration_ms": 400, - "session_id": "eb92c4ff-4bbb-429f-9b99-7279d7fe738f", - "request_time_ms": 243.7, -} - - -def _mock_ok(httpx_mock: HTTPXMock) -> None: - httpx_mock.add_response( - url=TRANSCRIBE_URL, - method="POST", - status_code=httpx.codes.OK, - json=_OK_RESPONSE, - ) - - -def test_transcribe_bytes_parses_response(httpx_mock: HTTPXMock): - # Given a mocked sync endpoint - _mock_ok(httpx_mock) - - # When transcribing raw audio bytes - result = aai.SyncTranscriber().transcribe(b"RIFFfake-wav-bytes") - - # Then the response is parsed into a SyncTranscriptResponse - assert isinstance(result, aai.SyncTranscriptResponse) - assert result.text == "hello world" - assert result.session_id == _OK_RESPONSE["session_id"] - assert result.words[0].start == 0 - assert result.words[0].end == 200 - assert result.words[1].text == "world" - assert result.request_time_ms == 243.7 - - -def test_transcribe_parses_response_without_request_time(httpx_mock: HTTPXMock): - # Given a server response that predates the request_time_ms field - response = {k: v for k, v in _OK_RESPONSE.items() if k != "request_time_ms"} - httpx_mock.add_response( - url=TRANSCRIBE_URL, - method="POST", - status_code=httpx.codes.OK, - json=response, - ) - - # When transcribing - result = aai.SyncTranscriber().transcribe(b"RIFFfake-wav-bytes") - - # Then request_time_ms is None instead of a parse failure - assert result.request_time_ms is None - - -def test_transcribe_sends_model_header_and_wav_part(httpx_mock: HTTPXMock): - # Given a mocked sync endpoint - _mock_ok(httpx_mock) - - # When transcribing bytes with the default config - aai.SyncTranscriber().transcribe(b"RIFFfake-wav-bytes") - - # Then the request routes via X-AAI-Model and ships a WAV audio part - request = httpx_mock.get_requests()[0] - assert request.headers["X-AAI-Model"] == "u3-sync-pro" - body = request.read() - assert b'name="audio"' in body - assert b"Content-Type: audio/wav" in body - # And no config part is sent when the config is empty - assert b'name="config"' not in body - - -def test_transcribe_sends_prompt_and_word_boost(httpx_mock: HTTPXMock): - # Given a mocked sync endpoint - _mock_ok(httpx_mock) - - # When transcribing with a prompt and word_boost - config = aai.SyncTranscriptionConfig( - prompt="Transcribe verbatim.", - word_boost=["AssemblyAI", " Lemur ", ""], - ) - aai.SyncTranscriber().transcribe(b"RIFFfake-wav-bytes", config=config) - - # Then a config JSON part carries the prompt and normalized word_boost - body = httpx_mock.get_requests()[0].read() - assert b'name="config"' in body - assert b"Transcribe verbatim." in body - assert b'"AssemblyAI"' in body - assert b'"Lemur"' in body # whitespace stripped, empty term dropped - # And the routing model is never placed in the body - assert b'"model"' not in body - - -def test_transcribe_sends_conversation_context_list(httpx_mock: HTTPXMock): - # Given a mocked sync endpoint - _mock_ok(httpx_mock) - - # When transcribing with prior conversation turns (oldest first) - config = aai.SyncTranscriptionConfig( - conversation_context=[ - "I'd like to book a flight to Denver.", - " Sure, what date were you thinking? ", - "", - ], - ) - aai.SyncTranscriber().transcribe(b"RIFFfake-wav-bytes", config=config) - - # Then the config JSON part carries the turns, stripped with empties dropped - body = httpx_mock.get_requests()[0].read() - assert b'name="config"' in body - assert b'"conversation_context"' in body - assert b"I'd like to book a flight to Denver." in body - assert b"Sure, what date were you thinking?" in body - - -def test_transcribe_coerces_conversation_context_string(httpx_mock: HTTPXMock): - # Given a mocked sync endpoint - _mock_ok(httpx_mock) - - # When conversation_context is a bare string (single prior turn) - config = aai.SyncTranscriptionConfig( - conversation_context="Sure, what date were you thinking?" - ) - - # Then it is normalized to a one-turn list - assert config.conversation_context == ["Sure, what date were you thinking?"] - - # And it ships as a JSON array in the config part - aai.SyncTranscriber().transcribe(b"RIFFfake-wav-bytes", config=config) - body = httpx_mock.get_requests()[0].read() - assert b'"conversation_context"' in body - assert b'"Sure, what date were you thinking?"' in body - - -def test_conversation_context_trims_oldest_turns_over_char_cap(): - # Given conversation_context whose total length exceeds the 4096-char cap - config = aai.SyncTranscriptionConfig(conversation_context=["a" * 3000, "b" * 3000]) - - # Then the oldest turn is dropped and the most recent turn is kept - assert config.conversation_context == ["b" * 3000] - - -def test_conversation_context_trims_oldest_turns_over_turn_cap(): - # Given conversation_context with more than 100 turns - turns = [f"turn {i}" for i in range(120)] - config = aai.SyncTranscriptionConfig(conversation_context=turns) - - # Then it is trimmed to the 100 most recent turns, oldest dropped first - assert config.conversation_context == turns[20:] - - -def test_conversation_context_empties_when_single_turn_over_char_cap(): - # Given a single turn that alone exceeds the character cap - config = aai.SyncTranscriptionConfig(conversation_context=["a" * 5000]) - - # Then the context trims to nothing rather than raising - assert config.conversation_context is None - - -def test_transcribe_sends_single_language_code(httpx_mock: HTTPXMock): - # Given a mocked sync endpoint - _mock_ok(httpx_mock) - - # When transcribing with a single language code - config = aai.SyncTranscriptionConfig(language_code="es") - aai.SyncTranscriber().transcribe(b"RIFFfake-wav-bytes", config=config) - - # Then the config JSON part carries language_code as a bare string - body = httpx_mock.get_requests()[0].read() - assert b'name="config"' in body - assert b'"language_code": "es"' in body - - -def test_transcribe_sends_language_code_list(httpx_mock: HTTPXMock): - # Given a mocked sync endpoint - _mock_ok(httpx_mock) - - # When transcribing with multiple language codes - config = aai.SyncTranscriptionConfig(language_code=["en", "es"]) - aai.SyncTranscriber().transcribe(b"RIFFfake-wav-bytes", config=config) - - # Then the config JSON part carries language_code as a list - body = httpx_mock.get_requests()[0].read() - assert b'"language_code"' in body - assert b'"en"' in body - assert b'"es"' in body - - -def test_default_config_omits_language_code(httpx_mock: HTTPXMock): - # Given a default config (no language specified) - _mock_ok(httpx_mock) - - # When transcribing, Then no config part is sent and the server defaults - # the language to English - aai.SyncTranscriber().transcribe(b"RIFFfake-wav-bytes") - body = httpx_mock.get_requests()[0].read() - assert b'name="config"' not in body - - -def test_transcribe_pcm_sends_pcm_part_and_rate(httpx_mock: HTTPXMock): - # Given a mocked sync endpoint - _mock_ok(httpx_mock) - - # When transcribing bytes with sample_rate + channels (raw PCM) - config = aai.SyncTranscriptionConfig(sample_rate=16000, channels=1) - aai.SyncTranscriber().transcribe(b"\x00\x01" * 100, config=config) - - # Then the audio part is PCM and the config carries rate + channels - body = httpx_mock.get_requests()[0].read() - assert b"Content-Type: audio/pcm" in body - assert b'"sample_rate"' in body - assert b'"channels"' in body - - -def test_transcribe_pcm_without_rate_raises(): - # Given a config with sample_rate but no channels (partial PCM intent) - config = aai.SyncTranscriptionConfig(sample_rate=16000) - - # When transcribing, Then it fails locally before any request - with pytest.raises(ValueError, match="sample_rate and channels"): - aai.SyncTranscriber().transcribe(b"\x00\x01" * 100, config=config) - - -def test_transcribe_rejects_url(): - # Given an http URL as input - transcriber = aai.SyncTranscriber() - - # When transcribing, Then it is rejected with a pointer to Transcriber - with pytest.raises(ValueError, match="does not accept URLs"): - transcriber.transcribe("https://example.com/audio.wav") - - -def test_transcribe_path_input(httpx_mock: HTTPXMock, tmp_path): - # Given a local WAV file - _mock_ok(httpx_mock) - audio_file = tmp_path / "call.wav" - audio_file.write_bytes(b"RIFFfake-wav-bytes") - - # When transcribing the path - result = aai.SyncTranscriber().transcribe(str(audio_file)) - - # Then it succeeds and ships the file under its own name - assert result.text == "hello world" - body = httpx_mock.get_requests()[0].read() - assert b'filename="call.wav"' in body - - -def test_word_boost_too_long_raises(): - # Given a word_boost exceeding the 2048-char cap - # When building the config, Then validation fails immediately - with pytest.raises(ValueError, match="word_boost exceeds"): - aai.SyncTranscriptionConfig(word_boost=["x" * 3000]) - - -def test_problem_details_envelope_maps_to_sync_transcript_error( - httpx_mock: HTTPXMock, -): - # Given the server rejects oversized audio with a problem-details body - httpx_mock.add_response( - url=TRANSCRIBE_URL, - method="POST", - status_code=413, - json={"status": 413, "title": "Audio Too Large", "detail": "too long"}, - ) - - # When transcribing, Then a SyncTranscriptError carries the snake_cased - # title as error_code, plus the status and detail - with pytest.raises(aai.SyncTranscriptError) as exc_info: - aai.SyncTranscriber().transcribe(b"RIFFfake-wav-bytes") - - error = exc_info.value - assert error.status_code == 413 - assert error.error_code == "audio_too_large" - assert "too long" in str(error) - - -def test_legacy_error_envelope_maps_to_sync_transcript_error( - httpx_mock: HTTPXMock, -): - # Given a server still on the pre-problem-details envelope - httpx_mock.add_response( - url=TRANSCRIBE_URL, - method="POST", - status_code=413, - json={"error_code": "audio_too_large", "message": "too long"}, - ) - - # When transcribing, Then a SyncTranscriptError carries code + status - with pytest.raises(aai.SyncTranscriptError) as exc_info: - aai.SyncTranscriber().transcribe(b"RIFFfake-wav-bytes") - - error = exc_info.value - assert error.status_code == 413 - assert error.error_code == "audio_too_large" - assert "too long" in str(error) - - -def test_rate_limit_surfaces_retry_after(httpx_mock: HTTPXMock): - # Given a rate-limit response with a Retry-After header - httpx_mock.add_response( - url=TRANSCRIBE_URL, - method="POST", - status_code=429, - json={ - "status": 429, - "title": "Too Many Requests", - "detail": "Too many requests", - }, - headers={"Retry-After": "5"}, - ) - - # When transcribing, Then retry_after and the snake_cased title are parsed - with pytest.raises(aai.SyncTranscriptError) as exc_info: - aai.SyncTranscriber().transcribe(b"RIFFfake-wav-bytes") - - error = exc_info.value - assert error.status_code == 429 - assert error.error_code == "too_many_requests" - assert error.retry_after == 5 - - -def test_legacy_detail_only_envelope(httpx_mock: HTTPXMock): - # Given an auth-style body with only a detail field - httpx_mock.add_response( - url=TRANSCRIBE_URL, - method="POST", - status_code=401, - json={"detail": "Invalid API key"}, - ) - - # When transcribing, Then the detail becomes the message and error_code - # stays absent - with pytest.raises(aai.SyncTranscriptError) as exc_info: - aai.SyncTranscriber().transcribe(b"RIFFfake-wav-bytes") - - error = exc_info.value - assert error.status_code == 401 - assert error.error_code is None - assert "Invalid API key" in str(error) - - -def test_default_model_is_u3_sync_pro(): - # Given a default config - # When inspecting the model - # Then it is the sync U3-Pro identifier - assert aai.SyncTranscriptionConfig().model == "u3-sync-pro" - assert aai.SyncSpeechModel.u3_sync_pro.value == "u3-sync-pro" - - -def test_warm_opens_connection_with_model_header(httpx_mock: HTTPXMock): - # Given a mocked health endpoint - httpx_mock.add_response(url=HEALTH_URL, method="GET", status_code=httpx.codes.OK) - - # When warming the transcriber - warmed = aai.SyncTranscriber().warm() - - # Then it returns True and routes the probe via X-AAI-Model - assert warmed is True - request = httpx_mock.get_requests()[0] - assert request.url == HEALTH_URL - assert request.method == "GET" - assert request.headers["X-AAI-Model"] == "u3-sync-pro" - - -def test_warm_uses_configured_model(httpx_mock: HTTPXMock): - # Given a transcriber pinned to a specific model - httpx_mock.add_response(url=HEALTH_URL, method="GET", status_code=httpx.codes.OK) - config = aai.SyncTranscriptionConfig(model="some-other-model") - - # When warming - aai.SyncTranscriber(config=config).warm() - - # Then the warm probe carries that model so it lands on the right backend - assert httpx_mock.get_requests()[0].headers["X-AAI-Model"] == "some-other-model" - - -def test_warm_returns_true_on_non_200(httpx_mock: HTTPXMock): - # Given a health route that the load balancer answers with a 404 - httpx_mock.add_response(url=HEALTH_URL, method="GET", status_code=404) - - # When warming, Then the socket is still established, so warm() is True - assert aai.SyncTranscriber().warm() is True - - -def test_warm_returns_false_on_transport_error(httpx_mock: HTTPXMock): - # Given the sync host is unreachable - httpx_mock.add_exception(httpx.ConnectError("connection refused")) - - # When warming, Then the failure is swallowed and reported as False - assert aai.SyncTranscriber().warm() is False - - -def test_context_manager_returns_self_and_closes(): - # Given a transcriber used as a context manager - with aai.SyncTranscriber() as transcriber: - # Then the bound value is the transcriber itself - assert isinstance(transcriber, aai.SyncTranscriber) - - # And leaving the block shuts the worker pool down - assert transcriber._executor._shutdown is True - - -def test_keepalive_expiry_defaults_to_httpx_default(): - # Given a default config - # When inspecting keepalive_expiry - # Then it is None, leaving httpx's own default in place - assert aai.Settings().keepalive_expiry is None - - -def test_client_accepts_custom_keepalive_expiry(): - # Given a client configured with a longer keepalive - from assemblyai import client as client_mod - - # When constructed, Then it builds cleanly (the value reaches httpx.Limits) - # and round-trips on settings - client = client_mod.Client( - settings=aai.Settings(api_key="k", keepalive_expiry=120.0) - ) - assert client.settings.keepalive_expiry == 120.0 - assert client.http_client is not None