Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 19 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 —
Expand All @@ -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)
```
Expand All @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions assemblyai/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
SyncTranscriptError,
SyncTranscriptionConfig,
SyncTranscriptResponse,
SyncWord,
Timestamp,
TranscriptError,
TranscriptionConfig,
Expand Down Expand Up @@ -148,6 +149,7 @@
"SyncTranscriptError",
"SyncTranscriptionConfig",
"SyncTranscriptResponse",
"SyncWord",
"Timestamp",
"Transcriber",
"TranscriptionConfig",
Expand Down
2 changes: 1 addition & 1 deletion assemblyai/__version__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.64.26"
__version__ = "0.64.29"
22 changes: 22 additions & 0 deletions assemblyai/sync/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
19 changes: 19 additions & 0 deletions assemblyai/sync/v1/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
106 changes: 106 additions & 0 deletions assemblyai/sync/v1/_base.py
Original file line number Diff line number Diff line change
@@ -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,
)
106 changes: 106 additions & 0 deletions assemblyai/sync/v1/api.py
Original file line number Diff line number Diff line change
@@ -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())
Loading
Loading