Arabic speech-to-text and text-to-speech behind a single API key. One key works for both.
http://sa-gateway.tryhamsa.com
Pass your API key on every request, either header works:
x-api-key: YOUR_KEY
# or
Authorization: Bearer YOUR_KEY
Keys are issued and revoked from the console. Missing, invalid, or revoked keys return 401.
Usage is metered in audio-seconds — the same unit for both services. STT bills the duration of audio you send; TTS bills the duration of audio generated. Track it in the console.
Lists the 113 voices and dialect codes. Use a voice id as the TTS speaker.
curl http://sa-gateway.tryhamsa.com/v1/voices -H "x-api-key: $HAMSA_KEY"
# -> { "count": 113, "dialects": [...],
# "data": [ {"id": "Layla", "dialect": "egy"}, ... ] }
| code | code | ||
|---|---|---|---|
msa | Std Arabic | irq | Iraqi |
egy | Egyptian | jor | Jordanian |
ksa | Saudi | pls | Palestinian |
uae | Emirati | leb | Lebanese |
kuw | Kuwaiti | syr | Syrian |
qat | Qatari | bah | Bahraini |
oma | Omani | en | English |
Transcribe audio. Send a base64-encoded WAV; get back text and the billed duration. Add the flags below for gender, diarization, or turn detection.
| field | type | |
|---|---|---|
audio | string | base64-encoded WAV required |
lang | string | ar, en, or omit to auto-detect |
prompt | string | optional decoding hint |
gender_detection | bool | also return speaker gender |
speaker_identification | bool | also return a speaker embedding (diarization) |
eos_enabled | bool | also return end-of-speech / turn detection |
curl -X POST http://sa-gateway.tryhamsa.com/v1/stt \
-H "x-api-key: $HAMSA_KEY" \
-H "content-type: application/json" \
-d '{"audio":"<base64-wav>","lang":"ar"}'
import base64, requests
audio = base64.b64encode(open("clip.wav","rb").read()).decode()
r = requests.post("http://sa-gateway.tryhamsa.com/v1/stt",
headers={"x-api-key": KEY},
json={"audio": audio, "lang": "ar"})
print(r.json()) # {"text": "...", "duration": 3.5}
{"text": "مرحبا بك في همسة", "duration": 3.5}
# with gender_detection / speaker_identification / eos_enabled:
{"text": "...", "duration": 3.5, "gender": "Female",
"eos": {"prediction": 1, "probability": 0.82}, "speaker": "<embedding>"}
Synthesize speech. Returns a stream of audio bytes (wav, or 8 kHz µ-law when mulaw is true).
| field | type | |
|---|---|---|
text | string | text to synthesize required — supports <pause_short> / <pause_long> |
speaker | string | voice id, case-insensitive (see /v1/voices) required |
language_id | string | voice language id required |
dialect | string | dialect code (egy, ksa, uae, …); auto if omitted |
expressiveness | float | 0.0–2.0, default 1.0 (neutral → emotional) |
speed | float | 0.5–2.0, default 1.0 |
mulaw | bool | 8 kHz µ-law output, default false (16 kHz pcm16) |
curl -X POST http://sa-gateway.tryhamsa.com/v1/tts \
-H "x-api-key: $HAMSA_KEY" \
-H "content-type: application/json" \
-d '{"text":"مرحبا بالعالم","speaker":"layla","language_id":"ar"}' \
--output out.wav
import requests
with requests.post("http://sa-gateway.tryhamsa.com/v1/tts",
headers={"x-api-key": KEY},
json={"text":"مرحبا بالعالم","speaker":"layla","language_id":"ar"},
stream=True) as r:
with open("out.wav","wb") as f:
for chunk in r.iter_content(8192):
f.write(chunk)
For full recordings (calls, meetings, media): submit a job, poll for the result. The completed job returns the full transcript, speaker diarization with word-level timestamps, and the detected language. Billed once, on completion, by audio duration.
| field | type | |
|---|---|---|
audio_url | string | public / pre-signed URL of the media file one of these two |
audio_base64 | string | base64-encoded audio |
language | string | hint, e.g. ar; auto-detected if omitted |
return_srt_format | bool | also return subtitles (SRT) |
srt_options | object | max_lines_per_subtitle, max_chars_per_line, … |
curl -X POST http://sa-gateway.tryhamsa.com/v1/transcriptions \
-H "x-api-key: $HAMSA_KEY" -H "content-type: application/json" \
-d '{"audio_url":"https://.../call.mp3","language":"ar"}'
# -> 202 {"job_id":"28b3ff340f58...","status":"IN_QUEUE"}
Poll until status is COMPLETED (or FAILED / CANCELLED / TIMED_OUT). Cancel with DELETE on the same path.
{
"job_id": "28b3ff340f58...", "status": "COMPLETED", "audio_seconds": 0.52,
"result": {
"detected_language": "ar",
"transcription": "مرحبا",
"diarization": [
{ "speaker": "speaker_0", "start": 0.07, "end": 0.49, "text": "مرحبا",
"words": [ {"word": "مرحبا", "start": 0.07, "end": 0.49, "score": 0.95, "speaker": "speaker_0"} ] }
],
"usage": { "audio_length": 0.0087 }
}
}
import time, requests
H = {"x-api-key": KEY}
job = requests.post("http://sa-gateway.tryhamsa.com/v1/transcriptions",
headers=H, json={"audio_url": MEDIA_URL, "language": "ar"}).json()
while True:
r = requests.get(f"http://sa-gateway.tryhamsa.com/v1/transcriptions/{job['job_id']}", headers=H).json()
if r["status"] in ("COMPLETED", "FAILED", "CANCELLED", "TIMED_OUT"): break
time.sleep(3)
for seg in r["result"]["diarization"]:
print(seg["speaker"], seg["start"], seg["end"], seg["text"])
For live transcription, open a WebSocket to /v1/stt/stream — use ws:// (wss:// over TLS). Authenticate with ?api_key=YOUR_KEY or the x-api-key header.
connect http://sa-gateway.tryhamsa.com/v1/stt/stream?api_key=YOUR_KEY (ws:// or wss://)
-> {"type":"handshake","options":{"gender_detection":true,"eos_enabled":true}}
<- {"type":"handshake_ack","status":"authenticated"}
-> {"type":"media","payload":"<base64 audio>"} # or raw binary frames
<- {"type":"transcription","data":{"transcription":"...","gender":"Female"},"duration_ms":500}
Handshake options: audio_type (PCM/MULAW), sample_rate, vad_threshold, min_silence_duration_ms, eos_enabled, eos_threshold, gender_detection, speaker_identification. Billed by streamed speech-seconds.
Already on the OpenAI SDK? Point its base_url at http://sa-gateway.tryhamsa.com/v1, pass your Hamsa key, and it just works. Usage is metered the same per-key audio-seconds way.
| OpenAI | → Hamsa model |
|---|---|
file (multipart) | base64 → /transcribe audio |
language | lang |
input | /tts/stream text |
voice | speaker (your Hamsa speaker id) |
speed | speed (clamped 0.5–2.0) |
dialect, expressiveness (extra) | passed through to the model |
response_format — speech: wav (default) · pcm · mulaw (others → 400). Transcription: json (default) · text · verbose_json · srt · vtt. Use model="hamsa"; TTS input ≤ 2000 chars, upload ≤ 25 MB.
Pass stream=true to receive the response as server-sent events instead of one body. Useful for long recordings: the transcript starts arriving well before the request would otherwise return, so your HTTP client does not sit idle and time out. Works with json and verbose_json (srt/vtt → 400, since a partial subtitle file is not meaningful).
| event | when |
|---|---|
transcript.text.delta | interim text, emitted as soon as transcription finishes — show it live, but treat it as a preview |
transcript.text.segment | one per diarized segment, with speaker, start, end |
transcript.text.done | final text; for verbose_json also segments, language, duration |
Deltas are interim; transcript.text.done is authoritative. Deltas come straight off the transcription pass, before word alignment runs. The final text is the aligned one, so it can differ slightly — in practice by a few standalone punctuation tokens that alignment drops. Render deltas live for responsiveness, then replace with done.text. This is the usual interim-then-final pattern for streaming speech recognition.
Speaker labels arrive at the end, not with the deltas. Diarization clusters voices across the whole recording, so a speaker's identity is not settled until the last audio is processed. Text streams early; transcript.text.segment events follow once diarization completes. Lines beginning : are keep-alive comments — ignore them (the OpenAI SDK already does).
stream = client.audio.transcriptions.create(
model="hamsa", file=open("call.mp3", "rb"),
response_format="verbose_json", stream=True)
preview = ""
for event in stream:
if event.type == "transcript.text.delta":
preview += event.delta # interim — display as it arrives
elif event.type == "transcript.text.segment":
print(event.speaker, event.start, event.text)
elif event.type == "transcript.text.done":
final = event.text # authoritative — use this one
Request response_format=verbose_json and every segment carries a speaker label (SPEAKER_00, SPEAKER_01, …) alongside start/end/text — full multi-speaker diarization in one synchronous call. The number of speakers is detected automatically; no hints or configuration required.
from openai import OpenAI
client = OpenAI(api_key="YOUR_HAMSA_KEY", base_url="http://sa-gateway.tryhamsa.com/v1")
# transcription
t = client.audio.transcriptions.create(
model="whisper-1", language="ar", file=open("clip.wav", "rb"))
print(t.text)
# speech (voice = your Hamsa speaker id)
with client.audio.speech.with_streaming_response.create(
model="tts-1", voice="layla", input="مرحبا بالعالم") as r:
r.stream_to_file("out.wav")
curl http://sa-gateway.tryhamsa.com/v1/audio/transcriptions \
-H "Authorization: Bearer $HAMSA_KEY" \
-F file=@call.wav -F model=hamsa -F language=ar \
-F response_format=verbose_json
# -> { "text": "...", "language": "ar",
# "segments": [
# { "speaker": "SPEAKER_01", "start": 0.7, "end": 12.0, "text": "..." },
# { "speaker": "SPEAKER_00", "start": 15.8, "end": 17.6, "text": "..." }
# ] }
curl http://sa-gateway.tryhamsa.com/v1/audio/speech \
-H "Authorization: Bearer $HAMSA_KEY" \
-H "content-type: application/json" \
-d '{"model":"tts-1","voice":"layla","input":"مرحبا بالعالم"}' \
--output out.wav
Transcription runs at roughly 17x realtime, and that rate holds steady from short clips to the largest accepted upload. To estimate any file: processing seconds ≈ audio seconds / 17.
| audio | upload | full response | first text with stream=true |
|---|---|---|---|
| 1 min | 0.6 MB | ~6 s | ~2 s |
| 36 min | 17 MB | ~127 s | ~27 s |
| 53 min | 24 MB | ~188 s | ~40 s |
The 25 MB cap works out to roughly 50 minutes of typical compressed speech. One transcription runs at a time with a second admitted and queued behind it; a third concurrent request receives 429 with Retry-After rather than waiting indefinitely. Retry on 429, or use the asynchronous job API below, which has no such limit.
For recordings beyond a couple of minutes, prefer stream=true: the total time is the same, but text begins arriving after about a fifth of the wait and a keep-alive is sent every 5 seconds, so long requests do not sit idle against a client timeout.
| synchronous /v1/audio/transcriptions | asynchronous /v1/transcriptions | |
|---|---|---|
| you get | the result on the same connection | a job_id to poll |
| starts | immediately, on a warm GPU | after ~1–2 min while a GPU is provisioned for your job |
| concurrency | 1 running, 1 queued, then 429 | unlimited, each job gets its own GPU |
| best for | anything interactive, and files under ~15 min | long recordings, bulk backlogs, many files at once |
Asynchronous jobs run on a GPU created for that job and released afterwards, which is why they carry a fixed start-up cost but no concurrency limit. Submit a hundred at once if you need to. Results are stored and returned by GET /v1/transcriptions/{job_id}, in the same shape as verbose_json.
| status | meaning |
|---|---|
400 | unsupported response_format, or stream=true combined with srt/vtt |
401 | missing, invalid, or revoked API key |
413 | upload exceeds the 25 MB limit — compress, split into ≤25 MB chunks (avoid splitting mid-sentence), or use the async POST /v1/transcriptions job API for long recordings |
422 | invalid request body (missing required field) |
429 | transcription capacity reached — retry after the Retry-After interval |
503 | transcription temporarily unavailable — retryable, see Retry-After |
4xx / 5xx | error from the model — body is passed through |
Prefer to click around? The interactive docs let you try every endpoint in the browser.