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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,12 @@ for word in result.words:

The input can be a local file path, raw `bytes`, or a binary file object — but not a URL. Pass a path/bytes, or use `aai.Transcriber` for URL ingestion.

Every entry point here opens the same connection: the audio is uploaded as a
stream, and the service transcribes each speech segment as it lands. A clip
you already have is simply a stream whose bytes are all ready at once, so
`transcribe()` is the ergonomic shape rather than a different request. Audio
must be WAV or raw PCM.

</details>

<details>
Expand Down
5 changes: 4 additions & 1 deletion assemblyai/sync/v1/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,10 @@ def transcribe(
audio_content_type=content_type,
model=config.model,
config=_config_to_json(config),
timeout=self._client.settings.sync_http_timeout,
# The live timeout: this rides the same streamed connection, whose
# per-operation shape bounds each socket write and read rather than
# the request end to end.
timeout=self._client.settings.sync_live_http_timeout,
)

def transcribe_live(
Expand Down
54 changes: 26 additions & 28 deletions assemblyai/sync/v1/api.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import json
from typing import Dict, Iterator, Optional, Tuple
from typing import Iterator, Optional

import httpx

Expand All @@ -8,8 +7,14 @@

# Canonical paths since the sync API gained a /v1 prefix (#18103); the
# unprefixed routes remain served for SDK versions that predate it.
#
# `/v1/transcribe/live` is the one endpoint this client posts audio to. It is
# also served at `/v1/transcribe/stream`, the path it shipped under, so an
# older deployment still answers there — but `live` is its name.
ENDPOINT_TRANSCRIBE_LIVE = "/v1/transcribe/live"
# The buffered endpoint. Still served, and still exported through the legacy
# `assemblyai.sync_api` shim, but nothing here requests it.
ENDPOINT_TRANSCRIBE = "/v1/transcribe"
ENDPOINT_TRANSCRIBE_STREAM = "/v1/transcribe/stream"
ENDPOINT_WARM = "/v1/warm"
MODEL_HEADER = "X-AAI-Model"

Expand Down Expand Up @@ -72,7 +77,13 @@ def transcribe(
timeout: float,
) -> types.SyncTranscriptResponse:
"""
Posts a single synchronous transcription request.
Posts a transcription request for audio that is already complete.

Sends it over the same live connection `transcribe_live` uses, as a single
chunk: audio that already exists is a stream whose bytes are all ready at
once. There is one request shape in this client, so a complete clip and a
live capture reach the service the same way, and the server transcribes
each speech segment as it lands either way.

Args:
client: the HTTP client (carries the `Authorization` header).
Expand All @@ -81,37 +92,24 @@ def transcribe(
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.
config: the JSON `config` part, or None for an empty one.
timeout: per-operation 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},
return transcribe_live(
client,
base_url=base_url,
chunks=(audio,),
filename=filename,
audio_content_type=audio_content_type,
model=model,
config=config,
timeout=timeout,
)

if response.status_code != httpx.codes.OK:
raise _error_from_response(response)

return types.SyncTranscriptResponse.parse_obj(response.json())


def transcribe_live(
client: httpx.Client,
Expand Down Expand Up @@ -173,7 +171,7 @@ def body() -> Iterator[bytes]:
yield encoder.closing()

response = client.post(
base_url.rstrip("/") + ENDPOINT_TRANSCRIBE_STREAM,
base_url.rstrip("/") + ENDPOINT_TRANSCRIBE_LIVE,
content=body(),
headers={MODEL_HEADER: model, "Content-Type": encoder.content_type},
timeout=timeout,
Expand Down
54 changes: 14 additions & 40 deletions assemblyai/sync/v1/async_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,14 @@
`SyncTranscriptError` through `api._error_from_response`.
"""

import json
from typing import AsyncIterator, Dict, Optional, Tuple
from typing import AsyncIterator, Optional

import httpx

from ... import types
from ._multipart import AsyncAudioChunks, StreamingMultipartEncoder, aiter_chunks
from .api import (
ENDPOINT_TRANSCRIBE,
ENDPOINT_TRANSCRIBE_STREAM,
ENDPOINT_TRANSCRIBE_LIVE,
MODEL_HEADER,
_error_from_response,
)
Expand All @@ -33,46 +31,22 @@ async def transcribe(
timeout: float,
) -> types.SyncTranscriptResponse:
"""
Posts a single synchronous transcription request.
Posts a transcription request for audio that is already complete.

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.
The asyncio counterpart of `api.transcribe`: the same single-chunk send
over the live connection, which is the only one this client opens.
"""
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 = await client.post(
base_url.rstrip("/") + ENDPOINT_TRANSCRIBE,
files=files,
headers={MODEL_HEADER: model},
return await transcribe_live(
client,
base_url=base_url,
chunks=(audio,),
filename=filename,
audio_content_type=audio_content_type,
model=model,
config=config,
timeout=timeout,
)

if response.status_code != httpx.codes.OK:
raise _error_from_response(response)

return types.SyncTranscriptResponse.parse_obj(response.json())


async def transcribe_live(
client: httpx.AsyncClient,
Expand Down Expand Up @@ -123,7 +97,7 @@ async def body() -> AsyncIterator[bytes]:
yield encoder.closing()

response = await client.post(
base_url.rstrip("/") + ENDPOINT_TRANSCRIBE_STREAM,
base_url.rstrip("/") + ENDPOINT_TRANSCRIBE_LIVE,
content=body(),
headers={MODEL_HEADER: model, "Content-Type": encoder.content_type},
timeout=timeout,
Expand Down
3 changes: 2 additions & 1 deletion assemblyai/sync/v1/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,8 @@ async def transcribe(
audio_content_type=content_type,
model=config.model,
config=_config_to_json(config),
timeout=self._client.settings.sync_http_timeout,
# The live timeout: this rides the same streamed connection.
timeout=self._client.settings.sync_live_http_timeout,
)

async def transcribe_live(
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/test_dx.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@

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_TRANSCRIBE_URL = f"{aai.settings.sync_base_url}/v1/transcribe/live"

_SYNC_OK_RESPONSE = {
"text": "hello world",
Expand Down
15 changes: 9 additions & 6 deletions tests/unit/test_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

aai.settings.api_key = "test"

TRANSCRIBE_URL = f"{aai.settings.sync_base_url}/v1/transcribe"
TRANSCRIBE_URL = f"{aai.settings.sync_base_url}/v1/transcribe/live"
WARM_URL = f"{aai.settings.sync_base_url}/v1/warm"

_OK_RESPONSE = {
Expand Down Expand Up @@ -78,8 +78,10 @@ def test_transcribe_sends_model_header_and_wav_part(httpx_mock: HTTPXMock):
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
# And an empty config part still goes out, ahead of the audio: the endpoint
# decodes the audio as it arrives and will not start without one.
assert body.index(b'name="config"') < body.index(b'name="audio"')
assert b'Content-Type: application/json\r\n\r\n{}\r\n' in body


def test_transcribe_sends_prompt_and_keyterms_prompt(httpx_mock: HTTPXMock):
Expand Down Expand Up @@ -210,11 +212,12 @@ 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
# When transcribing, Then the config part carries no language and the
# server defaults it to English
aai.SyncTranscriber().transcribe(b"RIFFfake-wav-bytes")
body = httpx_mock.get_requests()[0].read()
assert b'name="config"' not in body
assert b'Content-Type: application/json\r\n\r\n{}\r\n' in body
assert b"language_code" not in body


def test_transcribe_sends_timestamps_flag(httpx_mock: HTTPXMock):
Expand Down
Loading
Loading