Word timestamps
Generate speech and get character-level timings for the text you submitted, in one request. Use it to highlight words as they are spoken, drive captions, or align a transcript to the audio.
How it works
This endpoint streams NDJSON — one JSON object per line — instead of raw PCM. Two kinds of line arrive:
| Line | What it carries |
|---|---|
| Audio | audio_base64 — a base64-encoded chunk of PCM16 audio. Decode and append these in order to rebuild the clip. |
| Alignment | alignment and normalized_alignment — character arrays with start and end times. audio_base64 is empty on these lines. |
Audio format
The audio is the same PCM16 the streaming endpoint returns — it is simply base64-encoded and split across the audio_base64 lines instead of being sent as a raw byte stream.
| Property | Value |
|---|---|
| Format | PCM (Pulse Code Modulation), signed 16-bit little-endian |
| Encoding | Base64, one chunk per NDJSON line |
| Sample rate | Follows sample_rate: 48000 Hz recommended (engine-native), 24000 Hz default |
| Channels | Mono |
| Chunk size | Varies — typically a fraction of a second of audio per line |
To rebuild the clip: base64-decode each audio_base64 value and concatenate the bytes in the order the lines arrive. The result is headerless PCM — the same bytes the streaming endpoint would have given you. Most players need a container, so prepend a 44-byte WAV header (using your sample_rate, 1 channel, 16 bits) before writing a .wav file, or feed the samples straight into a Web Audio buffer.
audio_base64 value is a contiguous slice of one continuous waveform. Dropping or reordering a line produces audible clicks and shifts everything after it out of sync with the timings.
Endpoint & request
Requires API key authentication via the x-api-key header. Single voice only — the speakers array supported by Synthesize is not accepted here.
Path parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
model_id | string | Yes | The model identifier to use for generation. Must be a model served by the v1.5 engine — see Models. |
Request body — Content-Type: application/json
| Field | Type | Required | Description |
|---|---|---|---|
voice_id | string | Yes | The voice ID to use for synthesis |
text | string | Yes | The Arabic text to convert to speech (max 10,000 characters) |
stability | number | Yes | Voice stability (0.0 to 1.0). Higher values produce more consistent output |
speed | number | No | Speech speed (0.7 to 1.2, default 1.0) |
sample_rate | number | No | Output sample rate in Hz, 8000–48000 (default 24000). Use 48000 — it's the engine's native rate, so the audio skips downsampling. |
dialect | string | No | Dialect hint for synthesis: auto (default), emirati, or fusha. |
Example request
The examples use the faseeh-v1-preview model with the ar-najdi-male-2 voice, and rebuild both the audio and the word list.
Response
Status code: 200 OK. The body is a newline-delimited JSON stream.
| Header | Value |
|---|---|
Content-Type | application/x-ndjson;charset=utf-8;rate=<sample_rate> |
Cache-Control | no-cache |
A complete response looks like this — audio lines first, then the alignment lines. Every line carries all four keys, with null where a field does not apply:
A short clip like مرحبا بك في فصيح at 48 kHz comes back as roughly a dozen audio lines followed by the two alignment lines. Read to the end of the stream: closing early loses the timings entirely.
Line fields
| Field | Type | Description |
|---|---|---|
audio_base64 | string | Base64-encoded PCM16 mono audio at the requested sample_rate. Empty on alignment lines. |
alignment | object | Character timings aligned to the text you submitted. Use this one to map timings back onto your own string. |
normalized_alignment | object | Character timings aligned to the engine's normalized (and, for MSA, diacritized) form of the text. Does not match your input character-for-character — see the warning below. |
Alongside the three timing arrays, each alignment object carries quality flags:
| Field | On | Meaning |
|---|---|---|
aligned | both | Whether alignment succeeded. If false, treat the timings as unreliable and fall back to plain playback. |
mapped | alignment | Per character: whether it was mapped back onto your original text. Characters the engine could not place are false. |
anchored | normalized_alignment | Per character: whether the timing is anchored to real audio rather than interpolated between neighbours. Whitespace is typically false. |
coverage | normalized_alignment | Proportion of characters that are anchored, 0–1. 1.0 means every character got a real timing. |
For highlighting, the practical rule is: bail out if aligned is false, and skip any word whose characters are all unanchored — its timing is a guess, and highlighting it will look wrong against the audio.
Both alignment objects share the same shape — three parallel arrays of equal length:
If your text contains a break tag, it stays in alignment exactly as you wrote it, with timings spanning the silence it produces — so offsets still line up with your string. It is not present in normalized_alignment, which describes the spoken form only. When grouping into words, treat the whole tag as one unit: splitting on whitespace alone tears it into <break and time="1s"/>.
Concatenating alignment.characters reproduces your input string exactly, so index i in those arrays is index i in your text — that is what makes it safe to map timings back onto your own string.
alignment only. normalized_alignment describes the engine's spoken form, which can differ in length and content — numerals are expanded, so تأسست الشركة في 1985 (20 characters) becomes تأسست الشركة في ألف وتسعمئة وخمسة وثمانين (41 characters). Its indices do not map onto your input. Use it to read what was actually spoken, not to highlight your own text.Timings are per character, not per word — there is no word array. Derive words by walking the arrays and breaking on whitespace: a word's start is its first character's start time, its end is its last character's end time. The Python example above does exactly this.
Error responses
Errors come back as JSON with an errorCode and errorMessage.
| Status | Error code | Example message |
|---|---|---|
| 400 Bad Request | 400xx | Word timestamps are not available for model 'x'. Use a model served by the v1.5 engine. |
| 401 Unauthorized | 40101 | Authorization required. Provide Authorization Bearer token (Clerk) or x-api-key header. |
| 402 Payment Required | 402xx | Insufficient wallet balance. Required: $0.05, Available: $0.02 |
Cost calculation
Priced exactly like a standard synthesis request — from the text length and the model cost per character. Timestamps add no extra charge. The generation appears in your history the same way a streaming request does.
Go further
Working with an AI assistant? Every page is available as Markdown: add .md to the URL, or send an Accept: text/markdown header. For the whole documentation in one request, point it at llms-full.txt; the page index is llms.txt. Or use Copy Page, top right.
