Contents
Audio Round Trip
A workflow that speaks a phrase, transcribes the audio back to text, and checks whether it survived the trip — entirely inside PostgreSQL.
phrase (text)
│
├─ df.http → Azure OpenAI /audio/speech → MP3 (audio/mpeg)
│
├─ df.http_multipart → Azure OpenAI /audio/transcriptions → {"text": "..."}
│
└─ SQL → store transcript, compare, record verdict
Why this example exists
Most HTTP examples move JSON between steps. This one moves binary between steps, which is where composition actually gets tested:
- The speech call returns
audio/mpeg. pg_durable detects a non-textualContent-Type, base64-encodes the bytes into the response envelope, and setsencodingtobase64. Decoding it as UTF-8 instead would silently replace every invalid byte sequence withU+FFFDand destroy the MP3. - The transcription call uploads that audio as
multipart/form-data, taking the payload straight from the previous response:
'data_b64', '$speech.body'
The audio never passes through a SQL node. No query string ever contains it, and it is never widened by SQL quoting.
The form carries three parts, not just the file:
modeland an optional Whisperpromptride alongside the binaryfile. Theprompttext is 75 bytes, andencode(..., 'base64')wraps anything over 57 bytes onto multiple lines — so that part also demonstrates that wrapped base64 is accepted.The workflow verifies itself. A silent corruption produces a wrong transcript, not a green run.
Prerequisites
- pg_durable built with the
http-allow-azure-domainsfeature - The
dfrole permissions to use HTTP:sql SELECT df.grant_usage('your_role', include_http => true); - Azure CLI, logged in (
az login)
Setup
cd examples/audio-roundtrip
# Creates a resource group, an Azure OpenAI account, and the two deployments,
# then writes .audio-roundtrip.env (mode 600, git-ignored).
./scripts/provision_azure.sh
# Optional: confirm the live API behaves as the example assumes.
./scripts/live_smoke_check.sh
set -a && source .audio-roundtrip.env && set +a
psql -d postgres -f sql/01_schema.sql
psql -d postgres -f sql/02_set_vars.sql
psql -d postgres -f sql/03_seed_phrases.sql
psql -d postgres -f sql/04_start_workflow.sql
Then watch it run, and check the result:
psql -d postgres -f sql/05_monitor.sql # re-run while it works
psql -d postgres -f sql/06_verify.sql # raises an exception if it did not work
To run it again: psql -d postgres -f sql/07_reset.sql, then re-run
04_start_workflow.sql.
To tear down Azure: ./scripts/cleanup_azure.sh.
Region
The account must be in a region offering both tts and whisper. Fewer
regions do than you might expect:
| Region | tts |
whisper |
|---|---|---|
northcentralus (default) |
yes | yes |
swedencentral (alternative) |
yes | yes |
westeurope |
no | yes |
eastus2 |
only gpt-4o-mini-tts (GlobalStandard) |
yes |
To use the alternative:
LOCATION=swedencentral ./scripts/provision_azure.sh
Check any other region before trying it:
az cognitiveservices model list -l <region> \
--query "[?model.name=='tts' || model.name=='whisper'].model.name" -o tsv
Things this example had to account for
Whisper does not return your phrase verbatim, or even consistently. Sending
pg durable makes workflows durable returned PgDurable makes workflows
durable. on one run and PG Durable makes workflows durable. on the next —
same input, same deployment, different word boundaries. Adding the prompt
part shifted it again, to pg durable makes workflows durable. Add to that the
casing change and the trailing period, and comparing raw strings would report a
correct transcription as a failure. demo.audio_normalize() reduces both
sides to letters and digits before comparing. Exact-match verification here
would be flaky, not strict.
Whisper is limited to 3 requests per minute on a Standard deployment, and
the quota is granted per subscription, not per deployment. The workflow paces
itself with df.sleep(20) between phrases. That is a durable timer: the worker
is free during the wait, and the delay survives a restart.
A 429 is not an error, as far as the activity is concerned. pg_durable
returns Err only for 5xx; every 4xx comes back as a completed activity
carrying ok: false. Without handling, a rate-limited run would report
completed while transcribing nothing. The workflow branches on
$speech.ok and records speech_status / transcribe_status, so a throttled
run is visibly different from a clean one.
Phrases cannot contain " or \. The phrase is substituted into a JSON
request body, and df.http performs raw substitution without JSON escaping. A
CHECK constraint in 01_schema.sql rejects those characters at insert time
rather than letting the workflow fail later against an upstream 400.
The API reference is wrong about /audio/speech
For 2025-04-01-preview, the request-body table says multipart/form-data
while the worked example immediately below it posts JSON. They imply different
DSL calls.
JSON is correct — verified against a live deployment, and re-verifiable with
./scripts/live_smoke_check.sh. That is why the speech step uses df.http
rather than df.http_multipart.
Files
| File | Purpose |
|---|---|
sql/01_schema.sql |
Tables and the normalization function |
sql/02_set_vars.sql |
Build request URLs, store the key via df.setvar |
sql/03_seed_phrases.sql |
Insert the phrases |
sql/04_start_workflow.sql |
The workflow |
sql/05_monitor.sql |
Progress |
sql/06_verify.sql |
Assertions; raises on failure |
sql/07_reset.sql |
Reset for another run |
scripts/provision_azure.sh |
Create Azure resources, write the env file |
scripts/cleanup_azure.sh |
Delete them |
scripts/smoke_check.sh |
Offline, CI-safe checks |
scripts/live_smoke_check.sh |
Live API probe |
Cost
Pay-per-use with no idle charge: TTS bills per character, Whisper per
audio-minute. The three seeded phrases are short, so a full run costs a
fraction of a cent. cleanup_azure.sh removes everything.