Text to Speech Word timestamps

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.

1

How it works

This endpoint streams NDJSON — one JSON object per line — instead of raw PCM. Two kinds of line arrive:

LineWhat it carries
Audioaudio_base64 — a base64-encoded chunk of PCM16 audio. Decode and append these in order to rebuild the clip.
Alignmentalignment and normalized_alignment — character arrays with start and end times. audio_base64 is empty on these lines.
Alignment arrives at the end. Timings are emitted on the final lines, once generation has finished — not incrementally alongside each audio chunk. If you need to highlight from the first word, buffer the whole response before starting playback, or split long text into sentences and request them separately.
2

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.

PCM16base64 per line48000 Hz recommended24000 Hz defaultmono16-bit
PropertyValue
FormatPCM (Pulse Code Modulation), signed 16-bit little-endian
EncodingBase64, one chunk per NDJSON line
Sample rateFollows sample_rate: 48000 Hz recommended (engine-native), 24000 Hz default
ChannelsMono
Chunk sizeVaries — 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.

Don't skip lines. Every 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.
3

Endpoint & request

POST /api/v1/text-to-speech/{model_id}/with-timestamps

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

ParameterTypeRequiredDescription
model_idstringYesThe model identifier to use for generation. Must be a model served by the v1.5 engine — see Models.

Request bodyContent-Type: application/json

FieldTypeRequiredDescription
voice_idstringYesThe voice ID to use for synthesis
textstringYesThe Arabic text to convert to speech (max 10,000 characters)
stabilitynumberYesVoice stability (0.0 to 1.0). Higher values produce more consistent output
speednumberNoSpeech speed (0.7 to 1.2, default 1.0)
sample_ratenumberNoOutput sample rate in Hz, 800048000 (default 24000). Use 48000 — it's the engine's native rate, so the audio skips downsampling.
dialectstringNoDialect hint for synthesis: auto (default), emirati, or fusha.
4

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.

curl
curl -X POST "https://api.munsit.com/api/v1/text-to-speech/faseeh-v1-preview/with-timestamps" \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "voice_id": "ar-najdi-male-2", "text": "مرحبا بك في فصيح", "stability": 0.5, "sample_rate": 48000 }' \ --output stream.ndjson
5

Response

Status code: 200 OK. The body is a newline-delimited JSON stream.

HeaderValue
Content-Typeapplication/x-ndjson;charset=utf-8;rate=<sample_rate>
Cache-Controlno-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:

response.ndjson
// audio — one chunk per line, in playback order{"audio_base64": "XQACASUBAgERATUBYAFnAVkBcQGPAXYB…", "alignment": null, "normalized_alignment": null, "quality_check": null}{"audio_base64": "o+Ii5Tro1+sT8FL0qvhP/SYBhgMOBvUJ…", "alignment": null, "normalized_alignment": null, "quality_check": null}// … more audio lines …// then the timings — audio_base64 is empty from here on{"audio_base64": "", "alignment": null, "normalized_alignment": {"characters": […], "character_start_times_seconds": […], "character_end_times_seconds": […]}, "quality_check": null}{"audio_base64": "", "alignment": {"characters": […], "character_start_times_seconds": […], "character_end_times_seconds": […]}, "normalized_alignment": null, "quality_check": null}

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

FieldTypeDescription
audio_base64stringBase64-encoded PCM16 mono audio at the requested sample_rate. Empty on alignment lines.
alignmentobjectCharacter timings aligned to the text you submitted. Use this one to map timings back onto your own string.
normalized_alignmentobjectCharacter 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:

FieldOnMeaning
alignedbothWhether alignment succeeded. If false, treat the timings as unreliable and fall back to plain playback.
mappedalignmentPer character: whether it was mapped back onto your original text. Characters the engine could not place are false.
anchorednormalized_alignmentPer character: whether the timing is anchored to real audio rather than interpolated between neighbours. Whitespace is typically false.
coveragenormalized_alignmentProportion of characters that are anchored, 01. 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:

alignment
{ "characters": ["م", "ر", "ح", "ب", "ا", " ", "ب", "ك"], "character_start_times_seconds": [0.0, 0.132, 0.244, 0.366, 0.477, 0.528, 0.610, 0.701], "character_end_times_seconds": [0.132, 0.244, 0.366, 0.477, 0.528, 0.610, 0.701, 0.853], "aligned": true}

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.

That guarantee applies to 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.

6

Error responses

Errors come back as JSON with an errorCode and errorMessage.

StatusError codeExample message
400 Bad Request400xxWord timestamps are not available for model 'x'. Use a model served by the v1.5 engine.
401 Unauthorized40101Authorization required. Provide Authorization Bearer token (Clerk) or x-api-key header.
402 Payment Required402xxInsufficient wallet balance. Required: $0.05, Available: $0.02
7

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.

8

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.