Skip to content

feat(dictation): add the Dictation API with the live upload as its only connection - #247

Merged
ccampbell-aai merged 8 commits into
masterfrom
ccampbell/dictation-sdk
Sep 10, 2026
Merged

ccampbell-aai merged 8 commits into
masterfrom
ccampbell/dictation-sdk

Conversation

@ccampbell-aai

@ccampbell-aai ccampbell-aai commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Adds the Dictation API (dictation.assemblyai.com) to the SDK as assemblyai.dictation.v1, with top-level shortcuts under aai.*.

What's new

A dictation client for short spoken notes. Audio is uploaded as it is spoken over a single connection to POST /v1/transcribe/live, the service transcribes each speech segment as it lands, and the result comes back as one transcript with an optional LLM rewrite. There is no job id and no polling.

import assemblyai as aai

config = aai.DictationConfig(sample_rate=16000, channels=1, llm_instruction="Format as a SOAP note.")

with aai.DictationTranscriber() as transcriber:
    with transcriber.open_live(config) as session:
        start_capture(on_audio=session.write)   # any callback: sounddevice, WebRTC, telephony
        wait_until_done()
    print(session.result().final_text)

Clients

  • DictationTranscriber(*, client=None, config=None, max_workers=None, api_key=None)
    • open_live(config=None) -> DictationLiveSession — push-style session for callback-driven audio. The request starts on a worker thread the moment the session opens. write() is thread-safe and never blocks; close() ends the audio; result() waits for the transcript; abort() drops the request. As a context manager it closes on a clean exit and aborts if the block raised.
    • transcribe_live(data, config=None) — pull-style. Takes an iterable of chunks, a binary file object read as it fills, raw bytes, or a local path. Complete audio is sent as a single chunk over the same connection.
    • warm() opens the connection ahead of time; close() shuts the worker pool; context manager; config property.
  • AsyncDictationTranscriber(*, client=None, config=None, api_key=None) — the asyncio counterpart. transcribe_live() also accepts async iterables; open_live() -> AsyncDictationLiveSession runs the request as a task on the running loop, with write()/close() as plain functions and result()/abort() as coroutines. warm(), aclose(), async context manager.

Models

  • DictationConfig — sample_rate, channels (both required only for raw PCM; WAV carries them in its header), language_codes, keyterms_prompt (≤2048 chars total), llm_instruction (≤2048 chars). Unknown fields are rejected.
  • DictationResponse — text, words (DictationWord: text, confidence), confidence, llm_response, llm_error, audio_duration_ms, session_id, request_time_ms, sync_time_ms, plus final_text, which returns the LLM rewrite when there is one and the raw transcript otherwise. Unknown response fields are ignored.
  • DictationError — status_code, error_code (snake_cased problem-details title, e.g. audio_too_large), retry_after on 429/503. Raised for any non-200, including rejections the server sends while the upload is still in flight.
  • AudioSource / AsyncAudioSource — the accepted input types.

Wire behaviour

  • multipart/form-data with chunked transfer encoding, the config part always ahead of the audio part (an empty object when nothing is set).
  • Content-Type chosen from the extension or the PCM fields: audio/pcm for .pcm/.raw or when sample_rate/channels are set, audio/wav by default, and the true container type for other known extensions.
  • Auth is the raw API key. No X-AAI-Model header; the service picks the model. URLs are not accepted.

Settings

  • dictation_base_url (default https://dictation.assemblyai.com, overridable via ASSEMBLYAI_DICTATION_BASE_URL).
  • dictation_http_timeout (default 300 s, per socket operation like every httpx timeout).

Shared helpers

  • assemblyai/_multipart.py — the streaming multipart encoder and chunk iterators, used by both the dictation and sync live clients.
  • assemblyai/_audio.py — shared audio-input reading, format resolution and config serialization for the single-request products.

Docs

  • README: two rows in "Choosing a transcriber", a "Dictation Examples" section (microphone via open_live(), iterator/file/bytes/path via transcribe_live(), LLM rewrite, config, warm(), asyncio, errors), and the dictation settings in the defaults block.
  • CLAUDE.md class list updated.

Validation

  • Unit tests for both clients, the sessions, the multipart framing and the shared helpers; the suite passes on Python 3.9–3.12 and on the httpx 0.22/0.24 and pydantic 1.10 floors. Ruff and mypy clean.
  • Verified against prod: transcribe_live(path), a threaded open_live() session paced at real time, and an asyncio session all return the expected transcript with llm_response populated.

Version bumps to 1.5.0. Draft until the Dictation API is public.

🤖 Generated with Claude Code

ccampbell-aai and others added 6 commits September 10, 2026 11:44
Adds the Dictation API (dictation.assemblyai.com) as a first-class product
alongside the sync API, in assemblyai/dictation/v1/ mirroring sync/v1/.

Public surface:
  aai.DictationTranscriber(*, client=None, config=None, max_workers=None,
      api_key=None) -> transcribe(), transcribe_async(), warm(), close(),
      context manager, config property
  aai.AsyncDictationTranscriber(*, client=None, config=None, api_key=None)
      -> await transcribe(), await warm(), await aclose(), async context
      manager
  aai.DictationConfig(sample_rate, channels, language_codes,
      keyterms_prompt, llm_instruction) — rejects unknown fields
  aai.DictationResponse(text, words, confidence, llm_response, llm_error,
      audio_duration_ms, session_id, request_time_ms, sync_time_ms) plus
      final_text, which prefers the LLM rewrite
  aai.DictationWord(text, confidence)
  aai.DictationError(status_code, error_code, retry_after)

The request posts multipart/form-data with the config part ahead of the
audio part, authenticates with the raw API key, and sends no model routing
header — the server picks the model. Endpoints are /v1/transcribe and
/v1/warm under settings.dictation_base_url, with a 300s
settings.dictation_http_timeout.

Audio-input handling both single-request products share moves to
assemblyai/_audio.py: reading path/bytes/file-object input, choosing the
multipart Content-Type, and serializing the config part. sync/v1/_base.py
keeps the same names and signatures over it, and the sync API's wire
behavior is unchanged — every container still posts as WAV. Dictation maps
the common container extensions to their own Content-Types.
Error-envelope parsing is shared too, so dictation raises DictationError
from the same RFC 9457, {error, error_code}, and {detail} bodies.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every dictation entry point now posts to POST /v1/transcribe/live, the one
route the Dictation API markets, with the config part ahead of the audio
and the body chunked so the request starts before the audio exists. The
buffered transcribe()/transcribe_async() and the /v1/transcribe constant
are removed; a test pins that nothing requests the buffered route.

Public surface, mirroring the sync API's live work (#241/#244/#245):
  DictationTranscriber.transcribe_live(data, config=None) — an iterable
      of chunks, a binary file object read as it fills, raw bytes, or a
      local path (complete audio goes over the same connection as a
      single chunk; there is no transcribe() to steer it to)
  DictationTranscriber.open_live(config=None) -> DictationLiveSession —
      push-style: write()/close()/result()/abort(), context manager; the
      request runs on the transcriber's worker pool from the moment the
      session opens
  AsyncDictationTranscriber.transcribe_live() / open_live() ->
      AsyncDictationLiveSession — same, as a task on the running loop
  warm() unchanged. AudioSource / AsyncAudioSource replace the dictation
  AudioInput export; sessions are exported from assemblyai.dictation and
  assemblyai.dictation.v1.

Plumbing: the streaming multipart encoder and chunk iterators live in a
new product-neutral assemblyai/_multipart.py (sync's copy on #241 should
import it once that lands); _audio.resolve_format() decides filename and
Content-Type without reading bytes. settings.dictation_http_timeout stays
300 s and is documented as per-socket-operation, as every httpx timeout is.

Verified against prod with the quickstart harness: path, threaded session
and asyncio session all return the same transcript as the buffered route
did, with ~0.5 s from last byte to result on a paced 8.7 s clip.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…loop tick

The asyncio open_live() test asserted the transport had been reached after
a single sleep(0), but transcribe_live() first resolves the source in a
worker thread, so on a slower runner the task is still awaiting that when
the assertion runs. Poll for the signal with a bounded wait.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The httpx 0.22/0.24 tox envs pull an older pytest-httpx that has no
is_reusable and does not read an async request's stream, so a body read
after the call hits a spent stream. Register one response per request
instead, and have the asyncio client-level tests inspect what reached the
transport rather than re-read the recorded body; the framing itself is
still asserted through MockTransport.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ell/dictation-sdk

Keeps the shared _audio helpers for sync's _resolve_audio and adds
master's resolve_format() as a wrapper over _audio.resolve_format so
stream_filename() keeps its helper; merges the typing imports in
sync/v1/api.py; keeps both sync_live_http_timeout and the dictation
settings. Version to 1.7.0 on top of master's 1.6.0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The merge left two identical copies of the streaming multipart encoder:
sync/v1/_multipart.py from #241 and the product-neutral
assemblyai/_multipart.py the dictation client uses. Sync now imports the
shared one, including _Aborted, and its own copy is removed. No behaviour
change; the sync live tests import _STREAM_READ_SIZE from the shared path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@ccampbell-aai
ccampbell-aai force-pushed the ccampbell/dictation-sdk branch from 375ecd4 to 3b8ff35 Compare September 10, 2026 19:34
Master is at 1.4.0 (the sync live stack squash-merged with only #241's
bump surviving), so 1.7.0 skipped two versions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@ccampbell-aai
ccampbell-aai marked this pull request as ready for review September 10, 2026 21:21
…Examples section

Two rows in Choosing a transcriber, a Dictation Examples section that
mirrors the sync one (open_live() from a microphone, transcribe_live()
from an iterator/file object/bytes/path, llm_instruction and final_text,
config, warm(), asyncio, errors), and the dictation settings in the
defaults block.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@ccampbell-aai
ccampbell-aai merged commit efc202e into master Sep 10, 2026
8 checks passed
@ccampbell-aai
ccampbell-aai deleted the ccampbell/dictation-sdk branch September 10, 2026 23:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants