DEV Community

Mart Schweiger
Mart Schweiger

Posted on • Originally published at assemblyai.com

Build a Dictation App With the Sync API (Tutorial)

Dictation feels like it should be a solved problem. You press a key, you talk, the words show up. But if you've ever tried to build it, you know the first version is almost always disappointing — you speak, then you wait, and the text lands a beat too late to feel like your own voice.

That lag is almost never the model. It's the plumbing around it.

In this tutorial we'll build a small dictation app — we're calling it Blurt — on top of AssemblyAI's Sync API. You hold a hotkey, speak a sentence or two, release, and the transcript drops straight into whatever text field you're in. Then we'll do the part most tutorials skip: cut the perceived latency roughly in half with a connection pre-warm trick, so "test, test, test" comes back in around 300ms instead of feeling like a round trip to the moon.

By the end you'll have a working app and a clear mental model for why the Sync API is the right tool for short, interactive speech — and where it isn't.

Why dictation doesn't want streaming

The instinct is to reach for streaming. Dictation is real-time-ish, streaming is real-time, case closed.

But look at what dictation actually is: short bursts of speech, one utterance at a time, with a hard start and a hard stop that the user controls by pressing and releasing a key. You don't need partial transcripts scrolling across the screen. You don't need the app guessing when you've finished talking — you told it, when you let go of the key. And you don't want to manage a persistent WebSocket, reconnection logic, and streaming state for what is, functionally, a series of two-second clips.

Streaming solves a problem dictation doesn't have (deciding when the speaker is done) and adds infrastructure dictation doesn't want (a stateful connection). What you actually want is dead simple: send a short clip over HTTP, get the text back in one response, right now.

That's the Sync API. One HTTP request, one clip up to 2 minutes, one response with the transcript — no polling, no webhooks, ~134ms p50 latency, and the same accuracy you already get from async transcription. It sits between async (submit a job, poll or wait for a webhook) and streaming (open a socket, manage a live session): the speed of real-time with the simplicity of a single request.

What we're building

Blurt is a desktop dictation utility. The flow is:

  1. Hold a global hotkey to start recording.
  2. Speak.
  3. Release the key to stop.
  4. The clip goes to the Sync API over HTTP.
  5. The transcript is typed into the active application.

We'll build it in Python so the whole thing stays readable in one file. The same pattern ports cleanly to a browser extension, an Electron app, or a native menu-bar tool — the interesting part is the request pattern, not the UI shell.

Prerequisites: Python 3.9+, an AssemblyAI API key (grab one free), and a microphone. We'll use sounddevice to capture audio, pynput to listen for the hotkey and type the result, and requests for the API call:

pip install sounddevice pynput requests numpy
Enter fullscreen mode Exit fullscreen mode

Step 1: Capture audio while a key is held

First, record audio for exactly as long as the hotkey is down. We buffer raw PCM frames and stop the moment the key comes up.

import sounddevice as sd
import numpy as np
import threading

SAMPLE_RATE = 16000  # 16 kHz mono is plenty for speech

class Recorder:
    def __init__(self):
        self._frames = []
        self._stream = None
        self._lock = threading.Lock()

    def _callback(self, indata, frames, time, status):
        with self._lock:
            self._frames.append(indata.copy())

    def start(self):
        self._frames = []
        self._stream = sd.InputStream(
            samplerate=SAMPLE_RATE,
            channels=1,
            dtype="int16",
            callback=self._callback,
        )
        self._stream.start()

    def stop(self) -> bytes:
        self._stream.stop()
        self._stream.close()
        with self._lock:
            audio = np.concatenate(self._frames, axis=0) if self._frames else
np.array([], dtype="int16")
        return audio.tobytes()
Enter fullscreen mode Exit fullscreen mode

Nothing exotic here — we grab 16 kHz mono int16 audio, which is the sweet spot for speech recognition: small payloads, no meaningful accuracy loss versus higher sample rates.

Step 2: Send the clip to the Sync API

Here's the core of the app. We take the recorded bytes, wrap them as a WAV, and POST them in a single request. Because it's the Sync API, the transcript comes back in that same response — there's no transcript ID to poll. See the Sync API docs for the full request and response format.

import io
import wave
import requests

API_KEY = "YOUR_ASSEMBLYAI_API_KEY"

# Send audio as multipart/form-data with an "audio" part; model set via header.
SYNC_ENDPOINT = "https://sync.assemblyai.com/transcribe"

def pcm_to_wav(pcm_bytes: bytes, sample_rate: int = SAMPLE_RATE) -> bytes:
    buf = io.BytesIO()
    with wave.open(buf, "wb") as wf:
        wf.setnchannels(1)
        wf.setsampwidth(2)  # int16 = 2 bytes
        wf.setframerate(sample_rate)
        wf.writeframes(pcm_bytes)
    return buf.getvalue()

def transcribe_sync(pcm_bytes: bytes, session: requests.Session) -> str:
    wav_bytes = pcm_to_wav(pcm_bytes)
    response = session.post(
        SYNC_ENDPOINT,
        headers={"Authorization": API_KEY, "X-AAI-Model": "u3-sync-pro"},
        files={"audio": ("clip.wav", wav_bytes, "audio/wav")},
        timeout=10,
    )
    response.raise_for_status()
    return response.json()["text"]
Enter fullscreen mode Exit fullscreen mode

One request in, one transcript out. No while transcript.status != "completed" loop, no webhook receiver to stand up. For clips up to 2 minutes, this is the whole integration.

Step 3: Wire up the hotkey and type the result

Now connect recording to the key and push the transcript into whatever app has focus:

from pynput import keyboard

recorder = Recorder()
kb_controller = keyboard.Controller()
session = requests.Session()  # reused — this matters in Step 4

HOTKEY = keyboard.Key.f9
is_recording = False

def on_press(key):
    global is_recording
    if key == HOTKEY and not is_recording:
        is_recording = True
        recorder.start()

def on_release(key):
    global is_recording
    if key == HOTKEY and is_recording:
        is_recording = False
        pcm = recorder.stop()
        text = transcribe_sync(pcm, session)
        kb_controller.type(text + " ")

with keyboard.Listener(on_press=on_press, on_release=on_release) as listener:
    print("Blurt is running. Hold F9 to dictate.")
    listener.join()
Enter fullscreen mode Exit fullscreen mode

That's a working dictation app. Hold F9, say "the quick brown fox," release, and it types into your editor. But run it a few times and you'll feel it: there's a small hitch between releasing the key and seeing text. Let's kill it.

Step 4: The pre-warm trick that halves perceived latency

Here's the thing about a single HTTP request: the model isn't your only cost. Before the audio even reaches the transcription step, the client has to do DNS resolution, open a TCP connection, and complete the TLS handshake. On a cold connection that setup can rival the transcription itself — and it all happens after the user has finished speaking, which is exactly when they're waiting.

So move it earlier. The moment the user presses the hotkey — before they've said a word — fire a tiny warm-up request. That opens the connection and completes the handshake while the user is still talking. By the time they release the key and the real clip is ready, you're reusing a hot connection and paying only for transcription.

We already created a persistent requests.Session(), which reuses the underlying TCP/TLS connection across requests. We just need to warm it at key-down:

def prewarm(session: requests.Session):
    """Open the connection while the user is still speaking."""
    try:
        session.head(SYNC_ENDPOINT, timeout=2)
    except requests.RequestException:
        pass  # warm-up is best-effort; never block dictation on it

def on_press(key):
    global is_recording
    if key == HOTKEY and not is_recording:
        is_recording = True
        recorder.start()
        threading.Thread(target=prewarm, args=(session,), daemon=True).start()
Enter fullscreen mode Exit fullscreen mode

In informal testing across a handful of runs, this roughly halved the end-to-end time for a short phrase — dictating "test, test, test" landed around 300ms instead of noticeably longer on a cold connection. These aren't formal benchmark numbers, but the effect is easy to feel: the transcript shows up while your finger is still lifting off the key.

The lesson generalizes. When you're optimizing an interactive speech feature, don't just look at model latency. Look at everything that happens between "user stops talking" and "text appears," and move as much of it as you can to before the user stops talking.

Step 5: Test it

  • Short clips first. Try one- and two-word phrases ("yes," "next slide") and a full sentence. Short utterances are where latency is most noticeable, so they're the real test.
  • Punctuation and casing. The Sync API returns formatted text, so "email me at sam at example dot com" should come back cleanly. Dictation lives or dies on this.
  • Cold vs. warm. Comment out the prewarm call and feel the difference. This is the whole point of the tutorial — make sure you can feel it too.
  • Clips over 2 minutes. Sync is built for short audio. If your users might hold the key for minutes, that's a signal you want async or streaming instead.

[VIDEO: embed Blurt demo screencast here]

Where to go from here

Blurt is intentionally small, but it's a real foundation. A few natural next steps:

  • Post-processing. Pipe the transcript through an LLM for light cleanup — fix "um"s, apply a formatting style, or translate on the fly. Keep it optional and off the hot path so latency-sensitive users aren't paying for a feature they didn't ask for.
  • Custom vocabulary. If your users dictate names, product terms, or medical language, prime the model so those come back right the first time.
  • Know when to switch tiers. Sync is the right call for short, interactive, user-triggered speech. If you find yourself wanting live partial transcripts as the user speaks, or handling clips longer than two minutes, that's your cue to look at streaming or async instead. We break down that decision in our guide to real-time vs batch transcription.

The reason dictation is worth getting right is that it's one of the few places where users feel your speech-to-text directly, with no interface in between. When the text lands the instant they stop talking, it feels like the app is reading their mind. When it lags, it feels broken. The model was never the hard part — the request pattern was, and now you have it.

Frequently asked questions

What is the best API for building a dictation app?

For dictation, a synchronous HTTP transcription API is usually the best fit because dictation is short, user-triggered speech with a clear start and stop. AssemblyAI's Sync API takes one short clip in a single HTTP request and returns a finished, formatted transcript in the same response at roughly 134 ms p50 — no WebSocket, no polling. Streaming and async APIs solve different problems (live sessions and long files, respectively), which is why they add plumbing a dictation app doesn't need.

Why use a sync HTTP API instead of streaming for dictation?

Dictation gives you a hard start and stop that the user controls with a key, so you don't need streaming's partial transcripts or its end-of-turn detection — the two things streaming is built for. A sync HTTP call avoids managing a persistent WebSocket, reconnection logic, and streaming state for what is functionally a series of two-second clips. You send the clip and get the text back in one request, which is both simpler to build and fast enough to feel instant.

How do I reduce latency in a dictation app?

The biggest hidden cost isn't the model — it's the DNS lookup, TCP connection, and TLS handshake that happen after the user stops speaking. Move that work earlier: the moment the user presses the hotkey, fire a small warm-up request over a reused HTTP session so the connection is already open by the time the clip is ready. In informal testing this roughly halved end-to-end time on short phrases, landing "test, test, test" around 300 ms.

How fast is the Sync API for short dictation clips?

The Sync API returns a finished transcript for a short clip in roughly 134 ms at the median (p50), in a single request/response with no polling or webhooks. Because you control when the clip ends, there's no turn-detection step to wait on — the transcript comes back essentially as fast as the model can produce it. Pairing that with a connection pre-warm makes the text appear the instant the user releases the key.

What are the Sync API's limits, and what does it cost?

The Sync API accepts clips from 80 ms up to 2 minutes (40 MB max) across 18 languages, runs on the flagship Universal-3.5 Pro model, and returns formatted text with punctuation and casing. It's priced at $0.45/hr of audio, with keyterms prompting and conversation context included and no rate limits. For anything longer than two minutes, use the pre-recorded (async) API instead.

When should I use streaming or async instead of sync for voice input?

Use streaming when you need live partial transcripts as the user speaks — an open-microphone experience or a real-time voice agent that reacts mid-sentence. Use async (batch) when you're transcribing long recordings where completeness and full-file speech understanding matter more than instant turnaround. Use sync for short, user-triggered clips like dictation, voice commands, and push-to-talk, where you want a finished transcript back immediately over one HTTP request.

Top comments (0)