https://api.echonova.aiDevelopers
Code samples
Two complete programs per language, an asynchronous Text to Speech job from request to saved audio file and an asynchronous Speech to Text job from upload to transcript, plus the response envelope they all read.
Each program on this page runs from top to bottom with one edit: your API key. The first one turns a sentence into a saved audio file through an asynchronous Text to Speech job. The second uploads an audio file, waits for the asynchronous Speech to Text job, and prints the transcript. The quickstarts explain each call one at a time; this page puts them together. A short third program cleans a recording.
What you need
- An API key with Access to all three services, or with no service restriction. See Get an API key.
- The base URL. The callout at the top of this page shows the host these samples call. See Base URL.
- Credits on the workspace. Each job is charged before it runs. See Credits.
- cURL with
jq, Python 3 withrequests, or Node 18 or later. - For the second program, an audio file named
audio.mp3of up to 10 minutes and 150 MB. For WAV or OGG, changeCONTENT_TYPEtoaudio/wavoraudio/ogg.
Steps
Step 1: Read the envelope once
Every response, success or error, is one JSON object with the same four
fields. A successful call returns HTTP 200, including POST calls, and
the result is in data:
{
"success": true,
"statusCode": 200,
"errorMessage": null,
"data": {}
}On an error, success is false, data is null, statusCode matches
the HTTP status and errorMessage holds one string:
{
"data": null,
"statusCode": 402,
"success": false,
"errorMessage": "Insufficient subscription balance"
}Each program below wraps its calls in one helper that checks success and
returns data, so the rest of the code reads the result and nothing else.
The programs stop on the first error.
Retry requests safely shows the
version that backs off and retries.
Step 2: Text to Speech, from request to audio file
The program lists the voices and takes the first one whose language is
ar, starts an asynchronous job, polls it every 3 seconds until it is
completed or failed, then downloads audioUrl to speech.wav. The
link is signed, so the download sends no Authorization header.
set -eo pipefail
BASE_URL="https://api.echonova.ai"
AUTH="Authorization: Bearer YOUR_API_KEY"
TEXT="مرحبا بكم في إيكونوڤا"
# api METHOD PATH [curl options]: prints .data, or exits on an error envelope
api() {
local method=$1 path=$2 body
shift 2
body=$(curl -s -X "$method" "$BASE_URL$path" -H "$AUTH" "$@")
if [ "$(echo "$body" | jq -r '.success')" != "true" ]; then
echo "error $(echo "$body" | jq -r '.statusCode'): $(echo "$body" | jq -r '.errorMessage')" >&2
exit 1
fi
echo "$body" | jq '.data'
}
# 1. Pick an Arabic voice
VOICE_ID=$(api GET /v1/text-to-speech/voices \
| jq -r '[.items[] | select(.language == "ar")][0].id')
# 2. Start the job
JOB_ID=$(api POST /v1/text-to-speech -H "Content-Type: application/json" \
-d "$(jq -n --arg text "$TEXT" --arg voiceId "$VOICE_ID" '{text: $text, voiceId: $voiceId}')" \
| jq -r '.jobId')
echo "job $JOB_ID"
# 3. Poll until it ends
while true; do
JOB=$(api GET "/v1/text-to-speech/jobs/$JOB_ID")
STATUS=$(echo "$JOB" | jq -r '.status')
echo "$STATUS"
if [ "$STATUS" = "completed" ] || [ "$STATUS" = "failed" ]; then break; fi
sleep 3
done
if [ "$STATUS" = "failed" ]; then
echo "failed: $(echo "$JOB" | jq -r '.error')" >&2
exit 1
fi
# 4. Download the audio. The link is signed; send no Authorization header.
curl -s -o speech.wav "$(echo "$JOB" | jq -r '.audioUrl')"
echo "saved speech.wav"
echo "$JOB" | jq '{durationSeconds, creditsConsumed}'import time
import requests
BASE_URL = "https://api.echonova.ai"
HEADERS = {"Authorization": "Bearer YOUR_API_KEY"}
TEXT = "مرحبا بكم في إيكونوڤا"
def call(method, path, **kwargs):
# Every response uses the same envelope: raise when success is false, return data.
response = requests.request(
method, f"{BASE_URL}{path}", headers=HEADERS, timeout=60, **kwargs
)
body = response.json()
if not body["success"]:
raise RuntimeError(f'{body["statusCode"]}: {body["errorMessage"]}')
return body["data"]
# 1. Pick an Arabic voice
voices = call("GET", "/v1/text-to-speech/voices")["items"]
voice_id = next(voice["id"] for voice in voices if voice["language"] == "ar")
# 2. Start the job
job_id = call("POST", "/v1/text-to-speech", json={"text": TEXT, "voiceId": voice_id})["jobId"]
print("job", job_id)
# 3. Poll until it ends
while True:
job = call("GET", f"/v1/text-to-speech/jobs/{job_id}")
print(job["status"])
if job["status"] in ("completed", "failed"):
break
time.sleep(3)
if job["status"] == "failed":
raise SystemExit(f'failed: {job.get("error")}')
# 4. Download the audio. The link is signed; send no Authorization header.
audio = requests.get(job["audioUrl"], timeout=60)
audio.raise_for_status()
with open("speech.wav", "wb") as f:
f.write(audio.content)
print("saved speech.wav")
print(job["durationSeconds"], "seconds,", job["creditsConsumed"], "credits")import { writeFile } from 'node:fs/promises';
const BASE_URL = 'https://api.echonova.ai';
const HEADERS = { Authorization: 'Bearer YOUR_API_KEY' };
const TEXT = 'مرحبا بكم في إيكونوڤا';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Every response uses the same envelope: throw when success is false, return data.
async function call(path, init = {}) {
const response = await fetch(`${BASE_URL}${path}`, {
...init,
headers: { ...HEADERS, ...(init.headers ?? {}) },
});
const body = await response.json();
if (!body.success) throw new Error(`${body.statusCode}: ${body.errorMessage}`);
return body.data;
}
// 1. Pick an Arabic voice
const { items: voices } = await call('/v1/text-to-speech/voices');
const voiceId = voices.find((voice) => voice.language === 'ar').id;
// 2. Start the job
const { jobId } = await call('/v1/text-to-speech', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: TEXT, voiceId }),
});
console.log('job', jobId);
// 3. Poll until it ends
let job;
for (;;) {
job = await call(`/v1/text-to-speech/jobs/${jobId}`);
console.log(job.status);
if (job.status === 'completed' || job.status === 'failed') break;
await sleep(3000);
}
if (job.status === 'failed') throw new Error(`failed: ${job.error}`);
// 4. Download the audio. The link is signed; send no Authorization header.
const audio = await fetch(job.audioUrl);
if (!audio.ok) throw new Error(`download failed: ${audio.status}`);
await writeFile('speech.wav', Buffer.from(await audio.arrayBuffer()));
console.log('saved speech.wav');
console.log(job.durationSeconds, 'seconds,', job.creditsConsumed, 'credits');The POST returns { "jobId", "status" } with status set to
processing. The polled job object names its id id and adds text,
voiceId, createdAt and creditsConsumed; audioUrl and
durationSeconds appear when the job is completed, and error when it
is failed. The file is WAV. See
Text to Speech quickstart for each call
on its own and
Text to Speech voices and options
for speed, cfg, numSteps and tashkeel.
Step 3: Speech to Text, from upload to transcript
The program sends audio.mp3 as the file part of a multipart/form-data
request, polls the job every 5 seconds, and prints transcription and
durationSeconds when it is completed. The Content-Type of the file
part must start with audio/; each sample sets it explicitly.
set -eo pipefail
BASE_URL="https://api.echonova.ai"
AUTH="Authorization: Bearer YOUR_API_KEY"
AUDIO_FILE="audio.mp3"
CONTENT_TYPE="audio/mpeg"
# api METHOD PATH [curl options]: prints .data, or exits on an error envelope
api() {
local method=$1 path=$2 body
shift 2
body=$(curl -s -X "$method" "$BASE_URL$path" -H "$AUTH" "$@")
if [ "$(echo "$body" | jq -r '.success')" != "true" ]; then
echo "error $(echo "$body" | jq -r '.statusCode'): $(echo "$body" | jq -r '.errorMessage')" >&2
exit 1
fi
echo "$body" | jq '.data'
}
# 1. Upload the file and start the job
JOB_ID=$(api POST /v1/speech-to-text -F "file=@$AUDIO_FILE;type=$CONTENT_TYPE" | jq -r '.jobId')
echo "job $JOB_ID"
# 2. Poll until it ends
while true; do
JOB=$(api GET "/v1/speech-to-text/jobs/$JOB_ID")
STATUS=$(echo "$JOB" | jq -r '.status')
echo "$STATUS"
if [ "$STATUS" = "completed" ] || [ "$STATUS" = "failed" ]; then break; fi
sleep 5
done
if [ "$STATUS" = "failed" ]; then
echo "failed: $(echo "$JOB" | jq -r '.error')" >&2
exit 1
fi
# 3. Read the transcript
echo "$JOB" | jq -r '.transcription'
echo "$JOB" | jq '{durationSeconds, creditsConsumed}'import time
import requests
BASE_URL = "https://api.echonova.ai"
HEADERS = {"Authorization": "Bearer YOUR_API_KEY"}
AUDIO_FILE = "audio.mp3"
CONTENT_TYPE = "audio/mpeg"
def call(method, path, **kwargs):
# Every response uses the same envelope: raise when success is false, return data.
response = requests.request(
method, f"{BASE_URL}{path}", headers=HEADERS, timeout=300, **kwargs
)
body = response.json()
if not body["success"]:
raise RuntimeError(f'{body["statusCode"]}: {body["errorMessage"]}')
return body["data"]
# 1. Upload the file and start the job
with open(AUDIO_FILE, "rb") as audio:
job_id = call(
"POST",
"/v1/speech-to-text",
files={"file": (AUDIO_FILE, audio, CONTENT_TYPE)},
)["jobId"]
print("job", job_id)
# 2. Poll until it ends
while True:
job = call("GET", f"/v1/speech-to-text/jobs/{job_id}")
print(job["status"])
if job["status"] in ("completed", "failed"):
break
time.sleep(5)
if job["status"] == "failed":
raise SystemExit(f'failed: {job.get("error")}')
# 3. Read the transcript
print(job["transcription"])
print(job["durationSeconds"], "seconds,", job["creditsConsumed"], "credits")import { readFile } from 'node:fs/promises';
const BASE_URL = 'https://api.echonova.ai';
const HEADERS = { Authorization: 'Bearer YOUR_API_KEY' };
const AUDIO_FILE = 'audio.mp3';
const CONTENT_TYPE = 'audio/mpeg';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Every response uses the same envelope: throw when success is false, return data.
async function call(path, init = {}) {
const response = await fetch(`${BASE_URL}${path}`, {
...init,
headers: { ...HEADERS, ...(init.headers ?? {}) },
});
const body = await response.json();
if (!body.success) throw new Error(`${body.statusCode}: ${body.errorMessage}`);
return body.data;
}
// 1. Upload the file and start the job
const formData = new FormData();
formData.append('file', new Blob([await readFile(AUDIO_FILE)], { type: CONTENT_TYPE }), AUDIO_FILE);
const { jobId } = await call('/v1/speech-to-text', { method: 'POST', body: formData });
console.log('job', jobId);
// 2. Poll until it ends
let job;
for (;;) {
job = await call(`/v1/speech-to-text/jobs/${jobId}`);
console.log(job.status);
if (job.status === 'completed' || job.status === 'failed') break;
await sleep(5000);
}
if (job.status === 'failed') throw new Error(`failed: ${job.error}`);
// 3. Read the transcript
console.log(job.transcription);
console.log(job.durationSeconds, 'seconds,', job.creditsConsumed, 'credits');While the job is processing, durationSeconds holds an estimate read
from the file and transcription is absent. When it is completed, both
hold their final values. The job also carries language and
numbersAsDigits, and, when it was made with identify_speakers=true and
has speaker data, timed segments and speakers; without them those two
are empty lists. To get subtitles, download the job from
GET /v1/speech-to-text/jobs/{jobId}/export?format=srt. See
Speech to Text quickstart for each call
on its own and
Speech to Text formats and output
for the accepted inputs.
Step 4: Sound Cleaning, upload and download
Sound Cleaning is synchronous, so there is no polling. The program uploads
noisy.wav (10 minutes or less, 150 MB or less), reads outputUrl from the
response and saves the cleaned WAV as cleaned.wav. The Content-Type of
the file part must match the extension.
set -eo pipefail
BASE_URL="https://api.echonova.ai"
AUTH="Authorization: Bearer YOUR_API_KEY"
RESPONSE=$(curl -sS -X POST "$BASE_URL/v1/sound-cleaning" -H "$AUTH" -F "file=@noisy.wav;type=audio/wav")
if [ "$(echo "$RESPONSE" | jq -r '.success')" != "true" ]; then
echo "$RESPONSE" | jq -r '"\(.statusCode) \(.errorMessage)"' >&2
exit 1
fi
curl -sS -o cleaned.wav "$(echo "$RESPONSE" | jq -r '.data.outputUrl')"
echo "$RESPONSE" | jq -r '.data.creditsConsumed'import requests
BASE_URL = "https://api.echonova.ai"
HEADERS = {"Authorization": "Bearer YOUR_API_KEY"}
with open("noisy.wav", "rb") as audio:
body = requests.post(
f"{BASE_URL}/v1/sound-cleaning",
headers=HEADERS,
files={"file": ("noisy.wav", audio, "audio/wav")},
).json()
if not body["success"]:
raise SystemExit(f'{body["statusCode"]} {body["errorMessage"]}')
job = body["data"]
with open("cleaned.wav", "wb") as out:
out.write(requests.get(job["outputUrl"]).content)
print(job["creditsConsumed"], "credits")import { readFile, writeFile } from 'node:fs/promises';
const BASE_URL = 'https://api.echonova.ai';
const formData = new FormData();
formData.append('file', new Blob([await readFile('noisy.wav')], { type: 'audio/wav' }), 'noisy.wav');
const body = await fetch(`${BASE_URL}/v1/sound-cleaning`, {
method: 'POST',
headers: { Authorization: 'Bearer YOUR_API_KEY' },
body: formData,
}).then((r) => r.json());
if (!body.success) throw new Error(`${body.statusCode} ${body.errorMessage}`);
const audio = await fetch(body.data.outputUrl).then((r) => r.arrayBuffer());
await writeFile('cleaned.wav', Buffer.from(audio));
console.log(body.data.creditsConsumed, 'credits');See Sound Cleaning quickstart for the tuning fields and the limits.
What happens next
- Jobs are listed under your key, one list per service:
GET /v1/text-to-speech/jobs,GET /v1/speech-to-text/jobsandGET /v1/sound-cleaning/jobs, newest first. Each carries thecreditsConsumedit was charged. speech.wavis on disk. TheaudioUrlit came from expires; read the job again for a fresh link if you need the file later.- The transcript was printed to standard output. Store it yourself; the API keeps it on the job, and only there.
- If either job ended
failed, itserrorwas printed and its credits were refunded. Run the program again.
Limits
| Limit | Value | Applies to |
|---|---|---|
text length |
1 to 500 characters | POST /v1/text-to-speech |
| Audio duration | 10 minutes | POST /v1/speech-to-text |
| File size | 150 MB | POST /v1/speech-to-text |
Content-Type of the file part |
Must start with audio/ |
POST /v1/speech-to-text |
Time in processing |
10 minutes, then marked failed within 15 minutes of the POST |
Both jobs |
| Requests | 100 per 60 seconds per key per endpoint | Every call, including polls |
Troubleshooting
The program prints a status code and a message and stops
The helper raised on an error envelope. The number is statusCode and the
text is errorMessage. 401 Unauthorized means the key: check that
YOUR_API_KEY was replaced and that the key is enabled.
402 Insufficient subscription balance means the workspace cannot cover
the job; nothing ran. 403 with does not have access to means the key
restricts services. See Authentication.
The program prints failed with an error message
The job ended failed. The message names the reason; it is
AI server generation failed or AI server transcription failed when no
reason was given, and Job timed out — AI server did not respond when the
job stayed processing for more than 10 minutes. The credits were
refunded. Run the program again.
The voice lookup finds nothing
No active voice has language set to ar, so next() in Python raises
StopIteration, voices.find in Node returns undefined, and the shell
VOICE_ID is null. List the voices and pick an id by hand; see
List available voices.
400 Unsupported file type: application/octet-stream. Please upload an audio file.
The file part was sent without an audio/ content type. Keep the
CONTENT_TYPE value in the sample; do not remove the type= from the cURL
-F argument or the type from the Node Blob.
The audio download fails
The signed link has expired. Call GET /v1/text-to-speech/jobs/{jobId}
again; every call returns a fresh audioUrl. Download it at once.
429 ThrottlerException: Too Many Requests
Several copies of the program share one key and their polls add up to more
than 100 requests per 60 seconds on the job endpoint. Wait for Retry-After
seconds, then use a longer interval or one key per process. See
Rate limits.
FAQ
Where are the synchronous versions?
On the quickstarts. POST /v1/text-to-speech/sync returns audioUrl in
the same response and POST /v1/speech-to-text/sync returns
transcription, with no polling. See
Text to Speech quickstart and
Speech to Text quickstart, and
Synchronous and asynchronous calls to
choose between them.
Why check success instead of the HTTP status?
Both work. Every error response has success set to false and a
statusCode equal to the HTTP status, so a client can key off either. The
envelope check is one line and gives you errorMessage in the same step.
Can I skip the voices call?
Yes. Set voiceId to an id you already know. The voice must be active;
an unknown or inactive id returns 404 Voice not found.
Can I receive the result without polling?
Yes. Register a webhook for job.completed and job.failed. The
job.completed event carries audioUrl or transcription, so no further
call is needed. See Configure a webhook
and Webhook events.
Do the programs retry?
No. They stop on the first error. See
Retry requests safely for the
polling loop that honours Retry-After and backs off on 5xx.