voices-az 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. voices_az-0.1.0/.gitignore +16 -0
  2. voices_az-0.1.0/CHANGELOG.md +13 -0
  3. voices_az-0.1.0/LICENSE +21 -0
  4. voices_az-0.1.0/PKG-INFO +303 -0
  5. voices_az-0.1.0/README.md +266 -0
  6. voices_az-0.1.0/docs/README.az.md +91 -0
  7. voices_az-0.1.0/docs/README.ru.md +91 -0
  8. voices_az-0.1.0/examples/dub_youtube.py +61 -0
  9. voices_az-0.1.0/examples/transcribe_file.py +50 -0
  10. voices_az-0.1.0/examples/tts_to_file.py +59 -0
  11. voices_az-0.1.0/pyproject.toml +118 -0
  12. voices_az-0.1.0/src/voices_az/__init__.py +63 -0
  13. voices_az-0.1.0/src/voices_az/_base.py +421 -0
  14. voices_az-0.1.0/src/voices_az/_client.py +453 -0
  15. voices_az-0.1.0/src/voices_az/_version.py +1 -0
  16. voices_az-0.1.0/src/voices_az/exceptions.py +288 -0
  17. voices_az-0.1.0/src/voices_az/py.typed +0 -0
  18. voices_az-0.1.0/src/voices_az/resources/__init__.py +24 -0
  19. voices_az-0.1.0/src/voices_az/resources/_common.py +294 -0
  20. voices_az-0.1.0/src/voices_az/resources/account.py +47 -0
  21. voices_az-0.1.0/src/voices_az/resources/catalogue.py +155 -0
  22. voices_az-0.1.0/src/voices_az/resources/dubbing.py +692 -0
  23. voices_az-0.1.0/src/voices_az/resources/stt.py +499 -0
  24. voices_az-0.1.0/src/voices_az/resources/tts.py +412 -0
  25. voices_az-0.1.0/src/voices_az/types.py +727 -0
  26. voices_az-0.1.0/tests/__init__.py +0 -0
  27. voices_az-0.1.0/tests/conftest.py +136 -0
  28. voices_az-0.1.0/tests/fixtures/openapi.json +3553 -0
  29. voices_az-0.1.0/tests/test_async.py +184 -0
  30. voices_az-0.1.0/tests/test_catalogue.py +109 -0
  31. voices_az-0.1.0/tests/test_dubbing.py +331 -0
  32. voices_az-0.1.0/tests/test_errors.py +315 -0
  33. voices_az-0.1.0/tests/test_examples.py +95 -0
  34. voices_az-0.1.0/tests/test_retries.py +212 -0
  35. voices_az-0.1.0/tests/test_spec_coverage.py +182 -0
  36. voices_az-0.1.0/tests/test_stt.py +249 -0
  37. voices_az-0.1.0/tests/test_tts.py +311 -0
@@ -0,0 +1,16 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ .coverage
8
+ .coverage.*
9
+ coverage.xml
10
+ htmlcov/
11
+ build/
12
+ dist/
13
+ .mypy_cache/
14
+ .DS_Store
15
+ .idea/
16
+ .vscode/
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-10-14
4
+
5
+ First release.
6
+
7
+ - `Client` and `AsyncClient` covering every operation of the public Voices.az API:
8
+ text-to-speech, speech-to-text, dubbing, voices, languages, services and the account
9
+ balance.
10
+ - Helpers that wait for results: `tts.synthesize()`, `stt.recognize()`,
11
+ `dubbing.wait()`, `dubbing.download()`.
12
+ - Typed results and errors, automatic `Idempotency-Key` on paid calls, safe retries,
13
+ streamed uploads and downloads.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Learning Machine
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,303 @@
1
+ Metadata-Version: 2.5
2
+ Name: voices-az
3
+ Version: 0.1.0
4
+ Summary: Official Python client for the Voices.az API: text-to-speech, speech-to-text and video dubbing.
5
+ Project-URL: Homepage, https://voices.az
6
+ Project-URL: Documentation, https://voices.az/docs
7
+ Project-URL: Repository, https://github.com/Voices-az/voices-python
8
+ Project-URL: Issues, https://github.com/Voices-az/voices-python/issues
9
+ Author-email: Learning Machine <salam@learningmachine.az>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: api-client,azerbaijani,dubbing,speech-to-text,stt,text-to-speech,transcription,tts,voices.az
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
25
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
26
+ Classifier: Typing :: Typed
27
+ Requires-Python: >=3.9
28
+ Requires-Dist: httpx<1,>=0.24
29
+ Provides-Extra: dev
30
+ Requires-Dist: mypy>=1.10; extra == 'dev'
31
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
32
+ Requires-Dist: pytest-cov>=4; extra == 'dev'
33
+ Requires-Dist: pytest>=7; extra == 'dev'
34
+ Requires-Dist: respx>=0.20; extra == 'dev'
35
+ Requires-Dist: ruff>=0.4; extra == 'dev'
36
+ Description-Content-Type: text/markdown
37
+
38
+ # voices-az
39
+
40
+ The official Python client for the [Voices.az](https://voices.az) API: text-to-speech,
41
+ speech-to-text and video dubbing, with Azerbaijani first.
42
+
43
+ - One call to synthesize, transcribe or dub - the client waits for the result for you.
44
+ - Sync and `asyncio` clients with the same methods.
45
+ - Typed results (dataclasses), typed errors, safe automatic retries.
46
+ - Large files are streamed from disk, never loaded into memory.
47
+ - Python 3.9+, one dependency (`httpx`).
48
+
49
+ Documentation of the HTTP API: <https://voices.az/docs>
50
+
51
+ Also in: [Azərbaycan dili](https://github.com/Voices-az/voices-python/blob/main/docs/README.az.md) · [Русский](https://github.com/Voices-az/voices-python/blob/main/docs/README.ru.md)
52
+
53
+ ## Install
54
+
55
+ ```bash
56
+ pip install voices-az
57
+ ```
58
+
59
+ ## Authentication
60
+
61
+ Every request is made with your account's API key. Find it in the panel at
62
+ **[app.voices.az](https://app.voices.az) → Profile → API key**: create the key there
63
+ (or rotate / revoke it). A new key is shown only once - store it right away.
64
+
65
+ Pass the key explicitly, or set the `VOICES_API_KEY` environment variable:
66
+
67
+ ```bash
68
+ export VOICES_API_KEY=vz_live_...
69
+ ```
70
+
71
+ ```python
72
+ import voices_az
73
+
74
+ client = voices_az.Client() # reads VOICES_API_KEY
75
+ client = voices_az.Client(api_key="vz_live_...") # or explicitly
76
+ ```
77
+
78
+ Keep the key on the server side: anyone who has it can spend your balance. The client
79
+ always sends it in the `X-API-KEY` header (never in a URL), and never sends it to the
80
+ file-storage links it downloads from.
81
+
82
+ ## Text-to-speech
83
+
84
+ ```python
85
+ import voices_az
86
+
87
+ with voices_az.Client() as client:
88
+ result = client.tts.synthesize(
89
+ "Salam! Bu mətn Voices.az ilə səsləndirilib.",
90
+ language="az", # a language code or id
91
+ voice=41, # a voice id, see client.voices.list()
92
+ speed=1, # 1, 1.5, 2, 2.5 or 3
93
+ )
94
+ print(result.preview_url) # a listening copy, free
95
+ print(result.duration) # seconds
96
+
97
+ # Charges the balance, then saves the MP3:
98
+ client.tts.download(result.project_id, "salam.mp3")
99
+ ```
100
+
101
+ `synthesize()` queues the text and polls until the audio is ready (`wait=False` returns
102
+ right away; call `client.tts.wait(request_id)` later). The text may contain inline tags:
103
+ `[PAUSE=1.5]`, `[LAUGH]`, `[SIGH]`, `[UHM]`, `[SHH]`.
104
+
105
+ Multi-block projects: `client.tts.add_block()`, `update_block()`, `delete_block()`, then
106
+ `client.tts.generate(project_id, wait=True)`.
107
+
108
+ ### Voices and languages
109
+
110
+ ```python
111
+ for language in client.languages.list():
112
+ print(language.id, language.code, language.title)
113
+
114
+ for voice in client.voices.iter(language="az", gender="female"):
115
+ print(voice.id, voice.full_name)
116
+ ```
117
+
118
+ ## Speech-to-text
119
+
120
+ ```python
121
+ transcript = client.stt.recognize("interview.mp3", language="az", speakers=2)
122
+ print(transcript.text)
123
+ for segment in transcript.segments:
124
+ print(f"{segment.start:.2f}-{segment.end:.2f} {segment.text}")
125
+
126
+ # Subtitles or plain text: txt, srt, vtt, sbv, dfxp, csv
127
+ client.stt.download(transcript.project_id, "interview.srt", format="srt")
128
+ ```
129
+
130
+ `file=` takes a path, `bytes` or a binary file object; use `youtube_url=` for a YouTube
131
+ video. Options: `speakers` (1-10), `remove_background=True` (strip music and noise first),
132
+ `convert_numbers=True` (digits instead of words).
133
+
134
+ To see the price before paying, upload first and start later:
135
+
136
+ ```python
137
+ upload = client.stt.upload("interview.mp3")
138
+ print(upload.duration, upload.price)
139
+ client.stt.generate(upload.project_id, language="az") # charges the balance
140
+ transcript = client.stt.wait(upload.project_id)
141
+ ```
142
+
143
+ ## Dubbing
144
+
145
+ ```python
146
+ draft = client.dubbing.create(
147
+ youtube_url="https://www.youtube.com/watch?v=...", # or file="video.mp4"
148
+ target_language="az",
149
+ background_mode="duck", # or "separate": keep music and effects at full level
150
+ )
151
+ print(draft.project_id, draft.price) # the quote; nothing is charged yet
152
+
153
+ preview = client.dubbing.preview(draft.project_id) # the first seconds
154
+ print(client.dubbing.wait(preview.request_id).audio_url)
155
+
156
+ run = client.dubbing.generate(draft.project_id) # charges the balance
157
+ client.dubbing.wait(run.request_id)
158
+
159
+ client.dubbing.download(draft.project_id, "dubbed.mp4", audio_path="dubbed.mp3")
160
+ ```
161
+
162
+ `download()` asks for the clean (watermark-free) files and, when the video still has to be
163
+ assembled, waits for it. Choose a voice per speaker with
164
+ `generate(..., voice_map={"SPEAKER_00": 41})` - the speaker keys are in
165
+ `client.dubbing.get_project(id).body["speakers"]`. Edit single lines with `add_chunk()`,
166
+ `update_chunk()`, `delete_chunk()`, `regenerate_chunk()`, then `render()`.
167
+
168
+ ## Async
169
+
170
+ `AsyncClient` has exactly the same methods, awaitable:
171
+
172
+ ```python
173
+ import asyncio
174
+ import voices_az
175
+
176
+ async def main() -> None:
177
+ async with voices_az.AsyncClient() as client:
178
+ result = await client.tts.synthesize("Salam!", language="az")
179
+ await client.tts.download(result.project_id, "salam.mp3")
180
+
181
+ async for voice in client.voices.iter(language="az"):
182
+ print(voice.full_name)
183
+
184
+ asyncio.run(main())
185
+ ```
186
+
187
+ ## What costs money
188
+
189
+ The API bills at the same per-minute rates as the panel (`client.services.list()` shows
190
+ them) and takes the money from the same balance (`client.account.balance()`).
191
+
192
+ | Call | Charges the balance |
193
+ | --- | --- |
194
+ | `tts.synthesize()`, `tts.generate()` | No |
195
+ | `tts.export()`, `tts.download()` | **Yes**, on the first export of a project; exporting it again is free |
196
+ | `stt.recognize()`, `stt.generate()` | **Yes**, when recognition starts |
197
+ | `stt.upload()`, `stt.export()`, `stt.download()` | No |
198
+ | `dubbing.create()` | No |
199
+ | `dubbing.preview()`, `dubbing.render()` | The first few per project are free, then each one is charged (`price` on the result) |
200
+ | `dubbing.generate()` | **Yes**, the whole dub (plus the auto-emotion surcharge when it is on) |
201
+ | `dubbing.regenerate_chunk()` | **Yes** (`price` on the result) |
202
+ | `dubbing.export()`, `dubbing.download()` | No for dubs paid on `generate()` |
203
+
204
+ When the balance is too low the call raises `InsufficientBalanceError` and nothing is
205
+ charged or queued; `missing` tells how much to top up.
206
+
207
+ ## Errors
208
+
209
+ Every exception derives from `voices_az.VoicesError`:
210
+
211
+ | Exception | When |
212
+ | --- | --- |
213
+ | `AuthenticationError` | 401 - missing, unknown or revoked API key |
214
+ | `PermissionDeniedError` | 403 - the key may not call this endpoint (`type == "api_key_scope"`) |
215
+ | `NotFoundError` | 404 - no such project (or it belongs to another account) |
216
+ | `ConflictError`, `IdempotencyInProgressError` | 409 |
217
+ | `ProjectExpiredError` | 410 - the project's files were deleted after the retention window (`project_id`, `expired_at`) |
218
+ | `PayloadTooLargeError` | 413 - the file is over the upload limit |
219
+ | `ValidationError` | 422 - invalid input; `errors` holds `{"field": ["message"]}` |
220
+ | `InsufficientBalanceError` | 422 - `price`, `balance`, `missing`, `currency`, `project_id` (a `ValidationError`) |
221
+ | `ContentPolicyError` | 422 - the text or result was refused by the content policy |
222
+ | `RateLimitError` | 429 - `retry_after`, `limit`, `remaining` |
223
+ | `ServerError` | 5xx |
224
+ | `APIConnectionError`, `APITimeoutError` | the API could not be reached / the request timed out |
225
+ | `JobFailedError` | a synthesis, recognition or dubbing run failed |
226
+ | `voices_az.TimeoutError` | a `wait` helper gave up; the run continues on the server |
227
+
228
+ HTTP errors carry `status_code`, `type` (the API's stable `error.type` slug), `message`,
229
+ `data` and the raw `body`. Messages are localised; pick their language with
230
+ `Client(language="az" | "en" | "ru" | "tr")`, and branch on the class or `type`, not on the
231
+ message.
232
+
233
+ ```python
234
+ try:
235
+ client.tts.download(project_id, "out.mp3")
236
+ except voices_az.InsufficientBalanceError as exc:
237
+ print(f"Top up {exc.missing} {exc.currency} at https://app.voices.az")
238
+ except voices_az.ProjectExpiredError:
239
+ print("The files of this project are no longer stored.")
240
+ ```
241
+
242
+ ## Retries and idempotency
243
+
244
+ Failed requests are repeated (`max_retries=2` by default, exponential backoff) only when
245
+ that cannot charge twice:
246
+
247
+ - reads (`GET`) on network errors and on `502`/`503`/`504`;
248
+ - any request when the connection could not be opened at all, or on `429` (after
249
+ `Retry-After`; a wait longer than a minute raises `RateLimitError` instead);
250
+ - the calls that queue paid work (`synthesize`, `recognize`, `generate`, `preview`,
251
+ `render`, ...): the client sends an `Idempotency-Key` with them, so the API replays
252
+ the first answer instead of running and charging again. Pass your own key with
253
+ `idempotency_key=...` to make a retry safe across processes too.
254
+
255
+ `export` is never repeated automatically: it can charge and the API does not accept an
256
+ idempotency key there.
257
+
258
+ ## Polling, limits and retention
259
+
260
+ - The `wait` helpers poll every 2 seconds (`poll_interval`, never less) and give up after
261
+ `wait_timeout` seconds (`None` waits indefinitely). `on_progress` receives every status.
262
+ - The API allows 60 requests a minute and 5000 a day per key.
263
+ - Files are kept for 6 months after the last activity on a project (30 days for a dubbing
264
+ project that was only previewed). Afterwards the project still exists but its file calls
265
+ raise `ProjectExpiredError` - download what you need in time.
266
+
267
+ ## Configuration
268
+
269
+ ```python
270
+ client = voices_az.Client(
271
+ api_key="vz_live_...", # default: VOICES_API_KEY
272
+ base_url="https://voices.az/api", # default (or VOICES_BASE_URL)
273
+ timeout=120, # seconds, or an httpx.Timeout
274
+ max_retries=2,
275
+ language="en", # language of error messages
276
+ poll_interval=2,
277
+ http_client=None, # your own httpx.Client (proxies, TLS, ...)
278
+ )
279
+ ```
280
+
281
+ Every result keeps the decoded JSON in `.raw`, and `client.request("GET", "/services")`
282
+ calls any endpoint directly.
283
+
284
+ ## Examples
285
+
286
+ Runnable scripts in [`examples/`](examples): `tts_to_file.py`, `transcribe_file.py`,
287
+ `dub_youtube.py`. Each reads `VOICES_API_KEY`.
288
+
289
+ ## Development
290
+
291
+ ```bash
292
+ python -m venv .venv && . .venv/bin/activate
293
+ pip install -e ".[dev]"
294
+ pytest -q
295
+ ruff check .
296
+ mypy
297
+ ```
298
+
299
+ The tests never touch the network.
300
+
301
+ ## License
302
+
303
+ MIT - see [LICENSE](LICENSE).
@@ -0,0 +1,266 @@
1
+ # voices-az
2
+
3
+ The official Python client for the [Voices.az](https://voices.az) API: text-to-speech,
4
+ speech-to-text and video dubbing, with Azerbaijani first.
5
+
6
+ - One call to synthesize, transcribe or dub - the client waits for the result for you.
7
+ - Sync and `asyncio` clients with the same methods.
8
+ - Typed results (dataclasses), typed errors, safe automatic retries.
9
+ - Large files are streamed from disk, never loaded into memory.
10
+ - Python 3.9+, one dependency (`httpx`).
11
+
12
+ Documentation of the HTTP API: <https://voices.az/docs>
13
+
14
+ Also in: [Azərbaycan dili](https://github.com/Voices-az/voices-python/blob/main/docs/README.az.md) · [Русский](https://github.com/Voices-az/voices-python/blob/main/docs/README.ru.md)
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install voices-az
20
+ ```
21
+
22
+ ## Authentication
23
+
24
+ Every request is made with your account's API key. Find it in the panel at
25
+ **[app.voices.az](https://app.voices.az) → Profile → API key**: create the key there
26
+ (or rotate / revoke it). A new key is shown only once - store it right away.
27
+
28
+ Pass the key explicitly, or set the `VOICES_API_KEY` environment variable:
29
+
30
+ ```bash
31
+ export VOICES_API_KEY=vz_live_...
32
+ ```
33
+
34
+ ```python
35
+ import voices_az
36
+
37
+ client = voices_az.Client() # reads VOICES_API_KEY
38
+ client = voices_az.Client(api_key="vz_live_...") # or explicitly
39
+ ```
40
+
41
+ Keep the key on the server side: anyone who has it can spend your balance. The client
42
+ always sends it in the `X-API-KEY` header (never in a URL), and never sends it to the
43
+ file-storage links it downloads from.
44
+
45
+ ## Text-to-speech
46
+
47
+ ```python
48
+ import voices_az
49
+
50
+ with voices_az.Client() as client:
51
+ result = client.tts.synthesize(
52
+ "Salam! Bu mətn Voices.az ilə səsləndirilib.",
53
+ language="az", # a language code or id
54
+ voice=41, # a voice id, see client.voices.list()
55
+ speed=1, # 1, 1.5, 2, 2.5 or 3
56
+ )
57
+ print(result.preview_url) # a listening copy, free
58
+ print(result.duration) # seconds
59
+
60
+ # Charges the balance, then saves the MP3:
61
+ client.tts.download(result.project_id, "salam.mp3")
62
+ ```
63
+
64
+ `synthesize()` queues the text and polls until the audio is ready (`wait=False` returns
65
+ right away; call `client.tts.wait(request_id)` later). The text may contain inline tags:
66
+ `[PAUSE=1.5]`, `[LAUGH]`, `[SIGH]`, `[UHM]`, `[SHH]`.
67
+
68
+ Multi-block projects: `client.tts.add_block()`, `update_block()`, `delete_block()`, then
69
+ `client.tts.generate(project_id, wait=True)`.
70
+
71
+ ### Voices and languages
72
+
73
+ ```python
74
+ for language in client.languages.list():
75
+ print(language.id, language.code, language.title)
76
+
77
+ for voice in client.voices.iter(language="az", gender="female"):
78
+ print(voice.id, voice.full_name)
79
+ ```
80
+
81
+ ## Speech-to-text
82
+
83
+ ```python
84
+ transcript = client.stt.recognize("interview.mp3", language="az", speakers=2)
85
+ print(transcript.text)
86
+ for segment in transcript.segments:
87
+ print(f"{segment.start:.2f}-{segment.end:.2f} {segment.text}")
88
+
89
+ # Subtitles or plain text: txt, srt, vtt, sbv, dfxp, csv
90
+ client.stt.download(transcript.project_id, "interview.srt", format="srt")
91
+ ```
92
+
93
+ `file=` takes a path, `bytes` or a binary file object; use `youtube_url=` for a YouTube
94
+ video. Options: `speakers` (1-10), `remove_background=True` (strip music and noise first),
95
+ `convert_numbers=True` (digits instead of words).
96
+
97
+ To see the price before paying, upload first and start later:
98
+
99
+ ```python
100
+ upload = client.stt.upload("interview.mp3")
101
+ print(upload.duration, upload.price)
102
+ client.stt.generate(upload.project_id, language="az") # charges the balance
103
+ transcript = client.stt.wait(upload.project_id)
104
+ ```
105
+
106
+ ## Dubbing
107
+
108
+ ```python
109
+ draft = client.dubbing.create(
110
+ youtube_url="https://www.youtube.com/watch?v=...", # or file="video.mp4"
111
+ target_language="az",
112
+ background_mode="duck", # or "separate": keep music and effects at full level
113
+ )
114
+ print(draft.project_id, draft.price) # the quote; nothing is charged yet
115
+
116
+ preview = client.dubbing.preview(draft.project_id) # the first seconds
117
+ print(client.dubbing.wait(preview.request_id).audio_url)
118
+
119
+ run = client.dubbing.generate(draft.project_id) # charges the balance
120
+ client.dubbing.wait(run.request_id)
121
+
122
+ client.dubbing.download(draft.project_id, "dubbed.mp4", audio_path="dubbed.mp3")
123
+ ```
124
+
125
+ `download()` asks for the clean (watermark-free) files and, when the video still has to be
126
+ assembled, waits for it. Choose a voice per speaker with
127
+ `generate(..., voice_map={"SPEAKER_00": 41})` - the speaker keys are in
128
+ `client.dubbing.get_project(id).body["speakers"]`. Edit single lines with `add_chunk()`,
129
+ `update_chunk()`, `delete_chunk()`, `regenerate_chunk()`, then `render()`.
130
+
131
+ ## Async
132
+
133
+ `AsyncClient` has exactly the same methods, awaitable:
134
+
135
+ ```python
136
+ import asyncio
137
+ import voices_az
138
+
139
+ async def main() -> None:
140
+ async with voices_az.AsyncClient() as client:
141
+ result = await client.tts.synthesize("Salam!", language="az")
142
+ await client.tts.download(result.project_id, "salam.mp3")
143
+
144
+ async for voice in client.voices.iter(language="az"):
145
+ print(voice.full_name)
146
+
147
+ asyncio.run(main())
148
+ ```
149
+
150
+ ## What costs money
151
+
152
+ The API bills at the same per-minute rates as the panel (`client.services.list()` shows
153
+ them) and takes the money from the same balance (`client.account.balance()`).
154
+
155
+ | Call | Charges the balance |
156
+ | --- | --- |
157
+ | `tts.synthesize()`, `tts.generate()` | No |
158
+ | `tts.export()`, `tts.download()` | **Yes**, on the first export of a project; exporting it again is free |
159
+ | `stt.recognize()`, `stt.generate()` | **Yes**, when recognition starts |
160
+ | `stt.upload()`, `stt.export()`, `stt.download()` | No |
161
+ | `dubbing.create()` | No |
162
+ | `dubbing.preview()`, `dubbing.render()` | The first few per project are free, then each one is charged (`price` on the result) |
163
+ | `dubbing.generate()` | **Yes**, the whole dub (plus the auto-emotion surcharge when it is on) |
164
+ | `dubbing.regenerate_chunk()` | **Yes** (`price` on the result) |
165
+ | `dubbing.export()`, `dubbing.download()` | No for dubs paid on `generate()` |
166
+
167
+ When the balance is too low the call raises `InsufficientBalanceError` and nothing is
168
+ charged or queued; `missing` tells how much to top up.
169
+
170
+ ## Errors
171
+
172
+ Every exception derives from `voices_az.VoicesError`:
173
+
174
+ | Exception | When |
175
+ | --- | --- |
176
+ | `AuthenticationError` | 401 - missing, unknown or revoked API key |
177
+ | `PermissionDeniedError` | 403 - the key may not call this endpoint (`type == "api_key_scope"`) |
178
+ | `NotFoundError` | 404 - no such project (or it belongs to another account) |
179
+ | `ConflictError`, `IdempotencyInProgressError` | 409 |
180
+ | `ProjectExpiredError` | 410 - the project's files were deleted after the retention window (`project_id`, `expired_at`) |
181
+ | `PayloadTooLargeError` | 413 - the file is over the upload limit |
182
+ | `ValidationError` | 422 - invalid input; `errors` holds `{"field": ["message"]}` |
183
+ | `InsufficientBalanceError` | 422 - `price`, `balance`, `missing`, `currency`, `project_id` (a `ValidationError`) |
184
+ | `ContentPolicyError` | 422 - the text or result was refused by the content policy |
185
+ | `RateLimitError` | 429 - `retry_after`, `limit`, `remaining` |
186
+ | `ServerError` | 5xx |
187
+ | `APIConnectionError`, `APITimeoutError` | the API could not be reached / the request timed out |
188
+ | `JobFailedError` | a synthesis, recognition or dubbing run failed |
189
+ | `voices_az.TimeoutError` | a `wait` helper gave up; the run continues on the server |
190
+
191
+ HTTP errors carry `status_code`, `type` (the API's stable `error.type` slug), `message`,
192
+ `data` and the raw `body`. Messages are localised; pick their language with
193
+ `Client(language="az" | "en" | "ru" | "tr")`, and branch on the class or `type`, not on the
194
+ message.
195
+
196
+ ```python
197
+ try:
198
+ client.tts.download(project_id, "out.mp3")
199
+ except voices_az.InsufficientBalanceError as exc:
200
+ print(f"Top up {exc.missing} {exc.currency} at https://app.voices.az")
201
+ except voices_az.ProjectExpiredError:
202
+ print("The files of this project are no longer stored.")
203
+ ```
204
+
205
+ ## Retries and idempotency
206
+
207
+ Failed requests are repeated (`max_retries=2` by default, exponential backoff) only when
208
+ that cannot charge twice:
209
+
210
+ - reads (`GET`) on network errors and on `502`/`503`/`504`;
211
+ - any request when the connection could not be opened at all, or on `429` (after
212
+ `Retry-After`; a wait longer than a minute raises `RateLimitError` instead);
213
+ - the calls that queue paid work (`synthesize`, `recognize`, `generate`, `preview`,
214
+ `render`, ...): the client sends an `Idempotency-Key` with them, so the API replays
215
+ the first answer instead of running and charging again. Pass your own key with
216
+ `idempotency_key=...` to make a retry safe across processes too.
217
+
218
+ `export` is never repeated automatically: it can charge and the API does not accept an
219
+ idempotency key there.
220
+
221
+ ## Polling, limits and retention
222
+
223
+ - The `wait` helpers poll every 2 seconds (`poll_interval`, never less) and give up after
224
+ `wait_timeout` seconds (`None` waits indefinitely). `on_progress` receives every status.
225
+ - The API allows 60 requests a minute and 5000 a day per key.
226
+ - Files are kept for 6 months after the last activity on a project (30 days for a dubbing
227
+ project that was only previewed). Afterwards the project still exists but its file calls
228
+ raise `ProjectExpiredError` - download what you need in time.
229
+
230
+ ## Configuration
231
+
232
+ ```python
233
+ client = voices_az.Client(
234
+ api_key="vz_live_...", # default: VOICES_API_KEY
235
+ base_url="https://voices.az/api", # default (or VOICES_BASE_URL)
236
+ timeout=120, # seconds, or an httpx.Timeout
237
+ max_retries=2,
238
+ language="en", # language of error messages
239
+ poll_interval=2,
240
+ http_client=None, # your own httpx.Client (proxies, TLS, ...)
241
+ )
242
+ ```
243
+
244
+ Every result keeps the decoded JSON in `.raw`, and `client.request("GET", "/services")`
245
+ calls any endpoint directly.
246
+
247
+ ## Examples
248
+
249
+ Runnable scripts in [`examples/`](examples): `tts_to_file.py`, `transcribe_file.py`,
250
+ `dub_youtube.py`. Each reads `VOICES_API_KEY`.
251
+
252
+ ## Development
253
+
254
+ ```bash
255
+ python -m venv .venv && . .venv/bin/activate
256
+ pip install -e ".[dev]"
257
+ pytest -q
258
+ ruff check .
259
+ mypy
260
+ ```
261
+
262
+ The tests never touch the network.
263
+
264
+ ## License
265
+
266
+ MIT - see [LICENSE](LICENSE).