stt · azstt · oaistt · Rust · one core, two providers

stt

stt is a speech-to-text command and daemon. Give it a recording and it prints the transcript; run it with no arguments and it listens to the microphone, printing each sentence as you finish it. The same core works with Azure's MAI transcription and any OpenAI-compatible endpoint, in batch or over realtime sessions, and the daemon lets a machine without keys transcribe through one that has them.

stt meeting.m4a                         # any ffmpeg-readable file → one line
stt                                     # microphone → live lines
stt --mic --partials                    # the line in progress on stderr
stt --stream lecture.wav --json         # NDJSON events as they happen
stt --remote studio                     # microphone here, keys there

Batch or live

What you give stt decides how it works: a file is a batch request by default, the microphone is always a live session, and --stream makes a file behave like the microphone. Output is only ever the transcript, or JSON you asked for, on stdout; partials, errors and diagnostics go to stderr and never contain keys, audio or response bodies.

Batch or streaming How stt chooses what to do. Input is a path (a dash means stdin), otherwise piped stdin, otherwise the microphone through pacat. Files and stdin are batch by default: decoded once to 16 kHz mono WAV, at most 25 MiB or about thirteen minutes, sent as one request, printed as one transcript line or a JSON receipt. With --stream, and always for the microphone, PCM goes to a realtime WebSocket session at the model's rate, the provider's own voice activity detection cuts it into turns, and completed segments print in commit order as they finish, with partial text on stderr or as JSON events. a path- means stdin piped stdinraw PCM or any container microphonepacat --record batchffmpeg → 16 kHz WAV≤ 25 MiB · one request streamingrealtime WebSocketprovider VAD · commits one lineor a JSON receipt segments as they finishpartials on stderror NDJSON events --stream
One decode, one request; or one session, many segments. Neither path emulates the other.
stt --print-request --provider openai
{"content_type":"multipart/form-data","fields":{"model":"gpt-4o-mini-transcribe","response_format":"json"},
 "file":"audio.wav (audio/wav)","method":"POST","url":"https://api.openai.com/v1/audio/transcriptions"}

--print-request shows exactly what would be sent, with no key, no audio and no network.

Turn detection

Deciding when someone has finished a sentence is the hardest part of live transcription, and stt deliberately doesn't do it. It asks the provider's server-side voice activity detection to cut speech into turns, tunable by threshold, silence and prefix length, and prints each turn when the provider commits it. It never runs its own VAD and never fakes streaming by chopping audio into batch requests.

stt --print-request --provider openai --stream
{"url":"wss://api.openai.com/v1/realtime?model=gpt-realtime-whisper&intent=transcription",
 "rate":24000,"model":"gpt-realtime-whisper","auth_header":"authorization",
 "session_update":{"type":"session.update","session":{"type":"transcription","audio":{"input":{
   "format":{"type":"audio/pcm","rate":24000},"noise_reduction":null,
   "transcription":{"model":"gpt-realtime-whisper"},"turn_detection":{"type":"server_vad"}}}}}}
A realtime transcription session The messages of one realtime transcription session as stt sends them. It opens a WebSocket to the provider's realtime URL with intent=transcription and sends session.update with type transcription, 16-bit PCM at the model's rate, the transcription model and server voice activity detection. Audio is appended in chunks, 100 milliseconds from a microphone. The provider detects the end of a turn, commits it, streams text deltas or intermediate partials and then a completed transcript, which stt prints. At the end of input stt commits whatever remains and waits up to its timeout for outstanding segments. Models without turn detection, such as gpt-realtime-whisper, fall back before any audio to one commit at the end of input and still stream text as it arrives. GET wss://…/realtime?intent=transcription1 session.update {type: transcription,audio/pcm @ rate, model, server_vad}2 input_audio_buffer.append × N (100 ms each)3 input_audio_buffer.speech_started … speech_stopped4 input_audio_buffer.committed5 …input_audio_transcription.delta / .intermediate6 …input_audio_transcription.completed7 print in commit order--delimiter between segments8 input_audio_buffer.commit (end of input) · wait ≤ 20 s9 sttclientsttclient providerrealtime endpointproviderrealtime endpoint
The provider hears the pause. Text arrives as deltas while you speak; a turn prints when it completes, in commit order.

Some realtime models, gpt-realtime-whisper and mai-transcribe-2-streaming among them, reject turn detection. For those, stt falls back before sending any audio to a single commit at the end of input; they still stream text while you talk. --vad server insists on detection, semantic asks for semantic turns, and none always commits once.

Providers

OpenAI-compatible (oaistt)Azure MAI (azstt)
Batchmultipart POST …/v1/audio/transcriptionsJSON POST …/mai/v1/audio/transcriptions, WAV as a data URL
Batch modelgpt-4o-mini-transcribemai-transcribe-2 (a deployment name)
Realtime…/v1/realtime?model=M&intent=transcription…/mai/v1/realtime?intent=transcription
Realtime modelgpt-realtime-whispermai-transcribe-2-streaming
Rate24 kHz; 16 kHz for mai-transcribe* through a proxy16 kHz
CredentialsBearer OPENAI_API_KEYapi-key, or a Microsoft Entra token

Unsupported options fail instead of disappearing: MAI has no prompt field, so --prompt with Azure is an error, not a no-op. Unpinned, stt tries Azure and then OpenAI; batch fallback tries each configured provider once within the deadline, and streaming fallback only covers connecting before any audio. An explicitly chosen provider never falls back.

Remote transcription

stt daemon holds the provider keys; clients need its URL and a bearer token. Batch requests carry an ID, so a lost reply is recovered by asking for the receipt (kept for 120 seconds) rather than paying to transcribe the same audio twice. Live sessions are relayed with event cursors.

Streaming through the daemon How stt --remote streams through the stt daemon, which holds the provider keys. The client opens a session, then appends base64 PCM, each append carrying the cursor of the last event it has seen and returning any newer events. Between appends it long-polls for events after its cursor, bounded by a wait. Closing ends input and returns the final events. The cursor makes every call safe to retry, and the daemon never replays audio after a failure. stt.stream.open {vad, rate?}1 session.update2 session id · model · rate3 stt.stream.append {session, after: 4, audio}4 append5 events6 events after 4 → 5, 67 stt.stream.events {after: 6, wait_ms}8 stt.stream.close {after: 6}9 final events · closed10 stt --remotemicrophone herestt --remotemicrophone here stt daemonkeys therestt daemonkeys there providerrealtimeproviderrealtime
Cursors make retries safe. Each call says which events it has already seen; nothing is persisted and audio is never replayed.