Streaming
There are five flavours of "stream" in the Hakim API:
- Chunked TTS audio (HTTP).
POST /v1/audio/speechwith"stream": truereturns the synthesised audio as a chunked HTTP response, so playback can start before the whole sentence is finished. Same endpoint as the synchronous call; the body field is the only switch. - Realtime TTS (WebSocket).
GET /v1/audio/speech/streamupgrades to a long-lived WebSocket and lets you push manyspeech.createframes over a single TCP/TLS connection. Trades the ~7 ms per-request handshake cost for a persistent socket; pairs naturally with LLM-→-TTS pipelining (synthesise the next sentence while the LLM is still generating tokens). - Realtime STT (OpenAI-compatible WebSocket).
GET /v1/audio/transcriptions/streamupgrades to a long-lived WebSocket. Push microphone frames in, receive partial + final transcripts as they're decoded. Drop-in compatible with the OpenAI Realtime client SDKs; Arabic-first acoustic model (hakim-arab-v2). - Realtime translation (WebSocket).
GET /v1/audio/translate/streamchains STT → LLM → TTS behind one socket. Push microphone frames in, receive interim transcripts, translated text, and translated audio per utterance. Same auth, billing, and reconnect semantics as the other realtime endpoints. - Async results from long uploads. large STT files (> 25 MiB
or > 10 minutes) and voice clones return a job pointer. Track
completion via
/v1/jobs/{id}or a webhook.
The canonical contract for every endpoint below (request shape,
response codes, all enum values) lives at
api.tryhakim.ai/docs (interactive
Scalar reference) and api.tryhakim.ai/openapi.json
(machine-readable). When this guide and the OpenAPI disagree, the
OpenAPI wins.
Streaming TTS
There is no separate /stream endpoint for TTS. Streaming is a
flag on the standard POST /v1/audio/speech route: set
"stream": true in the request body and the server replies with
Transfer-Encoding: chunked, beginning to write audio as soon as
the first frame is ready (~150 ms target on the Hakim Fast v1.3
profile).
curl -N https://api.tryhakim.ai/v1/audio/speech \
-H "Authorization: Bearer $HAKIM_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "hakim-fast-v1",
"input": "مرحبا، كيف حالك اليوم؟",
"voice": "cmokbc2b1001pvu39wmj61b7h",
"response_format": "mp3",
"stream": true
}' \
--output - \
| ffplay -autoexit -nodisp -
The -N flag disables curl's response buffering so audio starts
playing as soon as the first frame arrives.
The voice field accepts a voice id (cuid form, recommended —
stable across renames) returned by GET /v1/audio/voices. Models:
hakim-fast-v1(Saree') · speed tier, the default. Streaming TTFB lands around 113 ms p95; use it for voicebots, IVR, in-game characters, anywhere the latency budget is the binding constraint.hakim-v2(Faseeh) · premium quality with non-verbal tag support; also streams, but the marketing pitch is naturalness, not speed.hakim-v3(Mulhim) · premium quality + voice design from a free-form prompt; streams via the same flag.
Streaming is supported on every tier. The first-chunk arrival time is a tier-level promise only for Saree'; Faseeh / Mulhim ship without a published baseline because their pitch is quality, not speed. See the models guide for the full ladder and the tier-picker recipe.
Node.js
The official @hakim/node SDK exposes streaming as
audio.speech.stream(...) (returns an AsyncIterable<Uint8Array>)
in addition to the buffered audio.speech.create(...) (returns the
full body):
import { HakimClient } from '@hakim/node';
const hakim = new HakimClient({ apiKey: process.env.HAKIM_API_KEY });
const { stream, contentType } = await hakim.audio.speech.stream({
model: 'hakim-fast-v1',
input: 'مرحبا، كيف حالك اليوم؟',
voice: 'cmokbc2b1001pvu39wmj61b7h',
response_format: 'mp3',
});
console.log('streaming', contentType);
for await (const chunk of stream) {
// Hand each chunk to your audio subsystem (Web Audio decoder,
// WhatsApp Voice Note encoder, on-disk file, etc.) as soon as it
// arrives. buffering defeats the purpose of streaming.
process.stdout.write(chunk);
}
The SDK forces stream: true on the wire when you call .stream()
even if the caller passes stream: false, so the request body and
the method name can never disagree.
Browsers
In a browser, pipe the ReadableStream into a MediaSource for
progressive playback. Never call the public API from a browser
directly. your API key is a server credential. The example
below assumes a server-side proxy at /api/proxy/hakim/* that
forwards to api.tryhakim.ai with the bearer token attached.
const res = await fetch('/api/proxy/hakim/audio/speech', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'hakim-fast-v1',
input,
voice,
response_format: 'mp3',
stream: true,
}),
});
if (!res.body) throw new Error('No stream body');
const mediaSource = new MediaSource();
const audio = new Audio(URL.createObjectURL(mediaSource));
audio.play();
mediaSource.addEventListener('sourceopen', async () => {
const sourceBuffer = mediaSource.addSourceBuffer('audio/mpeg');
const reader = res.body!.getReader();
const pump = async (): Promise<void> => {
const { done, value } = await reader.read();
if (done) {
mediaSource.endOfStream();
return;
}
await appendBuffer(sourceBuffer, value);
return pump();
};
await pump();
});
function appendBuffer(sb: SourceBuffer, data: Uint8Array) {
return new Promise<void>((resolve) => {
sb.addEventListener('updateend', () => resolve(), { once: true });
sb.appendBuffer(data);
});
}
Format notes
mp3(default): widest client compatibility; recommended for most browser playback paths.opus: lowest latency and smallest bytes-on-wire; requires a decoder (<audio>supports Opus in WebM since 2018).wav: uncompressed, ideal when the consumer is a DSP / ASR pipeline that doesn't want to decode MP3 on each chunk.pcm: raw 16-bit signed little-endian samples at thesample_rateyou requested (default 24 kHz). Use when feeding a vocoder, telephony bridge, or another model.
Chunk size is shaped to emit the first frame within ~150 ms of the model's first token. You'll typically see 4–8 chunks per second during steady-state synthesis.
Realtime TTS (WebSocket)
For sessions that need to synthesise many utterances back-to-back
(LLM-→-TTS pipelining, embedded agents, voice widgets that make
dozens of calls per page-view) the HTTP path pays a fresh
TCP/TLS/HTTP-header handshake on every call (~7 ms cross-region).
The WebSocket surface trades that per-request cost for a long-lived
socket: open it once, send speech.create frames as the LLM emits
sentences, receive raw PCM-S16LE chunks (24 kHz mono) interleaved
with lifecycle events. The companion endpoint is
wss://api.tryhakim.ai/v1/audio/speech/stream
It runs the same auth + scope + quota chain as POST /v1/audio/speech and bills the same character meter. Units flush
to UsageEvent('tts') every 30 s + on close, so a crashed session
still bills up to the last tick. For one-shot synthesis (a single
sentence) the chunked HTTP path above is simpler; the WebSocket
shines when there's more than one utterance per session.
Format constraints
response_formatis locked topcmfor v1. If you need mp3, opus, wav, aac, or flac, use the HTTP path.sample_rateis locked to24000(engine-native). Re-sample client-side if you need 16/22/44/48 kHz output.
These constraints exist because the proxy forwards raw engine output bytes through the socket. Anything else would require a server-side transcoder that defeats the latency story.
Message protocol
Client → server (JSON text frames):
type | Purpose |
|---|---|
session.update | Pin session-wide defaults (model, voice, cfg, voice_prompt, chunk_chain, trailing_silence_ms). |
speech.create | Request one utterance. request_id is optional and echoed back. |
session.close | Tear down the session cleanly. |
Server → client (mix of JSON text + binary):
type | Purpose |
|---|---|
session.created | Sent once after the upgrade; carries the merged config + limits. |
speech.started | Engine has begun streaming for the matching request_id. |
| (binary) | Raw PCM-S16LE bytes for the in-flight utterance. Reassemble in order. |
speech.done | Terminal per-utterance frame with the OpenAI-shaped usage block. |
session.usage | 30 s heartbeat with cumulative session_characters billed so far. |
error | Server-side failure. fatal: true ⇒ the session is closing. |
Session options for chunked text
Two opt-in session.update options help clients that send their text as many small
speech.create chunks, for example when forwarding an LLM's output sentence by sentence. Both are
off by default; a session that does not set them behaves exactly as before.
| Option | Default | What it does |
|---|---|---|
chunk_chain | engine default | By default the engine continues each utterance from the one it just generated. That keeps a voice consistent, but over a long session it can flatten delivery and let the level drift. Set false to generate every utterance from the voice's own reference instead. A speech.create can override it for a single utterance. |
trailing_silence_ms | 100 | Silence appended after every utterance, from 0 to 500. When text arrives in chunks you hear it as a gap between them: set 0 for gapless joins. speech.done.duration_ms includes whatever pad is applied. |
{
"type": "session.update",
"session": {
"model": "hakim-v3",
"voice": "YOUR_VOICE_ID",
"chunk_chain": false,
"trailing_silence_ms": 0
}
}
Try chunk_chain: false when long sessions start to sound flat or the volume creeps up from one
utterance to the next. Both options work on every model tier, including the premium
Faseeh and Mulhim tiers.
Node SDK
The official @tryhakim/voice SDK exposes the surface as
audio.speech.streamWs(...). Iterate events for lifecycle frames
- audio chunks, or iterate
audiofor raw PCM only:
import { Hakim } from '@tryhakim/voice';
const hakim = new Hakim({ apiKey: process.env.HAKIM_API_KEY });
const session = hakim.audio.speech.streamWs({
model: 'hakim-fast-v1',
voice: 'cmokbc2b1001pvu39wmj61b7h',
});
session.sendSpeech({ input: 'مرحبا، كيف حالك اليوم؟' });
session.sendSpeech({ input: 'تشرفت بلقائك.' });
for await (const ev of session.events) {
if (ev.type === 'speech.audio') speaker.write(ev.chunk);
if (ev.type === 'speech.done') console.log('billed', ev.usage.units, 'chars');
if (ev.type === 'session.usage') console.log('session total', ev.session_characters);
if (ev.type === 'error' && ev.fatal) throw new Error(ev.message);
}
await session.close();
Python SDK
The Python SDK ships the matching audio.speech.stream_ws(...)
helper:
from hakim import AsyncHakim
async with AsyncHakim(api_key=...).audio.speech.stream_ws(
model="hakim-fast-v1",
voice="cmokbc2b1001pvu39wmj61b7h",
) as session:
await session.send_speech({"input": "مرحبا، كيف حالك اليوم؟"})
async for ev in session:
if ev["type"] == "speech.audio":
speaker.write(ev["chunk"])
elif ev["type"] == "speech.done":
break
elif ev["type"] == "error" and ev.get("fatal"):
raise RuntimeError(ev["message"])
Raw WebSocket
The wire is plain enough that any WebSocket client works. Useful when you're driving the socket from a language without an official SDK (Go, Rust, browser without the proxy):
import { WebSocket } from 'ws';
const ws = new WebSocket('wss://api.tryhakim.ai/v1/audio/speech/stream', {
headers: { Authorization: `Bearer ${process.env.HAKIM_API_KEY}` },
});
ws.on('open', () => {
ws.send(
JSON.stringify({
type: 'session.update',
session: { model: 'hakim-fast-v1', voice: 'cmokbc2b1001pvu39wmj61b7h' },
}),
);
ws.send(JSON.stringify({ type: 'speech.create', input: 'مرحبا بالعالم' }));
});
ws.on('message', (data, isBinary) => {
if (isBinary) {
// Raw PCM-S16LE bytes for the in-flight utterance.
speaker.write(data);
return;
}
const ev = JSON.parse(data.toString());
if (ev.type === 'speech.done') ws.close();
if (ev.type === 'error') console.error(ev.code, ev.message);
});
The connection requires the tts:write scope on the API key. Same
per-IP session-open envelope as the STT realtime path applies; see
the rate-limits guide.
Realtime STT (WebSocket)
Open a WebSocket against
wss://api.tryhakim.ai/v1/audio/transcriptions/stream, push audio
frames as they arrive from the mic, and consume transcription.delta
events as the model decodes them. The wire format follows the
OpenAI Realtime
event contract, so existing realtime clients can point at Hakim by
swapping the base URL and bearer token, no protocol shim required.
wscat \
-H "Authorization: Bearer $HAKIM_API_KEY" \
--connect wss://api.tryhakim.ai/v1/audio/transcriptions/stream
The connection requires the stt:write scope on the API key.
Per-IP rate limiting fires on session open, not per audio
frame, so a chatty session won't trip the quota. But a flood of
new sessions will. See the rate-limits guide
for the session-open envelope.
Message protocol
Client → server:
type | Purpose |
|---|---|
session.update | Configure language, model, partial-segment cadence (optional). |
input_audio_buffer.append | Append a base64-encoded PCM 16-bit mono 16 kHz frame. |
input_audio_buffer.commit | Mark end-of-utterance; flush any buffered audio for final decode. |
session.close | Tear down the session cleanly. Server replies and closes. |
Server → client:
type | Purpose |
|---|---|
session.created | Sent once on connect with the assigned session_id. |
transcription.delta | Partial transcript fragment as audio is decoded. |
transcription.done | Final transcript for the most recent commit. |
error | Recoverable error (rate limit, decode failure). Session OK. |
A minimal Node example using a plain WebSocket (drop-in for the OpenAI Realtime SDK shape):
import { WebSocket } from 'ws';
const ws = new WebSocket('wss://api.tryhakim.ai/v1/audio/transcriptions/stream', {
headers: { Authorization: `Bearer ${process.env.HAKIM_API_KEY}` },
});
ws.on('open', () => {
// `hakim-arab-v2` is the only STT model id · pass it explicitly
// so the wire payload is unambiguous even if the default changes.
ws.send(
JSON.stringify({
type: 'session.update',
session: { model: 'hakim-arab-v2', language: 'ar' },
}),
);
});
ws.on('message', (raw) => {
const event = JSON.parse(raw.toString());
switch (event.type) {
case 'session.created':
// Echoed once after the upgrade succeeds. `event.session` is
// the merged config + defaults; `event.limits` reports the
// per-session size + duration caps.
break;
case 'transcription.delta':
// Partial transcript fragment. `event.is_final === false`
// means the text may still change on the next delta.
process.stdout.write(event.text);
break;
case 'transcription.done':
console.log('\nFINAL:', event.text);
break;
case 'error':
// `event.code` is the stable identifier to switch on
// (e.g. `upstream_error`, `frame_too_large`, `session_too_long`);
// `event.message` is a human-readable hint.
console.error('stream error', event.code, event.message);
break;
}
});
// Push 20 ms PCM frames as they arrive from your mic capture loop.
// The audio payload is **base64-encoded** inside the JSON frame —
// the server rejects binary WebSocket frames with `binary_frames_not_supported`.
function pushFrame(pcm16: Buffer) {
ws.send(
JSON.stringify({
type: 'input_audio_buffer.append',
audio: pcm16.toString('base64'),
}),
);
}
// When the speaker pauses, send `input_audio_buffer.commit` to flush
// the current buffer · the next `transcription.done` carries the
// stitched final transcript for that window.
function endOfUtterance() {
ws.send(JSON.stringify({ type: 'input_audio_buffer.commit' }));
}
Custom vocabulary
Pass a vocabulary object inside session.update to bias recognition
toward domain-specific words, pin unusual spellings, and give the model
broader topical context, useful for product names, acronyms, and
people's names that a general-purpose acoustic model wouldn't otherwise
favour.
ws.send(
JSON.stringify({
type: 'session.update',
session: {
model: 'hakim-arab-v2',
language: 'ar',
vocabulary: {
terms: ['هاكِم', 'Hakim', 'Saree', 'GPU'],
notes: 'A product demo call about the Hakim realtime Arabic speech API.',
hints: [{ key: 'topic', value: 'sales demo' }],
translations: [{ from: 'حوسبة سحابية', to: 'cloud computing' }],
},
},
}),
);
Every field is optional and additive. Set only what your session needs:
| Field | Purpose |
|---|---|
terms | Rare words, names, acronyms, or brand terms to bias recognition toward. |
notes | A free-text paragraph describing the call's subject matter. |
hints | Structured {key, value} metadata (speaker roles, topic tags) for extra context. |
translations | Pinned {from, to} term pairs for cross-language glossaries. Accepted here for forward compatibility; has no visible effect until the session also translates. |
vocabulary shares one combined 10,000-character budget across all four
fields. There's no separate quota per field, so a long notes paragraph
leaves less room for terms. Sending more than that returns
invalid_frame on the session.update frame that carries it and closes
the session, the same as any other malformed frame. Per field, terms
takes up to 1,000 entries of 200 characters, notes up to 4,000
characters and hints up to 200 pairs.
A worked example
A short word that is easy to mishear needs a little context. In this
support call the customer answers a name check with the single word
"speaking", which a model can easily hear as something else. terms
names the word and notes explains the situation:
ws.send(
JSON.stringify({
type: 'session.update',
session: {
model: 'hakim-arab-v2',
language: 'en',
trim_leading_silence: true,
vocabulary: {
terms: ['speaking'],
notes:
'A support call. The agent confirms they are speaking to the right person, ' +
'and the customer answers with the word speaking.',
},
},
}),
);
Getting the best results
- Set it once, in the first
session.update, before any audio. The vocabulary applies to the whole session and to every utterance in it, including after pauses: a session with no audio is kept open for up to 5 minutes. It is handed to the recognizer when the stream starts, so avocabularyorlanguagechange sent in a latersession.update, after audio has begun, is not applied. Open a new session to change it, and send the vocabulary again there, because a new session starts empty. - Set the language. Send
language(orlanguage_hintsfor mixed speech) instead of leaving it onauto. Context is applied far more reliably when the language is known. - Use
termsandnotestogether for short, common words.termsalone is enough for names, brands and acronyms. A short everyday word that gets confused with another also needs a sentence innotesdescribing the situation. - Keep silence out of the start of the stream. Biasing becomes
unreliable when the stream opens with half a second or more of silence
or quiet background. Start sending when speech starts, or let us drop
it for you with
trim_leading_silence. - Keep it short and specific. A handful of the words that actually matter beats a long glossary.
- Check the field name. Only
vocabularyis read. Fields with other names, such ascontextorcustom_vocabulary, are ignored without an error, so a misnamed field looks exactly like vocabulary that does not work. - Vocabulary applies to realtime sessions. The batch
POST /v1/audio/transcriptionsendpoint does not accept it.
Dropping silence at the start of a stream
Streams that start the moment a call connects often begin with seconds of
silence or line noise before anyone speaks. That lead is billed like any
other audio and, as above, it can make vocabulary less reliable. Set
trim_leading_silence: true in session.update and we hold audio back
until speech is detected, then send it from about 300 ms before the first
word. Everything after that is untouched.
- It is off by default, and a stream that starts with speech is passed through exactly as you sent it.
- A lead shorter than one second is also left alone. It only acts when there is a real stretch of silence to remove.
- It works with
pcm16andmulawinput, notopus. - Billing is unchanged. You are billed for all audio you send.
- Up to about 30 seconds of leading silence is removed. A noisy line that is never quiet is passed through untouched.
Audio format
- Codec: PCM 16-bit signed little-endian.
- Sample rate: 16 kHz mono. Higher rates are accepted and resampled server-side, but cost more upload bandwidth without improving accuracy on the model's training distribution.
- Frame cadence: 20–60 ms per frame. Smaller frames mean lower partial-transcript latency; larger frames mean fewer round-trips.
Latency profile
First transcription.delta typically arrives within ~150 ms of the
matching audio frame for short Arabic utterances on the
hakim-arab-v2 acoustic profile. Deltas keep streaming for ~1–4 s
after the last audio frame (the model finishes processing the tail
of the buffer), then go quiet. transcription.done follows roughly
1.5 s after the last delta; it carries the stitched final
transcript for the most recent commit window. For a 5-second
clip, end-to-end commit → done is ~3–5 s.
Synchronous fallback
For pre-recorded files (interviews, voicemail, podcast batches), use
the synchronous POST /v1/audio/transcriptions route instead. It
takes a multipart upload, transcribes the whole clip, and returns the
final transcript in a single response. The realtime route is the
right tool when audio is being captured now and partial transcripts
need to render before the speaker pauses.
Realtime translation (WebSocket)
Open a WebSocket against
wss://api.tryhakim.ai/v1/audio/translate/stream, push microphone
frames in, receive transcripts + translated text + translated
audio per utterance, all over one socket. Internally the endpoint
chains the three realtime models you already know: hakim-arab-v2
for STT, the Hakim LLM for translation, and hakim-fast-v1 for TTS,
behind the same authentication and billing surface. There is no
separate scope; the connection requires stt:write and
tts:write on the API key.
wscat \
-H "Authorization: Bearer $HAKIM_API_KEY" \
--connect wss://api.tryhakim.ai/v1/audio/translate/stream
Message protocol
Client → server:
type | Purpose |
|---|---|
session.update | Set target_language (required), source_language, voice, gender, partials. |
input_audio_buffer.append | Append a base64-encoded PCM 16-bit mono 16 kHz frame from the source mic. |
input_audio_buffer.commit | Mark end-of-utterance · the server kicks off translation + TTS for that window. |
session.close | Tear down the session cleanly. Server replies and closes. |
Server → client:
type | Purpose |
|---|---|
session.created | Sent once on connect with the assigned session_id + resolved voice. |
transcription.delta | Partial source-language transcript. |
transcription.done | Final source-language transcript for the most recent commit. |
translation.delta | Partial translated text as the LLM streams tokens. |
translation.done | Final translated text for the utterance. |
speech.started | TTS started for this utterance · carries voice_id + sample_rate. |
speech.audio | Binary frame · PCM 16-bit chunk of translated audio. |
speech.done | TTS finished for this utterance · carries duration_ms. |
session.usage | Rollup totals (STT ms, LLM tokens, TTS chars, credits, USD). |
error | Recoverable or fatal error · event.fatal distinguishes the two. |
Audio frames are binary WebSocket frames (unlike STT-realtime
where everything is JSON) so the client doesn't pay the base64
overhead twice. Bind the speech bytes to the active utterance by
keying on the most recent speech.started.
Node SDK
import { Hakim } from '@tryhakim/voice';
const hakim = new Hakim({ apiKey: process.env.HAKIM_API_KEY });
const stream = await hakim.audio.translate.streamWs({
target_language: 'en',
gender: 'female',
});
const audio: Uint8Array[] = [];
for await (const event of stream) {
switch (event.type) {
case 'transcription.done':
console.log('source :', event.text);
break;
case 'translation.done':
console.log('target :', event.text);
break;
case 'speech.audio':
audio.push(event.chunk);
break;
case 'speech.done':
// Concatenate `audio` into a WAV header + body, play it back.
break;
case 'session.usage':
console.log('credits used', event.totals.credits);
break;
}
}
Python SDK
from hakim import AsyncHakim
async with AsyncHakim() as hk:
async with hk.audio.translate.stream_ws({
'target_language': 'en',
'gender': 'female',
}) as stream:
async for event in stream:
if event['type'] == 'transcription.done':
print('source :', event['text'])
elif event['type'] == 'translation.done':
print('target :', event['text'])
elif event['type'] == 'session.usage':
print('credits', event['totals']['credits'])
Billing
Each utterance bills the underlying components separately so the
existing usage dashboard and quota envelopes apply unchanged: STT
audio ms, LLM tokens, and TTS characters all show up as their own
UsageEvent. On clean close the server emits one session.usage
frame with the per-session rollup so the client doesn't have to
sum the components itself.
Latency profile
The first transcription.delta lands ~150 ms after the first
input frame (same as STT-realtime). translation.delta starts
1–2 sentences behind the STT decoder, and the first
speech.audio chunk arrives ~250 ms after translation.done for
the utterance. End-to-end "user spoke → translated audio playing"
runs ~1.5–3 s for short utterances, dominated by the LLM's
sentence boundary detection.
Async results from long uploads
There is no public POST-to-enqueue jobs endpoint. Jobs are a
read surface, GET /v1/jobs, GET /v1/jobs/{id},
GET /v1/jobs/{id}/result. That surfaces the lifecycle of work
created via the routes that actually accept input:
| Source route | When it returns a job pointer |
|---|---|
POST /v1/audio/transcriptions | When the upload is > 25 MiB or > 10 minutes. Synchronous response is 202 with a poll_url. |
POST /v1/audio/voices | Voice cloning always; the API returns the voice_id immediately and the model trains async. |
The TTS route (POST /v1/audio/speech) is always synchronous —
small inputs return audio inline, larger ones stream the audio
back chunk-by-chunk. There is no batch TTS surface today.
Async STT: large file upload
curl -X POST https://api.tryhakim.ai/v1/audio/transcriptions \
-H "Authorization: Bearer $HAKIM_API_KEY" \
-F "file=@./long-interview.mp3" \
-F "model=hakim-arab-v2" \
-F "language=ar"
When the file crosses the inline threshold the response is a 202:
{
"id": "job_01HNQ9S4ZXN4F7N6M5CY8J6F3P",
"status": "queued",
"kind": "transcription",
"poll_url": "https://api.tryhakim.ai/v1/jobs/job_01HNQ9S4ZXN4F7N6M5CY8J6F3P",
"created_at": "2026-04-17T10:00:00Z"
}
Voice clone
curl -X POST https://api.tryhakim.ai/v1/audio/voices \
-H "Authorization: Bearer $HAKIM_API_KEY" \
-F "name=Aya, narrator" \
-F "language=ar" \
-F "consent_confirmed=true" \
-F "file=@./voice-sample-10s.wav"
Returns a Voice row with status: "processing". The voice id is
usable in POST /v1/audio/speech calls after the status
flips to ready (typically a few seconds for the 10-second sample
profile).
Tracking completion
Two patterns; pick whichever fits your deployment.
Webhook (recommended). Subscribe to job.succeeded and
job.failed via the webhooks guide. Your
handler receives the job payload with the result inline. Zero
polling, bounded latency.
Polling. Hit the poll_url returned by the source route (or
GET /v1/jobs/{id} directly) at a paced interval:
async function waitFor(jobId: string): Promise<Job> {
while (true) {
const job = await client.jobs.retrieve(jobId);
if (job.status === 'succeeded' || job.status === 'failed') return job;
await new Promise((r) => setTimeout(r, 1_500));
}
}
Once the job is succeeded, fetch the result envelope from
GET /v1/jobs/{id}/result (transcript JSON, voice metadata, etc.).
Back off the poll interval for very long jobs. Poll requests count in the same rate-limit bucket as everything else, see the rate-limits guide, so pace accordingly.
Troubleshooting
- Audio sounds chopped or glitchy. Browser
MediaSourcebuffers need contiguous frames; make sure you're not dropping chunks on the way toappendBuffer. If you're proxying the stream through your own backend, confirm the proxy isn't re-buffering the body. - Stream hangs after N chunks. Confirm your proxy isn't
buffering the full response. Nginx default behaviour is to
buffer
proxy_pass; setproxy_buffering offon the route that forwards to/v1/audio/speech. AWS ALB target groups buffer by default. Set therouting.http.response.buffering.enabledattribute tofalseon the listener rule. - Upstream disconnects mid-stream. The API sends a final
zero-length chunk to signal clean end-of-stream. Anything else
is a transport failure. The SDK throws
ConnectionError; on raw fetch you'll see the underlying TCP/TLS error. Retry the request; the response is idempotent if you supply anIdempotency-Keyheader. - Realtime WebSocket closes immediately on connect. Check the
close code:
4401is auth,4403is missingstt:writescope,4429is rate-limited (the per-IP session-open budget). The body of the close frame carries a JSONerror.codematching the errors guide.
Cookbook
Recipe · Build a voice agent (STT + LLM + TTS, all streaming)
The canonical conversational-AI loop: capture mic audio, transcribe on Hakim Arab v2 (STT), feed the transcript into your LLM, stream the response back as Saree' TTS. End-to-end latency lands around 500 ms with a fast LLM · comparable to OpenAI's Realtime for English.
import { WebSocket } from 'ws';
import { HakimClient } from '@hakim/node';
const hakim = new HakimClient({ apiKey: process.env.HAKIM_API_KEY });
// 1. STT realtime socket
const stt = new WebSocket('wss://api.tryhakim.ai/v1/audio/transcriptions/stream', {
headers: { Authorization: `Bearer ${process.env.HAKIM_API_KEY}` },
});
stt.on('open', () =>
stt.send(
JSON.stringify({
type: 'session.update',
session: { language: 'ar', model: 'hakim-arab-v2' },
}),
),
);
// One TTS socket reused for the whole conversation · saves the
// ~7 ms per-utterance handshake the chunked HTTP path pays on
// every call, which matters in a tight conversational loop.
const tts = hakim.audio.speech.streamWs({
model: 'hakim-fast-v1', // Saree' · the latency-sensitive tier
voice: 'cmokbc2b1001pvu39wmj61b7h',
});
(async () => {
for await (const ev of tts.events) {
if (ev.type === 'speech.audio') pushToAudioOutput(ev.chunk);
}
})();
stt.on('message', async (raw) => {
const event = JSON.parse(raw.toString());
if (event.type !== 'transcription.done') return;
// 2. Hand the final transcript to your LLM (streaming if your
// provider supports it; here we await the full reply for
// simplicity).
const reply = await callLlm(event.text);
// 3. Synthesize the reply over the persistent TTS socket. Audio
// chunks land on the iterator above as `speech.audio` events.
tts.sendSpeech({ input: reply });
});
function pushFrame(pcm16: Buffer) {
stt.send(
JSON.stringify({
type: 'input_audio_buffer.append',
audio: pcm16.toString('base64'),
}),
);
}
Latency budget for the loop:
| Phase | Typical wall-time |
|---|---|
| Caller stops speaking | reference point |
STT first transcription.delta | ~150 ms after the matching audio frame |
STT transcription.done event | ~1.5 s after the last delta. Use the delta stream for fast UX, done for the committed snapshot |
| Your LLM responds (varies per model) | 200–400 ms |
| Hakim Saree' returns first audio chunk | ~113 ms p95 |
| Audio plays through caller's speaker | + transport |
If the LLM step is the bottleneck (~250 ms on GPT-4o Realtime, faster on smaller models), the Hakim legs dominate roughly a third of the budget. Move the LLM closer to the inference region, or swap to a faster model, before re-tuning the audio pipeline.
Recipe · Failover from streaming to static MP3 (IVR pattern)
When the streaming TTS connection drops mid-utterance (Wi-Fi blip, ALB drain, etc.), the caller hears clipped audio. IVR pipelines typically pre-render a fallback set of static MP3s and switch on stream failure rather than retry the stream.
import { HakimClient } from '@hakim/node';
const hakim = new HakimClient({ apiKey: process.env.HAKIM_API_KEY });
async function speakOrFallback(line: string, fallbackUrl: string) {
try {
const { stream } = await hakim.audio.speech.stream({
model: 'hakim-fast-v1',
input: line,
voice: 'alex',
response_format: 'mp3',
stream: true,
});
for await (const chunk of stream) playToCaller(chunk);
} catch (err) {
// Stream error → fall back to pre-recorded static.
const blob = await fetch(fallbackUrl);
for await (const chunk of blob.body!) playToCaller(chunk);
}
}
Pre-render the top-20 IVR prompts on every deploy (TTS is cheap; the failsafe is essentially free) and store the MP3s on the same CDN as the rest of your application. Caller hears the fallback within a few hundred ms of the stream error rather than a 30-second timeout.
Recipe · Stream long-form Faseeh narration
Faseeh streams the same way Saree' does · the wire format and SDK call shape are identical. The marketing pitch is naturalness, not speed, so don't promise a TTFB to your users on this tier; do promise that they can play the audio as it renders rather than waiting for the full file.
curl -N https://api.tryhakim.ai/v1/audio/speech \
-H "Authorization: Bearer $HAKIM_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "hakim-v2",
"input": "[breath] Welcome back, listeners. Today we are talking about ...",
"voice": "layla-egyptian",
"response_format": "mp3",
"stream": true
}' \
--output episode-intro.mp3
For an hour-long episode, this lets the user press play within a second of submitting the script instead of waiting for the whole render · useful for previewing in the playground or as a UX nicety in your authoring tool. Render the final master via a webhook batch job for production-grade quality control.