voices-az
The official Python client for the Voices.az API: text-to-speech, speech-to-text and video dubbing, with Azerbaijani first.
- One call to synthesize, transcribe or dub - the client waits for the result for you.
- Sync and
asyncioclients with the same methods. - Typed results (dataclasses), typed errors, safe automatic retries.
- Large files are streamed from disk, never loaded into memory.
- Python 3.9+, one dependency (
httpx).
Documentation of the HTTP API: https://voices.az/docs
Also in: Azərbaycan dili · Русский
Install
pip install voices-az
Authentication
Every request is made with your account's API key. Find it in the panel at app.voices.az → Profile → API key: create the key there (or rotate / revoke it). A new key is shown only once - store it right away.
Pass the key explicitly, or set the VOICES_API_KEY environment variable:
export VOICES_API_KEY=vz_live_...
import voices_az
client = voices_az.Client() # reads VOICES_API_KEY
client = voices_az.Client(api_key="vz_live_...") # or explicitly
Keep the key on the server side: anyone who has it can spend your balance. The client
always sends it in the X-API-KEY header (never in a URL), and never sends it to the
file-storage links it downloads from.
Text-to-speech
import voices_az
with voices_az.Client() as client:
result = client.tts.synthesize(
"Salam! Bu mətn Voices.az ilə səsləndirilib.",
language="az", # a language code or id
voice=41, # a voice id, see client.voices.list()
speed=1, # 1, 1.5, 2, 2.5 or 3
)
print(result.preview_url) # a listening copy, free
print(result.duration) # seconds
# Charges the balance, then saves the MP3:
client.tts.download(result.project_id, "salam.mp3")
synthesize() queues the text and polls until the audio is ready (wait=False returns
right away; call client.tts.wait(request_id) later). The text may contain inline tags:
[PAUSE=1.5], [LAUGH], [SIGH], [UHM], [SHH].
Multi-block projects, one voice per block if you like:
first = client.tts.add_block("Salam!", voice=41)
client.tts.add_block("Xoş gəlmisiniz.", project_id=first.project_id, voice=58, speed=1.5)
client.tts.generate(first.project_id, wait=True)
Also update_block() (text, speed, voice), delete_block(), duplicate_block() and
sort_blocks().
Voices and languages
for language in client.languages.list():
print(language.id, language.code, language.title)
for voice in client.voices.iter(language="az", gender="female"):
print(voice.id, voice.full_name)
Speech-to-text
transcript = client.stt.recognize("interview.mp3", language="az", speakers=2)
print(transcript.text)
for segment in transcript.segments:
print(f"{segment.start:.2f}-{segment.end:.2f} {segment.text}")
# Subtitles or plain text: txt, srt, vtt, sbv, dfxp, csv
client.stt.download(transcript.project_id, "interview.srt", format="srt")
file= takes a path, bytes or a binary file object; use youtube_url= for a YouTube
video. Options: speakers (1-10), remove_background=True (strip music and noise first),
convert_numbers=True (digits instead of words).
To see the price before paying, upload first and start later:
upload = client.stt.upload("interview.mp3")
print(upload.duration, upload.price)
client.stt.generate(upload.project_id, language="az") # charges the balance
transcript = client.stt.wait(upload.project_id)
Dubbing
draft = client.dubbing.create(
youtube_url="https://www.youtube.com/watch?v=...", # or file="video.mp4"
target_language="az",
background_mode="duck", # or "separate": keep music and effects at full level
)
print(draft.project_id, draft.price) # the quote; nothing is charged yet
preview = client.dubbing.preview(draft.project_id) # the first seconds
print(client.dubbing.wait(preview.request_id).audio_url)
run = client.dubbing.generate(draft.project_id) # charges the balance
client.dubbing.wait(run.request_id)
client.dubbing.download(draft.project_id, "dubbed.mp4", audio_path="dubbed.mp3")
download() asks for the clean (watermark-free) files and, when the video still has to be
assembled, waits for it. A project made from an audio file (mp3, wav) is exported as the
dubbed mp3 (export().audio_only is True). Choose a voice per speaker with
generate(..., voice_map={"SPEAKER_00": 41}) - the speaker keys are in
client.dubbing.get_project(id).body["speakers"]. Edit single lines with add_chunk(),
update_chunk(), delete_chunk(), regenerate_chunk(), then render().
Async
AsyncClient has exactly the same methods, awaitable:
import asyncio
import voices_az
async def main() -> None:
async with voices_az.AsyncClient() as client:
result = await client.tts.synthesize("Salam!", language="az")
await client.tts.download(result.project_id, "salam.mp3")
async for voice in client.voices.iter(language="az"):
print(voice.full_name)
asyncio.run(main())
What costs money
The API bills at the same per-minute rates as the panel (client.services.list() shows
them) and takes the money from the same balance (client.account.balance()).
| Call | Charges the balance |
|---|---|
tts.synthesize(), tts.generate() |
No |
tts.export(), tts.download() |
Yes, on the first export of a project; exporting it again is free |
stt.recognize(), stt.generate() |
Yes, when recognition starts |
stt.upload(), stt.export(), stt.download() |
No |
dubbing.create() |
No |
dubbing.preview(), dubbing.render() |
The first few per project are free, then each one is charged (price on the result) |
dubbing.generate() |
Yes, the whole dub (plus the auto-emotion surcharge when it is on) |
dubbing.regenerate_chunk() |
Yes (price on the result) |
dubbing.export(), dubbing.download() |
No for dubs paid on generate() |
When the balance is too low the call raises InsufficientBalanceError and nothing is
charged or queued; missing tells how much to top up.
Errors
Every exception derives from voices_az.VoicesError:
| Exception | When |
|---|---|
AuthenticationError |
401 - missing, unknown or revoked API key |
PermissionDeniedError |
403 - the key may not call this endpoint (type == "api_key_scope") |
NotFoundError |
404 - no such project (or it belongs to another account) |
ConflictError, IdempotencyInProgressError |
409 |
ProjectExpiredError |
410 - the project's files were deleted after the retention window (project_id, expired_at) |
PayloadTooLargeError |
413 - the file is over the upload limit |
ValidationError |
422 - invalid input; errors holds {"field": ["message"]} |
InsufficientBalanceError |
422 - price, balance, missing, currency, project_id (a ValidationError) |
ContentPolicyError |
422 - the text or result was refused by the content policy |
RateLimitError |
429 - retry_after, limit, remaining |
ServerError |
5xx |
APIConnectionError, APITimeoutError |
the API could not be reached / the request timed out |
JobFailedError |
a synthesis, recognition or dubbing run failed |
voices_az.TimeoutError |
a wait helper gave up; the run continues on the server |
HTTP errors carry status_code, type (the API's stable error.type slug), message,
data and the raw body. Messages are localised; pick their language with
Client(language="az" | "en" | "ru" | "tr"), and branch on the class or type, not on the
message.
try:
client.tts.download(project_id, "out.mp3")
except voices_az.InsufficientBalanceError as exc:
print(f"Top up {exc.missing} {exc.currency} at https://app.voices.az")
except voices_az.ProjectExpiredError:
print("The files of this project are no longer stored.")
Retries and idempotency
Failed requests are repeated (max_retries=2 by default, exponential backoff) only when
that cannot charge twice:
- reads (
GET) on network errors and on502/503/504; - any request when the connection could not be opened at all, or on
429(afterRetry-After; a wait longer than a minute raisesRateLimitErrorinstead); - the calls that queue paid work (
synthesize,recognize,generate,preview,render, ...): the client sends anIdempotency-Keywith them, so the API replays the first answer instead of running and charging again. Pass your own key withidempotency_key=...to make a retry safe across processes too.
export is never repeated automatically: it can charge and the API does not accept an
idempotency key there.
Polling, limits and retention
- The
waithelpers poll every 2 seconds (poll_interval, never less) and give up afterwait_timeoutseconds (Nonewaits indefinitely).on_progressreceives every status. - The API allows 60 requests a minute and 5000 a day per key.
- Files are kept for 6 months after the last activity on a project (30 days for a dubbing
project that was only previewed). Afterwards the project still exists but its file calls
raise
ProjectExpiredError- download what you need in time.
Configuration
client = voices_az.Client(
api_key="vz_live_...", # default: VOICES_API_KEY
base_url="https://voices.az/api", # default (or VOICES_BASE_URL)
timeout=120, # seconds, or an httpx.Timeout
max_retries=2,
language="en", # language of error messages
poll_interval=2,
http_client=None, # your own httpx.Client (proxies, TLS, ...)
)
Every result keeps the decoded JSON in .raw, and client.request("GET", "/services")
calls any endpoint directly.
Examples
Runnable scripts in examples/: tts_to_file.py, transcribe_file.py,
dub_youtube.py. Each reads VOICES_API_KEY.
Development
python -m venv .venv && . .venv/bin/activate
pip install -e ".[dev]"
pytest -q
ruff check .
mypy
The tests never touch the network.
License
MIT - see LICENSE.
Metadata
Release files for voices-az 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| voices_az-0.2.0.tar.gz | 95.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| voices_az-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 143.0 kB
Release files / voices_az-0.2.0.tar.gz
| Download URL | voices_az-0.2.0.tar.gz |
|---|---|
| Size | 95.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
52cf37ce1b4086af4d06a8da534dfb5103f6c3bd6a0bad2d59e522ecc77351d7
|
|
BLAKE2b-256 checksum How to use checksums |
d94704b86190e5dcc616f80d31c85155d718cfb25178890d921c4db4888e067b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 15, 2026.
Transparency logRelease files / voices_az-0.2.0-py3-none-any.whl
| Download URL | voices_az-0.2.0-py3-none-any.whl |
|---|---|
| Size | 47.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
08f37ec2a7bf2f983361199986266c9d0395f8bf757934a325166d3d33dd7f77
|
|
BLAKE2b-256 checksum How to use checksums |
04cc7677af469f8b585ad2a0d6c1a4c07024b9f2f47b1955da1db835467aef7d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 15, 2026.
Transparency log