Recipe: Speech, Transcription and Media Questions¶
Goal¶
Use the one-shot multimedia calls end to end: speak a sentence to a WAV file (or stream it as it is generated, including an agent's answer), transcribe it back (whole and streamed), ask a model a question about the recording, look at a file locally without a model, and generate and then edit an image — reading the cost of each call as you go.
Every function here lives in doc_processing_toolkit.multimedia, validates its options against the
registry before sending anything, and returns a plain dict rather than raising on a provider failure.
Multimedia explains the rules; this page is the walkthrough.
Prerequisites¶
OPENAI_API_KEYforgpt-4o-mini-tts,gpt-transcribeandgpt-image-2(the organisation must be verified for GPT Image models), andGOOGLE_GEMINI_API_KEYforgemini-3.5-flash-liteand the Gemini 3.8 TTS rows. Keys are read from.envat the repo root.- Optional: the
[audio]extra, for transcribing containers a provider does not accept (see Audio the provider does not accept).
The snippets run top to bottom as one script; the file written in the first step is read by the later ones.
Speech out: save a WAV¶
from doc_processing_toolkit.multimedia import synthesize_speech
speech = synthesize_speech(
"gpt-4o-mini-tts",
"The invoice total is four hundred and twelve dollars.",
voice="coral",
output_format="wav",
instructions="Speak calmly and clearly.",
output_path="invoice_total.wav",
)
print(speech["output_path"], speech["mime_type"]) # invoice_total.wav audio/wav
print(speech["cost"], speech["cost_estimated"]) # 0.0011964 False
voice and output_format are checked against the row's supported_voices and
supported_output_formats, so a typo fails before the request. cost_estimated is False: the
toolkit asks OpenAI for the audio as an SSE stream, whose last event carries the provider's own token
counts, and joins the audio chunks back into one clip. DPT_OPENAI_TTS_SSE_USAGE=off restores the
plain audio request, whose cost is estimated from the clip length (cost_estimated True). Gemini TTS
rows report real token counts.
Style, dialogue and custom voices on Gemini 3.8¶
styled = synthesize_speech(
"gemini-3.8-flash-lite-tts",
"The invoice total is four hundred and twelve dollars. <short pause> Thank you.",
voice="Kore",
instructions="Warm and unhurried.",
)
print(styled["mime_type"], styled["cost_estimated"]) # audio/wav False
dialogue = synthesize_speech(
"gemini-3.8-flash-tts",
"Joe: Did the invoice arrive?\nJane: Yes, this morning.\nI filed it straight away.",
multi_speaker_config=[{"speaker": "Joe", "voice": "Puck"}, {"speaker": "Jane", "voice": {"id": "en-us-advisor-1"}}],
output_path="invoice_dialogue.wav",
)
The Gemini 3.8 rows speak the text exactly as written, so the toolkit sends instructions as the
part's style instead of prepending them: transcribing styled returns only the sentence. <short pause>
is one of the inline tags these rows understand. In the dialogue, each Name: line starts a turn, the
line without a label continues Jane's turn, and the labels themselves are not spoken. A voice can be a
prebuilt name or {"id": ...} from Google's Voices API. output_format="pcm" returns the raw samples
(audio/l16;rate=24000) instead of a WAV.
Speak as it is generated¶
synthesize_speech_stream yields the audio in chunks while the rest is still being made, so playback
can start in about a second instead of after the whole clip:
import wave
from doc_processing_toolkit.multimedia import synthesize_speech_stream
with wave.open("streamed.wav", "wb") as out:
out.setnchannels(1)
out.setsampwidth(2)
out.setframerate(24000)
for frame in synthesize_speech_stream(
"gemini-3.8-flash-lite-tts", "The invoice total is four hundred and twelve dollars.", voice="Kore"
):
if frame["type"] == "audio":
out.writeframes(frame["data"]) # or hand it to a player as it arrives
elif frame["type"] == "done":
print(frame["cost"], frame["cost_estimated"], frame["chunks"])
A Google stream is raw 24 kHz 16-bit mono PCM (audio/l16;rate=24000); on OpenAI ask for
output_format="pcm" to get the same shape. The cost arrives in the done frame. On Google a
streamed clip reports more audio tokens than the same clip made with synthesize_speech (see
Streaming speech), so stream for playback, not for
files.
Speak an agent's answer as it streams¶
An agent does not stream speech itself: its multimedia tools are unary. For a voice UI, stream the agent's text, cut it into sentences, and speak each sentence as soon as it is complete. The first sentence plays while the agent is still writing the rest.
import asyncio
import re
from contextlib import aclosing
from doc_processing_toolkit import Agent
from doc_processing_toolkit.multimedia import asynthesize_speech_stream
SENTENCE_END = re.compile(r"(?<=[.!?])\s+")
async def speak(sentence: str, play) -> float:
cost = 0.0
async with aclosing(asynthesize_speech_stream("gpt-4o-mini-tts", sentence, output_format="pcm")) as frames:
async for frame in frames:
if frame["type"] == "audio":
await play(frame["data"])
elif frame["type"] == "done":
cost = frame["cost"]
return cost
async def answer_aloud(agent: Agent, question: str, play) -> float:
buffer, total = "", 0.0
async for event in agent.stream(question):
if event.event_type == "text_delta" and not event.metadata: # prose, not a JSON payload
buffer += event.content
*finished, buffer = SENTENCE_END.split(buffer)
for sentence in finished:
total += await speak(sentence, play)
elif event.event_type == "complete":
total += event.metadata["cost"] # the agent run itself
if buffer.strip():
total += await speak(buffer, play)
return total
Each sentence is its own TTS request with its own cost, so the function adds them to the run's cost.
Here speech and text alternate; to keep reading the agent's stream while a sentence plays, put the
sentences on an asyncio.Queue and speak them from a second task.
An agent with tools, like most, streams its answer as prose. An agent with no tools answers
through the structured-output path, where the deltas are a JSON payload tagged
metadata == {"structured_output": True}; the check above skips them, and that agent's answer is
event.metadata["data"] on complete, to speak in one piece.
Look at a file without a model¶
from doc_processing_toolkit.multimedia import inspect_media
facts = inspect_media("invoice_total.wav")
facts["kind"], facts["mime_type"], facts["duration_s"] # ('audio', 'audio/wav', 4.05)
models = inspect_media("invoice_total.wav", include_models=True)["accepted_by"]
# every registry row whose input_types accept audio
inspect_media makes no network call. It reads the container, the size, image dimensions or a WAV
duration from the bytes themselves, and with include_models=True lists the rows that would take the
file — the cheap way to answer "can I send this to that model?" before paying to find out.
Speech in: transcribe a recording¶
from doc_processing_toolkit.multimedia import transcribe_audio
text = transcribe_audio("gpt-transcribe", "invoice_total.wav")
print(text["text"]) # The invoice total is four hundred and twelve dollars.
print(text["duration_minutes"]) # 0.0833...
print(text["duration_source"]) # 'provider'
print(text["cost"], text["cost_estimated"]) # 0.000375 False
gpt-transcribe is billed per minute, so the clip length is the price, and duration_source says
where the length came from. The precedence is fixed: "provider" (the model's own measurement),
then "caller" (a duration_seconds you pass), then "wav_header", then "byte_estimate" — and
only the last one sets cost_estimated.
Pass duration_seconds whenever you know the length. It does not override a provider measurement —
gpt-transcribe reports its billed length, so the source stays "provider" below — but on a model
or a response that reports none it replaces the byte heuristic, which under-counts a compressed
browser recording about tenfold:
from pathlib import Path
raw = Path("invoice_total.wav").read_bytes()
text = transcribe_audio("gpt-transcribe", raw, duration_seconds=4)
print(text["duration_source"], text["cost_estimated"]) # provider False
See Transcription duration for the full table.
Speaker labels come from gpt-4o-transcribe-diarize. For a clip longer than 30 seconds OpenAI
requires a chunking strategy. The row supplies "auto" for you, and a provider_options value
replaces it:
meeting = transcribe_audio("gpt-4o-transcribe-diarize", "standup.wav", diarization=True)
# chunking_strategy="auto" went out with the request; prompt= is refused on this model
tuned = transcribe_audio(
"gpt-4o-transcribe-diarize",
"standup.wav",
diarization=True,
provider_options={"chunking_strategy": {"type": "server_vad", "silence_duration_ms": 500}},
)
Stream the transcript¶
import asyncio
from doc_processing_toolkit.multimedia import atranscribe_audio_stream
async def stream():
async for frame in atranscribe_audio_stream("gpt-transcribe", raw):
if frame["type"] == "delta":
print(frame["text"], end="", flush=True)
elif frame["type"] == "done":
print()
print(frame["cost"], frame["duration_source"]) # 0.000375 provider
asyncio.run(stream())
Frames are delta, segment, an optional error, and exactly one done that carries the same
keys transcribe_audio returns. transcribe_audio(..., stream=True) consumes the same stream into
one result dict instead. Streaming is about the output: the recording must already exist. For live
microphone capture, open a
transcription session.
Streaming is gated on the registry's supports_streaming_transcription: gpt-transcribe and
gpt-4o-transcribe-diarize have it, whisper-1 and gemini-3.5-transcribe do not and are refused
locally.
Audio the provider does not accept¶
A browser usually records WebM/Opus or MP4, and not every provider accepts those containers. When the
sniffed container is not on the model's accepted list, transcribe_audio transcodes it and reports
converted_from. The converter is optional, so an install stays lean:
pip install "doc-processing-toolkit[audio]" # a bundled ffmpeg via imageio-ffmpeg
from doc_processing_toolkit.audio_convert import converter_available
converter_available() # 'ffmpeg', 'pyav', or None
Discovery order is DPT_FFMPEG_PATH (an explicit path to an ffmpeg binary), then ffmpeg on
PATH, then the binary the [audio] extra ships, then PyAV. With none of them present, an
unaccepted container raises MultimediaValidationError naming the extra instead of reaching the
provider as a 400. DPT_AUDIO_CONVERT_TIMEOUT and DPT_AUDIO_CONVERT_MAX_MB bound one transcode — see
Configuration.
Ask a model about a file¶
from doc_processing_toolkit.multimedia import describe_media
answer = describe_media(
"gemini-3.5-flash-lite",
"invoice_total.wav",
"What amount of money is mentioned? Answer with the number only.",
)
print(answer["text"]) # 412
print(answer["cost"], answer["cost_estimated"])
print(answer["media"]["kind"]) # 'audio' — the inspect_media record
print(answer["duration_s"]) # wall-clock seconds the call took, not the clip length
describe_media checks the file's kind against the model's input_types before uploading it, so
a PDF sent to an image-only row fails locally instead of being billed. Google's video knobs
(start_offset, end_offset, fps, media_resolution) raise on any other provider rather than
being dropped.
Generate and edit an image¶
from doc_processing_toolkit.multimedia import edit_image, generate_image, image_size_for
size = image_size_for("gpt-image-2", aspect_ratio="3:2", target="1K") # '1248x832', no API call
image = generate_image(
"gpt-image-2",
"A flat icon of a paper invoice with a green tick",
size=size,
quality="low",
output_dir="images",
)
print(image["paths"]) # ['images/generated_image_YYYYMMDD_HHMMSS_ffffff_1.png']
print(image["cost"]) # 0.004105
print(image["cost_breakdown"]) # named lines that sum to cost
edited = edit_image(
"gpt-image-2",
"Make the tick red",
image=image["images"][0],
size=size,
quality="low",
output_dir="images",
)
print(edited["cost"]) # 0.012182 — the source image is billed as image input
image_size_for solves a size the model accepts: GPT Image 2 publishes a dimension rule (multiples
of 16, ratio at most 3:1, a pixel range) rather than a preset list, and the helper returns the
closest valid WIDTHxHEIGHT for a target budget of "1K", "2K" or "4K". On a preset model it
returns the nearest listed preset. Gemini image rows take a resolution class (size="1K") plus
aspect_ratio instead of pixels.
Read the returned paths rather than predicting file names. Every image result carries
input_tokens, output_tokens and a cost_breakdown (cost_input_text_usd,
cost_input_image_usd, cost_text_usd, cost_image_usd, cost_cache_discount_usd) that sums to
cost.
A named parameter beats provider_options on the image calls
If you set the same key both ways, generate_image / edit_image keep the named parameter and
log the discarded passthrough value; synthesize_speech / transcribe_audio let
provider_options win. See
A named parameter beats provider_options.
To keep editing the same picture across turns — or across processes — use an
ImageSession instead of chaining edit_image calls.
Errors come back in the result¶
Each result carries error, error_code and retryable. A provider failure does not raise:
if image["error"]:
print(image["error_code"], image["retryable"])
moderation_blocked is never retryable; empty_response, timeout, connection_error, a 429 and
any 5xx are. Invalid options — an unknown voice, a size outside the rule, a mask on a Google model —
raise MultimediaValidationError before any request.
The same calls over MCP¶
python -m doc_processing_toolkit.mcp_servers.multimedia_server exposes these capabilities as MCP
tools, so an agent can use them without importing the toolkit. Each tool is a thin wrapper around the
Python call above, with the same validation and the same cost fields.
| Python call | MCP tool | What changes at the JSON boundary |
|---|---|---|
synthesize_speech |
synthesize_speech |
multi_speaker_config arrives as multi_speaker_config_json; the audio is written to a file and the result carries output_path, never the bytes |
transcribe_audio |
transcribe_audio |
the recording is audio_path or audio_base64; list options are *_json strings (custom_vocabulary_json, languages_json, keywords_json); the transcript is also saved as JSON, transcript_path |
atranscribe_audio_stream |
— | not exposed: a tool call returns once; transcribe_audio(stream=True) is the tool form |
generate_image |
generate_image |
output_compression=-1 means "unset"; the images are written to files and the result carries paths |
edit_image |
edit_image |
images are image_path / image_base64 (or image_paths_json / images_base64_json for several); the mask is mask_path / mask_base64 |
image_size_for |
image_size_for |
returns {size, model, error, error_code, retryable}; no API call |
describe_media, inspect_media |
— | Python only |
ImageSession |
— | Python only |
Validation failures come back as {"error": ...} results rather than exceptions, so a calling agent
can read the limitation and adapt. See Multimedia MCP Server.
Since 0.5.1 the tools write files and never return base64 (a model can neither hear nor see it, and it
floods the context). The folder is the call's output_dir, else DPT_MULTIMEDIA_OUTPUT_DIR, else
<system temp>/doc_processing_toolkit/multimedia (per-user on Linux and macOS); include_base64 is
accepted and ignored. See
Files, not base64.
To give an agent only some of the tools, list them in the tool entry's allowed_tools (0.5.1). For
example, {"type": "fastmcp", "server": multimedia_server, "allowed_tools": ["generate_image"]}
offers only generate_image. See
Choosing which tools a server exposes.
from doc_processing_toolkit.mcp_servers.multimedia_server import generate_image, transcribe_audio
image = generate_image(model="gpt-image-2", prompt="A lighthouse at dusk", output_dir="out/")
image["paths"], image["images_base64"] # ['out/generated_image_..._1.png'], []
notes = transcribe_audio(model="gpt-transcribe", audio_path="meeting.wav", output_dir="out/")
notes["text"], notes["transcript_path"] # the text, and 'out/transcript_gpt-transcribe_....json'
What to validate¶
speech["output_path"]exists andinspect_mediareads a non-zeroduration_sfrom it- the transcript matches the spoken sentence, and
duration_sourceis not"byte_estimate" - the streamed transcription's
doneframe carries the samecostastranscribe_audio - a streamed speech clip's
doneframe carries a cost. On OpenAI it matchessynthesize_speech. On Google it is higher: about 25% on the Gemini 3.8 rows (see Streaming speech) describe_mediaanswers from the audio, andanswer["media"]["kind"] == "audio"image["cost_breakdown"]values sum toimage["cost"], and the edit'scost_input_image_usdis non-zero
Test sources¶
tests/unit/test_multimedia.py,tests/unit/test_multimedia_provider_calls.pytests/unit/test_transcription_duration.py,tests/unit/test_transcription_gemini_and_streaming.pytests/unit/test_describe_media.py,tests/unit/test_multimedia_server.pytests/unit/test_tts_characterization.py,tests/unit/test_tts_gemini38_and_sse.pytests/unit/test_tts_stream.py,tests/unit/test_multimedia_file_output.pytests/integration/test_multimedia_live.py,tests/integration/test_multimedia_server_live.py
Related pages¶
- Multimedia — validation, the size solver, duration precedence, cost schemas
- Multi-turn Image Sessions — generate and keep editing across turns
- Realtime Voice Session — live speech, captions and interpretation
- MCP — the multimedia MCP server