diff --git a/README.md b/README.md index 12c5efaa..e4119bfa 100644 --- a/README.md +++ b/README.md @@ -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. +
diff --git a/assemblyai/sync/v1/_base.py b/assemblyai/sync/v1/_base.py index 31d2554a..8507a1c9 100644 --- a/assemblyai/sync/v1/_base.py +++ b/assemblyai/sync/v1/_base.py @@ -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( diff --git a/assemblyai/sync/v1/api.py b/assemblyai/sync/v1/api.py index 559de78d..8eb18e1e 100644 --- a/assemblyai/sync/v1/api.py +++ b/assemblyai/sync/v1/api.py @@ -1,5 +1,4 @@ -import json -from typing import Dict, Iterator, Optional, Tuple +from typing import Iterator, Optional import httpx @@ -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" @@ -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). @@ -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, @@ -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, diff --git a/assemblyai/sync/v1/async_api.py b/assemblyai/sync/v1/async_api.py index b3a6d6b2..05f94978 100644 --- a/assemblyai/sync/v1/async_api.py +++ b/assemblyai/sync/v1/async_api.py @@ -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, ) @@ -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, @@ -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, diff --git a/assemblyai/sync/v1/async_client.py b/assemblyai/sync/v1/async_client.py index a910bd2f..a78c5aa2 100644 --- a/assemblyai/sync/v1/async_client.py +++ b/assemblyai/sync/v1/async_client.py @@ -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( diff --git a/tests/unit/test_dx.py b/tests/unit/test_dx.py index 3a350cab..49109a23 100644 --- a/tests/unit/test_dx.py +++ b/tests/unit/test_dx.py @@ -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", diff --git a/tests/unit/test_sync.py b/tests/unit/test_sync.py index fa1a6553..8ab98a3a 100644 --- a/tests/unit/test_sync.py +++ b/tests/unit/test_sync.py @@ -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 = { @@ -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): @@ -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): diff --git a/tests/unit/test_sync_async.py b/tests/unit/test_sync_async.py index f0fa8e9a..99dc1a89 100644 --- a/tests/unit/test_sync_async.py +++ b/tests/unit/test_sync_async.py @@ -10,7 +10,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 = { @@ -35,6 +35,37 @@ def _mock_ok(httpx_mock: HTTPXMock) -> None: ) +class _Captured(list): + """Bodies recorded as the requests were sent, in order.""" + + @property + def first(self) -> bytes: + return self[0] + + +def _capture(transcriber, *, status: int = httpx.codes.OK) -> _Captured: + """Records each request body while the request is still open. + + The buffered entry points stream their body like every other call, and a + streamed body can only be read once — `pytest_httpx` records the request + without reading it on older httpx, so a read after the call sees a spent + iterator. Reading inside the transport is the same approach + `test_sync_live.py` takes for its body-shape assertions. + """ + captured = _Captured() + + async def handler(request: httpx.Request) -> httpx.Response: + captured.append(await request.aread()) + captured.headers = request.headers + return httpx.Response(status, json=_OK_RESPONSE) + + transcriber.client._http_client = httpx.AsyncClient( + transport=httpx.MockTransport(handler), + headers=transcriber.client.http_client.headers, + ) + return captured + + async def test_transcribe_bytes_parses_response(httpx_mock: HTTPXMock): # Given a mocked sync endpoint _mock_ok(httpx_mock) @@ -53,38 +84,37 @@ async def test_transcribe_bytes_parses_response(httpx_mock: HTTPXMock): assert result.request_time_ms == 243.7 -async 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 +async def test_transcribe_sends_model_header_and_wav_part(): + # Given a transcriber whose transport records the body as it is sent async with aai.AsyncSyncTranscriber() as transcriber: + captured = _capture(transcriber) + + # When transcribing bytes with the default config await transcriber.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"] == "universal-3-5-pro" - body = request.read() + assert captured.headers["X-AAI-Model"] == "universal-3-5-pro" + body = captured.first 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 -async def test_transcribe_sends_config_part(httpx_mock: HTTPXMock): - # Given a mocked sync endpoint - _mock_ok(httpx_mock) - +async def test_transcribe_sends_config_part(): # When transcribing with a prompt and keyterms_prompt config = aai.SyncTranscriptionConfig( prompt="Transcribe verbatim.", keyterms_prompt=["AssemblyAI"], ) async with aai.AsyncSyncTranscriber() as transcriber: + captured = _capture(transcriber) await transcriber.transcribe(b"RIFFfake-wav-bytes", config=config) # Then a config JSON part carries the options - body = httpx_mock.get_requests()[0].read() + body = captured.first assert b'name="config"' in body assert b"Transcribe verbatim." in body assert b'"AssemblyAI"' in body @@ -92,15 +122,12 @@ async def test_transcribe_sends_config_part(httpx_mock: HTTPXMock): assert b'"model"' not in body -async def test_transcribe_uses_default_config_and_per_call_override( - httpx_mock: HTTPXMock, -): +async def test_transcribe_uses_default_config_and_per_call_override(): # Given a transcriber with a default config - _mock_ok(httpx_mock) - _mock_ok(httpx_mock) default = aai.SyncTranscriptionConfig(prompt="default prompt") async with aai.AsyncSyncTranscriber(config=default) as transcriber: + captured = _capture(transcriber) # When transcribing without a per-call config await transcriber.transcribe(b"RIFFfake-wav-bytes") # And with a per-call override @@ -108,22 +135,20 @@ async def test_transcribe_uses_default_config_and_per_call_override( await transcriber.transcribe(b"RIFFfake-wav-bytes", config=override) # Then the default applies to the first call and the override to the second - first, second = (request.read() for request in httpx_mock.get_requests()) + first, second = captured assert b"default prompt" in first assert b"override prompt" in second -async def test_transcribe_pcm_sends_pcm_part_and_rate(httpx_mock: HTTPXMock): - # Given a mocked sync endpoint - _mock_ok(httpx_mock) - +async def test_transcribe_pcm_sends_pcm_part_and_rate(): # When transcribing bytes with sample_rate + channels (raw PCM) config = aai.SyncTranscriptionConfig(sample_rate=16000, channels=1) async with aai.AsyncSyncTranscriber() as transcriber: + captured = _capture(transcriber) await transcriber.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() + body = captured.first assert b"Content-Type: audio/pcm" in body assert b'"sample_rate"' in body assert b'"channels"' in body @@ -147,20 +172,19 @@ async def test_transcribe_rejects_url(): await transcriber.transcribe("https://example.com/audio.wav") -async def test_transcribe_path_input(httpx_mock: HTTPXMock, tmp_path): +async def test_transcribe_path_input(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 async with aai.AsyncSyncTranscriber() as transcriber: + captured = _capture(transcriber) result = await transcriber.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 + assert b'filename="call.wav"' in captured.first async def test_transcribe_gather_runs_concurrently(httpx_mock: HTTPXMock): diff --git a/tests/unit/test_sync_live.py b/tests/unit/test_sync_live.py index 4a5e7363..510ba49e 100644 --- a/tests/unit/test_sync_live.py +++ b/tests/unit/test_sync_live.py @@ -22,7 +22,7 @@ aai.settings.api_key = "test" -STREAM_URL = f"{aai.settings.sync_base_url}/v1/transcribe/stream" +STREAM_URL = f"{aai.settings.sync_base_url}/v1/transcribe/live" _OK_RESPONSE = { "text": "hello world", @@ -830,3 +830,61 @@ def test_async_open_live_needs_a_running_loop(): # When a session is opened, then the mistake is named rather than deferred with pytest.raises(RuntimeError, match="no running event loop"): transcriber.open_live() + + +def test_complete_audio_goes_over_the_live_connection(httpx_mock: HTTPXMock): + """`transcribe()` opens the same connection `transcribe_live()` does. + + There is one request shape in this client: a complete clip is a stream + whose bytes happen to all be ready, so it takes the streamed endpoint and + the chunked framing rather than a second, buffered path. + """ + httpx_mock.add_response(url=STREAM_URL, json=_OK_RESPONSE) + + aai.SyncTranscriber().transcribe(b"RIFFfake-wav-bytes") + + request = httpx_mock.get_requests()[0] + assert str(request.url) == STREAM_URL + # Chunked, not a declared length: the encoder frames an unsized iterator. + assert "content-length" not in request.headers + body = request.read() + assert body.index(b'name="config"') < body.index(b'name="audio"') + assert b"RIFFfake-wav-bytes" in body + + +@pytest.mark.asyncio +async def test_async_complete_audio_goes_over_the_live_connection( + httpx_mock: HTTPXMock, +): + httpx_mock.add_response(url=STREAM_URL, json=_OK_RESPONSE) + + async with aai.AsyncSyncTranscriber() as transcriber: + await transcriber.transcribe(b"RIFFfake-wav-bytes") + + request = httpx_mock.get_requests()[0] + assert str(request.url) == STREAM_URL + assert "content-length" not in request.headers + + +def test_the_buffered_endpoint_is_never_requested(httpx_mock: HTTPXMock): + """No entry point on this client posts to `/v1/transcribe`. + + The path is still exported for the legacy `assemblyai.sync_api` surface, + and the service still serves it — but nothing here sends to it, so a + mocked buffered endpoint goes unused whichever way audio is submitted. + """ + # One registration per call rather than a reusable one: the `is_reusable` + # kwarg postdates the oldest pytest-httpx the matrix tests against. + for _ in range(4): + httpx_mock.add_response(url=STREAM_URL, json=_OK_RESPONSE) + + transcriber = aai.SyncTranscriber() + transcriber.transcribe(b"RIFFfake-wav-bytes") + transcriber.transcribe_async(b"RIFFfake-wav-bytes").result() + transcriber.transcribe_live([b"RIFFfake", b"-wav-bytes"]) + with transcriber.open_live(aai.SyncTranscriptionConfig()) as session: + session.write(b"RIFFfake-wav-bytes") + session.result() + + requested = {str(request.url) for request in httpx_mock.get_requests()} + assert requested == {STREAM_URL}